mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
6552d2a5cb8e819e85b3859bb60d017ec6476bc1
691 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a1d7cf330d |
refactor(player): E.7 — extract two small file-private helpers (#570)
`emitNormalizationDebug` (debug-mode trace forwarder, 15+ internal call sites) and `isInOrbitSession` (Orbit-active guard used by next() and the async fallback paths to suppress local queue extensions, 5 call sites) move into dedicated modules under src/store/. Both were file-private — no caller-side changes outside playerStore's own imports. playerStore 3434 → 3415 LOC. |
||
|
|
81b161a418 |
refactor(player): E.6 — extract deriveNormalizationSnapshot into module (#569)
`deriveNormalizationSnapshot` — the loudness / replaygain / off branch that the runtime uses to compute the normalization fields on every track switch and queue rewrite — moves into src/store/normalizationSnapshot.ts. File-private, four internal call sites updated to import from the new module. Adds 8 focused tests pinning each branch (off, loudness with target LUFS, replaygain enabled with tag + pre-gain, replaygain enabled with fallback) plus neighbour-track context for album-mode resolution. playerStore 3470 → 3434 LOC. |
||
|
|
b68bddd034 |
refactor(player): E.5 — extract playback-URL routing into module (#568)
Three file-private helpers + their shared module-scoped track id move into src/store/playbackUrlRouting.ts: recordEnginePlayUrl, playbackSourceHintForResolvedUrl, shouldRebindPlaybackToHotCache. The `lastOpenedWithHttpTrackId` mutable goes with them — only those three read it. No external callers, no re-exports needed. Adds 12 focused tests covering the source-kind classifier (stream / hot / offline), the rebind decision across stream:-prefix forms, the empty-serverId / un-recorded edge cases, and the test-only reset helper. playerStore 3490 → 3470 LOC. |
||
|
|
5b89051817 |
refactor(player): E.4 — extract playback-progress pub/sub into module (#567)
The high-frequency PlaybackProgressSnapshot channel — type, mutable snapshot, listener Set, emit/get/subscribe — moves into a dedicated src/store/playbackProgress.ts. playerStore re-exports the public 3 (getPlaybackProgressSnapshot, subscribePlaybackProgress, the type) so the 5+ external callers (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView, TauriEventBridge) keep their existing imports. Direct unit tests pin the delta short-circuit (`currentTime <0.005`, `progress/buffered <0.0002`) that keeps idle CPU bounded. The end-to-end Tauri-event drive path remains covered by the existing playerStore.progress characterization test (unchanged). playerStore 3516 → 3490 LOC. |
||
|
|
d24514d67e |
refactor(player): E.3 — extract waveform/normalization/seek pure helpers (#566)
Three small tranches out of playerStore.ts: - src/utils/waveformParse.ts — waveformBlobLenOk + coerceWaveformBins (the parser that handles number[] / Uint8Array / ArrayLike payloads Rust serializes as). - src/utils/normalizationCompare.ts — normalizationAlmostEqual (tolerant null-aware dB comparison). - src/utils/seekErrors.ts — isRecoverableSeekError (retry classifier for the Rust seek pipeline). Each gets focused unit tests. All four were file-private, no external callers — pure code move. playerStore 3556 → 3516 LOC. |
||
|
|
1521e4ea8f |
refactor(player): E.2 — extract queue-undo machinery into queueUndo.ts (#565)
Move the bounded undo/redo stacks, snapshot factory, scroll-top reader registry, and pending-scroll-top channel into a dedicated module under src/store/. playerStore re-exports the three public APIs that QueuePanel and the test harness already imported (registerQueueListScrollTopReader, consumePendingQueueListScrollTop, _resetQueueUndoStacksForTest), so no caller-side changes are needed. The new module adds explicit push/pop accessors so the undo/redo store actions stop reaching into module-scoped arrays directly. Inline `pendingQueueListScrollTop = …` writes inside applyQueueHistorySnapshot become `setPendingQueueListScrollTop(…)` calls — same effect, scoped through the module. PlayerState gets the `export` keyword so queueUndo.ts can type its state-shaped parameters. playerStore.ts 3618 → 3576 LOC. queueUndo gains a focused test file covering snapshot deep-cloning, the redo-stack invalidation on a fresh undo push, max-size enforcement, and the scroll-top channel. Pre-PR check: PASS. |
||
|
|
dcf3dd98e0 |
refactor(player): E.1 — extract queue-identity helpers to utils/ (#563)
Four pure helpers move out of playerStore: normalizeAnalysisTrackId, sameQueueTrackId, queuesStructuralEqual, shallowCloneQueueTracks. Adds focused unit tests for the stream:-prefix normalization and the no-op detection that prevents unnecessary queue rewrites. Behaviour preserved verbatim. playerStore 3618 → 3598 LOC. |
||
|
|
c0f2bc00dd |
refactor(app): Phase D — move TauriEventBridge into src/app/ (#561)
Final piece of the App.tsx slim-down. The `TauriEventBridge` component — ZIP download progress, track-preview lifecycle, audio device changed/reset, the full `cli:*` listener surface (audio-device-set, instant-mix, library list/set, server list/set, search, player-command), tray-icon visibility sync, in-app keybindings keydown handler, media keys + tray actions, `shortcut:global-action` / `shortcut:run-action`, seek-relative / seek-absolute / set-volume, the window:close-requested + app:force-quit flow (with the shared `performExit` Orbit teardown), and the `psysonic --info` snapshot publisher — moves into `src/app/TauriEventBridge.tsx`. `MainApp` imports it from the new file. `App.tsx` is now 57 LOC: just the `App()` default-export that branches between `MiniPlayerApp` and `MainApp` after wiring the shared theme / font / track-preview document attributes. A.K.A. App.tsx 1453 → 57 LOC over Phase 2 (M0 + B.1 + B.2 + C.1 + C.2 + D). Pure code move at every step — no behaviour change. Pre-PR check: PASS (frontend tests, tsc, coverage gates, prod build, backend tests, clippy, backend coverage gates). |
||
|
|
796c7567ea |
refactor(app): Phase C.2 — move AppShell into src/app/AppShell.tsx (#560)
Companion to C.1. The full `AppShell` component — the persistent sidebar / header / route host / queue-resizer / player-bar layout plus its ~25 effects (tray-tooltip + title sync, Orbit role/phase body marker, platform attribute, fullscreen tracking, music-folders + rating-support probe, sidebar persistence, queue drag, WebKitGTK DnD/select-all blockers, blur/hidden cosmetic-animation pause) — moves into `src/app/AppShell.tsx` together with its three private helpers (`readInitialSidebarCollapsed`, `persistSidebarCollapsed`, `shouldSuppressQueueResizerMouseDown`). `MainApp` now imports `AppShell` from the new file instead of the App.tsx re-export. `App.tsx` 1232 → 560 LOC. What's left is the `TauriEventBridge` (~475 LOC, Phase D) plus the ~50-LOC `App()` default-export that splits between `MiniPlayerApp` and `MainApp`. Imports that were AppShell-only (Sidebar / PlayerBar / 9 components / 7 hooks / 3 platform helpers / useOfflineStore / useConnectionStatus / useEqStore / usePerfProbeFlags / useTranslation / useIsMobile / probeEntityRatingSupport / Suspense / useCallback / useRef / useState / useLocation / getCurrentWindow's UI use / ConnectionIndicator / LastfmIndicator / AppUpdater / TitleBar / OrbitSessionBar / OrbitStartTrigger / useOrbitHost / useOrbitGuest / cleanupOrphanedOrbitPlaylists / IS_MACOS / IS_WINDOWS / IS_LINUX / APP_MAIN_SCROLL_VIEWPORT_ID / AppRoutes / lucide icons) all leave with the component. No behaviour change — pure code move + import-graph shuffle. Tests unchanged; the existing AppShell behaviour is already covered indirectly by the per-component tests it composes. Pre-PR check: PASS (frontend tests, tsc, coverage gates, prod build, backend tests, clippy, backend coverage gates). |
||
|
|
2b1ad1542a |
refactor(app): Phase C.1 — extract AppRoutes + RequireAuth from App.tsx (#559)
Continues the App.tsx slim-down. Two pieces move out of the monolith: - `src/app/AppRoutes.tsx` — the route table and its 32 lazy page imports. AppShell now renders `<AppRoutes />` inside the existing `<Suspense>`; the `perfFlags.disableMainRouteContentMount` placeholder stays in AppShell because that branch is a layout concern, not a routing one. `useIsMobile()` moves inside AppRoutes so the `/now-playing` mobile swap stays self-contained. - `src/app/RequireAuth.tsx` — the 4-line auth guard, with a focused test that covers all three reject paths (no login, no active server id, empty server list) plus the happy path. MainApp imports it from the new file instead of routing through the App.tsx re-export. Side-cleanup of imports that B.2 had already orphaned in App.tsx (`version`, `initAudioListeners`, `lazy`, `Routes`, `Route`, `Navigate`, `MobilePlayerView`). `App.tsx` 1308 → 1232 LOC. `AppShell` stays in App.tsx for Phase C.2. Pre-PR check: PASS (frontend tests, tsc, coverage gates, prod build, backend tests, clippy, backend coverage gates). |
||
|
|
f09da2d2a3 |
refactor(app): Phase B.2 — split App() into MiniPlayerApp + MainApp (#557)
The 186-LOC default export shrinks to a thin window-kind switch with
shared document-attribute hooks. The mini-player tree and the main-app
tree each move into their own module under src/app/.
- src/app/MiniPlayerApp.tsx (48 LOC):
DragDropProvider + MiniPlayer + cross-window storage sync
- src/app/MainApp.tsx (129 LOC):
BrowserRouter + Routes + main-only lifecycle hooks
(audio listeners, hot cache, global shortcuts, mini-player
bridge, easter egg, scrollbar auto-hide)
AppShell + RequireAuth + TauriEventBridge are now named exports from
App.tsx so MainApp can compose them; Phase C/D will extract those into
their own modules.
App.tsx: 1453 -> 1308 LOC. Behaviour-preserving.
|
||
|
|
0cd8998dc9 |
refactor(app): Phase B.1 — extract pre-React bootstrap into src/app/ (#555)
main.tsx shrinks from 56 -> 17 LOC. New module surface:
- src/app/windowKind.ts: cached getWindowKind() detector,
replaces the global __PSY_WINDOW_LABEL__ string everywhere
- src/app/bootstrap.ts: pushUserAgentToBackend +
pushLoggingModeToBackend + runPreReactBootstrap orchestrator
App.tsx + playerStore.ts now read getWindowKind() instead of poking
window.__PSY_WINDOW_LABEL__ directly. Behaviour-preserving.
|
||
|
|
d3a8160b37 |
refactor(player): M0 — extract pure helpers from playerStore.ts (#554)
Moves four self-contained helpers into src/utils/, each with co-located characterization tests. playerStore re-exports them for the ~30 existing call sites; Phase E will migrate those imports. - shuffleArray (Fisher-Yates, generic) - resolveReplayGainDb (track/album/auto mode resolution) - songToTrack (Subsonic -> Track shape) - buildInfiniteQueueCandidates (Instant-Mix top-up source) playerStore.ts: 3732 -> 3618 LOC (-114). |
||
|
|
b3646daabd |
test(playerStore): miscellaneous actions push F1 toward the 50% floor (#550)
24 new tests covering the smaller action surfaces F1 / 2a-c skipped: - setStarredOverride / setUserRatingOverride (per-id maps) - openContextMenu / closeContextMenu (state + isOpen flip) - openSongInfo / closeSongInfo (modal state) - toggleQueue / setQueueVisible (visibility flip with persisted side effect) - toggleFullscreen (boolean flip) - setLastfmLoved (writes verbatim + caches under title::artist when track is set; does NOT cache without a track) + toggleLastfmLove (no-op without track or session-key; flips state + cache otherwise) - setLastfmLovedForSong (caches under title::artist key) - setProgress (currentTime + derived progress) - stop (invokes audio_stop, resets playback bookkeeping) - shuffleQueue (no-op when queue < 2; deterministic with mocked RNG, current track stays at queueIndex 0) - shuffleUpcomingQueue (no-op when upcoming < 2; head + current untouched, upcoming tail shuffled) - pruneUpcomingToCurrent (drops everything after queueIndex; clears the queue entirely when no current track; early return when queue is already empty) - setRadioArtistId (does-not-throw smoke; no public getter for the module-level state it writes) playerStore.ts coverage 40.48% -> 48.02% lines (functions 37.84% -> 50.34%). Close to but not over the F1 50% line floor — the remaining ~2pp lives in playTrack's async hot-cache/replay-gain body, which is the same surface PR 2c flagged for a separate follow-up. Not adding playerStore.ts to the gate yet; staying out one more PR to see the number stabilise. Frontend suite: 451 -> 475 tests (+24). |
||
|
|
568f3aeb7d |
test(api): subsonic.ts async endpoint contracts (F3 follow-up) (#549)
39 new tests targeting the response normalization paths the F3 PR (#544) deferred. Mocks axios at the module boundary; pins: - api() helper envelope: unwrap subsonic-response on status=ok, throw "Invalid response" without envelope, throw the server message on status=failed, throw a generic on failed-without-message, propagate network failures. - song-array vs single-object normalization paths: - getMusicDirectory normalizes child (object -> [object]), empty -> []. - getMusicIndexes flattens index -> artist arrays (object or array). - getMusicFolders coerces numeric ids to strings + defaults name. - getRandomSongs pass-through behaviour pinned (no normalization -- Navidrome always returns the array form). - collection-shape contracts: getAlbum splits { album, songs } + empty fallback when album.song is absent, getStarred returns empty arrays on missing starred2 / missing fields / pass-through arrays. - single endpoint behaviours: getSong null on failure, getTopSongs [] + slice to 5, getArtists flatten + empty, search whitespace-query short-circuit (no HTTP), getAlbumInfo2 null on error, ping true/false based on status. - pingWithCredentials (explicit-URL path): full response (type + serverVersion + openSubsonic), http:// prepend when scheme missing, trailing-slash strip before /rest/ping.view, ok=false on any failure / status=failed, openSubsonic defaults to false when omitted. subsonic.ts coverage 12.66% -> 31.87% lines (+19pp). The remaining surface lives in niche endpoints (playlist mutations, statistics overview/aggregates, internet radio CRUD, cover-art uploads, ratings prefetch). Not gate-eligible yet; a follow-up could push to ~60% but diminishing returns relative to other backlog items. Frontend suite: 412 -> 451 tests (+39). |
||
|
|
377675ae94 |
test(ui): MiniPlayer + FullscreenPlayer (§4.5 regression) (Phase F5c) (#548)
MiniPlayer.test.tsx (4): mounts without throwing, renders the
always-present titlebar controls (Pin + Open main window — Close is
Linux-only and lives on the manual smoke list per pick 4a), click on
Open main window / Close does not throw, clicking the Pin button
flips the alwaysOnTop label between "Unpin" and "Pin on top". Bridge
contract (mini:ready / mini:sync, geometry persistence) deferred to
B-tier phase B5 -- jsdom does not model two webviews.
FullscreenPlayer.test.tsx (9): renders the labelled Fullscreen Player
dialog + Close Fullscreen control. Control wiring: Close calls
onClose, Stop calls stop, Previous calls previous, Next calls next,
Repeat cycles via toggleRepeat.
§4.5 of the v2 plan -- useCachedUrl(coverUrl, coverKey, false)
regression. Mocks the CachedImage module so the call args are
observable. Pins:
- the 500 px cover-art call passes opt=false (no fetchUrl fallback;
prevents double crossfade fetchUrl -> blobUrl);
- the 300 px art-box call passes opt=true (default behaviour).
A refactor that "tidies up" the useCachedUrl call sites would silently
regress the FS player cover; this test makes it loud.
Harness fix: vi.mock for @tauri-apps/api/event now returns an async
emit that resolves -- components chain .catch() on emit which crashed
on the bare vi.fn() return value during first-render useEffect.
Benefits any future component test that mounts something using emit.
Frontend suite: 399 -> 412 tests (+13). F5 (and Phases F0-F6) complete.
|
||
|
|
2c38db6ea6 |
test(ui): QueuePanel (§4.4 DnD regression) + PlayerBar (Phase F5b) (#547)
QueuePanel.test.tsx (8): empty-queue affordance, one row per queue track with matching data-queue-idx, track titles render. Toolbar exposes Shuffle queue / Save Playlist / Load Playlist / Copy queue share link / Clear via aria-label; Shuffle is disabled when the queue has fewer than 2 tracks. §4.4 of the v2 plan -- DnD architecture pin. Queue rows do NOT declare draggable=true (no HTML5 native drag); the source file has no dataTransfer.setData / dataTransfer.getData / onDragStart / onDrop JSX usage; no application/json MIME anywhere. The project's psy-drop custom event system sidesteps WebView2's text/plain-only restriction by avoiding HTML5 DnD entirely -- a refactor that re-introduces native DnD on the queue would silently break Windows. PlayerBar.test.tsx (9): renders the labelled "Music Player" region. Surfaces Previous Track / Play / Next Track / Repeat / Stop when a track is loaded. The middle control flips between "Play" and "Pause" based on isPlaying. Control wiring: clicking Play/Pause calls togglePlay, Previous calls previous, Next calls next, Repeat cycles off -> all -> one, Stop calls stop. The region landmark is still rendered when no track is loaded. Adds a generic scrollIntoView no-op stub in src/test/mocks/browser.ts -- jsdom does not implement it and QueuePanel's queueAutoScroll calls it on mount with auto-advance enabled. Benefits any future component test that touches scroll affordances. Frontend suite: 382 -> 399 tests (+17). |
||
|
|
fae615fdb8 |
test(ui): ContextMenu + WaveformSeek behaviour pins (Phase F5a) (#546)
ContextMenu.test.tsx (11): renders nothing when closed, renders when openContextMenu has run, closeContextMenu hides on next render. Song-type shows Play Now / Play Next / Add to Queue; album-type shows Open Album / Play Next / Enqueue Album / Go to Artist; artist-type shows Start Radio + share affordances; queue-item shows a Remove option the song menu does not. Clicking an action calls the expected store method (playNext, enqueue) and closes the menu. Escape on the menu closes it. WaveformSeek.test.tsx (8): renders a canvas, cursor=default without trackId (no-track-loaded affordance), cursor=pointer with a trackId. Wheel guards: no seek without a trackId, no seek with duration=0. Wheel commit wiring: 350 ms trailing debounce delays the seek call until activity settles; rapid wheel events coalesce (fewer commits than events). Mount + unmount completes without throwing. Neither component joins the hot-path gate -- jsdom skips canvas drawing and these files have large branch surfaces (ContextMenu has 13 menu types, WaveformSeek has the animation loop + 11 seekbar styles) that need behavioural smoke rather than line coverage. The tests cover the contract the refactor must preserve; visual / interactive layers stay on the manual smoke list. Frontend suite: 363 -> 382 tests (+19). |
||
|
|
6e646351ee |
test(previewStore): startPreview + main-player volume sync (Phase F4) (#545)
Adds 22 new tests on top of the existing 7 _on* / stopPreview ones. startPreview happy path: invokes audio_preview_play with the configured args (id, url, durationSec, startSec, volume) and stores the previewing track + duration + reset elapsed / audioStarted. Short tracks (duration <= previewDuration * 1.5) start at 0; longer tracks seek to duration * trackPreviewStartRatio. Camel-case IPC keys pinned (startSec / durationSec, not snake_case -- CLAUDE.md gotcha). Cross-store guard tests: no-op when previews globally disabled, no-op when disabled at the calling location, no-op while a host or guest is inside any Orbit phase (active / joining / starting), falls through to play when role is null (no session). Same-id re-click: treats it as a stop -- audio_preview_stop fires, audio_preview_play does not. Failure path: engine invoke rejects -> store state rolls back (previewingId / previewingTrack / audioStarted) and the error propagates to the caller. Loudness pre-attenuation folding: with normalizationEngine=loudness + loudnessPreAnalysisAttenuationDb=-6 dB, volume is multiplied by 10^(-6/20). normalizationEngine=off keeps volume verbatim. Positive pre-attenuation values are pulled to 0 by the Math.min(0, ...) guard. Main-player volume sync side-effect (module-level usePlayerStore.subscribe): pings audio_preview_set_volume when volume changes during a preview, skips when no preview is active, skips when the new value equals the prior value (subscription guard). previewStore.ts coverage 33% -> 100% lines. Added to the hot-path gate. Plus the typed `OrbitRole` is `'host' | 'guest'` (null when no session), not 'idle' as a string -- minor type-correctness alignment. |
||
|
|
d2898ebaf6 |
test(api): URL builders + playback URL resolver + share link composition (Phase F3) (#544)
subsonic.contract.test.ts (21): parseSubsonicEntityStarRating (userRating first then rating fallback, numeric-string coercion, undefined for null / NaN / non-numeric), libraryFilterParams (empty without active server, empty on "all" filter, returns musicFolderId on specific filter), getClient (throws without a server, returns baseUrl + auth params, rotates token + salt across calls), coverArtCacheKey (serverId:cover:id:size shape, "_" fallback without active server, no ephemeral salt embedded -- stays cacheable), buildStreamUrl (URL shape + Subsonic auth params: id u t s v=1.16.1 c=psysonic/* f=json, rotates t/s across calls so Rust matches by id, special character ids encoded once not twice), buildCoverArtUrl (default size=256), buildDownloadUrl (download.view path), trailing-slash + scheme handling on base URL. resolvePlaybackUrl.test.ts (15): precedence offline > hot-cache > stream (first priority wins even when later sources also have the track), forwards trackId + serverId to both stores. getPlaybackSourceKind for offline / hot / stream / engine-preload-hint cases. streamUrlTrackId parser (id from stream.view query, null for non-stream URLs / no query / missing id, decodes URL-encoded ids, manual-query fallback for relative paths). copyEntityShareLink.test.ts (5): writes a psysonic2-prefixed payload that round-trips, returns false without an active server, returns false on empty / whitespace id, trims surrounding whitespace before encoding, propagates clipboard-failure return. Gate broadens with src/utils/resolvePlaybackUrl.ts (95.8 %) + src/utils/copyEntityShareLink.ts (100 %). subsonic.ts at 12.7 % stays out -- the URL-builder + parser surface this PR covers is the structural part; the async API endpoints need axios mocking, deferred to a follow-up. authStore.ts (79 %) and playerStore.ts (40 %) deferred-list comments updated to reflect F2 + F1 actuals. |
||
|
|
ae23bf61eb |
test(authStore): characterize login + servers + persistence + settings (Phase F2) (#543)
login.test.ts: composed addServer -> setActiveServer -> setLoggedIn flow, failed-login leaves prior state intact, addServer assigns unique ids, setConnecting / setConnectionError independence, logout clears isLoggedIn + musicFolders but keeps server entry, Last.fm session (setLastfm / connectLastfm / disconnectLastfm contracts). servers.test.ts: addServer / updateServer (patch by id, no-op on unknown), setActiveServer (clears musicFolders), removeServer (non-active no-effect, active picks newServers[0] fallback, last server -> null + isLoggedIn false, cleans per-server bookkeeping maps), getActiveServer / getBaseUrl selectors. Includes the gapless / crossfade mutex regression test from the v2 plan section 4.3 -- callers clear the other flag before setting, the setters themselves do NOT auto-clear (contract pin). persistence.test.ts: hydration loads existing localStorage shape, defaults missing fields, preserves saved values verbatim. Robust to corrupt JSON and missing top-level state. onRehydrate migrations: clears conflicting hotCacheEnabled + preloadMode!=off legacy combo, keeps hotCache when preload was already off, migrates legacy waveform seekbarStyle to truewave, strips removed animationMode / reducedAnimations fields. partialize strips musicFolders. Includes the synchronous-storage invariant regression from the v2 plan section 2 (CLAUDE.md gotcha) -- getActiveServer is visible in the same tick after addServer + setActiveServer with no await. settings.test.ts: API-pin sweep across 22 trivial setters via it.each (rename-resistant), focused tests for setters with logic (clamping in setTrackPreviewStartRatio / setTrackPreviewDurationSec / setRandomMixSize, boolean coercion in setTrackPreviewsEnabled, finite-number guard in setLoudnessPreAnalysisAttenuationDb, default reset in resetLoudnessPreAnalysisAttenuationDbDefault), per-server bookkeeping contracts (setEntityRatingSupport / setAudiomuseNavidromeEnabled positive-opt-in semantics), enum-value setters (setPreloadMode / setDiscordCoverSource / setLoggingMode / setReplayGainMode / setNormalizationEngine / setLyricsMode), genre blacklist + audio output device replacement. authStore.ts coverage 45.37% -> 79.29% lines (target was 60%). authStore.ts not yet added to the hot-path gate -- want to see it stable across a few real PRs first per the gate's curation rule. |
||
|
|
8569a17797 |
test(playerStore): progress snapshot + persistence flush (Phase F1 / PR 2c) (#542)
progress.test.ts: getPlaybackProgressSnapshot shape + post-emit reflection, subscribePlaybackProgress (notify, (next, prev) pair, near-duplicate epsilon coalesce at 0.005 s, unsub stops notifications, multiple subscribers independent), live-emit throttling guard (drops within 1500 ms + < 0.9 s delta; large delta passes; time threshold passes). persistence.test.ts: flushPlayQueuePosition forwards (ids, currentId, posMs) to savePlayQueue, caps song-id list at 1000, no-ops on radio / no-track / empty queue, swallows backend errors, floors position to whole ms. playerStore.ts coverage 39.55% -> 40.48%. F1 50% floor not met -- remaining ~10pp lives in playTrack's async hot-cache/replay-gain body, shuffleQueue, stop, enqueueRadio, initializeFromServerQueue and the orbit auto-merge paths. Either a follow-up PR 2d or a revised F1 floor; flagged in the PR body. Gate unchanged -- playerStore.ts stays out until the floor is met. Discovered + fixed during this PR: the spread ...await vi.importActual() mock pattern lets the real savePlayQueue leak through to playerStore.ts's relative import (../api/subsonic) even when the alias-form is mocked. Switched persistence.test.ts to an explicit non-spread mock map listing every export the store uses. |
||
|
|
4a18e15489 |
test(playerStore): playback actions + audio event handlers (Phase F1 / PR 2b) (#541)
playbackActions.test.ts: pause (invoke + failed-invoke controlled), resume (warm path, no-track guard), togglePlay (both branches), seek (clamp to dur-0.25, 100 ms debounce, coalesce rapid drags, no-op guards), next (advance, repeat=all wrap, repeat=off audio_stop + reset), previous (>3 s restart, jump-back, queueIndex=0 no-op), toggleRepeat cycle. events.test.ts: audio:progress (commit on active transport, drop without track / paused, duration=0 falls back to track.duration), audio:track_switched (advance, repeat=one pin, repeat=all wrap, end+off no-op, scrobbled+ lastfmLoved reset), audio:ended (immediate playback reset, radio path clears currentRadio without queue advance). 4 listener-lifecycle regression tests cover section 4.2 of the pre-refactor testing plan v2 -- cleanup actually unsubs; re-init keeps count=1; double-init without cleanup stacks (contract pin so a refactor that drops the cleanup return value fails loudly). playerStore.ts coverage 18.1% -> 39.55% lines. PR 2c (progress snapshot + persistence flush) pushes past the F1 50% floor. |
||
|
|
42e3fdb976 |
test(playerStore): characterize pure helpers + queue mutations (Phase F1 / PR 2a) (#540)
trackShape.test.ts: songToTrack mapping (required, optional, replayGain flattening, missing-block fallback, no invented flags); resolveReplayGainDb precedence (disabled, track, album, auto with prev/next neighbour match, missing albumId fallback, both gains missing); shuffleArray (non-mutating, preserves multiset, copies, empty/single, deterministic under mocked RNG). queue.test.ts: enqueue / enqueueAt (auto-added separator placement, queueIndex shift, index clamping), playNext (playNextAdded tag, position, empty input), clearQueue (state reset, audio_stop call), reorderQueue (splice, currentTrack-id-following queueIndex), removeTrack (clamp on shrink, keep when after cursor), undo / redo (empty stacks return false, rollback, replay, new edit drops pending redo). Adds a 5-line test-only export _resetQueueUndoStacksForTest in playerStore.ts because the undo/redo arrays live at module scope outside the Zustand state graph; storeReset.ts now calls it so resetPlayerStore is fully complete. playerStore.ts coverage 4.76% -> 18.1% lines. PR 2b + 2c push further toward the F1 floor of 50%. |
||
|
|
4f9ad07d65 |
test(frontend): harness expansion + utility coverage push (F0 + F6) (#539)
* test(frontend): expand harness for store/component/contract tests
- factories: makeSubsonicSong, makeServer, makeAuthState, makeQueueState
- storeReset.ts: per-test reset for player/auth/preview/orbit stores
- mocks/subsonic.ts: realistic fixtures + stream/cover URL helpers
- mocks/browser.ts: ResizeObserver/IntersectionObserver/matchMedia/clipboard/object URLs
- mocks/tauri.ts: tauriMockListenerCount for listener-lifecycle regression tests
- renderWithProviders: pin i18n language to 'en' by default; { language } opt-out
- vitest.config: pool 'forks' + isolate to avoid module-mock + Zustand-global flake
- README: documented patterns, store-reset policy, i18n rule, isolation rationale
* test(frontend): bump utility coverage + expand hot-path gate
serverMagicString: 71→100% (encode/decode rejection branches, clipboard
fallback paths). shareLink: 69→97% (all entity kinds, queue trim, orbit
decoder, findServerIdForShareUrl). dynamicColors: 44→100% (extractCoverColors
DOM paths via Image / canvas / fetch mocks).
Gate adds shareLink.ts and dynamicColors.ts — both stable above 95%.
Comments updated for the new floor and the M4 hard-gate handoff.
|
||
|
|
a228ce1c91 |
chore: fix stale doc references (#538)
- src/test/README.md: layout listed wrong filename for the readme itself - miniPlayerBridge.ts: comment pointed at a doc that lives outside the repo |
||
|
|
123fbcc802 |
fix(orbit): event-driven host push + guest seekbar lock (#537)
* fix(orbit): event-driven host push on play/pause flips Without this, the worst-case delay between "host hits pause" and "guest stops" is two full polling windows (host's 2.5 s push tick + guest's 2.5 s read tick, plus network) — long enough for the guest to noticeably run past the host. Subscribing to playerStore.isPlaying changes adds at most one extra remote write per flip; non-flip state ticks still ride the existing 2.5 s timer. The listener filters on isPlaying so the per-second currentTime ticks don't trigger spurious pushes. * fix(orbit): lock seekbar for guests — sync follows the host Guests could drag/click/wheel the seekbar, which would jump the local player and then snap back at the next host poll (2.5 s of inconsistent UX) — or push the guest into a diverged state where Catch Up was the only way back. The seekbar is host-controlled in Orbit; the guest input path now reflects that. - App.tsx exposes `data-orbit-role="host"|"guest"` on the root element alongside the existing `data-orbit-active` marker. - WaveformSeek's container gains a `.waveform-seek-container` class so CSS can target it. - Guest rule: `pointer-events: none` on children blocks click / drag / wheel / hover; the parent keeps `cursor: not-allowed` + reduced opacity so the disabled state is visually unambiguous. Hosts and non-orbit users see no change. * docs(changelog): credit PR #537 (orbit sync latency + guest seekbar) |
||
|
|
64b33e6941 |
feat: customizable queue toolbar with drag-and-drop reordering and visibility toggles (#534)
* Add drag-and-drop reordering and visibility toggles for queue toolbar * docs(changelog): credit PR #534 (queue toolbar customization) Adds the v1.46.0 CHANGELOG entry and a new bullet on kveld9's contributors block in Settings → System. --------- Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> |
||
|
|
02d533e949 |
test(frontend): vitest framework bootstrap + hot-path coverage gate (#536)
* test(frontend): vitest framework bootstrap + hot-path file coverage gate
Adds the harness for component, hook and store tests on top of the existing
util tests in src/utils/. Mirrors the backend rust-tests rollout: jsdom env,
v8 coverage, soft hot-path file gate, dedicated CI workflow.
What's in:
- vitest.config.ts: jsdom environment, v8 coverage, alias @ -> src
- src/test/setup.ts: jest-dom, @testing-library cleanup, vi.mock for
@tauri-apps/api/{core,event} + plugin-shell, Map-backed Storage polyfill
for Node 25 + jsdom 26 (both ship a broken native localStorage)
- src/test/mocks/tauri.ts: programmable onInvoke() / emitTauriEvent() helpers,
auto-reset between tests
- src/test/helpers/factories.ts: makeTrack / makeTracks
- src/test/helpers/renderWithProviders.tsx: render() wrapped with
MemoryRouter + I18nextProvider
- src/test/README.md: conventions doc (where tests go, how to mock Tauri,
what to never mock)
Sample tests showing the patterns:
- src/components/CoverLightbox.test.tsx: component, queries by role
- src/store/previewStore.test.ts: store characterization, event handlers
+ stopPreview (startPreview deferred until the cross-store provider
strategy is decided)
CI:
- .github/workflows/frontend-tests.yml: jobs for vitest, tsc, coverage +
hot-path gate. coverage job carries continue-on-error: true (soft).
- .github/frontend-hot-path-files.txt: initial list (3 utils at >=70%).
playerStore + the unfinished half of previewStore are deferred until
Phase 1 coverage work lands.
- scripts/check-frontend-hot-path-coverage.sh: mirror of the rust gate.
npm scripts:
- test: one-shot run (unchanged)
- test⌚ vitest in watch mode
- test:coverage: v8 coverage + html / lcov / json-summary reports
57 / 57 tests pass; tsc --noEmit clean.
* chore(nix): sync npmDepsHash with package-lock.json
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
|
||
|
|
308eb36f05 |
feat(analysis): re-analyze waveform when clearing loudness cache
Add analysis_delete_waveform_for_track, invoke it from loudness reseed, clear waveformBins in the UI, and extend queue strings for tooltips/toast. |
||
|
|
25507888a9 |
fix(orbit): host single-track playTrack appends instead of replacing (#529)
* fix(orbit): host single-track playTrack appends instead of replacing Reported by cucadmuh: "When playing from offline, the Orbit queue doesn't get appended to — it gets overwritten." Root cause: the Orbit bulk-guard fires only when `queue.length > 1`. A `playTrack(track, [track])` call (one example: OfflineLibrary's "Play this album" on a single-track album, but any UI that passes an explicit 1-track replacement queue triggers it) slips past the guard and replaces the host's `playerStore.queue`. The host's queue *is* the shared Orbit queue — replacing it wipes every guest suggestion + every upcoming track in one click. Re-route to append + jump when role is host: if the track is already in the queue, jump to that slot; otherwise append it and jump to the new tail. The guest path is intentionally left alone — guests opting out of host-sync via a local Play is the existing "guest running their own show" divergence behaviour. `useOrbitGuest`'s `syncToHost` is the only guest-side caller of `playTrack(track, [track])`, and it never matches `role === 'host'` so it's never intercepted. * docs(changelog): add host single-track Orbit-queue protection bullet |
||
|
|
eae649bcad |
fix(orbit): make initial-sync seek visually stick on join (#528)
* fix(orbit): make initial-sync seek visually stick on join Reported: "I join the room, the waveform shows the host's live position for a second or two, then snaps back to 0:00 and audio is still playing from the start. Only after that snap can I press Catch Up." Two compounding causes in `useOrbitGuest`: **1. Poll fires `applyMirror` before the engine is genuinely playing.** `playTrack` flips `isPlaying` to `true` *synchronously* in its optimistic store write, so the post-`playTrack` poll satisfied its "engine ready" check before the Tauri `audio_play` had even started producing sample. The `seek` inside `applyMirror` updates the store position immediately (waveform jumps to host's live position), but `audio_seek` is debounced and lands on a not-ready engine — it silently no-ops. The engine then starts playing from 0, and its first progress events overwrite the optimistic seek position, snapping the waveform back. Add `currentTime > 0.1` to the poll's "engine genuinely playing" condition: once audio has flowed past the cold-start barrier, the seek commits and the seek-target guard correctly filters subsequent progress events. **2. `applyMirror`'s play-state mirror raced its seek**, same shape as the `onCatchUp` race fixed in #527. `player.seek` debounces `audio_seek` via setTimeout(0) while `pause`/`resume` invoke synchronously — pause arriving first leaves the engine paused at the old position. Defer the play-state mirror by 200 ms so the seek lands first. * docs(changelog): add initial-sync seek-stickiness bullet |
||
|
|
6c5465e9c7 |
fix(orbit): hysteresis on Catch Up visibility so the button stays clickable (#527)
* fix(orbit): keep Catch Up button visible long enough to click Reported during testing: the Catch Up button "appears for a moment, then disappears too fast to click". The single-stage debounce only filtered the show direction (require ≥ 3 s sustained over-threshold before showing), but on a real-world high-latency session the genuine drift fluctuates between ~1 s and ~8 s in lockstep with both sides' chunked `currentTime` updates — so the button vanished as soon as drift dipped briefly under 3 s, even though the drift baseline was still 5–8 s. Add a hide-side hysteresis: once visible, the button stays visible until drift has been under a tighter 1 s threshold for ≥ 1 s. Otherwise the noisy 1–3 s drift valleys keep the button up so the user can actually click it. Constants left as locals; if testing wants different floors we can extract. * docs(changelog): add catch-up hysteresis bullet * fix(orbit): show Catch Up when host paused + serialise seek-then-pause Two follow-ons after the hysteresis change: **1. Show Catch Up even when the host is paused.** The visibility gate required `state.isPlaying === true`, but a guest who joined while the host was paused still benefits from Catch Up if their sync to the host's paused position failed (engine drop, brief network blip during initial-sync, manual local seek). Drop the `state.isPlaying` gate — the only signal that should matter is "drift between us and the host's reported state" — which `computeOrbitDriftMs` already computes correctly in the paused case (no time-extrapolation, just `guestPos - hostPos`). **2. Defer the play-state mirror after `player.seek`.** Reported symptom: "I press Catch Up, the player pauses; when I press play, it resumes from the *old* position and the waveform jumps back." Root cause: `player.seek` debounces `audio_seek` via `setTimeout(0)`, while `player.pause` / `player.resume` fire their invokes synchronously. Pause arrives at the engine first, leaving it paused at the pre-Catch-Up position with the seek queued behind. When resume happens later, the engine plays from the old spot. Push the play-state mirror behind a 200 ms `setTimeout` so the seek's invoke lands first. |
||
|
|
27c740b760 |
fix(orbit): kick a fresh playTrack when engine is stuck mid-load (#526)
* fix(orbit): kick a fresh playTrack when engine is stuck mid-load Follow-up to #525. Symptom: occasionally on join, the guest gets no audio until the next host-driven track change, at which point a fresh `playTrack` runs and audio plays normally. Root cause: when the initial `syncToHost` poll hits its 5 s deadline without the engine reporting `isPlaying === true` (slow Navidrome cold-start), the next pull tick takes the cheap "track already loaded" shortcut and calls `applyMirror`. `applyMirror` fires `seek` + `resume` on an engine that is stuck in a "loaded but never started" limbo — seek silently no-ops and `resume` can't kick a track that never began. Guest is silent until something else triggers a fresh `playTrack`. Tighten the shortcut: take the cheap path only when the engine is already in the state the host expects (or playing while host is paused, which is fine to align via pause). Otherwise fall through to a fresh `playTrack` and the existing 5 s ready-poll, which re-initialises the engine and lets audio actually start. * docs(changelog): add engine-limbo follow-up bullet * fix(orbit): re-sync when engine silently fell back to paused The optimistic `isPlaying: true` `playTrack` writes synchronously masks a `audio_play` failure: the post-playTrack poll sees the optimistic flag, fires `applyMirror`, and the outer tick records `lastAppliedRef = { ..., isPlaying: true }` as a successful sync. But if the underlying `invoke('audio_play')` rejects later (network blip / cold-start exhaustion), the catch handler flips the flag back to `false` and schedules `next()`, which short-circuits to `audio_stop` in an Orbit guest — leaving the player silent while `lastAppliedRef` still claims we're playing. None of the divergence-detection branches (track-change / play-pause-flip) match, so the guest never re-syncs. Add a recovery check before the if/else-if chain: when the captured `last` says we applied playing, the engine is currently not playing, and the host is still playing the same track, reset `lastAppliedRef.current = null`. The next iteration of the chain re-runs initial-sync (with the 500 ms fast-poll cadence) which fires a fresh `playTrack`. If `audio_play` succeeds the second time, audio finally starts; if it keeps failing, we loop with no extra harm (audio was already silent). Adds an `engine-recovery` event scope to the diagnostics buffer so future captures can see when this re-sync fired. * docs(changelog): expand engine-recovery bullet to cover both cases |
||
|
|
af1b9661f5 |
fix(orbit): three interlocking guest playback bugs (#525)
* fix(orbit): guest short-circuits queue-exhaustion fallback paths
When a guest's local queue runs out (single-track queue from `syncToHost`
empties on `audio:ended`), the player walks the standard fallback chain
in `next()`: radio top-up → infinite-queue → stop. The infinite-queue
branch builds a 6-track queue and calls `playTrack`, which trips
`orbitBulkGuard` and pops a "Add 6 tracks to the Orbit queue?" modal.
Hitting Cancel leaves playback frozen; "Add them all" injects unrelated
tracks into the host's shared queue.
In an active Orbit guest session the host owns the queue. Skip the
fallback paths entirely and just stop — the next `useOrbitGuest` pull
tick will sync to whatever the host advanced to.
Bonus side-effect: kills the deferred-promise race where a
`buildInfiniteQueueCandidates().then(...)` from a guest's track end
could resolve *after* a Catch Up replaced the queue and pop the modal
a second time against the now-current 1-track queue.
* fix(orbit): treat natural track-end as not-diverged in guest sync
When a guest's track ended naturally before the host advanced, the
divergence-detection branch read `player.isPlaying === false` and
classified it as the user manually paused — so it refused to load the
host's next track. The guest sat silent until they clicked Catch Up.
`handleAudioEnded` keeps `currentTrack` pinned to the just-ended track
and resets `currentTime` to 0, while a real manual pause leaves
`currentTime` somewhere mid-track. Use the 0-position discriminator to
classify natural-end as not-diverged so the host's new track loads.
Confirmed via the captured guest log buffer:
18:43:08.598 [track-change] host: VJkV5… → 6i6RP… BUT guest diverged
(player.isPlaying=false ≠ last.isPlaying=true)
— guest stuck for ~33s until Catch Up was pressed.
* fix(orbit): Catch Up polls until engine is ready before seeking
The 400 ms blind setTimeout in `onCatchUp` was too short for an
HTTP-streamed cold-start on high-latency links. If the audio engine
wasn't ready by then, `seek(fraction)` silently no-oped and playback
started at 0:00, making Catch Up effectively useless on exactly the
slow links where it's needed. Captured log shows a Catch Up bringing
the guest to posSec=30, then 5 s later the guest was at posSec=6
(playback restarted from the head).
Replace with the same poll-until-ready pattern `syncToHost` already
uses: check every 100 ms, fire the seek as soon as the engine reports
playing, fall back to a blind apply at the 4 s deadline.
* docs(changelog): add orbit guest playback fixes entry
* fix(orbit): debounce Catch Up button + match bar item height
Two follow-on UX fixes after PR #525's three primary bugs landed:
1. **Debounce visibility.** Drift is computed from an asymmetric signal:
guest's `currentTime` updates in coarse ~5 s chunks, while host's
position is extrapolated linearly via `(nowMs - posAt)`. Even on a
perfectly-synced session the diff swings ±5 s every tick, so the
button flickered in and out continuously. Show only after drift has
stayed over the 3 s threshold for ≥ 3 s of wall clock — measurement
noise is filtered out, real sustained drift still surfaces in time.
2. **Match neighbour height.** The button was 32 px tall against 26 px
for the other action buttons (.orbit-bar__settings) so every flicker
shifted the entire bar height. Set `height: 26 px` and tighten the
padding/font so the layout is stable regardless of visibility.
* fix(orbit): tighten queue-extension lockout + reliable initial-sync seek
Two follow-on fixes after the 4-bug umbrella:
**1. Local queue-extension paths fully off during Orbit.**
Phase check broadened from `active` to cover `starting` / `joining` /
`active` so a fetch-then-join race can't pop the bulk-add modal *after*
the join. The proactive infinite-queue topper inside `next()` (which
fires when ≤ 2 auto-tracks remain ahead) is now also gated, plus each
async `.then()` callback in the radio + infinite-queue paths re-checks
at resolution time. A `playTrack(... 6-track queue ...)` after the user
joined Orbit was the path that re-triggered the "Add 5 tracks?" modal
on a freshly-joined guest.
**2. `syncToHost` only seeks once the engine reports playing.**
The previous 2 s deadline-fallback applied the seek even when the
engine hadn't started, where the seek silently no-ops and the track
plays from 0:00. Symptom: clicking Catch Up makes the song "jump 50 %
forward" — that's the seek finally landing because the engine is now
ready, the initial-sync seek had already failed silently. New deadline
is 5 s, and on timeout we return `false` so the outer pull tick keeps
`lastAppliedRef` null and the 500 ms fast-poll retries.
* fix(orbit): double-click play button + hide preview during session
Two cucadmuh-flagged gaps:
**1. Double-click on the inline play button now reaches the orbit-add
path.** The album-track row's onDoubleClick already routes to
`addTrackToOrbit` when in Orbit, but the inline play button stopped
propagation on click — so clicking it twice just fired the "double-
click to add" hint toast and never touched the orbit queue. Add an
onDoubleClick on the button itself that delegates to the parent's
`onDoubleClickSong`.
**2. Track preview is suppressed during an Orbit session.** Preview
shares the Rust audio engine with the shared playback, so starting
one as a guest yanks the host's track out from under everyone. A new
`[data-orbit-active]` attribute on `<html>` (set whenever role is
host/guest and phase is starting/joining/active) hides every
preview button via a single CSS rule, and `previewStore.startPreview`
short-circuits as a defensive guard for keyboard shortcuts and any
programmatic callers.
|
||
|
|
a702a5dd5b |
feat(orbit): in-app diagnostics popover with copyable event log (#524)
* feat(orbit): in-app diagnostics popover with copyable event log Multiple users on Discord report Orbit guests stopping after the first song with no errors anywhere — Settings → Debug → Export Logs is too buried for non-technical reporters, and the relevant code branches have no logging at all (silent fail). This adds a one-click "Copy log" path right inside the Orbit session bar. The new Activity-icon button next to Help opens a popover with: - Live mini-display: role, host vs. guest track id + position, drift, age of the host's last state write — all updating once a second. - Scrolling event log textarea fed by an in-memory ring (200 events). - Copy + Clear buttons. Copy formats `[ISO] [scope] body` lines and drops them on the clipboard — paste straight into a Discord report. Instrumentation lands at the previously-silent decision points: - Guest pull tick: full snapshot of host vs. guest state on every read. - Each branch of the divergence detection in `useOrbitGuest.ts` logs which path it took and why (initial / track-change-followed / track-change-diverged / play-pause-flip), making the "stuck after first song" symptom diagnosable from the buffer alone. - Host pushes log track id, isPlaying, queue length, guest count. Events are also bridged to the existing `frontend_debug_log` Tauri command when Settings → Logging is on Debug, so power users still get the same data in `psysonic-logs-*.log` for offline triage. i18n: full `orbit.diag.*` namespace in all eight locales. EN + DE are native; ES / FR / NB / NL / RU / ZH are first-pass and may want a polish from native speakers later. * docs(changelog): add orbit diagnostics popover entry |
||
|
|
874b0c67ae |
fix(context-menu): drop inline z-index that hid menus under floating player (#522)
* fix(context-menu): drop inline z-index that hid menus under floating player
The main context menu wrapper carried an inline `zIndex: 999` that
overrode the `.context-menu { z-index: 10000 }` stylesheet rule. The
floating player bar sits at z-index 1000, so when a menu opened near
the bottom of the screen — long enough or anchored low — the player
bar covered it.
Removing the inline override lets the stylesheet rule (10000) win, so
the menu always paints on top of the floating bar. Submenus inherit
the same stacking context so they follow the parent menu.
Closes #521.
* docs(changelog): add context-menu floating-player z-index fix entry
|
||
|
|
fec513b629 |
fix(home): swap Because-you-listened rail to AlbumRow under 696 px (#520)
* fix(home): swap Because-you-listened rail to AlbumRow under 696 px The hero-style BecauseCards are tuned for full-rail widths (3 cards at 1052 px+, 2 cards at 696-1051 px). Below that the cards stretched full-width with a fixed 160 px cover stuck on the left and centred text floating in a wide empty area — looked like three over-sized banners stacked vertically instead of a compact recommendation rail. A `ResizeObserver` on the rail wrapper now watches the container width and below 696 px renders a standard `AlbumRow` (which is already perf-tuned for narrow rails: artwork budget, viewport windowing, scroll paging). Wide layouts keep the unchanged hero card layout, so the mainstage view at full width is identical to before. * docs(changelog): add Because-you-listened narrow-layout fix entry |
||
|
|
8b781a848d |
refactor(i18n): show language names as endonyms in picker (#514)
* refactor(i18n): show language names as endonyms in picker * docs(changelog): add language-endonyms entry for #514 |
||
|
|
c982362884 |
fix(i18n): OpenDyslexic font supports Cyrillic (#513)
* fix(i18n): OpenDyslexic font supports Cyrillic The OpenDyslexic subtitle in the font picker stated "no RU/ZH support", but the bundled `@fontsource/opendyslexic` 5.x ships Cyrillic glyphs and Russian renders correctly — verified empirically. Only Chinese (CJK) actually falls back to the system font. Updated the subtitle string in all 8 locales accordingly; the Russian locale itself no longer claims unsupported-self. * docs(changelog): correct OpenDyslexic locale-coverage claim (#513) The #507 entry in the unreleased v1.46.0 block claimed `Latin + Latin-extended only` and listed Cyrillic among the system-font fallbacks. Cyrillic glyphs ship in @fontsource/opendyslexic 5.x and render correctly — only Chinese (CJK) actually falls back. Updated the bullet in place to match the corrected subtitle hints from this PR. |
||
|
|
6f50fb6a19 |
feat(player-bar): album context menu on song title right-click (#512)
* feat(player-bar): album context menu on song title right-click Right-clicking the track title in the player bar now opens the same album context menu that album cards use (open, play next, enqueue, go to artist, favorite, rate, share, download, add to playlist). Mirrors the existing left-click behavior on the title, which already navigates to the album. Suppressed for radio and preview, matching the click handler. MarqueeText gains an optional onContextMenu prop; PlayerBar builds a SubsonicAlbum shape from currentTrack on demand. * docs(changelog): add entry for PR #512 (player-bar title context menu) |
||
|
|
57fe847d71 |
refactor(settings): collapse all sections, drop font dropdown, surface OpenDyslexic (#508)
* refactor(settings): collapse all sections, drop font dropdown, surface OpenDyslexic Settings opened on a tab where four or five sub-sections were expanded on first render — audio device, theme list, lyrics sources, sidebar customizer, random-mix copy, offline dir, language picker, keybindings table. The page felt like a wall of controls before the user had even looked for something specific. Removed every `defaultOpen` flag from the SettingsSubSection call sites so each tab now boots with only the section headers visible. Component default was already `false`. ThemePicker auto-expanded the group containing the active theme on mount. Same noise on a screen that already has the longest accordion list in the app, and the blue dot in the group header already tells the user which group holds the active theme. Initial open-group is now `null` — all groups collapsed until the user clicks one. Font picker had a dropdown-style button that toggled a list inside the sub-section, which meant two clicks (open the section, then open the dropdown) for what should be a one-click choice. Removed the button + the `fontPickerOpen` state — opening the Font sub-section now reveals the full list directly and a click sets the font without collapsing anything. OpenDyslexic moved to the top of the list so users with dyslexia don't scroll past 14 sans-serifs to find their option; the rest stays in the original order. * docs: changelog entry for PR #508 Logs the Settings collapse-by-default + font picker cleanup + OpenDyslexic ordering in v1.46.0 "## Changed". |
||
|
|
f520f7951a |
feat(settings): OpenDyslexic font option for dyslexic readers (#507)
* feat(settings): OpenDyslexic font option for dyslexic readers Next step on the accessibility track. The first pass was on the colour side — WCAG contrast audits across every theme and dedicated colour- vision-deficiency variants for the protanopia / deuteranopia / tritan- opia palettes. Typography is the other axis: some users with dyslexia find a font with a heavier weighted baseline and asymmetric glyph shapes (b/d, p/q never mirror, italic forms differentiated rather than slanted-regular) easier to track than a typical sans. Adds OpenDyslexic to the existing Fontsource font picker. SIL OFL licensed, freely redistributable, and the de-facto open-source standard for this use case. Non-variable axis, ships as four discrete weight/style files (regular, bold, italic, bold-italic) — the Settings picker grew an optional `hint` field on font entries so this one row can carry a "dyslexia-friendly · no RU/ZH support" subtitle without bloating the other 14 entries. Latin + Latin-extended only. Cyrillic and CJK locales (RU, ZH) fall back to the system font when this is selected; the subtitle calls out that limitation upfront. i18n: hint string in all 8 locales (settings.fontHintOpenDyslexic). Accessibility is intentional product positioning here — it's an underserved corner of the Subsonic-client ecosystem. * chore(nix): sync npmDepsHash with package-lock.json * docs: changelog entry for PR #507 Logs the OpenDyslexic font option in v1.46.0 "## Added". * docs(settings): contributor entry for PR #507 Adds the OpenDyslexic accessibility bullet to Psychotoxical's contributions list. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> |
||
|
|
bd56177e2c |
feat(home): Lossless Albums rail + dedicated page + sidebar nav (#506)
* wip(home): Lossless rail + dedicated /lossless-albums page Rail under Home > mostPlayed and a dedicated infinite-scroll page that list albums whose tracks are tagged in lossless containers. Walks Navidrome's native /api/song?_sort=bit_depth&_order=DESC, dedupes by albumId on the way down, stops when the song stream crosses into lossy (bitDepth==0) or the server runs out of rows. _filters has no operators on quality columns, so a sort + walk is the only path; equality on sample_rate / bit_depth probes returned empty (verified). The page paginates through the song-cursor with an in-flight ref so overlapping IntersectionObserver fires don't double-add albums, plus a cancelled flag so React StrictMode's double-mount doesn't apply two parallel result sets in dev. Suffix allowlist excludes ambiguous wrappers — m4a/m4b can be ALAC *or* AAC and Navidrome's response carries an empty codec field, so we can't tell them apart; same story for wma. Allowlist is flac, wav, aiff/aif, dsf/dff, ape, wv, shn, tta — containers that are only lossless. ALAC-in-m4a setups will miss out, acceptable trade-off without a reliable codec field. Status: WIP. Settings home-customizer label and en.ts strings landed, no other locales yet, no quality badge on AlbumCards across the rest of the app, no CHANGELOG. Branch was 'feat/home-hires-rail' during the earlier hi-res-only iteration before the lossless broadening. * wip(home): Lossless page header parity + streaming + sidebar nav Page header now mirrors All Albums: selection mode with three action buttons (Enqueue Selected, Add Offline, Download ZIPs) wired to the same handlers Albums.tsx uses, selection-counter title swap, and the perfFlags.disableMainstageStickyHeader respect path. Filters were intentionally skipped — the rail is sorted by bit_depth, mixing client- side filters with server-driven pagination would produce gaps. Loading feels noticeably faster: ndListLosslessAlbumsPage takes an optional onProgress callback that fires once per internal fetch with the entries discovered in that fetch, so the page can stream new albums into state instead of waiting on the whole loadMore. Page-side budget dropped from 5×200 to 2×100 songs per loadMore (~1 MB worst case vs 5 MB before), since the rail's catch-em-all pass is wrong for infinite-scroll UX. Subtitle under the page title primes the user that this is slower than other album pages because Psysonic walks the song catalog by quality (Navidrome ignores _fields, so per-song responses ship with lyrics + tags + participants whether we want them or not). Sidebar nav entry registered under 'losslessAlbums' with a Gem icon, defaults to visible:false (matches composers / folderBrowser / deviceSync — niche browsing modes). Existing users get the entry appended at the end of their persisted sidebar list automatically via the onRehydrateStorage merge that sidebarStore already runs for new DEFAULT_SIDEBAR_ITEMS. i18n: full coverage across all 8 locales for sidebar.losslessAlbums, home.losslessAlbums, losslessAlbums.empty, losslessAlbums.unsupported and the new losslessAlbums.slowFetchHint subtitle. ru/zh are machine-translation quality, flagged for a polish pass. * feat(home): default Lossless sidebar entry to visible Flips the DEFAULT_SIDEBAR_ITEMS entry for `losslessAlbums` from false to true. Existing installs keep whatever the user has in persisted storage; fresh installs see the entry in the sidebar from the start. Earlier wip commit defaulted it off (matched composers / folderBrowser / deviceSync as a niche browse mode), but the rail + page do show something useful for any library with at least one FLAC/WAV/etc. album, so off-by-default just hid the feature. * docs: changelog entry for PR #506 Logs the Lossless Albums rail + page + sidebar entry in v1.46.0 "## Added". * docs(settings): contributor entry for PR #506 Adds the Lossless Albums rail/page bullet to Psychotoxical's contributions list. |
||
|
|
a41e3a624a |
feat(song-info): show absolute file path on Navidrome via native API (#504)
* feat(song-info): show absolute file path on Navidrome via native API
Subsonic's `getSong.view` returns at most a relative path (or none on
Navidrome), so the Path row in the Song Info modal stayed empty for
most users. Feishin and the Navidrome web client surface the full
server-side path by hitting Navidrome's native `/api/song/{id}` instead.
Added an `nd_get_song_path` Tauri command that logs in to the native
API with the active server's credentials, fetches the song, and returns
the `path` string. Wired it into `SongInfoModal`: the Subsonic
`getSong` call still drives the rest of the dialog, and the native call
runs in parallel only when the active server's identity is
"navidrome". When it returns a path, that absolute path replaces the
relative Subsonic value; native-API failures are silent and the modal
falls back to whatever Subsonic provided.
No token cache yet — the modal is opened occasionally enough that one
fresh login per call is fine.
Closes discussion #479.
* docs: changelog entry for PR #504
Logs the Navidrome native-API absolute file path support for the
Song Info dialog in v1.46.0 "## Added".
* docs: settings contributor entry for PR #504, drop competitor mention
Adds the song-info absolute path bullet to Psychotoxical's contributors
list in Settings, and rewords the existing CHANGELOG entry so neither
text references competing clients.
|
||
|
|
726f3f0ff2 |
fix(radio): queue navigation, dedup, and similar-first variety (#500) (#503)
* fix(radio): queue navigation, dedup, and similar-first variety (#500) After a Radio session ran a while, three things broke: Queue navigation through duplicates. playTrack re-resolved the active queue index by `findIndex(t.id === track.id)`, returning the *first* matching id, so reaching the second occurrence of a track snapped queueIndex back to the earlier slot — highlight jumped and the next advance played the wrong follow-up. Added an optional `targetQueueIndex` to playTrack, threaded through next(), previous(), the audio:ended repeat-one path, queue-row click, and the queue-item context menu. findIndex stays as the fallback for callers that just have a track and a fresh queue. Queue accumulation. enqueueRadio didn't dedupe incoming tracks; the next() top-up deduped against the live queue but trimmed the played tail down to HISTORY_KEEP=5, so a song heard 8 ago was gone from `existingIds` and a later Last.fm/topSongs response could re-add it; and the `.filter(...)` pass admitted intra-batch repeats (top + similar overlap is common) because it read the dedup set before mutating it. A module-level radioSessionSeenIds set, fed by enqueueRadio and both top-up paths and reset on artist change and clearQueue, closes all three: trimmed ids stay in the set, ids about to be replaced (fresh enqueueRadio wiping the pending radio block) are removed first so callers can re-introduce them, and the dedup pass mutates the set inline. Variety. Starting Radio on a track stacked five top tracks of the seed artist before any similar-artist material played. Switched the seed path and both top-up paths to lead with similar songs (other artists) and only fall back to top tracks when similar comes back empty — preserves the "no Last.fm" graceful degradation but stops the seed artist from monopolising the front of the queue. Not affected: gapless audio:track_switched (already index-based, no findIndex), AudioMuse Instant Mix / Lucky Mix (single-element queues or enqueue-only paths), the artist-radio path (no seedTrack — already picks just one top track and fills the rest from similar). Reported by netherguy4. * docs: changelog entry for PR #503 Logs the radio queue navigation/dedup/similar-first fix in v1.46.0 "## Fixed". |
||
|
|
d7ff1d3113 |
fix(preview): keep preview sink volume in sync with player slider (#498) (#502)
* fix(preview): keep preview sink volume in sync with player slider (#498) The Rust preview sink had its volume set once at audio_preview_play and then never updated. audio_set_volume only ramps the main sink, so slider movements during a preview had zero effect on the preview level. With the default loudness normalization (-4.5 dB pre-analysis attenuation) applied at start, even a 100% slider gives 1.0 × 0.596 × MASTER_HEADROOM ≈ 53% — matching the user-visible "fixed at around 50%" symptom. - Add audio_preview_set_volume Rust command that updates the preview sink if one is active (clamp + master headroom mirror the path used in audio_preview_play). - Extract the preview-volume calculation in previewStore into computePreviewVolume() so startPreview and the new sync path share one formula (slider value, plus the LUFS pre-analysis attenuation the engine already applies to the main sink). - Subscribe to playerStore at module level: when volume changes and a preview is active, push the recomputed value to Rust. Auth / normalization tweaks during preview are intentionally not synced — preview is short and that case is rare. Reported by netherguy4. * docs: changelog entry for PR #502 Logs the preview-volume-slider sync fix in v1.46.0 "## Fixed". |
||
|
|
ddb1f29af9 |
refactor(settings): remove redundant Animations 3-state setting (#495)
* refactor(settings): remove redundant Animations 3-state setting under Seekbar Style The `animationMode` setting (Full / Reduced / Static) duplicated work the perf-flag system and OS-level reduced-motion preference already covered: - `perfFlags.disableMarqueeScroll` already kills marquee scrolling on demand, replacing what `static` mode used to gate. - The `data-perf-disable-animations` html-level switch already strips every `*` animation, replacing what `static` mode used to do globally. - `@media (prefers-reduced-motion: reduce)` honours the OS setting for every user that asked for it via system preferences. - The 30 fps cap that `reduced` mode applied to the seekbar wave was better served by per-feature perf toggles cucadmuh added later. Removed: - `AnimationMode` type, `animationMode` field + setter from auth store. - Settings UI block (3 buttons + hint text) under Appearance > Seekbar Style. - `animationMode === 'static'` short-circuit in WaveformSeek's rAF effect; `isReduced` skip-every-other-frame logic; `static`-checks in `drawNow` / `needsDirectDraw`. - `animationMode !== 'static'` guard and `data-anim-mode` attribute in MarqueeText. - `[data-anim-mode="static"]` and `[data-anim-mode="reduced"]` rules in layout.css. - Seven i18n keys (animationMode + 6 variants) across all eight locales. Migration: the persist layer strips `animationMode` (and the legacy `reducedAnimations` boolean predecessor) so anyone who had `'reduced'` or `'static'` selected silently lands on the former `'full'` path on first launch after upgrade. No user-facing prompt — the missing setting just stops existing. cucadmuh's PR #472 (FPS overlay), #476 (preview-freeze main seekbar, sleep-recovery hooks, card-hover removal) and #486 (interpolation anchor reset on resume) are all preserved untouched — they live in separate effects / files and were not driven by `animationMode`. * docs(changelog): add Removed section for animationMode setting (PR #495) * docs(changelog): refine animationMode removal rationale (drop prefers-reduced-motion overstatement) |
||
|
|
ba73649360 |
fix(home): more variety + reliable render in Because-you-listened rail (#494)
Two reports rolled into one pass: 1. Rail occasionally rendered nothing on Home open. Cause: the rotated anchor sometimes had no Last.fm similar-artists or no library matches among the sampled set, and the old code gave up after one try and stored that dud anchor as the rotation cursor — so the next mount started from the same dud's neighbour and could fail again. 2. Recommendations felt repetitive. Cause: pool of 12 anchors walked round-robin made each anchor recur every 12 mounts, similar-artist sample of 6 from 12 had heavy overlap visit-to-visit, and per-artist single-album random pick meant artists with one library album always surfaced the same record. Anchor selection: random pick from pool with a per-server cooldown buffer (last 5 anchors excluded, capped at floor(pool/2) so small libraries don't soft-lock). Up to 4 anchors are tried in a shuffled candidates list before giving up; the localStorage cursor only advances on a successful anchor so duds don't poison future mounts. Picks variety: similar-artist fetch raised from 12 to 25 (same getArtistInfo call, larger response — Last.fm typically returns up to ~50). Per-server ring buffer of the last 30 shown album ids; per-similar -artist album choice prefers an album not in that buffer, falling back to any album when the artist's whole catalogue is stale so the slot is never lost. Pool cap raised 12 -> 20 to give the cooldown buffer room to breathe in libraries with varied listening history. Storage: legacy `psysonic_because_anchor:` single-id keys from the round-robin era are stripped on module load (one-shot localStorage sweep, the new `..._anchor_history:` prefix has a different colon position so no false matches). API budget unchanged in the hot path: 1 getArtistInfo + 6 getArtist per Home mount. Worst case (3 dud anchors, 4th succeeds) is 4 getArtistInfo + 6 getArtist. |
||
|
|
d75670ec4b |
feat(home): broaden Because-you-like seed pool + tidy orphan card at 1080p (#493)
* feat(home): mix recently-played + starred into Because-you-like anchor pool Anchor pool was sourced only from getAlbumList(frequent), so the rotation cursor walked the same eight top-played artists no matter how varied the rest of the listening history was. Round-robin merge of mostPlayed, recentlyPlayed and starred (dedup by artistId) means each mount can land on a different listening *mode* — heavy rotation, current focus, or explicit favorites — instead of stepping through the same top-played sequence. Pool size 8 -> 12 to let the cursor visit all three modes before wrapping. Visibility guard widened so the rail still renders when the server has no frequent-play data yet but starred or recent items exist. Zero new API calls — all three lists are already in Home's initial fetch. * fix(home): drop orphan 3rd Because-card in 2-col range, keep all 3 stacked on mobile auto-fit grid wraps to 2 cols between 696-1051px container width, which left the third card alone on a second row at 1080p. Container query hides the 3rd card only inside that 2-col band; on wider screens the full 3-up row stays, on narrow viewports (single column) all three cards stack vertically as expected. * docs(changelog): Because-you-listened seed pool + 1080p layout polish (PR #493) * docs(changelog): fold PR #493 refinements into the existing Because-you-listened entry Drop the separate Changed section entry — the feature is in the same 1.46.0 release window as PR #489, so readers want a single description of the final behaviour, not "added X, then changed X" for the same release. PR reference becomes "PRs #489, #493". |