diff --git a/src/App.tsx b/src/App.tsx index fc4aced..ea090bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,19 +1,20 @@ import React, { Suspense, lazy, useState, useMemo, useCallback, useEffect, useRef } from 'react' import { ID3Writer } from 'browser-id3-writer' import { embedContainerAudioMetadata, supportsContainerMetadataEmbedding } from './audioMetadata' +import { type LibraryPrimaryAction, loadUiPreferences, saveUiPreferences } from './appPreferences' import Library from './pages/Library' import Header from './components/Header' import Sidebar from './components/Sidebar' import DownloadsOverlay, { type DownloadOverlayItem } from './components/DownloadsOverlay' import DownloadsPage from './pages/DownloadsPage' import { Box, CssBaseline, useMediaQuery } from '@mui/material' -import { ThemeProvider, createTheme } from '@mui/material/styles' +import { ThemeProvider } from '@mui/material/styles' import { ServersProvider, useServers } from './context/ServersContext' import { extractTitleFromTags, type HydrusFileDetails } from './api/hydrusClient' import { addDevLog } from './debugLog' import { clearStoredDownloads, deleteStoredDownload, listStoredDownloads, saveStoredDownload } from './downloadStore' -import { loadUiPreferences, saveUiPreferences } from './appPreferences' import { syncLibraryCache } from './librarySync' +import { createAppTheme } from './themes' import type { MediaSection, Track } from './types' const SettingsPage = lazy(() => import('./pages/SettingsPage')) @@ -369,15 +370,11 @@ function App() { const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true) const [libraryQuery, setLibraryQuery] = useState(initialUiPreferences.libraryQuery) const [libraryDisplayMode, setLibraryDisplayMode] = useState(initialUiPreferences.libraryDisplayMode) + const [appThemeId, setAppThemeId] = useState(initialUiPreferences.appTheme) + const [libraryPrimaryAction, setLibraryPrimaryAction] = useState(initialUiPreferences.libraryPrimaryAction) const [devOverlayEnabled, setDevOverlayEnabled] = useState(initialUiPreferences.devOverlayEnabled) - const theme = useMemo(() => createTheme({ - palette: { - mode: 'dark', - primary: { main: '#1db954' }, - background: { default: '#0f1113', paper: '#151617' } - } - }), []) + const theme = useMemo(() => createAppTheme(appThemeId), [appThemeId]) const mimeCacheRef = useRef>({}) const mimeRequestCacheRef = useRef>>({}) @@ -617,6 +614,14 @@ function App() { setLibraryDisplayMode(mode) saveUiPreferences({ libraryDisplayMode: mode }) }, []) + const handleAppThemeChange = useCallback((themeId: typeof appThemeId) => { + setAppThemeId(themeId) + saveUiPreferences({ appTheme: themeId }) + }, [appThemeId]) + const handleLibraryPrimaryActionChange = useCallback((action: LibraryPrimaryAction) => { + setLibraryPrimaryAction(action) + saveUiPreferences({ libraryPrimaryAction: action }) + }, []) const handleSidebarNavigate = useCallback((id: string) => { if (id === 'settings') setActivePage('settings') else if (id === 'downloads') setActivePage('downloads') @@ -832,8 +837,12 @@ function App() { onClose={closeSettings} devOverlayEnabled={devOverlayEnabled} onDevOverlayEnabledChange={handleDevOverlayEnabledChange} + appTheme={appThemeId} + onAppThemeChange={handleAppThemeChange} libraryDisplayMode={libraryDisplayMode} onLibraryDisplayModeChange={handleLibraryDisplayModeChange} + libraryPrimaryAction={libraryPrimaryAction} + onLibraryPrimaryActionChange={handleLibraryPrimaryActionChange} /> ) @@ -853,6 +862,7 @@ function App() { onPlayNow={playNow} onDownloadTrack={queueDownload} isTrackDownloading={isTrackDownloading} + primaryTapAction={libraryPrimaryAction} query={libraryQuery} onQueryChange={setLibraryQuery} displayModePreference={libraryDisplayMode} diff --git a/src/appPreferences.ts b/src/appPreferences.ts index 47b0c75..3bd3554 100644 --- a/src/appPreferences.ts +++ b/src/appPreferences.ts @@ -1,9 +1,14 @@ import type { MediaSection } from './types' +import type { AppThemeId } from './themes' + +export type LibraryPrimaryAction = 'details' | 'stream' export type UiPreferences = { devOverlayEnabled: boolean libraryQuery: string libraryDisplayMode: 'grid' | 'table' + appTheme: AppThemeId + libraryPrimaryAction: LibraryPrimaryAction librarySortBy: string librarySortDirection: 'asc' | 'desc' librarySectionViews: Partial> @@ -15,6 +20,8 @@ const DEFAULT_UI_PREFERENCES: UiPreferences = { devOverlayEnabled: true, libraryQuery: '', libraryDisplayMode: 'grid', + appTheme: 'hydrus-dark', + libraryPrimaryAction: 'details', librarySortBy: 'artist', librarySortDirection: 'asc', librarySectionViews: {}, diff --git a/src/pages/Library.tsx b/src/pages/Library.tsx index 9561eb3..5243618 100644 --- a/src/pages/Library.tsx +++ b/src/pages/Library.tsx @@ -4,7 +4,7 @@ import { useTheme } from '@mui/material/styles' import DownloadIcon from '@mui/icons-material/Download' import { useServers } from '../context/ServersContext' import { HydrusClient, extractTitleFromTags, type HydrusFileDetails } from '../api/hydrusClient' -import { loadUiPreferences, saveUiPreferences } from '../appPreferences' +import { type LibraryPrimaryAction, loadUiPreferences, saveUiPreferences } from '../appPreferences' import { buildLibraryCacheKey, loadLibraryCache, saveLibraryCache } from '../libraryCache' import { LIBRARY_CACHE_SYNC_EVENT } from '../librarySync' import type { MediaSection, Track } from '../types' @@ -13,12 +13,14 @@ const NO_IMAGE_DATA_URL = "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.o const RESULTS_PAGE_SIZE = 36 const LONG_PRESS_DELAY_MS = 420 const LOCAL_SEARCH_TOKEN_PATTERN = /(?:[^\s"]+:"(?:[^"\\]|\\.)*"|"(?:[^"\\]|\\.)*"|\S+)/g +const SEARCH_NAMESPACE_PATTERN = /^([a-z0-9_+-]+):(.*)$/i type Props = { mediaSection: MediaSection onPlayNow: (track: Track) => void | Promise onDownloadTrack: (track: Track, details?: HydrusFileDetails | null) => void isTrackDownloading: (track: Track) => boolean + primaryTapAction: LibraryPrimaryAction query: string onQueryChange: (value: string) => void displayModePreference: 'grid' | 'table' @@ -213,7 +215,7 @@ function getTrackKindLabel(track: Track, fallbackLabel: string) { return fallbackLabel } -export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTrackDownloading, query, onQueryChange, displayModePreference }: Props) { +export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTrackDownloading, primaryTapAction, query, onQueryChange, displayModePreference }: Props) { const initialUiPreferences = useMemo(() => loadUiPreferences(), []) const initialSectionViews = useMemo(() => initialUiPreferences.librarySectionViews as Partial>, [initialUiPreferences]) const [results, setResults] = useState([]) @@ -348,19 +350,123 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr return normalizeSearchText(normalized) } + function normalizeSearchPattern(value: string) { + let normalized = value.trim() + if (normalized.startsWith('"') && normalized.endsWith('"')) { + normalized = normalized.slice(1, -1) + } + + normalized = normalized + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\') + + return normalizeSearchText(normalized) + } + function matchesSearchValue(value: string | undefined | null, term: string) { if (!term) return true return normalizeSearchText(value).includes(term) } - function matchesNamespacedTag(tags: string[] | undefined, namespace: string, term: string) { + function matchesExactSearchValue(value: string | undefined | null, term: string) { + if (!term) return true + return normalizeSearchText(value) === term + } + + function matchesPatternSearchValue(value: string | undefined | null, pattern: string) { + if (!pattern) return true + + const normalizedValue = normalizeSearchText(value) + if (!pattern.includes('*')) return normalizedValue === pattern + + const escapedPattern = pattern.replace(/[|\\{}()[\]^$+?.]/g, '\\$&').replace(/\*/g, '.*') + return new RegExp(`^${escapedPattern}$`, 'i').test(normalizedValue) + } + + function matchesNamespacedTag(tags: string[] | undefined, namespace: string, term: string, exact = false) { if (!tags || tags.length === 0) return false const prefix = `${namespace.toLowerCase()}:` return tags.some((tag) => { if (typeof tag !== 'string') return false if (!tag.toLowerCase().startsWith(prefix)) return false - return matchesSearchValue(tag.slice(prefix.length), term) + return exact + ? matchesPatternSearchValue(tag.slice(prefix.length), term) + : matchesSearchValue(tag.slice(prefix.length), term) + }) + } + + function splitSearchClauses(rawQuery: string) { + const clauses: string[] = [] + let current = '' + let inQuotes = false + let escapeNext = false + + for (const char of rawQuery) { + if (escapeNext) { + current += char + escapeNext = false + continue + } + + if (char === '\\') { + current += char + escapeNext = true + continue + } + + if (char === '"') { + inQuotes = !inQuotes + current += char + continue + } + + if (char === ',' && !inQuotes) { + const trimmed = current.trim() + if (trimmed) clauses.push(trimmed) + current = '' + continue + } + + current += char + } + + const trimmed = current.trim() + if (trimmed) clauses.push(trimmed) + return clauses + } + + function matchesNamespacedClause(track: Track, namespace: string, rawValue: string) { + const term = normalizeSearchPattern(rawValue) + if (!term) return true + + if (namespace === 'system') return matchesSystemPredicate(track, `${namespace}:${rawValue}`) + if (namespace === 'title') return matchesPatternSearchValue(getTrackDisplayTitle(track), term) + if (namespace === 'artist') return matchesPatternSearchValue(track.artist, term) + if (namespace === 'album') return matchesPatternSearchValue(track.album, term) + if (namespace === 'ext') return matchesPatternSearchValue(getTrackExtension(track), term.replace(/^\./, '')) + + return matchesNamespacedTag(track.tags, namespace, term, true) + } + + function matchesGenericClause(track: Track, clause: string) { + const tokens = clause.match(LOCAL_SEARCH_TOKEN_PATTERN)?.filter(Boolean) ?? [] + if (tokens.length === 0) return true + + return tokens.every((token) => { + const term = normalizeSearchTerm(token) + if (!term) return true + + const searchableValues = [ + getTrackDisplayTitle(track), + track.artist, + track.album, + track.serverName, + getTrackExtension(track), + ...(track.tags || []), + ] + + return searchableValues.some((value) => matchesSearchValue(value, term)) }) } @@ -380,34 +486,18 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr const trimmedQuery = rawQuery.trim() if (!trimmedQuery) return true - const tokens = trimmedQuery.match(LOCAL_SEARCH_TOKEN_PATTERN)?.filter(Boolean) ?? [] - if (tokens.length === 0) return true + const clauses = splitSearchClauses(trimmedQuery) + if (clauses.length === 0) return true - return tokens.every((token) => { - const separatorIndex = token.indexOf(':') - if (separatorIndex > 0) { - const namespace = token.slice(0, separatorIndex).toLowerCase() - const term = normalizeSearchTerm(token.slice(separatorIndex + 1)) - - if (namespace === 'system') return matchesSystemPredicate(track, token) - if (namespace === 'title') return matchesSearchValue(getTrackDisplayTitle(track), term) - if (namespace === 'artist') return matchesSearchValue(track.artist, term) - if (namespace === 'album') return matchesSearchValue(track.album, term) - return matchesNamespacedTag(track.tags, namespace, term) + return clauses.every((clause) => { + const namespaceMatch = clause.match(SEARCH_NAMESPACE_PATTERN) + if (namespaceMatch) { + const namespace = namespaceMatch[1].toLowerCase() + const rawValue = namespaceMatch[2] + return matchesNamespacedClause(track, namespace, rawValue) } - const term = normalizeSearchTerm(token) - if (!term) return true - - const searchableValues = [ - getTrackDisplayTitle(track), - track.artist, - track.album, - track.serverName, - ...(track.tags || []), - ] - - return searchableValues.some((value) => matchesSearchValue(value, term)) + return matchesGenericClause(track, clause) }) } @@ -779,9 +869,26 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr return } + if (primaryTapAction === 'details') { + await openTrackDetails(track) + return + } + await handlePlayNow(track) } + const handleStreamTrack = async () => { + if (!detailsTrack?.url) return + const streamTrack = detailsData ? { ...detailsTrack, ...detailsData } : detailsTrack + closeDetails() + await onPlayNow(streamTrack) + } + + const handleOpenWeb = () => { + if (!detailsTrack?.url || typeof window === 'undefined') return + window.open(detailsTrack.url, '_blank', 'noopener,noreferrer') + } + const handleDownloadTrack = () => { if (!detailsTrack?.url || detailsTrackDownloading) return onDownloadTrack(detailsTrack, detailsData) @@ -1304,6 +1411,12 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr )} + + diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 3165093..ff5b155 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -28,21 +28,27 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow' import EditIcon from '@mui/icons-material/Edit' import AddIcon from '@mui/icons-material/Add' import CloudDownloadIcon from '@mui/icons-material/CloudDownload' +import type { LibraryPrimaryAction } from '../appPreferences' import type { Server } from '../context/ServersContext' import { useServers } from '../context/ServersContext' import { buildLibraryCacheKey, getLibraryCacheStats, pruneLibraryCache } from '../libraryCache' import { syncLibraryCache } from '../librarySync' +import { APP_THEME_PRESETS, type AppThemeId } from '../themes' const DEFAULT_SERVER_FORM = { name: '', host: '', port: undefined, apiKey: '', ssl: false, forceApiKeyInQuery: false } type SettingsPageProps = { onClose?: () => void devOverlayEnabled: boolean onDevOverlayEnabledChange: (enabled: boolean) => void + appTheme: AppThemeId + onAppThemeChange: (theme: AppThemeId) => void libraryDisplayMode: 'grid' | 'table' onLibraryDisplayModeChange: (mode: 'grid' | 'table') => void + libraryPrimaryAction: LibraryPrimaryAction + onLibraryPrimaryActionChange: (action: LibraryPrimaryAction) => void } -export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayEnabledChange, libraryDisplayMode, onLibraryDisplayModeChange }: SettingsPageProps) { +export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayEnabledChange, appTheme, onAppThemeChange, libraryDisplayMode, onLibraryDisplayModeChange, libraryPrimaryAction, onLibraryPrimaryActionChange }: SettingsPageProps) { const { servers, addServer, updateServer, removeServer, testServerById, testServerConfig, setActiveServerId, activeServerId } = useServers() const [editing, setEditing] = useState(null) const [form, setForm] = useState>(DEFAULT_SERVER_FORM) @@ -51,7 +57,9 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE const [lastTest, setLastTest] = useState(null) const [detailsOpen, setDetailsOpen] = useState(false) const [detailsText, setDetailsText] = useState(null) + const [draftAppTheme, setDraftAppTheme] = useState(appTheme) const [draftLibraryDisplayMode, setDraftLibraryDisplayMode] = useState<'grid' | 'table'>(libraryDisplayMode) + const [draftLibraryPrimaryAction, setDraftLibraryPrimaryAction] = useState(libraryPrimaryAction) const [draftDevOverlayEnabled, setDraftDevOverlayEnabled] = useState(devOverlayEnabled) const [syncCompletionNotices, setSyncCompletionNotices] = useState>({}) const [cacheStorageText, setCacheStorageText] = useState('0 B') @@ -70,10 +78,18 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE setDetailsOpen(false) }, []) + useEffect(() => { + setDraftAppTheme(appTheme) + }, [appTheme]) + useEffect(() => { setDraftLibraryDisplayMode(libraryDisplayMode) }, [libraryDisplayMode]) + useEffect(() => { + setDraftLibraryPrimaryAction(libraryPrimaryAction) + }, [libraryPrimaryAction]) + useEffect(() => { setDraftDevOverlayEnabled(devOverlayEnabled) }, [devOverlayEnabled]) @@ -84,7 +100,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE } }, []) - const preferencesDirty = draftLibraryDisplayMode !== libraryDisplayMode || draftDevOverlayEnabled !== devOverlayEnabled + const preferencesDirty = draftAppTheme !== appTheme || draftLibraryDisplayMode !== libraryDisplayMode || draftLibraryPrimaryAction !== libraryPrimaryAction || draftDevOverlayEnabled !== devOverlayEnabled const formatBytes = (value: number) => { if (!value || value <= 0) return '0 B' @@ -226,10 +242,18 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE }, [currentCacheKey]) const handleSavePreferences = () => { + if (draftAppTheme !== appTheme) { + onAppThemeChange(draftAppTheme) + } + if (draftLibraryDisplayMode !== libraryDisplayMode) { onLibraryDisplayModeChange(draftLibraryDisplayMode) } + if (draftLibraryPrimaryAction !== libraryPrimaryAction) { + onLibraryPrimaryActionChange(draftLibraryPrimaryAction) + } + if (draftDevOverlayEnabled !== devOverlayEnabled) { onDevOverlayEnabledChange(draftDevOverlayEnabled) } @@ -252,7 +276,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE Interface preferences - Save Library layout and development UI changes together. + Personalize the look and default actions without affecting your cached library. - - Display - - + + + Theme + + + + + Display + + + + + Track tap + + + + + + {APP_THEME_PRESETS.map((theme) => ( + + ))} + + + + {APP_THEME_PRESETS.find((theme) => theme.id === draftAppTheme)?.description} + {import.meta.env.DEV && ( <> diff --git a/src/themes.ts b/src/themes.ts new file mode 100644 index 0000000..5eb710d --- /dev/null +++ b/src/themes.ts @@ -0,0 +1,74 @@ +import { createTheme } from '@mui/material/styles' + +export type AppThemeId = 'hydrus-dark' | 'ocean' | 'ember' | 'paper' + +type ThemePreset = { + id: AppThemeId + label: string + description: string +} + +export const APP_THEME_PRESETS: ThemePreset[] = [ + { + id: 'hydrus-dark', + label: 'Hydrus Dark', + description: 'Dark graphite with the original green accent.', + }, + { + id: 'ocean', + label: 'Ocean', + description: 'Deep blue panels with a cool cyan accent.', + }, + { + id: 'ember', + label: 'Ember', + description: 'Warm dark surfaces with a copper highlight.', + }, + { + id: 'paper', + label: 'Paper Light', + description: 'Clean light interface with ink-blue accents.', + }, +] + +export function createAppTheme(themeId: AppThemeId) { + switch (themeId) { + case 'ocean': + return createTheme({ + palette: { + mode: 'dark', + primary: { main: '#4cc9f0' }, + secondary: { main: '#90e0ef' }, + background: { default: '#07141d', paper: '#0d1e2b' }, + }, + }) + case 'ember': + return createTheme({ + palette: { + mode: 'dark', + primary: { main: '#ff7a3d' }, + secondary: { main: '#ffb26b' }, + background: { default: '#17110f', paper: '#241714' }, + }, + }) + case 'paper': + return createTheme({ + palette: { + mode: 'light', + primary: { main: '#1f4d7a' }, + secondary: { main: '#6785a3' }, + background: { default: '#f2efe8', paper: '#fffdf8' }, + }, + }) + case 'hydrus-dark': + default: + return createTheme({ + palette: { + mode: 'dark', + primary: { main: '#1db954' }, + secondary: { main: '#6ee7a2' }, + background: { default: '#0f1113', paper: '#151617' }, + }, + }) + } +} \ No newline at end of file