mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
fc34a0ec598d8995869f3fc6b83bc77367fddb46
9 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
45b9229ceb |
refactor(queue): thin-state refs as canonical, full Track via resolver (#872)
* refactor(queue): wire queue UI to the track resolver (thin-state phase 3) cucadmuh's phase-3 steps: - Selectors (useQueueTracks) read resolver-first: getCachedTrack → queue: Track[] fallback (until phase 4), F4 star/rating overrides merged on read. - QueueList rows source their track from the resolver (queue fallback); rows show title/artist/duration only, so no override merge there. - pendingStarSync star/rating success → invalidateQueueResolver so the cache reflects the synced value. - queueResolverBridge re-seeds on queueIndex change too — the prefetch window travels with the playing track. Additive: queue: Track[] stays canonical and behaviour is unchanged (rows resolve to the same data). Phase 4 drops queue: Track[] and the fallbacks. * docs(changelog): queue panel reads through track cache (#860) * fix(queue): stop a render loop that froze the UI on long queues A long virtualized queue + a track change could lock the WebView for ~2 min: - useVirtualizer was handed a fresh `initialRect` object literal every render, so it kept re-initializing in a loop. Hoisted it to a stable module constant. - getCachedTrack did an LRU bump (Map delete+set) during render — a render-time side effect. Made it a pure read; recency is set at write time in cacheSet. * perf(mobile): virtualize the mobile player queue drawer The mobile now-playing queue drawer rendered the full queue with .map; a multi-thousand-track queue meant thousands of DOM nodes. Virtualize it with @tanstack/react-virtual (uniform rows, stable initialRect) so the DOM stays at O(visible rows), matching the desktop QueuePanel. Active track is centred on open via scrollToIndex. * perf(mini): virtualize the mini-player queue list The mini-player queue rendered the full MiniSyncPayload queue with .map. Virtualize it against the OverlayScrollArea viewport (stable initialRect) so the mini window's DOM stays at O(visible rows). Drag-reorder is preserved: rows keep data-mq-idx alongside the virtualizer's measureElement. * refactor(queue): add resolveQueueTrack/getQueueTracksView helper (thin-state phase 4) Render-safe ref→Track view for the phase-4 consumer migration off queue: Track[]. Resolver cache → caller fallback (legacy queue[idx] during dual-write) → placeholder; ref queue-only flags carried, F4 overrides merged. Pure synchronous read, no cache mutation (the freeze landmine), so it is safe in render. * refactor(queue): keep queueItems as the canonical in-memory mirror (thin-state phase 4) Step 1b: dual-write the thin queueItems ref list at every queue write site (the 11 mutations, next/radio top-up, playTrack, undo/redo restore, instant-mix, radio, server-queue init, lucky-mix rollback, and hydrate) so it tracks queue: Track[] in memory, not only at persist time. Identity-preserving maps (star/rating overrides) keep the same refs and are intentionally left untouched. Resolves the restore double-role flagged for 1b: queueItemsIndex is now the restore-pending sentinel that gates hydrateQueueFromIndex, while queueItems stays canonical -- rebuilt from the whole queue after a full hydrate instead of cleared. Normal mutations never set the sentinel, so it only fires on a fresh cold-start restore, not on later server switches. No behaviour change; queue: Track[] stays the source consumers read until phase 3. tsc + full vitest suite (1119 tests) green. * refactor(queue): mobile queue drawer reads through the track resolver (thin-state phase 4) Step 2: the mobile now-playing queue drawer resolves each row's track from the resolver cache (→ queue: Track[] fallback until phase 4), matching the desktop QueueList wired in the phase-3 commit. Subscribes to the resolver version so rows re-render as the cache fills. Structure (count, order, keys, the playTrack arg) still comes from queue: Track[] until it is dropped in the final step. The mobile drawer was the last queue display surface still reading track metadata straight off the fat queue. tsc + full vitest suite green. * refactor(queue): ref-native queue mutations + dual-write bridge (thin-state phase 4) Step 3a: the 11 queueMutationActions now splice/filter/reorder QueueItemRef[] (matching by trackId + the ref's queue-only flags) instead of Track[]. `bridgeQueueFromItems` rebuilds the dual-written queue: Track[] from the new refs by id — purely structural (no resolver/override merge), so behaviour is byte-identical and playerStore.queue.test.ts stays unchanged green. The working ref list comes from `itemsOf(state)` (derived from queue: Track[] for now); the final step swaps that one line to state.queueItems once the fat queue is gone. enqueue / enqueueAt / enqueueRadio seed the resolver cache with incoming tracks (seed-before-splice) so they resolve without a network round-trip after the fat queue is dropped. Adds a DEV-only id-parity guardrail (queue vs queueItems); dev-runtime only, silent in vitest and prod. tsc + full vitest suite (1119) green; contract test unchanged. * refactor(queue): ref-native radio/infinite top-ups (thin-state phase 4) Step 3b: nextAction's proactive infinite-queue and radio top-ups build the new queue as QueueItemRef[] and bridge back to queue: Track[] (same as the queue mutations), and seed the resolver cache with the freshly fetched tracks so they resolve without a network round-trip after the fat queue is dropped. The radio top-up keeps its HISTORY_KEEP front-trim, now expressed on refs. The exhausted-queue refill paths hand their new queue to playTrack, which keeps its fat-queue handling until the final step (its no-arg case needs the resolver- derived queue that lands with the queue: Track[] removal). tsc + full vitest (1119) green; contract test unchanged. * refactor(queue): undo snapshots store thin refs, not Track[] (thin-state phase 4) Step 4: QueueUndoSnapshot.queue: Track[] becomes queueItems: QueueItemRef[], killing the undo "hidden multiplier" — 32 snapshots of a 50k queue now cost refs, not 32×50k full tracks. applyQueueHistorySnapshot rebuilds the display queue from the refs via resolveQueueTrack: resolver cache → the live queue by id (covers tracks the edit didn't remove) → placeholder. currentTrack stays a full track in the snapshot and is restored to the engine unchanged. The snapshot refs derive from queue: Track[] for now (so the undo/redo contract cases, which seed only `queue`, stay green); the final step swaps that to [...s.queueItems]. tsc + full vitest suite (1119) green. * perf(mini): cap the mini-player queue snapshot to ±100 around the current track (thin-state phase 4) Step 5: the mini bridge no longer serializes the full queue over IPC on every push — a 50k Artist-Radio queue would otherwise re-encode in full on every track advance. snapshot() sends a window of 100 tracks before/after the playing song; queueIndex is made slice-relative. The mini component stays unchanged (slice- relative); jump/reorder/remove control events are translated back to absolute queue indices via the window offset captured on the last push. tsc + full vitest suite (1119) green. Mini bridge has no unit tests — needs a quick mini-player smoke (queue shows ±100, jump/reorder/remove land correctly). * refactor(queue): make queueItems a required PlayerState field (thin-state phase 4) Foundation for the final consumer migration off queue: Track[]: queueItems has been written at every queue write site since phase 1b, so promoting it from optional to required is a no-op at runtime (tsc confirms zero new errors) and lets the upcoming reader migrations read state.queueItems without `?? []` noise. * refactor(queue): migrate structural queue readers off queue: Track[] (thin-state phase 4) First reader batch toward dropping queue: Track[]: the queue-length selectors (usePlaybackServerId, usePlaybackCoverArt, useQueuePanelDrag, useMiniQueueDrag) now read state.queueItems.length, and FullscreenPlayer's next-track cover prefetch resolves through useQueueTrackAt instead of indexing the fat queue. All behaviour- identical during dual-write (queueItems is in lockstep with queue). tsc + full vitest suite (1119) green. Note: getPlaybackServerId() (playbackServer.ts) deliberately stays on queue for now — it is called from many partially-mocked test stores, so it migrates with the final field removal where the seedQueue helper covers those tests. * refactor(queue): QueuePanel save/share/playlist read queueItems (thin-state phase 4) The id/length reads (save to playlist, share link, create playlist, empty-queue guards, next-tracks divider) now read state.queueItems instead of the fat queue. Behaviour-identical during dual-write; queue: Track[] stays for the rendered QueueList + auto-scroll until the field is dropped. tsc + full suite (1119) green. * refactor(queue): drop queue: Track[] — thin queueItems is the only queue (thin-state phase 4) The store no longer holds the fat queue. `queueItems: QueueItemRef[]` is the sole canonical queue; full `Track`s resolve on demand via the resolver (index batch → getSong fallback, bounded LRU cache); only `currentTrack` stays a full Track. At 50k tracks the store holds ~hundreds of resolved tracks + the refs, not 50k Track objects. - **Persist:** partialize is refs-only (no windowed slice / PERSIST_QUEUE_HALF). A `merge` migrates every historical blob shape → `queueItems` (existing `queueItems` → legacy `queueRefs` → pre-ref windowed `queue: Track[]`) and drops the obsolete `queue` key, so saved queues survive the upgrade. - **Restore (decision B):** `hydrateQueueFromIndex` eager-resolves the whole ref list into the cache on cold start (index → getSong, so an index-off queue still plays), clears the restore sentinel. - **Resolver bridge:** keeps `[idx-50, idx+200]` warm via `resolveVisibleRange`. - **Mutations / actions / playback:** operate on refs; the playing track is `currentTrack`, the next/neighbour tracks resolve from the cache. Navigation (next/previous/row-jump) keeps `queueItems` and only moves the index — no full resolve or queue rebuild per track change. - **Persist tests** cover the three old-blob migrations; `seedQueue` test helper replaces the `setState({ queue })` seeds. tsc + full vitest suite (1115) green. Behaviour-preserving by the test contract; the gapless track change + cold-start restore + mini cap still want a live smoke before merge. * fix(queue): star/rating keeps the queue row resolved instead of blanking to "…" (thin-state) Rating/starring a queue song flashed the row's title to the "…" placeholder until the next track change. Root cause: on sync success pendingStarSync called invalidateQueueResolver, which DROPPED the cached track — and with queue: Track[] gone there's no fat fallback, so the row resolved to a placeholder until the resolver bridge re-fetched the window. Fix: add patchCachedTrack(trackId, patch) and use it on star/rating success to update the cached entry in place (title kept, synced starred/userRating applied) instead of dropping it. No placeholder flash, no re-fetch. tsc + full vitest suite (1115) green. * fix(player): quota-safe persist so a full localStorage can't kill playback A very large queue (~50k refs) overflows the localStorage quota; the persist write then threw QuotaExceededError from inside set(), which aborted playTrack before audio_play — no audio output at all. Back the player persist with a quota-safe storage wrapper so a failed write degrades to a no-op instead of throwing. Restoring the full ref list at that ceiling (vs a windowed cap) is left as a follow-up. * polish(player): throttle the quota-skip persist warning to once per key The quota-safe persist logs a skip on every failed write; on a huge queue that floods the dev console once per mutation. Warn once per key per quota-exceeded streak, re-armed when a write to that key next succeeds. * fix(queue): port new cover-pipeline readers to thin-state Main's cover pipeline (#870) reads s.queue.length and seeds the player store with queue: [track] in its tests. Under thin-state, queue: Track[] no longer exists — the canonical queue is queueItems: QueueItemRef[]. These four files were brought across in the merge but still spoke the old shape; this commit aligns them with the thin-state contract. - src/cover/usePlaybackCoverArt: queueLength = queueItems.length - src/cover/usePlaybackCoverArt.test: seed via toQueueItemRefs - src/api/coverCache.test: same - src/hooks/useNowPlayingPrewarm.test: same (two test cases) * fix(queue): canonicalize thin-state server identity for mixed-server queues `QueueItemRef.serverId` and `PlayerState.queueServerId` are now written as the URL-derived index key on every writer path, matching the library index direction. Mixed-server queues with duplicate `trackId` across servers stay unambiguous because the resolver cache, persistence, and playback bindings all share one key shape. - new `canonicalQueueServerKey()` helper (idempotent UUID-or-key normalizer) - `toQueueItemRefs`, `bindQueueServerForPlayback`, `seedQueueResolver`, and `hydrateQueueFromIndex` emit canonical keys - `getCachedTrack` falls back to the canonical lookup so refs persisted in the legacy UUID shape still resolve through the migration window - persist `merge` rewrites `queueServerId` and every ref `serverId` on rehydrate, so the live store never holds mixed shapes - `removeServer` compares against the resolved id so a profile delete still clears the matching queue binding - the two `playbackServer.test.ts` asserts that hard-coded the UUID shape are updated to the canonical key (existing reader-tolerance is unchanged) * fix(queue-undo): bind snapshot prepend to snapshot-canonical server identity When `applyQueueHistorySnapshot` has to prepend the still-playing track (the snapshot's queue does not contain it), the new ref must follow the snapshot's playback server, not the live `queueServerId`. A server switch racing the undo would otherwise stamp the prepended ref with the new server, mis-resolving the playing track on the very next render. - `QueueUndoSnapshot` now carries `queueServerId` (captured by `queueUndoSnapshotFromState`); older in-memory entries fall back through the snapshot's own refs and finally the live store value - the prepend in `applyQueueHistorySnapshot` plus the post-restore `seedQueueResolver` both source the server identity from this snapshot context, run through `canonicalQueueServerKey` so cache bucket and ref shape stay in lockstep * test(queue): regression cluster for mixed-server queues with duplicate trackId Covers the four invariants the thin-state review called out: - resolver correctness: same `trackId` on two servers maps to two distinct cache entries via canonical keys, and legacy UUID-shaped refs still read the same entries through the compat lookup path - restore/hydrate: persist `merge` forward-migrates UUID-form blobs in three shapes (canonical `queueItems`, legacy `queueRefs`, mixed-server `queueItems`) to canonical keys - undo snapshot application: prepended ref follows the snapshot's playback server even when the live queue has been rebound to a different one, with fallback to snapshot refs and live state for legacy entries - queue sync id emission: `flushPlayQueuePosition` -> `savePlayQueue` passes plain track ids and the playback server out of band, no per-ref `serverId` ever leaks into the request body Also asserts the write helpers (`toQueueItemRefs`, `bindQueueServerForPlayback`) emit canonical keys directly. * perf(queue-header): coalesce resolver burst updates and aggregate in one pass `QueueHeader` recomputed total and remaining queue durations on every resolver cache version bump via two separate full-queue reduces. A mass resolve burst (queue restore, prefetch window slide) bumps the version dozens of times in one frame, and very long queues turned that into visible main-thread stutter. - one pass: a single for-loop produces both total and future-tracks duration; a 50k-track queue costs one walk per recompute, not two - `useDeferredValue(version)` coalesces the burst into a single low-priority commit so the cache version is only sampled once per React frame instead of once per cache write * fix(queue): use stable artist seed for radio top-up The proactive radio top-up in `runNext` seeded `getSimilarSongs2` and `getTopSongs` from `resolveQueueTrack(nextRef)` metadata. When the next ref is still cold in the resolver cache, the placeholder track has empty artist fields, and the top-up would fire `getSimilarSongs2('')` -- silently returning nothing and leaving the queue dry just before the radio rail would have refilled. - prefer the just-played `currentTrack` (always fully resolved in the player store) and the stored radio seed artist id - fall back to the next-track metadata only when those are missing - skip the top-up entirely when no stable seed is available, instead of emitting a non-deterministic empty request * docs(changelog): queue mixed-server routing and quota-safe persist (#872) |
||
|
|
45e0e1206f |
fix(playback): pin queue playback to source server when browsing another library (#717)
* fix(playback): pin queue streams, cover art, and library links to queue server When the active server changes while a queue from another server is playing, keep streams and UI on queueServerId; switch back for artist/album links and queue or player-bar context menus. * fix(playback): switch to queue server when opening Now Playing Ensure active server matches queueServerId before Subsonic fetches on the Now Playing page, mobile player route, and queue info panel; scope caches by server id. * docs(credits): mention Now Playing in PR #717 contribution line * fix(playback): route scrobble and queue sync to queue server Address PR review: apiForServer for scrobble/now-playing/savePlayQueue, clear queueServerId on server removal, mini-player queueServerId sync, block cross-server enqueue with toast, and regression tests. |
||
|
|
3cc172723d |
fix(ui): split album and track artists (OpenSubsonic) (#696)
* fix(ui): split OpenSubsonic album and track artists in header and player Album detail header uses albumArtists from album or child songs; player bar, mobile player, and mini player use structured track artists with per-id links. Adds deriveAlbumHeaderArtistRefs helper and OpenArtistRefInline. Fixes #552 * docs: changelog and credits for OpenSubsonic artist links (PR #696) |
||
|
|
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 |
||
|
|
39f4d03da5 |
feat(player): queue undo/redo with hotkeys and playback-aware restore
Snapshot queue, current track, time, and pause before queue mutations. Ctrl+Z/Cmd+Z restores prior state; Ctrl+Shift+Z/Cmd+Shift+Z reapplies undone edits when the redo stack is non-empty; new edits clear redo. Resync the Rust audio engine only when the current song identity changes from the pre-apply state so reorder/enqueue-style edits keep live playback. Register the document capture listener from main.tsx after the window label is set. Mini player forwards undo/redo via mini:undo-queue and mini:redo-queue. |
||
|
|
9f21edee80 |
feat(mini): queue-style meta block, action toolbar, vertical volume slider
Restructure the MiniPlayer to mirror the main window's queue panel layout: UI changes: - Meta block: cover + title/artist/album/year (year added to MiniTrackInfo) - Action toolbar styled like .queue-round-btn (30px round, accent fill when active), in order: volume, shuffle | gapless, crossfade, infinite | queue - Volume button opens a thin 5px vertical strip slider that drops down (click / drag / wheel to adjust). Right-click on the volume button mutes. - Controls + progress moved to the bottom as a true footer (margin-top: auto), so they stay anchored even with the queue expanded - Queue toggle moved out of the titlebar into the action bar (logically lives with the other queue/playback toggles) - Window size bumped to 340x260 collapsed / 340x500 expanded for the new layout Bridge changes (miniPlayerBridge.ts): - MiniSyncPayload extended with volume, gaplessEnabled, crossfadeEnabled, infiniteQueueEnabled - Bridge now subscribes to authStore in addition to playerStore so toolbar toggle states propagate cross-window - New events: mini:set-volume, mini:shuffle, mini:set-gapless, mini:set-crossfade, mini:set-infinite-queue - Bridge enforces gapless ↔ crossfade mutual exclusion (per CLAUDE.md gotcha) so the mini doesn't need to know about both states to act Misc: - Belt-and-suspenders user-select: none on .mini-player-shell * to kill Ctrl+A / mouse-drag selection that WebKit/WebView2 occasionally let through |
||
|
|
2871db9a96 |
feat(mini-player): persistent geometry, queue DnD + context menu, overlay scrollbar, live theme sync
This iteration fills in the pieces that make the mini player usable as a daily standalone window: - Window position persists to <app_config_dir>/mini_player_pos.json on every WindowEvent::Moved (throttled 250 ms); first launch lands in the bottom-right of the main window's monitor. - Queue keeps a 260 px floor when expanded (~2 visible rows). The user-resized expanded height is restored on next toggle via localStorage so reopening doesn't snap back to the default 440 px. - set_mini_player_always_on_top(true) now forces a false-then-true cycle so the WM re-evaluates the layer after a hide/show; the frontend also re-asserts the pin state on mount and on window focus, so the user no longer has to click the pin button twice for it to stick. - Native scrollbar in the queue is hidden in favour of a JS-driven overlay thumb so items use the full width of the queue area. - PsyDnD reorder works inside the mini queue: drag emits 'mini:reorder', the bridge calls reorderQueue on the source-of-truth store in main. - Localized queue-item context menu (MiniContextMenu) with Play now / Remove from queue / Open album / Go to artist / Favorite / Song info. Cross-window actions forward to main via new bridge events (mini:reorder, mini:remove, mini:navigate, mini:song-info); a psy:navigate CustomEvent picked up by AppShell handles routing. - Theme, font and language changes in main propagate live to the mini via a 'storage' event listener that re-hydrates the persisted Zustand stores and calls i18n.changeLanguage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
0afcc4ab68 |
feat(mini-player): expandable queue + UX polish
Adds a queue panel that toggles via a new toolbar button and resizes the mini window between 340×180 (collapsed) and 340×440 (expanded). Tracks in the queue are clickable — click jumps to that index via a new mini:jump event handled in the main-window bridge. The current track auto-scrolls into view when the queue opens. Resize uses a new resize_mini_player Rust command (more reliable than JS setSize, which was silently no-op'ing because the core:window:allow-set-size capability was missing). That capability is now granted as well. The mini player hydrates its initial state from the persisted playerStore, so real content (cover, title, artist, queue) shows as soon as React mounts instead of "—" while we wait for the first mini:sync. Dynamic state (isPlaying, progress) still comes from events. Window sizing: - inner_size bumped to 340×180 so the toolbar row isn't clipped - mini label added to tauri-plugin-window-state denylist so old saved sizes don't come back - show_main_window now also hides the mini — clicking expand no longer leaves both windows on screen Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
cef2db92cb |
feat(mini-player): floating mini window — early alpha (#162)
A second webview window (label "mini") with a compact player: album art, title, artist, prev/play/next, progress bar, pin-on-top toggle, expand back to main, close. Main minimizes on open and restores when the mini is hidden or closed. Spacebar toggles, arrow keys skip tracks. Cross-window sync: main window subscribes to playerStore and pushes `mini:sync` via emitTo on track / play-state change. Audio progress already broadcasts to all windows via `audio:progress`. Control actions (prev/next/toggle) come back via `mini:control`; main-window restore goes through a direct Rust command because WebKitGTK pauses JS in a minimized webview. Tiling-WM detection reused from existing `is_tiling_wm()` — always-on-top is skipped on Hyprland/Sway/i3 since it's ignored there anyway. Early alpha: no queue expand, no lyrics, no EQ, no drag-snap. Scope kept deliberately tight for v1; follow-ups planned. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |