mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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)
This commit is contained in:
committed by
GitHub
parent
a8cfff0b62
commit
45b9229ceb
@@ -48,7 +48,7 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack, makeServer } from '@/test/helpers/factories';
|
||||
import { makeTrack, makeServer, seedQueue } from '@/test/helpers/factories';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('ContextMenu — type=artist', () => {
|
||||
describe('ContextMenu — type=queue-item', () => {
|
||||
it('shows a Remove from Queue affordance the song menu does not have', () => {
|
||||
const track = makeTrack({ id: 'q-1' });
|
||||
usePlayerStore.setState({ queue: [track], queueIndex: 0, currentTrack: track });
|
||||
seedQueue([track], { index: 0, currentTrack: track });
|
||||
openMenuFor('queue-item', track, 0);
|
||||
const { container } = renderWithProviders(<ContextMenu />);
|
||||
expect(container.querySelector('.context-menu')).not.toBeNull();
|
||||
|
||||
@@ -27,14 +27,14 @@ export default function ContextMenu() {
|
||||
const navigate = useNavigate();
|
||||
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queueItems, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
|
||||
useShallow(s => ({
|
||||
contextMenu: s.contextMenu,
|
||||
closeContextMenu: s.closeContextMenu,
|
||||
playTrack: s.playTrack,
|
||||
enqueue: s.enqueue,
|
||||
playNext: s.playNext,
|
||||
queue: s.queue,
|
||||
queueItems: s.queueItems,
|
||||
currentTrack: s.currentTrack,
|
||||
removeTrack: s.removeTrack,
|
||||
lastfmLovedCache: s.lastfmLovedCache,
|
||||
@@ -202,7 +202,7 @@ export default function ContextMenu() {
|
||||
playNext={playNext}
|
||||
enqueue={enqueue}
|
||||
removeTrack={removeTrack}
|
||||
queue={queue}
|
||||
queue={queueItems}
|
||||
currentTrack={currentTrack}
|
||||
closeContextMenu={closeContextMenu}
|
||||
starredOverrides={starredOverrides}
|
||||
|
||||
@@ -53,7 +53,7 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack } from '@/test/helpers/factories';
|
||||
import { makeTrack, seedQueue } from '@/test/helpers/factories';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
@@ -147,12 +147,11 @@ describe('FullscreenPlayer — control wiring', () => {
|
||||
});
|
||||
|
||||
it('clicking Previous Track calls previous()', () => {
|
||||
usePlayerStore.setState({
|
||||
queue: [makeTrack({ id: 'a' }), makeTrack({ id: 'b' })],
|
||||
queueIndex: 1,
|
||||
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
|
||||
index: 1,
|
||||
currentTrack: makeTrack({ id: 'b' }),
|
||||
currentTime: 5,
|
||||
});
|
||||
usePlayerStore.setState({ currentTime: 5 });
|
||||
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
@@ -162,9 +161,8 @@ describe('FullscreenPlayer — control wiring', () => {
|
||||
});
|
||||
|
||||
it('clicking Next Track calls next()', () => {
|
||||
usePlayerStore.setState({
|
||||
queue: [makeTrack({ id: 'a' }), makeTrack({ id: 'b' })],
|
||||
queueIndex: 0,
|
||||
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
|
||||
index: 0,
|
||||
currentTrack: makeTrack({ id: 'a' }),
|
||||
});
|
||||
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
|
||||
|
||||
@@ -21,6 +21,7 @@ import { FsPlayBtn } from './fullscreenPlayer/FsPlayBtn';
|
||||
import { useFsDynamicAccent } from '../hooks/useFsDynamicAccent';
|
||||
import { useFsArtistPortrait } from '../hooks/useFsArtistPortrait';
|
||||
import { useFsIdleFade } from '../hooks/useFsIdleFade';
|
||||
import { useQueueTrackAt } from '../hooks/useQueueTracks';
|
||||
|
||||
interface FullscreenPlayerProps {
|
||||
onClose: () => void;
|
||||
@@ -73,13 +74,12 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
|
||||
const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple';
|
||||
|
||||
// Pre-fetch next track's 300px cover into the IndexedDB cache.
|
||||
// Selector returns only the coverArt id, so it only re-runs on actual changes.
|
||||
const nextCoverArt = usePlayerStore(s => {
|
||||
const q = s.queue;
|
||||
const idx = s.queueIndex;
|
||||
return (idx >= 0 && idx + 1 < q.length) ? (q[idx + 1]?.coverArt ?? null) : null;
|
||||
});
|
||||
// Pre-fetch next track's 300px cover into the IndexedDB cache. Resolver-first
|
||||
// (thin-state): the next ref resolves from the cache (the prefetch window
|
||||
// around the current index keeps it warm).
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const nextTrack = useQueueTrackAt(queueIndex + 1);
|
||||
const nextCoverArt = queueIndex >= 0 ? (nextTrack?.coverArt ?? null) : null;
|
||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import React, { useState, useCallback, useMemo, useRef, useEffect, useSyncExtern
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import {
|
||||
ChevronDown, Play, Pause, SkipBack, SkipForward,
|
||||
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
|
||||
@@ -15,6 +16,11 @@ import { usePlayerStore } from '../store/playerStore';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||
import { formatTrackTime } from '../utils/format/formatDuration';
|
||||
import { resolveQueueTrack } from '../utils/library/queueTrackView';
|
||||
import {
|
||||
getQueueResolverVersion,
|
||||
subscribeQueueResolver,
|
||||
} from '../utils/library/queueTrackResolver';
|
||||
import LyricsPane from './LyricsPane';
|
||||
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
||||
import PlaybackDelayModal from './PlaybackDelayModal';
|
||||
@@ -71,17 +77,42 @@ function useAlbumAccentColor(imageUrl: string): string {
|
||||
|
||||
// ── Queue Drawer ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Stable initial rect so the virtualizer never re-initializes on re-render (an
|
||||
// inline literal would be a new ref each render → render loop). Replaced by the
|
||||
// real height on first ResizeObserver measure.
|
||||
const QUEUE_INITIAL_RECT = { width: 0, height: 600 };
|
||||
|
||||
function QueueDrawer({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queue = usePlayerStore(s => s.queueItems);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
// Thin-state: the queue is the canonical `QueueItemRef[]`; each row's Track
|
||||
// comes from the resolver (cache → placeholder), matching the desktop
|
||||
// QueueList. Subscribe once so rows re-render as the resolver cache fills.
|
||||
useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
||||
|
||||
// Scroll active track into view on open
|
||||
// Virtualize so a multi-thousand-track queue keeps DOM at O(visible rows) on
|
||||
// mobile too (matches the desktop QueuePanel).
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: queue.length,
|
||||
getScrollElement: () => listRef.current,
|
||||
estimateSize: () => 56,
|
||||
overscan: 10,
|
||||
getItemKey: i => `${queue[i].trackId}:${i}`,
|
||||
initialRect: QUEUE_INITIAL_RECT,
|
||||
});
|
||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
|
||||
// Scroll the active track into view on open. Rows are uniform height, so the
|
||||
// virtualizer's estimate lands the centred index accurately.
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.querySelector('.mq-item.active');
|
||||
el?.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
if (queueIndex >= 0 && queue.length > 0) {
|
||||
rowVirtualizer.scrollToIndex(queueIndex, { align: 'center' });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -100,13 +131,19 @@ function QueueDrawer({ onClose }: { onClose: () => void }) {
|
||||
{queue.length === 0 ? (
|
||||
<div className="mq-drawer-empty">{t('queue.emptyQueue')}</div>
|
||||
) : (
|
||||
queue.map((track, idx) => {
|
||||
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
|
||||
{virtualItems.map(vi => {
|
||||
const idx = vi.index;
|
||||
const track = resolveQueueTrack(queue[idx]);
|
||||
const isActive = idx === queueIndex;
|
||||
return (
|
||||
<div
|
||||
key={`${track.id}-${idx}`}
|
||||
key={vi.key}
|
||||
data-index={idx}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
className={`mq-item${isActive ? ' active' : ''}`}
|
||||
onClick={() => { playTrack(track, queue); onClose(); }}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
||||
onClick={() => { playTrack(track, undefined, undefined, undefined, idx); onClose(); }}
|
||||
>
|
||||
<div className="mq-item-info">
|
||||
<div className="mq-item-title">
|
||||
@@ -118,7 +155,8 @@ function QueueDrawer({ onClose }: { onClose: () => void }) {
|
||||
<span className="mq-item-dur">{formatTrackTime(track.duration)}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack } from '@/test/helpers/factories';
|
||||
import { makeTrack, seedQueue } from '@/test/helpers/factories';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
@@ -96,12 +96,11 @@ describe('PlayerBar — control wiring', () => {
|
||||
});
|
||||
|
||||
it('clicking Previous Track calls previous()', () => {
|
||||
usePlayerStore.setState({
|
||||
queue: [makeTrack({ id: 'a' }), makeTrack({ id: 'b' })],
|
||||
queueIndex: 1,
|
||||
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
|
||||
index: 1,
|
||||
currentTrack: makeTrack({ id: 'b' }),
|
||||
currentTime: 10, // > 3 s → restart current
|
||||
});
|
||||
usePlayerStore.setState({ currentTime: 10 }); // > 3 s → restart current
|
||||
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
|
||||
|
||||
const { getByLabelText } = renderWithProviders(<PlayerBar />);
|
||||
@@ -113,7 +112,7 @@ describe('PlayerBar — control wiring', () => {
|
||||
it('clicking Next Track calls next()', () => {
|
||||
const t1 = makeTrack({ id: 'a' });
|
||||
const t2 = makeTrack({ id: 'b' });
|
||||
usePlayerStore.setState({ queue: [t1, t2], queueIndex: 0, currentTrack: t1 });
|
||||
seedQueue([t1, t2], { index: 0, currentTrack: t1 });
|
||||
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
|
||||
|
||||
const { getByLabelText } = renderWithProviders(<PlayerBar />);
|
||||
|
||||
@@ -41,7 +41,7 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack, makeTracks } from '@/test/helpers/factories';
|
||||
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
@@ -86,11 +86,7 @@ describe('QueuePanel — render surface', () => {
|
||||
|
||||
it('renders one row per queue track with the matching data-queue-idx', () => {
|
||||
const tracks = makeTracks(3);
|
||||
usePlayerStore.setState({
|
||||
queue: tracks,
|
||||
queueIndex: 0,
|
||||
currentTrack: tracks[0],
|
||||
});
|
||||
seedQueue(tracks, { index: 0, currentTrack: tracks[0] });
|
||||
const { container } = renderWithProviders(<QueuePanel />);
|
||||
const rows = container.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||
expect(rows.length).toBe(3);
|
||||
@@ -101,11 +97,7 @@ describe('QueuePanel — render surface', () => {
|
||||
it('renders each queue row with the track title text', () => {
|
||||
const t1 = makeTrack({ id: 'q1', title: 'Test Song A' });
|
||||
const t2 = makeTrack({ id: 'q2', title: 'Test Song B' });
|
||||
usePlayerStore.setState({
|
||||
queue: [t1, t2],
|
||||
queueIndex: 0,
|
||||
currentTrack: t1,
|
||||
});
|
||||
seedQueue([t1, t2], { index: 0, currentTrack: t1 });
|
||||
const { getAllByText, getByText } = renderWithProviders(<QueuePanel />);
|
||||
// Title A appears both in the now-playing section and in the row;
|
||||
// assert at least one match. Title B only lives in its row.
|
||||
@@ -117,11 +109,7 @@ describe('QueuePanel — render surface', () => {
|
||||
describe('QueuePanel — toolbar', () => {
|
||||
it('exposes Shuffle / Save Playlist / Load Playlist / Share Queue / Clear via aria-label', () => {
|
||||
const tracks = makeTracks(3);
|
||||
usePlayerStore.setState({
|
||||
queue: tracks,
|
||||
queueIndex: 0,
|
||||
currentTrack: tracks[0],
|
||||
});
|
||||
seedQueue(tracks, { index: 0, currentTrack: tracks[0] });
|
||||
const { getByLabelText } = renderWithProviders(<QueuePanel />);
|
||||
expect(getByLabelText('Shuffle queue')).toBeInTheDocument();
|
||||
expect(getByLabelText('Save Playlist')).toBeInTheDocument();
|
||||
@@ -131,11 +119,7 @@ describe('QueuePanel — toolbar', () => {
|
||||
});
|
||||
|
||||
it('Shuffle button is disabled when the queue has fewer than 2 tracks', () => {
|
||||
usePlayerStore.setState({
|
||||
queue: [makeTrack()],
|
||||
queueIndex: 0,
|
||||
currentTrack: makeTrack(),
|
||||
});
|
||||
seedQueue([makeTrack()], { index: 0, currentTrack: makeTrack() });
|
||||
const { getByLabelText } = renderWithProviders(<QueuePanel />);
|
||||
const shuffle = getByLabelText('Shuffle queue') as HTMLButtonElement;
|
||||
expect(shuffle.disabled).toBe(true);
|
||||
@@ -149,11 +133,7 @@ describe('QueuePanel — DnD architecture pin (§4.4 of v2 plan)', () => {
|
||||
// queue back to native HTML5 DnD breaks loudly.
|
||||
|
||||
it('queue rows do not declare draggable=true (no HTML5 native drag)', () => {
|
||||
usePlayerStore.setState({
|
||||
queue: makeTracks(3),
|
||||
queueIndex: 0,
|
||||
currentTrack: makeTrack(),
|
||||
});
|
||||
seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() });
|
||||
const { container } = renderWithProviders(<QueuePanel />);
|
||||
const rows = container.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||
for (const row of rows) {
|
||||
|
||||
@@ -72,7 +72,10 @@ function QueuePanelHostOrSolo() {
|
||||
if (!addedBy || addedBy === orbitHostUsername) return t('orbit.queueAddedByYou');
|
||||
return t('orbit.queueAddedByUser', { user: addedBy });
|
||||
};
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
// Thin-state: the queue is the canonical `QueueItemRef[]`; rows resolve their
|
||||
// Track from the resolver. List, header, toolbar and id/length reads (save /
|
||||
// share / playlist) all read off the refs.
|
||||
const queueItems = usePlayerStore(s => s.queueItems);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
@@ -152,7 +155,7 @@ function QueuePanelHostOrSolo() {
|
||||
});
|
||||
|
||||
useQueueAutoScroll({
|
||||
queue,
|
||||
queue: queueItems,
|
||||
queueIndex,
|
||||
currentTrack,
|
||||
queueListRef,
|
||||
@@ -165,11 +168,11 @@ function QueuePanelHostOrSolo() {
|
||||
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (queue.length === 0) return;
|
||||
if (queueItems.length === 0) return;
|
||||
if (activePlaylist) {
|
||||
setSaveState('saving');
|
||||
try {
|
||||
await updatePlaylist(activePlaylist.id, queue.map(t => t.id));
|
||||
await updatePlaylist(activePlaylist.id, queueItems.map(r => r.trackId));
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 1500);
|
||||
} catch (e) {
|
||||
@@ -191,13 +194,13 @@ function QueuePanelHostOrSolo() {
|
||||
};
|
||||
|
||||
const handleCopyQueueShare = async () => {
|
||||
if (queue.length === 0) {
|
||||
if (queueItems.length === 0) {
|
||||
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
|
||||
return;
|
||||
}
|
||||
const srv = useAuthStore.getState().getBaseUrl();
|
||||
if (!srv) return;
|
||||
const ids = queue.map(t => t.id);
|
||||
const ids = queueItems.map(r => r.trackId);
|
||||
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
@@ -239,7 +242,7 @@ function QueuePanelHostOrSolo() {
|
||||
</>
|
||||
)}
|
||||
<QueueHeader
|
||||
queue={queue}
|
||||
queue={queueItems}
|
||||
queueIndex={queueIndex}
|
||||
activePlaylist={activePlaylist}
|
||||
isNowPlayingCollapsed={isNowPlayingCollapsed}
|
||||
@@ -279,7 +282,7 @@ function QueuePanelHostOrSolo() {
|
||||
{activeTab === 'queue' ? (<>
|
||||
{!isNowPlayingCollapsed && toolbarButtons.some(b => b.visible && b.id !== 'separator') && (
|
||||
<QueueToolbar
|
||||
queue={queue}
|
||||
queue={queueItems}
|
||||
activePlaylist={activePlaylist}
|
||||
saveState={saveState}
|
||||
toolbarButtons={toolbarButtons}
|
||||
@@ -300,10 +303,10 @@ function QueuePanelHostOrSolo() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
|
||||
{currentTrack && queueItems.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
|
||||
|
||||
<QueueList
|
||||
queue={queue}
|
||||
queue={queueItems}
|
||||
queueIndex={queueIndex}
|
||||
contextMenu={contextMenu}
|
||||
playTrack={playTrack}
|
||||
@@ -332,7 +335,7 @@ function QueuePanelHostOrSolo() {
|
||||
onSave={async (name) => {
|
||||
try {
|
||||
const createPlaylist = usePlaylistStore.getState().createPlaylist;
|
||||
const pl = await createPlaylist(name, queue.map(t => t.id));
|
||||
const pl = await createPlaylist(name, queueItems.map(r => r.trackId));
|
||||
if (pl) setActivePlaylist({ id: pl.id, name: pl.name });
|
||||
setSaveModalOpen(false);
|
||||
} catch (e) {
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
|
||||
const song = item as Track;
|
||||
return (
|
||||
<>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, queue, undefined, undefined, queueIndex))}>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, undefined, undefined, undefined, queueIndex))}>
|
||||
<Play size={14} /> {t('contextMenu.playNow')}
|
||||
</div>
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type React from 'react';
|
||||
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
|
||||
import type { EntityShareKind } from '../../utils/share/shareLink';
|
||||
|
||||
export type RatingKind = 'song' | 'album' | 'artist';
|
||||
@@ -22,7 +22,9 @@ export interface ContextMenuItemsProps {
|
||||
playNext: (tracks: Track[]) => void;
|
||||
enqueue: (tracks: Track[]) => void;
|
||||
removeTrack: (idx: number) => void;
|
||||
queue: Track[];
|
||||
/** Thin-state: the canonical queue refs. The queue-item "Play now" action uses
|
||||
* the row's `queueIndex` to jump in place — no full Track[] needed. */
|
||||
queue: QueueItemRef[];
|
||||
currentTrack: Track | null;
|
||||
closeContextMenu: () => void;
|
||||
starredOverrides: Record<string, boolean>;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { TFunction } from 'i18next';
|
||||
import OverlayScrollArea from '../OverlayScrollArea';
|
||||
import type { MiniSyncPayload, MiniTrackInfo } from '../../utils/miniPlayerBridge';
|
||||
|
||||
// Stable initial rect so the virtualizer never re-initializes on re-render (an
|
||||
// inline literal would be a new ref each render → render loop). Replaced by the
|
||||
// real height on first ResizeObserver measure.
|
||||
const MINI_QUEUE_INITIAL_RECT = { width: 0, height: 400 };
|
||||
|
||||
type StartDrag = (
|
||||
payload: { data: string; label: string },
|
||||
x: number,
|
||||
@@ -30,13 +36,26 @@ export function MiniQueue({
|
||||
dropTarget, setDropTarget, dropTargetRef, startDrag, ctxIndex, setCtxMenu,
|
||||
jumpTo, t,
|
||||
}: Props) {
|
||||
// Virtualize so a multi-thousand-track queue keeps the mini window's DOM at
|
||||
// O(visible rows). Scroll element is the OverlayScrollArea viewport.
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: state.queue.length,
|
||||
getScrollElement: () => queueScrollRef.current,
|
||||
estimateSize: () => 40,
|
||||
overscan: 10,
|
||||
getItemKey: i => `${state.queue[i].id}:${i}`,
|
||||
initialRect: MINI_QUEUE_INITIAL_RECT,
|
||||
});
|
||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
|
||||
return (
|
||||
<OverlayScrollArea
|
||||
wrapRef={miniQueueWrapRef}
|
||||
viewportRef={queueScrollRef}
|
||||
className="mini-queue-wrap"
|
||||
viewportClassName="mini-queue"
|
||||
measureDeps={[state.queue.length]}
|
||||
measureDeps={[state.queue.length, totalSize]}
|
||||
railInset="mini"
|
||||
viewportScrollBehaviorAuto={isReorderDrag}
|
||||
onMouseMove={(e) => {
|
||||
@@ -60,7 +79,10 @@ export function MiniQueue({
|
||||
{state.queue.length === 0 ? (
|
||||
<div className="mini-queue__empty">{t('miniPlayer.emptyQueue')}</div>
|
||||
) : (
|
||||
state.queue.map((track, i) => {
|
||||
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
|
||||
{virtualItems.map(vi => {
|
||||
const i = vi.index;
|
||||
const track = state.queue[i];
|
||||
let dragStyle: React.CSSProperties = {};
|
||||
if (isReorderDrag && psyDragFromIdxRef.current === i) {
|
||||
dragStyle = { opacity: 0.4 };
|
||||
@@ -71,7 +93,9 @@ export function MiniQueue({
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={`${track.id}-${i}`}
|
||||
key={vi.key}
|
||||
data-index={i}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-mq-idx={i}
|
||||
className={`mini-queue__item${i === state.queueIndex ? ' mini-queue__item--current' : ''}${ctxIndex === i ? ' mini-queue__item--ctx' : ''}`}
|
||||
onClick={() => jumpTo(i)}
|
||||
@@ -105,7 +129,7 @@ export function MiniQueue({
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
style={dragStyle}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)`, ...dragStyle }}
|
||||
>
|
||||
<span className="mini-queue__num">{i + 1}</span>
|
||||
<div className="mini-queue__meta">
|
||||
@@ -114,7 +138,8 @@ export function MiniQueue({
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</OverlayScrollArea>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { usePreviewStore } from '../../store/previewStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
|
||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||
import { getQueueTracksView } from '../../utils/library/queueTrackView';
|
||||
import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
|
||||
@@ -114,19 +115,22 @@ export default function PlaylistSuggestions({
|
||||
className="playlist-suggestion-play-btn"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
const { queue, queueIndex, currentTrack, playTrack } = usePlayerStore.getState();
|
||||
const { queueItems, queueIndex, currentTrack, playTrack } = usePlayerStore.getState();
|
||||
const track = songToTrack(song);
|
||||
if (!currentTrack || queue.length === 0) {
|
||||
if (!currentTrack || queueItems.length === 0) {
|
||||
playTrack(track, [track]);
|
||||
return;
|
||||
}
|
||||
const insertAt = Math.min(queueIndex + 1, queue.length);
|
||||
// Thin-state: resolve the current queue, insert after
|
||||
// the playing track, and play the inserted track.
|
||||
const resolved = getQueueTracksView(queueItems);
|
||||
const insertAt = Math.min(queueIndex + 1, resolved.length);
|
||||
const newQueue = [
|
||||
...queue.slice(0, insertAt),
|
||||
...resolved.slice(0, insertAt),
|
||||
track,
|
||||
...queue.slice(insertAt),
|
||||
...resolved.slice(insertAt),
|
||||
];
|
||||
playTrack(track, newQueue);
|
||||
playTrack(track, newQueue, undefined, undefined, insertAt);
|
||||
}}
|
||||
data-tooltip={t('playlists.playNextSuggestion')}
|
||||
aria-label={t('playlists.playNextSuggestion')}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useDeferredValue, useMemo, useSyncExternalStore } from 'react';
|
||||
import { ChevronDown, ListMusic } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import type { QueueItemRef } from '../../store/playerStoreTypes';
|
||||
import type { DurationMode } from '../../utils/componentHelpers/queuePanelHelpers';
|
||||
import { formatLongDuration } from '../../utils/format/formatDuration';
|
||||
import { formatClockTime } from '../../utils/format/formatClockTime';
|
||||
import { resolveQueueTrack } from '../../utils/library/queueTrackView';
|
||||
import {
|
||||
getQueueResolverVersion,
|
||||
subscribeQueueResolver,
|
||||
} from '../../utils/library/queueTrackResolver';
|
||||
|
||||
interface Props {
|
||||
queue: Track[];
|
||||
queue: QueueItemRef[];
|
||||
queueIndex: number;
|
||||
activePlaylist: { id: string; name: string } | null;
|
||||
isNowPlayingCollapsed: boolean;
|
||||
@@ -29,16 +34,32 @@ export function QueueHeader({
|
||||
const clockFormat = useAuthStore((s) => s.clockFormat);
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const totalSecs = useMemo(() =>
|
||||
queue.reduce((acc: number, track: Track) => acc + (track.duration || 0), 0),
|
||||
[queue]
|
||||
);
|
||||
const futureTracksDuration = useMemo(() =>
|
||||
queue.slice(queueIndex + 1).reduce((acc: number, track: Track) => acc + (track.duration || 0), 0),
|
||||
[queue, queueIndex]
|
||||
);
|
||||
// Thin-state: durations come from the resolver cache. The totals re-derive as
|
||||
// the cache fills (version) and on queue change; tracks past the cache window
|
||||
// contribute 0 until they resolve. Pure read (no cache mutation) in the memo.
|
||||
// H1 mitigation: a mass-resolve burst (queue restore, prefetch window slide)
|
||||
// bumps `version` dozens of times in one frame; useDeferredValue coalesces
|
||||
// the burst into a single low-priority commit so long queues do not block
|
||||
// the main thread on every cache tick. The aggregation itself is a single
|
||||
// pass — one loop produces both totals so a 50k-track queue costs one walk,
|
||||
// not two.
|
||||
const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
||||
const deferredVersion = useDeferredValue(version);
|
||||
const { totalSecs, futureTracksDuration } = useMemo(() => {
|
||||
if (queue.length === 0) return { totalSecs: 0, futureTracksDuration: 0 };
|
||||
let total = 0;
|
||||
let future = 0;
|
||||
for (let i = 0; i < queue.length; i += 1) {
|
||||
const dur = resolveQueueTrack(queue[i]).duration || 0;
|
||||
total += dur;
|
||||
if (i > queueIndex) future += dur;
|
||||
}
|
||||
return { totalSecs: total, futureTracksDuration: future };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queue, queueIndex, deferredVersion]);
|
||||
|
||||
const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + futureTracksDuration);
|
||||
const currentDuration = queue[queueIndex] ? resolveQueueTrack(queue[queueIndex]).duration : 0;
|
||||
const remainingSecs = Math.max(0, (currentDuration ?? 0) - currentTime + futureTracksDuration);
|
||||
|
||||
let dur: string | null = null;
|
||||
if (queue.length > 0) {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Play } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import OverlayScrollArea from '../OverlayScrollArea';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { useLuckyMixStore } from '../../store/luckyMixStore';
|
||||
import type { Track, PlayerState } from '../../store/playerStoreTypes';
|
||||
import type { QueueItemRef, PlayerState } from '../../store/playerStoreTypes';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
import { resolveQueueTrack } from '../../utils/library/queueTrackView';
|
||||
import {
|
||||
getQueueResolverVersion,
|
||||
subscribeQueueResolver,
|
||||
} from '../../utils/library/queueTrackResolver';
|
||||
|
||||
type StartDrag = (
|
||||
payload: { data: string; label: string },
|
||||
@@ -15,7 +20,7 @@ type StartDrag = (
|
||||
) => void;
|
||||
|
||||
interface Props {
|
||||
queue: Track[];
|
||||
queue: QueueItemRef[];
|
||||
queueIndex: number;
|
||||
contextMenu: PlayerState['contextMenu'];
|
||||
playTrack: PlayerState['playTrack'];
|
||||
@@ -31,11 +36,22 @@ interface Props {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
// Stable reference so the virtualizer never sees a "changed" option on re-render
|
||||
// (an inline object literal would be a new ref every render). Only used until the
|
||||
// ResizeObserver reports the real viewport height.
|
||||
const INITIAL_RECT = { width: 0, height: 600 };
|
||||
|
||||
export function QueueList({
|
||||
queue, queueIndex, contextMenu, playTrack, activeTab, queueListRef,
|
||||
suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget,
|
||||
startDrag, orbitAttributionLabel, luckyRolling, t,
|
||||
}: Props) {
|
||||
// Thin-state: the queue prop is the canonical `QueueItemRef[]`. Each row's
|
||||
// full Track comes from the resolver (cache → placeholder; F4 overrides merged
|
||||
// in resolveQueueTrack). Subscribe once so the list re-renders as the cache
|
||||
// fills. Pure read in render — no cache mutation (the freeze landmine).
|
||||
useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
||||
|
||||
// Virtualize so a 10k+ Artist-Radio queue keeps DOM at O(visible rows).
|
||||
// Scroll element is the OverlayScrollArea viewport (`queueListRef`); rows have
|
||||
// variable height (radio/auto dividers, lucky-mix loader) so we measure them.
|
||||
@@ -44,11 +60,11 @@ export function QueueList({
|
||||
getScrollElement: () => queueListRef.current,
|
||||
estimateSize: () => 52,
|
||||
overscan: 10,
|
||||
getItemKey: i => `${queue[i].id}:${i}`,
|
||||
getItemKey: i => `${queue[i].trackId}:${i}`,
|
||||
// Start with a sensible viewport height so rows render before the
|
||||
// ResizeObserver reports the real size (SSR / jsdom, where the observer
|
||||
// never fires). The real height overrides this on first measure.
|
||||
initialRect: { width: 0, height: 600 },
|
||||
initialRect: INITIAL_RECT,
|
||||
});
|
||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
@@ -93,10 +109,11 @@ export function QueueList({
|
||||
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
|
||||
{virtualItems.map(vi => {
|
||||
const idx = vi.index;
|
||||
const track = queue[idx];
|
||||
const base = queue[idx];
|
||||
const track = resolveQueueTrack(base);
|
||||
const isPlaying = idx === queueIndex;
|
||||
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
|
||||
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
|
||||
const isFirstAutoAdded = base.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
|
||||
const isFirstRadioAdded = base.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
|
||||
|
||||
let dragStyle: React.CSSProperties = {};
|
||||
if (isQueueDrag && psyDragFromIdxRef.current === idx) {
|
||||
@@ -131,9 +148,10 @@ export function QueueList({
|
||||
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
|
||||
onClick={() => {
|
||||
suppressNextAutoScrollRef.current = true;
|
||||
// Pass the row index so a click on a duplicate track lands on
|
||||
// *this* slot, not the first occurrence (issue #500).
|
||||
playTrack(track, queue, undefined, undefined, idx);
|
||||
// Same-queue jump: undefined keeps the canonical refs; the row
|
||||
// index lands a click on a duplicate track on *this* slot, not
|
||||
// the first occurrence (issue #500).
|
||||
playTrack(track, undefined, undefined, undefined, idx);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -3,14 +3,14 @@ import {
|
||||
Check, FolderOpen, Infinity, MoveRight, Save, Share2, Shuffle, Trash2, Waves,
|
||||
} from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import type { QueueItemRef } from '../../store/playerStoreTypes';
|
||||
import type {
|
||||
QueueToolbarButtonConfig,
|
||||
QueueToolbarButtonId,
|
||||
} from '../../store/queueToolbarStore';
|
||||
|
||||
interface Props {
|
||||
queue: Track[];
|
||||
queue: QueueItemRef[];
|
||||
activePlaylist: { id: string; name: string } | null;
|
||||
saveState: 'idle' | 'saving' | 'saved';
|
||||
toolbarButtons: QueueToolbarButtonConfig[];
|
||||
|
||||
Reference in New Issue
Block a user