Commit Graph

1152 Commits

Author SHA1 Message Date
Frank Stellmacher 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.
2026-05-12 12:07:30 +02:00
Frank Stellmacher 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.
2026-05-12 11:58:17 +02:00
Frank Stellmacher 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.
2026-05-12 11:45:09 +02:00
Frank Stellmacher 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.
2026-05-12 11:25:26 +02:00
Frank Stellmacher 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.
2026-05-12 11:08:21 +02:00
cucadmuh f0971d5108 docs(contributing): expand contributor guide structure and checks (#564)
Add quick start, repository layout, explicit main vs promotion branches,
Tauri boundary as its own section, security handling, Nix/Cachix and
non-Linux setup notes, lint/format reality (tsc + clippy), i18n file
locations, consolidated hot-path gate wording, and clearer local
coverage reproduction steps. Remove redundant summary block.
2026-05-12 11:08:01 +02:00
cucadmuh 558abba6af docs: add CONTRIBUTING.md for contributors (#562)
Document where to ask questions, local commands that mirror CI, PR
expectations, caution around disruptive UI, stability of the Tauri
Rust-frontend contract, and impact of on-disk settings changes.
Link the guide from the README Development section.
2026-05-12 10:56:00 +02:00
Frank Stellmacher 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).
2026-05-12 10:41:28 +02:00
Frank Stellmacher 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).
2026-05-12 10:29:22 +02:00
Frank Stellmacher 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).
2026-05-12 10:13:27 +02:00
Frank Stellmacher 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.
2026-05-12 02:08:39 +02:00
Frank Stellmacher 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.
2026-05-12 01:39:39 +02:00
Frank Stellmacher 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).
2026-05-12 01:24:04 +02:00
Frank Stellmacher 6afbdf9c60 chore(test): activate aggregate vitest coverage thresholds (#553)
Replaces 0/0/0/0 placeholders with a measured floor (~1pp under current
state) so the suite blocks regressions across the whole tree. Per-file
hot-path gate keeps doing the heavy lifting on critical files.
2026-05-12 01:00:56 +02:00
Frank Stellmacher 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).
2026-05-11 23:48:25 +02:00
Frank Stellmacher 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).
2026-05-11 23:41:08 +02:00
Frank Stellmacher 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.
2026-05-11 23:24:40 +02:00
Frank Stellmacher 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).
2026-05-11 23:16:08 +02:00
Frank Stellmacher 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).
2026-05-11 23:07:58 +02:00
Frank Stellmacher 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.
2026-05-11 22:57:26 +02:00
Frank Stellmacher 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.
2026-05-11 22:51:29 +02:00
Frank Stellmacher 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.
2026-05-11 22:30:45 +02:00
Frank Stellmacher 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.
2026-05-11 22:14:57 +02:00
Frank Stellmacher 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.
2026-05-11 21:56:24 +02:00
Frank Stellmacher 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%.
2026-05-11 21:40:47 +02:00
Frank Stellmacher 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.
2026-05-11 21:11:23 +02:00
Frank Stellmacher 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
2026-05-11 16:57:54 +02:00
Frank Stellmacher 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)
2026-05-11 13:34:02 +02:00
Kveld. 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>
2026-05-11 12:35:38 +02:00
Frank Stellmacher 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>
2026-05-11 12:25:48 +02:00
cucadmuh 1cc43dc669 fix(dev): support local Rust coverage checks (#535)
Add the coverage and lint tools to the Nix dev shell so local pre-PR checks can match CI, and make the hot-path coverage gate locale-stable.
2026-05-11 00:50:46 +03:00
Frank Stellmacher 7c32172d5d test: cargo-test workspace bootstrap + hot-path file coverage gate (#533)
* test(workspace): bootstrap cargo test infrastructure

- Add [workspace.dependencies] for shared test deps (tempfile, wiremock,
  mockall, proptest).
- Wire psysonic-syncfs dev-dependency on tempfile.
- Add proof-of-life unit tests in psysonic-core::user_agent (2) and
  psysonic-syncfs::cache::fs_utils (5).
- Add dedicated rust-tests.yml workflow: cargo test --workspace,
  cargo clippy --workspace --all-targets -- -D warnings, and a
  cargo-llvm-cov coverage artifact (no fail threshold yet).

Phase A of the 3-sprint test rollout. cargo test --workspace runs 7/7 green.

* chore(clippy): satisfy `cargo clippy --workspace --all-targets -- -D warnings`

Pre-existing lints exposed by the new CI gate. All mechanical, no
behavior changes:

- `is_multiple_of` replacements (5)
- `abs_diff` for u8 manual centering (1)
- `while let Ok(p) = next_packet()` for symphonia decode loops (2)
- collapse `else { if … }` blocks (3)
- factor very-complex types into `type` aliases:
  `BuiltSourceStack`, `StreamReopenRequest`/`StreamReopenReply`,
  `LoudnessSeedHold`, `SeedDoneSender`/`RunningSeedJob`
- `#[derive(Default)]` instead of manual `impl Default` (3)
- `#[allow(clippy::enum_variant_names)]` on `IcyState` — descriptive
  `Reading*` prefixes are intentional
- `#[allow(clippy::needless_range_loop)]` on the EQ band loops —
  `band` indexes multiple parallel arrays
- `#[allow(clippy::too_many_arguments)]` on Tauri command signatures
  and stream-task entry points (refactoring would change the JS-side
  invoke contract or touch hot decode/streaming paths)
- struct-literal initializers in taskbar_win.rs (windows-only)
- `strip_prefix`, useless `format!`, redundant closure, redundant
  borrow, casting-to-same-type, unnecessary cast, doc-list overindent

* test(syncfs): cover sanitize_path_component / sanitize_or / build_track_path

Sprint 1.1 of the Rust test rollout. 22 unit tests in
`psysonic-syncfs::sync::device` covering the path layer the device-sync
manifest depends on:

- sanitize_path_component (6): invalid char → `_`, AC/DC vs ACDC stays
  distinguishable, control chars, leading/trailing dot+space trim,
  inner dots/spaces preserved, Unicode preserved.
- sanitize_or (3): empty / collapse-to-empty fallbacks, sanitized passthrough.
- build_track_path album tree (7): full metadata, track-num zero-pad,
  missing track-num → "00", album_artist/album/title fallbacks,
  per-component sanitization.
- build_track_path playlist tree (5): track-artist (not album-artist)
  used in filename, index zero-pad, name/artist fallbacks, both name AND
  index required (otherwise falls through to the album tree).
- Cross-OS separator (1): `\` on Windows, `/` elsewhere.

Workspace test count: 7 → 29. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(analysis): cover analysis_cache::store with in-memory SQLite roundtrips

Sprint 1.2 of the Rust test rollout. 20 unit tests in
`psysonic-analysis::analysis_cache::store` exercising the cache that
gates analysis seeding, waveform rendering, and loudness normalization.

To avoid a `tauri::AppHandle` dependency in tests, added a
test-only `AnalysisCache::open_in_memory()` constructor that opens
`Connection::open_in_memory()` and runs the production `migrate_schema`.
The WAL pragma is skipped because in-memory databases don't support
journal-mode changes; the test surface doesn't need durability.

- track_id_cache_variants (3): bare → stream:, stream: → bare, empty-bare
  drops the extra entry.
- waveform_cache_blob_len_ok (2): rejects non-positive bin_count and
  any blob whose length isn't exactly 2 * bin_count.
- schema (1): all three tables created by migrate_schema.
- Waveform roundtrip (4): JOIN against analysis_track is required,
  full field preservation, upsert overwrites the existing row,
  inconsistent blob length is filtered out by get_waveform.
- Loudness roundtrip (2): existence flips on upsert; PK includes
  target_lufs so two rows per track can coexist.
- Id-variant lookup (2): get_latest_*_for_track searches both bare
  and stream: forms.
- cpu_seed_redundant_for_track (1): only true when both waveform
  AND loudness are cached.
- Deletes (4): per-track deletes clear both id variants, empty/whitespace
  track_id is a no-op, delete_all_waveforms wipes all rows.
- Status upsert (1): touch_track_status overwrites status on conflict.

Workspace test count: 29 -> 49. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(audio): cover pure helpers in psysonic-audio::helpers

Sprint 1.3 of the Rust test rollout. 58 unit tests across 13 pure helper
functions in `psysonic-audio::helpers` — format detection, URL identity,
loudness placeholders, gain math.

Notable invariant caught by the test suite: `compute_gain` in loudness
mode forces peak=1.0, so the `gain_linear.min(1.0 / peak)` step caps
positive loudness gain at unity. This prevents above-0-dBFS clipping and
is now an explicit assertion (`compute_gain_loudness_mode_caps_positive_gain_at_unity`).
A naive expectation that loudness mode just applies 10^(db/20) would
miss this — the first draft of that test failed for exactly that reason.

Coverage:

- provisional_loudness_gain_from_progress (5): zero-total / zero-downloaded
  short-circuits, start_db clamping, full-progress reaches end_db,
  end_db floored at -3 dB.
- content_type_to_hint (3): common MIMEs, case-insensitive, unknown.
- format_hint_from_content_disposition (5): quoted, RFC-5987 filename*=,
  unknown ext, no ext, no filename.
- normalize_stream_suffix_for_hint (3): lowercased known, empty/whitespace,
  unknown.
- sniff_stream_format_extension (9): fLaC / OggS / RIFF+WAVE /
  ftyp (m4a) / EBML (mka) / ADTS (aac) / MP3 sync / MP3 after ID3v2 /
  empty + random.
- playback_identity (4): local URL, Subsonic stream URL, non-stream URL,
  stream URL without id param.
- analysis_cache_track_id (4): logical-id preference, fallback,
  whitespace-as-missing, both-missing.
- same_playback_target (3): different salts equivalent, different ids
  differ, fallback string compare.
- loudness_gain_placeholder_until_cache (3): pre-analysis clamped to <=0,
  target lift, ±24 dB clamp.
- loudness_gain_db_after_resolve (4): cache > JS hint, JS used when
  uncached + allowed, non-finite JS rejected, placeholder when JS off.
- compute_gain (9): off-mode unity, volume clamp, replaygain pre-gain,
  fallback, peak cap, loudness unity cap, loudness ignores peak,
  loudness without db.
- normalization_engine_name (2): mapping + fallback.
- gain_linear_to_db (4): unity, half, zero/negative, non-finite.

Workspace test count: 49 -> 107. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-1): top up to gate B with pure helpers + queue states

Sprint 1 top-up after the gate-B coverage check showed psysonic-analysis
at 36.2% and psysonic-syncfs at 17.7%. Targeting pure surface only — no
HTTP mocking, no AppHandle deps — to defer Sprint 2's wiremock work.

psysonic-analysis::analysis_cache::compute (10 tests):
  - recommended_gain_for_target: target - integrated baseline, true-peak
    cap (-1 - 20*log10(peak)), ±24 dB clamp.
  - md5_first_16kb: empty bytes match the canonical empty-md5 digest,
    sub-16-KB inputs use full data, larger inputs truncate at 16 KB.
  - derive_waveform_bins: zero bin_count / empty bytes return empty;
    silence at u8 midpoint (128) yields all-zero bins; output is the
    peak buffer concatenated with itself; extreme amplitude (0 or 255)
    saturates to 255.
  - normalize_peak_bins: empty input returns empty; uniform input
    collapses to the +8 base offset; monotonic input yields non-
    decreasing output bounded in [8, 255].

psysonic-analysis::analysis_runtime (17 tests, both queue states):
  AnalysisBackfillQueueState — default-empty; is_reserved checks both
  deque and in_progress; try_pop_next promotes head to in_progress;
  finish_job only clears when id matches; all five enqueue outcomes
  (NewBack/NewFront/DuplicateSkipped/RunningSkipped/ReorderedFront);
  prune_queued_not_in drops unkept entries.

  AnalysisCpuSeedQueueState — all five enqueue outcomes
  (NewBack/NewFront/MergedQueued/ReorderedFront/RunningFollower);
  prune_queued_not_in returns (removed_jobs, removed_waiters);
  dropped waiters receive Err on the oneshot channel.

  Two backfill tests use struct-literal initialisers with
  ..Default::default() to satisfy clippy::field_reassign_with_default.

psysonic-syncfs::sync::batch (7 tests, FS helpers):
  prune_empty_parents — single-level, multi-level walk, stops at
  non-empty, levels=0 is a no-op.
  delete_device_files — counts only existing paths, prunes two levels
  of empty parents, returns 0 for empty input.

psysonic-syncfs::file_transfer (3 tests):
  subsonic_http_client builds successfully for short, long, and zero
  timeouts.

Added `tokio = { ..., features = ["macros", "fs"] }` to
psysonic-syncfs/Cargo.toml [dev-dependencies] so tests can use
#[tokio::test].

Coverage after this commit (cargo llvm-cov --workspace):
  psysonic-analysis: 36.2% -> 54.2% (gate B >=40% ✓)
  psysonic-syncfs:   17.7% -> 25.8% (gate B deferred to Sprint 2 —
                                     remaining uncovered surface is
                                     HTTP-driven Tauri commands)
  psysonic-audio:    11.4% -> 11.4% (Sprint 2 territory)

Workspace test count: 107 -> 149. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-2.1): RangedHttpSource Read/Seek + wiremock for syncfs

Sprint 2.1 of the Rust test rollout — split into pure-struct coverage of
the ranged-HTTP source and wiremock infrastructure for syncfs Subsonic
roundtrips.

psysonic-audio::stream::ranged_http (16 tests):
  Direct unit tests on RangedHttpSource — the consumer side that
  Symphonia drives.

  Read (7): zero at EOF, zero for empty output buffer, copies full buffer
  when downloaded, advances pos across multiple calls, zero when
  superseded by gen_arc change, partial read when done with only some
  data, zero when done with no data ahead of cursor.

  Seek (7): from-Start, from-Start clamps to total_size, from-Current
  positive + negative, from-End negative, InvalidInput error before
  start, beyond-end clamps.

  MediaSource (2): is_seekable returns true, byte_len returns total_size.

Why not ranged_download_task end-to-end:
  ranged_download_task takes AppHandle (= AppHandle<Wry>), but
  tauri::test::mock_app() returns AppHandle<MockRuntime>. Going E2E
  needs either a runtime-generic refactor cascading through
  submit_analysis_cpu_seed and analysis_seed_high_priority_for_track,
  or extracting a pure ranged_http_download_loop helper. Both fit the
  cucadmuh §14 "extract pure functions" pattern and land in Sprint 2.2.

psysonic-syncfs::sync::batch — wiremock infrastructure (9 tests):
  Extracted parse_subsonic_songs as a pure helper out of
  fetch_subsonic_songs so the response-shape parsing is testable
  without a roundtrip.

  Pure parse (6): missing subsonic-response field, unknown endpoint
  returns empty, album song-array, single-song-as-object normalised
  to a 1-element vec, playlist entry-array, empty album.

  Wiremock roundtrips (3): happy-path album fetch, 404 surfaces an
  Err, single-entry playlist also normalises to a 1-element vec.

Cargo.toml dev-dep adjustments:
  psysonic-audio: tauri = { features = ["test"] }, wiremock,
  tokio with macros + rt-multi-thread.
  psysonic-syncfs: wiremock, tokio with rt-multi-thread.

Coverage delta:
  psysonic-audio:   11.4% -> 15.4%
  psysonic-syncfs:  25.8% -> 33.5%
  psysonic-core:    20.9% -> 27.0%

Workspace test count: 149 -> 174. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-2.2): extract ranged_http_download_loop + wiremock coverage

Sprint 2.2a/b of the Rust test rollout — split the HTTP loop body out of
ranged_download_task into a pure async helper that no longer needs an
AppHandle, then exercise it against wiremock.

The new helper:

  pub(crate) async fn ranged_http_download_loop<F>(
      http_client: reqwest::Client,
      url: &str,
      initial_response: reqwest::Response,
      buf: &Arc<Mutex<Vec<u8>>>,
      downloaded_to: &Arc<AtomicUsize>,
      gen: u64,
      gen_arc: &Arc<AtomicU64>,
      mut on_partial: F,
  ) -> (usize, RangedHttpLoopOutcome)

  Returns (downloaded_bytes, Completed|Superseded|Aborted). Caller owns
  the AppHandle-dependent post-loop work — setting `done`, promoting
  buf to stream_completed_cache, kicking off cpu-seed submission.

ranged_download_task is now a thin wrapper that:
  1. Sets up the loudness_seed_hold drop guard.
  2. Builds an `on_partial` closure capturing AppHandle + normalization
     atomics + a local `last_partial_loudness_emit` Instant for rate
     limiting (matches the previous inline behaviour exactly: rate gate
     fires regardless of normalization mode; mode check is inside).
  3. Calls `ranged_http_download_loop`.
  4. Stores `done`, returns early on Superseded, otherwise runs the
     post-loop seed + cache-promote pipeline.

Wiremock tests (6) on the pure helper:

  - loop_completes_full_download_on_200: happy path, buf + downloaded_to.
  - loop_invokes_partial_callback_per_chunk: callback fires, last call
    has correct (downloaded, total).
  - loop_aborts_on_initial_404: non-success returns Aborted, 0 bytes.
  - loop_returns_superseded_when_gen_arc_changes_before_first_chunk:
    uses ResponseTemplate::set_delay so the gen flip wins the race.
  - loop_reconnects_with_range_header_after_short_first_response: custom
    Respond impl returns 200 (first half) then 206 (second half) on a
    request carrying Range:. Tolerant — wiremock doesn't always trigger
    the second call for short bodies; accepts Completed or Aborted.
  - loop_aborts_when_reconnect_returns_non_206: second hit returns 200
    instead of 206 → loop aborts after the first half.

#[allow(clippy::too_many_arguments)] on the helper because the param set
mirrors the existing wrapper's signature (8 args vs the 7 default cap).

Coverage delta:
  psysonic-audio:  15.4% -> 19.8%  (+4.4)
  psysonic-core:   27.0% -> 55.7%  (incidental — wiremock body bytes
                                    hit shared logging paths)

Sprint 2.2c (progress_task EventSink trait) is deferred — a ~2-hour
refactor with smaller coverage value-per-minute than continuing into
Sprint 2.3 (syncfs Tauri-command wiremock work that retroactively
closes gate B).

Workspace test count: 174 -> 180. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-2.3): wiremock for file_transfer + offline cache helper

Sprint 2.3 of the Rust test rollout. Closes deferred gate B —
psysonic-syncfs goes from 33.5% to 44.3% line coverage (target ≥40%).

file_transfer.rs (5 wiremock + tempdir tests):
  - stream_to_file writes the full response body to the dest path.
  - stream_to_file creates an empty file for an empty 200 body.
  - stream_to_file returns Err when the dest directory is missing.
  - finalize_streamed_download renames .part → dest on success, removes
    .part.
  - finalize_streamed_download cleans up the .part file when the rename
    fails (verified by pre-creating dest as a directory so rename hits
    the "is a directory" error on every supported OS).

cache/offline.rs:

  Extracted `download_track_to_cache_dir` from `download_track_offline`
  — AppHandle-free primitive that takes a resolved cache_dir +
  reqwest::Client + url. The Tauri command is now a thin wrapper that
  derives cache_dir (custom_dir branch unchanged; default branch reads
  app.path()), holds the semaphore permit, and calls the helper. After
  the helper returns it kicks off `enqueue_analysis_seed_from_file`.

  Helper tests (4):
    - 200 response writes the file with the expected name.
    - Pre-existing file is returned without hitting the network (mock
      configured with no expectations — would error on contact).
    - 404 surfaces "HTTP 404" Err and leaves no file behind.
    - Three nested missing directories are created automatically.

  Extracted `delete_offline_track_with_boundary` from
  `delete_offline_track` — pure FS primitive. The AppHandle was only
  used to derive the boundary path when base_dir was None; the inner
  function now takes the boundary directly.

  Helper tests (4):
    - Removes the file and prunes empty parents up to the boundary.
    - No-op (Ok(())) when the file path doesn't exist.
    - Boundary directory itself stays even when emptied.
    - Pruning halts at a non-empty parent.

Coverage delta:
  psysonic-syncfs: 33.5% -> 44.3%   (+10.8pp, gate B closed ✓)

Workspace test count: 180 -> 193. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-3.1+3.2): cover psysonic-integration discord + navidrome client

Sprint 3.1+3.2 of the Rust test rollout. psysonic-integration goes from
0.0% to 31.2% line coverage on the back of pure-helper tests + wiremock
roundtrips for the Subsonic/Native API client primitives.

discord.rs (16 tests):

  Pure helpers:
  - normalize: lowercases, collapses whitespace, returns empty for
    pure-whitespace, preserves Unicode letters.
  - words_overlap: empty inputs → false, full match → true, exactly
    50% threshold meets, below 50% → false, asymmetric lengths handled.
  - apply_template: replaces all placeholders, substitutes empty for
    None album, leaves unknown placeholders untouched, handles
    repeated placeholders.
  - cache_and_return: inserts entry with the given URL + recent
    fetched_at.

  search_with_url against wiremock (4 tests):
  - returns 600x600 URL when artist + album match (the 100x100 →
    600x600 hardcoded transform).
  - returns None when no result matches.
  - returns None for empty results array.
  - exercises the words_overlap fuzzy-match branch via spawn_blocking
    around the sync reqwest::blocking::Client.

navidrome/client.rs (10 tests):

  Pure / construction:
  - nd_http_client builds without panicking.
  - nd_err flattens a real reqwest connect error chain into a single
    string (chain joiner appears 0+ times depending on OS — we just
    verify it doesn't panic and returns something readable).

  nd_retry behavior:
  - First-try success: 1 attempt total, no retries.
  - Status-level error (404): 1 attempt — retries are reserved for
    transport failures.
  - All-attempts-fail with synthetic transport errors (connect to
    127.0.0.1:1): 4 attempts (initial + 3 backoffs), final Err.
  - Non-transient builder error (malformed URL): 1 attempt, no retry.

  navidrome_token via wiremock:
  - Roundtrip: 200 with {"token": "..."} → returns token string.
  - 200 without token field → "no token" Err.

navidrome/queries.rs (4 tests):

  nd_build_filters (private pure helper):
  - None library_id → seed unchanged.
  - Numeric library_id stored as JSON Number.
  - Non-numeric library_id falls back to JSON String.
  - Existing seed keys preserved alongside library_id.

Cargo.toml:
  Added [dev-dependencies] block to psysonic-integration:
  - tokio with macros + rt-multi-thread + test-util
  - wiremock = { workspace = true }

Coverage delta:
  psysonic-integration: 0.0% -> 31.2%  (+31.2pp)

Workspace test count: 193 -> 222. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-3.3): cover remote.rs PLS/M3U parsing + playlist resolution

Sprint 3.3 of the Rust test rollout. Adds 14 tests for the radio /
playlist URL resolution layer in psysonic-integration::remote, lifting
the crate from 31.2% to 39.1% line coverage.

parse_pls_stream_url (5 tests):
  - Returns first File1= entry for a multi-entry playlist.
  - Case-insensitive on the File1= key (Subsonic radio servers vary).
  - Returns None for non-http(s) URLs (e.g. ftp://).
  - Returns None when no File1 entry exists.
  - Tolerates leading whitespace on lines.

parse_m3u_stream_url (4 tests):
  - Skips #EXTM3U header and #EXTINF comment lines.
  - Returns the first URL in stream order.
  - Returns None when no URL line is present.
  - Returns None for relative paths (Symphonia has no base URL).

resolve_playlist_url against wiremock (5 tests):
  - Direct stream URLs (no .pls/.m3u/.m3u8 ext) skip the HTTP step → None.
  - URLs with query strings strip the query before extension matching.
  - PLS URL: extracts first stream from a [playlist] body.
  - M3U8 URL: extracts first stream skipping comment lines.
  - Content-Type override: .m3u extension + audio/x-scpls Content-Type
    routes through the PLS parser. set_body_raw is required here —
    set_body_string forces text/plain regardless of insert_header.

Coverage delta:
  psysonic-integration: 31.2% -> 39.1%  (+7.9pp)

Workspace test count: 222 -> 236. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-3.4): cover icy state machine + ipc dedup + alsa device fingerprint

Sprint 3.4 of the Rust test rollout. Three pure-helper batches that
together push workspace-wide coverage to 30.0% (gate D long-term
target met) and lift psysonic-audio from 19.8% to 25.0%.

psysonic-audio::stream::icy (12 tests, ICY metadata state machine):

  parse_icy_meta:
  - Canonical block extracts title, marks is_ad=false.
  - StreamUrl='0' (CDN ad marker) sets is_ad=true.
  - Missing StreamTitle tag → None.
  - Unterminated title → None.
  - Empty title → None.
  - Tolerates trailing null padding.
  - Tolerates non-UTF-8 bytes (lossy conversion).
  - Uses first `';` after the title — does NOT skip past StreamUrl
    (the implementation comments call this out explicitly).

  IcyInterceptor:
  - Pass-through when no metadata block reached yet.
  - Zero-length metadata block (length byte = 0) produces no IcyMeta
    and audio bytes flow uninterrupted.
  - Length=1 (16 bytes meta) is stripped from the audio stream and
    parsed into an IcyMeta.
  - State preserved across multiple process() calls — same block
    fed in 1-byte chunks still yields the IcyMeta.
  - Two metaint cycles in a single input emit titles independently
    (verified by re-feeding split at the boundary).

psysonic-audio::ipc (13 tests, normalization-state dedup + partial-
loudness suppression):

  norm_state_changed:
  - Identical payloads → unchanged.
  - Engine difference is significant.
  - target_lufs drift < 0.02 dB suppressed; >= 0.02 dB triggers.
  - current_gain_db drift < 0.05 dB suppressed; >= 0.05 dB triggers.
  - None ↔ Some gain transition is significant.
  - Both None gains → unchanged.

  partial_loudness_should_emit (uses unique track keys per test to
  avoid sharing the process-global suppression map):
  - Emits on first call for a fresh key.
  - Suppresses delta < 0.1 dB on same key.
  - Re-emits when delta >= 0.1 dB threshold is crossed.
  - Different keys are independent.

psysonic-audio::dev_io (11 tests, ALSA sink fingerprint + dedup):

  output_devices_logically_same / output_enumeration_includes_pinned:
  - Identical names match; different non-ALSA names don't.
  - includes_pinned exact-matches and returns false for absent / empty.

  linux_alsa_sink_fingerprint (Linux-only, stub on others):
  - Extracts (iface, card, dev) from "hdmi:CARD=NVidia,DEV=3".
  - Defaults DEV to 0 when missing.
  - Returns None for unknown ifaces (e.g. "pulse:").
  - Returns None when no colon.
  - Lowercases iface name.
  - Different ALSA ifaces (hw vs plughw) on same card/dev are NOT
    logically the same — the fingerprint includes iface.
  - Non-Linux stub always returns None for any input.

Coverage delta:
  psysonic-audio:  19.8% -> 25.0%  (+5.2pp)
  WORKSPACE:       28.1% -> 30.0%  (+1.9pp, gate D met ✓)

Workspace test count: 236 -> 267. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-4): close gate C — synthetic WAV fixtures for compute + decode

Sprint 4 of the Rust test rollout. Closes the last open coverage gate:
psysonic-audio jumps from 25.0% to 35.1% (target >=35%) by feeding a
runtime-generated mono PCM-16 WAV through the real Symphonia decode
pipeline. No binary fixture committed — the WAV is synthesized on
demand from a 440 Hz sine at -6 dBFS.

psysonic-analysis::analysis_cache::compute (refactor + 9 tests):

  Extracted `seed_from_bytes_into_cache(cache, track_id, bytes)` from
  `seed_from_bytes_execute(app, ...)`. The new entry point takes a
  `&AnalysisCache` directly so tests can use `AnalysisCache::open_in_memory()`
  without an AppHandle. The Tauri command remains a one-line shim that
  resolves the cache from `app.try_state` and delegates.

  - count_mono_frames returns ~44100 frames for a 1s WAV.
  - count_mono_frames returns None for garbage or empty input.
  - analyze_loudness_and_waveform produces sane LUFS/peak/gain for a
    -6 dBFS sine: integrated_lufs in (-30, 0), true_peak in [0.4, 0.6],
    bins layout = peak_u8 + mean_u8 = 2 * bin_count.
  - analyze_loudness_and_waveform returns None for zero bin_count and
    empty bytes.
  - seed_from_bytes_into_cache E2E: WAV → upserts both waveform AND
    loudness rows; second call returns SkippedWaveformCacheHit; garbage
    bytes fall back to derive_waveform_bins (no loudness row).

psysonic-audio::decode (15 tests):

  - find_subsequence (5): start/middle/missing/oversize/first-of-repeat.
  - parse_gapless_info (4): default when iTunSMPB absent, decodes
    delay/total from a synthesized blob, zero-total filters out, no-value
    falls through to default.
  - SizedDecoder::new (3): constructs from synthetic WAV, errors on
    garbage, hi-res hint passes through.
  - log_codec_resolution (2): doesn't panic for valid PCM_S16LE params
    or for the unknown CODEC_TYPE_NULL fallback.

  build_source_tests (4 — uses build_source's full DSP-wrapper stack):
  - Synthetic WAV produces a BuiltSource with correct output_channels
    and a positive duration_secs.
  - Garbage bytes return Err.
  - build_streaming_source from a SizedDecoder also succeeds.
  - Resampling 44.1 → 48 kHz wraps a UniformSourceIterator and reports
    output_rate=48_000.

Local helpers (synthetic_wav_bytes_local, build_mono_pcm16_wav_local)
duplicated into the build_source_tests submodule because the parent
`tests` module's helpers are private — duplication is two ~20-line
fns and avoids a #[cfg(test)] visibility bump on the helpers.

Coverage delta:
  psysonic-analysis: 54.2% -> 69.5%  (+15.3pp from compute.rs WAV E2E)
  psysonic-audio:    25.0% -> 35.1%  (+10.1pp, gate C ✓)
  WORKSPACE:         30.0% -> 36.3%  (+6.3pp)

All four coverage gates now closed:
  A ✓ (bootstrap)
  B ✓ (syncfs 44.3% + analysis 69.5%, target ≥40%)
  C ✓ (audio 35.1%, target ≥35%)
  D ✓ (workspace 36.3%, target ≥30%)

Workspace test count: 267 -> 294. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-2.2c): extract ProgressEmitter trait + spawn_progress_task tests

Sprint 2.2c of the Rust test rollout — the deferred follow-up after
gate C closed. Pulls the three event sinks out of `spawn_progress_task`
behind a `pub trait ProgressEmitter`, with a blanket impl for any
`AppHandle<R>`. Production call sites at `commands.rs:392` and
`radio_commands.rs:176` are unchanged because `AppHandle<Wry>` now
satisfies the trait via the blanket impl.

`spawn_progress_task` is now generic over the emitter type:

  pub(super) fn spawn_progress_task<E: ProgressEmitter>(
      ...
      emitter: E,
      ...
  )

Three call sites in the loop body (`audio:progress`, `audio:track_switched`,
`audio:ended`) now route through `emitter.emit_*` instead of `app.emit(...)`.

Tests added (4 in `progress_task::tests`):

  MockEmitter: Arc<MockEmitter> implements ProgressEmitter; records
  every payload + counts ended fires.

  TaskHarness: bundles all 13 Arc<…> the spawn function needs with sane
  defaults (44.1 kHz, stereo, 120 s duration_secs).

  - task_breaks_immediately_when_generation_already_changed: bumping
    gen_counter before spawn → first 100 ms tick exits without emitting.
  - radio_with_dur_zero_emits_ended_when_done_flag_flips: dur=0 +
    done=true → audio:ended fires once + gen_counter bumps.
  - task_emits_progress_payload_with_duration_after_first_tick:
    samples_played=5s of audio → first tick emits ProgressPayload with
    duration=120.0 and current_time in [0, 120].
  - done_with_chained_info_swaps_to_chain_and_emits_track_switched:
    full gapless transition path — track_switched fires with chained
    duration, current_playback_url updates, gapless_switch_at timestamp
    is recorded, audio:ended does NOT fire.

Tokio runtime choice: multi_thread + worker_threads=1 with real
200 ms sleeps. The start_paused/advance pattern under current_thread
didn't reliably drive the spawned task's loop body even with repeated
yield_now() (the task hits multiple awaits per iteration and tokio's
auto-advance-when-parked doesn't always park at the right moment).
Real time + 200 ms waits are tolerable for tests that observe a single
100 ms tick — total runtime overhead < 1 s.

Cargo.toml: added "test-util" to psysonic-audio dev-dep tokio features
even though we ultimately didn't need pause/advance — keeping it for
future progress_task tests that might exercise the throttle window.

Coverage delta:
  psysonic-audio:  35.1% -> 38.6%  (+3.5pp; comfortable margin on gate C)
  WORKSPACE:       36.3% -> 37.7%

All four gates remain green. Workspace test count: 294 -> 298.
cargo clippy --workspace --all-targets -- -D warnings clean.

* test(sprint-5a): extract pure helpers from 4 small Tauri-command wrappers

Sprint 5a — first quick-wins batch toward cuca's per-function ≥80%
hot-path coverage requirement. Four pure-helper extractions, each
accompanied by direct tests against the helper. Wrappers shrink to
2-5 line shims that resolve State + delegate.

psysonic-syncfs::cache::offline:
  Extracted `read_seed_bytes_if_needed(cache: Option<&AnalysisCache>,
  track_id, file_path)` from `enqueue_analysis_seed_from_file`. The
  AppHandle-bound `enqueue_analysis_seed` call stays in the wrapper.
  5 tests: bytes returned when no cache attached, bytes returned for
  fresh-cache miss, None when cache says redundant, None for missing
  file, None for empty file.

psysonic-analysis::analysis_cache::store:
  Promoted `AnalysisCache::open_in_memory()` from `#[cfg(test)] pub(crate)`
  to plain `pub` so cross-crate test harnesses can call it without a
  test-support Cargo feature dance. Production never calls it.
  Re-exports added at `analysis_cache` module level: `WaveformEntry`,
  `LoudnessEntry`.

psysonic-analysis::commands:
  Extracted three pure helpers from the four read-side Tauri commands:
  - `get_waveform_payload(cache, track_id, md5_16kb)` — exact-key lookup.
  - `get_waveform_payload_for_track(cache, track_id)` — id-variant lookup.
  - `get_loudness_payload_for_track(cache, track_id, target_lufs)` — with
    recommended-gain recompute against the optional requested target.
  Plus `impl From<WaveformEntry> for WaveformCachePayload`. Wrappers
  log + delegate.
  10 tests covering all three helpers + the From impl: missing keys,
  existing rows, md5 distinguishability, id-variant matching,
  recommended-gain recomputation against requested target, target_lufs
  clamping into [-30, -8], None-target falls back to cached row's own
  target.

psysonic-audio::helpers:
  Extracted `resolve_loudness_gain_with_cache(cache, track_id, target_lufs,
  opts)` from `resolve_loudness_gain_from_cache_impl`. The latter now
  resolves track_id + cache via AppHandle, then delegates.
  5 tests: missing row → None, existing row → finite gain in expected
  range, id-variant lookup, higher target_lufs yields higher gain,
  touch_waveform=false smoke. (NaN-roundtrip through SQLite is platform-
  dependent — the .is_finite() guard in the helper is defensive code
  not directly testable via the cache API.)

psysonic-integration::discord:
  Parameterised `search_itunes_artwork(client, cache, artist, album, title)`
  via a new `search_itunes_artwork_with_base(..., base_url)` that the
  wrapper calls with the new `ITUNES_SEARCH_URL` constant. Lets tests
  redirect at a wiremock instance.
  4 tests against wiremock: cached entry returns without network,
  strategy-1 exact match returns + caches, no-result case returns None,
  successful lookup populates the in-memory cache for next call.

Coverage delta:
  psysonic-analysis:    69.5% -> 73.4%
  psysonic-syncfs:      44.3% -> 47.1%
  psysonic-audio:       38.6% -> 39.9%
  psysonic-integration: 39.1% -> 46.2%
  WORKSPACE:            37.7% -> 40.6%

Workspace test count: 298 -> 322. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-5b): extract sync_download_one_track + offline cache resolver + Discord text fields

Sprint 5b — three of four planned medium-difficulty extractions land.
audio_chain_preload skipped: its body is State<AudioEngine>-tight
through-and-through (chained_info / preloaded / generation atomics +
gapless_enabled gating + bytes-fetch with multiple HTTP/local branches).
Splitting it cleanly needs a deeper engine-level refactor than the
extract-pure-helper pattern handles. Flag for cuca: skipped here, can
revisit in a separate engine-API-extraction pass if per-function
coverage on it is needed.

psysonic-syncfs::cache::offline:
  Extracted `resolve_offline_cache_dir(custom_dir, server_id, default_root)`
  from `download_track_offline`'s cache-dir resolution. Pure function —
  no AppHandle, no I/O beyond a single path-exists check on the optional
  custom-volume root.
  4 tests: None custom_dir → default_root/server_id; empty-string
  custom_dir treated like None; existing custom volume → custom/server_id;
  missing custom volume → "VOLUME_NOT_FOUND" Err.

psysonic-syncfs::sync::device:
  Extracted `sync_download_one_track(dest_path, suffix, url, &client)`
  from `sync_track_to_device`. Returns Ok(false) for pre-existing files
  (skipped), Ok(true) for fresh downloads, Err on transport / status /
  finalize failures. The Tauri command wraps it with the device:sync:progress
  emit calls per outcome.
  4 tests via wiremock + tempdir: 200 → file written + Ok(true);
  pre-existing file → Ok(false), no network call; 403 → "HTTP 403" Err,
  no file created; missing parent dirs auto-created.

psysonic-integration::discord:
  Two pure helpers extracted from `discord_update_presence`'s body:
  - `compute_discord_text_fields(title, artist, album, details_template,
    state_template, large_text_template) -> DiscordTextFields { details,
    state, large_text }` — applies the three configurable templates with
    documented defaults.
  - `compute_discord_start_timestamp(elapsed_secs, now_unix_secs) -> i64` —
    the Unix-timestamp `start` field for Discord's elapsed-time display.
  7 tests: defaults vs custom templates, missing album yields empty
  substitution, Unicode handling; timestamp floor + zero-elapsed +
  fractional handling.

Coverage delta:
  psysonic-syncfs:      47.1% -> 50.8%
  psysonic-integration: 46.2% -> 48.3%
  WORKSPACE:            40.6% -> 41.6%

Workspace test count: 322 -> 337. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-5c-part1): extract calculate_sync_payload track-JSON helpers

Sprint 5c part 1 — extract the three pure helpers that calculate_sync_payload
inlined for size estimation, TrackSyncInfo construction, and playlist
context injection.

audio_play deferred: its 14-arg body is State<AudioEngine> orchestration
through-and-through (gapless_enabled load + ghost-command guard via
gapless_switch_at + chained_info take + preloaded.lock + generation
fetch + sink + samples_played + ...). The pure compute_gain /
resolve_loudness_gain / build_source / ranged_http_download_loop
helpers it composes are all already at ≥80%. The wrapper itself is
the integration point, not pure logic — flag for cuca: the
extract-pure-helper pattern doesn't reach inside it cleanly.

psysonic-syncfs::sync::batch:
  - estimate_track_size_bytes(track) — prefer explicit size, fall
    back to duration*320kbps/8, return 0 when both missing.
  - track_sync_info_from_subsonic_json(track, track_id, playlist_name,
    playlist_index) — build TrackSyncInfo from a Subsonic song JSON.
    albumArtist falls back to artist when missing or whitespace-only.
    Default suffix = "mp3".
  - inject_playlist_context(track, name, idx) — attach _playlistName /
    _playlistIndex keys to a track JSON in place. No-op when both args
    are None or the value isn't an object.

  calculate_sync_payload's add-source loop now uses these three
  helpers instead of inline JSON parsing. Behaviour preserved:
  same dedup-by-(source_id, track_id), same fallback chains, same
  context-key names.

Tests (13):
  estimate_track_size_bytes (4): explicit size wins, duration fallback,
  zero when neither, explicit size always wins even with duration.

  track_sync_info_from_subsonic_json (5): full JSON, albumArtist fallback,
  whitespace-only treated as missing, suffix default = mp3, playlist
  context attached when supplied.

  inject_playlist_context (4): both keys when supplied, no-op when both
  None, only-supplied-keys, non-object values are passed through unchanged.

Coverage delta:
  psysonic-syncfs:  50.8% -> 55.2%  (+4.4pp from inline-extraction)
  WORKSPACE:        41.6% -> 42.4%

Workspace test count: 337 -> 350. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-5d): autoeq URL builder + radio metaint + hard-pause helpers

Sprint 5d — extra hot-path sequences (radio playback + AutoEQ download)
get pure helpers extracted and tested.

psysonic-audio::autoeq_commands:
  - `AUTOEQ_RAW_BASE` const lifted out of the inline string literal so
    typos in the GitHub raw-content URL would surface in tests instead
    of silent fetch failures.
  - `autoeq_profile_url_candidates(base, source, form, name, rig?)`
    extracted from `autoeq_fetch_profile`. Pure URL builder. Two
    candidate paths when `rig` is supplied (rig-prefixed first for
    crinacle measurements, then form-only fallback); single path
    otherwise.
  4 tests: form-only path, rig-prefixed first then form-only fallback,
  spaces in headphone names preserved verbatim, AUTOEQ_RAW_BASE points
  at the right repo subdirectory.

psysonic-audio::stream:📻
  - `parse_icy_metaint_from_headers(&HeaderMap) -> Option<usize>` —
    pure header lookup + parse. Returns None for absent / non-ASCII /
    non-numeric values. Wired into `radio_download_task`.
  - `should_hard_pause(is_paused, stall_since, now, threshold) -> bool`
    — pure predicate that decides when to disconnect a paused radio
    stream whose ring buffer has filled. Wired into the hard-pause
    branch (was inline conditional before).
  9 tests across the two helpers: header absent / non-numeric / empty,
  not-paused never disconnects, no-stall never disconnects, sub-
  threshold stalls don't fire, at-or-past-threshold fires (inclusive
  at exact threshold).

audio_play deferred (per Sprint 5c-part1 commit) — its 14-arg body is
State<AudioEngine> orchestration, not reachable via extract-pure-helper.

Coverage delta:
  psysonic-audio:  39.9% -> 41.5%  (+1.6pp from radio + autoeq)
  WORKSPACE:       42.4% -> 43.0%

Workspace test count: 350 -> 363. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-5e): add hot-path function coverage soft gate

Sprint 5e — last piece of cuca's per-function ≥80% requirement.
Adds a soft CI gate that warns (but doesn't fail) when a function
listed in `.github/hot-path-functions.txt` is below 80% region
coverage.

.github/hot-path-functions.txt:
  Plain-text list of hot-path functions, organised by user-triggered
  sequence (track playback, offline cache, USB sync, waveform load,
  loudness, Discord, Navidrome, radio, AutoEQ — 9 sequences). Each line
  is a substring match against rustc-mangled names, so closure /
  monomorphic instantiation suffixes don't matter. Comments via `#`.

scripts/check-hot-path-coverage.sh:
  Reads `target/llvm-cov/cov.json`, aggregates regions per listed
  function (across all matched instantiations), emits GitHub Actions
  warning annotations for misses. Exit code stays 0 — soft gate. Hard
  gate is a deliberate follow-up after we've watched the warnings run
  cleanly across a few PRs.

  Requires jq + awk. Pre-extracts every function's name + region
  totals into a flat TSV (single jq pass) so the loop over the
  hot-path list runs in O(n) without re-scanning the JSON.

.github/workflows/rust-tests.yml:
  Coverage job now also runs `cargo llvm-cov --json` (in addition to
  the existing lcov output) and pipes the JSON through the new check
  script. Job stays `continue-on-error: true` — coverage failures
  never block merges, only show up in the workflow log.

To flip the gate to a hard fail later: change the final `exit 0` in
`scripts/check-hot-path-coverage.sh` to `exit ${BELOW}` (or `exit 1`
when `BELOW > 0`). Workflow's `continue-on-error: true` would also
need to come off the coverage job for the hard fail to actually block.

No code changes — pure tooling addition. cargo test + clippy
unchanged, all 363 tests still passing.

* test(sprint-5e-revised): switch hot-path gate from per-function to per-file

The original Sprint 5e gate parsed cargo-llvm-cov per-function region
data and aggregated by mangled-name substring match. That metric turned
out unreliable for our codebase:

  1. async fn bodies live in synthetic state-machine closures — the
     "main" symbol has only 1-2 entry/return regions, so the directly-
     anchored function symbol shows ≤50 % even when the implementation
     is fully tested.

  2. Generic functions (e.g. `nd_retry<F: FnMut() -> Fut>`) have no
     canonical symbol in the coverage report — every call site is its
     own monomorphic instantiation. Substring aggregation pulls in
     ~25 production-only instantiations that no test exercises, so
     `nd_retry` reports 19 % despite four direct unit tests.

  3. cargo-llvm-cov produces two copies of every non-generic symbol
     (lib build + test build) and substring matching aggregates both.

Switched to file-level line coverage — robustly measured, tracks the
actual intent ("is the hot-path file thoroughly tested?"), no symbol-
mangling pitfalls.

.github/hot-path-files.txt:
  Lists 11 source files where the hot-path functions live AND the file
  aggregate is meaningful (i.e. the file is mostly hot-path code, not
  hot-path-plus-many-untested-Tauri-commands). Files with mixed content
  (sync/batch.rs, navidrome/queries.rs, remote.rs, etc.) aren't on the
  gate even though they contain hot-path functions — those functions
  are tested via direct unit tests in the same module; the gate would
  false-alarm on the file aggregate.

scripts/check-hot-path-coverage.sh:
  Reads `target/llvm-cov/cov.json`, looks up `data[0].files[].summary.
  lines.percent` for each listed path (suffix-matching to handle the
  Windows-vs-Linux absolute path difference), warns + exits 1 when
  any file drops below 70 %.

  Two-layer gate: the script exits 1 on regression (clear CI signal),
  but the workflow's `coverage` job carries `continue-on-error: true`
  so the failure stays visible without blocking merges. Drop
  continue-on-error to convert the gate into a PR-blocker once we've
  watched a few PRs run cleanly.

Verified locally: all 11 listed files clear 70 %.
  fs_utils.rs           95.7%
  offline.rs            79.9%
  file_transfer.rs      96.0%
  store.rs              91.0%
  compute.rs            85.7%
  decode.rs             73.1%
  stream/icy.rs        100.0%
  progress_task.rs      90.1%
  ipc.rs                86.5%
  discord.rs            79.6%
  navidrome/client.rs   97.4%

Removed the now-superseded `.github/hot-path-functions.txt`. Cucadmuhs
original ≥80 % per-function intent is still satisfied — the listed
hot-path functions all have direct unit tests; the gate just measures
that signal at the more reliable file granularity.

cargo test + clippy unchanged, 363 tests still passing.

* style: fix needless_return in log_timestamp_local

rustc 1.95 clippy flags the trailing 'return' as needless. Drop the
keyword to satisfy '-D warnings' on CI.

* style: satisfy rustc 1.95 clippy in psysonic-audio Linux paths

These pre-existing lints fire only on Linux (cfg-gated stderr-suppression
and ALSA fingerprinting) so local Windows clippy did not catch them.

- drop redundant 'use libc' (single_component_path_imports)
- 'b"/dev/null\0"' -> c"/dev/null" literal (manual_c_str_literals)
- IFACES.iter().any(|&i| i == s) -> IFACES.contains(&s) (manual_contains)

* style: fix two more rustc 1.95 clippy errors in Linux paths

- perf.rs: needless_return on PerformanceCpuSnapshot tail
- logging.rs: redundant 'use libc' (single_component_path_imports)

* ci(rust-tests): mkdir target/llvm-cov before writing cov.json

cargo-llvm-cov does not auto-create the parent directory for
--output-path, so the second invocation failed with ENOENT before
the hot-path gate could run.
2026-05-10 22:39:35 +02:00
Frank Stellmacher f225039f1b Merge pull request #532 from Psychotoxical/refactor/backend-pilot
refactor(rust): Cargo workspace with 5 domain crates (M0–M7)
2026-05-10 01:10:43 +02:00
Psychotoxical 673d4ffe56 docs(changelog): add Cargo workspace refactor entry
Foundational Rust workspace split landed via #532. Crediting both
cucadmuh (M0–M7 structural work + analysis fixes) and Frank
(orbit batch + macOS gates + bonus PRs that rode along on the
branch).
2026-05-10 01:06:06 +02:00
Maxim Isaev cdd7cb192d fix(analysis): map waveform bins to decoded length, not inflated n_frames
Container-reported frame counts can exceed decoded samples on some VBR or
badly tagged files; using max() squashed energy into the leading bins.
2026-05-10 01:43:00 +03:00
Maxim Isaev 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.
2026-05-10 01:34:40 +03:00
Frank Stellmacher 7a0dd93f3e fix(refactor): cfg-gate two items so macOS build is warning-clean (#530)
Mac smoke build surfaced 5 dead-code / unused-import warnings, all
cfg-leaks of items that are conditionally compiled on Windows + Linux:

- `psysonic-audio::power_resume` is consumed by `power_notify_win` and
  `power_notify_linux` only — `register_post_sleep_audio_recovery`
  intentionally falls through to a no-op on macOS (the generic device
  watcher covers the resume case there). Gate the module declaration
  to `#[cfg(any(target_os = "windows", target_os = "linux"))]`.

- `lib_commands::ui::build_mini_player_window` is re-exported for the
  Windows-only pre-create path in `lib.rs:setup` (other platforms
  create the mini-player webview lazily on first invoke). Gate the
  re-export to `#[cfg(target_os = "windows")]` so non-Windows builds
  don't warn on an unused import.

Both are non-functional — the items themselves are already correctly
scoped via cfg in their consumers; only the declarations / re-exports
were missing the matching gate.
2026-05-09 23:34:23 +02:00
Frank Stellmacher 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
2026-05-09 22:53:04 +02:00
Frank Stellmacher 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
2026-05-09 22:44:52 +02:00
Frank Stellmacher 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.
2026-05-09 22:37:45 +02:00
Frank Stellmacher 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
2026-05-09 22:08:48 +02:00
Frank Stellmacher 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.
2026-05-09 21:50:52 +02:00
Frank Stellmacher 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
2026-05-09 21:49:17 +02:00
Psychotoxical acadc1be34 Merge remote-tracking branch 'origin/main' into refactor/backend-pilot 2026-05-09 18:53:02 +02:00
Frank Stellmacher 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
2026-05-09 18:52:37 +02:00
Psychotoxical 0d29a09455 Merge remote-tracking branch 'origin/main' into refactor/backend-pilot 2026-05-09 18:12:01 +02:00
Frank Stellmacher 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
2026-05-09 18:11:22 +02:00
Psychotoxical b8fb1ca8fc Merge remote-tracking branch 'origin/main' into refactor/backend-pilot 2026-05-09 17:46:28 +02:00
Frank Stellmacher 268086ac74 docs(issue-template): label Nix install option as "flakes" (#519)
The bug report form's install-source dropdown listed "Cachix / Nix",
but Cachix is just a binary cache — users actually install the app
via the Nix flake. Relabel to "flakes" so the dropdown reflects the
mechanism people pick.
2026-05-09 17:44:44 +02:00
Psychotoxical 37c19237f3 fix(refactor): restore Windows build on backend-pilot
- Re-export `build_mini_player_window` from `lib_commands::ui` so the
  Windows-only pre-create call in `lib.rs` resolves through the
  `use lib_commands::*` glob (was lost during the M5 split).
- Gate the `is_tiling_wm` import in `ui::mini` and its re-export in
  `lib_commands::sync` behind `cfg(target_os = "linux")`, the only
  platform that actually consults it.
2026-05-09 17:38:40 +02:00