added themes and theme selection to settings page
This commit is contained in:
+19
-9
@@ -1,19 +1,20 @@
|
|||||||
import React, { Suspense, lazy, useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
import React, { Suspense, lazy, useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
||||||
import { ID3Writer } from 'browser-id3-writer'
|
import { ID3Writer } from 'browser-id3-writer'
|
||||||
import { embedContainerAudioMetadata, supportsContainerMetadataEmbedding } from './audioMetadata'
|
import { embedContainerAudioMetadata, supportsContainerMetadataEmbedding } from './audioMetadata'
|
||||||
|
import { type LibraryPrimaryAction, loadUiPreferences, saveUiPreferences } from './appPreferences'
|
||||||
import Library from './pages/Library'
|
import Library from './pages/Library'
|
||||||
import Header from './components/Header'
|
import Header from './components/Header'
|
||||||
import Sidebar from './components/Sidebar'
|
import Sidebar from './components/Sidebar'
|
||||||
import DownloadsOverlay, { type DownloadOverlayItem } from './components/DownloadsOverlay'
|
import DownloadsOverlay, { type DownloadOverlayItem } from './components/DownloadsOverlay'
|
||||||
import DownloadsPage from './pages/DownloadsPage'
|
import DownloadsPage from './pages/DownloadsPage'
|
||||||
import { Box, CssBaseline, useMediaQuery } from '@mui/material'
|
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 { ServersProvider, useServers } from './context/ServersContext'
|
||||||
import { extractTitleFromTags, type HydrusFileDetails } from './api/hydrusClient'
|
import { extractTitleFromTags, type HydrusFileDetails } from './api/hydrusClient'
|
||||||
import { addDevLog } from './debugLog'
|
import { addDevLog } from './debugLog'
|
||||||
import { clearStoredDownloads, deleteStoredDownload, listStoredDownloads, saveStoredDownload } from './downloadStore'
|
import { clearStoredDownloads, deleteStoredDownload, listStoredDownloads, saveStoredDownload } from './downloadStore'
|
||||||
import { loadUiPreferences, saveUiPreferences } from './appPreferences'
|
|
||||||
import { syncLibraryCache } from './librarySync'
|
import { syncLibraryCache } from './librarySync'
|
||||||
|
import { createAppTheme } from './themes'
|
||||||
import type { MediaSection, Track } from './types'
|
import type { MediaSection, Track } from './types'
|
||||||
|
|
||||||
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
||||||
@@ -369,15 +370,11 @@ function App() {
|
|||||||
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true)
|
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true)
|
||||||
const [libraryQuery, setLibraryQuery] = useState(initialUiPreferences.libraryQuery)
|
const [libraryQuery, setLibraryQuery] = useState(initialUiPreferences.libraryQuery)
|
||||||
const [libraryDisplayMode, setLibraryDisplayMode] = useState(initialUiPreferences.libraryDisplayMode)
|
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 [devOverlayEnabled, setDevOverlayEnabled] = useState(initialUiPreferences.devOverlayEnabled)
|
||||||
|
|
||||||
const theme = useMemo(() => createTheme({
|
const theme = useMemo(() => createAppTheme(appThemeId), [appThemeId])
|
||||||
palette: {
|
|
||||||
mode: 'dark',
|
|
||||||
primary: { main: '#1db954' },
|
|
||||||
background: { default: '#0f1113', paper: '#151617' }
|
|
||||||
}
|
|
||||||
}), [])
|
|
||||||
|
|
||||||
const mimeCacheRef = useRef<Record<string, MediaInfo>>({})
|
const mimeCacheRef = useRef<Record<string, MediaInfo>>({})
|
||||||
const mimeRequestCacheRef = useRef<Record<string, Promise<MediaInfo>>>({})
|
const mimeRequestCacheRef = useRef<Record<string, Promise<MediaInfo>>>({})
|
||||||
@@ -617,6 +614,14 @@ function App() {
|
|||||||
setLibraryDisplayMode(mode)
|
setLibraryDisplayMode(mode)
|
||||||
saveUiPreferences({ libraryDisplayMode: 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) => {
|
const handleSidebarNavigate = useCallback((id: string) => {
|
||||||
if (id === 'settings') setActivePage('settings')
|
if (id === 'settings') setActivePage('settings')
|
||||||
else if (id === 'downloads') setActivePage('downloads')
|
else if (id === 'downloads') setActivePage('downloads')
|
||||||
@@ -832,8 +837,12 @@ function App() {
|
|||||||
onClose={closeSettings}
|
onClose={closeSettings}
|
||||||
devOverlayEnabled={devOverlayEnabled}
|
devOverlayEnabled={devOverlayEnabled}
|
||||||
onDevOverlayEnabledChange={handleDevOverlayEnabledChange}
|
onDevOverlayEnabledChange={handleDevOverlayEnabledChange}
|
||||||
|
appTheme={appThemeId}
|
||||||
|
onAppThemeChange={handleAppThemeChange}
|
||||||
libraryDisplayMode={libraryDisplayMode}
|
libraryDisplayMode={libraryDisplayMode}
|
||||||
onLibraryDisplayModeChange={handleLibraryDisplayModeChange}
|
onLibraryDisplayModeChange={handleLibraryDisplayModeChange}
|
||||||
|
libraryPrimaryAction={libraryPrimaryAction}
|
||||||
|
onLibraryPrimaryActionChange={handleLibraryPrimaryActionChange}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
@@ -853,6 +862,7 @@ function App() {
|
|||||||
onPlayNow={playNow}
|
onPlayNow={playNow}
|
||||||
onDownloadTrack={queueDownload}
|
onDownloadTrack={queueDownload}
|
||||||
isTrackDownloading={isTrackDownloading}
|
isTrackDownloading={isTrackDownloading}
|
||||||
|
primaryTapAction={libraryPrimaryAction}
|
||||||
query={libraryQuery}
|
query={libraryQuery}
|
||||||
onQueryChange={setLibraryQuery}
|
onQueryChange={setLibraryQuery}
|
||||||
displayModePreference={libraryDisplayMode}
|
displayModePreference={libraryDisplayMode}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import type { MediaSection } from './types'
|
import type { MediaSection } from './types'
|
||||||
|
import type { AppThemeId } from './themes'
|
||||||
|
|
||||||
|
export type LibraryPrimaryAction = 'details' | 'stream'
|
||||||
|
|
||||||
export type UiPreferences = {
|
export type UiPreferences = {
|
||||||
devOverlayEnabled: boolean
|
devOverlayEnabled: boolean
|
||||||
libraryQuery: string
|
libraryQuery: string
|
||||||
libraryDisplayMode: 'grid' | 'table'
|
libraryDisplayMode: 'grid' | 'table'
|
||||||
|
appTheme: AppThemeId
|
||||||
|
libraryPrimaryAction: LibraryPrimaryAction
|
||||||
librarySortBy: string
|
librarySortBy: string
|
||||||
librarySortDirection: 'asc' | 'desc'
|
librarySortDirection: 'asc' | 'desc'
|
||||||
librarySectionViews: Partial<Record<MediaSection, string>>
|
librarySectionViews: Partial<Record<MediaSection, string>>
|
||||||
@@ -15,6 +20,8 @@ const DEFAULT_UI_PREFERENCES: UiPreferences = {
|
|||||||
devOverlayEnabled: true,
|
devOverlayEnabled: true,
|
||||||
libraryQuery: '',
|
libraryQuery: '',
|
||||||
libraryDisplayMode: 'grid',
|
libraryDisplayMode: 'grid',
|
||||||
|
appTheme: 'hydrus-dark',
|
||||||
|
libraryPrimaryAction: 'details',
|
||||||
librarySortBy: 'artist',
|
librarySortBy: 'artist',
|
||||||
librarySortDirection: 'asc',
|
librarySortDirection: 'asc',
|
||||||
librarySectionViews: {},
|
librarySectionViews: {},
|
||||||
|
|||||||
+142
-29
@@ -4,7 +4,7 @@ import { useTheme } from '@mui/material/styles'
|
|||||||
import DownloadIcon from '@mui/icons-material/Download'
|
import DownloadIcon from '@mui/icons-material/Download'
|
||||||
import { useServers } from '../context/ServersContext'
|
import { useServers } from '../context/ServersContext'
|
||||||
import { HydrusClient, extractTitleFromTags, type HydrusFileDetails } from '../api/hydrusClient'
|
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 { buildLibraryCacheKey, loadLibraryCache, saveLibraryCache } from '../libraryCache'
|
||||||
import { LIBRARY_CACHE_SYNC_EVENT } from '../librarySync'
|
import { LIBRARY_CACHE_SYNC_EVENT } from '../librarySync'
|
||||||
import type { MediaSection, Track } from '../types'
|
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 RESULTS_PAGE_SIZE = 36
|
||||||
const LONG_PRESS_DELAY_MS = 420
|
const LONG_PRESS_DELAY_MS = 420
|
||||||
const LOCAL_SEARCH_TOKEN_PATTERN = /(?:[^\s"]+:"(?:[^"\\]|\\.)*"|"(?:[^"\\]|\\.)*"|\S+)/g
|
const LOCAL_SEARCH_TOKEN_PATTERN = /(?:[^\s"]+:"(?:[^"\\]|\\.)*"|"(?:[^"\\]|\\.)*"|\S+)/g
|
||||||
|
const SEARCH_NAMESPACE_PATTERN = /^([a-z0-9_+-]+):(.*)$/i
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mediaSection: MediaSection
|
mediaSection: MediaSection
|
||||||
onPlayNow: (track: Track) => void | Promise<void>
|
onPlayNow: (track: Track) => void | Promise<void>
|
||||||
onDownloadTrack: (track: Track, details?: HydrusFileDetails | null) => void
|
onDownloadTrack: (track: Track, details?: HydrusFileDetails | null) => void
|
||||||
isTrackDownloading: (track: Track) => boolean
|
isTrackDownloading: (track: Track) => boolean
|
||||||
|
primaryTapAction: LibraryPrimaryAction
|
||||||
query: string
|
query: string
|
||||||
onQueryChange: (value: string) => void
|
onQueryChange: (value: string) => void
|
||||||
displayModePreference: 'grid' | 'table'
|
displayModePreference: 'grid' | 'table'
|
||||||
@@ -213,7 +215,7 @@ function getTrackKindLabel(track: Track, fallbackLabel: string) {
|
|||||||
return fallbackLabel
|
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 initialUiPreferences = useMemo(() => loadUiPreferences(), [])
|
||||||
const initialSectionViews = useMemo(() => initialUiPreferences.librarySectionViews as Partial<Record<MediaSection, string>>, [initialUiPreferences])
|
const initialSectionViews = useMemo(() => initialUiPreferences.librarySectionViews as Partial<Record<MediaSection, string>>, [initialUiPreferences])
|
||||||
const [results, setResults] = useState<Track[]>([])
|
const [results, setResults] = useState<Track[]>([])
|
||||||
@@ -348,19 +350,123 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
|
|||||||
return normalizeSearchText(normalized)
|
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) {
|
function matchesSearchValue(value: string | undefined | null, term: string) {
|
||||||
if (!term) return true
|
if (!term) return true
|
||||||
return normalizeSearchText(value).includes(term)
|
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
|
if (!tags || tags.length === 0) return false
|
||||||
|
|
||||||
const prefix = `${namespace.toLowerCase()}:`
|
const prefix = `${namespace.toLowerCase()}:`
|
||||||
return tags.some((tag) => {
|
return tags.some((tag) => {
|
||||||
if (typeof tag !== 'string') return false
|
if (typeof tag !== 'string') return false
|
||||||
if (!tag.toLowerCase().startsWith(prefix)) 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()
|
const trimmedQuery = rawQuery.trim()
|
||||||
if (!trimmedQuery) return true
|
if (!trimmedQuery) return true
|
||||||
|
|
||||||
const tokens = trimmedQuery.match(LOCAL_SEARCH_TOKEN_PATTERN)?.filter(Boolean) ?? []
|
const clauses = splitSearchClauses(trimmedQuery)
|
||||||
if (tokens.length === 0) return true
|
if (clauses.length === 0) return true
|
||||||
|
|
||||||
return tokens.every((token) => {
|
return clauses.every((clause) => {
|
||||||
const separatorIndex = token.indexOf(':')
|
const namespaceMatch = clause.match(SEARCH_NAMESPACE_PATTERN)
|
||||||
if (separatorIndex > 0) {
|
if (namespaceMatch) {
|
||||||
const namespace = token.slice(0, separatorIndex).toLowerCase()
|
const namespace = namespaceMatch[1].toLowerCase()
|
||||||
const term = normalizeSearchTerm(token.slice(separatorIndex + 1))
|
const rawValue = namespaceMatch[2]
|
||||||
|
return matchesNamespacedClause(track, namespace, rawValue)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const term = normalizeSearchTerm(token)
|
return matchesGenericClause(track, clause)
|
||||||
if (!term) return true
|
|
||||||
|
|
||||||
const searchableValues = [
|
|
||||||
getTrackDisplayTitle(track),
|
|
||||||
track.artist,
|
|
||||||
track.album,
|
|
||||||
track.serverName,
|
|
||||||
...(track.tags || []),
|
|
||||||
]
|
|
||||||
|
|
||||||
return searchableValues.some((value) => matchesSearchValue(value, term))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,9 +869,26 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (primaryTapAction === 'details') {
|
||||||
|
await openTrackDetails(track)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await handlePlayNow(track)
|
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 = () => {
|
const handleDownloadTrack = () => {
|
||||||
if (!detailsTrack?.url || detailsTrackDownloading) return
|
if (!detailsTrack?.url || detailsTrackDownloading) return
|
||||||
onDownloadTrack(detailsTrack, detailsData)
|
onDownloadTrack(detailsTrack, detailsData)
|
||||||
@@ -1304,6 +1411,12 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<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}>
|
<Button onClick={handleDownloadTrack} startIcon={<DownloadIcon />} disabled={!detailsTrack?.url || detailsLoading || detailsTrackDownloading}>
|
||||||
{detailsTrackDownloading ? 'Downloading...' : 'Download'}
|
{detailsTrackDownloading ? 'Downloading...' : 'Download'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+78
-15
@@ -28,21 +28,27 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow'
|
|||||||
import EditIcon from '@mui/icons-material/Edit'
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
import AddIcon from '@mui/icons-material/Add'
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
|
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
|
||||||
|
import type { LibraryPrimaryAction } from '../appPreferences'
|
||||||
import type { Server } from '../context/ServersContext'
|
import type { Server } from '../context/ServersContext'
|
||||||
import { useServers } from '../context/ServersContext'
|
import { useServers } from '../context/ServersContext'
|
||||||
import { buildLibraryCacheKey, getLibraryCacheStats, pruneLibraryCache } from '../libraryCache'
|
import { buildLibraryCacheKey, getLibraryCacheStats, pruneLibraryCache } from '../libraryCache'
|
||||||
import { syncLibraryCache } from '../librarySync'
|
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 }
|
const DEFAULT_SERVER_FORM = { name: '', host: '', port: undefined, apiKey: '', ssl: false, forceApiKeyInQuery: false }
|
||||||
|
|
||||||
type SettingsPageProps = {
|
type SettingsPageProps = {
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
devOverlayEnabled: boolean
|
devOverlayEnabled: boolean
|
||||||
onDevOverlayEnabledChange: (enabled: boolean) => void
|
onDevOverlayEnabledChange: (enabled: boolean) => void
|
||||||
|
appTheme: AppThemeId
|
||||||
|
onAppThemeChange: (theme: AppThemeId) => void
|
||||||
libraryDisplayMode: 'grid' | 'table'
|
libraryDisplayMode: 'grid' | 'table'
|
||||||
onLibraryDisplayModeChange: (mode: 'grid' | 'table') => void
|
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 { servers, addServer, updateServer, removeServer, testServerById, testServerConfig, setActiveServerId, activeServerId } = useServers()
|
||||||
const [editing, setEditing] = useState<Server | null>(null)
|
const [editing, setEditing] = useState<Server | null>(null)
|
||||||
const [form, setForm] = useState<Omit<Server, 'id' | 'lastTest'>>(DEFAULT_SERVER_FORM)
|
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 [lastTest, setLastTest] = useState<string | null>(null)
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||||
const [detailsText, setDetailsText] = useState<string | null>(null)
|
const [detailsText, setDetailsText] = useState<string | null>(null)
|
||||||
|
const [draftAppTheme, setDraftAppTheme] = useState<AppThemeId>(appTheme)
|
||||||
const [draftLibraryDisplayMode, setDraftLibraryDisplayMode] = useState<'grid' | 'table'>(libraryDisplayMode)
|
const [draftLibraryDisplayMode, setDraftLibraryDisplayMode] = useState<'grid' | 'table'>(libraryDisplayMode)
|
||||||
|
const [draftLibraryPrimaryAction, setDraftLibraryPrimaryAction] = useState<LibraryPrimaryAction>(libraryPrimaryAction)
|
||||||
const [draftDevOverlayEnabled, setDraftDevOverlayEnabled] = useState(devOverlayEnabled)
|
const [draftDevOverlayEnabled, setDraftDevOverlayEnabled] = useState(devOverlayEnabled)
|
||||||
const [syncCompletionNotices, setSyncCompletionNotices] = useState<Record<string, string>>({})
|
const [syncCompletionNotices, setSyncCompletionNotices] = useState<Record<string, string>>({})
|
||||||
const [cacheStorageText, setCacheStorageText] = useState('0 B')
|
const [cacheStorageText, setCacheStorageText] = useState('0 B')
|
||||||
@@ -70,10 +78,18 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
|
|||||||
setDetailsOpen(false)
|
setDetailsOpen(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftAppTheme(appTheme)
|
||||||
|
}, [appTheme])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraftLibraryDisplayMode(libraryDisplayMode)
|
setDraftLibraryDisplayMode(libraryDisplayMode)
|
||||||
}, [libraryDisplayMode])
|
}, [libraryDisplayMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftLibraryPrimaryAction(libraryPrimaryAction)
|
||||||
|
}, [libraryPrimaryAction])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraftDevOverlayEnabled(devOverlayEnabled)
|
setDraftDevOverlayEnabled(devOverlayEnabled)
|
||||||
}, [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) => {
|
const formatBytes = (value: number) => {
|
||||||
if (!value || value <= 0) return '0 B'
|
if (!value || value <= 0) return '0 B'
|
||||||
@@ -226,10 +242,18 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
|
|||||||
}, [currentCacheKey])
|
}, [currentCacheKey])
|
||||||
|
|
||||||
const handleSavePreferences = () => {
|
const handleSavePreferences = () => {
|
||||||
|
if (draftAppTheme !== appTheme) {
|
||||||
|
onAppThemeChange(draftAppTheme)
|
||||||
|
}
|
||||||
|
|
||||||
if (draftLibraryDisplayMode !== libraryDisplayMode) {
|
if (draftLibraryDisplayMode !== libraryDisplayMode) {
|
||||||
onLibraryDisplayModeChange(draftLibraryDisplayMode)
|
onLibraryDisplayModeChange(draftLibraryDisplayMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (draftLibraryPrimaryAction !== libraryPrimaryAction) {
|
||||||
|
onLibraryPrimaryActionChange(draftLibraryPrimaryAction)
|
||||||
|
}
|
||||||
|
|
||||||
if (draftDevOverlayEnabled !== devOverlayEnabled) {
|
if (draftDevOverlayEnabled !== devOverlayEnabled) {
|
||||||
onDevOverlayEnabledChange(draftDevOverlayEnabled)
|
onDevOverlayEnabledChange(draftDevOverlayEnabled)
|
||||||
}
|
}
|
||||||
@@ -252,7 +276,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
|
|||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Interface preferences</Typography>
|
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Interface preferences</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Button variant="contained" onClick={handleSavePreferences} disabled={!preferencesDirty}>
|
<Button variant="contained" onClick={handleSavePreferences} disabled={!preferencesDirty}>
|
||||||
@@ -260,18 +284,57 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<FormControl size="small" sx={{ minWidth: 180, maxWidth: 240, mb: import.meta.env.DEV ? 2 : 0 }}>
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, minmax(0, 240px))' }, gap: 1.5, mb: import.meta.env.DEV ? 2 : 0 }}>
|
||||||
<InputLabel id="settings-library-display-mode-label">Display</InputLabel>
|
<FormControl size="small">
|
||||||
<Select
|
<InputLabel id="settings-app-theme-label">Theme</InputLabel>
|
||||||
labelId="settings-library-display-mode-label"
|
<Select
|
||||||
value={draftLibraryDisplayMode}
|
labelId="settings-app-theme-label"
|
||||||
label="Display"
|
value={draftAppTheme}
|
||||||
onChange={(event) => setDraftLibraryDisplayMode(event.target.value as 'grid' | 'table')}
|
label="Theme"
|
||||||
>
|
onChange={(event) => setDraftAppTheme(event.target.value as AppThemeId)}
|
||||||
<MenuItem value="grid">Grid</MenuItem>
|
>
|
||||||
<MenuItem value="table">Table</MenuItem>
|
{APP_THEME_PRESETS.map((theme) => (
|
||||||
</Select>
|
<MenuItem key={theme.id} value={theme.id}>{theme.label}</MenuItem>
|
||||||
</FormControl>
|
))}
|
||||||
|
</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 && (
|
{import.meta.env.DEV && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user