Compare commits

...

16 Commits

Author SHA1 Message Date
github-actions[bot] 2d4cb95f7f chore(nix): refresh lock + npmDepsHash for v1.50.0 2026-07-19 21:54:40 +00:00
github-actions[bot] 4e05d32b88 chore(release): finalize release version 1.50.0 2026-07-19 21:35:05 +00:00
github-actions[bot] e822fff39f chore(nix): refresh lock + npmDepsHash for v1.50.0-rc.4 2026-07-19 21:19:56 +00:00
github-actions[bot] dc7addae03 chore(release): bump next channel to 1.50.0-rc.4 2026-07-19 21:01:38 +00:00
Psychotoxical c74e8f1921 fix(api): align native client UA with the WebView to collapse the duplicate Navidrome session (#1322)
* fix(api): align native client UA with the WebView to collapse the duplicate Navidrome session

* docs(changelog): duplicate Navidrome session fix (#1322)
2026-07-17 13:56:18 +02:00
Psychotoxical 7dfe58dba6 feat(artist): add "add to queue" for the whole discography (#1321)
* feat(artist): enqueue whole discography from artist page

* i18n(artist): add enqueue-all button strings

* docs(changelog): artist discography enqueue (#1321)
2026-07-17 12:37:25 +02:00
Psychotoxical 6759ee40e2 fix(themes): square corners for player bar cover and list thumbnails (#1320)
* fix(themes): square corners for player bar cover and list thumbnails

* docs(changelog): add square corners coverage fix
2026-07-17 03:33:27 +02:00
Psychotoxical fa69d11885 fix(i18n): translate cover art settings keys across locales (#1319)
* fix(i18n): translate cover art settings keys across locales

* docs(changelog): add cover art settings translation fix
2026-07-17 03:23:32 +02:00
Psychotoxical f9760fe8a8 fix(dev): push manifest-only changes in theme-watch (#1318) 2026-07-17 02:19:40 +02:00
Psychotoxical 4e876f6e12 fix(ci): tolerate transient GitHub API errors in ci-ok aggregate (#1317) 2026-07-17 02:16:02 +02:00
Psychotoxical 51b12eeec0 feat(dev): toast on successful theme-watch sync (#1316) 2026-07-17 02:12:39 +02:00
Psychotoxical 8610b2230e perf(themes): gate inactive injected theme styles out of style matching (#1315)
* perf(themes): gate inactive injected theme styles out of style matching

Inactive installed themes' <style> elements get media="not all", so the
browser skips their rules during style recalculation entirely; only the
active theme and the scheduler's day/night slots stay live. With many
installed themes every recalc (hover, playing-state flips) walked every
theme's rules. Switching only flips the media attribute in the same
effects flush that sets data-theme - no re-inject, no flash.

* docs(changelog): add 1315 theme style-matching entry
2026-07-16 23:40:47 +02:00
Psychotoxical e548c47078 fix(dev): real metadata and session-only installs for theme-watch (#1314)
The watcher now ships the sibling manifest.json's name/author/version/
description/mode with each push, so watched themes keep their real
identity instead of dev placeholders and the registry update badge
stays quiet. Freshly seeded themes are marked dev: session-only,
excluded from persistence and from the update check, so a theme-watch
session leaves no trace in the user's installed themes. Both windows
subscribe again since dev themes cannot travel over the cross-window
storage sync; a rehydrate merge keeps them in memory.
2026-07-16 23:06:27 +02:00
Psychotoxical fa390e9211 feat(dev): extend --theme-watch to a themes-repo checkout (#1313)
Accept a repo root, themes/ dir, or a single theme folder besides a bare
theme.css: every themes/*/theme.css is polled (mtime-gated) and theme
folders added while running are picked up live. A startup sweep installs
each theme without stealing the active selection; a save still applies
live. A theme-watch:ready handshake re-sends loaded contents after
dev-server reloads, dev pushes preserve a store-installed copy's
metadata and grid position, and the watcher only runs in the main
window.
2026-07-16 22:24:13 +02:00
cucadmuh 25b8b57328 revert: multi-server library scope (#1309) (#1310) 2026-07-16 13:56:08 +03:00
cucadmuh 599ac31306 feat(library): add unified multi-server library scope (#1309)
* feat(library): add multi-server scope foundation

* feat(library): wire unified multi-server browse

* feat(library): complete multi-server ownership flows

* fix(library): harden multi-server ownership

* fix(library): close multi-server edge cases

* docs: add multi-server library release notes
2026-07-16 08:10:29 +03:00
52 changed files with 770 additions and 107 deletions
+50 -7
View File
@@ -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);
+74
View File
@@ -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);
});
+30
View File
@@ -139,6 +139,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* The header search field on the Playlists page now filters the list by playlist name (same scoped badge pattern as Artists / Albums), including in folder view.
### Artist page — add the whole discography to the queue
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1321](https://github.com/Psychotoxical/psysonic/pull/1321)**
* A new queue button on the artist page appends the artist's entire discography to the current queue in one click, next to Play all and Shuffle — matching what album pages already offer.
## Changed
@@ -333,6 +339,30 @@ 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.
### Duplicate server session on Navidrome
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by TheHomeGuy on the Psysonic Discord, PR [#1322](https://github.com/Psychotoxical/psysonic/pull/1322)**
* Native requests carried a separate User-Agent from the in-app view, so the server listed the app as two logged-in players at once. They now share one identity and show as a single session.
## [1.49.0] - 2026-06-29
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1783776592,
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
"lastModified": 1784356753,
"narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
"rev": "61b7c44c4073f0b827768aff0049561b5110ea5a",
"type": "github"
},
"original": {
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-uWI/+FnIIrPO3sKB3CI/jdjDNOzoOFi6h6D12ZaaG/A="
"npmDepsHash": "sha256-ORdnzlm65b2HeaUrvZehq0jGDxbdchpCzRDPD0EFVh4="
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "psysonic",
"version": "1.50.0-dev",
"version": "1.50.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.50.0-dev",
"version": "1.50.0",
"dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.50.0-dev",
"version": "1.50.0",
"private": true,
"scripts": {
"check:css-imports": "node scripts/check-css-import-graph.mjs",
+7 -7
View File
@@ -4120,7 +4120,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.50.0-dev"
version = "1.50.0"
dependencies = [
"biquad",
"dasp_sample",
@@ -4176,7 +4176,7 @@ dependencies = [
[[package]]
name = "psysonic-analysis"
version = "1.50.0-dev"
version = "1.50.0"
dependencies = [
"ebur128",
"futures-util",
@@ -4196,7 +4196,7 @@ dependencies = [
[[package]]
name = "psysonic-audio"
version = "1.50.0-dev"
version = "1.50.0"
dependencies = [
"biquad",
"dasp_sample",
@@ -4227,7 +4227,7 @@ dependencies = [
[[package]]
name = "psysonic-core"
version = "1.50.0-dev"
version = "1.50.0"
dependencies = [
"libc",
"reqwest",
@@ -4239,7 +4239,7 @@ dependencies = [
[[package]]
name = "psysonic-integration"
version = "1.50.0-dev"
version = "1.50.0"
dependencies = [
"discord-rich-presence",
"futures-util",
@@ -4257,7 +4257,7 @@ dependencies = [
[[package]]
name = "psysonic-library"
version = "1.50.0-dev"
version = "1.50.0"
dependencies = [
"psysonic-core",
"psysonic-integration",
@@ -4273,7 +4273,7 @@ dependencies = [
[[package]]
name = "psysonic-syncfs"
version = "1.50.0-dev"
version = "1.50.0"
dependencies = [
"futures-util",
"id3",
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "1.50.0-dev"
version = "1.50.0"
edition = "2021"
rust-version = "1.95"
license = "GPL-3.0-or-later"
@@ -123,7 +123,10 @@ pub fn nd_http_client() -> reqwest::Client {
// the WebKit-side Subsonic calls end up negotiating most of the time
// on these setups.
reqwest::Client::builder()
.user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION")))
// Shared wire UA (the main WebView's User-Agent once the frontend reports
// it at startup) so Navidrome logs these native calls under the same
// client as the WebView instead of a second `[Psysonic]` session.
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.http1_only()
.pool_max_idle_per_host(0)
.max_tls_version(reqwest::tls::Version::TLS_1_2)
@@ -413,7 +413,10 @@ struct MusicFoldersWrapper {
fn default_http_client() -> reqwest::Client {
reqwest::Client::builder()
.user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION")))
// Shared wire UA (aligned with the main WebView at startup) so native
// Subsonic calls share the WebView's client identity on the server
// instead of registering a separate `[Psysonic]` session.
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
+186 -16
View File
@@ -391,34 +391,204 @@ pub fn run() {
let _ = window.set_title("Psysonic (Dev)");
}
// ── Dev: `--theme-watch <theme.css>` live theme reload ─────────
// Poll a local theme.css and push it into the running app on save,
// so theme authors get a live loop without re-importing a zip. The
// frontend (dev only) installs it under the id in its
// `[data-theme='<id>']` selector and applies it. Dev-builds only.
// ── Dev: `--theme-watch <theme.css | dir>` live theme reload ───
// Poll a local theme.css — or every `themes/*/theme.css` in a
// cloned themes-repo checkout — and push contents into the running
// app, so theme authors get a live loop without re-importing zips.
// The frontend (dev only, main window) installs each payload under
// the id in its `[data-theme='<id>']` selector; a save applies
// live, the directory startup sweep only installs so authors can
// switch between the checkout's themes in the UI. Dev-builds only.
#[cfg(debug_assertions)]
{
let args: Vec<String> = std::env::args().collect();
if let Some(i) = args.iter().position(|a| a == "--theme-watch") {
match args.get(i + 1).cloned() {
Some(path) => {
eprintln!("[theme-watch] watching {path}");
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use tauri::Listener;
// Accept a repo root (has themes/), a themes/ dir,
// a single theme folder, or a bare theme.css.
enum WatchTarget {
Dir(PathBuf),
File(PathBuf),
}
let root = PathBuf::from(&path);
let target = if root.is_dir() && !root.join("theme.css").is_file() {
if root.join("themes").is_dir() {
WatchTarget::Dir(root.join("themes"))
} else {
WatchTarget::Dir(root)
}
} else if root.is_dir() {
WatchTarget::File(root.join("theme.css"))
} else {
WatchTarget::File(root)
};
match &target {
WatchTarget::Dir(d) => {
eprintln!("[theme-watch] watching {}/*/theme.css", d.display())
}
WatchTarget::File(f) => {
if !f.is_file() {
eprintln!(
"[theme-watch] warning: {} does not exist — nothing will load until it appears",
f.display()
);
}
eprintln!("[theme-watch] watching {}", f.display());
}
}
// 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
// and after every dev-server reload) via
// `theme-watch:ready`; re-send everything already
// loaded so no emit is lost to a webview that
// wasn't listening yet. Directory mode re-seeds
// install-only (the active theme id is persisted,
// so its CSS comes back without stealing the
// selection); single-file mode re-applies the one
// authored theme. Change detection keeps running
// untouched, so a save landing mid-reload still
// arrives as an applying `css` event.
let ready_event = if matches!(target, WatchTarget::Dir(_)) {
"theme-watch:css-seed"
} else {
"theme-watch:css"
};
{
let seen = Arc::clone(&seen);
let handle = app.handle().clone();
app.listen("theme-watch:ready", move |_| {
let Ok(m) = seen.lock() else { return };
for (_, payload) in m.values() {
let _ = handle.emit(ready_event, payload);
}
});
}
let handle = app.handle().clone();
std::thread::spawn(move || {
let p = std::path::PathBuf::from(&path);
let mut last_css = String::new();
loop {
if let Ok(css) = std::fs::read_to_string(&p) {
if css != last_css {
last_css = css.clone();
let _ = handle.emit("theme-watch:css", css);
std::thread::spawn(move || loop {
let files: Vec<PathBuf> = match &target {
// Re-scan each tick so theme folders added
// while running are picked up live.
WatchTarget::Dir(dir) => std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.map(|e| e.path().join("theme.css"))
.filter(|p| p.is_file())
.collect()
})
.unwrap_or_default(),
WatchTarget::File(f) => vec![f.clone()],
};
for f in files {
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 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;
}
}
std::thread::sleep(std::time::Duration::from_millis(300));
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 == 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.
None if matches!(target, WatchTarget::Dir(_)) => {
"theme-watch:css-seed"
}
_ => "theme-watch:css",
};
if handle.emit(event, &payload).is_ok() {
m.insert(f, (stamps, payload));
}
}
std::thread::sleep(std::time::Duration::from_millis(300));
});
}
None => eprintln!("[theme-watch] usage: --theme-watch <path/to/theme.css>"),
None => eprintln!(
"[theme-watch] usage: --theme-watch <path/to/theme.css | path/to/themes-checkout>"
),
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.50.0-dev",
"version": "1.50.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
@@ -78,7 +78,7 @@
"installMode": "currentUser"
},
"wix": {
"version": "1.50.0.1"
"version": "1.50.0.65534"
}
}
}
+75 -18
View File
@@ -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,28 +32,84 @@ 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>` (debug builds) pushes a local theme's
// CSS in on every save. Install it under the id in its `[data-theme='<id>']`
// selector and apply it — the syncInjectedThemes effect above re-injects, so
// authoring is live without re-importing a zip. Never wired in production.
// Dev only: `--theme-watch <theme.css | dir>` (debug builds) pushes local
// 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;
let unlisten: (() => void) | undefined;
void import('@tauri-apps/api/event').then(({ listen }) => {
const sub = listen<string>('theme-watch:css', ({ payload }) => {
const id = payload.match(/\[data-theme=['"]([^'"]+)['"]\]/)?.[1];
const unlisteners: (() => void)[] = [];
void import('@tauri-apps/api/event').then(({ listen, emit }) => {
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;
// 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: id, author: 'dev', version: '0.0.0', description: '', mode: 'dark', css: payload, installedAt: Date.now(),
id,
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,
});
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<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.
if (sub && typeof sub.then === 'function') sub.then(u => { unlisten = u; });
for (const sub of subs) {
if (sub && typeof sub.then === 'function') void sub.then(u => { unlisteners.push(u); });
}
// Announce the attached listeners (again after every dev-server reload)
// so the watcher (re-)sends current contents — no lost first emit.
void emit('theme-watch:ready').catch(() => {});
}).catch(() => {});
return () => unlisten?.();
return () => { unlisteners.forEach(u => u()); };
}, []);
useEffect(() => {
+8 -1
View File
@@ -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,
+1
View File
@@ -430,6 +430,7 @@ const CONTRIBUTOR_ENTRIES = [
'Settings → System: community theme authors credited in a Themes sub-section; theme card What\'s new shows the latest version only (PR #1248)',
'Fullscreen player style — selectable Minimal or Immersive view, with the artist backdrop, cover-derived accent, and rail/Apple lyrics (PR #1249)',
'Word-by-word lyrics from the server via OpenSubsonic songLyrics v2, no third-party backend needed (PR #1265)',
'Artist page — add the whole discography to the queue (PR #1321)',
],
},
{
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useAlbumDetailBack } from '@/features/album';
import {
ArrowLeft, Camera, Check, HardDriveDownload, Heart,
Loader2, Play, Radio, Share2, Shuffle, Users,
ListPlus, Loader2, Play, Radio, Share2, Shuffle, Users,
} from 'lucide-react';
import type { SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo } from '@/lib/api/subsonicTypes';
import { useOfflineStore } from '@/features/offline';
@@ -35,6 +35,7 @@ interface Props {
toggleStar: () => Promise<void>;
handlePlayAll: () => void;
handleShuffle: () => void;
handleEnqueueAll: () => void;
handleStartRadio: () => void;
handleShareArtist: () => void;
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
@@ -100,7 +101,7 @@ function ArtistHeaderBg({ url, position }: { url: string; position?: string }) {
export default function ArtistDetailHero({
artist, id, albums, info, isStarred, artistEntityRating, handleArtistEntityRating,
toggleStar, handlePlayAll, handleShuffle, handleStartRadio, handleShareArtist,
toggleStar, handlePlayAll, handleShuffle, handleEnqueueAll, handleStartRadio, handleShareArtist,
handleImageUpload, playAllLoading, radioLoading, uploading,
openedLink, openLink,
coverId, coverRef, coverRevision, headerCoverFailed, setHeaderCoverFailed,
@@ -302,6 +303,15 @@ export default function ArtistDetailHero({
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
{!isMobile && <span className="compact-btn-label">{t('artistDetail.shuffle')}</span>}
</button>
<button
className="btn btn-surface"
onClick={handleEnqueueAll}
disabled={playAllLoading}
aria-label={t('artistDetail.enqueue')}
{...tooltipAttrs(t('artistDetail.enqueueTooltip'))}
>
<ListPlus size={16} />
</button>
</>
)}
<button
+6 -1
View File
@@ -18,7 +18,8 @@ import {
import { useArtistDetailData } from '@/features/artist/hooks/useArtistDetailData';
import { useArtistSimilarArtists } from '@/features/artist/hooks/useArtistSimilarArtists';
import {
runArtistDetailPlayAll, runArtistDetailPlayTopSong, runArtistDetailShuffle, runArtistDetailStartRadio,
runArtistDetailPlayAll, runArtistDetailPlayTopSong, runArtistDetailShuffle,
runArtistDetailStartRadio, runArtistDetailEnqueueAll,
} from '@/features/artist/utils/runArtistDetailPlay';
import { useOfflineBrowseContext } from '@/features/offline';
import { offlineActionPolicy } from '@/features/offline';
@@ -113,6 +114,9 @@ export default function ArtistDetail() {
const handleShuffle = () => runArtistDetailShuffle({
albums, serverId: activeServerId, setPlayAllLoading, playTrack,
});
const handleEnqueueAll = () => runArtistDetailEnqueueAll({
albums, serverId: activeServerId, setPlayAllLoading, enqueue,
});
const handleStartRadio = () => {
if (!artist) return;
return runArtistDetailStartRadio({ artist, t, setRadioLoading, playTrack, enqueue });
@@ -270,6 +274,7 @@ export default function ArtistDetail() {
toggleStar={toggleStar}
handlePlayAll={handlePlayAll}
handleShuffle={handleShuffle}
handleEnqueueAll={handleEnqueueAll}
handleStartRadio={handleStartRadio}
handleShareArtist={handleShareArtist}
handleImageUpload={handleImageUpload}
@@ -1,7 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import * as offlineMediaResolve from '@/features/offline';
import { fetchArtistDetailTracks, runArtistDetailPlayTopSong } from '@/features/artist/utils/runArtistDetailPlay';
import {
fetchArtistDetailTracks, runArtistDetailPlayTopSong, runArtistDetailEnqueueAll,
} from '@/features/artist/utils/runArtistDetailPlay';
vi.mock('@/features/offline', () => ({
resolveAlbum: vi.fn(),
@@ -99,3 +101,39 @@ describe('runArtistDetailPlayTopSong', () => {
);
});
});
describe('runArtistDetailEnqueueAll', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('enqueues the whole ordered discography', async () => {
resolveAlbumMock
.mockResolvedValueOnce({
album: albums[1],
songs: [{ id: 't1', title: 'One', artist: 'A', album: 'A', albumId: 'al-1', duration: 100, track: 1 }],
})
.mockResolvedValueOnce({
album: albums[0],
songs: [{ id: 't2', title: 'Two', artist: 'A', album: 'B', albumId: 'al-2', duration: 100, track: 1 }],
});
const enqueue = vi.fn();
const setPlayAllLoading = vi.fn();
await runArtistDetailEnqueueAll({ albums, serverId: 'srv-1', setPlayAllLoading, enqueue });
expect(enqueue).toHaveBeenCalledTimes(1);
expect(enqueue.mock.calls[0][0].map((t: { id: string }) => t.id)).toEqual(['t1', 't2']);
expect(setPlayAllLoading).toHaveBeenCalledWith(true);
expect(setPlayAllLoading).toHaveBeenLastCalledWith(false);
});
it('is a no-op with no albums', async () => {
const enqueue = vi.fn();
await runArtistDetailEnqueueAll({ albums: [], serverId: 'srv-1', setPlayAllLoading: vi.fn(), enqueue });
expect(resolveAlbumMock).not.toHaveBeenCalled();
expect(enqueue).not.toHaveBeenCalled();
});
});
@@ -3,7 +3,7 @@ import { getSimilarSongs2, getTopSongs } from '@/lib/api/subsonicArtists';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
import type { Track } from '@/lib/media/trackTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import { runBulkPlayAll, runBulkShuffle } from '@/features/playback/utils/playback/runBulkPlay';
import { runBulkPlayAll, runBulkShuffle, runBulkEnqueue } from '@/features/playback/utils/playback/runBulkPlay';
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
/** Ordered artist discography tracks for play-all / shuffle (network or local bytes). */
@@ -82,6 +82,24 @@ export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Pro
});
}
export interface RunArtistDetailEnqueueDeps {
albums: SubsonicAlbum[];
serverId?: string | null;
setPlayAllLoading: (v: boolean) => void;
enqueue: (tracks: Track[]) => void;
}
/** Append the whole artist discography to the play queue. */
export async function runArtistDetailEnqueueAll(deps: RunArtistDetailEnqueueDeps): Promise<void> {
const { albums, serverId, setPlayAllLoading, enqueue } = deps;
if (albums.length === 0) return;
await runBulkEnqueue({
fetchTracks: () => fetchArtistDetailTracks(albums, serverId),
setLoading: setPlayAllLoading,
enqueue,
});
}
export interface RunArtistDetailStartRadioDeps {
artist: SubsonicArtist;
t: TFunction;
@@ -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);
+30
View File
@@ -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');
});
});
+22
View File
@@ -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;
});
}
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Пусни цялата дискография',
shuffleTooltip: 'Разбъркай цялата дискография',
radioTooltip: 'Стартирай радио на базата на този изпълнител',
enqueue: 'Добави в опашката',
enqueueTooltip: 'Добави цялата дискография в опашката',
loading: 'Зареждане…',
noRadio: 'Няма намерени подобни песни за този изпълнител.',
notFound: 'Изпълнителят не е намерен.',
+3 -3
View File
@@ -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: 'Включи предварителните прослушвания',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Gesamte Diskografie abspielen',
shuffleTooltip: 'Gesamte Diskografie zufällig abspielen',
radioTooltip: 'Radio basierend auf diesem Künstler starten',
enqueue: 'Einreihen',
enqueueTooltip: 'Gesamte Diskografie zur Warteschlange hinzufügen',
loading: 'Lädt…',
noRadio: 'Keine ähnlichen Titel für diesen Künstler gefunden.',
notFound: 'Künstler nicht gefunden.',
+4 -4
View File
@@ -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.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Play the whole discography',
shuffleTooltip: 'Shuffle the whole discography',
radioTooltip: 'Start a radio based on this artist',
enqueue: 'Enqueue',
enqueueTooltip: 'Add the whole discography to queue',
loading: 'Loading…',
noRadio: 'No similar tracks found for this artist.',
notFound: 'Artist not found.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Reproducir toda la discografía',
shuffleTooltip: 'Reproducir toda la discografía en aleatorio',
radioTooltip: 'Iniciar una radio basada en este artista',
enqueue: 'Agregar a la Cola',
enqueueTooltip: 'Agregar toda la discografía a la cola',
loading: 'Cargando…',
noRadio: 'No se encontraron pistas similares para este artista.',
notFound: 'Artista no encontrado.',
+3 -3
View File
@@ -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',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Lire toute la discographie',
shuffleTooltip: 'Lire toute la discographie en aléatoire',
radioTooltip: 'Démarrer une radio basée sur cet artiste',
enqueue: 'Mettre en file',
enqueueTooltip: 'Ajouter toute la discographie à la file d\'attente',
loading: 'Chargement…',
noRadio: 'Aucun morceau similaire trouvé pour cet artiste.',
notFound: 'Artiste introuvable.',
+3 -3
View File
@@ -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',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'A teljes diszkográfia lejátszása',
shuffleTooltip: 'A teljes diszkográfia keverése',
radioTooltip: 'Rádió indítása az előadó alapján',
enqueue: 'Sorba',
enqueueTooltip: 'A teljes diszkográfia hozzáadása a sorhoz',
loading: 'Betöltés…',
noRadio: 'Nem található hasonló szám ehhez az előadóhoz.',
notFound: 'Az előadó nem található.',
+3 -3
View File
@@ -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',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Ascolta tutta la discografia',
shuffleTooltip: 'Mescola tutta la discografia',
radioTooltip: 'Crea una radio basata su questo artista',
enqueue: 'Accoda',
enqueueTooltip: 'Aggiungi tutta la discografia alla coda',
loading: 'Caricamento…',
noRadio: 'Non sono stati trovati brani simili per questo artista.',
notFound: 'Artista non trovato.',
+3 -3
View File
@@ -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',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'ディスコグラフィ全体を再生',
shuffleTooltip: 'ディスコグラフィ全体をシャッフル',
radioTooltip: 'このアーティストをもとにラジオを開始',
enqueue: 'キューに追加',
enqueueTooltip: 'ディスコグラフィ全体をキューに追加',
loading: '読み込み中…',
noRadio: 'このアーティストの類似トラックは見つかりませんでした。',
notFound: 'アーティストが見つかりません。',
+3 -3
View File
@@ -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: 'トラックプレビューを有効化',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Spill hele diskografien',
shuffleTooltip: 'Spill hele diskografien i tilfeldig rekkefølge',
radioTooltip: 'Start en radio basert på denne artisten',
enqueue: 'Legg i kø',
enqueueTooltip: 'Legg hele diskografien i kø',
loading: 'Laster…',
noRadio: 'Ingen lignende spor funnet for denne artisten.',
notFound: 'Artisten ble ikke funnet.',
+3 -3
View File
@@ -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',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Volledige discografie afspelen',
shuffleTooltip: 'Volledige discografie willekeurig afspelen',
radioTooltip: 'Radio op basis van deze artiest starten',
enqueue: 'In wachtrij',
enqueueTooltip: 'Volledige discografie aan wachtrij toevoegen',
loading: 'Laden…',
noRadio: 'Geen vergelijkbare nummers gevonden voor deze artiest.',
notFound: 'Artiest niet gevonden.',
+3 -3
View File
@@ -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',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Odtwórz całą dyskografię',
shuffleTooltip: 'Przetasuj całą dyskografię',
radioTooltip: 'Rozpocznij radio w oparciu o tego wykonawcę',
enqueue: 'Dodaj do kolejki',
enqueueTooltip: 'Dodaj całą dyskografię do kolejki',
loading: 'Ładowanie…',
noRadio: 'Nie znaleziono podobnych utworów dla tego wykonawcy.',
notFound: 'Wykonawca nie znaleziony.',
+3 -3
View File
@@ -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',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Redă întreaga discografie',
shuffleTooltip: 'Redă întreaga discografie amestecat',
radioTooltip: 'Pornește un radio bazat pe acest artist',
enqueue: 'Adaugă în coadă',
enqueueTooltip: 'Adaugă întreaga discografie în coadă',
loading: 'Se încarcă…',
noRadio: 'Nicio piesă similară găsită pentru acest artist.',
notFound: 'Artistul nu a fost găsit.',
+3 -3
View File
@@ -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',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Воспроизвести всю дискографию',
shuffleTooltip: 'Воспроизвести всю дискографию вперемешку',
radioTooltip: 'Запустить радио на основе этого исполнителя',
enqueue: 'В очередь',
enqueueTooltip: 'Добавить всю дискографию в очередь',
loading: 'Загрузка…',
noRadio: 'Похожие треки для этого исполнителя не найдены.',
notFound: 'Исполнитель не найден.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: '播放全部作品',
shuffleTooltip: '随机播放全部作品',
radioTooltip: '基于该艺人开始电台',
enqueue: '加入队列',
enqueueTooltip: '将全部作品添加到播放队列',
loading: '加载中…',
noRadio: '未找到该艺术家的相似曲目。',
notFound: '未找到艺术家。',
+3 -3
View File
@@ -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: '启用曲目预览',
+77
View File
@@ -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();
});
});
+25 -1
View File
@@ -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 {
@@ -36,7 +42,13 @@ export const useInstalledThemesStore = create<InstalledThemesState>()(
(set, get) => ({
themes: [],
install: (theme) =>
set((s) => ({ themes: [...s.themes.filter((t) => t.id !== theme.id), theme] })),
set((s) => ({
// Replace in place so an update (or a dev theme-watch push) keeps
// the theme's position in the grid; append only when it's new.
themes: s.themes.some((t) => t.id === theme.id)
? s.themes.map((t) => (t.id === theme.id ? theme : t))
: [...s.themes, theme],
})),
uninstall: (id) =>
set((s) => ({ themes: s.themes.filter((t) => t.id !== id) })),
isInstalled: (id) => get().themes.some((t) => t.id === id),
@@ -45,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] };
},
}
)
);
+21 -2
View File
@@ -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;
}