diff --git a/CHANGELOG.md b/CHANGELOG.md index 564d6238..10b1e492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Themes — community Theme Store + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009)** + +* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews. +* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline. +* Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light). + + + ## Changed ### Dependencies — npm and Rust refresh diff --git a/src/App.tsx b/src/App.tsx index 142d8186..08816e32 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,8 @@ import { useEffect } from 'react'; import { useAuthStore } from './store/authStore'; import { useThemeStore } from './store/themeStore'; +import { useInstalledThemesStore } from './store/installedThemesStore'; +import { syncInjectedThemes } from './utils/themes/themeInjection'; import { useThemeScheduler } from './hooks/useThemeScheduler'; import { useFontStore } from './store/fontStore'; import { getWindowKind } from './app/windowKind'; @@ -13,10 +15,21 @@ export default function App() { useThemeStore(s => s.theme); const effectiveTheme = useThemeScheduler(); const font = useFontStore(s => s.font); + const installedThemes = useInstalledThemesStore(s => s.themes); // Document-attribute hooks are shared between both window kinds — each // webview has its own `document`, and theme / font / track-preview tokens // are read by CSS in both trees. + + // Installed community themes have no build-time CSS — inject their + // `[data-theme='']` blocks into from the persisted (localStorage) + // store. Runs before the data-theme effect below so the matching style exists + // when the attribute is applied. The store hydrates synchronously, so an + // active community theme is painted without a network round-trip. + useEffect(() => { + syncInjectedThemes(installedThemes); + }, [installedThemes]); + useEffect(() => { document.documentElement.setAttribute('data-theme', effectiveTheme); }, [effectiveTheme]); diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 446d78ac..89ed4ab8 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -19,6 +19,7 @@ import ContextMenu from '../components/ContextMenu'; import SongInfoModal from '../components/SongInfoModal'; import DownloadFolderModal from '../components/DownloadFolderModal'; import GlobalConfirmModal from '../components/GlobalConfirmModal'; +import ThemeMigrationNotice from '../components/ThemeMigrationNotice'; import OrbitAccountPicker from '../components/OrbitAccountPicker'; import OrbitHelpModal from '../components/OrbitHelpModal'; import TooltipPortal from '../components/TooltipPortal'; @@ -320,6 +321,7 @@ export function AppShell() { + {!perfFlags.disableTooltipPortal && } diff --git a/src/app/bootstrap.test.ts b/src/app/bootstrap.test.ts index 7b16f5ae..f22e82d9 100644 --- a/src/app/bootstrap.test.ts +++ b/src/app/bootstrap.test.ts @@ -24,10 +24,14 @@ vi.mock('./windowKind', () => ({ import { invoke } from '@tauri-apps/api/core'; import { getWindowKind } from './windowKind'; import { + applyThemeAtStartup, + installCrossWindowThemeSync, pushLoggingModeToBackend, pushUserAgentToBackend, runPreReactBootstrap, } from './bootstrap'; +import { useThemeStore } from '../store/themeStore'; +import { useInstalledThemesStore } from '../store/installedThemesStore'; const ORIGINAL_USER_AGENT = window.navigator.userAgent; @@ -48,6 +52,8 @@ beforeEach(() => { afterEach(() => { setUserAgent(ORIGINAL_USER_AGENT); + document.documentElement.removeAttribute('data-theme'); + document.head.querySelectorAll('style[data-installed-theme]').forEach((el) => el.remove()); }); describe('pushUserAgentToBackend', () => { @@ -143,3 +149,56 @@ describe('runPreReactBootstrap', () => { // the bootstrap doesn't second-guess that decision. }); }); + +describe('applyThemeAtStartup', () => { + const setPersistedTheme = (state: Record) => + localStorage.setItem('psysonic_theme', JSON.stringify({ state, version: 1 })); + + it('does nothing when there is no persisted theme', () => { + applyThemeAtStartup(); + expect(document.documentElement.getAttribute('data-theme')).toBeNull(); + }); + + it('sets data-theme to the active theme (scheduler off)', () => { + setPersistedTheme({ theme: 'kanagawa-wave', enableThemeScheduler: false }); + applyThemeAtStartup(); + expect(document.documentElement.getAttribute('data-theme')).toBe('kanagawa-wave'); + }); + + it('injects installed community themes up front', () => { + setPersistedTheme({ theme: 'dracula', enableThemeScheduler: false }); + localStorage.setItem('psysonic_installed_themes', JSON.stringify({ + state: { themes: [{ id: 'dracula', name: 'Dracula', author: 'a', version: '1.0.0', description: '', mode: 'dark', css: "[data-theme='dracula']{--accent:#bd93f9;}", installedAt: 0 }] }, + version: 1, + })); + applyThemeAtStartup(); + expect(document.head.querySelector('style[data-installed-theme="dracula"]')).not.toBeNull(); + expect(document.documentElement.getAttribute('data-theme')).toBe('dracula'); + }); + + it('does not throw on malformed storage', () => { + localStorage.setItem('psysonic_theme', '{not json'); + expect(() => applyThemeAtStartup()).not.toThrow(); + expect(document.documentElement.getAttribute('data-theme')).toBeNull(); + }); +}); + +describe('installCrossWindowThemeSync', () => { + it('rehydrates the matching store on a cross-window storage event', () => { + const themeRehydrate = vi.spyOn(useThemeStore.persist, 'rehydrate').mockResolvedValue(undefined); + const installedRehydrate = vi.spyOn(useInstalledThemesStore.persist, 'rehydrate').mockResolvedValue(undefined); + installCrossWindowThemeSync(); + + window.dispatchEvent(new StorageEvent('storage', { key: 'psysonic_theme' })); + expect(themeRehydrate).toHaveBeenCalled(); + + window.dispatchEvent(new StorageEvent('storage', { key: 'psysonic_installed_themes' })); + expect(installedRehydrate).toHaveBeenCalled(); + + themeRehydrate.mockClear(); + installedRehydrate.mockClear(); + window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated-key' })); + expect(themeRehydrate).not.toHaveBeenCalled(); + expect(installedRehydrate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts index 8351f369..15258183 100644 --- a/src/app/bootstrap.ts +++ b/src/app/bootstrap.ts @@ -1,6 +1,10 @@ import { installQueueUndoHotkey } from '../store/queueUndoHotkey'; import { invoke } from '@tauri-apps/api/core'; import { getWindowKind } from './windowKind'; +import { migrateThemeSelection } from '../utils/themes/themeMigration'; +import { getScheduledTheme, useThemeStore } from '../store/themeStore'; +import { syncInjectedThemes } from '../utils/themes/themeInjection'; +import { useInstalledThemesStore, type InstalledTheme } from '../store/installedThemesStore'; /** Sync backend HTTP User-Agent from the main webview once at startup. */ export function pushUserAgentToBackend(): void { @@ -35,6 +39,63 @@ export function pushLoggingModeToBackend(): void { } } +function readInstalledThemes(): InstalledTheme[] { + try { + const raw = localStorage.getItem('psysonic_installed_themes'); + if (!raw) return []; + const parsed = JSON.parse(raw) as { state?: { themes?: InstalledTheme[] } }; + return Array.isArray(parsed.state?.themes) ? (parsed.state!.themes as InstalledTheme[]) : []; + } catch { + return []; + } +} + +/** + * Apply the active theme synchronously, before React mounts, so the first paint + * is already correct. Zustand rehydrate + the `data-theme` effect run after the + * first paint, so without this a non-Mocha active theme (every light theme and + * every installed community theme) flashes the `:root` Mocha default for a + * frame. We set `data-theme` to the effective (scheduler-resolved) theme and + * inject installed community themes' CSS up front. Runs after the migration so + * the persisted ids are already resolved. + */ +export function applyThemeAtStartup(): void { + try { + const raw = localStorage.getItem('psysonic_theme'); + if (!raw) return; // fresh profile — the :root default is correct + const parsed = JSON.parse(raw) as { state?: Record }; + const s = parsed.state; + if (!s) return; + syncInjectedThemes(readInstalledThemes()); + const effective = getScheduledTheme({ + enableThemeScheduler: !!s.enableThemeScheduler, + theme: String(s.theme ?? 'mocha'), + themeDay: String(s.themeDay ?? 'latte'), + themeNight: String(s.themeNight ?? 'mocha'), + timeDayStart: String(s.timeDayStart ?? '07:00'), + timeNightStart: String(s.timeNightStart ?? '19:00'), + }); + if (effective) document.documentElement.setAttribute('data-theme', effective); + } catch { + // Non-fatal — App's effects apply the theme after mount. + } +} + +/** + * Keep theme state in sync across webviews (main ↔ mini player). Zustand + * persist does not sync across windows on its own; the `storage` event fires in + * *other* windows when localStorage changes, so rehydrate the relevant store + * there. Installing/applying/uninstalling in one window then live-updates the + * other (App's effects re-run and re-apply `data-theme` / injected styles). + */ +export function installCrossWindowThemeSync(): void { + if (typeof window === 'undefined') return; + window.addEventListener('storage', (e) => { + if (e.key === 'psysonic_theme') void useThemeStore.persist?.rehydrate?.(); + else if (e.key === 'psysonic_installed_themes') void useInstalledThemesStore.persist?.rehydrate?.(); + }); +} + /** Mark the document in Vite dev so CSS can show dev-only chrome. */ export function markDevBuildDocument(): void { if (import.meta.env.DEV) { @@ -46,6 +107,12 @@ export function markDevBuildDocument(): void { export function runPreReactBootstrap(): void { // Pre-warm the window-kind cache so subsequent reads are sync + safe. getWindowKind(); + // Reset any persisted theme that is no longer bundled and not installed, so + // the store hydrates onto a paintable theme (no unstyled-:root flash). + migrateThemeSelection(); + // Paint the correct theme on the very first frame (no Mocha flash). + applyThemeAtStartup(); + installCrossWindowThemeSync(); markDevBuildDocument(); pushUserAgentToBackend(); pushLoggingModeToBackend(); diff --git a/src/components/BackToTopButton.tsx b/src/components/BackToTopButton.tsx new file mode 100644 index 00000000..64221570 --- /dev/null +++ b/src/components/BackToTopButton.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { ArrowUp } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; + +interface BackToTopButtonProps { + /** Id of the scroll viewport to watch/scroll. Defaults to the main route scroller. */ + viewportId?: string; + /** Show the button once the viewport is scrolled past this many pixels. */ + threshold?: number; +} + +/** + * A floating "back to top" affordance for long pages. Watches a scroll viewport + * (the overlay-scroll element wrapping the routes by default) and, once it is + * scrolled past `threshold`, shows a button that smooth-scrolls it back to the + * top — reusing the same `getElementById(...).scrollTo` pattern AppShell uses + * for its route-change scroll reset. + * + * The button is portalled into `.app-shell-route-host` and positioned + * `absolute` against it: the scroll viewport itself sets `contain: paint`, which + * would otherwise make a `position: fixed` child resolve against the *scrolling* + * box (so it would drift with the content). The route host is a non-contained, + * `position: relative` ancestor that spans exactly the content area (between the + * sidebar and queue, above the player bar), so the button stays pinned to the + * visible viewport corner regardless of scroll. + */ +export default function BackToTopButton({ + viewportId = APP_MAIN_SCROLL_VIEWPORT_ID, + threshold = 400, +}: BackToTopButtonProps) { + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const [host, setHost] = useState(null); + + useEffect(() => { + setHost(document.querySelector('.app-shell-route-host')); + const el = document.getElementById(viewportId); + if (!el) return; + const onScroll = () => setVisible(el.scrollTop > threshold); + onScroll(); // sync immediately (e.g. switching back to an already-scrolled tab) + el.addEventListener('scroll', onScroll, { passive: true }); + return () => el.removeEventListener('scroll', onScroll); + }, [viewportId, threshold]); + + if (!visible || !host) return null; + + return createPortal( + , + host, + ); +} diff --git a/src/components/CachedImage.tsx b/src/components/CachedImage.tsx index 48d48eed..082dea5b 100644 --- a/src/components/CachedImage.tsx +++ b/src/components/CachedImage.tsx @@ -247,7 +247,7 @@ export default function CachedImage({ }; const fallbackStyle: React.CSSProperties = isFallback - ? { objectFit: 'contain', background: 'var(--bg-card, var(--ctp-surface0, #313244))', padding: '15%' } + ? { objectFit: 'contain', background: 'var(--bg-card, var(--bg-card, #313244))', padding: '15%' } : {}; return ( diff --git a/src/components/LosslessFilterButton.tsx b/src/components/LosslessFilterButton.tsx index 3d87e87b..c6f6c5e3 100644 --- a/src/components/LosslessFilterButton.tsx +++ b/src/components/LosslessFilterButton.tsx @@ -10,7 +10,7 @@ interface Props { export default function LosslessFilterButton({ active, onChange }: Props) { const { t } = useTranslation(); const tooltip = active ? t('albums.losslessTooltipOn') : t('albums.losslessTooltipOff'); - const activeStyle = active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}; + const activeStyle = active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}; return ( - {isOpen && ( -
-
- {(() => { - let lastFamily: string | undefined; - return themes.map((t, i) => { - const isActive = value === t.id; - const showFamilyHeader = !!t.family && t.family !== lastFamily; - lastFamily = t.family; - return ( - - {showFamilyHeader && ( -
- {t.family} -
- )} - -
- ); - }); - })()} -
-
- )} - - ); - })} - - ); -} diff --git a/src/components/WaveformSeekPreview.tsx b/src/components/WaveformSeekPreview.tsx index 495af2f6..58d5680a 100644 --- a/src/components/WaveformSeekPreview.tsx +++ b/src/components/WaveformSeekPreview.tsx @@ -59,11 +59,11 @@ export function SeekbarPreview({ style, label, selected, onClick }: Props) { {lfmLoveEnabled && ( - + ))} diff --git a/src/components/settings/AnalyticsStrategySection.tsx b/src/components/settings/AnalyticsStrategySection.tsx index 5073a93f..ff5de3f7 100644 --- a/src/components/settings/AnalyticsStrategySection.tsx +++ b/src/components/settings/AnalyticsStrategySection.tsx @@ -427,7 +427,7 @@ export default function AnalyticsStrategySection() { role="note" style={{ marginTop: '0.85rem', display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }} > - + {t('settings.analyticsStrategyAdvancedWarning')} diff --git a/src/components/settings/AppearanceTab.tsx b/src/components/settings/AppearanceTab.tsx index 530093eb..9e24b817 100644 --- a/src/components/settings/AppearanceTab.tsx +++ b/src/components/settings/AppearanceTab.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { invoke } from '@tauri-apps/api/core'; -import { Clock, LayoutGrid, Palette, Sliders, Type, ZoomIn } from 'lucide-react'; +import { LayoutGrid, Palette, Sliders, Type, ZoomIn } from 'lucide-react'; import { useAuthStore } from '../../store/authStore'; import { LIBRARY_GRID_MAX_COLUMNS_MAX, @@ -11,13 +11,11 @@ import type { SeekbarStyle } from '../../store/authStoreTypes'; import { useFontStore, FontId } from '../../store/fontStore'; import { useThemeStore } from '../../store/themeStore'; import { IS_LINUX, IS_WINDOWS } from '../../utils/platform'; -import CustomSelect from '../CustomSelect'; import SettingsSubSection from '../SettingsSubSection'; -import ThemePicker, { THEME_GROUPS } from '../ThemePicker'; import { SeekbarPreview } from '../WaveformSeekPreview'; export function AppearanceTab() { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const auth = useAuthStore(); const theme = useThemeStore(); const fontStore = useFontStore(); @@ -30,88 +28,6 @@ export function AppearanceTab() { return ( <> - } - > -
- {theme.enableThemeScheduler && ( -
- {t('settings.themeSchedulerActiveHint')} -
- )} - theme.setTheme(v as any)} /> -
-
- - } - > -
-
-
-
{t('settings.themeSchedulerEnable')}
-
{t('settings.themeSchedulerEnableSub')}
-
- -
- {theme.enableThemeScheduler && (() => { - const themeOptions = THEME_GROUPS.flatMap(g => - g.themes.map(th => ({ - value: th.id, - label: th.family ? `${th.family} ${th.label}` : th.label, - group: g.group, - })) - ); - const use12h = i18n.language === 'en'; - const hourOptions = Array.from({ length: 24 }, (_, i) => { - const value = String(i).padStart(2, '0'); - const label = use12h - ? `${i % 12 === 0 ? 12 : i % 12} ${i < 12 ? 'AM' : 'PM'}` - : value; - return { value, label }; - }); - const minuteOptions = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'].map(m => ({ value: m, label: m })); - const dayH = theme.timeDayStart.split(':')[0]; - const dayM = theme.timeDayStart.split(':')[1]; - const nightH = theme.timeNightStart.split(':')[0]; - const nightM = theme.timeNightStart.split(':')[1]; - return ( -
-
- - -
-
- -
- theme.setTimeDayStart(`${v}:${dayM}`)} options={hourOptions} /> - : - theme.setTimeDayStart(`${dayH}:${v}`)} options={minuteOptions} /> -
-
-
- - -
-
- -
- theme.setTimeNightStart(`${v}:${nightM}`)} options={hourOptions} /> - : - theme.setTimeNightStart(`${nightH}:${v}`)} options={minuteOptions} /> -
-
-
- ); - })()} -
-
- } diff --git a/src/components/settings/InputTab.tsx b/src/components/settings/InputTab.tsx index 21e54cbb..1fb6a43a 100644 --- a/src/components/settings/InputTab.tsx +++ b/src/components/settings/InputTab.tsx @@ -74,7 +74,7 @@ export function InputTab() { minWidth: 72, padding: '3px 10px', borderRadius: 'var(--radius-sm)', fontSize: 12, fontWeight: 600, fontFamily: 'monospace', background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)', - color: isListening ? 'var(--ctp-base)' : bound ? 'var(--text-primary)' : 'var(--text-muted)', + color: isListening ? 'var(--bg-app)' : bound ? 'var(--text-primary)' : 'var(--text-muted)', border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`, cursor: 'pointer', }} @@ -156,7 +156,7 @@ export function InputTab() { minWidth: 120, padding: '3px 10px', borderRadius: 'var(--radius-sm)', fontSize: 12, fontWeight: 600, fontFamily: 'monospace', background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)', - color: isListening ? 'var(--ctp-base)' : bound ? 'var(--text-primary)' : 'var(--text-muted)', + color: isListening ? 'var(--bg-app)' : bound ? 'var(--text-primary)' : 'var(--text-muted)', border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`, cursor: 'pointer', }} diff --git a/src/components/settings/InstalledThemes.tsx b/src/components/settings/InstalledThemes.tsx new file mode 100644 index 00000000..ca8c564a --- /dev/null +++ b/src/components/settings/InstalledThemes.tsx @@ -0,0 +1,137 @@ +import { Check, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useThemeStore } from '../../store/themeStore'; +import { useInstalledThemesStore } from '../../store/installedThemesStore'; +import { uninstallTheme } from '../../utils/themes/uninstallTheme'; +import { FIXED_THEMES } from './fixedThemes'; + +/** Pull a 3-band swatch (bg / card / accent) out of an installed theme's CSS. */ +function swatch(css: string): { bg: string; card: string; accent: string } { + const read = (name: string, fallback: string) => { + const m = css.match(new RegExp(`--${name}\\s*:\\s*([^;]+);`)); + return m ? m[1].trim() : fallback; + }; + return { + bg: read('bg-app', '#1e1e2e'), + card: read('bg-card', '#313244'), + accent: read('accent', '#cba6f7'), + }; +} + +interface Card { + id: string; + label: string; + bg: string; + card: string; + accent: string; + fixed: boolean; + accessibility: boolean; +} + +/** + * Flat card grid of the user's available themes: the fixed cores plus every + * installed community theme. Click a card to apply it; community themes carry an + * uninstall control (the fixed cores do not). No accordion — this page is only + * about themes, so everything is shown at once. + */ +export function InstalledThemes() { + const { t } = useTranslation(); + const active = useThemeStore(s => s.theme); + const setTheme = useThemeStore(s => s.setTheme); + const installed = useInstalledThemesStore(s => s.themes); + + const cards: Card[] = [ + ...FIXED_THEMES.map(f => ({ id: f.id, label: f.label, bg: f.bg, card: f.card, accent: f.accent, fixed: true, accessibility: !!f.accessibility })), + ...installed.map(it => { + const s = swatch(it.css); + return { id: it.id, label: it.name, bg: s.bg, card: s.card, accent: s.accent, fixed: false, accessibility: (it.tags || []).includes('accessibility') }; + }), + ]; + + return ( +
+
+ {cards.map(c => { + const isActive = active === c.id; + return ( +
+ + {!c.fixed && ( + + )} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/settings/ServersTab.tsx b/src/components/settings/ServersTab.tsx index f654320d..af0b0840 100644 --- a/src/components/settings/ServersTab.tsx +++ b/src/components/settings/ServersTab.tsx @@ -400,7 +400,7 @@ export function ServersTab({
{serverListDisplayLabel(srv, auth.servers)} {isActive && ( - + {t('settings.serverActive')} )} @@ -500,7 +500,7 @@ export function ServersTab({ {!!auth.audiomuseNavidromeByServer[srv.id] && auth.audiomuseNavidromeIssueByServer[srv.id] && ( diff --git a/src/components/settings/ThemeStoreSection.tsx b/src/components/settings/ThemeStoreSection.tsx new file mode 100644 index 00000000..db35ab32 --- /dev/null +++ b/src/components/settings/ThemeStoreSection.tsx @@ -0,0 +1,306 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Check, Download, RefreshCw, Trash2 } from 'lucide-react'; +import { open as openUrl } from '@tauri-apps/plugin-shell'; +import CoverLightbox from '../CoverLightbox'; +import { useThemeStore } from '../../store/themeStore'; +import { useInstalledThemesStore, type InstalledTheme } from '../../store/installedThemesStore'; +import { + cdnUrl, + fetchRegistry, + fetchThemeCss, + type RegistryTheme, +} from '../../utils/themes/themeRegistry'; +import { validateThemeCss } from '../../utils/themes/themeInjection'; +import { uninstallTheme } from '../../utils/themes/uninstallTheme'; +import { isNewer } from '../../utils/componentHelpers/appUpdaterHelpers'; + +type ModeFilter = 'all' | 'dark' | 'light'; + +const THEMES_REPO_URL = 'https://github.com/Psysonic/psysonic-themes'; + +/** + * The community Theme Store: browse the jsDelivr-hosted registry, filter by name + * and light/dark, install (fetch + persist + runtime inject), apply, update and + * uninstall. Built-in themes are not in the registry, so they never appear here. + */ +export function ThemeStoreSection() { + const { t } = useTranslation(); + const activeTheme = useThemeStore(s => s.theme); + const setTheme = useThemeStore(s => s.setTheme); + const installed = useInstalledThemesStore(s => s.themes); + const install = useInstalledThemesStore(s => s.install); + + const [themes, setThemes] = useState(null); + const [generatedAt, setGeneratedAt] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [stale, setStale] = useState(false); + const [query, setQuery] = useState(''); + const [mode, setMode] = useState('all'); + const [busyId, setBusyId] = useState(null); + const [failedId, setFailedId] = useState(null); + const [lightbox, setLightbox] = useState<{ src: string; name: string } | null>(null); + + const load = (force = false) => { + setLoading(true); + setError(false); + fetchRegistry({ force }) + .then(r => { setThemes(r.registry.themes); setGeneratedAt(r.registry.generatedAt); setStale(r.stale); }) + .catch(() => setError(true)) + .finally(() => setLoading(false)); + }; + + // Thumbnails live at a stable CDN path, so the webview caches them hard + // (jsDelivr sends max-age 7d). Tie a cache-buster to the registry's + // generatedAt — it changes on every themes push — so refreshed thumbnails + // show up after a registry refresh instead of being stuck on the old image. + const thumbUrl = (rel: string) => + generatedAt ? `${cdnUrl(rel)}?v=${encodeURIComponent(generatedAt)}` : cdnUrl(rel); + + useEffect(() => { load(false); }, []); + + const installedMap = useMemo(() => { + const m = new Map(); + for (const it of installed) m.set(it.id, it); + return m; + }, [installed]); + + const filtered = useMemo(() => { + if (!themes) return []; + const q = query.trim().toLowerCase(); + return themes.filter(th => { + if (mode !== 'all' && th.mode !== mode) return false; + if (!q) return true; + return ( + th.name.toLowerCase().includes(q) || + th.author.toLowerCase().includes(q) || + th.description.toLowerCase().includes(q) || + (th.tags || []).some(tag => tag.includes(q)) + ); + }).sort((a, b) => a.name.localeCompare(b.name)); + }, [themes, query, mode]); + + const handleInstall = async (th: RegistryTheme) => { + setBusyId(th.id); + setFailedId(null); + try { + const css = await fetchThemeCss(th.css); + // Don't persist CSS that won't inject — otherwise the theme would show as + // installed/active but render nothing. Validate before storing. + if (validateThemeCss(css, th.id) == null) { + setFailedId(th.id); + return; + } + install({ + id: th.id, + name: th.name, + author: th.author, + version: th.version, + description: th.description, + mode: th.mode, + tags: th.tags, + css, + installedAt: Date.now(), + }); + } catch { + setFailedId(th.id); + } finally { + setBusyId(null); + } + }; + + + const modeBtns: { key: ModeFilter; label: string }[] = [ + { key: 'all', label: t('settings.themeStoreModeAll') }, + { key: 'dark', label: t('settings.themeStoreModeDark') }, + { key: 'light', label: t('settings.themeStoreModeLight') }, + ]; + + return ( +
+ {/* Submit-your-own-theme hint */} +
+ {t('settings.themeStoreSubmitText')}{' '} + +
+ + {/* Toolbar: search + mode filter + refresh */} +
+ setQuery(e.target.value)} + placeholder={t('settings.themeStoreSearchPlaceholder')} + aria-label={t('settings.themeStoreSearchPlaceholder')} + style={{ flex: '1 1 180px', minWidth: 140 }} + /> +
+ {modeBtns.map(b => ( + + ))} +
+ +
+ + {!loading && stale && ( +
+ {t('settings.themeStoreOffline')} +
+ )} + + {loading && ( +

{t('settings.themeStoreLoading')}

+ )} + + {!loading && error && ( +
+

{t('settings.themeStoreError')}

+ +
+ )} + + {!loading && !error && filtered.length === 0 && ( +

{t('settings.themeStoreEmpty')}

+ )} + + {!loading && !error && filtered.length > 0 && ( +
+ {filtered.map(th => { + const inst = installedMap.get(th.id); + const isInstalled = !!inst; + const updateAvailable = isInstalled && isNewer(th.version, inst!.version); + const isActive = activeTheme === th.id; + const busy = busyId === th.id; + return ( +
+ +
+
+ {th.name} + {isActive && ( + + {t('settings.themeStoreActive')} + + )} +
+
+ {t('settings.themeStoreByAuthor', { author: th.author })} +
+
+ {th.description} +
+ {/* Rating slot reserved — see Theme Store roadmap (deferred). */} +
+ {!isInstalled && ( + + )} + {isInstalled && !isActive && ( + + )} + {updateAvailable && ( + + )} + {isInstalled && ( + + )} + {failedId === th.id && ( + + {t('settings.themeStoreInstallFailed')} + + )} +
+
+
+ ); + })} +
+ )} + + {lightbox && ( + setLightbox(null)} /> + )} +
+ ); +} diff --git a/src/components/settings/ThemesTab.tsx b/src/components/settings/ThemesTab.tsx new file mode 100644 index 00000000..7971efbc --- /dev/null +++ b/src/components/settings/ThemesTab.tsx @@ -0,0 +1,123 @@ +import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Clock, Palette, Store } from 'lucide-react'; +import { useThemeStore } from '../../store/themeStore'; +import { useInstalledThemesStore } from '../../store/installedThemesStore'; +import CustomSelect from '../CustomSelect'; +import BackToTopButton from '../BackToTopButton'; +import { FIXED_THEMES } from './fixedThemes'; +import { InstalledThemes } from './InstalledThemes'; +import { ThemeStoreSection } from './ThemeStoreSection'; + +/** + * A flat, always-visible section. The Themes tab has a single purpose, so its + * parts are laid out one below the other with no collapsing — deliberately not + * the collapsible
SettingsSubSection used elsewhere. `data-settings- + * search` keeps each section reachable from the global settings search. + */ +function ThemesSection({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) { + return ( +
+

+ {icon} + {title} +

+ {children} +
+ ); +} + +/** + * Dedicated Themes tab: pick a theme (fixed cores + installed community themes), + * the day/night scheduler, and the community Theme Store — all flat on one page. + */ +export function ThemesTab() { + const { t, i18n } = useTranslation(); + const theme = useThemeStore(); + const installed = useInstalledThemesStore(s => s.themes); + + return ( + <> + } title={t('settings.themesYourThemesTitle')}> + {theme.enableThemeScheduler && ( +
+ {t('settings.themeSchedulerActiveHint')} +
+ )} + +
+ + } title={t('settings.themeSchedulerTitle')}> +
+
+
+
{t('settings.themeSchedulerEnable')}
+
{t('settings.themeSchedulerEnableSub')}
+
+ +
+ {theme.enableThemeScheduler && (() => { + const themeOptions = [ + ...FIXED_THEMES.map(f => ({ value: f.id, label: f.label })), + ...installed.map(it => ({ + value: it.id, + label: it.name, + group: t('settings.themesYourThemesTitle'), + })), + ]; + const use12h = i18n.language === 'en'; + const hourOptions = Array.from({ length: 24 }, (_, i) => { + const value = String(i).padStart(2, '0'); + const label = use12h + ? `${i % 12 === 0 ? 12 : i % 12} ${i < 12 ? 'AM' : 'PM'}` + : value; + return { value, label }; + }); + const minuteOptions = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'].map(m => ({ value: m, label: m })); + const dayH = theme.timeDayStart.split(':')[0]; + const dayM = theme.timeDayStart.split(':')[1]; + const nightH = theme.timeNightStart.split(':')[0]; + const nightM = theme.timeNightStart.split(':')[1]; + return ( +
+
+ + +
+
+ +
+ theme.setTimeDayStart(`${v}:${dayM}`)} options={hourOptions} /> + : + theme.setTimeDayStart(`${dayH}:${v}`)} options={minuteOptions} /> +
+
+
+ + +
+
+ +
+ theme.setTimeNightStart(`${v}:${nightM}`)} options={hourOptions} /> + : + theme.setTimeNightStart(`${nightH}:${v}`)} options={minuteOptions} /> +
+
+
+ ); + })()} +
+
+ + } title={t('settings.themeStoreTitle')}> + + + + + + ); +} diff --git a/src/components/settings/UserForm.tsx b/src/components/settings/UserForm.tsx index 6253bbab..c9cb93b3 100644 --- a/src/components/settings/UserForm.tsx +++ b/src/components/settings/UserForm.tsx @@ -294,8 +294,8 @@ export function UserForm({ marginBottom: 10, padding: '8px 10px', borderRadius: 6, - border: '1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent)', - background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 10%, transparent)', + border: '1px solid color-mix(in srgb, var(--warning, #f59e0b) 35%, transparent)', + background: 'color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent)', color: 'var(--text-primary)', }} > @@ -326,8 +326,8 @@ export function UserForm({ marginBottom: 10, padding: '8px 10px', borderRadius: 6, - border: '1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent)', - background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 10%, transparent)', + border: '1px solid color-mix(in srgb, var(--warning, #f59e0b) 35%, transparent)', + background: 'color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent)', color: 'var(--text-primary)', }} > diff --git a/src/components/settings/fixedThemes.ts b/src/components/settings/fixedThemes.ts new file mode 100644 index 00000000..dfdfdf95 --- /dev/null +++ b/src/components/settings/fixedThemes.ts @@ -0,0 +1,23 @@ +/** + * The fixed, bundled core themes — always present, never uninstallable. Every + * other palette lives in the community Theme Store and is applied by id once + * installed. `bg`/`card`/`accent` drive the 3-band swatch preview. + */ +export interface FixedTheme { + id: string; + label: string; + bg: string; + card: string; + accent: string; + /** Colour-blind-safe accessibility theme — flagged with a badge in the UI. */ + accessibility?: boolean; +} + +export const FIXED_THEMES: FixedTheme[] = [ + { id: 'mocha', label: 'Catppuccin Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7' }, + { id: 'latte', label: 'Catppuccin Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef' }, + { id: 'kanagawa-wave', label: 'Kanagawa Wave', bg: '#1F1F28', card: '#2A2A37', accent: '#7E9CD8' }, + { id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' }, + { id: 'vision-dark', label: 'Vision Dark', bg: '#0d0b12', card: '#16131e', accent: '#ffd700', accessibility: true }, + { id: 'vision-navy', label: 'Vision Navy', bg: '#0a1628', card: '#112038', accent: '#ffd700', accessibility: true }, +]; diff --git a/src/components/settings/settingsTabs.ts b/src/components/settings/settingsTabs.ts index 2cddae80..b70b7efc 100644 --- a/src/components/settings/settingsTabs.ts +++ b/src/components/settings/settingsTabs.ts @@ -4,6 +4,7 @@ export type Tab = | 'audio' | 'lyrics' | 'appearance' + | 'themes' | 'personalisation' | 'integrations' | 'input' @@ -23,7 +24,7 @@ export function resolveTab(input: string | undefined | null): Tab { if (!input) return 'servers'; const aliased = LEGACY_TAB_ALIAS[input]; if (aliased) return aliased; - const known: Tab[] = ['library', 'servers', 'audio', 'lyrics', 'appearance', 'personalisation', 'integrations', 'input', 'storage', 'system', 'users']; + const known: Tab[] = ['library', 'servers', 'audio', 'lyrics', 'appearance', 'themes', 'personalisation', 'integrations', 'input', 'storage', 'system', 'users']; return (known as string[]).includes(input) ? (input as Tab) : 'servers'; } @@ -59,8 +60,9 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [ { tab: 'storage', titleKey: 'settings.mediaDirTitle', keywords: 'media folder offline library cache directory local playback' }, { tab: 'storage', titleKey: 'settings.nextTrackBufferingTitle', keywords: 'next track buffering hot cache streaming' }, { tab: 'storage', titleKey: 'settings.downloadsTitle', keywords: 'downloads zip export archive folder' }, - { tab: 'appearance', titleKey: 'settings.theme', keywords: 'theme color palette dark light' }, - { tab: 'appearance', titleKey: 'settings.themeSchedulerTitle', keywords: 'theme scheduler auto time dark mode sunset' }, + { tab: 'themes', titleKey: 'settings.themesYourThemesTitle', keywords: 'theme color palette dark light install uninstall apply your' }, + { tab: 'themes', titleKey: 'settings.themeSchedulerTitle', keywords: 'theme scheduler auto time dark mode sunset' }, + { tab: 'themes', titleKey: 'settings.themeStoreTitle', keywords: 'theme store community download install browse marketplace' }, { tab: 'appearance', titleKey: 'settings.visualOptionsTitle', keywords: 'visual options animations effects titlebar mini player' }, { tab: 'appearance', titleKey: 'settings.uiScaleTitle', keywords: 'ui scale zoom dpi size' }, { tab: 'appearance', titleKey: 'settings.font', keywords: 'font typography typeface' }, diff --git a/src/components/settings/userMgmt/MagicStringModal.tsx b/src/components/settings/userMgmt/MagicStringModal.tsx index 1ed6af6a..c07ab3b2 100644 --- a/src/components/settings/userMgmt/MagicStringModal.tsx +++ b/src/components/settings/userMgmt/MagicStringModal.tsx @@ -126,8 +126,8 @@ export function MagicStringModal({ marginBottom: '1rem', padding: '8px 10px', borderRadius: 6, - border: '1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent)', - background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 10%, transparent)', + border: '1px solid color-mix(in srgb, var(--warning, #f59e0b) 35%, transparent)', + background: 'color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent)', color: 'var(--text-primary)', }} > diff --git a/src/components/settings/userMgmt/UserMgmtRow.tsx b/src/components/settings/userMgmt/UserMgmtRow.tsx index b7e0dfa3..82d64502 100644 --- a/src/components/settings/userMgmt/UserMgmtRow.tsx +++ b/src/components/settings/userMgmt/UserMgmtRow.tsx @@ -75,13 +75,13 @@ export function UserMgmtRow({ · {u.name} )} {isSelf && ( - + {t('settings.userMgmtYouBadge')} )} {u.isAdmin && ( diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 924778e8..cd14119f 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -356,6 +356,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Performance Probe: Monitor/Toggles redesign, live CPU/RSS/thread metrics, overlay pins and sparklines, macOS snapshots (PR #890)', 'Performance Probe: opt-in thread-group CPU poll toggle and includeThreadGroups IPC fix (PR #891)', 'Queue: switchable display mode — Queue (upcoming only) vs Playlist (full list), with header toggle and settings entry (PR #922)', + 'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, and 80+ palettes moved to an on-demand CDN repo (PR #1009)', ], }, { diff --git a/src/contexts/DragDropContext.tsx b/src/contexts/DragDropContext.tsx index e6dd4f89..26ae32c7 100644 --- a/src/contexts/DragDropContext.tsx +++ b/src/contexts/DragDropContext.tsx @@ -122,7 +122,7 @@ function DragGhost({ state }: { state: DragState }) { size={16} strokeWidth={2.25} aria-hidden - style={{ flexShrink: 0, color: 'var(--danger, var(--ctp-red, #f38ba8))' }} + style={{ flexShrink: 0, color: 'var(--danger, var(--danger, #f38ba8))' }} /> )} {coverUrl && ( diff --git a/src/locales/de/common.ts b/src/locales/de/common.ts index 777d4ad8..e79a12e1 100644 --- a/src/locales/de/common.ts +++ b/src/locales/de/common.ts @@ -8,6 +8,7 @@ export const common = { downloading: 'Lade…', downloadZip: 'Download (ZIP)', back: 'Zurück', + backToTop: 'Nach oben', cancel: 'Abbrechen', close: 'Schließen', save: 'Speichern', diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index 9bb76943..d0fb7d22 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -321,6 +321,36 @@ export const settings = { tabAudio: 'Audio', tabStorage: 'Offline & Cache', tabAppearance: 'Darstellung', + tabThemes: 'Themes', + themesYourThemesTitle: 'Deine Themes', + themesCvdTooltip: 'Farbfehlsichtigkeits-sicher – Deuteranopie, Protanopie, Tritanopie', + themeStoreTitle: 'Theme-Store', + themeStoreSubmitText: 'Eigenes Theme gebaut? Teil es mit der Community — mehr Infos im Themes-Repository.', + themeStoreSubmitLink: 'Themes-Repository öffnen', + themeStoreSearchPlaceholder: 'Themes suchen…', + themeStoreFilterMode: 'Nach Modus filtern', + themeStoreModeAll: 'Alle', + themeStoreModeDark: 'Dunkel', + themeStoreModeLight: 'Hell', + themeStoreRefresh: 'Aktualisieren', + themeStoreLoading: 'Themes werden geladen…', + themeStoreError: 'Theme-Store konnte nicht geladen werden. Prüfe deine Verbindung.', + themeStoreRetry: 'Erneut versuchen', + themeStoreEmpty: 'Keine Themes passen zu deinen Filtern.', + themeStoreActive: 'Aktiv', + themeStoreEnlarge: 'Vorschau vergrößern', + themeStoreByAuthor: 'von {{author}}', + themeMigrationNoticeTitle: 'Theme in den Store gezogen', + themeMigrationNoticeBody: '{{themes}} ist nicht mehr in der App enthalten. Du kannst es im Theme-Store neu installieren (Einstellungen → Themes) oder ein anderes Theme wählen.', + themeMigrationNoticeOpen: 'Theme-Store öffnen', + themeStoreOffline: 'Zeige den zuletzt gecachten Katalog — du scheinst offline zu sein.', + themeStoreInstall: 'Installieren', + themeStoreInstalling: 'Installiere…', + themeStoreApply: 'Anwenden', + themeStoreUpdate: 'Aktualisieren', + themeStoreUpdating: 'Aktualisiere…', + themeStoreUninstall: 'Deinstallieren', + themeStoreInstallFailed: 'Installation fehlgeschlagen', tabLibrary: 'Bibliothek', tabServers: 'Server', tabLyrics: 'Songtexte', diff --git a/src/locales/en/common.ts b/src/locales/en/common.ts index f8480d57..62d50258 100644 --- a/src/locales/en/common.ts +++ b/src/locales/en/common.ts @@ -8,6 +8,7 @@ export const common = { downloading: 'Downloading…', downloadZip: 'Download (ZIP)', back: 'Back', + backToTop: 'Back to top', cancel: 'Cancel', close: 'Close', save: 'Save', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index 39fce797..cd6fe5be 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -388,6 +388,36 @@ export const settings = { tabAudio: 'Audio', tabStorage: 'Offline & Cache', tabAppearance: 'Appearance', + tabThemes: 'Themes', + themesYourThemesTitle: 'Your Themes', + themesCvdTooltip: 'Colour-blind safe — deuteranopia, protanopia, tritanopia', + themeStoreTitle: 'Theme Store', + themeStoreSubmitText: 'Made your own theme? Share it with the community — more info in the themes repository.', + themeStoreSubmitLink: 'Open the themes repository', + themeStoreSearchPlaceholder: 'Search themes…', + themeStoreFilterMode: 'Filter by mode', + themeStoreModeAll: 'All', + themeStoreModeDark: 'Dark', + themeStoreModeLight: 'Light', + themeStoreRefresh: 'Refresh', + themeStoreLoading: 'Loading themes…', + themeStoreError: 'Could not load the theme store. Check your connection.', + themeStoreRetry: 'Retry', + themeStoreEmpty: 'No themes match your filters.', + themeStoreActive: 'Active', + themeStoreEnlarge: 'Enlarge preview', + themeStoreByAuthor: 'by {{author}}', + themeMigrationNoticeTitle: 'Theme moved to the Store', + themeMigrationNoticeBody: '{{themes}} is no longer bundled with the app. You can reinstall it from the Theme Store (Settings → Themes), or pick another theme.', + themeMigrationNoticeOpen: 'Open Theme Store', + themeStoreOffline: 'Showing the last cached catalogue — you appear to be offline.', + themeStoreInstall: 'Install', + themeStoreInstalling: 'Installing…', + themeStoreApply: 'Apply', + themeStoreUpdate: 'Update', + themeStoreUpdating: 'Updating…', + themeStoreUninstall: 'Uninstall', + themeStoreInstallFailed: 'Install failed', tabLibrary: 'Library', tabServers: 'Servers', tabLyrics: 'Lyrics', diff --git a/src/locales/es/common.ts b/src/locales/es/common.ts index df5c55ed..ff753a4b 100644 --- a/src/locales/es/common.ts +++ b/src/locales/es/common.ts @@ -8,6 +8,7 @@ export const common = { downloading: 'Descargando…', downloadZip: 'Descargar (ZIP)', back: 'Volver', + backToTop: 'Volver arriba', cancel: 'Cancelar', save: 'Guardar', delete: 'Eliminar', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index b9a6c299..0f281b9b 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -319,6 +319,36 @@ export const settings = { tabAudio: 'Audio', tabStorage: 'Sin conexión y caché', tabAppearance: 'Apariencia', + tabThemes: 'Temas', + themesYourThemesTitle: 'Tus temas', + themesCvdTooltip: 'Apto para daltonismo: deuteranopía, protanopía, tritanopía', + themeStoreTitle: 'Tienda de temas', + themeStoreSubmitText: '¿Has creado tu propio tema? Compártelo con la comunidad — más información en el repositorio de temas.', + themeStoreSubmitLink: 'Abrir el repositorio de temas', + themeStoreSearchPlaceholder: 'Buscar temas…', + themeStoreFilterMode: 'Filtrar por modo', + themeStoreModeAll: 'Todos', + themeStoreModeDark: 'Oscuro', + themeStoreModeLight: 'Claro', + themeStoreRefresh: 'Actualizar', + themeStoreLoading: 'Cargando temas…', + themeStoreError: 'No se pudo cargar la tienda de temas. Comprueba tu conexión.', + themeStoreRetry: 'Reintentar', + themeStoreEmpty: 'Ningún tema coincide con tus filtros.', + themeStoreActive: 'Activo', + themeStoreEnlarge: 'Ampliar vista previa', + themeStoreByAuthor: 'por {{author}}', + themeMigrationNoticeTitle: 'Tema movido a la tienda', + themeMigrationNoticeBody: '{{themes}} ya no viene incluido en la app. Puedes reinstalarlo desde la Tienda de Temas (Ajustes → Temas) o elegir otro tema.', + themeMigrationNoticeOpen: 'Abrir Tienda de Temas', + themeStoreOffline: 'Mostrando el último catálogo en caché: parece que estás sin conexión.', + themeStoreInstall: 'Instalar', + themeStoreInstalling: 'Instalando…', + themeStoreApply: 'Aplicar', + themeStoreUpdate: 'Actualizar', + themeStoreUpdating: 'Actualizando…', + themeStoreUninstall: 'Desinstalar', + themeStoreInstallFailed: 'Error al instalar', tabLibrary: 'Biblioteca', tabServers: 'Servidores', tabLyrics: 'Letras', diff --git a/src/locales/fr/common.ts b/src/locales/fr/common.ts index 9da49c6d..f417d8de 100644 --- a/src/locales/fr/common.ts +++ b/src/locales/fr/common.ts @@ -8,6 +8,7 @@ export const common = { downloading: 'Téléchargement…', downloadZip: 'Télécharger (ZIP)', back: 'Retour', + backToTop: 'Retour en haut', cancel: 'Annuler', save: 'Enregistrer', delete: 'Supprimer', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index 434c8f68..5107cf6a 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -317,6 +317,36 @@ export const settings = { tabAudio: 'Audio', tabStorage: 'Hors ligne & Cache', tabAppearance: 'Apparence', + tabThemes: 'Thèmes', + themesYourThemesTitle: 'Vos thèmes', + themesCvdTooltip: 'Adapté au daltonisme — deutéranopie, protanopie, tritanopie', + themeStoreTitle: 'Boutique de thèmes', + themeStoreSubmitText: "Vous avez créé votre propre thème ? Partagez-le avec la communauté — plus d'infos dans le dépôt de thèmes.", + themeStoreSubmitLink: 'Ouvrir le dépôt de thèmes', + themeStoreSearchPlaceholder: 'Rechercher des thèmes…', + themeStoreFilterMode: 'Filtrer par mode', + themeStoreModeAll: 'Tous', + themeStoreModeDark: 'Sombre', + themeStoreModeLight: 'Clair', + themeStoreRefresh: 'Actualiser', + themeStoreLoading: 'Chargement des thèmes…', + themeStoreError: 'Impossible de charger la boutique de thèmes. Vérifiez votre connexion.', + themeStoreRetry: 'Réessayer', + themeStoreEmpty: 'Aucun thème ne correspond à vos filtres.', + themeStoreActive: 'Actif', + themeStoreEnlarge: "Agrandir l'aperçu", + themeStoreByAuthor: 'par {{author}}', + themeMigrationNoticeTitle: 'Thème déplacé vers la boutique', + themeMigrationNoticeBody: "{{themes}} n'est plus inclus dans l'application. Vous pouvez le réinstaller depuis la boutique de thèmes (Paramètres → Thèmes) ou choisir un autre thème.", + themeMigrationNoticeOpen: 'Ouvrir la boutique de thèmes', + themeStoreOffline: 'Affichage du dernier catalogue en cache — vous semblez hors ligne.', + themeStoreInstall: 'Installer', + themeStoreInstalling: 'Installation…', + themeStoreApply: 'Appliquer', + themeStoreUpdate: 'Mettre à jour', + themeStoreUpdating: 'Mise à jour…', + themeStoreUninstall: 'Désinstaller', + themeStoreInstallFailed: "Échec de l'installation", tabLibrary: 'Bibliothèque', tabServers: 'Serveurs', tabLyrics: 'Paroles', diff --git a/src/locales/nb/common.ts b/src/locales/nb/common.ts index dadfde40..f99ba76e 100644 --- a/src/locales/nb/common.ts +++ b/src/locales/nb/common.ts @@ -8,6 +8,7 @@ export const common = { downloading: 'Laster ned…', downloadZip: 'Last ned (ZIP)', back: 'Tilbake', + backToTop: 'Til toppen', cancel: 'Avbryt', save: 'Lagre', delete: 'Slett', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index 44bdbb43..268b8157 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -320,6 +320,36 @@ export const settings = { tabPersonalisation: 'Personalisering', tabIntegrations: 'Integrasjoner', tabAppearance: 'Utseende', + tabThemes: 'Temaer', + themesYourThemesTitle: 'Dine temaer', + themesCvdTooltip: 'Fargeblind-sikker – deuteranopi, protanopi, tritanopi', + themeStoreTitle: 'Temabutikk', + themeStoreSubmitText: 'Laget ditt eget tema? Del det med fellesskapet — mer info i tema-repoet.', + themeStoreSubmitLink: 'Åpne tema-repoet', + themeStoreSearchPlaceholder: 'Søk i temaer…', + themeStoreFilterMode: 'Filtrer etter modus', + themeStoreModeAll: 'Alle', + themeStoreModeDark: 'Mørk', + themeStoreModeLight: 'Lys', + themeStoreRefresh: 'Oppdater', + themeStoreLoading: 'Laster temaer…', + themeStoreError: 'Kunne ikke laste temabutikken. Sjekk tilkoblingen din.', + themeStoreRetry: 'Prøv igjen', + themeStoreEmpty: 'Ingen temaer samsvarer med filtrene dine.', + themeStoreActive: 'Aktiv', + themeStoreEnlarge: 'Forstørr forhåndsvisning', + themeStoreByAuthor: 'av {{author}}', + themeMigrationNoticeTitle: 'Temaet er flyttet til butikken', + themeMigrationNoticeBody: '{{themes}} følger ikke lenger med appen. Du kan installere det på nytt fra temabutikken (Innstillinger → Temaer), eller velge et annet tema.', + themeMigrationNoticeOpen: 'Åpne temabutikken', + themeStoreOffline: 'Viser den sist bufrede katalogen — du ser ut til å være frakoblet.', + themeStoreInstall: 'Installer', + themeStoreInstalling: 'Installerer…', + themeStoreApply: 'Bruk', + themeStoreUpdate: 'Oppdater', + themeStoreUpdating: 'Oppdaterer…', + themeStoreUninstall: 'Avinstaller', + themeStoreInstallFailed: 'Installasjonen mislyktes', tabStorage: 'Frakoblet & Cache', inputKeybindingsTitle: 'Tastatursnarveier', aboutContributorsCount_one: '{{count}} bidrag', diff --git a/src/locales/nl/common.ts b/src/locales/nl/common.ts index a5f74158..5d26233b 100644 --- a/src/locales/nl/common.ts +++ b/src/locales/nl/common.ts @@ -8,6 +8,7 @@ export const common = { downloading: 'Downloaden…', downloadZip: 'Downloaden (ZIP)', back: 'Terug', + backToTop: 'Terug naar boven', cancel: 'Annuleren', save: 'Opslaan', delete: 'Verwijderen', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index 198a417e..118b02ef 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -317,6 +317,36 @@ export const settings = { tabAudio: 'Audio', tabStorage: 'Offline & Cache', tabAppearance: 'Weergave', + tabThemes: "Thema's", + themesYourThemesTitle: "Jouw thema's", + themesCvdTooltip: 'Kleurenblind-veilig – deuteranopie, protanopie, tritanopie', + themeStoreTitle: 'Themawinkel', + themeStoreSubmitText: 'Eigen thema gemaakt? Deel het met de community — meer info in de themarepository.', + themeStoreSubmitLink: 'Themarepository openen', + themeStoreSearchPlaceholder: "Thema's zoeken…", + themeStoreFilterMode: 'Filteren op modus', + themeStoreModeAll: 'Alle', + themeStoreModeDark: 'Donker', + themeStoreModeLight: 'Licht', + themeStoreRefresh: 'Vernieuwen', + themeStoreLoading: "Thema's laden…", + themeStoreError: 'Kan de themawinkel niet laden. Controleer je verbinding.', + themeStoreRetry: 'Opnieuw proberen', + themeStoreEmpty: "Geen thema's komen overeen met je filters.", + themeStoreActive: 'Actief', + themeStoreEnlarge: 'Voorbeeld vergroten', + themeStoreByAuthor: 'door {{author}}', + themeMigrationNoticeTitle: 'Thema verplaatst naar de store', + themeMigrationNoticeBody: '{{themes}} wordt niet meer met de app meegeleverd. Je kunt het opnieuw installeren via de Theme Store (Instellingen → Thema\'s) of een ander thema kiezen.', + themeMigrationNoticeOpen: 'Theme Store openen', + themeStoreOffline: 'De laatst gecachte catalogus wordt getoond — je lijkt offline te zijn.', + themeStoreInstall: 'Installeren', + themeStoreInstalling: 'Installeren…', + themeStoreApply: 'Toepassen', + themeStoreUpdate: 'Bijwerken', + themeStoreUpdating: 'Bijwerken…', + themeStoreUninstall: 'Verwijderen', + themeStoreInstallFailed: 'Installatie mislukt', tabLibrary: 'Bibliotheek', tabServers: 'Servers', tabLyrics: 'Songteksten', diff --git a/src/locales/ro/common.ts b/src/locales/ro/common.ts index 0c0378d2..c15aa259 100644 --- a/src/locales/ro/common.ts +++ b/src/locales/ro/common.ts @@ -8,6 +8,7 @@ export const common = { downloading: 'Se descarcă…', downloadZip: 'Descarcă (ZIP)', back: 'Înapoi', + backToTop: 'Înapoi sus', cancel: 'Anulează', close: 'Închide', save: 'Salvează', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index 3d5f94c1..0896c35d 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -323,6 +323,36 @@ export const settings = { tabAudio: 'Audio', tabStorage: 'Offline & Cache', tabAppearance: 'Aparență', + tabThemes: 'Teme', + themesYourThemesTitle: 'Temele tale', + themesCvdTooltip: 'Sigur pentru daltonism – deuteranopie, protanopie, tritanopie', + themeStoreTitle: 'Magazin de teme', + themeStoreSubmitText: 'Ți-ai creat propria temă? Împărtășește-o cu comunitatea — mai multe informații în depozitul de teme.', + themeStoreSubmitLink: 'Deschide depozitul de teme', + themeStoreSearchPlaceholder: 'Caută teme…', + themeStoreFilterMode: 'Filtrează după mod', + themeStoreModeAll: 'Toate', + themeStoreModeDark: 'Întunecat', + themeStoreModeLight: 'Luminos', + themeStoreRefresh: 'Reîmprospătează', + themeStoreLoading: 'Se încarcă temele…', + themeStoreError: 'Magazinul de teme nu a putut fi încărcat. Verifică conexiunea.', + themeStoreRetry: 'Reîncearcă', + themeStoreEmpty: 'Nicio temă nu corespunde filtrelor tale.', + themeStoreActive: 'Activă', + themeStoreEnlarge: 'Mărește previzualizarea', + themeStoreByAuthor: 'de {{author}}', + themeMigrationNoticeTitle: 'Tema a fost mutată în magazin', + themeMigrationNoticeBody: '{{themes}} nu mai este inclusă în aplicație. O poți reinstala din Magazinul de Teme (Setări → Teme) sau poți alege altă temă.', + themeMigrationNoticeOpen: 'Deschide Magazinul de Teme', + themeStoreOffline: 'Se afișează ultimul catalog din cache — pari să fii offline.', + themeStoreInstall: 'Instalează', + themeStoreInstalling: 'Se instalează…', + themeStoreApply: 'Aplică', + themeStoreUpdate: 'Actualizează', + themeStoreUpdating: 'Se actualizează…', + themeStoreUninstall: 'Dezinstalează', + themeStoreInstallFailed: 'Instalarea a eșuat', tabLibrary: 'Librărie', tabServers: 'Servere', tabLyrics: 'Versuri', diff --git a/src/locales/ru/common.ts b/src/locales/ru/common.ts index 7054a2d2..519425f8 100644 --- a/src/locales/ru/common.ts +++ b/src/locales/ru/common.ts @@ -8,6 +8,7 @@ export const common = { downloading: 'Скачивание…', downloadZip: 'Скачать (ZIP)', back: 'Назад', + backToTop: 'Наверх', cancel: 'Отмена', save: 'Сохранить', delete: 'Удалить', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index beb72c26..855459f4 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -399,6 +399,36 @@ export const settings = { tabAudio: 'Звук', tabStorage: 'Офлайн и кэш', tabAppearance: 'Внешний вид', + tabThemes: 'Темы', + themesYourThemesTitle: 'Ваши темы', + themesCvdTooltip: 'Безопасно при дальтонизме — дейтеранопия, протанопия, тританопия', + themeStoreTitle: 'Магазин тем', + themeStoreSubmitText: 'Создали свою тему? Поделитесь с сообществом — подробности в репозитории тем.', + themeStoreSubmitLink: 'Открыть репозиторий тем', + themeStoreSearchPlaceholder: 'Поиск тем…', + themeStoreFilterMode: 'Фильтр по режиму', + themeStoreModeAll: 'Все', + themeStoreModeDark: 'Тёмные', + themeStoreModeLight: 'Светлые', + themeStoreRefresh: 'Обновить', + themeStoreLoading: 'Загрузка тем…', + themeStoreError: 'Не удалось загрузить магазин тем. Проверьте подключение.', + themeStoreRetry: 'Повторить', + themeStoreEmpty: 'Нет тем, соответствующих фильтрам.', + themeStoreActive: 'Активна', + themeStoreEnlarge: 'Увеличить превью', + themeStoreByAuthor: 'от {{author}}', + themeMigrationNoticeTitle: 'Тема перенесена в магазин', + themeMigrationNoticeBody: '{{themes}} больше не входит в приложение. Вы можете переустановить её из магазина тем (Настройки → Темы) или выбрать другую тему.', + themeMigrationNoticeOpen: 'Открыть магазин тем', + themeStoreOffline: 'Показан последний кэшированный каталог — похоже, вы офлайн.', + themeStoreInstall: 'Установить', + themeStoreInstalling: 'Установка…', + themeStoreApply: 'Применить', + themeStoreUpdate: 'Обновить', + themeStoreUpdating: 'Обновление…', + themeStoreUninstall: 'Удалить', + themeStoreInstallFailed: 'Ошибка установки', tabLibrary: 'Библиотека', tabServers: 'Серверы', tabLyrics: 'Тексты песен', diff --git a/src/locales/zh/common.ts b/src/locales/zh/common.ts index f5a10ba9..fcf54d8f 100644 --- a/src/locales/zh/common.ts +++ b/src/locales/zh/common.ts @@ -8,6 +8,7 @@ export const common = { downloading: '正在下载…', downloadZip: '下载 (ZIP)', back: '返回', + backToTop: '回到顶部', cancel: '取消', save: '保存', delete: '删除', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index d2fbc403..b09447ef 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -316,6 +316,36 @@ export const settings = { tabAudio: '音频', tabStorage: '离线与缓存', tabAppearance: '外观', + tabThemes: '主题', + themesYourThemesTitle: '你的主题', + themesCvdTooltip: '色觉障碍友好 — 绿色盲、红色盲、蓝色盲', + themeStoreTitle: '主题商店', + themeStoreSubmitText: '做了自己的主题?与社区分享——更多信息见主题仓库。', + themeStoreSubmitLink: '打开主题仓库', + themeStoreSearchPlaceholder: '搜索主题…', + themeStoreFilterMode: '按模式筛选', + themeStoreModeAll: '全部', + themeStoreModeDark: '深色', + themeStoreModeLight: '浅色', + themeStoreRefresh: '刷新', + themeStoreLoading: '正在加载主题…', + themeStoreError: '无法加载主题商店。请检查网络连接。', + themeStoreRetry: '重试', + themeStoreEmpty: '没有符合筛选条件的主题。', + themeStoreActive: '使用中', + themeStoreEnlarge: '放大预览', + themeStoreByAuthor: '作者:{{author}}', + themeMigrationNoticeTitle: '主题已移至商店', + themeMigrationNoticeBody: '{{themes}} 不再内置于应用中。你可以在主题商店(设置 → 主题)重新安装,或选择其他主题。', + themeMigrationNoticeOpen: '打开主题商店', + themeStoreOffline: '正在显示上次缓存的目录——你似乎处于离线状态。', + themeStoreInstall: '安装', + themeStoreInstalling: '正在安装…', + themeStoreApply: '应用', + themeStoreUpdate: '更新', + themeStoreUpdating: '正在更新…', + themeStoreUninstall: '卸载', + themeStoreInstallFailed: '安装失败', tabLibrary: '媒体库', tabServers: '服务器', tabLyrics: '歌词', diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index f6b689af..4ccdb5c1 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -418,7 +418,7 @@ export default function Albums() { data-tooltip-pos="bottom" style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', - ...(compFilter !== 'all' ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}), + ...(compFilter !== 'all' ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}), }} > @@ -439,7 +439,7 @@ export default function Albums() { onClick={toggleSelectionMode} data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')} data-tooltip-pos="bottom" - style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}} + style={selectionMode ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}} > {selectionMode ? t('albums.cancelSelect') : t('albums.select')} @@ -534,7 +534,7 @@ export default function Albums() { display: 'flex', justifyContent: 'center', paddingTop: '3rem', - background: 'var(--ctp-base)', + background: 'var(--bg-app)', }} >
diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx index 39e1a26a..995e1285 100644 --- a/src/pages/Artists.tsx +++ b/src/pages/Artists.tsx @@ -329,7 +329,7 @@ export default function Artists() {