Compare commits

...

16 Commits

Author SHA1 Message Date
github-actions[bot] 5f0a06ea4c chore(nix): refresh lock + npmDepsHash for v1.50.0-rc.3 2026-07-17 12:22:30 +00:00
github-actions[bot] a20f32819a chore(release): bump next channel to 1.50.0-rc.3 2026-07-17 12:09:13 +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
cucadmuh 16aee64d66 feat(playlist): scoped header search on Playlists browse (#1308) 2026-07-16 04:30:41 +03:00
Psychotoxical fa1dc1a328 feat(playlist): add play and queue actions to the playlist card context menu (#1307)
* refactor(playlist): extract resolvePlaylistTracks shared helper

Move the offline- and active-library-scope-aware playlist track resolution out of the Playlists overview Play handler into a reusable helper, so the overview and the playlist context menu share one resolution path instead of diverging.

* feat(playlist): add play and queue actions to the playlist card context menu

Right-clicking a playlist card now offers Play next and Add to queue alongside Play now, matching the album card. All three resolve tracks through resolvePlaylistTracks (offline- and active-library aware), so Play now becomes consistent with the queue actions and works offline. Drops the now-unused playPlaylistById.

* docs(changelog): add playlist card queue actions entry (#1307)
2026-07-16 01:41:22 +02:00
96 changed files with 1126 additions and 168 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);
});
+42
View File
@@ -127,6 +127,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **Settings → Integrations → Discord → Cover art source** gets a **Server** option, alongside **None** and **Apple Music**. It resolves artwork through the standard Subsonic `getAlbumInfo2` endpoint's public image link — never an authenticated cover URL that could expose your login credentials (reported by lavioso on Discord). Needs a publicly reachable server; anyone viewing your Discord profile can see that server's public address, but nothing else.
### Playlist cards — play and queue from the right-click menu
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1307](https://github.com/Psychotoxical/psysonic/pull/1307)**
* Right-clicking a playlist card now offers **Play next** and **Add to queue** alongside **Play now**, matching the album card. All three honour offline mode and the active multi-library filter.
### Playlists browse — scoped header search
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1308](https://github.com/Psychotoxical/psysonic/pull/1308)**
* 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
@@ -321,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": 1784120854,
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
"rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46",
"type": "github"
},
"original": {
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-uWI/+FnIIrPO3sKB3CI/jdjDNOzoOFi6h6D12ZaaG/A="
"npmDepsHash": "sha256-eoX17bw4fuFJtbOzEb1N3gq4/TkxNUg5wQJK6U2ZUw8="
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "psysonic",
"version": "1.50.0-dev",
"version": "1.50.0-rc.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.50.0-dev",
"version": "1.50.0-rc.3",
"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-rc.3",
"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-rc.3"
dependencies = [
"biquad",
"dasp_sample",
@@ -4176,7 +4176,7 @@ dependencies = [
[[package]]
name = "psysonic-analysis"
version = "1.50.0-dev"
version = "1.50.0-rc.3"
dependencies = [
"ebur128",
"futures-util",
@@ -4196,7 +4196,7 @@ dependencies = [
[[package]]
name = "psysonic-audio"
version = "1.50.0-dev"
version = "1.50.0-rc.3"
dependencies = [
"biquad",
"dasp_sample",
@@ -4227,7 +4227,7 @@ dependencies = [
[[package]]
name = "psysonic-core"
version = "1.50.0-dev"
version = "1.50.0-rc.3"
dependencies = [
"libc",
"reqwest",
@@ -4239,7 +4239,7 @@ dependencies = [
[[package]]
name = "psysonic-integration"
version = "1.50.0-dev"
version = "1.50.0-rc.3"
dependencies = [
"discord-rich-presence",
"futures-util",
@@ -4257,7 +4257,7 @@ dependencies = [
[[package]]
name = "psysonic-library"
version = "1.50.0-dev"
version = "1.50.0-rc.3"
dependencies = [
"psysonic-core",
"psysonic-integration",
@@ -4273,7 +4273,7 @@ dependencies = [
[[package]]
name = "psysonic-syncfs"
version = "1.50.0-dev"
version = "1.50.0-rc.3"
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-rc.3"
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-rc.3",
"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.10003"
}
}
}
+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,
+3
View File
@@ -206,6 +206,8 @@ const CONTRIBUTOR_ENTRIES = [
'Player bar — hideable stop button, optional album line, drag-reorderable action buttons (request: mikmik on Psysonic Discord, PR #1287)',
'Persistent shuffle mode — queue-reordering shuffle with restore, survives restart, in sync with other devices and Orbit (request: mikmik on Psysonic Discord, PR #1288)',
'Playlist and radio custom covers — preserve Navidrome pl-/ra-* getCoverArt ids (fixes blank uploaded covers; PR #1295)',
'Playlist cards — Play next and Add to queue from the right-click menu, matching album cards (PR #1307)',
'Playlists browse — scoped header search by playlist name (PR #1308)',
],
},
{
@@ -428,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;
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import { Play, ChevronRight, FolderTree, ListMusic, Trash2 } from 'lucide-react';
import { Play, ChevronsRight, ChevronRight, FolderTree, ListMusic, ListPlus, Trash2 } from 'lucide-react';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { usePlaylistStore } from '@/features/playlist';
import { usePlaylistStore, resolvePlaylistTracks } from '@/features/playlist';
import { MultiPlaylistToPlaylistSubmenu, SinglePlaylistToPlaylistSubmenu } from '@/features/contextMenu/components/PlaylistToPlaylistSubmenus';
import MoveToFolderSubmenu from '@/features/contextMenu/components/MoveToFolderSubmenu';
import type { ContextMenuItemsProps } from '@/features/contextMenu/components/contextMenuItemTypes';
@@ -9,6 +9,7 @@ import type { ContextMenuItemsProps } from '@/features/contextMenu/components/co
export default function PlaylistContextItems(props: ContextMenuItemsProps) {
const {
type, item, closeContextMenu,
playTrack, playNext, enqueue,
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
playlistSongIds, setPlaylistSongIds,
handleAction,
@@ -23,15 +24,26 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
return (
<>
<div className="context-menu-item" onClick={() => handleAction(async () => {
const { playPlaylistById } = await import('@/features/playlist');
try {
await playPlaylistById(playlist.id);
} catch {
// Network/load failure — leave playback untouched rather than crash.
}
const tracks = await resolvePlaylistTracks(playlist.id);
if (tracks.length === 0) return;
playTrack(tracks[0], tracks);
})}>
<Play size={14} /> {t('contextMenu.playNow')}
</div>
<div className="context-menu-item" onClick={() => handleAction(async () => {
const tracks = await resolvePlaylistTracks(playlist.id);
if (tracks.length === 0) return;
playNext(tracks);
})}>
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
</div>
<div className="context-menu-item" onClick={() => handleAction(async () => {
const tracks = await resolvePlaylistTracks(playlist.id);
if (tracks.length === 0) return;
enqueue(tracks);
})}>
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
</div>
<div className="context-menu-divider" />
{offlinePolicy.canAddToPlaylist && (
<div
@@ -10,6 +10,8 @@ interface Props {
playlists: SubsonicPlaylist[];
renderCard: (pl: SubsonicPlaylist) => React.ReactNode;
disableVirtualization: boolean;
/** When true, omit folder sections that currently have no matching playlists. */
hideEmptyFolders?: boolean;
}
/**
@@ -18,17 +20,26 @@ interface Props {
* virtualisation match the flat grid; only the grouping differs. Rendered only
* when at least one folder exists (the page falls back to the plain grid).
*/
export default function PlaylistsFolderView({ serverId, playlists, renderCard, disableVirtualization }: Props) {
export default function PlaylistsFolderView({
serverId,
playlists,
renderCard,
disableVirtualization,
hideEmptyFolders = false,
}: Props) {
const bucket = usePlaylistFolderStore(s => s.byServer[serverId]) ?? EMPTY_SERVER_FOLDERS;
const { isDragging } = useDragDrop();
const grouped = groupPlaylistsByFolder(playlists, bucket.folders, bucket.assignments);
// Keep the ungrouped section as a drop target during a drag even when empty,
// so a playlist filed into a folder can always be dragged back out to root.
const showUngrouped = grouped.ungrouped.length > 0 || isDragging;
const folderSections = hideEmptyFolders && !isDragging
? grouped.folders.filter(({ playlists: items }) => items.length > 0)
: grouped.folders;
return (
<div className="playlist-folder-view">
{grouped.folders.map(({ folder, playlists: items }) => (
{folderSections.map(({ folder, playlists: items }) => (
<PlaylistFolderSection
key={folder.id}
serverId={serverId}
+2 -1
View File
@@ -29,8 +29,8 @@ export * from './utils/addTracksToPlaylistWithDedup';
export * from './utils/playlistBulkPlayActions';
export * from './utils/playlistDisplayedSongs';
export * from './utils/playlistFolders';
export * from './utils/playlistsBrowseSearch';
export * from './utils/playlistsSmart';
export * from './utils/playPlaylistById';
export * from './utils/runPlaylistCsvImport';
export * from './utils/runPlaylistLoad';
export * from './utils/runPlaylistReorderDrop';
@@ -38,6 +38,7 @@ export * from './utils/runPlaylistsActions';
export * from './utils/runPlaylistSaveMeta';
export * from './utils/runPlaylistsOpenSmartEditor';
export * from './utils/runPlaylistsSaveSmart';
export * from './utils/resolvePlaylistTracks';
export * from './utils/runPlaylistZipDownload';
export * from './utils/spotifyCsvImport';
export * from './utils/spotifyCsvMatch';
+47 -20
View File
@@ -1,14 +1,14 @@
import { resolveMediaServerId, resolvePlaylist } from '@/features/offline';
import { resolvePlaylistTracks } from '@/features/playlist/utils/resolvePlaylistTracks';
import { getGenres } from '@/lib/api/subsonicGenres';
import { filterSongsToActiveLibrary } from '@/lib/api/subsonicLibrary';
import type { SubsonicPlaylist, SubsonicGenre } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
import { useAuthStore } from '@/store/authStore';
import { useTranslation } from 'react-i18next';
import { useRangeSelection } from '@/lib/hooks/useRangeSelection';
import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore';
import { filterPlaylistsByNameQuery } from '@/features/playlist/utils/playlistsBrowseSearch';
import {
defaultSmartFilters,
@@ -40,6 +40,12 @@ export default function Playlists() {
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const removeId = usePlaylistStore((s) => s.removeId);
const playlists = usePlaylistStore((s) => s.playlists);
const playlistsSearchQuery = useScopedBrowseSearchQuery('playlists');
const visiblePlaylists = useMemo(
() => filterPlaylistsByNameQuery(playlists, playlistsSearchQuery),
[playlists, playlistsSearchQuery],
);
const textSearchActive = playlistsSearchQuery.trim().length > 0;
const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists);
const activeUsername = useAuthStore(s => s.getActiveServer()?.username ?? '');
const activeServerId = useAuthStore(s => s.activeServerId);
@@ -73,12 +79,37 @@ export default function Playlists() {
// ── Multi-selection ──────────────────────────────────────────────────────
const [selectionMode, setSelectionMode] = useState(false);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(playlists);
const {
selectedIds,
setSelectedIds,
toggleSelect,
clearSelection: resetSelection,
} = useRangeSelection(visiblePlaylists);
const isNavidromeServer = Boolean(
activeServerId &&
(subsonicIdentityByServer[activeServerId]?.type ?? '').toLowerCase() === 'navidrome',
);
// Intersect with the visible list so header/bulk actions never count hidden ids
// (even for the render before the prune effect below runs).
const visibleSelectedIds = useMemo(() => {
if (selectedIds.size === 0) return selectedIds;
const visibleIds = new Set(visiblePlaylists.map(p => p.id));
let changed = false;
const next = new Set<string>();
for (const id of selectedIds) {
if (visibleIds.has(id)) next.add(id);
else changed = true;
}
return changed ? next : selectedIds;
}, [selectedIds, visiblePlaylists]);
// Drop ids that the scoped search hid so range-select state stays coherent.
useEffect(() => {
if (visibleSelectedIds === selectedIds) return;
setSelectedIds(visibleSelectedIds);
}, [visibleSelectedIds, selectedIds, setSelectedIds]);
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
resetSelection();
@@ -89,7 +120,7 @@ export default function Playlists() {
resetSelection();
};
const selectedPlaylists = playlists.filter(p => selectedIds.has(p.id));
const selectedPlaylists = visiblePlaylists.filter(p => visibleSelectedIds.has(p.id));
const isPlaylistDeletable = useCallback((pl: SubsonicPlaylist) => {
if (!pl.owner) return true;
if (!activeUsername) return false;
@@ -144,14 +175,7 @@ export default function Playlists() {
if (playingId === pl.id) return;
setPlayingId(pl.id);
try {
const serverId = resolveMediaServerId(activeServerId);
if (!serverId) return;
const data = await resolvePlaylist(serverId, pl.id);
if (!data) return;
const songs = offlineBrowseActive
? data.songs
: await filterSongsToActiveLibrary(data.songs);
const tracks = songs.map(songToTrack);
const tracks = await resolvePlaylistTracks(pl.id);
if (tracks.length > 0) {
touchPlaylist(pl.id);
playTrack(tracks[0], tracks);
@@ -165,7 +189,7 @@ export default function Playlists() {
});
const handleDeleteSelected = () => runPlaylistDeleteSelected({
selectedPlaylists, selectedIds, isPlaylistDeletable, removeId, clearSelection, t,
selectedPlaylists, isPlaylistDeletable, removeId, clearSelection, t,
});
const renderCard = (pl: SubsonicPlaylist) => (
@@ -173,7 +197,7 @@ export default function Playlists() {
pl={pl}
selectionMode={selectionMode}
draggable={showFolderView}
selectedIds={selectedIds}
selectedIds={visibleSelectedIds}
selectedPlaylists={selectedPlaylists}
toggleSelect={toggleSelect}
isPlaylistDeletable={isPlaylistDeletable}
@@ -246,7 +270,7 @@ export default function Playlists() {
<PlaylistsHeader
selectionMode={selectionMode}
selectedIds={selectedIds}
selectedIds={visibleSelectedIds}
selectedPlaylists={selectedPlaylists}
isPlaylistDeletable={isPlaylistDeletable}
toggleSelectionMode={toggleSelectionMode}
@@ -283,6 +307,8 @@ export default function Playlists() {
{/* ── Grid ── */}
{playlists.length === 0 ? (
<div className="empty-state">{t('playlists.empty')}</div>
) : visiblePlaylists.length === 0 && textSearchActive ? (
<div className="empty-state">{t('playlists.noMatchingSearch')}</div>
) : (
<>
{showFolderView && (
@@ -293,17 +319,18 @@ export default function Playlists() {
{showFolderView && activeServerId ? (
<PlaylistsFolderView
serverId={activeServerId}
playlists={playlists}
playlists={visiblePlaylists}
renderCard={renderCard}
disableVirtualization={perfFlags.disableMainstageVirtualLists}
hideEmptyFolders={textSearchActive}
/>
) : (
<VirtualCardGrid
items={playlists}
items={visiblePlaylists}
itemKey={(pl, _i) => pl.id}
rowVariant="playlist"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={playlists.length}
layoutSignal={visiblePlaylists.length}
renderItem={renderCard}
/>
)}
@@ -1,19 +0,0 @@
import { getPlaylist } from '@/lib/api/subsonicPlaylists';
import { songToTrack } from '@/lib/media/songToTrack';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { playPlaylistAll } from '@/features/playlist/utils/playlistBulkPlayActions';
/**
* Load a playlist's songs and start playback immediately ("Play Now").
*
* Used where only the playlist metadata is on hand the playlist context menu
* on the Playlists overview so the tracks have to be fetched first. Once
* loaded it defers to {@link playPlaylistAll}, the same action the playlist
* detail "Play All" button uses, so playback behaviour stays in one place.
*/
export async function playPlaylistById(id: string): Promise<void> {
const { songs } = await getPlaylist(id);
const tracks = songs.map(songToTrack);
const { playTrack, enqueue } = usePlayerStore.getState();
playPlaylistAll({ songsLength: tracks.length, id, tracks, playTrack, enqueue });
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import {
filterPlaylistsByNameQuery,
isPlaylistsBrowsePath,
} from './playlistsBrowseSearch';
function pl(id: string, name: string): SubsonicPlaylist {
return { id, name, songCount: 0, duration: 0, created: '', changed: '' };
}
describe('isPlaylistsBrowsePath', () => {
it('matches only the playlists list route', () => {
expect(isPlaylistsBrowsePath('/playlists')).toBe(true);
expect(isPlaylistsBrowsePath('/playlists/abc')).toBe(false);
expect(isPlaylistsBrowsePath('/artists')).toBe(false);
});
});
describe('filterPlaylistsByNameQuery', () => {
const list = [pl('1', 'Road Trip'), pl('2', 'Focus Mix'), pl('3', 'road house')];
it('returns all playlists when query is empty', () => {
expect(filterPlaylistsByNameQuery(list, ' ')).toEqual(list);
});
it('filters by case-insensitive name substring', () => {
expect(filterPlaylistsByNameQuery(list, 'road').map(p => p.id)).toEqual(['1', '3']);
expect(filterPlaylistsByNameQuery(list, 'FOCUS').map(p => p.id)).toEqual(['2']);
});
});
@@ -0,0 +1,16 @@
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
/** True when pathname is the Playlists browse route (`/playlists`). */
export function isPlaylistsBrowsePath(pathname: string): boolean {
return pathname === '/playlists';
}
/** Scoped Playlists text search — playlist name only. */
export function filterPlaylistsByNameQuery(
playlists: SubsonicPlaylist[],
query: string,
): SubsonicPlaylist[] {
const needle = query.trim().toLowerCase();
if (!needle) return playlists;
return playlists.filter(p => (p.name ?? '').toLowerCase().includes(needle));
}
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resolvePlaylistTracks } from '@/features/playlist/utils/resolvePlaylistTracks';
const offlineMock = vi.fn(() => false);
const resolveServerMock = vi.fn((id: string | null | undefined) => id ?? undefined);
const resolvePlaylistMock = vi.fn();
const filterMock = vi.fn();
let activeServerId: string | null = 'srv-1';
vi.mock('@/features/offline', () => ({
isOfflineBrowseActive: () => offlineMock(),
resolveMediaServerId: (id: string | null | undefined) => resolveServerMock(id),
resolvePlaylist: (serverId: string, playlistId: string) => resolvePlaylistMock(serverId, playlistId),
}));
vi.mock('@/lib/api/subsonicLibrary', () => ({
filterSongsToActiveLibrary: (songs: unknown) => filterMock(songs),
}));
vi.mock('@/lib/media/songToTrack', () => ({
songToTrack: (song: { id: string }) => ({ id: song.id, track: true }),
}));
vi.mock('@/store/authStore', () => ({
useAuthStore: { getState: () => ({ activeServerId }) },
}));
describe('resolvePlaylistTracks', () => {
beforeEach(() => {
offlineMock.mockReset().mockReturnValue(false);
resolveServerMock.mockReset().mockImplementation((id: string | null | undefined) => id ?? undefined);
resolvePlaylistMock.mockReset();
filterMock.mockReset();
activeServerId = 'srv-1';
});
it('scopes to the active library when online', async () => {
resolvePlaylistMock.mockResolvedValue({ playlist: { id: 'pl-1' }, songs: [{ id: 'a' }, { id: 'b' }, { id: 'c' }] });
// Active-library scope hides b and c.
filterMock.mockResolvedValue([{ id: 'a' }]);
const tracks = await resolvePlaylistTracks('pl-1');
expect(filterMock).toHaveBeenCalledOnce();
expect(tracks).toEqual([{ id: 'a', track: true }]);
});
it('uses the full offline list without library filtering', async () => {
offlineMock.mockReturnValue(true);
resolvePlaylistMock.mockResolvedValue({ playlist: { id: 'pl-1' }, songs: [{ id: 'a' }, { id: 'b' }] });
const tracks = await resolvePlaylistTracks('pl-1');
expect(filterMock).not.toHaveBeenCalled();
expect(tracks).toEqual([{ id: 'a', track: true }, { id: 'b', track: true }]);
});
it('returns [] when the active server cannot be resolved', async () => {
activeServerId = null;
resolveServerMock.mockReturnValue(undefined);
const tracks = await resolvePlaylistTracks('pl-1');
expect(tracks).toEqual([]);
expect(resolvePlaylistMock).not.toHaveBeenCalled();
});
it('returns [] when the playlist cannot be resolved', async () => {
resolvePlaylistMock.mockResolvedValue(null);
const tracks = await resolvePlaylistTracks('pl-1');
expect(tracks).toEqual([]);
expect(filterMock).not.toHaveBeenCalled();
});
it('swallows a rejecting library-scope filter to [] (no unhandled rejection)', async () => {
resolvePlaylistMock.mockResolvedValue({ playlist: { id: 'pl-1' }, songs: [{ id: 'a' }] });
filterMock.mockRejectedValue(new Error('network'));
await expect(resolvePlaylistTracks('pl-1')).resolves.toEqual([]);
});
});
@@ -0,0 +1,33 @@
import { isOfflineBrowseActive, resolveMediaServerId, resolvePlaylist } from '@/features/offline';
import { filterSongsToActiveLibrary } from '@/lib/api/subsonicLibrary';
import { songToTrack } from '@/lib/media/songToTrack';
import type { Track } from '@/lib/media/trackTypes';
import { useAuthStore } from '@/store/authStore';
/**
* Resolve a playlist's playable tracks from its id alone the same way the
* Playlists overview "Play" button does: offline-browse aware via
* {@link resolvePlaylist}, then scoped to the active library (#1241) when
* online. Shared by the overview play control and the playlist context-menu
* queue actions so those paths cannot drift.
*
* Best-effort: returns `[]` when the server is unknown or the playlist cannot
* be resolved, so callers can treat empty as "nothing to enqueue".
*/
export async function resolvePlaylistTracks(playlistId: string): Promise<Track[]> {
const serverId = resolveMediaServerId(useAuthStore.getState().activeServerId);
if (!serverId) return [];
try {
const data = await resolvePlaylist(serverId, playlistId);
if (!data) return [];
// The library-scope filter fetches the album list over the network, so it
// can reject; swallow to [] so context-menu callers (which run via a
// no-catch handler) never leak an unhandled rejection.
const songs = isOfflineBrowseActive()
? data.songs
: await filterSongsToActiveLibrary(data.songs);
return songs.map(songToTrack);
} catch {
return [];
}
}
@@ -42,7 +42,6 @@ export async function runPlaylistDelete(deps: RunPlaylistDeleteDeps): Promise<vo
export interface RunPlaylistDeleteSelectedDeps {
selectedPlaylists: SubsonicPlaylist[];
selectedIds: Set<string>;
isPlaylistDeletable: (pl: SubsonicPlaylist) => boolean;
removeId: (id: string) => void;
clearSelection: () => void;
@@ -50,26 +49,26 @@ export interface RunPlaylistDeleteSelectedDeps {
}
export async function runPlaylistDeleteSelected(deps: RunPlaylistDeleteSelectedDeps): Promise<void> {
const { selectedPlaylists, selectedIds, isPlaylistDeletable, removeId, clearSelection, t } = deps;
const { selectedPlaylists, isPlaylistDeletable, removeId, clearSelection, t } = deps;
const deletable = selectedPlaylists.filter(isPlaylistDeletable);
if (deletable.length === 0) return;
let deleted = 0;
const removedIds = new Set<string>();
for (const pl of deletable) {
try {
await deletePlaylist(pl.id);
removeId(pl.id);
deleted++;
removedIds.add(pl.id);
} catch {
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
}
}
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => !(selectedIds.has(p.id) && isPlaylistDeletable(p))),
}));
clearSelection();
if (deleted > 0) {
showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info');
if (removedIds.size > 0) {
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => !removedIds.has(p.id)),
}));
showToast(t('playlists.deleteSuccess', { count: removedIds.size }), 3000, 'info');
}
clearSelection();
}
export interface RunPlaylistMergeSelectedDeps {
@@ -5,6 +5,7 @@ import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '@/features/album';
import { isTracksBrowsePath } from '@/store/advancedSearchSessionStore';
import { isArtistsBrowsePath } from '@/features/artist';
import { isComposersBrowsePath } from '@/features/composers';
import { isPlaylistsBrowsePath } from '@/features/playlist';
export const SCOPE_NAV_ITEM: Record<LiveSearchScope, keyof typeof ALL_NAV_ITEMS> = {
artists: 'artists',
@@ -12,6 +13,7 @@ export const SCOPE_NAV_ITEM: Record<LiveSearchScope, keyof typeof ALL_NAV_ITEMS>
newReleases: 'newReleases',
tracks: 'tracks',
composers: 'composers',
playlists: 'playlists',
};
/** Scope to restore when on a browse route but the badge was cleared (global search mode). */
@@ -25,6 +27,7 @@ export function resolveLiveSearchScopeGhost(
if (isNewReleasesBrowsePath(pathname)) return 'newReleases';
if (isTracksBrowsePath(pathname)) return 'tracks';
if (isComposersBrowsePath(pathname)) return 'composers';
if (isPlaylistsBrowsePath(pathname)) return 'playlists';
return null;
}
@@ -40,6 +43,8 @@ export function liveSearchScopePlaceholderKey(scope: LiveSearchScope | null): st
return 'search.scopeTracksPlaceholder';
case 'composers':
return 'search.scopeComposersPlaceholder';
case 'playlists':
return 'search.scopePlaylistsPlaceholder';
default:
return 'search.placeholder';
}
@@ -62,6 +67,8 @@ export function liveSearchScopeBadgeTooltipKey(scope: LiveSearchScope): string {
return 'search.scopeTracksBadgeTooltip';
case 'composers':
return 'search.scopeComposersBadgeTooltip';
case 'playlists':
return 'search.scopePlaylistsBadgeTooltip';
default:
return 'search.scopeArtistsBadgeTooltip';
}
@@ -79,6 +86,8 @@ export function liveSearchScopeGhostTooltipKey(scope: LiveSearchScope): string {
return 'search.scopeTracksGhostTooltip';
case 'composers':
return 'search.scopeComposersGhostTooltip';
case 'playlists':
return 'search.scopePlaylistsGhostTooltip';
default:
return 'search.scopeArtistsGhostTooltip';
}
@@ -41,6 +41,9 @@ describe('resolveLiveSearchScopeGhost', () => {
expect(resolveLiveSearchScopeGhost('/tracks', 'tracks')).toBeNull();
expect(resolveLiveSearchScopeGhost('/composers', null)).toBe('composers');
expect(resolveLiveSearchScopeGhost('/composers', 'composers')).toBeNull();
expect(resolveLiveSearchScopeGhost('/playlists', null)).toBe('playlists');
expect(resolveLiveSearchScopeGhost('/playlists', 'playlists')).toBeNull();
expect(resolveLiveSearchScopeGhost('/playlists/abc', null)).toBeNull();
});
});
@@ -128,6 +131,7 @@ describe('liveSearchScopePlaceholderKey', () => {
expect(liveSearchScopePlaceholderKey('newReleases')).toBe('search.scopeNewReleasesPlaceholder');
expect(liveSearchScopePlaceholderKey('tracks')).toBe('search.scopeTracksPlaceholder');
expect(liveSearchScopePlaceholderKey('composers')).toBe('search.scopeComposersPlaceholder');
expect(liveSearchScopePlaceholderKey('playlists')).toBe('search.scopePlaylistsPlaceholder');
expect(liveSearchScopePlaceholderKey(null)).toBe('search.placeholder');
});
});
@@ -16,6 +16,9 @@ describe('syncLiveSearchRouteScope', () => {
syncLiveSearchRouteScope('/composers');
expect(useLiveSearchScopeStore.getState().scope).toBe('composers');
syncLiveSearchRouteScope('/playlists');
expect(useLiveSearchScopeStore.getState().scope).toBe('playlists');
});
it('clears scope and query when leaving browse routes', () => {
@@ -27,6 +30,15 @@ describe('syncLiveSearchRouteScope', () => {
expect(useLiveSearchScopeStore.getState().query).toBe('');
});
it('clears scope when entering playlist detail', () => {
useLiveSearchScopeStore.setState({ query: 'road', scope: 'playlists' });
syncLiveSearchRouteScope('/playlists/abc123');
expect(useLiveSearchScopeStore.getState().scope).toBeNull();
expect(useLiveSearchScopeStore.getState().query).toBe('');
});
it('clears query when leaving browse with scope already cleared (ghost mode)', () => {
useLiveSearchScopeStore.setState({ query: 'beatles', scope: null });
@@ -4,6 +4,7 @@ import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '@/features/album';
import { isArtistsBrowsePath } from '@/features/artist';
import { isTracksBrowsePath } from '@/store/advancedSearchSessionStore';
import { isComposersBrowsePath } from '@/features/composers';
import { isPlaylistsBrowsePath } from '@/features/playlist';
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
/** Keep scope badge in sync with browse routes; clear field text when leaving browse. */
@@ -20,6 +21,8 @@ export function syncLiveSearchRouteScope(pathname: string): void {
store.setScope('tracks');
} else if (isComposersBrowsePath(pathname)) {
store.setScope('composers');
} else if (isPlaylistsBrowsePath(pathname)) {
store.setScope('playlists');
} else {
if (store.scope != null) store.clearScope();
if (store.query !== '') store.setQuery('');
@@ -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: 'Изпълнителят не е намерен.',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Създай',
cancel: 'Отказ',
empty: 'Все още няма плейлисти.',
noMatchingSearch: 'Няма плейлисти, които съответстват на търсенето.',
emptyPlaylist: 'Този плейлист е празен.',
addFirstSong: 'Добавете първата си песен',
notFound: 'Плейлистът не е намерен.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Търси композитор…',
scopeComposersBadgeTooltip: 'Кликнете, за да премахнете',
scopeComposersGhostTooltip: 'Кликнете, за да търсите само на тази страница',
scopePlaylistsPlaceholder: 'Търси плейлист…',
scopePlaylistsBadgeTooltip: 'Кликнете, за да премахнете',
scopePlaylistsGhostTooltip: 'Кликнете, за да търсите само на тази страница',
genres: 'Жанрове',
shareLink: 'Връзка за споделяне',
shareTrackTitle: 'Споделена песен',
+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.',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Erstellen',
cancel: 'Abbrechen',
empty: 'Noch keine Playlists.',
noMatchingSearch: 'Keine Playlists entsprechen deiner Suche.',
emptyPlaylist: 'Diese Playlist ist leer.',
addFirstSong: 'Ersten Song hinzufügen',
notFound: 'Playlist nicht gefunden.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Komponist suchen…',
scopeComposersBadgeTooltip: 'Klicken — entfernen',
scopeComposersGhostTooltip: 'Klicken — nur auf dieser Seite suchen',
scopePlaylistsPlaceholder: 'Playlist suchen…',
scopePlaylistsBadgeTooltip: 'Klicken — entfernen',
scopePlaylistsGhostTooltip: 'Klicken — nur auf dieser Seite suchen',
genres: 'Genres',
shareLink: 'Share link',
shareTrackTitle: 'Shared track',
+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.',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Create',
cancel: 'Cancel',
empty: 'No playlists yet.',
noMatchingSearch: 'No playlists match your search.',
emptyPlaylist: 'This playlist is empty.',
addFirstSong: 'Add your first song',
notFound: 'Playlist not found.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Search for composer…',
scopeComposersBadgeTooltip: 'Click to remove',
scopeComposersGhostTooltip: 'Click to search on this page only',
scopePlaylistsPlaceholder: 'Search for playlist…',
scopePlaylistsBadgeTooltip: 'Click to remove',
scopePlaylistsGhostTooltip: 'Click to search on this page only',
genres: 'Genres',
shareLink: 'Share link',
shareTrackTitle: 'Shared track',
+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.',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Crear',
cancel: 'Cancelar',
empty: 'Aún no hay listas.',
noMatchingSearch: 'Ninguna playlist coincide con tu búsqueda.',
emptyPlaylist: 'Esta lista está vacía.',
addFirstSong: 'Agrega tu primera canción',
notFound: 'Lista no encontrada.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Buscar compositor…',
scopeComposersBadgeTooltip: 'Clic — quitar',
scopeComposersGhostTooltip: 'Clic — buscar solo en esta página',
scopePlaylistsPlaceholder: 'Buscar playlist…',
scopePlaylistsBadgeTooltip: 'Clic — quitar',
scopePlaylistsGhostTooltip: 'Clic — buscar solo en esta página',
genres: 'Géneros',
shareLink: 'Share link',
shareTrackTitle: 'Shared track',
+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.',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Créer',
cancel: 'Annuler',
empty: 'Aucune playlist pour l\'instant.',
noMatchingSearch: 'Aucune playlist ne correspond à votre recherche.',
emptyPlaylist: 'Cette playlist est vide.',
addFirstSong: 'Ajouter votre premier titre',
notFound: 'Playlist introuvable.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Rechercher un compositeur…',
scopeComposersBadgeTooltip: 'Clic — retirer',
scopeComposersGhostTooltip: 'Clic — rechercher sur cette page seulement',
scopePlaylistsPlaceholder: 'Rechercher une playlist…',
scopePlaylistsBadgeTooltip: 'Clic — retirer',
scopePlaylistsGhostTooltip: 'Clic — rechercher sur cette page seulement',
genres: 'Genres',
shareLink: 'Share link',
shareTrackTitle: 'Shared track',
+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ó.',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Létrehozás',
cancel: 'Mégse',
empty: 'Még nincsenek lejátszási listák.',
noMatchingSearch: 'Nincs a keresésnek megfelelő lejátszási lista.',
emptyPlaylist: 'Ez a lejátszási lista üres.',
addFirstSong: 'Add hozzá az első dalt',
notFound: 'A lejátszási lista nem található.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Zeneszerző keresése…',
scopeComposersBadgeTooltip: 'Kattints az eltávolításhoz',
scopeComposersGhostTooltip: 'Kattints, hogy csak ezen az oldalon keress',
scopePlaylistsPlaceholder: 'Lejátszási lista keresése…',
scopePlaylistsBadgeTooltip: 'Kattints az eltávolításhoz',
scopePlaylistsGhostTooltip: 'Kattints, hogy csak ezen az oldalon keress',
genres: 'Műfajok',
shareLink: 'Megosztási link',
shareTrackTitle: 'Megosztott szám',
+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.',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Crea',
cancel: 'Annulla',
empty: 'Ancora nessuna playlist.',
noMatchingSearch: 'Nessuna playlist corrisponde alla ricerca.',
emptyPlaylist: 'Questa playlist è vuota.',
addFirstSong: 'Aggiungi il tuo primo brano',
notFound: 'Playlist non trovata.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Cerca un compositore…',
scopeComposersBadgeTooltip: 'Clicca per rimuovere',
scopeComposersGhostTooltip: 'Clicca per cercare solo in questa pagina',
scopePlaylistsPlaceholder: 'Cerca una playlist…',
scopePlaylistsBadgeTooltip: 'Clicca per rimuovere',
scopePlaylistsGhostTooltip: 'Clicca per cercare solo in questa pagina',
genres: 'Generi',
shareLink: 'Condividi link',
shareTrackTitle: 'Brano condiviso',
+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: 'アーティストが見つかりません。',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: '作成',
cancel: 'キャンセル',
empty: 'プレイリストはまだありません。',
noMatchingSearch: '検索に一致するプレイリストはありません。',
emptyPlaylist: 'このプレイリストは空です。',
addFirstSong: '最初の曲を追加',
notFound: 'プレイリストが見つかりません。',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: '作曲者を検索…',
scopeComposersBadgeTooltip: 'クリックして削除',
scopeComposersGhostTooltip: 'クリックしてこのページだけで検索',
scopePlaylistsPlaceholder: 'プレイリストを検索…',
scopePlaylistsBadgeTooltip: 'クリックして削除',
scopePlaylistsGhostTooltip: 'クリックしてこのページだけで検索',
genres: 'ジャンル',
shareLink: '共有リンク',
shareTrackTitle: '共有トラック',
+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.',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Opprett',
cancel: 'Avbryt',
empty: 'Ingen spillelister ennå.',
noMatchingSearch: 'Ingen spillelister matcher søket.',
emptyPlaylist: 'Denne spillelisten er tom.',
addFirstSong: 'Legg til din første sang',
notFound: 'Spillelisten ble ikke funnet.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Søk komponist…',
scopeComposersBadgeTooltip: 'Klikk — fjern',
scopeComposersGhostTooltip: 'Klikk — søk bare på denne siden',
scopePlaylistsPlaceholder: 'Søk etter spilleliste…',
scopePlaylistsBadgeTooltip: 'Klikk — fjern',
scopePlaylistsGhostTooltip: 'Klikk — søk bare på denne siden',
genres: 'Sjangre',
shareLink: 'Share link',
shareTrackTitle: 'Shared track',
+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.',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Aanmaken',
cancel: 'Annuleren',
empty: 'Nog geen playlists.',
noMatchingSearch: 'Geen playlists komen overeen met je zoekopdracht.',
emptyPlaylist: 'Deze playlist is leeg.',
addFirstSong: 'Voeg je eerste nummer toe',
notFound: 'Playlist niet gevonden.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Componist zoeken…',
scopeComposersBadgeTooltip: 'Klik — verwijderen',
scopeComposersGhostTooltip: 'Klik — alleen op deze pagina zoeken',
scopePlaylistsPlaceholder: 'Zoek een playlist…',
scopePlaylistsBadgeTooltip: 'Klik — verwijderen',
scopePlaylistsGhostTooltip: 'Klik — alleen op deze pagina zoeken',
genres: 'Genres',
shareLink: 'Share link',
shareTrackTitle: 'Shared track',
+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.',
+1
View File
@@ -6,6 +6,7 @@ export const playlists = {
create: 'Stwórz',
cancel: 'Anuluj',
empty: 'Nie masz jeszcze żadnych playlist.',
noMatchingSearch: 'Żadna playlista nie pasuje do wyszukiwania.',
emptyPlaylist: 'Ta playlista jest pusta.',
addFirstSong: 'Dodaj pierwszy utwór',
notFound: 'Playlista nie znaleziona.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Szukaj kompozytora…',
scopeComposersBadgeTooltip: 'Kliknij, aby usunąć',
scopeComposersGhostTooltip: 'Kliknij, aby wyszukać tylko na tej stronie',
scopePlaylistsPlaceholder: 'Szukaj playlisty…',
scopePlaylistsBadgeTooltip: 'Kliknij, aby usunąć',
scopePlaylistsGhostTooltip: 'Kliknij, aby wyszukać tylko na tej stronie',
genres: 'Gatunki',
shareLink: 'Udostępnij link',
shareTrackTitle: 'Udostępniony utwór',
+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.',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Crează',
cancel: 'Anulează',
empty: 'Niciun playlist deocamdată.',
noMatchingSearch: 'Niciun playlist nu se potrivește căutării.',
emptyPlaylist: 'Acest playlist este gol.',
addFirstSong: 'Adaugă prima ta piesă',
notFound: 'Playlistul nu a fost găsit.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Caută compozitor…',
scopeComposersBadgeTooltip: 'Clic — elimină',
scopeComposersGhostTooltip: 'Clic — caută doar pe această pagină',
scopePlaylistsPlaceholder: 'Caută playlist…',
scopePlaylistsBadgeTooltip: 'Clic — elimină',
scopePlaylistsGhostTooltip: 'Clic — caută doar pe această pagină',
genres: 'Genuri',
shareLink: 'Share link',
shareTrackTitle: 'Shared track',
+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: 'Исполнитель не найден.',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Создать',
cancel: 'Отмена',
empty: 'Плейлистов пока нет.',
noMatchingSearch: 'Нет плейлистов, подходящих под поиск.',
emptyPlaylist: 'Плейлист пуст.',
addFirstSong: 'Добавьте первый трек',
notFound: 'Плейлист не найден.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Поиск композитора…',
scopeComposersBadgeTooltip: 'Щелчок — удалить',
scopeComposersGhostTooltip: 'Щелчок — искать только на этой странице',
scopePlaylistsPlaceholder: 'Поиск плейлиста…',
scopePlaylistsBadgeTooltip: 'Щелчок — удалить',
scopePlaylistsGhostTooltip: 'Щелчок — искать только на этой странице',
genres: 'Жанры',
shareLink: 'Ссылка для обмена',
shareTrackTitle: 'Общий трек',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: '播放全部作品',
shuffleTooltip: '随机播放全部作品',
radioTooltip: '基于该艺人开始电台',
enqueue: '加入队列',
enqueueTooltip: '将全部作品添加到播放队列',
loading: '加载中…',
noRadio: '未找到该艺术家的相似曲目。',
notFound: '未找到艺术家。',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: '创建',
cancel: '取消',
empty: '暂无播放列表。',
noMatchingSearch: '没有匹配搜索的播放列表。',
emptyPlaylist: '此播放列表为空。',
addFirstSong: '添加第一首歌曲',
notFound: '未找到播放列表。',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: '搜索作曲家…',
scopeComposersBadgeTooltip: '单击 — 移除',
scopeComposersGhostTooltip: '单击 — 仅在此页面搜索',
scopePlaylistsPlaceholder: '搜索播放列表…',
scopePlaylistsBadgeTooltip: '单击 — 移除',
scopePlaylistsGhostTooltip: '单击 — 仅在此页面搜索',
genres: '流派',
shareLink: 'Share link',
shareTrackTitle: 'Shared track',
+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] };
},
}
)
);
+3
View File
@@ -24,6 +24,9 @@ describe('liveSearchScopeStore', () => {
useLiveSearchScopeStore.setState({ query: 'bach', scope: 'composers' });
expect(scopedBrowseSearchQuery('bach', 'composers', 'composers')).toBe('bach');
expect(scopedBrowseSearchQuery('bach', 'artists', 'composers')).toBe('');
useLiveSearchScopeStore.setState({ query: 'road', scope: 'playlists' });
expect(scopedBrowseSearchQuery('road', 'playlists', 'playlists')).toBe('road');
expect(scopedBrowseSearchQuery('road', 'albums', 'playlists')).toBe('');
});
it('undoes query and scope badge changes', () => {
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
/** Page-scoped live search mode — badge in the header search field. */
export type LiveSearchScope = 'artists' | 'albums' | 'newReleases' | 'tracks' | 'composers';
export type LiveSearchScope = 'artists' | 'albums' | 'newReleases' | 'tracks' | 'composers' | 'playlists';
export type LiveSearchSnapshot = {
query: string;
+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;
}