diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7f2fbf9d..e17f6906 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -56,7 +56,7 @@ 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), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015), [#1016](https://github.com/Psychotoxical/psysonic/pull/1016), [#1018](https://github.com/Psychotoxical/psysonic/pull/1018), [#1020](https://github.com/Psychotoxical/psysonic/pull/1020), [#1036](https://github.com/Psychotoxical/psysonic/pull/1036), [#1038](https://github.com/Psychotoxical/psysonic/pull/1038)**
+**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015), [#1016](https://github.com/Psychotoxical/psysonic/pull/1016), [#1018](https://github.com/Psychotoxical/psysonic/pull/1018), [#1020](https://github.com/Psychotoxical/psysonic/pull/1020), [#1036](https://github.com/Psychotoxical/psysonic/pull/1036), [#1038](https://github.com/Psychotoxical/psysonic/pull/1038), [#1041](https://github.com/Psychotoxical/psysonic/pull/1041)**
* 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.
@@ -65,6 +65,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* The store paginates large catalogues, and refreshing the list no longer jumps back to the top.
* Each store theme shows its **total downloads** and a **last-changed** date, and can be sorted by most popular, newest or name; the catalogue now has numbered page navigation. These stats refresh once a day.
* The **Now Playing** page now follows the active theme end to end — light themes render it legibly instead of washed-out, with no per-theme tweaks needed.
+* The sidebar now shows a small notice when one of your installed themes has an update available — click it to jump straight to the Theme Store, or dismiss it until the next update. Themes with an update also show an update control right on their card under **Settings → Themes** to update them in place with one click, and the store refreshes once on startup so new themes and updates show up without hitting refresh.
* 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).
diff --git a/src/components/ThemeUpdateBanner.tsx b/src/components/ThemeUpdateBanner.tsx
new file mode 100644
index 00000000..2f09adbf
--- /dev/null
+++ b/src/components/ThemeUpdateBanner.tsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Paintbrush, X } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { useAuthStore } from '../store/authStore';
+import { useThemeUpdates, themeUpdateSignature } from '../hooks/useThemeUpdates';
+
+interface Props {
+ collapsed?: boolean;
+}
+
+/**
+ * Sidebar pill shown above Now Playing while one or more installed community
+ * themes have a newer version in the store. Clicking opens Settings → Themes;
+ * X dismisses until a new update changes the set (see {@link themeUpdateSignature}).
+ *
+ * Sibling of {@link WhatsNewBanner}; reuses its `.whats-new-banner` styling.
+ */
+export default function ThemeUpdateBanner({ collapsed }: Props) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const updates = useThemeUpdates();
+ const lastDismissed = useAuthStore(s => s.lastDismissedThemeUpdateSig);
+ const setDismissed = useAuthStore(s => s.setLastDismissedThemeUpdateSig);
+
+ const count = updates.length;
+ const sig = themeUpdateSignature(updates);
+ if (count === 0 || sig === lastDismissed) return null;
+
+ const open = () => navigate('/settings', { state: { tab: 'themes' } });
+ const dismiss = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setDismissed(sig);
+ };
+
+ if (collapsed) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/settings/InstalledThemes.tsx b/src/components/settings/InstalledThemes.tsx
index fb292772..2405a041 100644
--- a/src/components/settings/InstalledThemes.tsx
+++ b/src/components/settings/InstalledThemes.tsx
@@ -1,9 +1,13 @@
-import { Check, X } from 'lucide-react';
+import { useMemo, useState } from 'react';
+import { Check, RefreshCw, 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 { installThemeFromRegistry } from '../../utils/themes/installThemeFromRegistry';
+import { useThemeUpdates } from '../../hooks/useThemeUpdates';
import { useThemeAnimationRisk } from '../../hooks/useThemeAnimationRisk';
+import { showToast } from '../../utils/ui/toast';
import { AnimatedThemeBadge } from './AnimatedThemeBadge';
import { FIXED_THEMES } from './fixedThemes';
@@ -43,6 +47,18 @@ export function InstalledThemes() {
const setTheme = useThemeStore(s => s.setTheme);
const installed = useInstalledThemesStore(s => s.themes);
const animRisk = useThemeAnimationRisk();
+ const updates = useThemeUpdates();
+ const updateById = useMemo(() => new Map(updates.map(u => [u.id, u])), [updates]);
+ const [updatingId, setUpdatingId] = useState(null);
+
+ const handleUpdate = async (id: string) => {
+ const th = updateById.get(id);
+ if (!th) return;
+ setUpdatingId(id);
+ const result = await installThemeFromRegistry(th);
+ setUpdatingId(null);
+ if (result !== 'ok') showToast(t('settings.themeStoreInstallFailed'), 4000, 'error');
+ };
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, animated: false })),
@@ -133,6 +149,38 @@ export function InstalledThemes() {
)}
+ {updateById.has(c.id) && (
+
+ )}
);
})}
diff --git a/src/components/settings/ThemeStoreSection.tsx b/src/components/settings/ThemeStoreSection.tsx
index 5e6026fc..e4bb3d3f 100644
--- a/src/components/settings/ThemeStoreSection.tsx
+++ b/src/components/settings/ThemeStoreSection.tsx
@@ -13,10 +13,9 @@ import { useInstalledThemesStore, type InstalledTheme } from '../../store/instal
import {
cdnUrl,
fetchRegistry,
- fetchThemeCss,
type RegistryTheme,
} from '../../utils/themes/themeRegistry';
-import { validateThemeCss } from '../../utils/themes/themeInjection';
+import { installThemeFromRegistry } from '../../utils/themes/installThemeFromRegistry';
import { uninstallTheme } from '../../utils/themes/uninstallTheme';
import { isNewer } from '../../utils/componentHelpers/appUpdaterHelpers';
@@ -52,7 +51,6 @@ export function ThemeStoreSection() {
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 animRisk = useThemeAnimationRisk();
const [themes, setThemes] = useState(null);
@@ -150,30 +148,9 @@ export function ThemeStoreSection() {
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 result = await installThemeFromRegistry(th);
+ if (result !== 'ok') setFailedId(th.id);
+ setBusyId(null);
};
diff --git a/src/components/sidebar/SidebarNavBody.tsx b/src/components/sidebar/SidebarNavBody.tsx
index 2eb7c8fc..2bcc8c02 100644
--- a/src/components/sidebar/SidebarNavBody.tsx
+++ b/src/components/sidebar/SidebarNavBody.tsx
@@ -5,6 +5,7 @@ import { AudioLines, ChevronRight, HardDriveDownload, PlayCircle, Settings, Spar
import type { SidebarItemConfig } from '../../store/sidebarStore';
import { ALL_NAV_ITEMS } from '../../config/navItems';
import WhatsNewBanner from '../WhatsNewBanner';
+import ThemeUpdateBanner from '../ThemeUpdateBanner';
import { displayPlaylistName, isSmartPlaylistName } from '../../utils/componentHelpers/sidebarHelpers';
import SidebarLibraryPicker from './SidebarLibraryPicker';
import SidebarActiveJobs from './SidebarActiveJobs';
@@ -216,6 +217,9 @@ export default function SidebarNavBody(props: Props) {
{/* What's New banner — only visible while the current release hasn't been seen. */}
+ {/* Theme-update notice — only visible while an installed theme has an update. */}
+
+
{/* Now Playing — pinned at the bottom unless the user moved it to the top. */}
{!nowPlayingAtTop && nowPlayingLink}
diff --git a/src/hooks/useThemeUpdates.test.ts b/src/hooks/useThemeUpdates.test.ts
new file mode 100644
index 00000000..dbae00e7
--- /dev/null
+++ b/src/hooks/useThemeUpdates.test.ts
@@ -0,0 +1,77 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+
+vi.mock('../utils/themes/themeRegistry', () => ({
+ fetchRegistry: vi.fn(),
+ getCachedRegistry: vi.fn(() => null),
+}));
+
+import { fetchRegistry, type Registry } from '../utils/themes/themeRegistry';
+import { useInstalledThemesStore, type InstalledTheme } from '../store/installedThemesStore';
+import { useThemeUpdates, themeUpdateSignature } from './useThemeUpdates';
+
+const fetchRegistryMock = vi.mocked(fetchRegistry);
+
+function inst(id: string, version: string): InstalledTheme {
+ return { id, name: id, author: 'x', version, description: '', mode: 'dark', css: '', installedAt: 0 };
+}
+
+function registry(themes: { id: string; version: string }[]): Registry {
+ return { themes: themes.map(t => ({ id: t.id, name: t.id, version: t.version })) } as unknown as Registry;
+}
+
+beforeEach(() => {
+ useInstalledThemesStore.setState({ themes: [] });
+ fetchRegistryMock.mockReset();
+});
+
+describe('useThemeUpdates', () => {
+ it('lists only installed themes that have a newer registry version', async () => {
+ useInstalledThemesStore.setState({ themes: [inst('a', '1.0.0'), inst('b', '2.0.0'), inst('c', '1.5.0')] });
+ fetchRegistryMock.mockResolvedValue({
+ registry: registry([
+ { id: 'a', version: '1.1.0' }, // newer → update
+ { id: 'b', version: '2.0.0' }, // same → no
+ { id: 'c', version: '1.4.0' }, // older → no
+ ]),
+ stale: false,
+ });
+
+ const { result } = renderHook(() => useThemeUpdates());
+ await waitFor(() => expect(result.current).toHaveLength(1));
+ expect(result.current[0].id).toBe('a');
+ expect(result.current[0].version).toBe('1.1.0');
+ });
+
+ it('returns nothing when nothing is outdated', async () => {
+ useInstalledThemesStore.setState({ themes: [inst('a', '1.0.0')] });
+ fetchRegistryMock.mockResolvedValue({ registry: registry([{ id: 'a', version: '1.0.0' }]), stale: false });
+
+ const { result } = renderHook(() => useThemeUpdates());
+ await waitFor(() => expect(fetchRegistryMock).toHaveBeenCalled());
+ expect(result.current).toEqual([]);
+ });
+
+ it('ignores registry themes the user has not installed', async () => {
+ useInstalledThemesStore.setState({ themes: [inst('a', '1.0.0')] });
+ fetchRegistryMock.mockResolvedValue({ registry: registry([{ id: 'z', version: '9.0.0' }]), stale: false });
+
+ const { result } = renderHook(() => useThemeUpdates());
+ await waitFor(() => expect(fetchRegistryMock).toHaveBeenCalled());
+ expect(result.current).toEqual([]);
+ });
+});
+
+describe('themeUpdateSignature', () => {
+ it('is order-independent and encodes id@version', () => {
+ const a = themeUpdateSignature([{ id: 'b', version: '2.0.0' }, { id: 'a', version: '1.1.0' }]);
+ const b = themeUpdateSignature([{ id: 'a', version: '1.1.0' }, { id: 'b', version: '2.0.0' }]);
+ expect(a).toBe(b);
+ expect(a).toBe('a@1.1.0,b@2.0.0');
+ });
+
+ it('changes when a version bumps so a dismissed notice can reappear', () => {
+ expect(themeUpdateSignature([{ id: 'a', version: '1.1.0' }]))
+ .not.toBe(themeUpdateSignature([{ id: 'a', version: '1.2.0' }]));
+ });
+});
diff --git a/src/hooks/useThemeUpdates.ts b/src/hooks/useThemeUpdates.ts
new file mode 100644
index 00000000..cec9dd3e
--- /dev/null
+++ b/src/hooks/useThemeUpdates.ts
@@ -0,0 +1,49 @@
+import { useEffect, useMemo, useState } from 'react';
+import { isNewer } from '../utils/componentHelpers/appUpdaterHelpers';
+import { fetchRegistry, getCachedRegistry, type Registry, type RegistryTheme } from '../utils/themes/themeRegistry';
+import { useInstalledThemesStore } from '../store/installedThemesStore';
+
+// Refresh the registry from source once per app launch (not just from the
+// cache). This surfaces newly published themes and updates without the user
+// having to hit the manual refresh in the Theme Store, and it feeds the
+// sidebar update notice. Subsequent reads this session use the cache.
+let sessionRefreshStarted = false;
+
+/**
+ * Registry entries for installed community themes that have a newer version
+ * available. Returns the full registry theme (css path, version, metadata) so a
+ * caller can update in place. Seeds from the last-cached registry synchronously,
+ * then revalidates (forced on the first call this session). Recomputes when the
+ * installed set changes, so the list shrinks as the user updates themes.
+ */
+export function useThemeUpdates(): RegistryTheme[] {
+ const installed = useInstalledThemesStore(s => s.themes);
+ const [registry, setRegistry] = useState(() => getCachedRegistry());
+
+ useEffect(() => {
+ let alive = true;
+ const opts = sessionRefreshStarted ? undefined : { force: true };
+ sessionRefreshStarted = true;
+ fetchRegistry(opts)
+ .then(r => { if (alive) setRegistry(r.registry); })
+ .catch(() => { /* offline: keep whatever the cache gave us */ });
+ return () => { alive = false; };
+ }, []);
+
+ return useMemo(() => {
+ if (!registry) return [];
+ const installedVersionById = new Map(installed.map(t => [t.id, t.version]));
+ return registry.themes.filter(rt => {
+ const current = installedVersionById.get(rt.id);
+ return current != null && isNewer(rt.version, current);
+ });
+ }, [registry, installed]);
+}
+
+/**
+ * Stable signature of an update set, used to remember a dismissal: the sidebar
+ * notice stays hidden until a new or bumped update changes this string.
+ */
+export function themeUpdateSignature(updates: Array<{ id: string; version: string }>): string {
+ return updates.map(u => `${u.id}@${u.version}`).sort().join(',');
+}
diff --git a/src/locales/de/sidebar.ts b/src/locales/de/sidebar.ts
index b7d668ea..7d222c33 100644
--- a/src/locales/de/sidebar.ts
+++ b/src/locales/de/sidebar.ts
@@ -34,4 +34,8 @@ export const sidebar = {
collapsePlaylists: 'Playlists einklappen',
more: 'Mehr',
feelingLucky: 'Glücks-Mix',
+
+ themeUpdatesTitle: 'Theme-Updates',
+ themeUpdatesTooltip: 'Theme-Updates verfügbar',
+ themeUpdatesDismiss: 'Ausblenden',
};
diff --git a/src/locales/en/sidebar.ts b/src/locales/en/sidebar.ts
index b18ab1ce..837c1a18 100644
--- a/src/locales/en/sidebar.ts
+++ b/src/locales/en/sidebar.ts
@@ -38,4 +38,8 @@ export const sidebar = {
collapsePlaylists: 'Collapse playlists',
more: 'More',
feelingLucky: 'Lucky Mix',
+
+ themeUpdatesTitle: 'Theme updates',
+ themeUpdatesTooltip: 'Theme updates available',
+ themeUpdatesDismiss: 'Dismiss',
};
diff --git a/src/locales/es/sidebar.ts b/src/locales/es/sidebar.ts
index a44f44d8..89318d35 100644
--- a/src/locales/es/sidebar.ts
+++ b/src/locales/es/sidebar.ts
@@ -35,4 +35,8 @@ export const sidebar = {
collapsePlaylists: 'Colapsar listas',
more: 'Más',
feelingLucky: 'Mezcla Suerte',
+
+ themeUpdatesTitle: 'Actualizaciones de temas',
+ themeUpdatesTooltip: 'Hay actualizaciones de temas',
+ themeUpdatesDismiss: 'Descartar',
};
diff --git a/src/locales/fr/sidebar.ts b/src/locales/fr/sidebar.ts
index 75b84073..f7b09f93 100644
--- a/src/locales/fr/sidebar.ts
+++ b/src/locales/fr/sidebar.ts
@@ -34,4 +34,8 @@ export const sidebar = {
collapsePlaylists: 'Réduire les playlists',
more: 'Plus',
feelingLucky: 'Mix Chance',
+
+ themeUpdatesTitle: 'Mises à jour de thèmes',
+ themeUpdatesTooltip: 'Mises à jour de thèmes disponibles',
+ themeUpdatesDismiss: 'Masquer',
};
diff --git a/src/locales/nb/sidebar.ts b/src/locales/nb/sidebar.ts
index 669bad6e..d4700a5d 100644
--- a/src/locales/nb/sidebar.ts
+++ b/src/locales/nb/sidebar.ts
@@ -34,4 +34,8 @@ export const sidebar = {
collapsePlaylists: 'Skjul spillelister',
more: 'Mer',
feelingLucky: 'Lykkemiks',
+
+ themeUpdatesTitle: 'Temaoppdateringer',
+ themeUpdatesTooltip: 'Temaoppdateringer tilgjengelig',
+ themeUpdatesDismiss: 'Lukk',
};
diff --git a/src/locales/nl/sidebar.ts b/src/locales/nl/sidebar.ts
index 747b6eac..b1bd1c67 100644
--- a/src/locales/nl/sidebar.ts
+++ b/src/locales/nl/sidebar.ts
@@ -34,4 +34,8 @@ export const sidebar = {
collapsePlaylists: 'Afspeellijsten inklappen',
more: 'Meer',
feelingLucky: 'Geluksmix',
+
+ themeUpdatesTitle: 'Thema-updates',
+ themeUpdatesTooltip: 'Thema-updates beschikbaar',
+ themeUpdatesDismiss: 'Sluiten',
};
diff --git a/src/locales/ro/sidebar.ts b/src/locales/ro/sidebar.ts
index d8109741..9fa29f03 100644
--- a/src/locales/ro/sidebar.ts
+++ b/src/locales/ro/sidebar.ts
@@ -36,4 +36,8 @@ export const sidebar = {
collapsePlaylists: 'Restrânge playlisturi',
more: 'Mai mult',
feelingLucky: 'Mix Norocos',
+
+ themeUpdatesTitle: 'Actualizări de teme',
+ themeUpdatesTooltip: 'Actualizări de teme disponibile',
+ themeUpdatesDismiss: 'Închide',
};
diff --git a/src/locales/ru/sidebar.ts b/src/locales/ru/sidebar.ts
index d6f011e9..a7dd7a19 100644
--- a/src/locales/ru/sidebar.ts
+++ b/src/locales/ru/sidebar.ts
@@ -37,4 +37,8 @@ export const sidebar = {
collapsePlaylists: 'Свернуть плейлисты',
more: 'Ещё',
feelingLucky: 'Мне повезёт',
+
+ themeUpdatesTitle: 'Обновления тем',
+ themeUpdatesTooltip: 'Доступны обновления тем',
+ themeUpdatesDismiss: 'Скрыть',
};
diff --git a/src/locales/zh/sidebar.ts b/src/locales/zh/sidebar.ts
index aa240882..a7ff630c 100644
--- a/src/locales/zh/sidebar.ts
+++ b/src/locales/zh/sidebar.ts
@@ -34,4 +34,8 @@ export const sidebar = {
collapsePlaylists: '收起播放列表',
more: '更多',
feelingLucky: '好运混音',
+
+ themeUpdatesTitle: '主题更新',
+ themeUpdatesTooltip: '有可用的主题更新',
+ themeUpdatesDismiss: '忽略',
};
diff --git a/src/store/authStore.settings.test.ts b/src/store/authStore.settings.test.ts
index c64da181..d8c3e8ff 100644
--- a/src/store/authStore.settings.test.ts
+++ b/src/store/authStore.settings.test.ts
@@ -93,6 +93,7 @@ describe('trivial pass-through setters', () => {
['setOfflineDownloadDir', 'offlineDownloadDir', '/tmp/offline'],
['setHotCacheDownloadDir', 'hotCacheDownloadDir', '/tmp/hot'],
['setLastSeenChangelogVersion', 'lastSeenChangelogVersion', '1.46.0'],
+ ['setLastDismissedThemeUpdateSig', 'lastDismissedThemeUpdateSig', 'theme-a@1.1.0'],
['setDiscordTemplateDetails', 'discordTemplateDetails', '{artist} — {title}'],
['setDiscordTemplateState', 'discordTemplateState', '{album}'],
['setDiscordTemplateLargeText', 'discordTemplateLargeText', 'Hi'],
diff --git a/src/store/authStore.ts b/src/store/authStore.ts
index 0939cabb..724a685a 100644
--- a/src/store/authStore.ts
+++ b/src/store/authStore.ts
@@ -90,6 +90,7 @@ export const useAuthStore = create()(
sidebarLyricsStyle: 'classic',
showChangelogOnUpdate: true,
lastSeenChangelogVersion: '',
+ lastDismissedThemeUpdateSig: '',
advancedSettingsEnabled: false,
seekbarStyle: 'truewave',
queueNowPlayingCollapsed: false,
diff --git a/src/store/authStoreTypes.ts b/src/store/authStoreTypes.ts
index c8cf29b9..9d098c16 100644
--- a/src/store/authStoreTypes.ts
+++ b/src/store/authStoreTypes.ts
@@ -178,6 +178,9 @@ export interface AuthState {
sidebarLyricsStyle: 'classic' | 'apple';
showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string;
+ /** Signature of the installed-theme updates last dismissed in the sidebar
+ * notice; the notice reappears once a new update changes the signature. */
+ lastDismissedThemeUpdateSig: string;
/** Reveals sub-sections marked `advanced` across all Settings tabs. */
advancedSettingsEnabled: boolean;
@@ -348,6 +351,7 @@ export interface AuthState {
setSidebarLyricsStyle: (v: 'classic' | 'apple') => void;
setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void;
+ setLastDismissedThemeUpdateSig: (v: string) => void;
setAdvancedSettingsEnabled: (v: boolean) => void;
setSeekbarStyle: (v: SeekbarStyle) => void;
setQueueNowPlayingCollapsed: (v: boolean) => void;
diff --git a/src/store/authUiAppearanceActions.ts b/src/store/authUiAppearanceActions.ts
index 5a485f72..60667c01 100644
--- a/src/store/authUiAppearanceActions.ts
+++ b/src/store/authUiAppearanceActions.ts
@@ -30,6 +30,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
| 'setSidebarLyricsStyle'
| 'setShowChangelogOnUpdate'
| 'setLastSeenChangelogVersion'
+ | 'setLastDismissedThemeUpdateSig'
| 'setAdvancedSettingsEnabled'
> {
return {
@@ -51,6 +52,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
setSidebarLyricsStyle: (v) => set({ sidebarLyricsStyle: v }),
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
+ setLastDismissedThemeUpdateSig: (v) => set({ lastDismissedThemeUpdateSig: v }),
setAdvancedSettingsEnabled: (v) => set({ advancedSettingsEnabled: v }),
};
}
diff --git a/src/styles/components/what-s-new-banner-page.css b/src/styles/components/what-s-new-banner-page.css
index 76f1c2da..afe31a35 100644
--- a/src/styles/components/what-s-new-banner-page.css
+++ b/src/styles/components/what-s-new-banner-page.css
@@ -80,3 +80,32 @@
justify-content: center;
margin: 0 auto 6px;
}
+
+/* Theme-update sidebar notice — reuses the What's New banner styling, plus a
+ count chip on the right (expanded) or a corner dot (collapsed). */
+.theme-update-banner__count {
+ flex-shrink: 0;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 5px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 9px;
+ background: var(--accent);
+ color: var(--text-on-accent, #fff);
+ font-size: 11px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+}
+
+.theme-update-banner__count--dot {
+ position: absolute;
+ top: -2px;
+ right: -2px;
+ min-width: 16px;
+ height: 16px;
+ padding: 0 4px;
+ font-size: 10px;
+ border: 2px solid var(--bg-sidebar, var(--bg-app));
+}
diff --git a/src/utils/themes/installThemeFromRegistry.test.ts b/src/utils/themes/installThemeFromRegistry.test.ts
new file mode 100644
index 00000000..7324ea3d
--- /dev/null
+++ b/src/utils/themes/installThemeFromRegistry.test.ts
@@ -0,0 +1,57 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('./themeRegistry', () => ({ fetchThemeCss: vi.fn() }));
+vi.mock('./themeInjection', () => ({ validateThemeCss: vi.fn() }));
+
+import { fetchThemeCss, type RegistryTheme } from './themeRegistry';
+import { validateThemeCss } from './themeInjection';
+import { useInstalledThemesStore } from '../../store/installedThemesStore';
+import { installThemeFromRegistry } from './installThemeFromRegistry';
+
+const fetchCss = vi.mocked(fetchThemeCss);
+const validate = vi.mocked(validateThemeCss);
+
+const TH = {
+ id: 'theme-a',
+ name: 'Theme A',
+ author: 'someone',
+ version: '1.1.0',
+ description: 'desc',
+ mode: 'dark',
+ tags: ['x'],
+ css: 'themes/theme-a/theme.css',
+} as unknown as RegistryTheme;
+
+beforeEach(() => {
+ useInstalledThemesStore.setState({ themes: [] });
+ fetchCss.mockReset();
+ validate.mockReset();
+});
+
+describe('installThemeFromRegistry', () => {
+ it('installs the validated CSS and returns ok', async () => {
+ fetchCss.mockResolvedValue('/* css */');
+ validate.mockReturnValue('/* css */');
+
+ await expect(installThemeFromRegistry(TH)).resolves.toBe('ok');
+
+ const installed = useInstalledThemesStore.getState().getInstalled('theme-a');
+ expect(installed?.version).toBe('1.1.0');
+ expect(installed?.css).toBe('/* css */');
+ });
+
+ it('does not persist CSS that fails the safety floor', async () => {
+ fetchCss.mockResolvedValue('bad');
+ validate.mockReturnValue(null);
+
+ await expect(installThemeFromRegistry(TH)).resolves.toBe('invalid');
+ expect(useInstalledThemesStore.getState().isInstalled('theme-a')).toBe(false);
+ });
+
+ it('returns error when the fetch fails', async () => {
+ fetchCss.mockRejectedValue(new Error('network'));
+
+ await expect(installThemeFromRegistry(TH)).resolves.toBe('error');
+ expect(useInstalledThemesStore.getState().isInstalled('theme-a')).toBe(false);
+ });
+});
diff --git a/src/utils/themes/installThemeFromRegistry.ts b/src/utils/themes/installThemeFromRegistry.ts
new file mode 100644
index 00000000..579553dd
--- /dev/null
+++ b/src/utils/themes/installThemeFromRegistry.ts
@@ -0,0 +1,37 @@
+import { fetchThemeCss, type RegistryTheme } from './themeRegistry';
+import { validateThemeCss } from './themeInjection';
+import { useInstalledThemesStore } from '../../store/installedThemesStore';
+
+export type InstallResult = 'ok' | 'invalid' | 'error';
+
+/**
+ * Fetch a registry theme's CSS, validate it against the in-app safety floor,
+ * and persist it (install or in-place update — the store replaces by id).
+ * Shared by the Theme Store list and the "your themes" update chip so both go
+ * through the same fetch → validate → install path.
+ *
+ * Never throws: returns `'invalid'` when the CSS fails the floor and `'error'`
+ * on a network/fetch failure, so callers can surface it without a try/catch.
+ */
+export async function installThemeFromRegistry(th: RegistryTheme): Promise {
+ try {
+ const css = await fetchThemeCss(th.css);
+ // Don't persist CSS that won't inject — it would show as installed/active
+ // but render nothing. Validate before storing.
+ if (validateThemeCss(css, th.id) == null) return 'invalid';
+ useInstalledThemesStore.getState().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(),
+ });
+ return 'ok';
+ } catch {
+ return 'error';
+ }
+}