mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-27 01:27:41 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c2a5b8853 | |||
| 0286b9b161 | |||
| 61947ee5a7 | |||
| f66ad3b578 | |||
| 8dfe5f9094 | |||
| 42a84932e6 | |||
| 0d83a6e957 | |||
| d6a03532b2 | |||
| 2fd99c7097 | |||
| 8ec32d7057 | |||
| 38843103b5 | |||
| 688aa5dff6 | |||
| 90b7851785 |
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* Path-aware ci-ok gate: wait for required workflow job checks on a ref, then pass
|
||||
* or fail. Mirrors path filters in frontend-tests.yml, eslint.yml, rust-tests.yml.
|
||||
*/
|
||||
|
||||
const FRONTEND_PATH_RE =
|
||||
/^(src\/|package\.json$|package-lock\.json$|vitest\.config\.ts$|vite\.config\.ts$|tsconfig\.json$|eslint\.config\.mjs$|\.github\/workflows\/frontend-tests\.yml$|\.github\/workflows\/eslint\.yml$|\.github\/frontend-hot-path-files\.txt$|scripts\/check-frontend-hot-path-coverage\.sh$|scripts\/check-css-import-graph\.mjs$)/;
|
||||
|
||||
const RUST_PATH_RE = /^(src-tauri\/|\.github\/workflows\/rust-tests\.yml$)/;
|
||||
|
||||
const FRONTEND_JOBS = [
|
||||
'vitest run',
|
||||
'tsc --noEmit',
|
||||
'vitest --coverage (baseline + hot-path file gate)',
|
||||
'eslint',
|
||||
];
|
||||
|
||||
const RUST_JOBS = [
|
||||
'cargo test --workspace',
|
||||
'cargo clippy --workspace',
|
||||
'cargo llvm-cov (baseline + hot-path file gate)',
|
||||
];
|
||||
|
||||
const POLL_MS = 30_000;
|
||||
const TIMEOUT_MS = 90 * 60 * 1000;
|
||||
const OK_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']);
|
||||
|
||||
export function pathTriggersFrontend(file) {
|
||||
return FRONTEND_PATH_RE.test(file);
|
||||
}
|
||||
|
||||
export function pathTriggersRust(file) {
|
||||
return RUST_PATH_RE.test(file);
|
||||
}
|
||||
|
||||
export function requiredJobNames(changedFiles) {
|
||||
const names = [];
|
||||
if (changedFiles.some(pathTriggersFrontend)) {
|
||||
names.push(...FRONTEND_JOBS);
|
||||
}
|
||||
if (changedFiles.some(pathTriggersRust)) {
|
||||
names.push(...RUST_JOBS);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
export function newestChecksByName(checks, excludeRunId) {
|
||||
const newest = new Map();
|
||||
for (const check of checks) {
|
||||
if (excludeRunId && (check.details_url || '').includes(`/actions/runs/${excludeRunId}/`)) {
|
||||
continue;
|
||||
}
|
||||
const key = check.name;
|
||||
const prev = newest.get(key);
|
||||
if (!prev) {
|
||||
newest.set(key, check);
|
||||
continue;
|
||||
}
|
||||
const prevTime = Date.parse(prev.started_at || prev.completed_at || '') || 0;
|
||||
const curTime = Date.parse(check.started_at || check.completed_at || '') || 0;
|
||||
if (curTime >= prevTime) {
|
||||
newest.set(key, check);
|
||||
}
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
export function evaluateRequiredJobs(required, newestByName) {
|
||||
const pending = [];
|
||||
const failures = [];
|
||||
|
||||
for (const name of required) {
|
||||
const latest = newestByName.get(name);
|
||||
if (!latest) {
|
||||
pending.push(`${name}: not started`);
|
||||
continue;
|
||||
}
|
||||
if (latest.status !== 'completed') {
|
||||
pending.push(`${name}: status=${latest.status}`);
|
||||
continue;
|
||||
}
|
||||
if (!OK_CONCLUSIONS.has(latest.conclusion || '')) {
|
||||
failures.push(`${name}: conclusion=${latest.conclusion}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { pending, failures, done: pending.length === 0 && failures.length === 0 };
|
||||
}
|
||||
|
||||
export async function listChangedFiles(github, context) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
if (context.eventName === 'pull_request') {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
per_page: 100,
|
||||
});
|
||||
return files.map((f) => f.filename);
|
||||
}
|
||||
|
||||
if (context.eventName === 'push') {
|
||||
const before = context.payload.before;
|
||||
const after = context.sha;
|
||||
if (!before || /^0+$/.test(before)) {
|
||||
const commit = await github.rest.repos.getCommit({ owner, repo, ref: after });
|
||||
return commit.data.files?.map((f) => f.filename) ?? [];
|
||||
}
|
||||
const compare = await github.rest.repos.compareCommits({
|
||||
owner,
|
||||
repo,
|
||||
base: before,
|
||||
head: after,
|
||||
});
|
||||
return compare.data.files?.map((f) => f.filename) ?? [];
|
||||
}
|
||||
|
||||
if (context.eventName === 'workflow_run') {
|
||||
const pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (pr?.number) {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
return files.map((f) => f.filename);
|
||||
}
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const commit = await github.rest.repos.getCommit({ owner, repo, ref: headSha });
|
||||
return commit.data.files?.map((f) => f.filename) ?? [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function resolveTargetSha(context) {
|
||||
if (context.eventName === 'pull_request') {
|
||||
return context.payload.pull_request.head.sha;
|
||||
}
|
||||
if (context.eventName === 'workflow_run') {
|
||||
return context.payload.workflow_run.head_sha;
|
||||
}
|
||||
return context.sha;
|
||||
}
|
||||
|
||||
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 required = requiredJobNames(changedFiles);
|
||||
|
||||
core.info(`ci-ok @ ${sha}; ${changedFiles.length} changed file(s)`);
|
||||
if (required.length === 0) {
|
||||
core.info('No path-filtered test workflows apply — ci-ok passes.');
|
||||
return;
|
||||
}
|
||||
core.info(`Waiting for required job checks: ${required.join(', ')}`);
|
||||
|
||||
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,
|
||||
});
|
||||
const newestByName = newestChecksByName(checksAll, excludeRunId);
|
||||
const { pending, failures, done } = evaluateRequiredJobs(required, newestByName);
|
||||
|
||||
if (failures.length > 0) {
|
||||
core.setFailed(`Required checks failed:\n${failures.join('\n')}`);
|
||||
return;
|
||||
}
|
||||
if (done) {
|
||||
core.info('All required checks are green.');
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Pending (${pending.length}): ${pending.join('; ')}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
||||
}
|
||||
|
||||
core.setFailed(`Timed out after ${TIMEOUT_MS / 60_000} minutes waiting for: ${required.join(', ')}`);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
evaluateRequiredJobs,
|
||||
newestChecksByName,
|
||||
pathTriggersFrontend,
|
||||
pathTriggersRust,
|
||||
requiredJobNames,
|
||||
} from './ci-ok-aggregate.mjs';
|
||||
|
||||
test('pathTriggersFrontend matches frontend workflow paths', () => {
|
||||
assert.equal(pathTriggersFrontend('src/App.tsx'), true);
|
||||
assert.equal(pathTriggersFrontend('eslint.config.mjs'), true);
|
||||
assert.equal(pathTriggersFrontend('README.md'), false);
|
||||
});
|
||||
|
||||
test('pathTriggersRust matches rust workflow paths', () => {
|
||||
assert.equal(pathTriggersRust('src-tauri/src/lib.rs'), true);
|
||||
assert.equal(pathTriggersRust('src/App.tsx'), false);
|
||||
});
|
||||
|
||||
test('requiredJobNames unions frontend and rust jobs', () => {
|
||||
const names = requiredJobNames(['src/foo.ts', 'src-tauri/bar.rs']);
|
||||
assert.ok(names.includes('eslint'));
|
||||
assert.ok(names.includes('cargo test --workspace'));
|
||||
});
|
||||
|
||||
test('evaluateRequiredJobs fails on red conclusions', () => {
|
||||
const newest = newestChecksByName([
|
||||
{
|
||||
name: 'eslint',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
started_at: '2026-01-01T00:00:00Z',
|
||||
details_url: '',
|
||||
},
|
||||
]);
|
||||
const result = evaluateRequiredJobs(['eslint'], newest);
|
||||
assert.equal(result.done, false);
|
||||
assert.equal(result.failures.length, 1);
|
||||
});
|
||||
|
||||
test('evaluateRequiredJobs passes when all required jobs succeeded', () => {
|
||||
const checks = ['eslint', 'vitest run'].map((name) => ({
|
||||
name,
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
started_at: '2026-01-01T00:00:00Z',
|
||||
details_url: '',
|
||||
}));
|
||||
const result = evaluateRequiredJobs(['eslint', 'vitest run'], newestChecksByName(checks));
|
||||
assert.equal(result.done, true);
|
||||
});
|
||||
@@ -1,30 +1,15 @@
|
||||
name: ci-main
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_run:
|
||||
workflows: [frontend-tests, rust-tests, eslint]
|
||||
types: [completed]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
ci-ok:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.event.workflow_run.head_sha || github.sha }}
|
||||
- name: aggregate required checks
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { runCiOkAggregate } = await import('${{ github.workspace }}/.github/scripts/ci-ok-aggregate.mjs');
|
||||
await runCiOkAggregate(github, context, core);
|
||||
- name: ci sentinel
|
||||
run: echo "main CI sentinel is green"
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
name: eslint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- 'eslint.config.mjs'
|
||||
- '.github/workflows/eslint.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
- 'scripts/check-css-import-graph.mjs'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- 'eslint.config.mjs'
|
||||
- '.github/workflows/eslint.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
- 'scripts/check-css-import-graph.mjs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
eslint:
|
||||
name: eslint
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- name: eslint
|
||||
run: npm run lint
|
||||
@@ -10,7 +10,6 @@ on:
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- 'eslint.config.mjs'
|
||||
- '.github/workflows/frontend-tests.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
@@ -24,7 +23,6 @@ on:
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- 'eslint.config.mjs'
|
||||
- '.github/workflows/frontend-tests.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
name: Publish to WinGet
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Derive winget version from app-v* tag
|
||||
id: ver
|
||||
env:
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: echo "value=${TAG#app-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Submit to WinGet Community Repository
|
||||
uses: vedantmgoyal9/winget-releaser@v2
|
||||
with:
|
||||
identifier: Psychotoxical.Psysonic
|
||||
version: ${{ steps.ver.outputs.value }}
|
||||
installers-regex: 'Psysonic_.*_x64-setup\.exe$'
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
+34
-197
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
>
|
||||
|
||||
|
||||
## [1.49.0] - 2026-06-29
|
||||
## [1.49.0]
|
||||
|
||||
## Added
|
||||
|
||||
@@ -50,23 +50,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* **Idle auto-pull** when paused/stopped for 30+ seconds on a single-server queue (active = playback): polls every 10s and applies server changes.
|
||||
* **Push** now sends only tracks owned by the playback server (fixes mixed-server queues). Switching browse servers flushes the old server's queue slice without auto-pull.
|
||||
|
||||
### Japanese and Hungarian translations
|
||||
### Japanese translation
|
||||
|
||||
**By [@Soli0222](https://github.com/Soli0222), PR [#1134](https://github.com/Psychotoxical/psysonic/pull/1134)**
|
||||
|
||||
* Full Japanese (日本語) UI translation — selectable from the language picker on the Settings and Login screens.
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1149](https://github.com/Psychotoxical/psysonic/pull/1149)**, a gift to [@falu](https://github.com/falu) for the first independent review of Psysonic
|
||||
|
||||
* Psysonic is now available in **Hungarian (Magyar)** — pick it from the language menu on the Settings and Login screens.
|
||||
|
||||
### Artist artwork from fanart.tv
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1137](https://github.com/Psychotoxical/psysonic/pull/1137) and PR [#1193](https://github.com/Psychotoxical/psysonic/pull/1193)**
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1137](https://github.com/Psychotoxical/psysonic/pull/1137)**
|
||||
|
||||
* New opt-in **External Artwork Scraper** (Settings → Integrations, off by default): artist imagery from fanart.tv — a 16:9 background on the fullscreen player and a wide banner on the artist page — with Navidrome staying the canonical cover. Optional personal key; turning it off removes the fetched images again.
|
||||
* The **mainstage hero** on the home screen now shows the album artist's backdrop too, matching the fullscreen player and artist page.
|
||||
* Choose, per place (mainstage hero, artist page, fullscreen player), which images to use as the background and in what order — drag to reorder or switch a source off, under the same setting. The hero also preloads the upcoming backdrops so they appear without a long blank.
|
||||
|
||||
### Remember the equalizer per audio output device
|
||||
|
||||
@@ -74,6 +68,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* New opt-in **Remember EQ per device** toggle (Settings → Audio → Audio Output Device, off by default): the equalizer profile — bands, pre-gain, enabled state and active preset — is saved per audio output device and restored automatically when you switch devices.
|
||||
|
||||
### Hungarian translation
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1149](https://github.com/Psychotoxical/psysonic/pull/1149)**, a gift to [@falu](https://github.com/falu) for the first independent review of Psysonic
|
||||
|
||||
* Psysonic is now available in **Hungarian (Magyar)** — pick it from the language menu on the Settings and Login screens.
|
||||
|
||||
### Custom HTTP headers for gated servers
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1156](https://github.com/Psychotoxical/psysonic/pull/1156)**, closes [#1095](https://github.com/Psychotoxical/psysonic/issues/1095)
|
||||
@@ -89,58 +89,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* In an Orbit session the host's track-transition settings — crossfade, gapless or AutoDJ, including the crossfade length and smooth-skip — now apply to everyone, so guests blend between tracks the same way the host does instead of each person using their own. Your own settings are restored when you leave.
|
||||
* While you are a guest in a session, the transition controls in Settings → Audio and the queue toolbar are shown as host-controlled.
|
||||
|
||||
### Theme scheduler — follow the system theme
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1163](https://github.com/Psychotoxical/psysonic/pull/1163)**, suggested by [@mokazemi](https://github.com/mokazemi)
|
||||
|
||||
* The theme scheduler can now switch your day/night theme pair based on your operating system's light/dark setting, in addition to the existing time-of-day schedule. Pick the trigger with a new Time of Day / System Theme switch; in system mode the two pickers read as Light and Dark theme. On Linux setups where the OS does not signal the change live, a hint notes it applies after restarting the app.
|
||||
|
||||
### Hi-Res transition blend rate
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1171](https://github.com/Psychotoxical/psysonic/pull/1171)**
|
||||
|
||||
* **Settings → Audio → Native Hi-Res** gains a blend-rate picker (44.1 / 88.2 / 96 kHz, default 44.1 kHz) for transitions when adjacent tracks have different sample rates, with a note that resampling uses extra CPU and memory.
|
||||
* **Crossfade / AutoDJ:** both sides resample to the chosen rate; the output stream reopens when needed and the outgoing track rebuilds from cache so mixed 88.2 ↔ 44.1 kHz transitions no longer tear mid-fade.
|
||||
* **Gapless:** the next track chains at the blend rate and the current track realigns when the stream Hz differs, instead of falling back to a hard cut.
|
||||
|
||||
### AutoDJ — configurable overlap cap
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1173](https://github.com/Psychotoxical/psysonic/pull/1173)**
|
||||
|
||||
* **Settings → Audio → Track transitions → AutoDJ:** choose **Auto** (content-driven overlap, up to 12 s) or **Limit** (slider 2–30 s, default 15 s when enabled) to cap how long AutoDJ may overlap tracks.
|
||||
* The cap applies to end-of-track planning, JS auto-advance, smooth skip, and Orbit transition sync; the audio engine accepts dynamic overlap overrides up to 30 s.
|
||||
|
||||
### Polish translation
|
||||
|
||||
**By [@Rextens](https://github.com/Rextens), PR [#1185](https://github.com/Psychotoxical/psysonic/pull/1185)**
|
||||
|
||||
* Full Polish (Polski) UI translation — selectable from the language picker on the Settings and Login screens.
|
||||
|
||||
### Multiple genres in album details
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1186](https://github.com/Psychotoxical/psysonic/pull/1186)**, suggested by [@Thraka](https://github.com/Thraka)
|
||||
|
||||
* Album details now surface every genre a release spans instead of just the first one: the main genre shows inline with a **+N** chip that opens the full, clickable list, each genre linking to its genre page.
|
||||
* Genres combine album and track tags (matching the genre browser) and read from the local library index when it is ready, so they also work offline.
|
||||
|
||||
### Compact buttons — switch action and toolbar buttons to icon-only
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1189](https://github.com/Psychotoxical/psysonic/pull/1189)**
|
||||
|
||||
* New **Compact buttons** setting under Settings → Appearance. Switch the action and toolbar buttons between large labelled buttons and small icon-only ones — across album, artist and playlist headers, the shared browse toolbars (sort, filters, multi-select), and the Most Played sort/filter controls. Defaults to large, so nothing changes unless you turn it on. On phones the album header keeps its large touch targets.
|
||||
|
||||
### Playlists — sort by date added
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1191](https://github.com/Psychotoxical/psysonic/pull/1191)**, suggested by SinFist
|
||||
|
||||
* Sort a playlist by **Date added** (newest or oldest first), or by title, artist, album and the other columns, from a new sort dropdown in the playlist filter toolbar. The Subsonic API has no per-track "added on" date, so this follows the playlist's own order — servers add new tracks at the end, so newest-first puts your latest additions on top.
|
||||
|
||||
### WinGet update command in the update dialog (Windows)
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1202](https://github.com/Psychotoxical/psysonic/pull/1202)**
|
||||
|
||||
* The Windows update dialog now also shows the WinGet command (`winget upgrade Psysonic`) next to the installer download, so you can update whichever way you installed.
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
@@ -155,22 +103,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* **Settings → Personalisation** gains a **Queue Settings** category that brings the queue display mode, the queue toolbar customizer, and the **Preserve "Play Next" order** toggle (moved here from Audio) together in one place.
|
||||
* On macOS, the **Audio Output Device** category is now hidden rather than showing a notice — playback there always follows the system output device.
|
||||
|
||||
### Russian locale — missing strings and phrasing cleanup
|
||||
|
||||
**By [@kilyabin](https://github.com/kilyabin), PR [#1181](https://github.com/Psychotoxical/psysonic/pull/1181)**
|
||||
|
||||
* Fifty strings that still fell back to English in the Russian UI are now translated — macOS in-place updater, device sync file migration, fullscreen lyrics, and statistics share-image export.
|
||||
* User-facing descriptions in Russian and English no longer mention WebKitGTK or Fisher–Yates internals; several Russian labels and section titles read more naturally (settings casing, smart playlists, track transitions, and home rails).
|
||||
|
||||
### macOS — themed window title bar
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1199](https://github.com/Psychotoxical/psysonic/pull/1199)**, suggested by [@bcorporaal](https://github.com/bcorporaal)
|
||||
|
||||
* On macOS the window's title bar now follows the active theme instead of the grey system bar; the native macOS window buttons stay in place, floating over the themed bar.
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playlists header buttons clipped at narrow widths
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1153](https://github.com/Psychotoxical/psysonic/pull/1153)**
|
||||
|
||||
* The action buttons at the top of the Playlists page (New Playlist, New Smart Playlist, folder controls, Select) could run off-screen and get cut off when the window was narrow or the queue panel was open. They now wrap onto multiple rows, left-aligned.
|
||||
|
||||
### Seeking in streamed Opus/Ogg tracks
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1110](https://github.com/Psychotoxical/psysonic/pull/1110)**
|
||||
@@ -214,24 +155,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* Niri is now recognized as a tiling window manager (`NIRI_SOCKET`, `XDG_CURRENT_DESKTOP=niri`), so it gets the same custom title bar, window decorations, and mini-player behavior as Hyprland and Sway instead of being treated like a floating desktop.
|
||||
|
||||
### Play queue sync — follow-up fixes
|
||||
### Play queue idle pull overwrote local edits
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1132](https://github.com/Psychotoxical/psysonic/pull/1132)**
|
||||
|
||||
* After cross-device idle pull while paused, a local queue change (e.g. enqueue) could be overwritten when auto-pull ran again. Idle auto-pull now stops on local mutations until manual sync from the header; the connection LED turns yellow while auto-sync is paused.
|
||||
|
||||
### Paused client did not push edited queue on Play
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1133](https://github.com/Psychotoxical/psysonic/pull/1133)**
|
||||
|
||||
* After editing the queue while paused (yellow sync LED), pressing Play only resumed audio and could leave the server on another device's queue until the debounced push fired. Resume and play-from-queue now flush the local play queue immediately and clear the yellow indicator when the push succeeds.
|
||||
|
||||
### Connection indicator flapping on flaky links
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1135](https://github.com/Psychotoxical/psysonic/pull/1135)**
|
||||
|
||||
* The header connection probe now retries a failed ping twice (2 s apart) before marking the server unreachable, so a single dropped packet on an otherwise fine link no longer flips the LED to disconnected.
|
||||
|
||||
### Yellow sync LED during normal playback
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1136](https://github.com/Psychotoxical/psysonic/pull/1136)**
|
||||
|
||||
* Track-advance queue pushes no longer suspend idle auto-pull, so the connection LED does not flash yellow on every song change. Yellow sync still appears after a local queue edit while paused; it clears while audio is playing.
|
||||
|
||||
### Update notification — clearer popup on Linux
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1142](https://github.com/Psychotoxical/psysonic/pull/1142)**, reported by zunoz on Discord
|
||||
|
||||
* The "new version available" popup no longer shows blurry, unfocused text on some Linux setups (the background blur could bleed onto the dialog). The version arrow now lines up with the heading, and the Skip / Remind me later buttons read clearly — Remind me later is the highlighted action when there's no in-app installer.
|
||||
|
||||
### Favorites — bulk add to playlist and play/enqueue selected
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#1140](https://github.com/Psychotoxical/psysonic/pull/1140)**
|
||||
@@ -240,18 +193,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* With rows selected, **Play all** / **Add all to queue** become **Play selected** / **Add selected to queue** and act on the checked tracks only.
|
||||
* Bulk add now snapshots every checked row when the picker opens so all selected tracks land in the playlist, not just the last one.
|
||||
|
||||
### Update notification — clearer popup on Linux
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1142](https://github.com/Psychotoxical/psysonic/pull/1142)**, reported by zunoz on Discord
|
||||
|
||||
* The "new version available" popup no longer shows blurry, unfocused text on some Linux setups (the background blur could bleed onto the dialog). The version arrow now lines up with the heading, and the Skip / Remind me later buttons read clearly — Remind me later is the highlighted action when there's no in-app installer.
|
||||
|
||||
### Artists letter index — Navidrome ignored articles and library index
|
||||
### Artists letter index — Navidrome ignored articles
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1145](https://github.com/Psychotoxical/psysonic/pull/1145)**, closes [#1144](https://github.com/Psychotoxical/psysonic/issues/1144)
|
||||
|
||||
* On the **Artists** page (and **Composers**), the A–Z filter now groups names like Navidrome: leading articles such as **The** are skipped before picking the letter — **The Beatles** lands under **B**, not **T**. The bucket follows the server's own `ignoredArticles` list when the local index knows it.
|
||||
* The local library index stores `name_sort` and the server's `ignoredArticles` from `getArtists`, sorts browse SQL by the sort key (now indexed), and repairs stale keys once on upgrade.
|
||||
|
||||
### Library index — safer open, swap and recovery
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1145](https://github.com/Psychotoxical/psysonic/pull/1145)**
|
||||
|
||||
* The local library database now opens, swaps and restores through one pipeline, so a swapped or restored file always picks up pending migrations and one-time repairs instead of serving a stale schema.
|
||||
* A panic or a poisoned lock in one query no longer wedges the whole library index — connections recover and report the error instead, and the new sort-key migration applies idempotently so a half-applied upgrade self-heals on the next launch.
|
||||
|
||||
@@ -269,12 +221,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* **Favorites** on All Albums uses the same `getStarred2` catalog path as the Favorites page instead of the empty sparse `album` table browse.
|
||||
* Pre-index compilation filtering auto-paginates again in network page mode; offline library aggregates set `isCompilation` from track tags.
|
||||
|
||||
### Playlists header buttons clipped at narrow widths
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1153](https://github.com/Psychotoxical/psysonic/pull/1153)**
|
||||
|
||||
* The action buttons at the top of the Playlists page (New Playlist, New Smart Playlist, folder controls, Select) could run off-screen and get cut off when the window was narrow or the queue panel was open. They now wrap onto multiple rows, left-aligned.
|
||||
|
||||
### Orbit — session reliability fixes
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#1155](https://github.com/Psychotoxical/psysonic/pull/1155), [#1157](https://github.com/Psychotoxical/psysonic/pull/1157), [#1159](https://github.com/Psychotoxical/psysonic/pull/1159)**
|
||||
@@ -287,115 +233,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* Guest suggestions no longer get silently lost or stuck on "waiting on host": overlapping host updates are serialised, a lost suggestion is re-sent (with a notice if it still can't get through), and a flaky join no longer leaves a duplicate suggestion list on the server.
|
||||
* Pasted invites are rejected unless they point at a normal http/https server address.
|
||||
|
||||
### macOS dock icon larger than native apps
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1169](https://github.com/Psychotoxical/psysonic/pull/1169)**, closes [#1166](https://github.com/Psychotoxical/psysonic/issues/1166)
|
||||
|
||||
* On macOS the dock icon was rendered edge-to-edge and looked larger than other apps; it is now padded to Apple's icon grid so it matches native sizing.
|
||||
|
||||
### Artist header showing the plain image instead of the external background
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1172](https://github.com/Psychotoxical/psysonic/pull/1172)**
|
||||
|
||||
* On the artist page, when an artist had an external background image (from fanart.tv) but no banner, the header showed the plain Navidrome artist image instead of the background — even though the fullscreen player used the background correctly. The header now falls back banner → background → Navidrome image as intended. The background also sits a little higher so band members' heads aren't cropped on wide screens.
|
||||
|
||||
### Context menu "Play Now" and resize behaviour
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1174](https://github.com/Psychotoxical/psysonic/pull/1174)**, reported by [@peri4ko](https://github.com/peri4ko)
|
||||
|
||||
* On the Playlists page, right-clicking a playlist and choosing "Play Now" only opened the playlist instead of playing it. It now starts playback.
|
||||
* Resizing the window while a context menu was open could leave the menu stranded and drifting off-screen. The context menu now closes when the window is resized.
|
||||
|
||||
### Genres page kept empty genres after tag changes
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1176](https://github.com/Psychotoxical/psysonic/pull/1176)**, closes [#1162](https://github.com/Psychotoxical/psysonic/issues/1162)
|
||||
|
||||
* After retagging a track and resyncing the library, genres with no remaining albums could still appear on the Genres page until restart. The local genre catalog now counts only live indexed tracks, filters zero-count genres, and the Genres page refreshes when library sync finishes.
|
||||
|
||||
### AutoDJ — last track in the queue was cut short
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1183](https://github.com/Psychotoxical/psysonic/pull/1183)**
|
||||
|
||||
* With AutoDJ active and no next track to blend into, the engine could still fire the crossfade end timer and trim the final song. The last track now plays through to real source exhaustion.
|
||||
|
||||
### Play queue sync — idle pull rewound after the queue finished
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1183](https://github.com/Psychotoxical/psysonic/pull/1183)**
|
||||
|
||||
* After the last track ended (repeat off), idle auto-pull could restore an earlier server position from the last debounced push and seek backward. The client now flushes end-of-track position to the server and skips idle auto-pull until playback resumes, the queue is edited, or the user pulls manually.
|
||||
|
||||
### Sidebar — offline nav gating after manual reconnect Retry
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1190](https://github.com/Psychotoxical/psysonic/pull/1190)**, closes [#1160](https://github.com/Psychotoxical/psysonic/issues/1160)
|
||||
|
||||
* Strengthens the existing disconnect/recovery path: connection status is now shared across all `useConnectionStatus` hook instances, so a successful **Retry** on the offline banner clears offline-browse sidebar filtering in step with the header connection indicator (no app restart).
|
||||
|
||||
### Timeline play history disappeared on album/playlist play
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1204](https://github.com/Psychotoxical/psysonic/pull/1204)**, closes [#1096](https://github.com/Psychotoxical/psysonic/issues/1096)
|
||||
|
||||
* Timeline mode now keeps a session play-history strip (plus cold bootstrap of the last 50 plays from statistics) when Play album/playlist replaces the queue; canonical queue sync is unchanged.
|
||||
* The current track stays pinned to the top of the list; clicking a history row inserts after the playing track instead of replacing the queue, and replayed tracks remain in the history strip.
|
||||
* History rows from other servers resolve album/cover metadata per server so Now Playing artwork loads when replaying cross-server plays.
|
||||
* Cross-server queue switches now send `playbackReport` **stopped** to the previous server so its Who is listening entry clears promptly.
|
||||
|
||||
### Album and artist covers — full resolution restored
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1205](https://github.com/Psychotoxical/psysonic/pull/1205)**
|
||||
|
||||
* Album and artist covers — and the full-size view when you click a cover — could appear small and low-quality even though the source image was large, depending on how you reached the album. Root cause: the cache built its larger sizes from a smaller already-saved size instead of the full-resolution download, so they were stored downscaled. Covers are now built from the full-resolution image, and the full-size view opens at full resolution. The cover cache refreshes once on update. Reported by users on Discord.
|
||||
|
||||
## Under the Hood
|
||||
|
||||
### WinGet — automated manifest updates on release
|
||||
|
||||
**By [@ImAsra](https://github.com/ImAsra), PR [#1077](https://github.com/Psychotoxical/psysonic/pull/1077)**
|
||||
|
||||
* New GitHub Actions workflow publishes Windows installer updates to `microsoft/winget-pkgs` on each release — scans the `_x64-setup.exe` asset, computes SHA-256, and opens the upstream PR via `winget-releaser`.
|
||||
|
||||
### ESLint setup and a strict lint pass over the frontend
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1165](https://github.com/Psychotoxical/psysonic/pull/1165)**
|
||||
|
||||
* Added an ESLint config and `npm run lint`, and brought `src/` to zero errors and warnings under the strict React-hooks ruleset. Developer-only — no user-facing behaviour change.
|
||||
|
||||
### CI — ESLint gate and path-aware ci-ok merge check
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1170](https://github.com/Psychotoxical/psysonic/pull/1170)**
|
||||
|
||||
* Strict `npm run lint` runs in CI on frontend path filters via a dedicated workflow parallel to the existing frontend test jobs.
|
||||
* The `ci-ok` check waits for every applicable test and lint job on a PR (frontend and/or Rust, depending on changed paths) and blocks merge when any required job failed or did not finish in time.
|
||||
|
||||
### Settings — consistent design for the Audio sub-sections
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1175](https://github.com/Psychotoxical/psysonic/pull/1175)**
|
||||
|
||||
* The AutoDJ overlap-cap and the Native Hi-Res blend-rate options in Settings → Audio now sit in the same bordered sub-card the Normalization options use, and the Hi-Res section no longer shows a double border.
|
||||
|
||||
### App no longer blanks on an unexpected error
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1194](https://github.com/Psychotoxical/psysonic/pull/1194)**
|
||||
|
||||
* If a screen hit an unexpected rendering error, the whole window could go blank with no way back. The app now shows a small recoverable error card (Try again / Reload app) instead, and playback keeps going.
|
||||
|
||||
### Windows update notice waits out WinGet moderation
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1200](https://github.com/Psychotoxical/psysonic/pull/1200)**
|
||||
|
||||
* On Windows, the "update available" notice now waits until a release is a couple of days old, so it no longer points to a version that WinGet has not finished publishing yet. macOS and Linux are unaffected.
|
||||
|
||||
### Playlist no longer reloads when you press Play
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1201](https://github.com/Psychotoxical/psysonic/pull/1201)**
|
||||
|
||||
* Pressing Play, Shuffle or Add to queue on a playlist no longer reloads the whole page with a spinner — it just starts playback. Editing the playlist (adding or removing songs) still refreshes the list as before.
|
||||
|
||||
### Sidebar items jumped back when reordered
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1206](https://github.com/Psychotoxical/psysonic/pull/1206)**, reported by [@tummydummy](https://github.com/tummydummy)
|
||||
|
||||
* In Settings → Personalization → Sidebar, dragging an item to a new position could snap it back or land it one place off, depending on which items were hidden. Reordering now tracks each item directly, so it stays exactly where you release it — both in the customizer and when long-pressing items in the sidebar itself.
|
||||
|
||||
## [1.48.1] - 2026-06-15
|
||||
|
||||
## Fixed
|
||||
|
||||
+3
-6
@@ -73,7 +73,7 @@ If you use **Nix**, `nix develop` (see [`flake.nix`](flake.nix)) provides the pi
|
||||
| Topic | Location |
|
||||
|--------|----------|
|
||||
| Frontend test stack (Vitest, Tauri/Subsonic mocks, store resets, i18n in tests) | [`src/test/README.md`](src/test/README.md) |
|
||||
| What CI runs for frontend / backend | [`frontend-tests.yml`](.github/workflows/frontend-tests.yml), [`eslint.yml`](.github/workflows/eslint.yml), [`rust-tests.yml`](.github/workflows/rust-tests.yml) |
|
||||
| What CI runs for frontend / backend | [`frontend-tests.yml`](.github/workflows/frontend-tests.yml), [`rust-tests.yml`](.github/workflows/rust-tests.yml) |
|
||||
| Frontend "hot path" files held to a coverage threshold | [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt), [`check-frontend-hot-path-coverage.sh`](scripts/check-frontend-hot-path-coverage.sh) |
|
||||
| Rust hot-path gate | [`hot-path-files.txt`](.github/hot-path-files.txt), [`check-hot-path-coverage.sh`](scripts/check-hot-path-coverage.sh) |
|
||||
| Nix packaging / release automation | [`flake.nix`](flake.nix), workflows under [`.github/workflows/`](.github/workflows/) |
|
||||
@@ -84,7 +84,7 @@ If you use **Nix**, `nix develop` (see [`flake.nix`](flake.nix)) provides the pi
|
||||
|
||||
1. **One pull request, one coherent goal.** Easier review, easier revert, fewer merge conflicts.
|
||||
2. **Match existing style** in touched files (naming, module layout, comment density). Avoid drive-by refactors unrelated to the task.
|
||||
3. **Linting and formatting:** ESLint (strict `eslint.config.mjs`) runs in CI on frontend paths; run `npm run lint` locally before opening a frontend PR. `tsc --noEmit` is also required. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt.
|
||||
3. **Linting and formatting:** there is no enforced JS/TS formatter or ESLint config in the repo today — `tsc --noEmit` is the only frontend gate beyond tests. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt.
|
||||
4. **Commit messages:** a short **human-readable** summary of what changed and why; Conventional Commits-style prefixes (`feat:`, `fix:`, ...) are fine if you prefer them. Do not include meta references (IDEs, assistants, or how the message was produced) — only what matters for project history.
|
||||
5. **License:** new code must remain compatible with the project's GPLv3.
|
||||
6. **Tests:** when you change behaviour users rely on, add or update tests next to the code (see [`src/test/README.md`](src/test/README.md)). Purely visual tweaks may not need tests, but behavioural regressions should be covered where the suite can catch them.
|
||||
@@ -114,11 +114,9 @@ PRs must target `main`. `next` and `release` are maintainer-driven promotion bra
|
||||
|
||||
Workflows are path-filtered (see the YAML for exact `paths` / `paths-ignore`):
|
||||
|
||||
- **Frontend** (`src/**`, lockfile, Vitest/Vite/tsconfig, ESLint config, etc.): `npm run lint` (ESLint strict), `npm test` (Vitest), `npx tsc --noEmit`, then a coverage run.
|
||||
- **Frontend** (`src/**`, lockfile, Vitest/Vite/tsconfig, etc.): `npm test` (Vitest), `npx tsc --noEmit`, then a coverage run.
|
||||
- **Rust** (`src-tauri/**`): `cargo test --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings`, then coverage.
|
||||
|
||||
The **`ci-ok`** job in [`ci-main.yml`](.github/workflows/ci-main.yml) is the merge gate: it waits for every required job above whose path filter matched the PR, and fails if any of them failed or did not finish in time.
|
||||
|
||||
Hot-path coverage gates are **required** on pull requests: the `coverage` jobs in [`frontend-tests.yml`](.github/workflows/frontend-tests.yml) and [`rust-tests.yml`](.github/workflows/rust-tests.yml) fail when any listed file drops below the floor. See the headers in [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt) and [`hot-path-files.txt`](.github/hot-path-files.txt) for curation rules and thresholds.
|
||||
|
||||
---
|
||||
@@ -131,7 +129,6 @@ Assume the repository root is `psysonic/` (for example after `git clone https://
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run lint
|
||||
npm test
|
||||
npx tsc --noEmit
|
||||
npm run test:coverage
|
||||
|
||||
@@ -36,7 +36,6 @@ Some Psysonic features can communicate with external services, such as:
|
||||
- Last.fm
|
||||
- Bandsintown
|
||||
- Discord Rich Presence
|
||||
- Fanart.tv
|
||||
|
||||
These integrations are optional and clearly presented as opt-in features. They are never required for using Psysonic.
|
||||
|
||||
|
||||
+16
-83
@@ -12,121 +12,54 @@ Within each section, order by **user impact** (most noticeable first) — not PR
|
||||
|
||||
## Highlights
|
||||
|
||||
### Play queue sync — pick up where you left off on another device
|
||||
|
||||
- Click the header connection indicator to **pull** the active server's play queue when it differs from yours; a yellow LED shows when browse and playback servers do not match.
|
||||
- While paused or stopped, **idle auto-pull** checks every 10 seconds and applies server changes when you have been still for 30+ seconds.
|
||||
- Queue **push** sends only tracks owned by the playback server, so mixed-server queues stay sane when you switch servers.
|
||||
- Local queue edits while paused are no longer overwritten by auto-pull; pressing **Play** pushes your changes immediately, and the sync LED no longer flashes on every track during normal playback.
|
||||
- After the last track ends with repeat off, idle pull no longer rewinds to an earlier server position — the queue stays where playback finished.
|
||||
|
||||
### AutoDJ — minimum pauses, maximum music
|
||||
|
||||
- New **AutoDJ** mode — a smart crossfade that blends tracks intelligently: it trims dead air, rides natural fades, and keeps handovers musical instead of abrupt. Its own button in the queue toolbar and its own entry under **Settings → Audio**, alongside Crossfade and Gapless — only one at a time. Off by default; classic **Crossfade** is unchanged.
|
||||
- **Smooth skip** (on by default with AutoDJ) crossfades manual Next/Previous and track picks from where you are listening instead of hard-cutting; the play/pause button pulses while a blend is active.
|
||||
- Cap how long overlaps may last: **Auto** (content-driven, up to 12 s) or **Limit** (slider 2–30 s) under **Settings → Audio → Track transitions**.
|
||||
- The last track in the queue plays through to the end instead of being trimmed when nothing follows.
|
||||
- New **AutoDJ** mode — a smart crossfade that blends tracks intelligently. Each transition is shaped to fit the music: awkward gaps fade away, handovers feel natural, and you spend less time in silence between songs. It's now its own choice in **Settings → Audio** and its own button in the queue toolbar, sitting alongside Crossfade and Gapless — pick one at a time. Off by default; classic **Crossfade** is unchanged.
|
||||
|
||||
### Playlist folders — your playlists, organised
|
||||
|
||||
- Folders on the **Playlists** page and in the sidebar keep long lists tidy — group by mood, occasion, or anything you like. Drag playlists in, rename and collapse folders, or choose **Move to folder** from the right-click menu. Switch back to a flat list whenever you prefer.
|
||||
|
||||
### Settings — tidier and easier to scan
|
||||
|
||||
- Settings are grouped into clear, labelled panels so related options sit together — less hunting around. The **Native Hi-Res Playback** option now explains in plain language what it actually does.
|
||||
- **Normalization** and **Track transitions** are now their own sections under **Settings → Audio**, and the queue options (display mode, toolbar, and Play-Next order) are gathered into one **Queue Settings** group under **Personalisation**.
|
||||
|
||||
### Japanese, Hungarian, and Polish — now in your language
|
||||
|
||||
- Psysonic is now available in **Japanese (日本語)**, **Hungarian (Magyar)**, and **Polish (Polski)** — pick any of them from the language menu on the **Settings** and **Login** screens.
|
||||
|
||||
### Theme store — spot updates, pick your style
|
||||
|
||||
- Version numbers on store themes and ones you have installed make it obvious when an update is ready.
|
||||
- Filter for **animated** or **static** themes only — less scrolling when you already know the look you want.
|
||||
|
||||
### Hi-Res playback — smoother transitions between sample rates
|
||||
### Settings — tidier and easier to scan
|
||||
|
||||
- Under **Settings → Audio → Native Hi-Res**, choose a **blend rate** (44.1 / 88.2 / 96 kHz) for crossfade, AutoDJ, and gapless when adjacent tracks differ in sample rate — mixed 88.2 ↔ 44.1 kHz handovers no longer tear mid-transition.
|
||||
- Settings are grouped into clear, labelled panels so related options sit together — less hunting around. The **Native Hi-Res Playback** option now explains in plain language what it actually does.
|
||||
- **Normalization** and **Track transitions** are now their own sections under **Settings → Audio**, and the queue options (display mode, toolbar, and Play-Next order) are gathered into one **Queue Settings** group under **Personalisation**.
|
||||
|
||||
### Artist artwork — richer home, artist, and fullscreen views
|
||||
### Japanese — now in your language
|
||||
|
||||
- Switch on **External Artwork Scraper** under **Settings → Integrations** to pull artist imagery from fanart.tv: a wide backdrop on the fullscreen player, a banner across the top of the artist page, and now the artist's backdrop behind the home screen's **mainstage** too. Off by default, your Navidrome covers stay in charge, and turning it back off removes the fetched images again.
|
||||
- Choose which images each place uses as its background, and in what order — drag to reorder or switch a source off — right under the same setting. The mainstage also loads the next backdrops ahead of time so they appear without a blank gap.
|
||||
- Psysonic is now available in **Japanese (日本語)** — pick it from the language menu on the **Settings** and **Login** screens.
|
||||
|
||||
### Artist artwork — richer artist and fullscreen views
|
||||
|
||||
- Switch on **External Artwork Scraper** under **Settings → Integrations** to pull artist imagery from fanart.tv: a wide backdrop on the fullscreen player and a banner across the top of the artist page. Off by default, your Navidrome covers stay in charge, and turning it back off removes the fetched images again.
|
||||
|
||||
### Equalizer — a profile per output device
|
||||
|
||||
- Turn on **Remember EQ per device** under **Settings → Audio** and Psysonic keeps a separate equalizer setup for each output — speakers, headphones, a USB DAC — and switches to the right one automatically when you change devices. Off by default.
|
||||
|
||||
### Orbit — everyone hears transitions the host chose
|
||||
|
||||
- In a shared **Orbit** session, the host's crossfade, gapless, or AutoDJ settings — including length and smooth skip — apply to all guests until you leave. Transition controls in **Settings → Audio** and the queue toolbar show as host-controlled while you are a guest.
|
||||
|
||||
### Themes — follow your system's light and dark mode
|
||||
|
||||
- The theme scheduler can now match your **system's light/dark setting** instead of a fixed clock: pick a light theme and a dark one, and Psysonic switches along with your OS. Choose **Time of Day** or **System Theme** under **Settings → Themes** — the existing time-based schedule is still there.
|
||||
|
||||
### Servers behind a reverse proxy — custom HTTP headers
|
||||
|
||||
- Per-server **custom HTTP headers** in **Settings → Servers** for Cloudflare Access, Pangolin, and similar gates — applied to library sync, playback, covers, offline download, and the rest without putting secrets in invite links.
|
||||
|
||||
### Album details — every genre, not just the first
|
||||
|
||||
- Album details now show **all** the genres a release spans: the main genre appears inline with a **+N** chip that opens the full, clickable list, each genre linking to its own page. Genres combine album and track tags and read from the local library index, so they work offline too.
|
||||
|
||||
### Compact buttons — switch to icon-only controls
|
||||
|
||||
- New **Compact buttons** option under **Settings → Appearance** switches the action and toolbar buttons between large labelled buttons and small icon-only ones — across album, artist and playlist headers, the shared browse toolbars, and the Most Played controls. Defaults to large; on phones the album header keeps its large touch targets.
|
||||
|
||||
### Playlists — sort by date added
|
||||
|
||||
- Sort a playlist by **Date added** (newest or oldest first), or by title, artist, album and the other columns, from a new sort dropdown in the playlist toolbar. The Subsonic API has no per-track "added on" date, so this follows the playlist's own order — servers add new tracks at the end, so newest-first puts your latest additions on top.
|
||||
|
||||
## Improved
|
||||
|
||||
- **macOS:** the window's title bar now follows the active theme instead of the grey system bar; the native window buttons stay in place, floating over the themed bar.
|
||||
- Pressing **Play**, **Shuffle**, or **Add to queue** on a playlist starts playback without reloading the whole page with a spinner — editing the playlist still refreshes the list as before.
|
||||
- Dragging sidebar items in **Settings → Personalisation → Sidebar** (or long-pressing in the sidebar itself) keeps each item exactly where you release it — no snap-back or off-by-one landing.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- **Timeline** mode keeps your session play-history strip when you **Play** an album or playlist; the current track stays pinned at the top, and replaying a history row inserts after the playing track instead of replacing the queue.
|
||||
- **Opus/Ogg** tracks no longer fight the seekbar while they are still loading — scrub to where you want to be and keep listening.
|
||||
- The equalizer preset picker shows the active **AutoEQ** profile name again instead of going blank.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
- The **Live** listener count in the header stays up to date even when the "Who is listening?" popover is closed.
|
||||
|
||||
### Browse and library
|
||||
|
||||
- Album and artist covers — and the full-size view when you click a cover — open at full resolution again instead of looking soft or small.
|
||||
- Albums sorted by artist now list each artist's work A–Z by title — no more random order within a name.
|
||||
- **Artist → Year** keeps artists grouped but walks through their albums chronologically, oldest first.
|
||||
- Genres with no remaining tracks disappear after you retag and resync the library, without restarting the app.
|
||||
- The **Artists** A–Z index matches Navidrome ignored articles — **The Beatles** lands under **B**, not **T**.
|
||||
- **All Albums → Only compilations** and **Favorites** return the albums you expect instead of an empty or partial list.
|
||||
|
||||
### Player and playlists
|
||||
|
||||
- **Add to playlist** from the player bar adds the song you are hearing, not the whole album.
|
||||
- On **Favorites**, bulk **Add to playlist** and **Play selected** / **Add selected to queue** act on every checked row.
|
||||
- **Play Now** on a playlist in the right-click menu starts playback instead of only opening the list.
|
||||
- Playlists page header buttons wrap on narrow windows instead of clipping off-screen when the queue panel is open.
|
||||
|
||||
### Other
|
||||
### Browse and library
|
||||
|
||||
- Albums sorted by artist now list each artist's work A–Z by title — no more random order within a name.
|
||||
- **Artist → Year** keeps artists grouped but walks through their albums chronologically, oldest first.
|
||||
|
||||
### Windows
|
||||
|
||||
- **Orbit** sessions stay reliable on long listens — guests keep receiving updates, radio no longer pollutes the shared queue, and opening Psysonic on a second device does not delete a live session elsewhere.
|
||||
- On the artist page, the header uses the fanart.tv background when no banner is available — the same image the fullscreen player already showed.
|
||||
- **Windows:** Previous, Play/Pause, and Next are back when you hover the taskbar icon — and Play/Pause shows whether music is playing or paused.
|
||||
- **macOS:** the dock icon matches native app sizing instead of looking oversized.
|
||||
- **Linux:** **Niri** is recognised as a tiling compositor and gets the same custom title bar behaviour as Hyprland and Sway; the "new version available" popup reads clearly on setups where the background blur used to bleed through.
|
||||
|
||||
## Under the hood
|
||||
|
||||
- If a screen hits an unexpected error, the app now shows a small recoverable card (**Try again** / **Reload app**) and keeps playing, instead of the whole window going blank.
|
||||
|
||||
|
||||
## [1.48.1]
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import eslint from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
// `scripts/` (Node CI helpers) is intentionally ignored — this config targets the browser `src/` tree.
|
||||
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts'] },
|
||||
// This gradual baseline deliberately omits the React Compiler rules (set-state-in-effect, refs,
|
||||
// immutability, …) that the strict config enables. The per-line `eslint-disable-next-line` directives
|
||||
// those rules require therefore read as "unused" here, so unused-directive reporting is turned off for
|
||||
// this config only — the strict config keeps the default reporting and stays 0/0.
|
||||
{ linterOptions: { reportUnusedDisableDirectives: 'off' } },
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,42 +0,0 @@
|
||||
import eslint from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
// `scripts/` (Node CI helpers) is intentionally ignored — this config targets the
|
||||
// browser `src/` tree. See `npm run lint:scripts` if scripts ever need a Node-globals lint pass.
|
||||
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts'] },
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
// Promote deps to error at strict stage (wave 6+)
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1782467914,
|
||||
"narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
|
||||
"lastModified": 1779560665,
|
||||
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
|
||||
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-ujJETHWrjZXDt+c0GGlsK/v8L8Ceturp16I3VxQvUo0="
|
||||
"npmDepsHash": "sha256-Y5gZccVREEG9XpYJ/MTtoDn7lC3u9sKiZ8tN3OyEYCU="
|
||||
}
|
||||
|
||||
Generated
+24
-1619
File diff suppressed because it is too large
Load Diff
+2
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.49.0",
|
||||
"version": "1.49.0-dev",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check:css-imports": "node scripts/check-css-import-graph.mjs",
|
||||
@@ -9,10 +9,8 @@
|
||||
"build": "npm run prebuild:release-notes && tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "npm run prebuild:release-notes && tauri dev --config src-tauri/tauri.dev.conf.json",
|
||||
"tauri:dev": "npm run prebuild:release-notes && tauri dev",
|
||||
"tauri:build": "npm run prebuild:release-notes && tauri build",
|
||||
"lint": "eslint -c eslint.config.mjs src",
|
||||
"lint:gradual": "eslint -c eslint.config.gradual.mjs src",
|
||||
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs && npm run check:css-imports",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "npm run prebuild:release-notes && vitest run --coverage && npm run check:css-imports"
|
||||
@@ -55,7 +53,6 @@
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -68,13 +65,8 @@
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "^8.0.14",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
|
||||
Generated
+7
-7
@@ -4114,7 +4114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.49.0"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4167,7 +4167,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-analysis"
|
||||
version = "1.49.0"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"ebur128",
|
||||
"futures-util",
|
||||
@@ -4186,7 +4186,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-audio"
|
||||
version = "1.49.0"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4216,7 +4216,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-core"
|
||||
version = "1.49.0"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"reqwest",
|
||||
@@ -4227,7 +4227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-integration"
|
||||
version = "1.49.0"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"discord-rich-presence",
|
||||
"futures-util",
|
||||
@@ -4244,7 +4244,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-library"
|
||||
version = "1.49.0"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"psysonic-core",
|
||||
"psysonic-integration",
|
||||
@@ -4259,7 +4259,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-syncfs"
|
||||
version = "1.49.0"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"id3",
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.49.0"
|
||||
version = "1.49.0-dev"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
@@ -13,7 +13,6 @@ use tauri::{AppHandle, Emitter, State};
|
||||
use super::decode::build_source;
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::*;
|
||||
use super::hi_res_blend::{self, OutgoingBlendSnapshot};
|
||||
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
|
||||
use super::play_input::{select_play_input, url_format_hint, PlayInputContext};
|
||||
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs};
|
||||
@@ -52,7 +51,6 @@ pub async fn audio_play(
|
||||
fallback_db: f32,
|
||||
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
||||
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
|
||||
hi_res_crossfade_resample_hz: Option<u32>, // 44100 / 88200 / 96000 when hi-res + crossfade
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
stream_format_suffix: Option<String>,
|
||||
@@ -247,12 +245,9 @@ pub async fn audio_play(
|
||||
state.crossfade_enabled.load(Ordering::Relaxed) && (!manual || manual_blend);
|
||||
// Per-transition override (dynamic crossfade) caps the fade for this swap;
|
||||
// otherwise fall back to the global crossfade length. Both clamped the same.
|
||||
let crossfade_secs_val = if let Some(override_secs) = crossfade_secs_override {
|
||||
override_secs.clamp(0.5, 30.0)
|
||||
} else {
|
||||
f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed))
|
||||
.clamp(0.5, 12.0)
|
||||
};
|
||||
let crossfade_secs_val = crossfade_secs_override
|
||||
.unwrap_or_else(|| f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)))
|
||||
.clamp(0.5, 12.0);
|
||||
|
||||
// Measure how much audio Track A actually has left right now.
|
||||
// By the time audio_play is called, near_end_ticks (2×500ms) + IPC latency
|
||||
@@ -290,13 +285,6 @@ pub async fn audio_play(
|
||||
0.0
|
||||
};
|
||||
|
||||
let blend_rate = hi_res_blend::blend_rate_hz(
|
||||
hi_res_enabled,
|
||||
crossfade_enabled || (gapless && !manual),
|
||||
hi_res_crossfade_resample_hz,
|
||||
);
|
||||
let resample_target_hz = blend_rate.unwrap_or(0);
|
||||
|
||||
// Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count.
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
// Reset sample counter for the new track.
|
||||
@@ -313,7 +301,6 @@ pub async fn audio_play(
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
},
|
||||
&state,
|
||||
@@ -352,26 +339,6 @@ pub async fn audio_play(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let outgoing_blend: Option<OutgoingBlendSnapshot> =
|
||||
if let Some(blend) = blend_rate {
|
||||
if crossfade_enabled && current_stream_rate > 0 && current_stream_rate != blend {
|
||||
hi_res_blend::capture_outgoing_blend_snapshot(
|
||||
&state,
|
||||
outgoing_fade_secs,
|
||||
actual_fade_secs,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if outgoing_blend.is_some() {
|
||||
hi_res_blend::detach_current_sink_for_blend_reopen(&state);
|
||||
}
|
||||
|
||||
// ── Stream rate management ────────────────────────────────────────────────
|
||||
// Hi-Res ON: open device at file's native rate (bit-perfect, no resampler).
|
||||
// Hi-Res OFF: if the stream was previously opened at a hi-res rate (e.g. the
|
||||
@@ -380,12 +347,11 @@ pub async fn audio_play(
|
||||
// If already at the device default — skip entirely (no IPC, no
|
||||
// PipeWire reconfigure, no scheduler cost).
|
||||
{
|
||||
let target_rate = if let Some(blend) = blend_rate {
|
||||
blend
|
||||
} else if hi_res_enabled {
|
||||
output_rate // native file rate
|
||||
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let target_rate = if hi_res_enabled {
|
||||
output_rate // native file rate
|
||||
} else {
|
||||
state.device_default_rate // restore device default
|
||||
state.device_default_rate // restore device default
|
||||
};
|
||||
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
|
||||
if needs_switch {
|
||||
@@ -417,23 +383,6 @@ pub async fn audio_play(
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(snap), Some(blend)) = (&outgoing_blend, blend_rate) {
|
||||
if let Err(e) = hi_res_blend::spawn_outgoing_blend_resample(
|
||||
&app,
|
||||
&state,
|
||||
snap,
|
||||
blend,
|
||||
gen,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!("{e}");
|
||||
}
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let stream = super::engine::ensure_output_stream_open(&state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
sink.set_volume(effective_volume);
|
||||
@@ -450,7 +399,7 @@ pub async fn audio_play(
|
||||
// ring buffer time to build headroom before the cpal callback drains it.
|
||||
let needs_preserve_prefill = preserve_pitch_will_run(&state.playback_rate);
|
||||
let needs_prefill =
|
||||
(hi_res_enabled && blend_rate.unwrap_or(output_rate) > 48_000) || needs_preserve_prefill;
|
||||
(hi_res_enabled && output_rate > 48_000) || needs_preserve_prefill;
|
||||
let defer_playback_start = !state.stream_playback_armed.load(Ordering::Relaxed);
|
||||
if needs_prefill || defer_playback_start {
|
||||
sink.pause();
|
||||
@@ -613,7 +562,6 @@ pub async fn audio_chain_preload(
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
hi_res_enabled: bool,
|
||||
hi_res_crossfade_resample_hz: Option<u32>,
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
app: AppHandle,
|
||||
@@ -727,9 +675,8 @@ pub async fn audio_chain_preload(
|
||||
// Use a dedicated counter for the chained source — it will be swapped into
|
||||
// samples_played when the chained track becomes active.
|
||||
let chain_counter = Arc::new(AtomicU64::new(0));
|
||||
// Always 0 unless hi-res gapless blend resampling is active.
|
||||
let blend_rate = hi_res_blend::blend_rate_hz(hi_res_enabled, hi_res_enabled, hi_res_crossfade_resample_hz);
|
||||
let target_rate: u32 = blend_rate.unwrap_or(0);
|
||||
// Always 0 — no application-level resampling (same as audio_play).
|
||||
let target_rate: u32 = 0;
|
||||
let format_hint = url.rsplit('.').next()
|
||||
.and_then(|ext| ext.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
@@ -755,70 +702,23 @@ pub async fn audio_chain_preload(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Hi-res gapless: resample the chained track to the blend rate and realign
|
||||
// the output stream when its Hz differs from the current track.
|
||||
// In hi-res mode: if the next track's native rate differs from the current
|
||||
// output stream, we cannot chain gaplessly — audio_play will do a hard cut
|
||||
// with a stream re-open. Store raw bytes to avoid re-downloading.
|
||||
// In safe mode (44.1 kHz locked): the stream rate is always 44100, so the
|
||||
// chain proceeds and rodio resamples internally — no bail needed.
|
||||
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
|
||||
let stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
if let Some(br) = blend_rate {
|
||||
if stream_rate > 0 && stream_rate != br {
|
||||
if let Some(snap) = hi_res_blend::capture_outgoing_blend_snapshot(&state, 0.0, 0.0) {
|
||||
hi_res_blend::detach_current_sink_for_blend_reopen(&state);
|
||||
let dev = state.selected_device.lock().unwrap().clone();
|
||||
if super::engine::open_output_stream_blocking(&state, br, true, dev).is_ok() {
|
||||
if hi_res_enabled && br > 48_000 {
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
}
|
||||
if state.generation.load(Ordering::SeqCst) == snapshot_gen {
|
||||
if let Err(e) = hi_res_blend::rebuild_current_track_at_blend_rate(
|
||||
&app,
|
||||
&state,
|
||||
&snap,
|
||||
br,
|
||||
snapshot_gen,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!("{e}");
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url: url.clone(),
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless blend stream reopen failed (wanted {br} Hz, had {stream_rate} Hz)"
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless blend skipped: current track not cached for realign"
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
|
||||
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
|
||||
next_rate, stream_rate
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
|
||||
next_rate, stream_rate
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Append to the existing Sink. The audio hardware stream never stalls.
|
||||
|
||||
@@ -161,7 +161,6 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur: std::time::Duration::from_millis(5),
|
||||
hi_res_enabled,
|
||||
resample_target_hz: 0,
|
||||
duration_hint: snap.duration_secs,
|
||||
},
|
||||
&engine,
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
//! Hi-Res transition blend: resample to a user-chosen rate when crossfade,
|
||||
//! AutoDJ, or gapless must cross a sample-rate boundary.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rodio::Player;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::engine::AudioEngine;
|
||||
use super::playback_rate::raw_counter_samples_for_content_position;
|
||||
use super::play_input::{url_format_hint, PlayInput};
|
||||
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs, PlaybackSource};
|
||||
use super::stream::LocalFileSource;
|
||||
|
||||
const BLEND_44100: u32 = 44_100;
|
||||
const BLEND_88200: u32 = 88_200;
|
||||
const BLEND_96000: u32 = 96_000;
|
||||
|
||||
/// User-selected blend rate for hi-res transitions; `None` when inactive.
|
||||
pub(crate) fn blend_rate_hz(
|
||||
hi_res_enabled: bool,
|
||||
transition_blend_active: bool,
|
||||
hz: Option<u32>,
|
||||
) -> Option<u32> {
|
||||
if !hi_res_enabled || !transition_blend_active {
|
||||
return None;
|
||||
}
|
||||
let raw = hz.unwrap_or(BLEND_44100);
|
||||
match raw {
|
||||
BLEND_44100 | BLEND_88200 | BLEND_96000 => Some(raw),
|
||||
_ => Some(BLEND_44100),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct OutgoingBlendSnapshot {
|
||||
pub(crate) url: String,
|
||||
pub(crate) position_secs: f64,
|
||||
pub(crate) duration_secs: f64,
|
||||
pub(crate) base_volume: f32,
|
||||
pub(crate) gain_linear: f32,
|
||||
pub(crate) outgoing_fade_secs: f32,
|
||||
pub(crate) actual_fade_secs: f32,
|
||||
pub(crate) analysis_track_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Capture the currently playing track before a hi-res blend stream reopen.
|
||||
pub(crate) fn capture_outgoing_blend_snapshot(
|
||||
state: &AudioEngine,
|
||||
outgoing_fade_secs: f32,
|
||||
actual_fade_secs: f32,
|
||||
) -> Option<OutgoingBlendSnapshot> {
|
||||
let url = state.current_playback_url.lock().unwrap().clone()?;
|
||||
if url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (position_secs, duration_secs, base_volume, gain_linear, playing) = {
|
||||
let cur = state.current.lock().unwrap();
|
||||
let playing = cur.sink.is_some() && cur.paused_at.is_none();
|
||||
(
|
||||
cur.position(),
|
||||
cur.duration_secs,
|
||||
cur.base_volume,
|
||||
cur.replay_gain_linear,
|
||||
playing,
|
||||
)
|
||||
};
|
||||
if !playing {
|
||||
return None;
|
||||
}
|
||||
let analysis_track_id = state.current_analysis_track_id.lock().unwrap().clone();
|
||||
Some(OutgoingBlendSnapshot {
|
||||
url,
|
||||
position_secs,
|
||||
duration_secs,
|
||||
base_volume,
|
||||
gain_linear,
|
||||
outgoing_fade_secs,
|
||||
actual_fade_secs,
|
||||
analysis_track_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Drop the live main sink so a stream reopen does not leave dangling players.
|
||||
pub(crate) fn detach_current_sink_for_blend_reopen(state: &AudioEngine) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(old) = cur.sink.take() {
|
||||
old.stop();
|
||||
}
|
||||
cur.fadeout_trigger = None;
|
||||
cur.fadeout_samples = None;
|
||||
}
|
||||
|
||||
fn resolve_cached_play_input(engine: &AudioEngine, url: &str) -> Option<PlayInput> {
|
||||
if url.starts_with("psysonic-local://") {
|
||||
let path = url.strip_prefix("psysonic-local://").unwrap_or(url);
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
return Some(PlayInput::SeekableMedia {
|
||||
reader: Box::new(LocalFileSource { file, len }),
|
||||
format_hint: url_format_hint(url),
|
||||
tag: "LocalFile[hi-res-blend]",
|
||||
random_access: true,
|
||||
mp4_probe_gate: None,
|
||||
});
|
||||
}
|
||||
|
||||
let ram_bytes = {
|
||||
let guard = engine.stream_completed_cache.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|t| t.url == url)
|
||||
.map(|t| t.data.clone())
|
||||
};
|
||||
let bytes = if let Some(b) = ram_bytes {
|
||||
b
|
||||
} else {
|
||||
let spill_path = {
|
||||
let guard = engine.stream_completed_spill.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|s| s.url == url)
|
||||
.map(|s| s.path.clone())
|
||||
};
|
||||
match spill_path {
|
||||
Some(p) => std::fs::read(&p).ok()?,
|
||||
None => return None,
|
||||
}
|
||||
};
|
||||
Some(PlayInput::Bytes(bytes))
|
||||
}
|
||||
|
||||
/// Rebuild the outgoing track on `fading_out_sink` at `blend_rate` after reopen.
|
||||
pub(crate) async fn spawn_outgoing_blend_resample(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
snap: &OutgoingBlendSnapshot,
|
||||
blend_rate: u32,
|
||||
gen: u64,
|
||||
) -> Result<(), String> {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let play_input = resolve_cached_play_input(state, &snap.url).ok_or_else(|| {
|
||||
format!(
|
||||
"[hi-res-blend] outgoing track not cached for blend reopen: {}",
|
||||
snap.url
|
||||
)
|
||||
})?;
|
||||
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let format_hint = url_format_hint(&snap.url);
|
||||
let stream_format_suffix: Option<String> = snap
|
||||
.url
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.and_then(|e| e.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let resume_server = super::helpers::current_playback_server_id_str(state);
|
||||
|
||||
let ps: PlaybackSource = build_playback_source_with_probe_fallback(
|
||||
play_input,
|
||||
BuildSourceArgs {
|
||||
url: &snap.url,
|
||||
gen,
|
||||
cache_id_for_tasks: snap.analysis_track_id.as_deref(),
|
||||
server_id: Some(resume_server.as_str()),
|
||||
url_format_hint: format_hint.as_deref(),
|
||||
stream_format_suffix: stream_format_suffix.as_deref(),
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur: Duration::from_millis(5),
|
||||
hi_res_enabled: true,
|
||||
resample_target_hz: blend_rate,
|
||||
duration_hint: snap.duration_secs,
|
||||
},
|
||||
state,
|
||||
app,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stream = super::engine::ensure_output_stream_open(state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
|
||||
sink.set_volume(effective_volume);
|
||||
sink.append(ps.built.source);
|
||||
|
||||
if ps.is_seekable && snap.position_secs > 0.05 {
|
||||
let target = Duration::from_secs_f64(snap.position_secs.max(0.0));
|
||||
sink.try_seek(target)
|
||||
.map_err(|e| format!("[hi-res-blend] outgoing seek failed: {e}"))?;
|
||||
}
|
||||
|
||||
let fade_secs = snap.outgoing_fade_secs;
|
||||
if fade_secs > 0.0 {
|
||||
let rate = blend_rate;
|
||||
let ch = state.current_channels.load(Ordering::Relaxed).max(2);
|
||||
let fade_total = (fade_secs as f64 * rate as f64 * ch as f64) as u64;
|
||||
ps.built
|
||||
.fadeout_samples
|
||||
.store(fade_total.max(1), Ordering::SeqCst);
|
||||
ps.built.fadeout_trigger.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
sink.play();
|
||||
*state.fading_out_sink.lock().unwrap() = Some(sink);
|
||||
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
let cleanup_secs = snap.actual_fade_secs.max(snap.outgoing_fade_secs) + 0.5;
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs_f32(cleanup_secs)).await;
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[hi-res-blend] outgoing rebuilt at {blend_rate} Hz from {:.2}s (fade {:.2}s)",
|
||||
snap.position_secs,
|
||||
fade_secs
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild the **current** track on a freshly opened blend-rate stream (gapless
|
||||
/// chain realign) so the next source can append to the same sink.
|
||||
pub(crate) async fn rebuild_current_track_at_blend_rate(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
snap: &OutgoingBlendSnapshot,
|
||||
blend_rate: u32,
|
||||
gen: u64,
|
||||
) -> Result<(), String> {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let play_input = resolve_cached_play_input(state, &snap.url).ok_or_else(|| {
|
||||
format!(
|
||||
"[hi-res-blend] current track not cached for gapless realign: {}",
|
||||
snap.url
|
||||
)
|
||||
})?;
|
||||
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let format_hint = url_format_hint(&snap.url);
|
||||
let stream_format_suffix: Option<String> = snap
|
||||
.url
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.and_then(|e| e.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let resume_server = super::helpers::current_playback_server_id_str(state);
|
||||
|
||||
let ps: PlaybackSource = build_playback_source_with_probe_fallback(
|
||||
play_input,
|
||||
BuildSourceArgs {
|
||||
url: &snap.url,
|
||||
gen,
|
||||
cache_id_for_tasks: snap.analysis_track_id.as_deref(),
|
||||
server_id: Some(resume_server.as_str()),
|
||||
url_format_hint: format_hint.as_deref(),
|
||||
stream_format_suffix: stream_format_suffix.as_deref(),
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur: Duration::from_millis(5),
|
||||
hi_res_enabled: true,
|
||||
resample_target_hz: blend_rate,
|
||||
duration_hint: snap.duration_secs,
|
||||
},
|
||||
state,
|
||||
app,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
state
|
||||
.current_sample_rate
|
||||
.store(ps.built.output_rate, Ordering::Relaxed);
|
||||
state
|
||||
.current_channels
|
||||
.store(ps.built.output_channels as u32, Ordering::Relaxed);
|
||||
|
||||
let stream = super::engine::ensure_output_stream_open(state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
|
||||
sink.set_volume(effective_volume);
|
||||
sink.append(ps.built.source);
|
||||
|
||||
if ps.is_seekable && snap.position_secs > 0.05 {
|
||||
let target = Duration::from_secs_f64(snap.position_secs.max(0.0));
|
||||
sink.try_seek(target)
|
||||
.map_err(|e| format!("[hi-res-blend] gapless realign seek failed: {e}"))?;
|
||||
}
|
||||
|
||||
sink.play();
|
||||
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.sink = Some(sink);
|
||||
cur.duration_secs = ps.built.duration_secs;
|
||||
cur.seek_offset = snap.position_secs;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.replay_gain_linear = snap.gain_linear;
|
||||
cur.base_volume = snap.base_volume;
|
||||
cur.fadeout_trigger = Some(ps.built.fadeout_trigger);
|
||||
cur.fadeout_samples = Some(ps.built.fadeout_samples);
|
||||
}
|
||||
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
snap.position_secs,
|
||||
ps.built.output_rate,
|
||||
ps.built.output_channels as u32,
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[hi-res-blend] gapless realigned current track at {blend_rate} Hz from {:.2}s",
|
||||
snap.position_secs
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blend_rate_inactive_without_hi_res_or_transition() {
|
||||
assert_eq!(blend_rate_hz(false, true, Some(96_000)), None);
|
||||
assert_eq!(blend_rate_hz(true, false, Some(96_000)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_rate_sanitizes_hz() {
|
||||
assert_eq!(blend_rate_hz(true, true, None), Some(44_100));
|
||||
assert_eq!(blend_rate_hz(true, true, Some(88_200)), Some(88_200));
|
||||
assert_eq!(blend_rate_hz(true, true, Some(48_000)), Some(44_100));
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ mod power_notify_win;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod power_notify_linux;
|
||||
mod helpers;
|
||||
mod hi_res_blend;
|
||||
mod ipc;
|
||||
pub mod preview;
|
||||
mod sources;
|
||||
|
||||
@@ -188,14 +188,13 @@ pub fn audio_set_playback_rate(
|
||||
state: State<'_, AudioEngine>,
|
||||
) {
|
||||
use crate::playback_rate::{
|
||||
content_position_from_samples, is_effect_active, raw_counter_samples_for_content_position,
|
||||
uses_preserve_dsp, STRATEGY_PRESERVE_PITCH, STRATEGY_SPEED_CORRECTED,
|
||||
STRATEGY_VARISPEED,
|
||||
content_position_from_samples, is_effect_active, rate_change_needs_restamp,
|
||||
raw_counter_samples_for_content_position, STRATEGY_PRESERVE_PITCH,
|
||||
STRATEGY_SPEED_CORRECTED, STRATEGY_VARISPEED,
|
||||
};
|
||||
|
||||
let clamped_speed = speed.clamp(0.5, 2.0);
|
||||
let clamped_pitch = pitch_semitones.clamp(-12.0, 12.0);
|
||||
let old_enabled = state.playback_rate.enabled.load(Ordering::Relaxed);
|
||||
let old_strat = state.playback_rate.load_strategy();
|
||||
let old_speed = state.playback_rate.load_speed();
|
||||
let was_active = is_effect_active(&state.playback_rate);
|
||||
@@ -206,24 +205,37 @@ pub fn audio_set_playback_rate(
|
||||
};
|
||||
let speed_changed = (clamped_speed - old_speed).abs() > 0.001;
|
||||
|
||||
let restamp_content = if was_active
|
||||
&& enabled == old_enabled
|
||||
&& uses_preserve_dsp(old_strat)
|
||||
&& new_strat == old_strat
|
||||
&& speed_changed
|
||||
// Will the *new* config leave the rate effect active?
|
||||
let new_active = enabled
|
||||
&& match new_strat {
|
||||
STRATEGY_PRESERVE_PITCH => {
|
||||
(clamped_speed - 1.0).abs() > 0.001 || clamped_pitch.abs() > 0.001
|
||||
}
|
||||
_ => (clamped_speed - 1.0).abs() > 0.001,
|
||||
};
|
||||
|
||||
// Preserve the content (song) position across any change to the
|
||||
// sample-counter ↔ position mapping: an active↔neutral toggle (enable /
|
||||
// disable, or speed crossing 1.0×) OR a speed change while active. Scoped to
|
||||
// the preserve-pitch DSP family and same strategy — varispeed has no
|
||||
// content/raw factor, and a strategy switch is out of scope here.
|
||||
//
|
||||
// The old condition only restamped active→active, so every enable/disable
|
||||
// toggle reinterpreted `samples_played` under the new factor and jumped the
|
||||
// position (≈ raw_secs × Δspeed — e.g. ±18 s at the 180 s mark on a ±10%
|
||||
// toggle; this is what broke Orbit drift correction).
|
||||
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let channels = state.current_channels.load(Ordering::Relaxed);
|
||||
let restamp_content = if sample_rate > 0
|
||||
&& channels > 0
|
||||
&& rate_change_needs_restamp(old_strat, new_strat, was_active, new_active, speed_changed)
|
||||
{
|
||||
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let channels = state.current_channels.load(Ordering::Relaxed);
|
||||
if sample_rate > 0 && channels > 0 {
|
||||
Some(content_position_from_samples(
|
||||
state.samples_played.load(Ordering::Relaxed),
|
||||
sample_rate,
|
||||
channels,
|
||||
&state.playback_rate,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
Some(content_position_from_samples(
|
||||
state.samples_played.load(Ordering::Relaxed),
|
||||
sample_rate,
|
||||
channels,
|
||||
&state.playback_rate,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -246,19 +258,19 @@ pub fn audio_set_playback_rate(
|
||||
.store(clamped_pitch.to_bits(), Ordering::Relaxed);
|
||||
|
||||
if let Some(content_secs) = restamp_content {
|
||||
if is_effect_active(&state.playback_rate) {
|
||||
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let channels = state.current_channels.load(Ordering::Relaxed);
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
content_secs,
|
||||
sample_rate,
|
||||
channels,
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
// Always re-derive the counter for the NEW config — including the
|
||||
// neutral case (raw_counter_… maps content == raw there), which is
|
||||
// exactly the active↔neutral transition the old is_effect_active gate
|
||||
// skipped.
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
content_secs,
|
||||
sample_rate,
|
||||
channels,
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,27 @@ pub fn is_effect_active(atomics: &PlaybackRateAtomics) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a playback-rate config change must restamp the sample counter to keep
|
||||
/// the content (song) position stable.
|
||||
///
|
||||
/// The counter ↔ position factor is `speed` while the preserve-pitch effect is
|
||||
/// active and `1.0` while neutral (see [`effective_position_secs`]). So any
|
||||
/// transition that flips active↔neutral, or changes speed while staying active,
|
||||
/// changes that factor and needs a restamp. Scoped to the preserve-pitch DSP
|
||||
/// family with an unchanged strategy: varispeed has no content/raw factor, and a
|
||||
/// strategy switch is handled elsewhere.
|
||||
pub(crate) fn rate_change_needs_restamp(
|
||||
old_strategy: u32,
|
||||
new_strategy: u32,
|
||||
was_active: bool,
|
||||
now_active: bool,
|
||||
speed_changed: bool,
|
||||
) -> bool {
|
||||
uses_preserve_dsp(old_strategy)
|
||||
&& new_strategy == old_strategy
|
||||
&& (was_active != now_active || (was_active && now_active && speed_changed))
|
||||
}
|
||||
|
||||
/// True when preserve-pitch DSP (background worker) should run for this track.
|
||||
pub(crate) fn preserve_pitch_will_run(atomics: &PlaybackRateAtomics) -> bool {
|
||||
atomics.enabled.load(Ordering::Relaxed)
|
||||
@@ -659,4 +680,55 @@ mod tests {
|
||||
let after = content_position_from_samples(restamped, 44_100, 2, &atomics);
|
||||
assert!((after - 30.0).abs() < 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_change_needs_restamp_covers_active_neutral_toggles() {
|
||||
let sc = STRATEGY_SPEED_CORRECTED;
|
||||
// Both directions of an active↔neutral toggle need a restamp.
|
||||
assert!(rate_change_needs_restamp(sc, sc, false, true, true));
|
||||
assert!(rate_change_needs_restamp(sc, sc, true, false, true));
|
||||
// Active→active with a speed change needs one too.
|
||||
assert!(rate_change_needs_restamp(sc, sc, true, true, true));
|
||||
// Active→active with no speed change, and neutral→neutral, do not.
|
||||
assert!(!rate_change_needs_restamp(sc, sc, true, true, false));
|
||||
assert!(!rate_change_needs_restamp(sc, sc, false, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_change_needs_restamp_skips_varispeed_and_strategy_switch() {
|
||||
let sc = STRATEGY_SPEED_CORRECTED;
|
||||
let vs = STRATEGY_VARISPEED;
|
||||
// Varispeed has no content/raw factor → never restamp.
|
||||
assert!(!rate_change_needs_restamp(vs, vs, false, true, true));
|
||||
// A strategy switch is out of scope for the restamp path.
|
||||
assert!(!rate_change_needs_restamp(sc, vs, true, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restamp_keeps_position_across_active_neutral_toggle() {
|
||||
// The bug: toggling the effect on/off must not move the song position.
|
||||
// Start active at 1.10×, sitting at 180 s of content.
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a.strategy.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
a.speed.store(1.10f32.to_bits(), Ordering::Relaxed);
|
||||
let samples = raw_counter_samples_for_content_position(180.0, 44_100, 2, &a);
|
||||
assert!((content_position_from_samples(samples, 44_100, 2, &a) - 180.0).abs() < 0.05);
|
||||
|
||||
// Toggle to neutral (disabled). Without a restamp the position would
|
||||
// jump ~18 s (180 × 0.10); with it, the position is preserved.
|
||||
let old_content = content_position_from_samples(samples, 44_100, 2, &a);
|
||||
a.enabled.store(false, Ordering::Relaxed);
|
||||
let restamped = raw_counter_samples_for_content_position(old_content, 44_100, 2, &a);
|
||||
let after = content_position_from_samples(restamped, 44_100, 2, &a);
|
||||
assert!((after - 180.0).abs() < 0.05, "position jumped to {after}");
|
||||
|
||||
// Back to active at 0.90× — still stable.
|
||||
let old_content2 = content_position_from_samples(restamped, 44_100, 2, &a);
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a.speed.store(0.90f32.to_bits(), Ordering::Relaxed);
|
||||
let restamped2 = raw_counter_samples_for_content_position(old_content2, 44_100, 2, &a);
|
||||
let after2 = content_position_from_samples(restamped2, 44_100, 2, &a);
|
||||
assert!((after2 - 180.0).abs() < 0.05, "position jumped to {after2}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,20 +33,9 @@ pub(crate) struct BuildSourceArgs<'a> {
|
||||
pub done_flag: Arc<AtomicBool>,
|
||||
pub fade_in_dur: Duration,
|
||||
pub hi_res_enabled: bool,
|
||||
/// When > 0, resample decoded audio to this Hz (hi-res crossfade / AutoDJ blend).
|
||||
pub resample_target_hz: u32,
|
||||
pub duration_hint: f64,
|
||||
}
|
||||
|
||||
/// Decoder/output-shaping inputs shared by [`build_source_from_play_input`].
|
||||
struct PlaybackSourceShape {
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
hi_res_enabled: bool,
|
||||
resample_target_hz: u32,
|
||||
duration_hint: f64,
|
||||
}
|
||||
|
||||
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
|
||||
/// whether the chosen source path is seekable (only the Streaming variant
|
||||
/// is not).
|
||||
@@ -194,7 +183,6 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
} = args;
|
||||
let media_hint = play_media_format_hint(&play_input);
|
||||
@@ -208,15 +196,15 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
crate::app_deprintln!("[stream] playback format hint: {h}");
|
||||
}
|
||||
|
||||
let shape = PlaybackSourceShape {
|
||||
done_flag: done_flag.clone(),
|
||||
match build_source_from_play_input(
|
||||
play_input,
|
||||
state,
|
||||
effective_hint.as_deref(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
};
|
||||
|
||||
match build_source_from_play_input(play_input, state, effective_hint.as_deref(), &shape)
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Ok(p),
|
||||
@@ -273,7 +261,10 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
PlayInput::Bytes(data.clone()),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
&shape,
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -305,13 +296,10 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
PlayInput::Bytes(fresh),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
&PlaybackSourceShape {
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
},
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -329,32 +317,29 @@ async fn build_source_from_play_input(
|
||||
play_input: PlayInput,
|
||||
state: &State<'_, AudioEngine>,
|
||||
format_hint: Option<&str>,
|
||||
shape: &PlaybackSourceShape,
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
hi_res_enabled: bool,
|
||||
duration_hint: f64,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
let PlaybackSourceShape {
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
} = shape;
|
||||
// 0 = native rate; hi-res crossfade blend passes an explicit Hz.
|
||||
let target_rate: u32 = *resample_target_hz;
|
||||
// Always 0 — no application-level resampling. Rodio handles conversion to
|
||||
// the output device rate internally; we let every track play at its native rate.
|
||||
let target_rate: u32 = 0;
|
||||
let mut is_seekable = true;
|
||||
let built = match play_input {
|
||||
PlayInput::Bytes(data) => build_source(
|
||||
data,
|
||||
*duration_hint,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag.clone(),
|
||||
*fade_in_dur,
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
format_hint,
|
||||
*hi_res_enabled,
|
||||
hi_res_enabled,
|
||||
),
|
||||
PlayInput::SeekableMedia {
|
||||
reader,
|
||||
@@ -376,13 +361,13 @@ async fn build_source_from_play_input(
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
*duration_hint,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag.clone(),
|
||||
*fade_in_dur,
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
None,
|
||||
@@ -402,13 +387,13 @@ async fn build_source_from_play_input(
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
*duration_hint,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag.clone(),
|
||||
*fade_in_dur,
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
Some(state.stream_playback_armed.clone()),
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Written to `{cover_root}/.storage-layout` — mismatch triggers cache reset.
|
||||
pub const LAYOUT_STAMP: &str = "canonical-segment-v5";
|
||||
pub const LAYOUT_STAMP: &str = "canonical-segment-v4";
|
||||
|
||||
/// True for ids that are only valid as `getCoverArt` targets, not library entity keys.
|
||||
pub fn is_fetch_only_cover_id(id: &str) -> bool {
|
||||
|
||||
@@ -9,16 +9,10 @@ pub fn strip_leading_articles(name: &str, ignored_articles: &str) -> String {
|
||||
for article in ignored_articles.split(' ').filter(|s| !s.is_empty()) {
|
||||
let prefix = format!("{} ", article);
|
||||
// `prefix` is ASCII; use `get` so we never slice inside a multibyte rune
|
||||
// (e.g. probing "The " / "El " on CJK names must not panic).
|
||||
let Some(head) = trimmed.get(..prefix.len()) else {
|
||||
continue;
|
||||
};
|
||||
if head.eq_ignore_ascii_case(&prefix) {
|
||||
return trimmed
|
||||
.get(prefix.len()..)
|
||||
.map(str::trim_start)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
// (e.g. "Elə…" must not panic when probing the "El " article).
|
||||
let head = trimmed.get(0..prefix.len());
|
||||
if head.is_some_and(|h| h.eq_ignore_ascii_case(&prefix)) {
|
||||
return trimmed[prefix.len()..].trim_start().to_string();
|
||||
}
|
||||
}
|
||||
trimmed.to_string()
|
||||
@@ -75,13 +69,4 @@ mod tests {
|
||||
let key = sort_key_for_display_name("Eləmir", DEFAULT_IGNORED_ARTICLES);
|
||||
assert_eq!(key, "eləmir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_panic_on_cjk_multi_artist_credit_string() {
|
||||
// Discord report (Asra): sync panicked on FromSoftware OST composer list
|
||||
// when probing the 4-byte "The " article prefix against 北村友香…
|
||||
let name = "北村友香, 齋藤司, 桜庭統 & 鈴木伸嘉";
|
||||
let key = sort_key_for_display_name(name, DEFAULT_IGNORED_ARTICLES);
|
||||
assert_eq!(key, name.to_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,15 +113,26 @@ pub(crate) fn genre_album_counts_for_server(
|
||||
) -> Result<Vec<GenreAlbumCountDto>, String> {
|
||||
store
|
||||
.with_read_conn(|conn| {
|
||||
let mut sql = String::from(
|
||||
"SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \
|
||||
COUNT(DISTINCT tg.track_id) AS song_count \
|
||||
FROM track_genre tg \
|
||||
INNER JOIN track t \
|
||||
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \
|
||||
WHERE tg.server_id = ?1 \
|
||||
AND tg.album_id IS NOT NULL AND tg.album_id != ''",
|
||||
);
|
||||
let scoped = library_scope.is_some_and(|s| !s.trim().is_empty());
|
||||
let mut sql = if scoped {
|
||||
String::from(
|
||||
"SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \
|
||||
COUNT(DISTINCT tg.track_id) AS song_count \
|
||||
FROM track_genre tg \
|
||||
INNER JOIN track t \
|
||||
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \
|
||||
WHERE tg.server_id = ?1 \
|
||||
AND tg.album_id IS NOT NULL AND tg.album_id != ''",
|
||||
)
|
||||
} else {
|
||||
String::from(
|
||||
"SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \
|
||||
COUNT(DISTINCT tg.track_id) AS song_count \
|
||||
FROM track_genre tg \
|
||||
WHERE tg.server_id = ?1 \
|
||||
AND tg.album_id IS NOT NULL AND tg.album_id != ''",
|
||||
)
|
||||
};
|
||||
let mut params: Vec<rusqlite::types::Value> =
|
||||
vec![rusqlite::types::Value::Text(server_id.to_string())];
|
||||
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
|
||||
@@ -130,7 +141,6 @@ pub(crate) fn genre_album_counts_for_server(
|
||||
}
|
||||
sql.push_str(
|
||||
" GROUP BY tg.genre COLLATE NOCASE \
|
||||
HAVING album_count > 0 \
|
||||
ORDER BY album_count DESC, tg.genre COLLATE NOCASE ASC",
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
@@ -360,49 +370,6 @@ mod tests {
|
||||
assert_eq!(counts[0].album_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genre_album_counts_drop_genre_after_track_retag() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
let mut track = make_row("s1", "t1", "al1", 1);
|
||||
track.genre = Some("ruspop".into());
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[track.clone()])
|
||||
.unwrap();
|
||||
let counts = genre_album_counts_for_server(&store, "s1", None).unwrap();
|
||||
assert_eq!(counts.len(), 1);
|
||||
assert_eq!(counts[0].value, "ruspop");
|
||||
|
||||
track.genre = Some("Pop".into());
|
||||
TrackRepository::new(&store).upsert_batch(&[track]).unwrap();
|
||||
let counts = genre_album_counts_for_server(&store, "s1", None).unwrap();
|
||||
assert_eq!(counts.len(), 1);
|
||||
assert_eq!(counts[0].value, "Pop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genre_album_counts_ignore_orphan_track_genre_rows() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
let mut live = make_row("s1", "live", "al1", 1);
|
||||
live.genre = Some("Rock".into());
|
||||
let mut stale = make_row("s1", "gone", "al_stale", 1);
|
||||
stale.genre = Some("ruspop".into());
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[live, stale])
|
||||
.unwrap();
|
||||
store
|
||||
.with_conn("test", |conn| {
|
||||
conn.execute(
|
||||
"UPDATE track SET deleted = 1 WHERE server_id = 's1' AND id = 'gone'",
|
||||
[],
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let counts = genre_album_counts_for_server(&store, "s1", None).unwrap();
|
||||
assert_eq!(counts.len(), 1);
|
||||
assert_eq!(counts[0].value, "Rock");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_album_stars_clears_all_when_server_list_empty() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::dto::{
|
||||
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
|
||||
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto,
|
||||
LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto,
|
||||
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionRecentTrackDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
|
||||
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
|
||||
TrackArtifactDto, TrackFactDto, TrackRefDto,
|
||||
};
|
||||
use crate::live_search;
|
||||
@@ -1234,16 +1234,6 @@ pub fn library_get_player_stats_recent_days(
|
||||
PlaySessionRepository::new(&runtime.store).recent_days(limit.unwrap_or(30))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn library_get_recent_play_sessions(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
limit: Option<u32>,
|
||||
since_ms: Option<i64>,
|
||||
) -> Result<Vec<PlaySessionRecentTrackDto>, String> {
|
||||
PlaySessionRepository::new(&runtime.store)
|
||||
.recent_plays(limit.unwrap_or(50), since_ms)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn library_purge_server(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
|
||||
@@ -346,9 +346,6 @@ pub struct PlaySessionDayTrackDto {
|
||||
pub listened_sec: f64,
|
||||
pub completion: String,
|
||||
pub started_at_ms: i64,
|
||||
pub album: Option<String>,
|
||||
pub album_id: Option<String>,
|
||||
pub cover_art_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -358,9 +355,6 @@ pub struct PlaySessionDayDetailDto {
|
||||
pub tracks: Vec<PlaySessionDayTrackDto>,
|
||||
}
|
||||
|
||||
/// One row from `library_get_recent_play_sessions` (timeline cold bootstrap).
|
||||
pub type PlaySessionRecentTrackDto = PlaySessionDayTrackDto;
|
||||
|
||||
/// Summary for one day in the recent-days list (no track rows).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -45,11 +45,15 @@ fn count_genre_albums(
|
||||
conn: &rusqlite::Connection,
|
||||
where_sql: &str,
|
||||
params: &[SqlValue],
|
||||
_library_scoped: bool,
|
||||
library_scoped: bool,
|
||||
) -> Result<u32, rusqlite::Error> {
|
||||
let from = "FROM track_genre tg \
|
||||
let from = if library_scoped {
|
||||
"FROM track_genre tg \
|
||||
INNER JOIN track t \
|
||||
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0";
|
||||
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0"
|
||||
} else {
|
||||
"FROM track_genre tg"
|
||||
};
|
||||
let count_sql = format!("SELECT COUNT(DISTINCT tg.album_id) {from} WHERE {where_sql}");
|
||||
let n: i64 = conn.query_row(
|
||||
&count_sql,
|
||||
|
||||
@@ -195,68 +195,4 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(name_sort, "beatles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_from_tracks_accepts_cjk_artist_display_name() {
|
||||
use crate::artist_sort::DEFAULT_IGNORED_ARTICLES;
|
||||
use crate::repos::{TrackRepository, TrackRow};
|
||||
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let cjk = "北村友香, 齋藤司, 桜庭統 & 鈴木伸嘉";
|
||||
let row = TrackRow {
|
||||
server_id: "s1".into(),
|
||||
id: "tr_1".into(),
|
||||
title: "Song".into(),
|
||||
title_sort: None,
|
||||
artist: Some(cjk.into()),
|
||||
artist_id: Some("ar_cjk".into()),
|
||||
album: "al_1".into(),
|
||||
album_id: Some("al_1".into()),
|
||||
album_artist: None,
|
||||
duration_sec: 200,
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
year: None,
|
||||
genre: None,
|
||||
suffix: None,
|
||||
bit_rate: None,
|
||||
size_bytes: None,
|
||||
cover_art_id: None,
|
||||
starred_at: None,
|
||||
user_rating: None,
|
||||
play_count: None,
|
||||
played_at: None,
|
||||
server_path: None,
|
||||
library_id: None,
|
||||
isrc: None,
|
||||
mbid_recording: None,
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
deleted: false,
|
||||
synced_at: 1,
|
||||
raw_json: "{}".into(),
|
||||
};
|
||||
TrackRepository::new(&store).upsert_batch(&[row]).unwrap();
|
||||
|
||||
let repo = ArtistRepository::new(&store);
|
||||
let n = repo
|
||||
.backfill_from_tracks("s1", DEFAULT_IGNORED_ARTICLES, 2)
|
||||
.unwrap();
|
||||
assert_eq!(n, 1);
|
||||
|
||||
let name_sort: String = store
|
||||
.with_conn("misc", |c| {
|
||||
c.query_row(
|
||||
"SELECT name_sort FROM artist WHERE server_id = 's1' AND id = 'ar_cjk'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(name_sort, cjk.to_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use rusqlite::{params, OptionalExtension};
|
||||
use crate::dto::{
|
||||
PlaySessionDayDetailDto, PlaySessionDayTrackDto, PlaySessionDayTotalsDto,
|
||||
PlaySessionHeatmapDayDto, PlaySessionInputDto, PlaySessionRecentDayDto,
|
||||
PlaySessionRecentTrackDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto,
|
||||
PlaySessionYearBoundsDto, PlaySessionYearSummaryDto,
|
||||
};
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
@@ -22,21 +22,6 @@ use completion::{
|
||||
completion_from_position, effective_duration_sec, MIN_LISTENED_SEC,
|
||||
};
|
||||
|
||||
fn map_play_session_track_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PlaySessionDayTrackDto> {
|
||||
Ok(PlaySessionDayTrackDto {
|
||||
server_id: row.get(0)?,
|
||||
track_id: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
artist: row.get(3)?,
|
||||
listened_sec: row.get(4)?,
|
||||
completion: row.get(5)?,
|
||||
started_at_ms: row.get(6)?,
|
||||
album: row.get(7)?,
|
||||
album_id: row.get(8)?,
|
||||
cover_art_id: row.get(9)?,
|
||||
})
|
||||
}
|
||||
|
||||
struct DayAgg {
|
||||
total_listened_sec: f64,
|
||||
track_play_count: u32,
|
||||
@@ -238,15 +223,24 @@ impl<'a> PlaySessionRepository<'a> {
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT ps.server_id, ps.track_id, t.title, t.artist, \
|
||||
ps.listened_sec, ps.completion, ps.started_at_ms, \
|
||||
t.album, t.album_id, t.cover_art_id \
|
||||
ps.listened_sec, ps.completion, ps.started_at_ms \
|
||||
FROM play_session ps \
|
||||
JOIN track t ON t.server_id = ps.server_id AND t.id = ps.track_id \
|
||||
WHERE date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?1 \
|
||||
ORDER BY ps.started_at_ms DESC",
|
||||
)?;
|
||||
let tracks = stmt
|
||||
.query_map(params![date_iso], map_play_session_track_row)?
|
||||
.query_map(params![date_iso], |row| {
|
||||
Ok(PlaySessionDayTrackDto {
|
||||
server_id: row.get(0)?,
|
||||
track_id: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
artist: row.get(3)?,
|
||||
listened_sec: row.get(4)?,
|
||||
completion: row.get(5)?,
|
||||
started_at_ms: row.get(6)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
let plays: Vec<PlaySpan> = tracks
|
||||
@@ -352,29 +346,4 @@ impl<'a> PlaySessionRepository<'a> {
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Most recent track plays across all servers (newest first). Used for timeline cold bootstrap.
|
||||
pub fn recent_plays(
|
||||
&self,
|
||||
limit: u32,
|
||||
since_ms: Option<i64>,
|
||||
) -> Result<Vec<PlaySessionRecentTrackDto>, String> {
|
||||
let limit = limit.clamp(1, 200);
|
||||
self.store
|
||||
.with_read_conn(|conn| {
|
||||
let sql = "SELECT ps.server_id, ps.track_id, t.title, t.artist, \
|
||||
ps.listened_sec, ps.completion, ps.started_at_ms, \
|
||||
t.album, t.album_id, t.cover_art_id \
|
||||
FROM play_session ps \
|
||||
INNER JOIN track t \
|
||||
ON t.server_id = ps.server_id AND t.id = ps.track_id AND t.deleted = 0 \
|
||||
WHERE (?2 IS NULL OR ps.started_at_ms >= ?2) \
|
||||
ORDER BY ps.started_at_ms DESC \
|
||||
LIMIT ?1";
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map(params![limit, since_ms], map_play_session_track_row)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,97 +424,3 @@ fn purge_deletes_play_session_rows_for_server() {
|
||||
assert_eq!(s1_count, 0);
|
||||
assert_eq!(s2_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_plays_returns_newest_first_and_respects_limit() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_track(&store, "s1", "t1", 200);
|
||||
seed_track(&store, "s1", "t2", 200);
|
||||
seed_track(&store, "s2", "t3", 200);
|
||||
let repo = PlaySessionRepository::new(&store);
|
||||
for (sid, tid, ms) in [("s1", "t1", 1_000_i64), ("s1", "t2", 2_000), ("s2", "t3", 3_000)] {
|
||||
repo.insert(&PlaySessionInputDto {
|
||||
server_id: sid.into(),
|
||||
track_id: tid.into(),
|
||||
started_at_ms: ms,
|
||||
listened_sec: 20.0,
|
||||
position_max_sec: 15.0,
|
||||
end_reason: "ended".into(),
|
||||
duration_sec_hint: None,
|
||||
})
|
||||
.expect("insert");
|
||||
}
|
||||
let rows = repo.recent_plays(2, None).expect("recent");
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].track_id, "t3");
|
||||
assert_eq!(rows[1].track_id, "t2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_plays_excludes_deleted_tracks() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_track(&store, "s1", "t1", 200);
|
||||
let repo = PlaySessionRepository::new(&store);
|
||||
repo.insert(&sample_input("s1", "t1")).expect("insert");
|
||||
store
|
||||
.with_conn_mut("test.soft_delete", |conn| {
|
||||
conn.execute(
|
||||
"UPDATE track SET deleted = 1 WHERE server_id = ?1 AND id = ?2",
|
||||
rusqlite::params!["s1", "t1"],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.expect("soft delete");
|
||||
let rows = repo.recent_plays(10, None).expect("recent");
|
||||
assert!(rows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_plays_includes_album_cover_metadata() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[TrackRow {
|
||||
server_id: "s1".into(),
|
||||
id: "t1".into(),
|
||||
title: "Song".into(),
|
||||
title_sort: None,
|
||||
artist: Some("Artist".into()),
|
||||
artist_id: None,
|
||||
album: "Album Name".into(),
|
||||
album_id: Some("al-1".into()),
|
||||
album_artist: None,
|
||||
duration_sec: 200,
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
year: None,
|
||||
genre: None,
|
||||
suffix: None,
|
||||
bit_rate: None,
|
||||
size_bytes: None,
|
||||
cover_art_id: Some("al-1".into()),
|
||||
starred_at: None,
|
||||
user_rating: None,
|
||||
play_count: None,
|
||||
played_at: None,
|
||||
server_path: None,
|
||||
library_id: None,
|
||||
isrc: None,
|
||||
mbid_recording: None,
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
deleted: false,
|
||||
synced_at: 1,
|
||||
raw_json: "{}".into(),
|
||||
}])
|
||||
.expect("seed track");
|
||||
let repo = PlaySessionRepository::new(&store);
|
||||
repo.insert(&sample_input("s1", "t1")).expect("insert");
|
||||
let rows = repo.recent_plays(1, None).expect("recent");
|
||||
assert_eq!(rows[0].album.as_deref(), Some("Album Name"));
|
||||
assert_eq!(rows[0].album_id.as_deref(), Some("al-1"));
|
||||
assert_eq!(rows[0].cover_art_id.as_deref(), Some("al-1"));
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -59,12 +59,6 @@ pub struct CoverCacheEnsureResult {
|
||||
pub tier: u32,
|
||||
}
|
||||
|
||||
/// Result of the foreground tier-encode pass: whether the requested tier was
|
||||
/// written, the freshly written `(tier, path)` pairs, and the full-resolution
|
||||
/// decoded source kept for deriving the larger tiers (None on the bulk/quiet
|
||||
/// path, which writes every tier up front).
|
||||
type EncodeTiersOutcome = Result<(bool, Vec<(u32, PathBuf)>, Option<DynamicImage>), String>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverCacheStatsDto {
|
||||
@@ -270,7 +264,7 @@ impl CoverCacheState {
|
||||
) -> Result<CoverCacheEnsureResult, String> {
|
||||
let this = state.lock().await;
|
||||
let dir = cover_dir_for_args(&this.root, args);
|
||||
if let Some(path) = ensure_peek(&dir, args.tier, args) {
|
||||
if let Some(path) = external_ensure::peek_cover_path(&dir, args.tier, args) {
|
||||
return Ok(CoverCacheEnsureResult {
|
||||
hit: true,
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
@@ -308,18 +302,12 @@ impl CoverCacheState {
|
||||
}
|
||||
|
||||
// For an external artist surface (`fanart` 16:9 background or `banner`
|
||||
// strip), resolve fanart.tv only. On a hit we return the external image;
|
||||
// on a miss we report a genuine `hit=false` (no `.fetch-failed` marker)
|
||||
// and DO NOT fall back to the Navidrome cover here. The fallback is the
|
||||
// caller's job, because each surface has its own chain: the artist-detail
|
||||
// hero wants banner → fanart → Navidrome, the fullscreen player wants
|
||||
// fanart → Navidrome. Falling back to Navidrome at this layer would mask
|
||||
// "this artist has no banner" as a hit and short-circuit the caller's
|
||||
// fanart step (the banner ensure would win the `||` with the ND cover and
|
||||
// the existing fanart would never show).
|
||||
// strip), try fanart.tv before the Navidrome fallback. On any miss it
|
||||
// falls through WITHOUT writing a `.fetch-failed` marker, so Navidrome
|
||||
// stays the display fallback (§28).
|
||||
if args.external_artwork_enabled && !args.library_bulk && args.cache_kind == "artist" {
|
||||
if let Some(surface) = external_ensure::external_surface(args.surface_kind.as_deref()) {
|
||||
let external = external_ensure::try_external_fanart(
|
||||
if let Some(path) = external_ensure::try_external_fanart(
|
||||
app,
|
||||
args,
|
||||
&dir,
|
||||
@@ -329,19 +317,14 @@ impl CoverCacheState {
|
||||
args.tier,
|
||||
surface,
|
||||
)
|
||||
.await;
|
||||
return Ok(match external {
|
||||
Some(path) => CoverCacheEnsureResult {
|
||||
.await
|
||||
{
|
||||
return Ok(CoverCacheEnsureResult {
|
||||
hit: true,
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
tier: args.tier,
|
||||
},
|
||||
None => CoverCacheEnsureResult {
|
||||
hit: false,
|
||||
path: String::new(),
|
||||
tier: args.tier,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,16 +351,7 @@ impl CoverCacheState {
|
||||
Bytes(Vec<u8>),
|
||||
}
|
||||
|
||||
// Full-res must come from the network: the largest on-disk derive tier is
|
||||
// 800, so reusing a disk tier as the source would store a `2000.webp` that
|
||||
// is only 800px (resize never upscales). Smaller tiers may reuse a disk
|
||||
// source.
|
||||
let disk_source = if args.tier >= 2000 {
|
||||
None
|
||||
} else {
|
||||
load_image_from_disk(&dir)
|
||||
};
|
||||
let source = if let Some(img) = disk_source {
|
||||
let source = if let Some(img) = load_image_from_disk(&dir) {
|
||||
CoverSource::Image(img)
|
||||
} else {
|
||||
let http_registry = app
|
||||
@@ -405,8 +379,8 @@ impl CoverCacheState {
|
||||
.acquire_owned()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (mut wrote_requested, fresh_tiers, derive_source) = tauri::async_runtime::spawn_blocking(
|
||||
move || -> EncodeTiersOutcome {
|
||||
let (mut wrote_requested, fresh_tiers) = tauri::async_runtime::spawn_blocking(
|
||||
move || -> Result<(bool, Vec<(u32, PathBuf)>), String> {
|
||||
let _cpu_permit = cpu_permit;
|
||||
let img = match source {
|
||||
CoverSource::Image(i) => i,
|
||||
@@ -418,29 +392,23 @@ impl CoverCacheState {
|
||||
if quiet {
|
||||
disk::write_derived_webp_tiers(&dir_bg, &img, requested)?;
|
||||
wrote_requested = tier_exists(&dir_bg, requested).is_some();
|
||||
return Ok((wrote_requested, fresh, None));
|
||||
}
|
||||
for tier in tiers_bg {
|
||||
if tier_exists(&dir_bg, tier).is_some() {
|
||||
} else {
|
||||
for tier in tiers_bg {
|
||||
if tier_exists(&dir_bg, tier).is_some() {
|
||||
if tier == requested {
|
||||
wrote_requested = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let path = tier_path(&dir_bg, tier);
|
||||
write_webp_tier(&img, tier, &path)?;
|
||||
fresh.push((tier, path));
|
||||
if tier == requested {
|
||||
wrote_requested = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let path = tier_path(&dir_bg, tier);
|
||||
write_webp_tier(&img, tier, &path)?;
|
||||
fresh.push((tier, path));
|
||||
if tier == requested {
|
||||
wrote_requested = true;
|
||||
}
|
||||
}
|
||||
// Hand the full-resolution decoded source back so the larger tiers
|
||||
// are derived from it directly. Re-reading the largest tier off
|
||||
// disk would pick the just-written `requested` tier (≤ requested);
|
||||
// because `resize_tier` never upscales, deriving 512/800 from that
|
||||
// smaller tier stored them at the small resolution — the
|
||||
// "cover preview stays small" bug (e.g. an 800.webp that is 256×256).
|
||||
Ok((wrote_requested, fresh, Some(img)))
|
||||
Ok((wrote_requested, fresh))
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -463,7 +431,7 @@ impl CoverCacheState {
|
||||
// the Performance Probe "on-demand (ui)" throughput.
|
||||
note_ui_cover_produced(args);
|
||||
if !quiet {
|
||||
if let Some(img) = derive_source {
|
||||
if let Some(img) = load_image_from_disk(&dir) {
|
||||
spawn_derive_remaining_tiers(
|
||||
app.clone(),
|
||||
state.clone(),
|
||||
@@ -897,9 +865,7 @@ pub async fn cover_cache_peek_batch(
|
||||
&item.cache_kind,
|
||||
&item.cache_entity_id,
|
||||
);
|
||||
// Plain-cover peek (no surface in the batch DTO): full-res is exact-only,
|
||||
// so a 2000 request never returns a smaller tier to seed the grid cache.
|
||||
let path = peek_plain_cover_tier(&dir, item.tier);
|
||||
let path = peek_tier_path(&dir, item.tier);
|
||||
if let Some(p) = path {
|
||||
out.insert(item.storage_key, p.to_string_lossy().into_owned());
|
||||
}
|
||||
@@ -932,28 +898,6 @@ fn peek_tier_path(dir: &Path, want: u32) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Disk peek for a plain (non-surface) cover request — shared by the ensure path
|
||||
/// AND `cover_cache_peek_batch`. Full-res (≥2000) is **exact-only**: a smaller
|
||||
/// peek-ladder fallback would both serve a downscaled image and short-circuit the
|
||||
/// download, and (via the frontend grid seeder) poison the full-res in-memory key
|
||||
/// for Hero / fullscreen / artist-hero surfaces, which peek 2000 before ensure.
|
||||
/// Smaller display tiers keep the normal ladder peek.
|
||||
fn peek_plain_cover_tier(dir: &Path, tier: u32) -> Option<PathBuf> {
|
||||
if tier >= 2000 {
|
||||
return tier_exists(dir, tier);
|
||||
}
|
||||
peek_tier_path(dir, tier)
|
||||
}
|
||||
|
||||
/// Peek used by the ensure path. External surfaces keep their own ladder; plain
|
||||
/// covers go through [`peek_plain_cover_tier`] (full-res exact).
|
||||
fn ensure_peek(dir: &Path, tier: u32, args: &CoverCacheEnsureArgs) -> Option<PathBuf> {
|
||||
if args.surface_kind.is_some() {
|
||||
return external_ensure::peek_cover_path(dir, tier, args);
|
||||
}
|
||||
peek_plain_cover_tier(dir, tier)
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_ensure(
|
||||
@@ -1416,8 +1360,8 @@ mod tests {
|
||||
use super::decode_image_bytes;
|
||||
use super::disk::{cover_dir, tier_path};
|
||||
use super::{
|
||||
count_cached_cover_ids, ensure_peek, is_safe_index_key, merge_cover_bucket,
|
||||
peek_plain_cover_tier, purge_external_files, rename_bucket_inner, CoverCacheEnsureArgs,
|
||||
count_cached_cover_ids, is_safe_index_key, merge_cover_bucket, purge_external_files,
|
||||
rename_bucket_inner,
|
||||
};
|
||||
use psysonic_core::cover_cache_layout::CANONICAL_PROGRESS_TIER;
|
||||
use std::fs;
|
||||
@@ -1642,75 +1586,4 @@ mod tests {
|
||||
assert!(!entity.join(".miss-banner").exists());
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
fn test_ensure_args(tier: u32, surface_kind: Option<&str>) -> CoverCacheEnsureArgs {
|
||||
CoverCacheEnsureArgs {
|
||||
server_index_key: "srv".into(),
|
||||
cache_kind: "album".into(),
|
||||
cache_entity_id: "al-1".into(),
|
||||
cover_art_id: "al-1".into(),
|
||||
tier,
|
||||
rest_base_url: "http://x".into(),
|
||||
username: "u".into(),
|
||||
password: "p".into(),
|
||||
library_bulk: false,
|
||||
library_server_id: None,
|
||||
external_artwork_enabled: false,
|
||||
surface_kind: surface_kind.map(String::from),
|
||||
artist_name: None,
|
||||
album_title: None,
|
||||
external_artwork_byok: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_peek_fullres_requires_exact_tier() {
|
||||
let root = fresh_tmpdir("ensure-peek-fullres");
|
||||
let dir = root.join("album").join("al-1");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
// A smaller tier is on disk (from a grid/hero load) but the full-res tier
|
||||
// is not. A 2000 request must NOT accept the smaller fallback, or the
|
||||
// download is skipped and the 2000 tier is never built/cached.
|
||||
fs::write(tier_path(&dir, 512), b"x").unwrap();
|
||||
let args = test_ensure_args(2000, None);
|
||||
assert!(ensure_peek(&dir, 2000, &args).is_none());
|
||||
// Once the exact tier exists it is a hit.
|
||||
fs::write(tier_path(&dir, 2000), b"y").unwrap();
|
||||
assert_eq!(ensure_peek(&dir, 2000, &args), Some(tier_path(&dir, 2000)));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peek_plain_cover_fullres_is_exact_but_display_tiers_ladder() {
|
||||
let root = fresh_tmpdir("peek-plain-fullres");
|
||||
let dir = root.join("album").join("al-1");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
// Only a smaller tier on disk (grid/hero load). A full-res (2000) peek —
|
||||
// used by both ensure AND cover_cache_peek_batch — must NOT ladder down to
|
||||
// it, or the frontend grid seeder writes the 512 path under the 2000 key
|
||||
// and Hero/fullscreen surfaces show a downscaled image.
|
||||
fs::write(tier_path(&dir, 512), b"x").unwrap();
|
||||
assert!(peek_plain_cover_tier(&dir, 2000).is_none());
|
||||
// A display tier still ladders to a larger warmed tier.
|
||||
assert_eq!(peek_plain_cover_tier(&dir, 256), Some(tier_path(&dir, 512)));
|
||||
// Exact full-res is a hit.
|
||||
fs::write(tier_path(&dir, 2000), b"y").unwrap();
|
||||
assert_eq!(peek_plain_cover_tier(&dir, 2000), Some(tier_path(&dir, 2000)));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_peek_display_tier_keeps_ladder_fallback() {
|
||||
let root = fresh_tmpdir("ensure-peek-ladder");
|
||||
let dir = root.join("album").join("al-1");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(tier_path(&dir, 800), b"x").unwrap();
|
||||
// A 256 display request still accepts a larger warmed tier (grid behaviour
|
||||
// is unchanged — only the full-res tier is exact-only).
|
||||
assert_eq!(
|
||||
ensure_peek(&dir, 256, &test_ensure_args(256, None)),
|
||||
Some(tier_path(&dir, 800)),
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -837,7 +837,6 @@ pub fn run() {
|
||||
psysonic_library::commands::library_get_player_stats_day_detail,
|
||||
psysonic_library::commands::library_get_player_stats_year_bounds,
|
||||
psysonic_library::commands::library_get_player_stats_recent_days,
|
||||
psysonic_library::commands::library_get_recent_play_sessions,
|
||||
psysonic_library::commands::library_purge_server,
|
||||
psysonic_library::commands::library_migrate_server_index_keys,
|
||||
psysonic_library::commands::library_delete_server_data,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.49.0",
|
||||
"version": "1.49.0-dev",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -22,7 +22,6 @@
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"visible": false,
|
||||
"backgroundColor": "#1e1e2e",
|
||||
"dragDropEnabled": false
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"bundle": {
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ export default function App() {
|
||||
useThemeStore(s => s.theme);
|
||||
const effectiveTheme = useThemeScheduler();
|
||||
const font = useFontStore(s => s.font);
|
||||
const buttonSize = useThemeStore(s => s.buttonSize);
|
||||
const installedThemes = useInstalledThemesStore(s => s.themes);
|
||||
|
||||
// Document-attribute hooks are shared between both window kinds — each
|
||||
@@ -82,10 +81,6 @@ export default function App() {
|
||||
document.documentElement.setAttribute('data-font', font);
|
||||
}, [font]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-button-size', buttonSize);
|
||||
}, [buttonSize]);
|
||||
|
||||
// Hide all inline track-preview buttons when the user opts out — single
|
||||
// CSS hook (`html[data-track-previews="off"]`) instead of conditional
|
||||
// rendering in every tracklist. Per-location toggles use additional
|
||||
|
||||
+1
-29
@@ -709,7 +709,7 @@ export type PlaySessionDayTrack = {
|
||||
title: string;
|
||||
artist: string | null;
|
||||
listenedSec: number;
|
||||
completion: 'partial' | 'full';
|
||||
completion: 'partial' | 'full' | string;
|
||||
startedAtMs: number;
|
||||
};
|
||||
|
||||
@@ -840,34 +840,6 @@ export function libraryGetPlayerStatsRecentDays(limit = 30): Promise<PlaySession
|
||||
return invoke<PlaySessionRecentDay[]>('library_get_player_stats_recent_days', { limit });
|
||||
}
|
||||
|
||||
export type PlaySessionRecentTrack = {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
title: string;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
albumId: string | null;
|
||||
coverArtId: string | null;
|
||||
startedAtMs: number;
|
||||
listenedSec: number;
|
||||
completion: 'partial' | 'full';
|
||||
};
|
||||
|
||||
export function libraryGetRecentPlaySessions(args?: {
|
||||
limit?: number;
|
||||
sinceMs?: number;
|
||||
}): Promise<PlaySessionRecentTrack[]> {
|
||||
return invoke<PlaySessionRecentTrack[]>('library_get_recent_play_sessions', {
|
||||
limit: args?.limit,
|
||||
sinceMs: args?.sinceMs,
|
||||
}).then(rows =>
|
||||
rows.map(row => ({
|
||||
...row,
|
||||
serverId: mapServerIdFromIndexKey(row.serverId),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Event subscriptions ───────────────────────────────────────────────
|
||||
|
||||
export interface LibrarySyncProgressPayload {
|
||||
|
||||
@@ -129,9 +129,6 @@ export interface OrbitTransitionSettings {
|
||||
crossfadeTrimSilence: boolean;
|
||||
autodjSmoothSkip: boolean;
|
||||
gaplessEnabled: boolean;
|
||||
/** Optional — absent on sessions hosted by builds before overlap-cap sync. */
|
||||
autodjOverlapCapMode?: 'auto' | 'limit';
|
||||
autodjOverlapCapSec?: number;
|
||||
}
|
||||
|
||||
export interface OrbitSettings {
|
||||
|
||||
@@ -15,8 +15,7 @@ import type {
|
||||
} from './subsonicTypes';
|
||||
|
||||
export async function getArtists(): Promise<SubsonicArtist[]> {
|
||||
type ArtistIndexEntry = { artist?: SubsonicArtist | SubsonicArtist[] };
|
||||
const data = await api<{ artists?: { index?: ArtistIndexEntry | ArtistIndexEntry[] } }>('getArtists.view', {
|
||||
const data = await api<{ artists: { index: any } }>('getArtists.view', {
|
||||
...libraryFilterParams(),
|
||||
});
|
||||
const rawIdx = data.artists?.index;
|
||||
|
||||
@@ -136,6 +136,11 @@ async function albumIdsInLibraryScope(serverId: string): Promise<Set<string> | n
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function albumIdsInActiveLibraryScope(): Promise<Set<string> | null> {
|
||||
const { activeServerId } = useAuthStore.getState();
|
||||
return activeServerId ? albumIdsInLibraryScope(activeServerId) : null;
|
||||
}
|
||||
|
||||
export async function filterSongsToServerLibrary(
|
||||
songs: SubsonicSong[],
|
||||
serverId: string,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import md5 from 'md5';
|
||||
import { coverStorageKeyFromRef } from '../cover/storageKeys';
|
||||
import { coverStorageKey, coverStorageKeyFromRef } from '../cover/storageKeys';
|
||||
import { coverEntryToRef, resolveAlbumCoverEntry } from '../cover/resolveEntry';
|
||||
import type { CoverArtTier } from '../cover/types';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
+7
-14
@@ -2,6 +2,7 @@ import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ensurePlaybackServerActive } from '../utils/playback/playbackServer';
|
||||
import { navigatePathWithAlbumReturnTo, shouldSkipMainScrollResetOnRouteChange } from '../utils/navigation/albumDetailNavigation';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
import { PanelRight } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -56,7 +57,7 @@ import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm';
|
||||
import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav';
|
||||
import { useOfflineLibraryFilterSuspend } from '../hooks/useOfflineLibraryFilterSuspend';
|
||||
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
|
||||
import { IS_LINUX, IS_MACOS } from '../utils/platform';
|
||||
import { IS_LINUX } from '../utils/platform';
|
||||
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useIdlePlayQueuePull } from '../hooks/useIdlePlayQueuePull';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -75,7 +76,7 @@ import {
|
||||
} from '../utils/componentHelpers/appShellHelpers';
|
||||
|
||||
/**
|
||||
* The main webview's persistent layout: titlebar (Linux + macOS) + sidebar +
|
||||
* The main webview's persistent layout: titlebar (Linux only) + sidebar +
|
||||
* main content area (header + route host + offline banner) + queue panel +
|
||||
* player bar + fullscreen overlay + global modals + tray-tooltip / title
|
||||
* sync. Mounted under `<RequireAuth>` and shared across all routes.
|
||||
@@ -112,6 +113,7 @@ export function AppShell() {
|
||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
|
||||
const hasOfflineContent = offlineCtx.hasBrowsingContent;
|
||||
const hasOfflineBrowse = offlineCtx.hasBrowseCapability;
|
||||
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
@@ -216,6 +218,7 @@ export function AppShell() {
|
||||
|
||||
const {
|
||||
queueWidth,
|
||||
isDraggingQueue,
|
||||
setIsDraggingQueue,
|
||||
queueHandleTop,
|
||||
handleQueueHandleMouseDown,
|
||||
@@ -226,22 +229,12 @@ export function AppShell() {
|
||||
|
||||
const isMobilePlayer = isMobile && location.pathname === '/now-playing';
|
||||
|
||||
// Custom in-page titlebar. Linux: opt-in, native decorations off. macOS:
|
||||
// always on — `titleBarStyle: Overlay` lets the webview reach the top edge
|
||||
// with the native traffic lights floating over our themed bar, so the bar
|
||||
// follows the active theme instead of the grey system titlebar (#1198).
|
||||
// Hidden in native fullscreen (the OS chrome is gone there anyway).
|
||||
const showLinuxTitlebar = IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm;
|
||||
const showMacTitlebar = IS_MACOS && !isWindowFullscreen;
|
||||
const showTitlebar = showLinuxTitlebar || showMacTitlebar;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`app-shell ${floatingPlayerBar ? 'floating-player' : ''}`}
|
||||
data-mobile={isMobile || undefined}
|
||||
data-mobile-player={isMobilePlayer || undefined}
|
||||
data-titlebar={showTitlebar || undefined}
|
||||
data-titlebar-platform={showMacTitlebar ? 'macos' : showLinuxTitlebar ? 'linux' : undefined}
|
||||
data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm) || undefined}
|
||||
data-fullscreen={isWindowFullscreen || undefined}
|
||||
style={{
|
||||
'--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'),
|
||||
@@ -251,7 +244,7 @@ export function AppShell() {
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
>
|
||||
{showTitlebar && <TitleBar />}
|
||||
{IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm && <TitleBar />}
|
||||
{import.meta.env.DEV && isMobile && (
|
||||
<span className="dev-build-badge" aria-hidden>DEV</span>
|
||||
)}
|
||||
|
||||
+3
-6
@@ -32,7 +32,6 @@ import { useMigrationOrchestrator } from '../hooks/useMigrationOrchestrator';
|
||||
import { IS_WINDOWS } from '../utils/platform';
|
||||
import TauriEventBridge from './TauriEventBridge';
|
||||
import AppShell from './AppShell';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
import BlockingMigrationGate from './BlockingMigrationGate';
|
||||
import RequireAuth from './RequireAuth';
|
||||
import { useMigrationStore } from '../store/migrationStore';
|
||||
@@ -192,11 +191,9 @@ export default function MainApp() {
|
||||
path="/*"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<ErrorBoundary>
|
||||
<DragDropProvider>
|
||||
<AppShell />
|
||||
</DragDropProvider>
|
||||
</ErrorBoundary>
|
||||
<DragDropProvider>
|
||||
<AppShell />
|
||||
</DragDropProvider>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
+8
-17
@@ -69,23 +69,14 @@ export function applyThemeAtStartup(): void {
|
||||
const s = parsed.state;
|
||||
if (!s) return;
|
||||
syncInjectedThemes(readInstalledThemes());
|
||||
// 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,
|
||||
// but only affects this initial paint before the effect corrects it.
|
||||
const systemPrefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||
const effective = getScheduledTheme(
|
||||
{
|
||||
enableThemeScheduler: !!s.enableThemeScheduler,
|
||||
schedulerMode: s.schedulerMode === 'system' ? 'system' : 'time',
|
||||
theme: String(s.theme ?? 'mocha'),
|
||||
themeDay: String(s.themeDay ?? 'latte'),
|
||||
themeNight: String(s.themeNight ?? 'mocha'),
|
||||
timeDayStart: String(s.timeDayStart ?? '07:00'),
|
||||
timeNightStart: String(s.timeNightStart ?? '19:00'),
|
||||
},
|
||||
systemPrefersDark,
|
||||
);
|
||||
const effective = getScheduledTheme({
|
||||
enableThemeScheduler: !!s.enableThemeScheduler,
|
||||
theme: String(s.theme ?? 'mocha'),
|
||||
themeDay: String(s.themeDay ?? 'latte'),
|
||||
themeNight: String(s.themeNight ?? 'mocha'),
|
||||
timeDayStart: String(s.timeDayStart ?? '07:00'),
|
||||
timeNightStart: String(s.timeNightStart ?? '19:00'),
|
||||
});
|
||||
if (effective) document.documentElement.setAttribute('data-theme', effective);
|
||||
} catch {
|
||||
// Non-fatal — App's effects apply the theme after mount.
|
||||
|
||||
@@ -29,8 +29,8 @@ export function AboutPsysonicBrandHeader({
|
||||
}) {
|
||||
const modalWordmarkGradSuffix = useId().replace(/:/g, '');
|
||||
const [phase, setPhase] = useState<'idle' | 'hint' | 'done'>('idle');
|
||||
const [, setIdleTaps] = useState(0);
|
||||
const [, setHintTimestamps] = useState<number[]>([]);
|
||||
const [idleTaps, setIdleTaps] = useState(0);
|
||||
const [hintTimestamps, setHintTimestamps] = useState<number[]>([]);
|
||||
const [overlayOpen, setOverlayOpen] = useState(false);
|
||||
|
||||
const onLogoClick = useCallback(() => {
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../test/helpers/renderWithProviders';
|
||||
import AlbumHeader from './AlbumHeader';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
|
||||
const navigate = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', async importActual => {
|
||||
const actual = await importActual<typeof import('react-router-dom')>();
|
||||
return { ...actual, useNavigate: () => navigate };
|
||||
});
|
||||
|
||||
// Genre-unrelated dependencies — stub so the test stays focused on the meta row.
|
||||
vi.mock('../cover/useLibraryCoverRef', () => ({ useAlbumCoverRef: () => undefined }));
|
||||
vi.mock('../cover/lightbox', () => ({ useCoverLightboxSrc: () => ({ open: vi.fn(), lightbox: null }) }));
|
||||
vi.mock('../hooks/useAlbumDetailBack', () => ({ useAlbumDetailBack: () => vi.fn() }));
|
||||
vi.mock('../hooks/useIsMobile', () => ({ useIsMobile: () => false }));
|
||||
vi.mock('../store/themeStore', () => ({ useThemeStore: () => false }));
|
||||
vi.mock('./StarRating', () => ({ default: () => null }));
|
||||
vi.mock('./OpenArtistRefInline', () => ({ OpenArtistRefInline: () => null }));
|
||||
vi.mock('../cover/CoverArtImage', () => ({ CoverArtImage: () => null }));
|
||||
|
||||
function baseProps() {
|
||||
return {
|
||||
headerArtistRefs: [],
|
||||
songs: [] as SubsonicSong[],
|
||||
resolvedCoverUrl: null,
|
||||
isStarred: false,
|
||||
downloadProgress: null,
|
||||
offlineStatus: 'none' as const,
|
||||
offlineProgress: null,
|
||||
bio: null,
|
||||
bioOpen: false,
|
||||
onToggleStar: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
onCacheOffline: vi.fn(),
|
||||
onRemoveOffline: vi.fn(),
|
||||
onPlayAll: vi.fn(),
|
||||
onEnqueueAll: vi.fn(),
|
||||
onBio: vi.fn(),
|
||||
onCloseBio: vi.fn(),
|
||||
entityRatingValue: 0,
|
||||
onEntityRatingChange: vi.fn(),
|
||||
entityRatingSupport: 'unknown' as const,
|
||||
};
|
||||
}
|
||||
|
||||
const albumInfo = (over: Record<string, unknown> = {}) => ({
|
||||
id: 'al1', name: 'Album', artist: 'Artist', artistId: 'a1', ...over,
|
||||
});
|
||||
|
||||
describe('AlbumHeader genres', () => {
|
||||
it('shows the primary genre inline and the rest in a cursor menu', async () => {
|
||||
navigate.mockClear();
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<AlbumHeader
|
||||
{...baseProps()}
|
||||
info={albumInfo({ genres: [{ name: 'Power Metal' }, { name: 'Rock' }] })}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Primary genre is a link; the extra genre stays hidden until the menu opens.
|
||||
expect(screen.getByRole('button', { name: 'More albums in Power Metal' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Rock')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show all genres' }));
|
||||
|
||||
// The menu lists only the remaining genres — the primary is not repeated.
|
||||
expect(screen.getAllByText('Power Metal')).toHaveLength(1);
|
||||
const rock = screen.getByText('Rock');
|
||||
await user.click(rock);
|
||||
expect(navigate).toHaveBeenCalledWith('/genres/Rock', { state: { returnTo: '/album/al1' } });
|
||||
});
|
||||
|
||||
it('unions track-level genres after the album genres', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<AlbumHeader
|
||||
{...baseProps()}
|
||||
songs={[{ id: 't1', duration: 100, genres: [{ name: 'Heavy Metal' }] } as unknown as SubsonicSong]}
|
||||
info={albumInfo({ genres: [{ name: 'Power Metal' }] })}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Album has one genre, the track adds another → +1 chip, extra genre in the menu.
|
||||
await user.click(screen.getByRole('button', { name: 'Show all genres' }));
|
||||
expect(screen.getByText('Heavy Metal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no +N control for a single genre', () => {
|
||||
renderWithProviders(
|
||||
<AlbumHeader {...baseProps()} info={albumInfo({ genres: [{ name: 'Power Metal' }] })} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'More albums in Power Metal' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Show all genres' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to splitting the legacy genre string', () => {
|
||||
renderWithProviders(
|
||||
<AlbumHeader {...baseProps()} info={albumInfo({ genre: 'Rock; Metal' })} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'More albums in Rock' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Show all genres' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens via keyboard with focus inside the menu, arrow-navigates, and restores focus on close', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<AlbumHeader
|
||||
{...baseProps()}
|
||||
info={albumInfo({ genres: [{ name: 'Power Metal' }, { name: 'Rock' }, { name: 'Jazz' }] })}
|
||||
/>,
|
||||
);
|
||||
|
||||
const more = screen.getByRole('button', { name: 'Show all genres' });
|
||||
more.focus();
|
||||
// Enter activates the chip from the keyboard — focus must land on the first item.
|
||||
await user.keyboard('{Enter}');
|
||||
const items = screen.getAllByRole('menuitem');
|
||||
expect(items[0]).toHaveFocus();
|
||||
|
||||
await user.keyboard('{ArrowDown}');
|
||||
expect(items[1]).toHaveFocus();
|
||||
|
||||
// Escape closes the menu and returns focus to the +N trigger.
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||
expect(more).toHaveFocus();
|
||||
});
|
||||
});
|
||||
+11
-147
@@ -1,8 +1,8 @@
|
||||
import type { EntityRatingSupportLevel, SubsonicItemGenre, SubsonicOpenArtistRef, SubsonicSong } from '../api/subsonicTypes';
|
||||
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { EntityRatingSupportLevel, SubsonicOpenArtistRef, SubsonicSong } from '../api/subsonicTypes';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Heart, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { useCoverLightboxSrc } from '../cover/lightbox';
|
||||
@@ -20,8 +20,6 @@ import { sanitizeHtml } from '../utils/sanitizeHtml';
|
||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||
import { deriveAlbumGenreTags } from '../utils/library/genreTags';
|
||||
import { genreColor } from '../utils/library/genreColor';
|
||||
|
||||
/** True when the album artist label means "no single artist" — `getArtistInfo`
|
||||
* has nothing meaningful to return for these, so the Artist Bio entry is hidden.
|
||||
@@ -56,83 +54,6 @@ function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
|
||||
}
|
||||
|
||||
|
||||
/** Cursor-anchored genre list (context-menu style) for the "+N" chip. */
|
||||
function GenreMenu({
|
||||
genres, pos, onPick, onClose,
|
||||
}: {
|
||||
genres: string[];
|
||||
pos: { x: number; y: number };
|
||||
onPick: (genre: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [coords, setCoords] = useState(pos);
|
||||
|
||||
// Clamp into the viewport once the menu has measured its own size, then move
|
||||
// focus to the first genre so keyboard users land inside the menu.
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pad = 8;
|
||||
setCoords({
|
||||
x: Math.max(pad, Math.min(pos.x, window.innerWidth - rect.width - pad)),
|
||||
y: Math.max(pad, Math.min(pos.y, window.innerHeight - rect.height - pad)),
|
||||
});
|
||||
el.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
|
||||
}, [pos]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { onClose(); return; }
|
||||
const items = Array.from(
|
||||
ref.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [],
|
||||
);
|
||||
if (items.length === 0) return;
|
||||
const focusAt = (i: number) => items[(i + items.length) % items.length].focus();
|
||||
const at = items.indexOf(document.activeElement as HTMLElement);
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); focusAt(at + 1); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); focusAt(at < 0 ? -1 : at - 1); }
|
||||
else if (e.key === 'Home') { e.preventDefault(); focusAt(0); }
|
||||
else if (e.key === 'End') { e.preventDefault(); focusAt(items.length - 1); }
|
||||
};
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
document.addEventListener('mousedown', onDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className="genre-menu"
|
||||
role="menu"
|
||||
aria-label={t('albumDetail.genresModalTitle')}
|
||||
style={{ left: coords.x, top: coords.y }}
|
||||
>
|
||||
{genres.map(g => (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="genre-menu-item"
|
||||
style={{ '--genre-color': genreColor(g) } as CSSProperties}
|
||||
onClick={() => onPick(g)}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
interface AlbumInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -140,8 +61,6 @@ interface AlbumInfo {
|
||||
artistId: string;
|
||||
year?: number;
|
||||
genre?: string;
|
||||
/** OpenSubsonic atomic genres — preferred over the legacy `genre` string. */
|
||||
genres?: SubsonicItemGenre[];
|
||||
coverArt?: string;
|
||||
recordLabel?: string;
|
||||
created?: string;
|
||||
@@ -220,13 +139,6 @@ export default function AlbumHeader({
|
||||
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
|
||||
const isNewAlbum = isAlbumRecentlyAdded(info.created);
|
||||
const showBioButton = !isVariousArtistsLabel(info.artist);
|
||||
const genreTags = deriveAlbumGenreTags(info, songs);
|
||||
const [genreMenuPos, setGenreMenuPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const genreMoreRef = useRef<HTMLButtonElement>(null);
|
||||
const goToGenre = (genre: string) => {
|
||||
setGenreMenuPos(null);
|
||||
navigate(`/genres/${encodeURIComponent(genre)}`, { state: { returnTo: `/album/${info.id}` } });
|
||||
};
|
||||
|
||||
const handleShareAlbum = async () => {
|
||||
try {
|
||||
@@ -243,18 +155,6 @@ export default function AlbumHeader({
|
||||
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
|
||||
{lightbox}
|
||||
|
||||
{genreMenuPos && (
|
||||
<GenreMenu
|
||||
genres={genreTags.slice(1)}
|
||||
pos={genreMenuPos}
|
||||
onPick={goToGenre}
|
||||
onClose={() => {
|
||||
setGenreMenuPos(null);
|
||||
genreMoreRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="album-detail-header">
|
||||
{resolvedCoverUrl && enableCoverArtBackground && (
|
||||
<>
|
||||
@@ -303,42 +203,9 @@ export default function AlbumHeader({
|
||||
linkClassName="album-detail-artist-link"
|
||||
/>
|
||||
</p>
|
||||
{genreTags.length > 0 && (
|
||||
<div className="album-detail-genre-row">
|
||||
<button
|
||||
className="album-detail-genre-pill"
|
||||
data-tooltip={t('albumDetail.moreGenreAlbums', { genre: genreTags[0] })}
|
||||
aria-label={t('albumDetail.moreGenreAlbums', { genre: genreTags[0] })}
|
||||
onClick={() => goToGenre(genreTags[0])}
|
||||
>
|
||||
{genreTags[0]}
|
||||
</button>
|
||||
{genreTags.length > 1 && (
|
||||
<button
|
||||
ref={genreMoreRef}
|
||||
className="album-detail-genre-pill"
|
||||
data-tooltip={t('albumDetail.showAllGenres')}
|
||||
aria-label={t('albumDetail.showAllGenres')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={genreMenuPos != null}
|
||||
onClick={e => {
|
||||
// Keyboard activation (Enter/Space) reports clientX/Y = 0 →
|
||||
// anchor below the chip instead of the viewport corner.
|
||||
if (e.detail === 0) {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
setGenreMenuPos({ x: r.left, y: r.bottom + 4 });
|
||||
} else {
|
||||
setGenreMenuPos({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{`+${genreTags.length - 1}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="album-detail-info">
|
||||
{info.year && <span>{info.year}</span>}
|
||||
{info.genre && <span>· {info.genre}</span>}
|
||||
<span>· {songs.length} Tracks</span>
|
||||
<span>· {formatLongDuration(totalDuration)}</span>
|
||||
{formatLabel && <span>· {formatLabel}</span>}
|
||||
@@ -475,7 +342,7 @@ export default function AlbumHeader({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="album-detail-actions compact-action-bar">
|
||||
<div className="album-detail-actions">
|
||||
<div className="album-detail-actions-primary">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
@@ -483,7 +350,7 @@ export default function AlbumHeader({
|
||||
onClick={onPlayAll}
|
||||
{...tooltipAttrs(t('albumDetail.playTooltip'))}
|
||||
>
|
||||
<Play size={15} /> <span className="compact-btn-label">{t('common.play', 'Reproducir')}</span>
|
||||
<Play size={15} /> {t('common.play', 'Reproducir')}
|
||||
</button>
|
||||
{onShuffleAll && (
|
||||
<button
|
||||
@@ -528,7 +395,7 @@ export default function AlbumHeader({
|
||||
onClick={onBio}
|
||||
{...tooltipAttrs(t('albumDetail.artistBioTooltip'))}
|
||||
>
|
||||
<Highlighter size={16} /> <span className="compact-btn-label">{t('albumDetail.artistBio')}</span>
|
||||
<Highlighter size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -548,7 +415,7 @@ export default function AlbumHeader({
|
||||
onClick={onDownload}
|
||||
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
|
||||
>
|
||||
<Download size={16} /> <span className="compact-btn-label">{t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}</span>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
@@ -562,31 +429,28 @@ export default function AlbumHeader({
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn offline-cache-btn--queued"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.offlineQueued')}
|
||||
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
<span className="compact-btn-label">{t('albumDetail.offlineQueued')}</span>
|
||||
{t('albumDetail.offlineQueued')}
|
||||
</button>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
|
||||
onClick={onRemoveOffline}
|
||||
aria-label={t('albumDetail.offlineCached')}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
<span className="compact-btn-label">{t('albumDetail.offlineCached')}</span>
|
||||
{t('albumDetail.offlineCached')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.cacheOffline')}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
<span className="compact-btn-label">{t('albumDetail.cacheOffline')}</span>
|
||||
{t('albumDetail.cacheOffline')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -129,18 +129,12 @@ export default function AlbumRow({
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
ro.disconnect();
|
||||
};
|
||||
// handleScroll/recomputeArtworkBudget are recreated each render but read live
|
||||
// refs/props; the listeners are intentionally (re)bound only when the row data
|
||||
// or artwork config changes, not on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uniqueAlbums, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
|
||||
|
||||
// Reset when the row’s identity changes (new data / server), not when the list grows via
|
||||
// “load more” — reusing albums.length would shrink the budget mid-scroll and flash placeholders.
|
||||
const rowArtworkResetKey = uniqueAlbums[0]?.id ?? '';
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setArtworkBudget(initialArtworkBudget);
|
||||
}, [initialArtworkBudget, rowArtworkResetKey]);
|
||||
|
||||
@@ -204,10 +198,6 @@ export default function AlbumRow({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// handleScroll/recomputeArtworkBudget/onScrollRestoreComplete are recreated
|
||||
// each render but read live state; the restore pass is intentionally keyed on
|
||||
// the row identity / layout signals, not on those callback identities.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rowArtworkResetKey, windowArtworkByViewport, initialArtworkBudget, uniqueAlbums.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -81,8 +81,6 @@ export default function AlbumTrackList({
|
||||
} = useAlbumTrackListSelection({ songs, tracklistRef });
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function AppUpdater() {
|
||||
const {
|
||||
release, dismissed, setDismissed, changelogOpen, setChangelogOpen,
|
||||
dlState, dlProgress, dlError, countdown,
|
||||
asset, showAurHint, showWingetHint, useTauriUpdater, showInstallBtn, pct,
|
||||
asset, showAurHint, useTauriUpdater, showInstallBtn, pct,
|
||||
handleSkip, handleRestartNow, handleDownload, handleShowFolder,
|
||||
} = useAppUpdater();
|
||||
|
||||
@@ -184,12 +184,6 @@ export default function AppUpdater() {
|
||||
<span className="update-modal-asset-size">{formatBytes(asset.size)}</span>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'idle' && showWingetHint && (
|
||||
<div className="update-modal-winget">
|
||||
<div className="update-modal-winget-title">{t('common.updaterWingetHint')}</div>
|
||||
<code className="update-modal-winget-cmd">winget upgrade Psysonic</code>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'downloading' && (
|
||||
<div className="update-modal-progress">
|
||||
<div className="app-updater-progress-bar">
|
||||
|
||||
@@ -83,18 +83,12 @@ export default function ArtistRow({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// handleScroll is recreated each render but reads live refs; the restore pass
|
||||
// is intentionally keyed on the row identity, not on the callback identity.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rowResetKey, artists.length]);
|
||||
|
||||
useEffect(() => {
|
||||
handleScroll();
|
||||
window.addEventListener('resize', handleScroll);
|
||||
return () => window.removeEventListener('resize', handleScroll);
|
||||
// handleScroll is recreated each render but reads live refs; the resize
|
||||
// listener is intentionally rebound only when the row data changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [artists]);
|
||||
|
||||
const scroll = (dir: 'left' | 'right') => {
|
||||
|
||||
@@ -35,8 +35,6 @@ export default function BackToTopButton({
|
||||
const [host, setHost] = useState<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setHost(document.querySelector<HTMLElement>('.app-shell-route-host'));
|
||||
const el = document.getElementById(viewportId);
|
||||
if (!el) return;
|
||||
|
||||
@@ -214,8 +214,6 @@ function useBecauseRowSlotCount(active: boolean, max = SHOW_COUNT): number {
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setCount(1);
|
||||
return;
|
||||
}
|
||||
@@ -372,8 +370,6 @@ export default function BecauseYouLikeRail({
|
||||
* while-revalidate), only clearing to skeleton when nothing is available. */
|
||||
useLayoutEffect(() => {
|
||||
if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAnchor(_becauseReserve!.anchor);
|
||||
setRecs(_becauseReserve!.recs);
|
||||
setRefreshing(false);
|
||||
@@ -417,8 +413,6 @@ export default function BecauseYouLikeRail({
|
||||
return;
|
||||
}
|
||||
if (!activeServerId) {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAnchor(null);
|
||||
setRecs([]);
|
||||
setRefreshing(false);
|
||||
|
||||
@@ -43,11 +43,6 @@ type ResolvedSlice = { key: string | null; url: string };
|
||||
* loading immediately. Pass false for CSS background-image consumers that
|
||||
* should only see a stable blob URL (prevents a double crossfade).
|
||||
*/
|
||||
// useCachedUrl is the headless companion of CachedImage — it shares the same
|
||||
// caching contract and the module-local ResolvedSlice type, and is documented
|
||||
// alongside the component in src/CLAUDE.md. Intentional co-location; the
|
||||
// fast-refresh rule is an HMR-only concern.
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useCachedUrl(
|
||||
fetchUrl: string,
|
||||
cacheKey: string,
|
||||
@@ -59,8 +54,6 @@ export function useCachedUrl(
|
||||
// `fetchUrl` were an effect dependency, cleanup would run every frame, call
|
||||
// `releaseUrl`, revoke the blob, and break <img> until onError hides it.
|
||||
const fetchUrlRef = useRef(fetchUrl);
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
fetchUrlRef.current = fetchUrl;
|
||||
|
||||
// Pair blob URL with the `cacheKey` it belongs to. After `cacheKey` changes,
|
||||
@@ -78,8 +71,6 @@ export function useCachedUrl(
|
||||
);
|
||||
|
||||
const getPriorityRef = useRef(getPriority);
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
getPriorityRef.current = getPriority;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -109,8 +100,6 @@ export function useCachedUrl(
|
||||
const sync = acquireUrl(cacheKey);
|
||||
if (sync) {
|
||||
ownedKeyRef.current = cacheKey;
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setResolvedSlice({ key: cacheKey, url: sync });
|
||||
return release;
|
||||
}
|
||||
@@ -222,8 +211,6 @@ export default function CachedImage({
|
||||
// useLayoutEffect: one paint with `loaded` still true + a stale/wrong `src`
|
||||
// showed a broken-cover flash in the player bar when switching tracks (#606).
|
||||
useLayoutEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLoaded(false);
|
||||
setFallbackSrc(undefined);
|
||||
}, [cacheKey]);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* markup as long as the menu items + their handlers stay observable.
|
||||
*/
|
||||
import type { ServerProfile } from '@/store/authStoreTypes';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/api/subsonic', () => ({
|
||||
|
||||
@@ -109,8 +109,6 @@ export default function ContextMenu() {
|
||||
useEffect(() => {
|
||||
if (contextMenu.isOpen) {
|
||||
cancelPlaylistSubmenuCloseTimer();
|
||||
// React Compiler set-state-in-effect rule: local coords synced from the store's contextMenu position when the menu opens.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setCoords({ x: contextMenu.x, y: contextMenu.y });
|
||||
setPlaylistSubmenuOpen(false);
|
||||
setPlaylistSongIds([]);
|
||||
@@ -132,18 +130,6 @@ export default function ContextMenu() {
|
||||
}
|
||||
}, [contextMenu.isOpen, contextMenu.x, contextMenu.y]);
|
||||
|
||||
// Close on any window resize. The menu is absolutely positioned at fixed
|
||||
// coordinates, so a resize would otherwise leave it stranded and drifting
|
||||
// off-screen. Whether a resize closed the menu was inconsistent across
|
||||
// setups (it stayed open on some Windows and Linux environments); always
|
||||
// closing it here makes the behaviour the same everywhere.
|
||||
useEffect(() => {
|
||||
if (!contextMenu.isOpen) return;
|
||||
const onResize = () => closeContextMenu();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [contextMenu.isOpen, closeContextMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contextMenu.isOpen) {
|
||||
previousFocusRef.current = document.activeElement as HTMLElement | null;
|
||||
|
||||
@@ -31,10 +31,6 @@ export default function Equalizer() {
|
||||
const bg = style.getPropertyValue('--bg-app').trim() || '#1e1e2e';
|
||||
const text = style.getPropertyValue('--text-muted').trim() || 'rgba(255,255,255,0.4)';
|
||||
drawCurve(canvas, gains, accent, bg, text);
|
||||
// theme is an intentional re-create trigger: redraw reads the live CSS custom
|
||||
// properties via getComputedStyle, so it must re-run when the theme changes
|
||||
// even though the `theme` value itself is not referenced here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gains, theme]);
|
||||
|
||||
useEffect(() => { redraw(); }, [redraw]);
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* App-level error boundary. A render-time throw used to blank the entire window
|
||||
* — no boundary existed (issue #382), so any thrown error during render took the
|
||||
* whole UI down. This catches it and shows a recoverable fallback while playback
|
||||
* (driven by the Rust audio engine, outside React) keeps going.
|
||||
*
|
||||
* Deliberately hook-free and English-only: the fallback has to render even when
|
||||
* i18n, the theme, or app state is exactly what broke, so it must not depend on
|
||||
* any of them. The CSS uses literal fallbacks in `var(...)` for the same reason.
|
||||
*/
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error('[error-boundary]', error, info.componentStack);
|
||||
}
|
||||
|
||||
private handleRetry = (): void => this.setState({ error: null });
|
||||
|
||||
private handleReload = (): void => window.location.reload();
|
||||
|
||||
render(): ReactNode {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
return (
|
||||
<div className="app-error-boundary" role="alert">
|
||||
<div className="app-error-boundary__card">
|
||||
<h1 className="app-error-boundary__title">Something went wrong</h1>
|
||||
<p className="app-error-boundary__text">
|
||||
This view hit an unexpected error and stopped rendering. Playback keeps going — try the
|
||||
view again, or reload the app.
|
||||
</p>
|
||||
<pre className="app-error-boundary__detail">{error.message}</pre>
|
||||
<div className="app-error-boundary__actions">
|
||||
<button type="button" className="btn btn-primary" onClick={this.handleRetry}>
|
||||
Try again
|
||||
</button>
|
||||
<button type="button" className="btn btn-surface" onClick={this.handleReload}>
|
||||
Reload app
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -92,8 +92,6 @@ export default function FpsOverlay() {
|
||||
} = visibility;
|
||||
|
||||
const sparklineNow = useMemo(
|
||||
// React Compiler purity rule: intentional live-timestamp read at render (Date.now()); the value is allowed to differ between renders.
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
() => (live.sampleAt > 0 ? live.sampleAt : Date.now()),
|
||||
[live.sampleAt],
|
||||
);
|
||||
@@ -103,8 +101,6 @@ export default function FpsOverlay() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAnalysisPerfOverlay) {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTpm(0);
|
||||
return;
|
||||
}
|
||||
@@ -116,8 +112,6 @@ export default function FpsOverlay() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAnalysisPerfOverlay) {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setQueueStats(null);
|
||||
return;
|
||||
}
|
||||
@@ -141,8 +135,6 @@ export default function FpsOverlay() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCoverPerfOverlay) {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setCpm(0);
|
||||
setCpmUi(0);
|
||||
return;
|
||||
@@ -158,8 +150,6 @@ export default function FpsOverlay() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCoverPerfOverlay) {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setCoverQueueLines([]);
|
||||
return;
|
||||
}
|
||||
@@ -190,8 +180,6 @@ export default function FpsOverlay() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!showFpsOverlay) {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setFps(0);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -50,8 +50,6 @@ export default function GenreFilterBar({
|
||||
|
||||
useEffect(() => {
|
||||
if (catalogGenres != null) {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setGenreRows(mergeGenreRows(catalogGenres, selected));
|
||||
return;
|
||||
}
|
||||
|
||||
+36
-106
@@ -6,11 +6,8 @@ import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
||||
import { Play, ListPlus, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { useArtistBanner, useArtistFanart } from '../cover/useArtistFanart';
|
||||
import { useCoverArt } from '../cover/useCoverArt';
|
||||
import { useHeroBackdrop } from '../cover/useHeroBackdrop';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
@@ -22,7 +19,7 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
|
||||
import { useLongPressAction } from '../hooks/useLongPressAction';
|
||||
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
||||
import { albumArtistDisplayName, deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
|
||||
const INTERVAL_MS = 10000;
|
||||
const HERO_ALBUM_COUNT = 8;
|
||||
@@ -33,69 +30,31 @@ const HERO_FG_CSS_PX = 220;
|
||||
/** Hero blurred backdrop (full banner height). */
|
||||
const HERO_BG_CSS_PX = 360;
|
||||
|
||||
// Crossfading background — same layer pattern as FullscreenPlayer. Each layer
|
||||
// carries its own `position` (the banner stays centered, portrait-ish fanart /
|
||||
// artist covers raise the focal point), so a crossfade never re-frames the
|
||||
// outgoing image. `position` is keyed off `url` (it only changes when the url
|
||||
// changes) so the effect dep stays `[url]`.
|
||||
function HeroBg({ url, position }: { url: string; position?: string }) {
|
||||
const [layers, setLayers] = useState<
|
||||
Array<{ url: string; position?: string; id: number; visible: boolean }>
|
||||
>(() => (url ? [{ url, position, id: 0, visible: true }] : []));
|
||||
// Crossfading background — same layer pattern as FullscreenPlayer
|
||||
function HeroBg({ url }: { url: string }) {
|
||||
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
|
||||
url ? [{ url, id: 0, visible: true }] : []
|
||||
);
|
||||
const counter = useRef(url ? 1 : 0);
|
||||
const latestUrlRef = useRef(url);
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
latestUrlRef.current = url;
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLayers([]);
|
||||
return;
|
||||
}
|
||||
const id = counter.current++;
|
||||
setLayers(prev => [...prev, { url, position, id, visible: false }]);
|
||||
|
||||
let revealed = false;
|
||||
let cleanup: ReturnType<typeof setTimeout> | undefined;
|
||||
const reveal = () => {
|
||||
if (revealed || latestUrlRef.current !== url) return;
|
||||
revealed = true;
|
||||
// Crossfade this layer in; the others fade out, then get dropped.
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
const t1 = setTimeout(() => {
|
||||
if (latestUrlRef.current !== url) return;
|
||||
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
|
||||
cleanup = setTimeout(() => {
|
||||
if (latestUrlRef.current !== url) return;
|
||||
setLayers(prev => prev.filter(l => l.id === id));
|
||||
}, 900);
|
||||
};
|
||||
|
||||
// Reveal only once the bytes are decoded, so the crossfade never fades in a
|
||||
// blank / half-loaded image (the flicker the bare 20 ms timer had). The
|
||||
// preload + scheduling happen exactly once here per url — no per-render
|
||||
// <img> ref / onLoad, so this can't stack updates like the reverted attempt.
|
||||
const pre = new Image();
|
||||
pre.decoding = 'async';
|
||||
pre.src = url;
|
||||
let fallback: ReturnType<typeof setTimeout> | undefined;
|
||||
if (pre.complete && pre.naturalWidth > 0) {
|
||||
reveal();
|
||||
} else {
|
||||
pre.onload = reveal;
|
||||
pre.onerror = reveal;
|
||||
fallback = setTimeout(reveal, 1500);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (fallback) clearTimeout(fallback);
|
||||
if (cleanup) clearTimeout(cleanup);
|
||||
pre.onload = null;
|
||||
pre.onerror = null;
|
||||
};
|
||||
// `position` is intentionally omitted — it tracks `url` 1:1, and adding it
|
||||
// would spawn a duplicate layer if it ever changed without the url.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, 20);
|
||||
const t2 = setTimeout(() => {
|
||||
if (latestUrlRef.current !== url) return;
|
||||
setLayers(prev => prev.filter(l => l.id === id));
|
||||
}, 900);
|
||||
return () => { clearTimeout(t1); clearTimeout(t2); };
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
@@ -105,7 +64,7 @@ function HeroBg({ url, position }: { url: string; position?: string }) {
|
||||
key={layer.id}
|
||||
className="hero-bg-image"
|
||||
src={layer.url}
|
||||
style={{ opacity: layer.visible ? 1 : 0, objectPosition: layer.position }}
|
||||
style={{ opacity: layer.visible ? 1 : 0 }}
|
||||
aria-hidden="true"
|
||||
alt=""
|
||||
loading="eager"
|
||||
@@ -128,7 +87,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const isMobile = useIsMobile();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
|
||||
const mainstageBackdrop = useThemeStore(s => s.backdrops.mainstageHero);
|
||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
|
||||
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>(() =>
|
||||
@@ -143,8 +102,6 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const visibilityRafRef = useRef<number | null>(null);
|
||||
const [heroInView, setHeroInView] = useState(true);
|
||||
const heroInViewRef = useRef(true);
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
heroInViewRef.current = heroInView;
|
||||
|
||||
const computeHeroVisibleNow = useCallback((): boolean => {
|
||||
@@ -241,8 +198,6 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
}, [heroInView, windowHidden, updateHeroVisibility]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (albumsProp?.length) { setAlbums(albumsProp); return; }
|
||||
const cfg = { ...getMixMinRatingsConfigFromAuth(), minSong: 0 };
|
||||
const albumMix = cfg.enabled && (cfg.minAlbum > 0 || cfg.minArtist > 0);
|
||||
@@ -333,49 +288,24 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
}).catch(() => {
|
||||
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
|
||||
});
|
||||
// Intentionally keyed on album?.id only: the format label is fetched once per
|
||||
// album id and cached in albumFormats. Depending on the album object or the
|
||||
// albumFormats map would re-run on every render / cache write.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [album?.id]);
|
||||
|
||||
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt);
|
||||
const albumId = album?.id;
|
||||
const bgHandle = useCoverArt(heroCoverRef, HERO_BG_CSS_PX, {
|
||||
surface: 'dense',
|
||||
ensurePriority: 'high',
|
||||
});
|
||||
|
||||
// Mainstage hero backdrop — the album artist's fanart (banner → 16:9 fanart),
|
||||
// but its LAST fallback is the album's own Navidrome cover, not the artist
|
||||
// image: the hero frames an album, so its base layer stays the album cover
|
||||
// (the same backdrop shown when the feature is off). The artist-detail header
|
||||
// keeps the artist cover as its last fallback — that surface frames an artist.
|
||||
// Fed entirely from the album already in hand (artist id + name + album title),
|
||||
// so there is no getArtist/getAlbum round-trip: the MBID lookup + fanart fetch
|
||||
// live Rust-side in cover_cache.
|
||||
const heroArtist = useMemo(
|
||||
() => (album ? deriveAlbumArtistRefs(album)[0] : undefined),
|
||||
[album],
|
||||
);
|
||||
const heroArtistId = heroArtist?.id;
|
||||
const heroBanner = useArtistBanner(heroArtistId, {
|
||||
artistName: heroArtist?.name,
|
||||
albumTitle: album?.name,
|
||||
});
|
||||
const heroFanart = useArtistFanart(heroArtistId, {
|
||||
artistName: heroArtist?.name,
|
||||
albumTitle: album?.name,
|
||||
});
|
||||
// Last-fallback layer: the album's own Navidrome cover (HERO_BG_CSS_PX, full
|
||||
// res), resolved scope-true from the album cover ref already in hand.
|
||||
const ndAlbum = useCoverArt(heroCoverRef, HERO_BG_CSS_PX, { surface: 'sparse', fullRes: true });
|
||||
const ndAlbumUrl = useCachedUrl(ndAlbum.src, ndAlbum.cacheKey, true);
|
||||
const heroBackdrop = useHeroBackdrop(
|
||||
mainstageBackdrop.sources,
|
||||
{ banner: heroBanner, fanart: heroFanart, navidrome: ndAlbumUrl },
|
||||
albumId,
|
||||
);
|
||||
const showHeroBackdrop =
|
||||
mainstageBackdrop.enabled &&
|
||||
!perfFlags.disableMainstageHeroBackdrop &&
|
||||
heroInView;
|
||||
// Per-album fallback so a cache miss on the current slide does not flash empty,
|
||||
// but never reuse another album's art (that caused bg/foreground desync on fast nav).
|
||||
const stableBgByAlbum = useRef<Record<string, string>>({});
|
||||
const albumId = album?.id;
|
||||
useEffect(() => {
|
||||
if (bgHandle.src && albumId) {
|
||||
stableBgByAlbum.current[albumId] = bgHandle.src;
|
||||
}
|
||||
}, [bgHandle.src, albumId]);
|
||||
const heroBgUrl = bgHandle.src || (albumId ? stableBgByAlbum.current[albumId] ?? '' : '');
|
||||
const { isHolding, pressBind } = useLongPressAction({
|
||||
onShortPress: () => { if (albumId) playAlbum(albumId); },
|
||||
onLongPress: () => { if (albumId) playAlbumShuffled(albumId); },
|
||||
@@ -392,8 +322,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
onClick={() => navigateToAlbum(album.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{showHeroBackdrop && <HeroBg url={heroBackdrop.url} position={heroBackdrop.position} />}
|
||||
{showHeroBackdrop && <div className="hero-overlay" aria-hidden="true" />}
|
||||
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={heroBgUrl} />}
|
||||
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <div className="hero-overlay" aria-hidden="true" />}
|
||||
|
||||
{/* key causes re-mount → animate-fade-in triggers on each album change */}
|
||||
<div className="hero-content" key={album.id}>
|
||||
@@ -440,7 +370,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const albumData = await resolveAlbum(serverId, album.id);
|
||||
if (!albumData) return;
|
||||
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
|
||||
} catch (_) { /* ignore: best-effort */ }
|
||||
} catch (_) {}
|
||||
}}
|
||||
aria-label={t('hero.enqueue')}
|
||||
data-tooltip={t('hero.enqueueTooltip')}
|
||||
@@ -474,7 +404,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
if (!albumData) return;
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
usePlayerStore.getState().enqueue(tracks);
|
||||
} catch (_) { /* ignore: best-effort */ }
|
||||
} catch (_) {}
|
||||
}}
|
||||
style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }}
|
||||
data-tooltip={t('hero.enqueueTooltip')}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
declineOrbitSuggestion,
|
||||
suggestionKey,
|
||||
} from '../utils/orbit';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { TrackCoverArtImage } from '../cover/TrackCoverArtImage';
|
||||
import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit';
|
||||
|
||||
|
||||
@@ -171,8 +171,6 @@ export default function LicensesPanel() {
|
||||
}, [data, query]);
|
||||
|
||||
const scrollParentRef = useRef<HTMLDivElement | null>(null);
|
||||
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filtered.length,
|
||||
getScrollElement: () => scrollParentRef.current,
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import {
|
||||
logLibrarySearch,
|
||||
} from '../utils/library/libraryDevLog';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
||||
import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react';
|
||||
@@ -42,8 +42,6 @@ import ShareSearchResults from './search/ShareSearchResults';
|
||||
import {
|
||||
LiveSearchScopeBadge,
|
||||
LiveSearchScopeGhostBadge,
|
||||
} from './search/liveSearchScopeUi';
|
||||
import {
|
||||
createLiveSearchScopeBackspaceState,
|
||||
handleLiveSearchScopeBackspace,
|
||||
handleLiveSearchScopeUndo,
|
||||
@@ -52,7 +50,7 @@ import {
|
||||
noteLiveSearchScopeQueryInput,
|
||||
resetLiveSearchScopeBackspaceState,
|
||||
resolveLiveSearchScopeGhost,
|
||||
} from './search/liveSearchScope';
|
||||
} from './search/liveSearchScopeUi';
|
||||
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
|
||||
import { resolveIndexKey } from '../utils/server/serverIndexKey';
|
||||
|
||||
@@ -100,8 +98,6 @@ function LiveSearchSongThumb({ song }: { song: Pick<SubsonicSong, 'id' | 'albumI
|
||||
|
||||
function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
|
||||
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
|
||||
return (
|
||||
@@ -231,8 +227,6 @@ export default function LiveSearch() {
|
||||
|
||||
useEffect(() => {
|
||||
if (isLiveSearchDropdownBlocked(scope)) {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setResults(null);
|
||||
setOpen(false);
|
||||
setSearchSource(null);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||||
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -95,11 +95,11 @@ export default function MiniPlayer() {
|
||||
const toggleOnTop = async () => {
|
||||
const next = !alwaysOnTop;
|
||||
setAlwaysOnTop(next);
|
||||
try { await invoke('set_mini_player_always_on_top', { onTop: next }); } catch { /* ignore: best-effort */ }
|
||||
try { await invoke('set_mini_player_always_on_top', { onTop: next }); } catch {}
|
||||
};
|
||||
|
||||
const closeMini = async () => {
|
||||
try { await invoke('close_mini_player'); } catch { /* ignore: best-effort */ }
|
||||
try { await invoke('close_mini_player'); } catch {}
|
||||
};
|
||||
|
||||
const showMain = () => invoke('show_main_window').catch(() => {});
|
||||
@@ -112,11 +112,11 @@ export default function MiniPlayer() {
|
||||
if (!next) {
|
||||
const h = Math.round(window.innerHeight);
|
||||
if (h >= EXPANDED_MIN.h) {
|
||||
try { localStorage.setItem(EXPANDED_H_KEY, String(h)); } catch { /* ignore: best-effort */ }
|
||||
try { localStorage.setItem(EXPANDED_H_KEY, String(h)); } catch {}
|
||||
}
|
||||
}
|
||||
setQueueOpen(next);
|
||||
try { localStorage.setItem(QUEUE_OPEN_KEY, next ? '1' : '0'); } catch { /* ignore: best-effort */ }
|
||||
try { localStorage.setItem(QUEUE_OPEN_KEY, next ? '1' : '0'); } catch {}
|
||||
const targetH = next ? readStoredExpandedHeight() : COLLAPSED_SIZE.h;
|
||||
const targetW = next ? EXPANDED_SIZE.w : COLLAPSED_SIZE.w;
|
||||
const min = next ? EXPANDED_MIN : COLLAPSED_MIN;
|
||||
@@ -127,7 +127,7 @@ export default function MiniPlayer() {
|
||||
minWidth: min.w,
|
||||
minHeight: min.h,
|
||||
});
|
||||
} catch { /* ignore: best-effort */ }
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const jumpTo = (index: number) => emit('mini:jump', { index }).catch(() => {});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||||
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import type { Track } from '../store/playerStoreTypes';
|
||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress';
|
||||
import React, { useState, useCallback, useRef, useEffect, useSyncExternalStore, CSSProperties } from 'react';
|
||||
import React, { useState, useCallback, useMemo, useRef, useEffect, useSyncExternalStore, CSSProperties } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -68,8 +68,6 @@ function extractVibrantColor(imageUrl: string): Promise<string> {
|
||||
function useAlbumAccentColor(imageUrl: string): string {
|
||||
const [color, setColor] = useState('0,0,0');
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!imageUrl) { setColor('0,0,0'); return; }
|
||||
let cancelled = false;
|
||||
extractVibrantColor(imageUrl).then(c => { if (!cancelled) setColor(c); });
|
||||
@@ -98,8 +96,6 @@ function QueueDrawer({ onClose }: { onClose: () => void }) {
|
||||
|
||||
// Virtualize so a multi-thousand-track queue keeps DOM at O(visible rows) on
|
||||
// mobile too (matches the desktop QueuePanel).
|
||||
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: queue.length,
|
||||
getScrollElement: () => listRef.current,
|
||||
@@ -294,12 +290,10 @@ export default function MobilePlayerView() {
|
||||
window.removeEventListener('touchmove', onMove);
|
||||
window.removeEventListener('touchend', onEnd);
|
||||
};
|
||||
}, [seekFromX, seek]);
|
||||
}, [seekFromX]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingSeekRef.current = null;
|
||||
// React Compiler set-state-in-effect rule: state set from an external subscription/event callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPreviewProgress(null);
|
||||
}, [currentTrack?.id]);
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../test/helpers/renderWithProviders';
|
||||
import MobileSearchOverlay from './MobileSearchOverlay';
|
||||
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
|
||||
|
||||
// The overlay's only behaviour-bearing change in PR #1165 was renaming the
|
||||
// recent-search handler `useRecent` → `applyRecentSearch` (it was a plain
|
||||
// function mis-flagged as a hook). Smoke-test that the recent-search path still
|
||||
// applies the term to the live-search store. Heavy collaborators are stubbed.
|
||||
vi.mock('../hooks/useShareSearch', () => ({ useShareSearch: () => ({ shareMatch: null }) }));
|
||||
vi.mock('../api/subsonicSearch', () => ({
|
||||
search: vi.fn(() => Promise.resolve({ artists: [], albums: [], songs: [] })),
|
||||
}));
|
||||
vi.mock('../cover/AlbumCoverArtImage', () => ({ AlbumCoverArtImage: () => null }));
|
||||
vi.mock('../cover/ArtistCoverArtImage', () => ({ ArtistCoverArtImage: () => null }));
|
||||
vi.mock('../cover/CoverArtImage', () => ({ CoverArtImage: () => null }));
|
||||
|
||||
const RECENT_KEY = 'psysonic_recent_searches';
|
||||
|
||||
describe('MobileSearchOverlay — recent search (applyRecentSearch, PR #1165)', () => {
|
||||
beforeEach(() => {
|
||||
useLiveSearchScopeStore.setState({ query: '', scope: null, undoStack: [] });
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify(['first query', 'second query']));
|
||||
});
|
||||
|
||||
it('lists stored recent searches in the empty state', () => {
|
||||
renderWithProviders(<MobileSearchOverlay onClose={vi.fn()} />);
|
||||
expect(screen.getByText('first query')).toBeInTheDocument();
|
||||
expect(screen.getByText('second query')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies a recent search term to the live-search store on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<MobileSearchOverlay onClose={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByText('first query'));
|
||||
|
||||
expect(useLiveSearchScopeStore.getState().query).toBe('first query');
|
||||
});
|
||||
});
|
||||
@@ -22,8 +22,6 @@ import ShareSearchResults from './search/ShareSearchResults';
|
||||
import {
|
||||
LiveSearchScopeBadge,
|
||||
LiveSearchScopeGhostBadge,
|
||||
} from './search/liveSearchScopeUi';
|
||||
import {
|
||||
createLiveSearchScopeBackspaceState,
|
||||
handleLiveSearchScopeBackspace,
|
||||
handleLiveSearchScopeUndo,
|
||||
@@ -32,7 +30,7 @@ import {
|
||||
noteLiveSearchScopeQueryInput,
|
||||
resetLiveSearchScopeBackspaceState,
|
||||
resolveLiveSearchScopeGhost,
|
||||
} from './search/liveSearchScope';
|
||||
} from './search/liveSearchScopeUi';
|
||||
|
||||
const STORAGE_KEY = 'psysonic_recent_searches';
|
||||
const MAX_RECENT = 6;
|
||||
@@ -65,9 +63,6 @@ function MobileSearchSongThumb({
|
||||
}) {
|
||||
const coverRef = useMemo(
|
||||
() => (song.albumId?.trim() ? albumCoverRefForSong(song) : undefined),
|
||||
// Keyed on song's identity fields; depending on the `song` object would
|
||||
// recompute the ref on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[song.id, song.albumId, song.coverArt, song.discNumber],
|
||||
);
|
||||
if (!coverRef) return null;
|
||||
@@ -85,8 +80,6 @@ function MobileSearchSongThumb({
|
||||
|
||||
function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
|
||||
if (failed) {
|
||||
return (
|
||||
@@ -148,13 +141,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, []);
|
||||
|
||||
// doSearch wraps a debounce() result, so the useCallback argument is not an
|
||||
// inline function and its deps can't be statically analysed. It is recreated
|
||||
// only on musicLibraryFilterVersion (search() reads the active filter state).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const doSearch = useCallback(
|
||||
// React Compiler rule: memoization shape is intentional here.
|
||||
// eslint-disable-next-line react-hooks/use-memo
|
||||
debounce(async (q: string) => {
|
||||
if (!q.trim()) { setResults(null); setLoading(false); return; }
|
||||
setLoading(true);
|
||||
@@ -167,8 +154,6 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
|
||||
useEffect(() => {
|
||||
if (isLiveSearchDropdownBlocked(scope)) {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setResults(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -198,7 +183,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
|
||||
onClose();
|
||||
};
|
||||
const applyRecentSearch = (term: string) => {
|
||||
const useRecent = (term: string) => {
|
||||
setQuery(term, { recordUndo: true });
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
@@ -283,7 +268,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
<div className="mobile-search-section">
|
||||
<div className="mobile-search-section-label">{t('search.recentSearches')}</div>
|
||||
{recentSearches.map(term => (
|
||||
<button key={term} className="mobile-search-item" onClick={() => applyRecentSearch(term)}>
|
||||
<button key={term} className="mobile-search-item" onClick={() => useRecent(term)}>
|
||||
<div className="mobile-search-avatar">
|
||||
<Clock size={18} />
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { TrackCoverArtImage } from '../cover/TrackCoverArtImage';
|
||||
import { getNowPlaying } from '../api/subsonicScrobble';
|
||||
import type { SubsonicNowPlaying } from '../api/subsonicTypes';
|
||||
@@ -44,8 +45,6 @@ export default function NowPlayingDropdown() {
|
||||
let ms = entry.positionMs;
|
||||
if (entry.state === 'playing') {
|
||||
const rate = entry.playbackRate && entry.playbackRate > 0 ? entry.playbackRate : 1;
|
||||
// React Compiler purity rule: intentional live-timestamp read at render (Date.now()); the value is allowed to differ between renders.
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
ms += (Date.now() - fetchedAtRef.current) * rate;
|
||||
}
|
||||
const maxMs = entry.duration > 0 ? entry.duration * 1000 : ms;
|
||||
@@ -207,8 +206,6 @@ export default function NowPlayingDropdown() {
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{/* React Compiler refs rule: the row renderer reads a ref for latest presence state; intentional, not reactive render data. */}
|
||||
{/* eslint-disable-next-line react-hooks/refs */}
|
||||
{visible.map((stream, idx) => {
|
||||
const presence = nowPlayingPresence(stream);
|
||||
const presenceLabel = t(`nowPlaying.presence.${presence}`);
|
||||
|
||||
@@ -118,14 +118,10 @@ export default function NowPlayingInfo() {
|
||||
const bioRef = useRef<HTMLParagraphElement | null>(null);
|
||||
|
||||
// Reset per-track UI state when the track changes
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { setBioExpanded(false); setShowAllTours(false); }, [artistId, songId]);
|
||||
|
||||
// Artist bio + image
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!subsonicReady || !subsonicServerId || !artistId) { setArtistInfoEntry(null); return; }
|
||||
const cacheKey = queuePanelCacheKey(subsonicServerId, artistId);
|
||||
const cached = artistInfoCache.get(cacheKey);
|
||||
@@ -140,8 +136,6 @@ export default function NowPlayingInfo() {
|
||||
|
||||
// Song detail (for OpenSubsonic contributors[])
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!subsonicReady || !subsonicServerId || !songId) { setSongDetailEntry(null); return; }
|
||||
const cacheKey = queuePanelCacheKey(subsonicServerId, songId);
|
||||
const cached = songDetailCache.get(cacheKey);
|
||||
@@ -156,8 +150,6 @@ export default function NowPlayingInfo() {
|
||||
|
||||
// Bandsintown — only when opt-in toggle is on
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!enableBandsintown || !artistName) { setTourEvents([]); return; }
|
||||
let cancelled = false;
|
||||
setTourLoading(true);
|
||||
|
||||
@@ -19,8 +19,6 @@ export default function OrbitAccountPicker() {
|
||||
// Reset + focus first item each time the picker re-opens.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSelected(0);
|
||||
// Defer focus to the next tick so the DOM has actually mounted.
|
||||
queueMicrotask(() => itemRefs.current[0]?.focus());
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Copy, Trash2 } from 'lucide-react';
|
||||
import { Activity, Copy, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { computeOrbitDriftMs } from '../utils/orbit';
|
||||
import {
|
||||
clearDriftTrace,
|
||||
computeOrbitDriftMs,
|
||||
driftTraceCount,
|
||||
formatDriftTraceCsv,
|
||||
getOrbitDriftStatus,
|
||||
} from '../utils/orbit';
|
||||
import {
|
||||
clearOrbitEvents,
|
||||
formatOrbitEvents,
|
||||
@@ -36,10 +42,11 @@ export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
|
||||
const events = useSyncExternalStore(subscribeOrbitEvents, getOrbitEvents, getOrbitEvents);
|
||||
const formatted = formatOrbitEvents(events);
|
||||
|
||||
// Tick the mini-display once a second so drift / position read fresh.
|
||||
// Tick the mini-display at the drift loop's cadence (500 ms) so the live
|
||||
// correction rate / drift read in near-real-time, not once a second.
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 1000);
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 500);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
@@ -65,8 +72,6 @@ export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
|
||||
};
|
||||
}, [anchorRef, onClose]);
|
||||
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const anchor = anchorRef.current?.getBoundingClientRect();
|
||||
const style: React.CSSProperties = anchor
|
||||
? {
|
||||
@@ -88,6 +93,14 @@ export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
|
||||
const driftMs = sameTrack && state ? computeOrbitDriftMs(state, localPosMs, nowMs) : null;
|
||||
const hostStateAgeMs = state ? Math.max(0, nowMs - state.positionAt) : null;
|
||||
|
||||
// Live drift-correction status — re-read each render; the 1 s nowMs tick above
|
||||
// already repaints this popover, so the snapshot stays fresh without a subscribe.
|
||||
const dc = getOrbitDriftStatus();
|
||||
const dcRateText = dc.action === 'idle' ? '—' : `${dc.currentRate.toFixed(2)}×`;
|
||||
const dcStatusText = dc.smoothedDriftMs != null
|
||||
? `${dc.action} · ${(dc.smoothedDriftMs / 1000).toFixed(1)}s`
|
||||
: dc.action;
|
||||
|
||||
const hostPosSec = state ? Math.round(((state.positionMs ?? 0) + (state.isPlaying ? (nowMs - state.positionAt) : 0)) / 1000) : null;
|
||||
const guestPosSec = Math.round((player.currentTime ?? 0));
|
||||
|
||||
@@ -101,8 +114,23 @@ export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyTrace = async () => {
|
||||
const csv = formatDriftTraceCsv();
|
||||
if (!csv) {
|
||||
showToast(t('orbit.diag.traceEmpty'), 2500, 'info');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(csv);
|
||||
showToast(t('orbit.diag.traceCopied', { count: driftTraceCount() }), 2500, 'info');
|
||||
} catch {
|
||||
showToast(t('orbit.diag.copyFailed'), 4000, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
clearOrbitEvents();
|
||||
clearDriftTrace();
|
||||
showToast(t('orbit.diag.cleared'), 2000, 'info');
|
||||
};
|
||||
|
||||
@@ -137,6 +165,14 @@ export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
|
||||
<span className="orbit-diag-pop__live-label">{t('orbit.diag.drift')}</span>
|
||||
<span>{driftMs != null ? `${(driftMs / 1000).toFixed(1)}s` : '—'}</span>
|
||||
</div>
|
||||
<div className="orbit-diag-pop__live-row">
|
||||
<span className="orbit-diag-pop__live-label">{t('orbit.diag.driftRate')}</span>
|
||||
<span>{dcRateText}</span>
|
||||
</div>
|
||||
<div className="orbit-diag-pop__live-row">
|
||||
<span className="orbit-diag-pop__live-label">{t('orbit.diag.driftStatus')}</span>
|
||||
<span className="orbit-diag-pop__mono">{dcStatusText}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{hostStateAgeMs != null && (
|
||||
@@ -160,6 +196,16 @@ export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
|
||||
<Copy size={13} />
|
||||
<span>{t('orbit.diag.copyLabel')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-diag-pop__btn"
|
||||
onClick={handleCopyTrace}
|
||||
data-tooltip={t('orbit.diag.traceTooltip')}
|
||||
aria-label={t('orbit.diag.traceTooltip')}
|
||||
>
|
||||
<Activity size={13} />
|
||||
<span>{t('orbit.diag.traceLabel')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-diag-pop__btn"
|
||||
|
||||
@@ -48,6 +48,7 @@ export default function OrbitExitModal() {
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function OrbitGuestQueue() {
|
||||
const { t } = useTranslation();
|
||||
const state = useOrbitStore(s => s.state);
|
||||
const pending = useOrbitStore(s => s.pendingSuggestions);
|
||||
const queueItems = useMemo(() => state?.playQueue ?? [], [state?.playQueue]);
|
||||
const queueItems = state?.playQueue ?? [];
|
||||
const totalUpcoming = state?.playQueueTotal ?? queueItems.length;
|
||||
const truncatedBy = Math.max(0, totalUpcoming - queueItems.length);
|
||||
const currentTrack = state?.currentTrack ?? null;
|
||||
|
||||
@@ -28,8 +28,6 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const [confirm, setConfirm] = useState<{ user: string; mode: 'remove' | 'ban' } | null>(null);
|
||||
// React Compiler purity rule: intentional live-timestamp read at render (Date.now()); the value is allowed to differ between renders.
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
const nowMs = Date.now();
|
||||
|
||||
// Close on outside click / Escape — unless a confirm dialog is open
|
||||
@@ -57,8 +55,6 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const anchor = anchorRef.current?.getBoundingClientRect();
|
||||
const style: React.CSSProperties = anchor
|
||||
? {
|
||||
|
||||
@@ -9,10 +9,11 @@ import { usePlayerStore } from '../store/playerStore';
|
||||
import {
|
||||
endOrbitSession,
|
||||
leaveOrbitSession,
|
||||
computeOrbitDriftMs,
|
||||
getOrbitDriftStatus,
|
||||
effectiveShuffleIntervalMs,
|
||||
} from '../utils/orbit';
|
||||
import { estimateLivePosition } from '../api/orbit';
|
||||
import { pushOrbitEvent } from '../utils/orbitDiag';
|
||||
import OrbitParticipantsPopover from './OrbitParticipantsPopover';
|
||||
import OrbitExitModal from './OrbitExitModal';
|
||||
import OrbitSettingsPopover from './OrbitSettingsPopover';
|
||||
@@ -33,8 +34,6 @@ import { formatTrackTime } from '../utils/format/formatDuration';
|
||||
* reshaping the layout.
|
||||
*/
|
||||
|
||||
const CATCH_UP_DRIFT_THRESHOLD_MS = 3_000;
|
||||
|
||||
/** `m:ss` countdown from a millisecond value. */
|
||||
function formatCountdown(ms: number): string {
|
||||
return formatTrackTime(Math.round(ms / 1000));
|
||||
@@ -66,53 +65,28 @@ export default function OrbitSessionBar() {
|
||||
return () => window.clearInterval(id);
|
||||
}, [state, phase]);
|
||||
|
||||
// ── Catch Up button visibility — debounced + hysteresis ───────────────
|
||||
// The raw drift signal is noisy: guest's `currentTime` updates in coarse
|
||||
// ~5 s chunks while host's position is extrapolated linearly via
|
||||
// `(nowMs - posAt)`, so the diff swings between ~1 s and ~8 s every
|
||||
// tick on a normal session even when both sides are perfectly synced.
|
||||
// Two-stage filter:
|
||||
// - **Hidden → shown**: drift must stay over the show-threshold (3 s)
|
||||
// for 3 s of wall-clock. Filters out brief over-threshold blips.
|
||||
// - **Shown → hidden**: drift must stay under the hide-threshold
|
||||
// (1 s) for 1 s of wall-clock. Once visible, the button persists
|
||||
// through the 1–3 s "drift back to small" valleys that come from
|
||||
// guest's currentTime catching up in chunks; otherwise the button
|
||||
// would vanish too fast to actually click on a high-latency
|
||||
// session where genuine drift fluctuates around 5–8 s.
|
||||
const SHOW_THRESHOLD_MS = CATCH_UP_DRIFT_THRESHOLD_MS;
|
||||
const HIDE_THRESHOLD_MS = 1_000;
|
||||
// ── Catch Up button visibility — debounced ────────────────────────────
|
||||
// Driven by the automatic drift correction, not the raw drift: the loop
|
||||
// surfaces status 'seek' only when the smoothed drift is too large to nudge
|
||||
// softly (it handles everything smaller silently). So the manual button
|
||||
// appears exactly when auto-correction has given up — what cucadmuh asked
|
||||
// for. Debounced so it persists long enough to click and doesn't flicker.
|
||||
const SHOW_DEBOUNCE_MS = 3_000;
|
||||
const HIDE_DEBOUNCE_MS = 1_000;
|
||||
const [showCatchUp, setShowCatchUp] = useState(false);
|
||||
const overSinceRef = useRef<number | null>(null);
|
||||
const underSinceRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
// Note: `state.isPlaying` is *not* a gate. A guest who joined while
|
||||
// the host was paused still benefits from Catch Up if their sync to
|
||||
// the host's paused position failed — the only signal that matters
|
||||
// is "is there drift between us and the host's last reported state".
|
||||
// `computeOrbitDriftMs` correctly stops time-extrapolation when the
|
||||
// host is paused, so the formula holds in both states.
|
||||
if (role !== 'guest' || !state || !state.currentTrack) {
|
||||
overSinceRef.current = null;
|
||||
underSinceRef.current = null;
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setShowCatchUp(false);
|
||||
return;
|
||||
}
|
||||
const player = usePlayerStore.getState();
|
||||
const localPositionMs = Math.round((player.currentTime ?? 0) * 1000);
|
||||
const driftMs = player.currentTrack?.id === state.currentTrack.trackId
|
||||
? computeOrbitDriftMs(state, localPositionMs, nowMs)
|
||||
: null;
|
||||
const absDrift = driftMs == null ? Infinity : Math.abs(driftMs);
|
||||
const wantShow = getOrbitDriftStatus().action === 'seek';
|
||||
if (showCatchUp) {
|
||||
// Currently visible — only hide once drift has been clearly small
|
||||
// for the full hide-debounce window.
|
||||
overSinceRef.current = null;
|
||||
if (absDrift < HIDE_THRESHOLD_MS) {
|
||||
if (!wantShow) {
|
||||
if (underSinceRef.current === null) underSinceRef.current = Date.now();
|
||||
if (Date.now() - underSinceRef.current >= HIDE_DEBOUNCE_MS) {
|
||||
setShowCatchUp(false);
|
||||
@@ -122,9 +96,8 @@ export default function OrbitSessionBar() {
|
||||
underSinceRef.current = null;
|
||||
}
|
||||
} else {
|
||||
// Currently hidden — only show after sustained over-threshold drift.
|
||||
underSinceRef.current = null;
|
||||
if (absDrift > SHOW_THRESHOLD_MS) {
|
||||
if (wantShow) {
|
||||
if (overSinceRef.current === null) overSinceRef.current = Date.now();
|
||||
if (Date.now() - overSinceRef.current >= SHOW_DEBOUNCE_MS) {
|
||||
setShowCatchUp(true);
|
||||
@@ -134,7 +107,7 @@ export default function OrbitSessionBar() {
|
||||
overSinceRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [role, state, nowMs, showCatchUp, SHOW_THRESHOLD_MS]);
|
||||
}, [role, state, nowMs, showCatchUp]);
|
||||
|
||||
// Bar is visible while active, ended (pre-ack), or explicitly kicked / soft-removed.
|
||||
const shouldShowBar = !!state && (
|
||||
@@ -174,6 +147,9 @@ export default function OrbitSessionBar() {
|
||||
if (!state.currentTrack) return;
|
||||
const trackId = state.currentTrack.trackId;
|
||||
const targetMs = estimateLivePosition(state, Date.now());
|
||||
// Mark manual catch-ups in the same log stream as the auto correction, so
|
||||
// the trace can tell a user-driven seek apart from an automatic one.
|
||||
pushOrbitEvent('drift-correction', `manual catch-up → seeking to host @ ${Math.round(targetMs / 1000)}s`);
|
||||
const targetSec = Math.max(0, targetMs / 1000);
|
||||
const hostPlaying = state.isPlaying;
|
||||
try {
|
||||
|
||||
@@ -38,8 +38,6 @@ export default function OrbitSettingsPopover({ anchorRef, onClose }: Props) {
|
||||
};
|
||||
}, [anchorRef, onClose]);
|
||||
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const anchor = anchorRef.current?.getBoundingClientRect();
|
||||
const style: React.CSSProperties = anchor
|
||||
? {
|
||||
|
||||
@@ -57,8 +57,6 @@ export default function OrbitSharePopover({ anchorRef, onClose }: Props) {
|
||||
|
||||
if (!shareLink) return null;
|
||||
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const anchor = anchorRef.current?.getBoundingClientRect();
|
||||
const style: React.CSSProperties = anchor
|
||||
? {
|
||||
|
||||
@@ -47,8 +47,6 @@ export default function OrbitStartModal({ onClose }: Props) {
|
||||
|
||||
const shareLink = useMemo(
|
||||
() => buildOrbitShareLink(serverBase, sid),
|
||||
// React Compiler rule: manual memoization is intentional and must be preserved.
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
[serverBase, sid],
|
||||
);
|
||||
|
||||
|
||||
@@ -47,8 +47,6 @@ export default function OrbitStartTrigger() {
|
||||
if (role !== null) return null;
|
||||
if (!visible) return null;
|
||||
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const anchor = btnRef.current?.getBoundingClientRect();
|
||||
const popoverStyle: React.CSSProperties = anchor
|
||||
? {
|
||||
|
||||
@@ -25,8 +25,6 @@ interface Props {
|
||||
*/
|
||||
export default function PagedSongList({ songs, hasMore, loadingMore, onLoadMore, showBpm }: Props) {
|
||||
const onLoadMoreRef = useRef(onLoadMore);
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
onLoadMoreRef.current = onLoadMore;
|
||||
|
||||
const bindSentinel = useInpageScrollSentinel({
|
||||
|
||||
@@ -198,10 +198,6 @@ export default function PasteClipboardHandler() {
|
||||
};
|
||||
document.addEventListener('paste', onPaste, true);
|
||||
return () => document.removeEventListener('paste', onPaste, true);
|
||||
// handleJoinError and the router location are captured by the onPaste closure;
|
||||
// the global paste listener is intentionally not re-registered on every render
|
||||
// or navigation, only when the auth/handler inputs below change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [navigate, t, isLoggedIn, queuePaste]);
|
||||
|
||||
const closeQueuePaste = () => {
|
||||
|
||||
@@ -86,8 +86,6 @@ export default function PlaybackDelayModal({ open, onClose, anchorRef }: Playbac
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setCustomMinutes('');
|
||||
setHoverSeconds(null);
|
||||
}, [open]);
|
||||
@@ -147,8 +145,6 @@ export default function PlaybackDelayModal({ open, onClose, anchorRef }: Playbac
|
||||
};
|
||||
|
||||
const useAnchor = !!anchorRef;
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const anchorEl = anchorRef?.current ?? null;
|
||||
void posTick;
|
||||
const anchoredPanelStyle =
|
||||
|
||||
@@ -51,8 +51,6 @@ export default function PlaybackScheduleBadge({ layoutAnchorRef, className }: Pl
|
||||
|
||||
useEffect(() => {
|
||||
if (deadlineMs == null) return;
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setNowMs(Date.now());
|
||||
}, [deadlineMs]);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { queueSongStar } from '../store/pendingStarSync';
|
||||
import { coverArtIdFromRadio } from '../cover/ids';
|
||||
import { resolvePlaybackTrackCoverArtId } from '../cover/resolveCoverArtId';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
SlidersVertical, X,
|
||||
@@ -12,18 +13,23 @@ import { usePlayerStore } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
import Equalizer from './Equalizer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import MarqueeText from './MarqueeText';
|
||||
import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
||||
import { useRadioMprisSync } from '../hooks/useRadioMprisSync';
|
||||
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
||||
import PlaybackDelayModal from './PlaybackDelayModal';
|
||||
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
||||
import { usePlaybackScheduleRemaining } from '../utils/format/playbackScheduleFormat';
|
||||
import { usePreviewStore } from '../store/previewStore';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { coerceOpenArtistRefs } from '../utils/openArtistRefs';
|
||||
import { resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs';
|
||||
import { formatTrackTime } from '../utils/format/formatDuration';
|
||||
import { PlayerTrackInfo } from './playerBar/PlayerTrackInfo';
|
||||
import { PlayerTransportControls } from './playerBar/PlayerTransportControls';
|
||||
import { PlayerSeekbarSection } from './playerBar/PlayerSeekbarSection';
|
||||
@@ -47,12 +53,15 @@ export default function PlayerBar() {
|
||||
const [showVolPct, setShowVolPct] = useState(false);
|
||||
const [localShowRemaining, setLocalShowRemaining] = useState(() => useThemeStore.getState().showRemainingTime);
|
||||
const premuteVolumeRef = useRef(1);
|
||||
const showLyrics = useLyricsStore(s => s.showLyrics);
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
// currentTime is intentionally excluded — PlaybackTime handles it via direct DOM update.
|
||||
const {
|
||||
currentTrack, currentRadio, isPlaying, volume,
|
||||
togglePlay, next, previous, setVolume,
|
||||
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
||||
networkLoved, toggleNetworkLove,
|
||||
isQueueVisible, toggleQueue,
|
||||
starredOverrides,
|
||||
userRatingOverrides,
|
||||
openContextMenu,
|
||||
@@ -168,7 +177,7 @@ export default function PlayerBar() {
|
||||
volumeWheelMenuTimerRef.current = null;
|
||||
}, 1000);
|
||||
}
|
||||
}, [volume, setVolume, utilityOverflow, setSuppressOverflowTooltip, setUtilityMenuMode, setUtilityMenuOpen, volumeWheelMenuTimerRef]);
|
||||
}, [volume, setVolume, utilityOverflow]);
|
||||
|
||||
const volumeStyle = {
|
||||
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--bg-elevated) ${volume * 100}%)`,
|
||||
|
||||
@@ -55,7 +55,6 @@ beforeEach(() => {
|
||||
onInvoke('audio_get_state', () => ({ playing: false }));
|
||||
onInvoke('audio_update_replay_gain', () => undefined);
|
||||
onInvoke('discord_update_presence', () => undefined);
|
||||
onInvoke('library_get_recent_play_sessions', () => []);
|
||||
});
|
||||
|
||||
describe('QueuePanel — render surface', () => {
|
||||
@@ -155,20 +154,14 @@ describe('QueuePanel — display mode', () => {
|
||||
it('header mode-toggle button advances queueDisplayMode (default queue → timeline)', () => {
|
||||
seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() });
|
||||
const { container } = renderWithProviders(<QueuePanel />);
|
||||
// The mode toggle is the first .queue-action-btn in the header (the
|
||||
// collapse chevron is the second). The toggle's label names its target;
|
||||
// from the default 'queue' that is the next mode in the cycle, "Timeline".
|
||||
const toggle = container.querySelector<HTMLButtonElement>('.queue-header .queue-action-btn');
|
||||
expect(toggle?.getAttribute('aria-label')).toBe('Timeline');
|
||||
toggle!.click();
|
||||
expect(useAuthStore.getState().queueDisplayMode).toBe('timeline');
|
||||
});
|
||||
|
||||
it('timeline mode: renders current + upcoming only (not played queue prefix)', () => {
|
||||
const tracks = makeTracks(4);
|
||||
useAuthStore.getState().setQueueDisplayMode('timeline');
|
||||
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
|
||||
const { container } = renderWithProviders(<QueuePanel />);
|
||||
const idxs = [...container.querySelectorAll('[data-queue-idx]')].map(r => r.getAttribute('data-queue-idx'));
|
||||
expect(idxs).toEqual(['1', '2', '3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueuePanel — toolbar', () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useThemeStore } from '../store/themeStore';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import LyricsPane from './LyricsPane';
|
||||
import NowPlayingInfo from './NowPlayingInfo';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useLuckyMixStore } from '../store/luckyMixStore';
|
||||
import { useQueueToolbarStore } from '../store/queueToolbarStore';
|
||||
import { SavePlaylistModal } from './queuePanel/SavePlaylistModal';
|
||||
@@ -33,8 +34,6 @@ import { QueueToolbar } from './queuePanel/QueueToolbar';
|
||||
import { QueueList } from './queuePanel/QueueList';
|
||||
import { QueueTabBar } from './queuePanel/QueueTabBar';
|
||||
import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
|
||||
import { useTimelineBootstrapOnMode, useTimelineHistoryResolver, useTimelinePlayHistory } from '../hooks/useTimelinePlayHistory';
|
||||
import { buildTimelineDisplayRows } from '../utils/queue/buildTimelineDisplayRows';
|
||||
import { activeServerQueueTrackIds } from '../utils/playback/trackServerScope';
|
||||
|
||||
export default function QueuePanel() {
|
||||
@@ -122,17 +121,6 @@ function QueuePanelHostOrSolo() {
|
||||
const setIsNowPlayingCollapsed = useAuthStore(s => s.setQueueNowPlayingCollapsed);
|
||||
const queueDisplayMode = useAuthStore(s => s.queueDisplayMode);
|
||||
const setQueueDisplayMode = useAuthStore(s => s.setQueueDisplayMode);
|
||||
useTimelineBootstrapOnMode(queueDisplayMode === 'timeline');
|
||||
const timelineHistoryRefs = useTimelinePlayHistory();
|
||||
useTimelineHistoryResolver(timelineHistoryRefs, queueDisplayMode === 'timeline');
|
||||
const timelineRows = useMemo(() => {
|
||||
if (queueDisplayMode !== 'timeline') return undefined;
|
||||
return buildTimelineDisplayRows({
|
||||
historyRefs: timelineHistoryRefs,
|
||||
queueItems,
|
||||
queueIndex,
|
||||
});
|
||||
}, [queueDisplayMode, timelineHistoryRefs, queueItems, queueIndex]);
|
||||
const toolbarButtons = useQueueToolbarStore(s => s.buttons);
|
||||
const durationMode = useAuthStore(s => s.queueDurationDisplayMode);
|
||||
const setDurationMode = useAuthStore(s => s.setQueueDurationDisplayMode);
|
||||
@@ -234,11 +222,11 @@ function QueuePanelHostOrSolo() {
|
||||
// index for every index-based handler (play / remove / reorder / drag).
|
||||
const displayBaseIndex = queueDisplayMode === 'queue' ? Math.max(0, queueIndex + 1) : 0;
|
||||
const displayItems = displayBaseIndex > 0 ? queueItems.slice(displayBaseIndex) : queueItems;
|
||||
const queueEmptyLabel = queueDisplayMode === 'timeline'
|
||||
? (timelineRows && timelineRows.length > 0 ? '' : t('queue.emptyQueue'))
|
||||
: queueDisplayMode === 'queue' && queueItems.length > 0
|
||||
? t('queue.noUpcoming')
|
||||
: t('queue.emptyQueue');
|
||||
// In queue mode the list can be empty while the queue still holds the
|
||||
// now-playing (last) track — say "no upcoming" rather than "queue is empty".
|
||||
const queueEmptyLabel = queueDisplayMode === 'queue' && queueItems.length > 0
|
||||
? t('queue.noUpcoming')
|
||||
: t('queue.emptyQueue');
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -363,8 +351,6 @@ function QueuePanelHostOrSolo() {
|
||||
|
||||
<QueueList
|
||||
queue={displayItems}
|
||||
timelineRows={timelineRows}
|
||||
canonicalQueue={queueItems}
|
||||
queueIndex={queueIndex}
|
||||
displayBaseIndex={displayBaseIndex}
|
||||
queueDisplayMode={queueDisplayMode}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import SelectionToggleButton from './SelectionToggleButton';
|
||||
|
||||
describe('SelectionToggleButton', () => {
|
||||
it('shows the select label and calls onToggle on click', async () => {
|
||||
const onToggle = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SelectionToggleButton
|
||||
active={false}
|
||||
onToggle={onToggle}
|
||||
selectLabel="Multi-select"
|
||||
cancelLabel="Cancel selection"
|
||||
/>,
|
||||
);
|
||||
const btn = screen.getByRole('button', { name: 'Multi-select' });
|
||||
await user.click(btn);
|
||||
expect(onToggle).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows the cancel label and active styling when active', () => {
|
||||
render(
|
||||
<SelectionToggleButton
|
||||
active
|
||||
onToggle={() => {}}
|
||||
selectLabel="Multi-select"
|
||||
cancelLabel="Cancel selection"
|
||||
/>,
|
||||
);
|
||||
const btn = screen.getByRole('button', { name: 'Cancel selection' });
|
||||
expect(btn).toHaveClass('btn-sort-active');
|
||||
});
|
||||
|
||||
it('keeps the label in a toolbar-btn-label span for compact-mode hiding', () => {
|
||||
render(
|
||||
<SelectionToggleButton
|
||||
active={false}
|
||||
onToggle={() => {}}
|
||||
selectLabel="Multi-select"
|
||||
cancelLabel="Cancel"
|
||||
/>,
|
||||
);
|
||||
expect(document.querySelector('.toolbar-btn-label')?.textContent).toBe('Multi-select');
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { CheckSquare2 } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
/** Whether selection mode is currently active. */
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
/** Label when not selecting (e.g. "Multi-select"). */
|
||||
selectLabel: string;
|
||||
/** Label while selecting (e.g. "Cancel selection"). */
|
||||
cancelLabel: string;
|
||||
/** Tooltip when inactive — defaults to `selectLabel`. */
|
||||
startTooltip?: string;
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared multi-select toggle for browse-page toolbars (Albums, Artists,
|
||||
* New Releases, Random Albums, Lossless Albums). The label sits in a
|
||||
* `toolbar-btn-label` span so the existing mobile / compact-mode rule can
|
||||
* collapse it to icon-only while keeping the icon + tooltip + aria-label.
|
||||
*/
|
||||
export default function SelectionToggleButton({
|
||||
active,
|
||||
onToggle,
|
||||
selectLabel,
|
||||
cancelLabel,
|
||||
startTooltip,
|
||||
iconSize = 15,
|
||||
}: Props) {
|
||||
const label = active ? cancelLabel : selectLabel;
|
||||
return (
|
||||
<button
|
||||
className={`btn btn-surface${active ? ' btn-sort-active' : ''}`}
|
||||
onClick={onToggle}
|
||||
aria-label={label}
|
||||
data-tooltip={active ? cancelLabel : (startTooltip ?? selectLabel)}
|
||||
data-tooltip-pos="bottom"
|
||||
style={active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : undefined}
|
||||
>
|
||||
<CheckSquare2 size={iconSize} />
|
||||
<span className="toolbar-btn-label">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -144,9 +144,10 @@ export default function Sidebar({
|
||||
);
|
||||
|
||||
const sidebarItemsRef = useRef(sidebarItems);
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
sidebarItemsRef.current = sidebarItems;
|
||||
const randomNavModeRef = useRef(randomNavMode);
|
||||
randomNavModeRef.current = randomNavMode;
|
||||
|
||||
const {
|
||||
navDnd,
|
||||
navDndTrashHint,
|
||||
@@ -156,6 +157,7 @@ export default function Sidebar({
|
||||
} = useSidebarNavDnd({
|
||||
isCollapsed,
|
||||
sidebarItemsRef,
|
||||
randomNavModeRef,
|
||||
setSidebarItems,
|
||||
});
|
||||
const newReleasesUnreadCount = useSidebarNewReleasesUnread({
|
||||
@@ -251,7 +253,9 @@ export default function Sidebar({
|
||||
musicFolders={musicFolders}
|
||||
pickLibrary={pickLibrary}
|
||||
visibleLibraryConfigs={visibleLibraryConfigs}
|
||||
libraryItemsForReorder={libraryItemsForReorder}
|
||||
visibleSystemConfigs={visibleSystemConfigs}
|
||||
systemItemsForReorder={systemItemsForReorder}
|
||||
playlistsExpanded={playlistsExpanded}
|
||||
setPlaylistsExpanded={setPlaylistsExpanded}
|
||||
playlists={playlists}
|
||||
|
||||
@@ -80,8 +80,6 @@ export default function SongInfoModal() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!songInfoModal.isOpen || !songInfoModal.songId) {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSong(null);
|
||||
setEnrichment(null);
|
||||
setAbsolutePath(null);
|
||||
|
||||
@@ -84,16 +84,10 @@ export default function SongRail({
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
ro.disconnect();
|
||||
};
|
||||
// handleScroll/recomputeArtworkBudget are recreated each render but read live
|
||||
// refs/props; the listeners are intentionally (re)bound only when the row data
|
||||
// or artwork config changes, not on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uniqueSongs, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
|
||||
|
||||
const rowArtworkResetKey = uniqueSongs[0]?.id ?? '';
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setArtworkBudget(initialArtworkBudget);
|
||||
}, [initialArtworkBudget, rowArtworkResetKey]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowDownUp, Check } from 'lucide-react';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
@@ -15,15 +15,9 @@ interface Props<V extends string> {
|
||||
ariaLabel?: string;
|
||||
/** Hover tooltip describing the action (shown below the trigger). */
|
||||
tooltip?: string;
|
||||
/**
|
||||
* Horizontal anchor of the popover. `right` opens it leftwards (right edge
|
||||
* aligned to the trigger) — use it when the trigger is docked to the right,
|
||||
* e.g. next to an open side panel, so the popover never opens off-screen.
|
||||
*/
|
||||
align?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export default function SortDropdown<V extends string>({ value, options, onChange, ariaLabel, tooltip, align = 'left' }: Props<V>) {
|
||||
export default function SortDropdown<V extends string>({ value, options, onChange, ariaLabel, tooltip }: Props<V>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
@@ -32,7 +26,7 @@ export default function SortDropdown<V extends string>({ value, options, onChang
|
||||
|
||||
const current = options.find(o => o.value === value);
|
||||
|
||||
const updatePopStyle = useCallback(() => {
|
||||
const updatePopStyle = () => {
|
||||
if (!triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const MARGIN = 6;
|
||||
@@ -41,9 +35,8 @@ export default function SortDropdown<V extends string>({ value, options, onChang
|
||||
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||
const spaceAbove = rect.top - MARGIN;
|
||||
const useAbove = spaceBelow < 160 && spaceAbove > spaceBelow;
|
||||
const anchorLeft = align === 'right' ? rect.right - WIDTH : rect.left;
|
||||
const left = Math.min(
|
||||
Math.max(anchorLeft, 8),
|
||||
Math.max(rect.left, 8),
|
||||
window.innerWidth - WIDTH - 8,
|
||||
);
|
||||
setPopStyle({
|
||||
@@ -56,12 +49,12 @@ export default function SortDropdown<V extends string>({ value, options, onChang
|
||||
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
|
||||
zIndex: 99998,
|
||||
});
|
||||
}, [align]);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updatePopStyle();
|
||||
}, [open, updatePopStyle]);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -72,7 +65,7 @@ export default function SortDropdown<V extends string>({ value, options, onChang
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('scroll', onResize, true);
|
||||
};
|
||||
}, [open, updatePopStyle]);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
@@ -40,8 +40,6 @@ export default function StarRating({
|
||||
const cappedValue = Math.min(Math.max(0, value), selectCap);
|
||||
|
||||
React.useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (value > 0) setSuppressHoverPreview(false);
|
||||
}, [value]);
|
||||
|
||||
@@ -70,8 +68,6 @@ export default function StarRating({
|
||||
|
||||
if (next < prev) {
|
||||
const star = Math.max(1, Math.min(selectCap, prev));
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (next === 0) setSuppressHoverPreview(true);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setClearShrinkStar(star));
|
||||
|
||||
@@ -53,8 +53,6 @@ export default function StatsExportModal({ open, albums, meta, onClose }: Props)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (albums.length >= MAX_NEEDED) {
|
||||
// React Compiler set-state-in-effect rule: local state synced with the already-available `albums` prop when the modal opens (the async top-up below is skipped).
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTopUpAlbums(albums);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { Minus, Square, X } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { IS_MACOS } from '../utils/platform';
|
||||
|
||||
export default function TitleBar() {
|
||||
const win = getCurrentWindow();
|
||||
@@ -14,11 +13,6 @@ export default function TitleBar() {
|
||||
|
||||
return (
|
||||
<div className="titlebar" data-tauri-drag-region>
|
||||
{/* macOS drops the now-playing label: subpixel antialiasing is disabled
|
||||
in the Overlay title-bar zone, so small text renders frayed. The bar
|
||||
stays a clean themed strip with the native traffic lights (#1198);
|
||||
the track is already shown in the player bar. */}
|
||||
{!IS_MACOS && (
|
||||
<div className="titlebar-track" data-tauri-drag-region>
|
||||
{currentTrack && (
|
||||
<>
|
||||
@@ -29,11 +23,7 @@ export default function TitleBar() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* macOS keeps its native traffic lights (floating over the bar via
|
||||
titleBarStyle: Overlay); only Linux draws in-page window buttons. */}
|
||||
{!IS_MACOS && (
|
||||
<div className="titlebar-controls" data-btnstyle={windowButtonStyle}>
|
||||
{showMinimizeButton && (
|
||||
<button
|
||||
@@ -65,7 +55,6 @@ export default function TitleBar() {
|
||||
<X size={10} strokeWidth={2.5} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user