mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
Compare commits
7 Commits
fa390e9211
...
6759ee40e2
| Author | SHA1 | Date | |
|---|---|---|---|
| 6759ee40e2 | |||
| fa69d11885 | |||
| f9760fe8a8 | |||
| 4e876f6e12 | |||
| 51b12eeec0 | |||
| 8610b2230e | |||
| e548c47078 |
@@ -26,6 +26,33 @@ const POLL_MS = 30_000;
|
||||
const TIMEOUT_MS = 90 * 60 * 1000;
|
||||
const OK_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']);
|
||||
|
||||
/**
|
||||
* GitHub API hiccups (5xx, secondary rate limits, dropped connections) must
|
||||
* not fail the gate — the answer is to poll again, not to go red while the
|
||||
* real jobs are green. 4xx config errors (401/404 …) still throw.
|
||||
*/
|
||||
export function isTransientApiError(err) {
|
||||
const status = typeof err?.status === 'number' ? err.status : 0;
|
||||
return status === 0 || status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
export async function withTransientRetry(label, fn, core, attempts = 5, delayMs = POLL_MS) {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (!isTransientApiError(err) || attempt >= attempts) {
|
||||
throw err;
|
||||
}
|
||||
// err.message can be a whole HTML error page — log only the status.
|
||||
core.info(
|
||||
`${label}: transient API error (status=${err?.status ?? 'network'}), retry ${attempt}/${attempts - 1} in ${delayMs / 1000}s`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function pathTriggersFrontend(file) {
|
||||
return FRONTEND_PATH_RE.test(file);
|
||||
}
|
||||
@@ -150,7 +177,11 @@ export async function runCiOkAggregate(github, context, core) {
|
||||
const { owner, repo } = context.repo;
|
||||
const sha = resolveTargetSha(context);
|
||||
const excludeRunId = String(context.runId);
|
||||
const changedFiles = await listChangedFiles(github, context);
|
||||
const changedFiles = await withTransientRetry(
|
||||
'listChangedFiles',
|
||||
() => listChangedFiles(github, context),
|
||||
core,
|
||||
);
|
||||
const required = requiredJobNames(changedFiles);
|
||||
|
||||
core.info(`ci-ok @ ${sha}; ${changedFiles.length} changed file(s)`);
|
||||
@@ -162,12 +193,24 @@ export async function runCiOkAggregate(github, context, core) {
|
||||
|
||||
const deadline = Date.now() + TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const checksAll = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
let checksAll;
|
||||
try {
|
||||
checksAll = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isTransientApiError(err)) {
|
||||
throw err;
|
||||
}
|
||||
// Same as an inconclusive poll: wait out the hiccup, the 90-minute
|
||||
// deadline stays the backstop.
|
||||
core.info(`checks.listForRef: transient API error (status=${err?.status ?? 'network'}) — retrying next poll`);
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
||||
continue;
|
||||
}
|
||||
const newestByName = newestChecksByName(checksAll, excludeRunId);
|
||||
const { pending, failures, done } = evaluateRequiredJobs(required, newestByName);
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ import test from 'node:test';
|
||||
|
||||
import {
|
||||
evaluateRequiredJobs,
|
||||
isTransientApiError,
|
||||
newestChecksByName,
|
||||
pathTriggersFrontend,
|
||||
pathTriggersRust,
|
||||
requiredJobNames,
|
||||
withTransientRetry,
|
||||
} from './ci-ok-aggregate.mjs';
|
||||
|
||||
test('pathTriggersFrontend matches frontend workflow paths', () => {
|
||||
@@ -52,3 +54,75 @@ test('evaluateRequiredJobs passes when all required jobs succeeded', () => {
|
||||
const result = evaluateRequiredJobs(['eslint', 'vitest run'], newestChecksByName(checks));
|
||||
assert.equal(result.done, true);
|
||||
});
|
||||
|
||||
test('isTransientApiError treats 5xx, 429 and network errors as transient', () => {
|
||||
assert.equal(isTransientApiError({ status: 503 }), true);
|
||||
assert.equal(isTransientApiError({ status: 500 }), true);
|
||||
assert.equal(isTransientApiError({ status: 429 }), true);
|
||||
assert.equal(isTransientApiError(new Error('socket hang up')), true);
|
||||
assert.equal(isTransientApiError({ status: 404 }), false);
|
||||
assert.equal(isTransientApiError({ status: 401 }), false);
|
||||
});
|
||||
|
||||
const silentCore = { info: () => {} };
|
||||
|
||||
test('withTransientRetry retries transient errors and returns the late success', async () => {
|
||||
let calls = 0;
|
||||
const result = await withTransientRetry(
|
||||
'test',
|
||||
async () => {
|
||||
calls += 1;
|
||||
if (calls < 3) {
|
||||
const err = new Error('unavailable');
|
||||
err.status = 503;
|
||||
throw err;
|
||||
}
|
||||
return 'ok';
|
||||
},
|
||||
silentCore,
|
||||
5,
|
||||
1,
|
||||
);
|
||||
assert.equal(result, 'ok');
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
test('withTransientRetry rethrows non-transient errors immediately', async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
withTransientRetry(
|
||||
'test',
|
||||
async () => {
|
||||
calls += 1;
|
||||
const err = new Error('not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
},
|
||||
silentCore,
|
||||
5,
|
||||
1,
|
||||
),
|
||||
/not found/,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('withTransientRetry gives up after the attempt budget', async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
withTransientRetry(
|
||||
'test',
|
||||
async () => {
|
||||
calls += 1;
|
||||
const err = new Error('unavailable');
|
||||
err.status = 503;
|
||||
throw err;
|
||||
},
|
||||
silentCore,
|
||||
3,
|
||||
1,
|
||||
),
|
||||
/unavailable/,
|
||||
);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
@@ -333,6 +333,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* Modal dialogs carried no accessible name, so a screen reader announced them without saying which dialog had opened. The dialog is now linked to its title, and each instance gets its own id so several open dialogs cannot be confused for one another.
|
||||
|
||||
### Themes — smooth UI with many themes installed
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by Asra on the Psysonic Discord, PR [#1315](https://github.com/Psychotoxical/psysonic/pull/1315)**
|
||||
|
||||
* With a large number of community themes installed, every hover or playback-state change made the browser re-evaluate the CSS of every installed theme, which could slow the UI to a crawl. Only the active theme (plus the scheduler's day and night picks) participates now; the others stay dormant until applied — switching themes is unaffected.
|
||||
|
||||
### Settings — cover art toggles translated
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1319](https://github.com/Psychotoxical/psysonic/pull/1319)**
|
||||
|
||||
* The queue cover-art setting and the track-list setting's title showed English text in every language except Russian — both are translated in all languages now, and the German description states more precisely which pages show the thumbnails.
|
||||
|
||||
### Square corners — player bar and list thumbnails included
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by JU3RG on the Psysonic Discord, PR [#1320](https://github.com/Psychotoxical/psysonic/pull/1320)**
|
||||
|
||||
* The Square Corners toggle left the player bar cover and the small cover thumbnails in list rows rounded (queue, playlists, favorites, search, Random Mix). They now go square with everything else; the floating player bar's circular cover stays round by design.
|
||||
|
||||
|
||||
## [1.49.0] - 2026-06-29
|
||||
|
||||
|
||||
+74
-19
@@ -444,12 +444,14 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-file state: mtime (gates the read while a
|
||||
// file is unchanged) and last pushed contents
|
||||
// (change detection + ready re-send). Entries only
|
||||
// update after a successful emit, so a failed push
|
||||
// retries next tick instead of being marked seen.
|
||||
type Seen = HashMap<PathBuf, (Option<SystemTime>, String)>;
|
||||
// Per-file state: css + manifest mtimes (gate the
|
||||
// reads while both files are unchanged) and last
|
||||
// pushed payload (change detection + ready
|
||||
// re-send). Entries only update after a successful
|
||||
// emit, so a failed push retries next tick instead
|
||||
// of being marked seen.
|
||||
type Stamps = (Option<SystemTime>, Option<SystemTime>);
|
||||
type Seen = HashMap<PathBuf, (Stamps, serde_json::Value)>;
|
||||
let seen: Arc<Mutex<Seen>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// The frontend announces its listeners (on mount
|
||||
@@ -473,8 +475,8 @@ pub fn run() {
|
||||
let handle = app.handle().clone();
|
||||
app.listen("theme-watch:ready", move |_| {
|
||||
let Ok(m) = seen.lock() else { return };
|
||||
for (_, css) in m.values() {
|
||||
let _ = handle.emit(ready_event, css);
|
||||
for (_, payload) in m.values() {
|
||||
let _ = handle.emit(ready_event, payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -495,27 +497,80 @@ pub fn run() {
|
||||
WatchTarget::File(f) => vec![f.clone()],
|
||||
};
|
||||
for f in files {
|
||||
let mtime =
|
||||
let css_mtime =
|
||||
std::fs::metadata(&f).and_then(|m| m.modified()).ok();
|
||||
// The sibling manifest is part of the
|
||||
// watched payload — a version bump alone
|
||||
// must reach the UI (tester report: it
|
||||
// didn't), so its stamp gates too.
|
||||
let manifest_path =
|
||||
f.parent().map(|d| d.join("manifest.json"));
|
||||
let (manifest_exists, manifest_mtime) = match manifest_path
|
||||
.as_ref()
|
||||
.map(std::fs::metadata)
|
||||
{
|
||||
Some(Ok(md)) => (true, md.modified().ok()),
|
||||
_ => (false, None),
|
||||
};
|
||||
let stamps = (css_mtime, manifest_mtime);
|
||||
let Ok(mut m) = seen.lock() else { continue };
|
||||
// mtime gate: skip the full read while the
|
||||
// stamp is unchanged (a None stamp never
|
||||
// matches, so filesystems without mtime
|
||||
// fall back to read + compare).
|
||||
if let Some((prev_mtime, _)) = m.get(&f) {
|
||||
if prev_mtime.is_some() && *prev_mtime == mtime {
|
||||
// mtime gate: skip the full reads while
|
||||
// both stamps are unchanged (a None stamp
|
||||
// never matches an existing file, so
|
||||
// filesystems without mtime fall back to
|
||||
// read + compare).
|
||||
if let Some(((prev_css, prev_manifest), _)) = m.get(&f) {
|
||||
let css_fresh =
|
||||
prev_css.is_some() && *prev_css == css_mtime;
|
||||
let manifest_fresh = *prev_manifest == manifest_mtime
|
||||
&& (manifest_mtime.is_some() || !manifest_exists);
|
||||
if css_fresh && manifest_fresh {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Ok(css) = std::fs::read_to_string(&f) else {
|
||||
continue;
|
||||
};
|
||||
// Ship the sibling manifest's metadata so
|
||||
// the frontend can show the theme's real
|
||||
// name/author/version instead of dev
|
||||
// placeholders (and the update badge
|
||||
// stays quiet).
|
||||
let manifest = manifest_path
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|s| {
|
||||
serde_json::from_str::<serde_json::Value>(&s).ok()
|
||||
});
|
||||
let meta = |k: &str| {
|
||||
manifest
|
||||
.as_ref()
|
||||
.and_then(|v| v.get(k))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
};
|
||||
let payload = serde_json::json!({
|
||||
"css": css,
|
||||
"name": meta("name"),
|
||||
"author": meta("author"),
|
||||
"version": meta("version"),
|
||||
"description": meta("description"),
|
||||
"mode": meta("mode"),
|
||||
});
|
||||
let event = match m.get_mut(&f) {
|
||||
// mtime-only touch — restamp quietly.
|
||||
Some(entry) if entry.1 == css => {
|
||||
entry.0 = mtime;
|
||||
Some(entry) if entry.1 == payload => {
|
||||
entry.0 = stamps;
|
||||
continue;
|
||||
}
|
||||
// Metadata-only change (version bump
|
||||
// etc.) refreshes the install without
|
||||
// stealing the active theme.
|
||||
Some(entry)
|
||||
if entry.1.get("css").and_then(|c| c.as_str())
|
||||
== Some(css.as_str()) =>
|
||||
{
|
||||
"theme-watch:css-seed"
|
||||
}
|
||||
// First sight in directory mode
|
||||
// installs without stealing the
|
||||
// active theme; a save applies.
|
||||
@@ -524,8 +579,8 @@ pub fn run() {
|
||||
}
|
||||
_ => "theme-watch:css",
|
||||
};
|
||||
if handle.emit(event, &css).is_ok() {
|
||||
m.insert(f, (mtime, css));
|
||||
if handle.emit(event, &payload).is_ok() {
|
||||
m.insert(f, (stamps, payload));
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
|
||||
+57
-29
@@ -4,17 +4,18 @@ import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useLyricsStore } from './store/lyricsStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useInstalledThemesStore } from './store/installedThemesStore';
|
||||
import { syncInjectedThemes } from '@/lib/themes/themeInjection';
|
||||
import { gateInjectedThemes, syncInjectedThemes } from '@/lib/themes/themeInjection';
|
||||
import { useThemeScheduler } from '@/app/hooks/useThemeScheduler';
|
||||
import { useFontStore } from './store/fontStore';
|
||||
import { getWindowKind } from './app/windowKind';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
import MiniPlayerApp from './app/MiniPlayerApp';
|
||||
import MainApp from './app/MainApp';
|
||||
|
||||
export default function App() {
|
||||
// Re-subscribe so themeStore changes trigger a re-render (the value itself
|
||||
// is consumed via useThemeScheduler / data-theme attribute below).
|
||||
useThemeStore(s => s.theme);
|
||||
const theme = useThemeStore(s => s.theme);
|
||||
const themeDay = useThemeStore(s => s.themeDay);
|
||||
const themeNight = useThemeStore(s => s.themeNight);
|
||||
const effectiveTheme = useThemeScheduler();
|
||||
const font = useFontStore(s => s.font);
|
||||
const buttonSize = useThemeStore(s => s.buttonSize);
|
||||
@@ -31,47 +32,74 @@ export default function App() {
|
||||
// active community theme is painted without a network round-trip.
|
||||
useEffect(() => {
|
||||
syncInjectedThemes(installedThemes);
|
||||
}, [installedThemes]);
|
||||
// Only the active slots participate in style matching (inactive styles
|
||||
// get media="not all" — see gateInjectedThemes). Runs in the same effects
|
||||
// flush as the data-theme attribute below, so a switch paints with both
|
||||
// applied.
|
||||
gateInjectedThemes([theme, themeDay, themeNight, effectiveTheme]);
|
||||
}, [installedThemes, theme, themeDay, themeNight, effectiveTheme]);
|
||||
|
||||
// Dev only: `--theme-watch <theme.css | dir>` (debug builds) pushes local
|
||||
// theme CSS in on every save. Each payload is installed under the id in its
|
||||
// `[data-theme='<id>']` selector — the syncInjectedThemes effect above
|
||||
// re-injects, so authoring is live without re-importing a zip.
|
||||
// `theme-watch:css` also applies (single file, or a save in a watched
|
||||
// directory); `theme-watch:css-seed` only installs (directory startup
|
||||
// sweep), so authors can switch between a themes-repo checkout's themes in
|
||||
// the UI. Never wired in production.
|
||||
// theme CSS (+ sibling manifest.json metadata) in on every save. Each
|
||||
// payload is installed under the id in its `[data-theme='<id>']` selector —
|
||||
// the syncInjectedThemes effect above re-injects, so authoring is live
|
||||
// without re-importing a zip. `theme-watch:css` also applies (single file,
|
||||
// or a save in a watched directory); `theme-watch:css-seed` only installs
|
||||
// (directory startup sweep), so authors can switch between a themes-repo
|
||||
// checkout's themes in the UI. Both windows subscribe: dev-seeded themes
|
||||
// are session-only (excluded from persistence), so the mini player cannot
|
||||
// get them through the cross-window storage sync. Never wired in
|
||||
// production.
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return;
|
||||
// Main window only: the mini player shares the same persisted store — a
|
||||
// second subscriber would double every install and could write a stale
|
||||
// whole-store snapshot over changes made in the main window.
|
||||
if (getWindowKind() !== 'main') return;
|
||||
const unlisteners: (() => void)[] = [];
|
||||
void import('@tauri-apps/api/event').then(({ listen, emit }) => {
|
||||
const install = (css: string, apply: boolean) => {
|
||||
const id = css.match(/\[data-theme=['"]([^'"]+)['"]\]/)?.[1];
|
||||
type WatchPayload = {
|
||||
css: string;
|
||||
name?: string | null;
|
||||
author?: string | null;
|
||||
version?: string | null;
|
||||
description?: string | null;
|
||||
mode?: string | null;
|
||||
};
|
||||
const install = (payload: WatchPayload, apply: boolean) => {
|
||||
const css = payload?.css;
|
||||
const id = css?.match(/\[data-theme=['"]([^'"]+)['"]\]/)?.[1];
|
||||
if (!id) return;
|
||||
// Only the CSS is the dev payload — keep a store-installed copy's
|
||||
// metadata and installedAt, so watching a checkout doesn't rebrand
|
||||
// real installs as 'dev' or reshuffle the Themes grid.
|
||||
// Manifest metadata wins, then a store-installed copy's, then dev
|
||||
// placeholders — watched themes keep their real identity, and only
|
||||
// the CSS is the live payload. Fresh seeds are marked dev
|
||||
// (session-only, never persisted); a store-installed theme being
|
||||
// watched keeps its persisted entry.
|
||||
const prev = useInstalledThemesStore.getState().getInstalled(id);
|
||||
useInstalledThemesStore.getState().install({
|
||||
id,
|
||||
name: prev?.name ?? id,
|
||||
author: prev?.author ?? 'dev',
|
||||
version: prev?.version ?? '0.0.0',
|
||||
description: prev?.description ?? '',
|
||||
mode: prev?.mode ?? 'dark',
|
||||
name: payload.name ?? prev?.name ?? id,
|
||||
author: payload.author ?? prev?.author ?? 'dev',
|
||||
version: payload.version ?? prev?.version ?? '0.0.0',
|
||||
description: payload.description ?? prev?.description ?? '',
|
||||
mode: payload.mode === 'light' || payload.mode === 'dark'
|
||||
? payload.mode
|
||||
: prev?.mode ?? 'dark',
|
||||
tags: prev?.tags,
|
||||
css,
|
||||
installedAt: prev?.installedAt ?? Date.now(),
|
||||
dev: prev ? prev.dev ?? false : true,
|
||||
});
|
||||
if (apply) useThemeStore.getState().setTheme(id);
|
||||
if (apply) {
|
||||
useThemeStore.getState().setTheme(id);
|
||||
// Confirm the save reached the app — theme authors watch the app
|
||||
// window, not the terminal. Main window only: the mini player
|
||||
// subscribes too and would double the toast. Dev-only path, so the
|
||||
// string stays untranslated (same as the rest of theme-watch).
|
||||
if (getWindowKind() !== 'mini') {
|
||||
showToast(`Theme synced: ${payload.name ?? prev?.name ?? id}`, 2500, 'success');
|
||||
}
|
||||
}
|
||||
};
|
||||
const subs = [
|
||||
listen<string>('theme-watch:css', ({ payload }) => install(payload, true)),
|
||||
listen<string>('theme-watch:css-seed', ({ payload }) => install(payload, false)),
|
||||
listen<WatchPayload>('theme-watch:css', ({ payload }) => install(payload, true)),
|
||||
listen<WatchPayload>('theme-watch:css-seed', ({ payload }) => install(payload, false)),
|
||||
];
|
||||
// Guard the mocked-in-tests case where listen() isn't a promise.
|
||||
for (const sub of subs) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { setLoggingMode, setSubsonicWireUserAgent } from '@/lib/api/platformShel
|
||||
import { getWindowKind } from './windowKind';
|
||||
import { migrateThemeSelection } from '@/lib/themes/themeMigration';
|
||||
import { getScheduledTheme, useThemeStore } from '../store/themeStore';
|
||||
import { syncInjectedThemes } from '@/lib/themes/themeInjection';
|
||||
import { gateInjectedThemes, syncInjectedThemes } from '@/lib/themes/themeInjection';
|
||||
import { useInstalledThemesStore, type InstalledTheme } from '../store/installedThemesStore';
|
||||
|
||||
/** Sync backend HTTP User-Agent from the main webview once at startup. */
|
||||
@@ -69,6 +69,13 @@ export function applyThemeAtStartup(): void {
|
||||
const s = parsed.state;
|
||||
if (!s) return;
|
||||
syncInjectedThemes(readInstalledThemes());
|
||||
// Gate to the active slots right away so the first style recalcs don't
|
||||
// walk every installed theme's rules (App's effect re-gates after mount).
|
||||
gateInjectedThemes([
|
||||
String(s.theme ?? 'mocha'),
|
||||
String(s.themeDay ?? 'latte'),
|
||||
String(s.themeNight ?? 'mocha'),
|
||||
]);
|
||||
// First-frame best effort for the "follow system" mode: the Web media query
|
||||
// is sync here (the native Tauri theme resolves only after mount, when the
|
||||
// App effect re-applies the effective theme). Unreliable on Linux WebKitGTK,
|
||||
|
||||
@@ -32,7 +32,11 @@ export function useThemeUpdates(): RegistryTheme[] {
|
||||
|
||||
return useMemo(() => {
|
||||
if (!registry) return [];
|
||||
const installedVersionById = new Map(installed.map(t => [t.id, t.version]));
|
||||
// Dev theme-watch copies are session-only working state — offering a
|
||||
// registry "update" for them would overwrite the author's local work.
|
||||
const installedVersionById = new Map(
|
||||
installed.filter(t => !t.dev).map(t => [t.id, t.version]),
|
||||
);
|
||||
return registry.themes.filter(rt => {
|
||||
const current = installedVersionById.get(rt.id);
|
||||
return current != null && isNewer(rt.version, current);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
validateThemeCss,
|
||||
injectTheme,
|
||||
syncInjectedThemes,
|
||||
gateInjectedThemes,
|
||||
} from '@/lib/themes/themeInjection';
|
||||
import type { InstalledTheme } from '@/store/installedThemesStore';
|
||||
|
||||
@@ -120,3 +121,32 @@ describe('syncInjectedThemes', () => {
|
||||
expect(injected()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gateInjectedThemes', () => {
|
||||
const media = (id: string) =>
|
||||
document.head.querySelector<HTMLStyleElement>(`style[${ATTR}="${id}"]`)?.media;
|
||||
|
||||
it('disables inactive styles via media="not all" and keeps active ones on', () => {
|
||||
syncInjectedThemes([mk('a', block('a')), mk('b', block('b')), mk('c', block('c'))]);
|
||||
gateInjectedThemes(['b']);
|
||||
expect(media('a')).toBe('not all');
|
||||
expect(media('b')).toBe('all');
|
||||
expect(media('c')).toBe('not all');
|
||||
});
|
||||
|
||||
it('re-enables a style when its theme becomes active', () => {
|
||||
syncInjectedThemes([mk('a', block('a')), mk('b', block('b'))]);
|
||||
gateInjectedThemes(['a']);
|
||||
gateInjectedThemes(['b']);
|
||||
expect(media('a')).toBe('not all');
|
||||
expect(media('b')).toBe('all');
|
||||
});
|
||||
|
||||
it('keeps every listed slot active (theme + scheduler day/night)', () => {
|
||||
syncInjectedThemes([mk('a', block('a')), mk('b', block('b')), mk('c', block('c'))]);
|
||||
gateInjectedThemes(['a', 'c']);
|
||||
expect(media('a')).toBe('all');
|
||||
expect(media('b')).toBe('not all');
|
||||
expect(media('c')).toBe('all');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,3 +97,25 @@ export function syncInjectedThemes(themes: InstalledTheme[]): void {
|
||||
});
|
||||
for (const theme of themes) injectTheme(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate injected theme styles by activity: only the styles for `activeIds`
|
||||
* (the applied theme plus the scheduler's day/night slots) participate in
|
||||
* style matching — every other installed theme's <style> gets
|
||||
* `media="not all"`, so the browser skips its rules entirely. With many
|
||||
* installed themes (a big collection, or a dev `--theme-watch` checkout
|
||||
* sweep), an ungated head makes every style recalculation — e.g. `:hover`
|
||||
* while moving the mouse across the Themes grid — walk all of their rules;
|
||||
* gating keeps recalc cost independent of the installed count. Switching
|
||||
* themes only flips the attribute: synchronous, no re-inject, no flash.
|
||||
*/
|
||||
export function gateInjectedThemes(activeIds: Iterable<string>): void {
|
||||
const active = new Set(activeIds);
|
||||
document.head
|
||||
.querySelectorAll<HTMLStyleElement>(`style[${ATTR}]`)
|
||||
.forEach((el) => {
|
||||
const id = el.getAttribute(ATTR);
|
||||
const media = id && active.has(id) ? 'all' : 'not all';
|
||||
if (el.media !== media) el.media = media;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -710,9 +710,9 @@ export const settings = {
|
||||
queueBehaviourTitle: 'Поведение на опашката',
|
||||
preservePlayNextOrder: 'Запази реда на „Изпълни следваща“',
|
||||
preservePlayNextOrderDesc: 'Новодобавените елементи чрез „Изпълни следваща“ се нареждат зад по-ранните, вместо да изскачат отпред.',
|
||||
queueTrackListCovers: 'Album art in queue',
|
||||
queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.',
|
||||
trackListCoverArtOnPages: 'Album art in track lists',
|
||||
queueTrackListCovers: 'Обложки в опашката',
|
||||
queueTrackListCoversSub: 'Миниатюра на албума до всяка песен в панела на опашката, опашката на мини плейъра и „Следващи“ на цял екран. Препоръчва се агресивно кеширане.',
|
||||
trackListCoverArtOnPages: 'Обложки в списъците с песни',
|
||||
trackListCoverArtOnPagesSub: 'Миниатюра на албума до песните в Песни, Търсене, плейлисти, любими, случаен микс и други списъци за преглед. Препоръчва се агресивно кеширане.',
|
||||
trackPreviewsTitle: 'Предварителни прослушвания',
|
||||
trackPreviewsToggle: 'Включи предварителните прослушвания',
|
||||
|
||||
@@ -643,10 +643,10 @@ export const settings = {
|
||||
queueBehaviourTitle: 'Warteschlangen-Verhalten',
|
||||
preservePlayNextOrder: '„Als Nächstes"-Reihenfolge bewahren',
|
||||
preservePlayNextOrderDesc: 'Neu hinzugefügte „Als Nächstes"-Titel reihen sich hinten an statt sich vorn einzuschieben.',
|
||||
queueTrackListCovers: 'Album art in queue',
|
||||
queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.',
|
||||
trackListCoverArtOnPages: 'Album art in track lists',
|
||||
trackListCoverArtOnPagesSub: 'Kleines Albumcover neben Titeln in den Bereichen Titel, Suche, Playlists, Favoriten, Zufallsmix und anderen Übersichtslisten. Aggressives Caching wird empfohlen.',
|
||||
queueTrackListCovers: 'Albumcover in der Warteschlange',
|
||||
queueTrackListCoversSub: 'Kleines Albumcover neben jedem Titel im Warteschlangen-Panel, in der Mini-Player-Warteschlange und in „Als Nächstes“ im Vollbild. Aggressives Caching wird empfohlen.',
|
||||
trackListCoverArtOnPages: 'Albumcover in Tracklisten',
|
||||
trackListCoverArtOnPagesSub: 'Kleines Albumcover neben jedem Titel auf der Titel-Seite, in der Suche, in Playlists, Favoriten, im Zufallsmix und anderen Übersichtslisten. Aggressives Caching wird empfohlen.',
|
||||
trackPreviewsTitle: 'Track-Vorschau',
|
||||
trackPreviewsToggle: 'Track-Vorschau aktivieren',
|
||||
trackPreviewsDesc: 'Inline Play- und Vorschau-Buttons in den Tracklisten anzeigen für eine kurze Hörprobe mitten im Song.',
|
||||
|
||||
@@ -642,9 +642,9 @@ export const settings = {
|
||||
queueBehaviourTitle: 'Comportamiento de la cola',
|
||||
preservePlayNextOrder: 'Mantener el orden de "Reproducir Siguiente"',
|
||||
preservePlayNextOrderDesc: 'Los elementos añadidos a "Reproducir Siguiente" se encolan detrás de los anteriores en vez de saltar al principio.',
|
||||
queueTrackListCovers: 'Album art in queue',
|
||||
queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.',
|
||||
trackListCoverArtOnPages: 'Album art in track lists',
|
||||
queueTrackListCovers: 'Carátulas en la cola',
|
||||
queueTrackListCoversSub: 'Miniatura del álbum junto a cada canción en el panel de la cola, la cola del minirreproductor y «A continuación» en pantalla completa. Se recomienda la caché agresiva.',
|
||||
trackListCoverArtOnPages: 'Carátulas en las listas de canciones',
|
||||
trackListCoverArtOnPagesSub: 'Miniatura del álbum junto a las canciones en Canciones, Buscar, listas de reproducción, favoritos, Mezcla Aleatoria y otras listas de exploración. Se recomienda la caché agresiva.',
|
||||
trackPreviewsTitle: 'Previsualizaciones de pistas',
|
||||
trackPreviewsToggle: 'Activar previsualizaciones',
|
||||
|
||||
@@ -630,9 +630,9 @@ export const settings = {
|
||||
queueBehaviourTitle: 'Comportement de la file',
|
||||
preservePlayNextOrder: "Préserver l'ordre « Lire ensuite »",
|
||||
preservePlayNextOrderDesc: 'Les nouveaux éléments « Lire ensuite » s\'ajoutent à la fin de la file existante au lieu de la doubler.',
|
||||
queueTrackListCovers: 'Album art in queue',
|
||||
queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.',
|
||||
trackListCoverArtOnPages: 'Album art in track lists',
|
||||
queueTrackListCovers: 'Pochettes dans la file d\'attente',
|
||||
queueTrackListCoversSub: 'Miniature d\'album à côté de chaque morceau dans le panneau de file d\'attente, la file du mini-lecteur et « À suivre » en plein écran. Le cache agressif est recommandé.',
|
||||
trackListCoverArtOnPages: 'Pochettes dans les listes de morceaux',
|
||||
trackListCoverArtOnPagesSub: 'Miniature d\'album à côté des morceaux dans Titres, Recherche, Playlists, Favoris, Mix aléatoire et autres listes de navigation. Le cache agressif est recommandé.',
|
||||
trackPreviewsTitle: 'Aperçus de pistes',
|
||||
trackPreviewsToggle: 'Activer les aperçus de pistes',
|
||||
|
||||
@@ -710,9 +710,9 @@ export const settings = {
|
||||
queueBehaviourTitle: 'Lejátszási sor viselkedése',
|
||||
preservePlayNextOrder: 'A „Játszás következőként" sorrend megőrzése',
|
||||
preservePlayNextOrderDesc: 'Az újonnan hozzáadott „Játszás következőként" elemek a korábbiak mögé sorakoznak fel, ahelyett, hogy eléjük ugranának.',
|
||||
queueTrackListCovers: 'Album art in queue',
|
||||
queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.',
|
||||
trackListCoverArtOnPages: 'Album art in track lists',
|
||||
queueTrackListCovers: 'Albumborítók a lejátszási sorban',
|
||||
queueTrackListCoversSub: 'Kis albumborító minden szám mellett a lejátszási sor paneljén, a minilejátszó sorában és a teljes képernyős „Következik” listában. Az agresszív gyorsítótárazás ajánlott.',
|
||||
trackListCoverArtOnPages: 'Albumborítók a számlistákban',
|
||||
trackListCoverArtOnPagesSub: 'Kis albumborító a számok mellett a Számok, Keresés, lejátszási listák, kedvencek, véletlen mix és egyéb böngészési listákban. Az agresszív gyorsítótárazás ajánlott.',
|
||||
trackPreviewsTitle: 'Számelőnézetek',
|
||||
trackPreviewsToggle: 'Számelőnézetek engedélyezése',
|
||||
|
||||
@@ -710,9 +710,9 @@ export const settings = {
|
||||
queueBehaviourTitle: 'Comportamento della coda',
|
||||
preservePlayNextOrder: 'Mantieni l\'ordine di "Riproduci dopo"',
|
||||
preservePlayNextOrderDesc: 'I nuovi elementi aggiunti con Riproduci dopo si accodano dietro quelli precedenti invece di passare avanti.',
|
||||
queueTrackListCovers: 'Album art in queue',
|
||||
queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.',
|
||||
trackListCoverArtOnPages: 'Album art in track lists',
|
||||
queueTrackListCovers: 'Copertine nella coda',
|
||||
queueTrackListCoversSub: 'Miniatura dell\'album accanto a ogni brano nel pannello della coda, nella coda del mini player e in "A seguire" a schermo intero. Si consiglia la cache aggressiva.',
|
||||
trackListCoverArtOnPages: 'Copertine nelle liste dei brani',
|
||||
trackListCoverArtOnPagesSub: 'Miniatura dell\'album accanto ai brani in Brani, Cerca, playlist, preferiti, Mix casuale e altre liste di navigazione. Si consiglia la cache aggressiva.',
|
||||
trackPreviewsTitle: 'Anteprime brani',
|
||||
trackPreviewsToggle: 'Attiva anteprime brani',
|
||||
|
||||
@@ -704,9 +704,9 @@ export const settings = {
|
||||
queueBehaviourTitle: 'キューの動作',
|
||||
preservePlayNextOrder: '"次に再生" の順序を保持',
|
||||
preservePlayNextOrderDesc: '新しく追加された "次に再生" 項目を、先に追加されたものの前へ割り込ませず後ろに並べます。',
|
||||
queueTrackListCovers: 'Album art in queue',
|
||||
queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.',
|
||||
trackListCoverArtOnPages: 'Album art in track lists',
|
||||
queueTrackListCovers: 'キューにアルバムアートを表示',
|
||||
queueTrackListCoversSub: 'キューパネル、ミニプレイヤーのキュー、フルスクリーンの「次に再生」で各トラックの横に小さなアルバムカバーを表示します。アグレッシブキャッシュを有効にすることをおすすめします。',
|
||||
trackListCoverArtOnPages: 'トラックリストにアルバムアートを表示',
|
||||
trackListCoverArtOnPagesSub: 'トラック、検索、プレイリスト、お気に入り、ランダムミックスなどの閲覧リストで、各トラックの横に小さなアルバムカバーを表示します。アグレッシブキャッシュを有効にすることをおすすめします。',
|
||||
trackPreviewsTitle: 'トラックプレビュー',
|
||||
trackPreviewsToggle: 'トラックプレビューを有効化',
|
||||
|
||||
@@ -629,9 +629,9 @@ export const settings = {
|
||||
queueBehaviourTitle: 'Køoppførsel',
|
||||
preservePlayNextOrder: 'Behold "Spill neste"-rekkefølge',
|
||||
preservePlayNextOrderDesc: 'Nye "Spill neste"-elementer havner bak de eksisterende i stedet for å snike seg foran.',
|
||||
queueTrackListCovers: 'Album art in queue',
|
||||
queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.',
|
||||
trackListCoverArtOnPages: 'Album art in track lists',
|
||||
queueTrackListCovers: 'Albumomslag i køen',
|
||||
queueTrackListCoversSub: 'Lite albumomslag ved siden av hvert spor i køpanelet, minispiller-køen og «Neste» i fullskjerm. Aggressiv hurtigbufring anbefales.',
|
||||
trackListCoverArtOnPages: 'Albumomslag i sporlister',
|
||||
trackListCoverArtOnPagesSub: 'Litet albumomslag ved siden av spor i Spor, Søk, spillelister, favoritter, tilfeldig miks og andre blafringslister. Aggressiv hurtigbufring anbefales.',
|
||||
trackPreviewsTitle: 'Sporforhåndsvisning',
|
||||
trackPreviewsToggle: 'Aktiver sporforhåndsvisning',
|
||||
|
||||
@@ -630,9 +630,9 @@ export const settings = {
|
||||
queueBehaviourTitle: 'Wachtrijgedrag',
|
||||
preservePlayNextOrder: '"Volgende afspelen"-volgorde behouden',
|
||||
preservePlayNextOrderDesc: 'Nieuwe "Volgende afspelen"-items komen achter bestaande te staan in plaats van ervoor.',
|
||||
queueTrackListCovers: 'Album art in queue',
|
||||
queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.',
|
||||
trackListCoverArtOnPages: 'Album art in track lists',
|
||||
queueTrackListCovers: 'Albumhoezen in de wachtrij',
|
||||
queueTrackListCoversSub: 'Klein albumhoesje naast elk nummer in het wachtrijpaneel, de wachtrij van de minispeler en "Hierna" in volledig scherm. Agressieve caching wordt aanbevolen.',
|
||||
trackListCoverArtOnPages: 'Albumhoezen in nummerlijsten',
|
||||
trackListCoverArtOnPagesSub: 'Klein albumhoesje naast nummers in Nummers, Zoeken, Playlists, Favorieten, willekeurige mix en andere bladerlijsten. Agressieve caching wordt aanbevolen.',
|
||||
trackPreviewsTitle: 'Track-voorvertoning',
|
||||
trackPreviewsToggle: 'Track-voorvertoning inschakelen',
|
||||
|
||||
@@ -710,9 +710,9 @@ export const settings = {
|
||||
queueBehaviourTitle: 'Zachowanie kolejki',
|
||||
preservePlayNextOrder: 'Zachowaj kolejność "Odtwórz następne"',
|
||||
preservePlayNextOrderDesc: 'Nowo dodane elementy "Odtwórz następne" są kolejkowane za wcześniej dodanymi zamiast wskakiwać na sam początek.',
|
||||
queueTrackListCovers: 'Album art in queue',
|
||||
queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.',
|
||||
trackListCoverArtOnPages: 'Album art in track lists',
|
||||
queueTrackListCovers: 'Okładki w kolejce',
|
||||
queueTrackListCoversSub: 'Miniatura albumu obok każdego utworu w panelu kolejki, kolejce mini odtwarzacza i sekcji „Następne” w trybie pełnoekranowym. Zalecane jest agresywne buforowanie.',
|
||||
trackListCoverArtOnPages: 'Okładki na listach utworów',
|
||||
trackListCoverArtOnPagesSub: 'Miniatura albumu obok utworów w Utworach, Szukaj, playlistach, ulubionych, losowym mikście i innych listach przeglądania. Zalecane jest agresywne buforowanie.',
|
||||
trackPreviewsTitle: 'Podglądy utworów',
|
||||
trackPreviewsToggle: 'Włącz podgląd utworów',
|
||||
|
||||
@@ -645,9 +645,9 @@ export const settings = {
|
||||
queueBehaviourTitle: 'Comportamentul cozii',
|
||||
preservePlayNextOrder: 'Prezervă ordinea "Redă următoarea"',
|
||||
preservePlayNextOrderDesc: 'Elementele Redă Următoarea noi adăugate sunt puse după cele adăugate mai devreme în loc să sară în față.',
|
||||
queueTrackListCovers: 'Album art in queue',
|
||||
queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.',
|
||||
trackListCoverArtOnPages: 'Album art in track lists',
|
||||
queueTrackListCovers: 'Coperți în coadă',
|
||||
queueTrackListCoversSub: 'Miniatură album lângă fiecare piesă în panoul cozii, coada mini-playerului și „Urmează” pe ecran complet. Se recomandă cache-ul agresiv.',
|
||||
trackListCoverArtOnPages: 'Coperți în listele de piese',
|
||||
trackListCoverArtOnPagesSub: 'Miniatură album lângă piese în Piese, Caută, playlisturi, favorite, Mix Aleatoriu și alte liste de navigare. Se recomandă cache-ul agresiv.',
|
||||
trackPreviewsTitle: 'Previzualizări Piese',
|
||||
trackPreviewsToggle: 'Pornește previzualizările pieselor',
|
||||
|
||||
@@ -629,9 +629,9 @@ export const settings = {
|
||||
queueBehaviourTitle: '队列行为',
|
||||
preservePlayNextOrder: '保留"下一首播放"顺序',
|
||||
preservePlayNextOrderDesc: '新添加的"下一首播放"项目排在现有项目之后,而不是插到前面。',
|
||||
queueTrackListCovers: 'Album art in queue',
|
||||
queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.',
|
||||
trackListCoverArtOnPages: 'Album art in track lists',
|
||||
queueTrackListCovers: '队列中显示专辑封面',
|
||||
queueTrackListCoversSub: '在队列面板、迷你播放器队列和全屏“即将播放”中,于每首曲目旁显示小号专辑封面。建议启用积极缓存。',
|
||||
trackListCoverArtOnPages: '曲目列表中显示专辑封面',
|
||||
trackListCoverArtOnPagesSub: '在曲目、搜索、播放列表、收藏夹、随机混音及其他浏览列表中,于曲目旁显示小号专辑封面。建议启用积极缓存。',
|
||||
trackPreviewsTitle: '曲目预览',
|
||||
trackPreviewsToggle: '启用曲目预览',
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useInstalledThemesStore, type InstalledTheme } from './installedThemesStore';
|
||||
|
||||
const theme = (id: string, over: Partial<InstalledTheme> = {}): InstalledTheme => ({
|
||||
id,
|
||||
name: id,
|
||||
author: 'tester',
|
||||
version: '1.0.0',
|
||||
description: '',
|
||||
mode: 'dark',
|
||||
css: `[data-theme='${id}'] { --accent: #000; }`,
|
||||
installedAt: 1,
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useInstalledThemesStore.setState({ themes: [] });
|
||||
});
|
||||
|
||||
describe('installedThemesStore', () => {
|
||||
it('replaces an existing theme in place, keeping its grid position', () => {
|
||||
const s = useInstalledThemesStore.getState();
|
||||
s.install(theme('a'));
|
||||
s.install(theme('b'));
|
||||
s.install(theme('c'));
|
||||
useInstalledThemesStore.getState().install(theme('b', { version: '1.0.1' }));
|
||||
expect(useInstalledThemesStore.getState().themes.map(t => t.id)).toEqual(['a', 'b', 'c']);
|
||||
expect(useInstalledThemesStore.getState().getInstalled('b')?.version).toBe('1.0.1');
|
||||
});
|
||||
|
||||
it('appends a new theme at the end', () => {
|
||||
const s = useInstalledThemesStore.getState();
|
||||
s.install(theme('a'));
|
||||
s.install(theme('b'));
|
||||
expect(useInstalledThemesStore.getState().themes.map(t => t.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('keeps dev themes out of persisted storage', () => {
|
||||
const s = useInstalledThemesStore.getState();
|
||||
s.install(theme('real'));
|
||||
s.install(theme('wip', { dev: true }));
|
||||
const raw = localStorage.getItem('psysonic_installed_themes');
|
||||
const stored = JSON.parse(raw ?? '{}') as { state?: { themes?: InstalledTheme[] } };
|
||||
expect(stored.state?.themes?.map(t => t.id)).toEqual(['real']);
|
||||
// In-memory state still has both.
|
||||
expect(useInstalledThemesStore.getState().themes.map(t => t.id)).toEqual(['real', 'wip']);
|
||||
});
|
||||
|
||||
it('keeps in-memory dev themes across a rehydrate', async () => {
|
||||
const s = useInstalledThemesStore.getState();
|
||||
s.install(theme('real'));
|
||||
s.install(theme('wip', { dev: true }));
|
||||
// Simulate another window persisting a change (cross-window storage sync
|
||||
// rehydrates this window from the new snapshot).
|
||||
localStorage.setItem(
|
||||
'psysonic_installed_themes',
|
||||
JSON.stringify({ state: { themes: [theme('real', { version: '2.0.0' })] }, version: 1 }),
|
||||
);
|
||||
await useInstalledThemesStore.persist.rehydrate();
|
||||
expect(useInstalledThemesStore.getState().themes.map(t => t.id)).toEqual(['real', 'wip']);
|
||||
expect(useInstalledThemesStore.getState().getInstalled('real')?.version).toBe('2.0.0');
|
||||
});
|
||||
|
||||
it('drops a dev theme on rehydrate when storage has a real install of the same id', async () => {
|
||||
useInstalledThemesStore.getState().install(theme('x', { dev: true }));
|
||||
localStorage.setItem(
|
||||
'psysonic_installed_themes',
|
||||
JSON.stringify({ state: { themes: [theme('x', { version: '3.0.0' })] }, version: 1 }),
|
||||
);
|
||||
await useInstalledThemesStore.persist.rehydrate();
|
||||
const only = useInstalledThemesStore.getState().themes;
|
||||
expect(only).toHaveLength(1);
|
||||
expect(only[0].version).toBe('3.0.0');
|
||||
expect(only[0].dev).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,12 @@ export interface InstalledTheme {
|
||||
/** The `[data-theme='<id>']` block — the only CSS, already CI-validated. */
|
||||
css: string;
|
||||
installedAt: number;
|
||||
/**
|
||||
* Session-only copy pushed by the dev `--theme-watch` sweep. Never written
|
||||
* to storage (see partialize/merge below), so a dev session leaves no trace
|
||||
* in the user's installed themes.
|
||||
*/
|
||||
dev?: boolean;
|
||||
}
|
||||
|
||||
interface InstalledThemesState {
|
||||
@@ -51,6 +57,18 @@ export const useInstalledThemesStore = create<InstalledThemesState>()(
|
||||
{
|
||||
name: 'psysonic_installed_themes',
|
||||
version: 1,
|
||||
// Dev theme-watch copies are session-only: partialize keeps them out of
|
||||
// storage, and merge keeps the in-memory ones across a rehydrate (the
|
||||
// cross-window storage sync rehydrates on every write from the other
|
||||
// window — without this, a persisted change would wipe them).
|
||||
partialize: (s) => ({ themes: s.themes.filter((t) => !t.dev) }),
|
||||
merge: (persisted, current) => {
|
||||
const stored = (persisted as { themes?: InstalledTheme[] } | undefined)?.themes ?? [];
|
||||
const dev = current.themes.filter(
|
||||
(t) => t.dev && !stored.some((p) => p.id === t.id),
|
||||
);
|
||||
return { ...current, themes: [...stored, ...dev] };
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* corners off the grid cards (album / playlist / artist / song / "because")
|
||||
* and their cover artwork, for users who prefer a sharp, boxy look.
|
||||
*
|
||||
* Scope is deliberately limited to cards + their covers — buttons, inputs,
|
||||
* dialogs and the player bar keep whatever radius the active theme defines.
|
||||
* Scope is deliberately limited to cards + covers — buttons, inputs and
|
||||
* dialogs keep whatever radius the active theme defines.
|
||||
* `!important` is required because some themes set card radii with their own
|
||||
* `!important` (see e.g. `.artist-card-avatar-initial`).
|
||||
*
|
||||
@@ -68,3 +68,22 @@ html[data-square-corners] :is(
|
||||
) {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Player bar cover and the list-row cover thumbs (queue, playlists,
|
||||
* favorites, search — every list surface shares `.track-row-cover-thumb`).
|
||||
* The wrap clips the art (`overflow: hidden`), so it must lose its radius
|
||||
* too or the corners stay visually rounded. */
|
||||
html[data-square-corners] :is(
|
||||
.player-album-art,
|
||||
.player-album-art-placeholder,
|
||||
.player-album-art-wrap,
|
||||
.track-row-cover-thumb
|
||||
) {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* The floating player bar's cover is a deliberate circular disc, not a card
|
||||
* corner — it stays round, same rule as the avatar placeholder circle. */
|
||||
html[data-square-corners] .player-bar.floating .player-album-art-wrap {
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user