{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();
diff --git a/src/components/queuePanel/QueueToolbar.tsx b/src/components/queuePanel/QueueToolbar.tsx
index 16e30834..6d04cef1 100644
--- a/src/components/queuePanel/QueueToolbar.tsx
+++ b/src/components/queuePanel/QueueToolbar.tsx
@@ -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[];
diff --git a/src/config/shortcutActionRegistry.ts b/src/config/shortcutActionRegistry.ts
index 239afead..e00ad55f 100644
--- a/src/config/shortcutActionRegistry.ts
+++ b/src/config/shortcutActionRegistry.ts
@@ -331,7 +331,7 @@ export const SHORTCUT_ACTION_REGISTRY = {
runInMiniWindow: false,
run: () => {
const store = usePlayerStore.getState();
- const { currentTrack, queue, stop, resetAudioPause, playTrack, initializeFromServerQueue } = store;
+ const { currentTrack, stop, resetAudioPause, playTrack, initializeFromServerQueue } = store;
stop();
resetAudioPause();
invoke('audio_stop')
@@ -341,9 +341,10 @@ export const SHORTCUT_ACTION_REGISTRY = {
try {
const fresh = await getSong(currentTrack.id);
const t = fresh ? songToTrack(fresh) : currentTrack;
- playTrack(t, queue, true);
+ // No-arg queue: keep the canonical refs, re-bind the current track.
+ playTrack(t, undefined, true);
} catch {
- playTrack(currentTrack, queue, true);
+ playTrack(currentTrack, undefined, true);
}
} else {
await initializeFromServerQueue();
diff --git a/src/cover/usePlaybackCoverArt.test.ts b/src/cover/usePlaybackCoverArt.test.ts
index c508637f..6688cf04 100644
--- a/src/cover/usePlaybackCoverArt.test.ts
+++ b/src/cover/usePlaybackCoverArt.test.ts
@@ -6,6 +6,7 @@ import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { makeTrack } from '../test/helpers/factories';
import { resetAllStores } from '../test/helpers/storeReset';
+import { toQueueItemRefs } from '../utils/library/queueItemRef';
const hoisted = vi.hoisted(() => ({
useCoverArtMock: vi.fn(
@@ -39,7 +40,7 @@ function seedPlaybackState(): { active: string; playback: string } {
useAuthStore.getState().setActiveServer(active);
const track = makeTrack({ id: 'song-1', coverArt: 'cover-1' });
usePlayerStore.setState({
- queue: [track],
+ queueItems: toQueueItemRefs(playback, [track]),
queueIndex: 0,
queueServerId: playback,
currentTrack: track,
diff --git a/src/cover/usePlaybackCoverArt.ts b/src/cover/usePlaybackCoverArt.ts
index 8b0aa786..70abe118 100644
--- a/src/cover/usePlaybackCoverArt.ts
+++ b/src/cover/usePlaybackCoverArt.ts
@@ -11,7 +11,7 @@ export function usePlaybackCoverArt(
displayCssPx: number,
): CoverArtHandle {
const queueServerId = usePlayerStore(s => s.queueServerId);
- const queueLength = usePlayerStore(s => s.queue.length);
+ const queueLength = usePlayerStore(s => s.queueItems.length);
const activeServerId = useAuthStore(s => s.activeServerId);
const serversFingerprint = useAuthStore(s =>
s.servers
diff --git a/src/hooks/tauriBridge/usePlayerSnapshotPublisher.ts b/src/hooks/tauriBridge/usePlayerSnapshotPublisher.ts
index 2f894df5..5505a69c 100644
--- a/src/hooks/tauriBridge/usePlayerSnapshotPublisher.ts
+++ b/src/hooks/tauriBridge/usePlayerSnapshotPublisher.ts
@@ -3,6 +3,11 @@ import { invoke } from '@tauri-apps/api/core';
import { getPlaybackProgressSnapshot } from '../../store/playbackProgress';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
+import { resolveQueueTrack } from '../../utils/library/queueTrackView';
+
+/** Half-width of the CLI snapshot queue window (thin-state — like the mini
+ * bridge, the full 50k queue must not serialize over IPC on every change). */
+const SNAPSHOT_QUEUE_HALF = 100;
/** `psysonic --info`: publishes a JSON snapshot under XDG_RUNTIME_DIR (Rust
* writes atomically). Coalesces store changes through a 200 ms debounce and
@@ -27,12 +32,19 @@ export function usePlayerSnapshotPublisher() {
ct != null
? (ct.id in s.starredOverrides ? s.starredOverrides[ct.id] : Boolean(ct.starred))
: null;
+ // Thin-state: resolve only a window around the playing track (resolver
+ // cache → placeholder) instead of the whole 50k queue. `queue_length`
+ // stays the true total; `queue_index` is remapped into the window.
+ const total = s.queueItems.length;
+ const winStart = Math.max(0, s.queueIndex - SNAPSHOT_QUEUE_HALF);
+ const winEnd = Math.min(total, s.queueIndex + SNAPSHOT_QUEUE_HALF + 1);
+ const windowedQueue = s.queueItems.slice(winStart, winEnd).map(r => resolveQueueTrack(r));
const snapshot = {
current_track: s.currentTrack,
current_radio: s.currentRadio,
- queue: s.queue,
- queue_index: s.queueIndex,
- queue_length: s.queue.length,
+ queue: windowedQueue,
+ queue_index: s.queueIndex - winStart,
+ queue_length: total,
is_playing: s.isPlaying,
current_time: getPlaybackProgressSnapshot().currentTime,
volume: s.volume,
@@ -50,7 +62,7 @@ export function usePlayerSnapshotPublisher() {
trackId: s.currentTrack?.id ?? null,
radioId: s.currentRadio?.id ?? null,
queueIndex: s.queueIndex,
- queueLength: s.queue.length,
+ queueLength: total,
isPlaying: s.isPlaying,
volume: Math.round(s.volume * 100),
repeatMode: s.repeatMode,
diff --git a/src/hooks/useMiniQueueDrag.ts b/src/hooks/useMiniQueueDrag.ts
index ed0c99f7..f4a53145 100644
--- a/src/hooks/useMiniQueueDrag.ts
+++ b/src/hooks/useMiniQueueDrag.ts
@@ -52,7 +52,7 @@ export function useMiniQueueDrag({
if (parsed.type !== 'queue_reorder') return;
const fromIdx = parsed.index as number;
psyDragFromIdxRef.current = null;
- const queueLen = usePlayerStore.getState().queue.length || fallbackQueueLen;
+ const queueLen = usePlayerStore.getState().queueItems.length || fallbackQueueLen;
const insertIdx = tgt
? (tgt.before ? tgt.idx : tgt.idx + 1)
: queueLen;
diff --git a/src/hooks/useNowPlayingPrewarm.test.ts b/src/hooks/useNowPlayingPrewarm.test.ts
index fef1bf3c..a78c1478 100644
--- a/src/hooks/useNowPlayingPrewarm.test.ts
+++ b/src/hooks/useNowPlayingPrewarm.test.ts
@@ -8,6 +8,7 @@ import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { makeTrack } from '../test/helpers/factories';
import { resetAllStores } from '../test/helpers/storeReset';
+import { toQueueItemRefs } from '../utils/library/queueItemRef';
vi.mock('../api/coverCache', () => ({
coverCachePeekBatch: vi.fn(async () => ({})),
@@ -51,7 +52,7 @@ describe('useNowPlayingPrewarm', () => {
coverArt: 'cover-1',
});
usePlayerStore.setState({
- queue: [track],
+ queueItems: toQueueItemRefs(playback, [track]),
queueIndex: 0,
queueServerId: playback,
currentTrack: track,
@@ -76,7 +77,7 @@ describe('useNowPlayingPrewarm', () => {
const { active, playback } = seedServers();
const track = makeTrack({ id: 'song-2', coverArt: 'cover-2' });
usePlayerStore.setState({
- queue: [track],
+ queueItems: toQueueItemRefs(playback, [track]),
queueIndex: 0,
queueServerId: playback,
currentTrack: null,
diff --git a/src/hooks/useOrbitHost.ts b/src/hooks/useOrbitHost.ts
index 24e4faf0..4b4aa46e 100644
--- a/src/hooks/useOrbitHost.ts
+++ b/src/hooks/useOrbitHost.ts
@@ -108,23 +108,24 @@ export function useOrbitHost(): void {
&& (Date.now() - afterSweep.lastShuffle >= effectiveShuffleIntervalMs(afterSweep));
const afterShuffle = maybeShuffleQueue(afterSweep);
if (shouldShuffleNow) {
- const before = usePlayerStore.getState().queue.length;
+ const before = usePlayerStore.getState().queueItems.length;
usePlayerStore.getState().shuffleUpcomingQueue();
if (before > 0) showToast(i18n.t('orbit.toastShuffled'), 2500, 'info');
}
- // 4) Overlay the host's live playback snapshot.
+ // 4) Overlay the host's live playback snapshot. Host tick reads ids only,
+ // so the thin `queueItems` refs are all we need (no full Track resolve).
const playerLive = usePlayerStore.getState();
- const upcoming = playerLive.queue.slice(playerLive.queueIndex + 1);
+ const upcoming = playerLive.queueItems.slice(playerLive.queueIndex + 1);
// Map track id → original suggester (if any). State's `queue` carries
// every suggestion we've ever seen this session, so it's the right
// attribution source even after the track has been merged into the
// host's player queue.
const suggesterByTrack = new Map
();
for (const q of afterShuffle.queue) suggesterByTrack.set(q.trackId, q.addedBy);
- const playQueue = upcoming.slice(0, ORBIT_PLAY_QUEUE_LIMIT).map(t => ({
- trackId: t.id,
- addedBy: suggesterByTrack.get(t.id) ?? base.host,
+ const playQueue = upcoming.slice(0, ORBIT_PLAY_QUEUE_LIMIT).map(r => ({
+ trackId: r.trackId,
+ addedBy: suggesterByTrack.get(r.trackId) ?? base.host,
}));
const next: OrbitState = {
...afterShuffle,
@@ -204,7 +205,7 @@ export function useOrbitHost(): void {
for (const { track } of toEnqueue) {
const live = usePlayerStore.getState();
const from = Math.max(0, live.queueIndex + 1);
- const to = live.queue.length;
+ const to = live.queueItems.length;
const span = Math.max(1, to - from + 1);
const pos = from + Math.floor(Math.random() * span);
player.enqueueAt([track], pos);
diff --git a/src/hooks/usePlaybackServerId.test.ts b/src/hooks/usePlaybackServerId.test.ts
index 73d45a16..54afb57a 100644
--- a/src/hooks/usePlaybackServerId.test.ts
+++ b/src/hooks/usePlaybackServerId.test.ts
@@ -19,7 +19,7 @@ describe('usePlaybackServerId', () => {
isLoggedIn: true,
});
usePlayerStore.setState({
- queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }],
+ queueItems: [{ serverId: 'a', trackId: 't1' }],
queueServerId: 'a',
queueIndex: 0,
});
diff --git a/src/hooks/usePlaybackServerId.ts b/src/hooks/usePlaybackServerId.ts
index ebf4fa19..d4e0f968 100644
--- a/src/hooks/usePlaybackServerId.ts
+++ b/src/hooks/usePlaybackServerId.ts
@@ -9,7 +9,7 @@ import { getPlaybackServerId } from '../utils/playback/playbackServer';
*/
export function usePlaybackServerId(): string {
const queueServerId = usePlayerStore(s => s.queueServerId);
- const queueLength = usePlayerStore(s => s.queue.length);
+ const queueLength = usePlayerStore(s => s.queueItems.length);
const activeServerId = useAuthStore(s => s.activeServerId);
return useMemo(
() => getPlaybackServerId(),
diff --git a/src/hooks/useQueueAutoScroll.ts b/src/hooks/useQueueAutoScroll.ts
index 6a6931e7..1bf844f4 100644
--- a/src/hooks/useQueueAutoScroll.ts
+++ b/src/hooks/useQueueAutoScroll.ts
@@ -1,9 +1,9 @@
import React, { useLayoutEffect } from 'react';
import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '../store/queueUndo';
-import type { Track } from '../store/playerStoreTypes';
+import type { QueueItemRef, Track } from '../store/playerStoreTypes';
interface Args {
- queue: Track[];
+ queue: QueueItemRef[];
queueIndex: number;
currentTrack: Track | null;
queueListRef: React.RefObject;
diff --git a/src/hooks/useQueuePanelDrag.ts b/src/hooks/useQueuePanelDrag.ts
index 281f8785..d8e9950b 100644
--- a/src/hooks/useQueuePanelDrag.ts
+++ b/src/hooks/useQueuePanelDrag.ts
@@ -66,7 +66,7 @@ export function useQueuePanelDrag({
const insertIdx = dropTarget
? (dropTarget.before ? dropTarget.idx : dropTarget.idx + 1)
- : usePlayerStore.getState().queue.length;
+ : usePlayerStore.getState().queueItems.length;
if (parsedData.type === 'queue_reorder') {
const fromIdx: number = parsedData.index;
diff --git a/src/hooks/useQueueTracks.test.ts b/src/hooks/useQueueTracks.test.ts
index f4e09702..f84ee224 100644
--- a/src/hooks/useQueueTracks.test.ts
+++ b/src/hooks/useQueueTracks.test.ts
@@ -3,9 +3,8 @@ import { renderHook } from '@testing-library/react';
import { usePlayerStore } from '@/store/playerStore';
import type { Track } from '@/store/playerStoreTypes';
import { getCachedTrack, _resetQueueResolverForTest } from '@/utils/library/queueTrackResolver';
+import { seedQueue } from '@/test/helpers/factories';
import { useQueueTrackAt, useCurrentTrack, useQueueItems } from './useQueueTracks';
-// Importing the bridge registers the queue→resolver seed subscriber.
-import '@/store/queueResolverBridge';
const track = (id: string, over: Partial