added themes and theme selection to settings page

This commit is contained in:
2026-04-15 14:47:58 -07:00
parent 694eb23e7b
commit ca559de2c0
5 changed files with 320 additions and 53 deletions
+19 -9
View File
@@ -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<LibraryPrimaryAction>(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<Record<string, MediaInfo>>({})
const mimeRequestCacheRef = useRef<Record<string, Promise<MediaInfo>>>({})
@@ -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}
/>
</Suspense>
)
@@ -853,6 +862,7 @@ function App() {
onPlayNow={playNow}
onDownloadTrack={queueDownload}
isTrackDownloading={isTrackDownloading}
primaryTapAction={libraryPrimaryAction}
query={libraryQuery}
onQueryChange={setLibraryQuery}
displayModePreference={libraryDisplayMode}
+7
View File
@@ -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<Record<MediaSection, string>>
@@ -15,6 +20,8 @@ const DEFAULT_UI_PREFERENCES: UiPreferences = {
devOverlayEnabled: true,
libraryQuery: '',
libraryDisplayMode: 'grid',
appTheme: 'hydrus-dark',
libraryPrimaryAction: 'details',
librarySortBy: 'artist',
librarySortDirection: 'asc',
librarySectionViews: {},
+142 -29
View File
@@ -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<void>
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<Record<MediaSection, string>>, [initialUiPreferences])
const [results, setResults] = useState<Track[]>([])
@@ -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
)}
</DialogContent>
<DialogActions>
<Button onClick={handleOpenWeb} disabled={!detailsTrack?.url}>
Web
</Button>
<Button onClick={() => void handleStreamTrack()} disabled={!detailsTrack?.url}>
Stream
</Button>
<Button onClick={handleDownloadTrack} startIcon={<DownloadIcon />} disabled={!detailsTrack?.url || detailsLoading || detailsTrackDownloading}>
{detailsTrackDownloading ? 'Downloading...' : 'Download'}
</Button>
+78 -15
View File
@@ -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<Server | null>(null)
const [form, setForm] = useState<Omit<Server, 'id' | 'lastTest'>>(DEFAULT_SERVER_FORM)
@@ -51,7 +57,9 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
const [lastTest, setLastTest] = useState<string | null>(null)
const [detailsOpen, setDetailsOpen] = useState(false)
const [detailsText, setDetailsText] = useState<string | null>(null)
const [draftAppTheme, setDraftAppTheme] = useState<AppThemeId>(appTheme)
const [draftLibraryDisplayMode, setDraftLibraryDisplayMode] = useState<'grid' | 'table'>(libraryDisplayMode)
const [draftLibraryPrimaryAction, setDraftLibraryPrimaryAction] = useState<LibraryPrimaryAction>(libraryPrimaryAction)
const [draftDevOverlayEnabled, setDraftDevOverlayEnabled] = useState(devOverlayEnabled)
const [syncCompletionNotices, setSyncCompletionNotices] = useState<Record<string, string>>({})
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
<Box>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Interface preferences</Typography>
<Typography variant="body2" color="text.secondary">
Save Library layout and development UI changes together.
Personalize the look and default actions without affecting your cached library.
</Typography>
</Box>
<Button variant="contained" onClick={handleSavePreferences} disabled={!preferencesDirty}>
@@ -260,18 +284,57 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
</Button>
</Box>
<FormControl size="small" sx={{ minWidth: 180, maxWidth: 240, mb: import.meta.env.DEV ? 2 : 0 }}>
<InputLabel id="settings-library-display-mode-label">Display</InputLabel>
<Select
labelId="settings-library-display-mode-label"
value={draftLibraryDisplayMode}
label="Display"
onChange={(event) => setDraftLibraryDisplayMode(event.target.value as 'grid' | 'table')}
>
<MenuItem value="grid">Grid</MenuItem>
<MenuItem value="table">Table</MenuItem>
</Select>
</FormControl>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, minmax(0, 240px))' }, gap: 1.5, mb: import.meta.env.DEV ? 2 : 0 }}>
<FormControl size="small">
<InputLabel id="settings-app-theme-label">Theme</InputLabel>
<Select
labelId="settings-app-theme-label"
value={draftAppTheme}
label="Theme"
onChange={(event) => setDraftAppTheme(event.target.value as AppThemeId)}
>
{APP_THEME_PRESETS.map((theme) => (
<MenuItem key={theme.id} value={theme.id}>{theme.label}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small">
<InputLabel id="settings-library-display-mode-label">Display</InputLabel>
<Select
labelId="settings-library-display-mode-label"
value={draftLibraryDisplayMode}
label="Display"
onChange={(event) => setDraftLibraryDisplayMode(event.target.value as 'grid' | 'table')}
>
<MenuItem value="grid">Grid</MenuItem>
<MenuItem value="table">Table</MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<InputLabel id="settings-library-primary-action-label">Track tap</InputLabel>
<Select
labelId="settings-library-primary-action-label"
value={draftLibraryPrimaryAction}
label="Track tap"
onChange={(event) => setDraftLibraryPrimaryAction(event.target.value as LibraryPrimaryAction)}
>
<MenuItem value="details">Open details popup</MenuItem>
<MenuItem value="stream">Stream immediately</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: import.meta.env.DEV ? 2 : 0.5 }}>
{APP_THEME_PRESETS.map((theme) => (
<Chip key={theme.id} label={theme.label} color={draftAppTheme === theme.id ? 'primary' : 'default'} variant={draftAppTheme === theme.id ? 'filled' : 'outlined'} />
))}
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: import.meta.env.DEV ? 0.5 : 0 }}>
{APP_THEME_PRESETS.find((theme) => theme.id === draftAppTheme)?.description}
</Typography>
{import.meta.env.DEV && (
<>
+74
View File
@@ -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' },
},
})
}
}