mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(queue): co-locate QueuePanel UI into features/queue
Extract the queue UI (QueuePanel + queuePanel/* components + useQueue* hooks)
into features/queue/ with a barrel. This is the chosen playback/queue boundary:
queue = the QueuePanel UI that CONSUMES the playback store; queue STATE and the
audio engine stay in store/ (playback-core, moved separately). Verified
one-way: no playback-core file imports the queue UI back (only doc comments
mention it).
Excluded as NOT queue-UI: useIdlePlayQueuePull (AppShell) and
usePlayQueueSyncLedState (ConnectionIndicator) are queue-SYNC orchestration,
not panel UI — left in hooks/ to move with playback.
Pure move. One test fix: QueuePanel.test.tsx reads its subject via a hardcoded
readFileSync('src/components/QueuePanel.tsx') path (architecture-pin grep for
forbidden HTML5 DnD) — repointed to the new location (the move tool can't
rewrite non-import string paths). tsc 0, lint 0/0, suite 319/2353 green.
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import React, { useLayoutEffect } from 'react';
|
||||
import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '@/store/queueUndo';
|
||||
import type { QueueItemRef, Track } from '@/store/playerStoreTypes';
|
||||
|
||||
interface Args {
|
||||
queue: QueueItemRef[];
|
||||
queueIndex: number;
|
||||
currentTrack: Track | null;
|
||||
queueListRef: React.RefObject<HTMLDivElement | null>;
|
||||
suppressNextAutoScrollRef: React.MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
/** Queue scroll-position bridge for undo: publishes the list's scrollTop to the
|
||||
* undo store and restores any pending scrollTop snapshot (set when an undo
|
||||
* restores a prior queue state). The "scroll the next track into view" path
|
||||
* lives in `QueueList` now that the list is virtualized (scrollToIndex). */
|
||||
export function useQueueAutoScroll({
|
||||
queue, queueIndex, currentTrack, queueListRef, suppressNextAutoScrollRef,
|
||||
}: Args) {
|
||||
useLayoutEffect(() => {
|
||||
registerQueueListScrollTopReader(() => queueListRef.current?.scrollTop);
|
||||
return () => registerQueueListScrollTopReader(null);
|
||||
}, [queueListRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const top = consumePendingQueueListScrollTop();
|
||||
if (top === undefined) return;
|
||||
const el = queueListRef.current;
|
||||
if (!el) return;
|
||||
suppressNextAutoScrollRef.current = true;
|
||||
el.scrollTop = top;
|
||||
el.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||
}, [queue, queueIndex, currentTrack?.id, queueListRef, suppressNextAutoScrollRef]);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
/** Manages the open/closed state, button + menu refs, and positioning of the
|
||||
* LUFS-target listbox popover inside the queue panel's current-track header.
|
||||
* The popover collapses automatically when the parent replay-gain expansion
|
||||
* is toggled off. */
|
||||
export function useQueueLufsTgtPopover(expandReplayGain: boolean) {
|
||||
const [lufsTgtOpen, setLufsTgtOpen] = useState(false);
|
||||
const [lufsTgtPopStyle, setLufsTgtPopStyle] = useState<React.CSSProperties>({});
|
||||
const lufsTgtBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const lufsTgtMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lufsTgtOpen) return;
|
||||
const handle = (e: MouseEvent) => {
|
||||
if (
|
||||
lufsTgtBtnRef.current?.contains(e.target as Node) ||
|
||||
lufsTgtMenuRef.current?.contains(e.target as Node)
|
||||
) return;
|
||||
setLufsTgtOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handle);
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, [lufsTgtOpen]);
|
||||
|
||||
const updateLufsTgtPopStyle = () => {
|
||||
if (!lufsTgtBtnRef.current) return;
|
||||
const rect = lufsTgtBtnRef.current.getBoundingClientRect();
|
||||
const MARGIN = 6;
|
||||
const WIDTH = 160;
|
||||
const MAX_H = 220;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||
const spaceAbove = rect.top - MARGIN;
|
||||
const useAbove = spaceBelow < 120 && spaceAbove > spaceBelow;
|
||||
const left = Math.min(
|
||||
Math.max(rect.right - WIDTH, 8),
|
||||
window.innerWidth - WIDTH - 8,
|
||||
);
|
||||
setLufsTgtPopStyle({
|
||||
position: 'fixed',
|
||||
left,
|
||||
width: WIDTH,
|
||||
...(useAbove
|
||||
? { bottom: window.innerHeight - rect.top + MARGIN }
|
||||
: { top: rect.bottom + MARGIN }),
|
||||
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
|
||||
zIndex: 99998,
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!lufsTgtOpen) return;
|
||||
updateLufsTgtPopStyle();
|
||||
}, [lufsTgtOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lufsTgtOpen) return;
|
||||
const onResize = () => updateLufsTgtPopStyle();
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('scroll', onResize, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('scroll', onResize, true);
|
||||
};
|
||||
}, [lufsTgtOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an external subscription/event callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!expandReplayGain) setLufsTgtOpen(false);
|
||||
}, [expandReplayGain]);
|
||||
|
||||
return {
|
||||
lufsTgtOpen,
|
||||
setLufsTgtOpen,
|
||||
lufsTgtPopStyle,
|
||||
lufsTgtBtnRef,
|
||||
lufsTgtMenuRef,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import { useDragDrop, registerQueueDragHitTest } from '@/lib/dnd/DragDropContext';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
|
||||
/** Drag types that may be dropped into the queue panel. */
|
||||
const QUEUE_DROP_TYPES = new Set(['song', 'album', 'queue_reorder']);
|
||||
|
||||
interface Args {
|
||||
asideRef: React.RefObject<HTMLElement | null>;
|
||||
isQueueVisible: boolean;
|
||||
reorderQueue: (from: number, to: number) => void;
|
||||
enqueueAt: (tracks: Track[], idx: number) => void;
|
||||
removeTrack: (idx: number) => void;
|
||||
}
|
||||
|
||||
/** Queue drag/drop wiring: hit-test registration, psy-drop dispatch for drops
|
||||
* inside the panel, removal-on-drop-outside, and visual feedback refs. */
|
||||
export function useQueuePanelDrag({
|
||||
asideRef, isQueueVisible, reorderQueue, enqueueAt, removeTrack,
|
||||
}: Args) {
|
||||
const psyDragFromIdxRef = useRef<number | null>(null);
|
||||
const [externalDropTarget, setExternalDropTarget] = useState<{ idx: number; before: boolean } | null>(null);
|
||||
const externalDropTargetRef = useRef<{ idx: number; before: boolean } | null>(null);
|
||||
|
||||
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
|
||||
const isQueueDrag = isPsyDragging && !!psyPayload && (() => {
|
||||
try { return QUEUE_DROP_TYPES.has(JSON.parse(psyPayload.data).type); } catch { return false; }
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
const hitTest = (cx: number, cy: number) => {
|
||||
const el = asideRef.current;
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom;
|
||||
};
|
||||
return registerQueueDragHitTest(hitTest);
|
||||
}, [asideRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPsyDragging) {
|
||||
externalDropTargetRef.current = null;
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setExternalDropTarget(null);
|
||||
}
|
||||
}, [isPsyDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
const aside = asideRef.current;
|
||||
if (!aside) return;
|
||||
|
||||
const onPsyDrop = async (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.data) return;
|
||||
|
||||
let parsedData: {
|
||||
type?: string;
|
||||
index?: number;
|
||||
track?: Track;
|
||||
tracks?: Track[];
|
||||
serverId?: string;
|
||||
id?: string;
|
||||
};
|
||||
try { parsedData = JSON.parse(detail.data); } catch { return; }
|
||||
|
||||
// Radio streams are not tracks — reject silently
|
||||
if (parsedData.type === 'radio') return;
|
||||
|
||||
const dropTarget = externalDropTargetRef.current;
|
||||
externalDropTargetRef.current = null;
|
||||
setExternalDropTarget(null);
|
||||
|
||||
const insertIdx = dropTarget
|
||||
? (dropTarget.before ? dropTarget.idx : dropTarget.idx + 1)
|
||||
: usePlayerStore.getState().queueItems.length;
|
||||
|
||||
if (parsedData.type === 'queue_reorder') {
|
||||
const fromIdx = parsedData.index as number;
|
||||
psyDragFromIdxRef.current = null;
|
||||
if (fromIdx !== insertIdx) reorderQueue(fromIdx, insertIdx);
|
||||
} else if (parsedData.type === 'song') {
|
||||
enqueueAt([parsedData.track as Track], insertIdx);
|
||||
} else if (parsedData.type === 'songs') {
|
||||
enqueueAt(parsedData.tracks as Track[], insertIdx);
|
||||
} else if (parsedData.type === 'album') {
|
||||
const serverId = resolveMediaServerId(parsedData.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, parsedData.id as string);
|
||||
if (!albumData) return;
|
||||
enqueueAt(albumData.songs.map(songToTrack), insertIdx);
|
||||
}
|
||||
};
|
||||
|
||||
aside.addEventListener('psy-drop', onPsyDrop);
|
||||
return () => aside.removeEventListener('psy-drop', onPsyDrop);
|
||||
}, [asideRef, enqueueAt, reorderQueue]);
|
||||
|
||||
// Drag a queue row outside the panel → remove (drop never reaches `aside`).
|
||||
useEffect(() => {
|
||||
const onDocPsyDrop = (e: Event) => {
|
||||
if (!isQueueVisible) return;
|
||||
const d = (e as CustomEvent<{ data?: string; clientX?: number; clientY?: number }>).detail;
|
||||
if (!d?.data) return;
|
||||
const cx = d.clientX;
|
||||
const cy = d.clientY;
|
||||
if (typeof cx !== 'number' || typeof cy !== 'number') return;
|
||||
let parsed: { type?: string; index?: number } | null;
|
||||
try {
|
||||
parsed = JSON.parse(d.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (parsed?.type !== 'queue_reorder' || typeof parsed.index !== 'number') return;
|
||||
const aside = asideRef.current;
|
||||
if (!aside) return;
|
||||
const r = aside.getBoundingClientRect();
|
||||
const inside =
|
||||
cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom;
|
||||
if (inside) return;
|
||||
psyDragFromIdxRef.current = null;
|
||||
externalDropTargetRef.current = null;
|
||||
setExternalDropTarget(null);
|
||||
removeTrack(parsed.index);
|
||||
};
|
||||
document.addEventListener('psy-drop', onDocPsyDrop);
|
||||
return () => document.removeEventListener('psy-drop', onDocPsyDrop);
|
||||
}, [asideRef, isQueueVisible, removeTrack]);
|
||||
|
||||
return {
|
||||
psyDragFromIdxRef,
|
||||
externalDropTarget,
|
||||
externalDropTargetRef,
|
||||
setExternalDropTarget,
|
||||
isPsyDragging,
|
||||
isQueueDrag,
|
||||
startDrag,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface UseQueueResizerArgs {
|
||||
isMobile: boolean;
|
||||
isSidebarCollapsed: boolean;
|
||||
isQueueVisible: boolean;
|
||||
toggleQueue: () => void;
|
||||
}
|
||||
|
||||
interface UseQueueResizerResult {
|
||||
queueWidth: number;
|
||||
isDraggingQueue: boolean;
|
||||
setIsDraggingQueue: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
queueHandleTop: number | null;
|
||||
handleQueueHandleMouseDown: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
const QUEUE_MIN_WIDTH = 310;
|
||||
const QUEUE_MAX_WIDTH = 500;
|
||||
const QUEUE_DEFAULT_WIDTH = 340;
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
/**
|
||||
* State + handlers for the queue panel's vertical resizer:
|
||||
* - `queueWidth` follows the resizer drag (clamped 310..500 px).
|
||||
* - `queueHandleTop` tracks the y-center of the sidebar's collapse button
|
||||
* so the queue handle visually aligns with it across resizes and
|
||||
* sidebar collapse changes.
|
||||
* - `handleQueueHandleMouseDown` distinguishes a click from a drag with a
|
||||
* 4-pixel threshold so the handle can both toggle the queue and resize
|
||||
* it without conflicting interaction modes.
|
||||
*/
|
||||
export function useQueueResizer({
|
||||
isMobile,
|
||||
isSidebarCollapsed,
|
||||
isQueueVisible,
|
||||
toggleQueue,
|
||||
}: UseQueueResizerArgs): UseQueueResizerResult {
|
||||
const [queueWidth, setQueueWidth] = useState(QUEUE_DEFAULT_WIDTH);
|
||||
const [isDraggingQueue, setIsDraggingQueue] = useState(false);
|
||||
const [queueHandleTop, setQueueHandleTop] = useState<number | null>(null);
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!isDraggingQueue) return;
|
||||
const newWidth = Math.max(QUEUE_MIN_WIDTH, Math.min(window.innerWidth - e.clientX, QUEUE_MAX_WIDTH));
|
||||
setQueueWidth(newWidth);
|
||||
}, [isDraggingQueue]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDraggingQueue(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDraggingQueue) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.classList.add('is-dragging');
|
||||
} else {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'default';
|
||||
document.body.classList.remove('is-dragging');
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.classList.remove('is-dragging');
|
||||
};
|
||||
}, [isDraggingQueue, handleMouseMove, handleMouseUp]);
|
||||
|
||||
const syncQueueHandleTop = useCallback(() => {
|
||||
const leftBtn = document.querySelector('.sidebar .collapse-btn') as HTMLElement | null;
|
||||
if (!leftBtn) return;
|
||||
const r = leftBtn.getBoundingClientRect();
|
||||
setQueueHandleTop(r.top + r.height / 2);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) return;
|
||||
const leftBtn = document.querySelector('.sidebar .collapse-btn') as HTMLElement | null;
|
||||
if (!leftBtn) return;
|
||||
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
syncQueueHandleTop();
|
||||
const raf = requestAnimationFrame(syncQueueHandleTop);
|
||||
|
||||
const onResize = () => syncQueueHandleTop();
|
||||
window.addEventListener('resize', onResize);
|
||||
const observer = new ResizeObserver(onResize);
|
||||
observer.observe(leftBtn);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', onResize);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [isMobile, isSidebarCollapsed, syncQueueHandleTop]);
|
||||
|
||||
const handleQueueHandleMouseDown = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
let didDrag = false;
|
||||
|
||||
const cleanup = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp, true);
|
||||
document.body.style.cursor = '';
|
||||
document.body.classList.remove('is-dragging');
|
||||
};
|
||||
|
||||
const applyWidthFromClientX = (clientX: number) => {
|
||||
const newWidth = Math.max(QUEUE_MIN_WIDTH, Math.min(window.innerWidth - clientX, QUEUE_MAX_WIDTH));
|
||||
setQueueWidth(newWidth);
|
||||
};
|
||||
|
||||
const onMove = (me: MouseEvent) => {
|
||||
const movedEnough = Math.hypot(me.clientX - startX, me.clientY - startY) >= DRAG_THRESHOLD_PX;
|
||||
if (!didDrag && movedEnough) {
|
||||
didDrag = true;
|
||||
if (!isQueueVisible) toggleQueue();
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.classList.add('is-dragging');
|
||||
}
|
||||
if (!didDrag) return;
|
||||
applyWidthFromClientX(me.clientX);
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
cleanup();
|
||||
if (!didDrag) toggleQueue();
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp, true);
|
||||
}, [isQueueVisible, toggleQueue]);
|
||||
|
||||
return { queueWidth, isDraggingQueue, setIsDraggingQueue, queueHandleTop, handleQueueHandleMouseDown };
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { libraryGetFacts, libraryGetTrack } from '@/lib/api/library';
|
||||
import { usePlaybackServerId } from '@/hooks/usePlaybackServerId';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import {
|
||||
enrichmentDisplayComplete,
|
||||
OXIMEDIA_MOOD_UI_ENABLED,
|
||||
parseTrackEnrichmentFacts,
|
||||
type ParsedTrackEnrichment,
|
||||
} from '@/utils/library/trackEnrichment';
|
||||
import { libraryIsReady } from '@/utils/library/libraryReady';
|
||||
import { normalizeAnalysisTrackId } from '@/utils/playback/queueIdentity';
|
||||
|
||||
const EMPTY: ParsedTrackEnrichment = {
|
||||
serverBpm: null,
|
||||
measuredBpm: null,
|
||||
moodLabels: [],
|
||||
};
|
||||
|
||||
/** Enrichment may finish several seconds after CPU seed / playback start. */
|
||||
const REFETCH_MS = [3_000, 8_000, 15_000, 30_000, 60_000] as const;
|
||||
|
||||
const ENRICHMENT_FACT_KINDS = OXIMEDIA_MOOD_UI_ENABLED
|
||||
? (['bpm', 'moods', 'mood_tag', 'mood_labels', 'valence', 'arousal'] as const)
|
||||
: (['bpm'] as const);
|
||||
|
||||
/**
|
||||
* Loads server BPM + oximedia mood facts for the queue "now playing" block.
|
||||
* Uses the playback server id (queue scope), not the browsed server.
|
||||
*/
|
||||
export function useQueueTrackEnrichment(trackId: string | undefined): ParsedTrackEnrichment {
|
||||
const serverId = usePlaybackServerId();
|
||||
const indexEnabled = useLibraryIndexStore(s =>
|
||||
serverId ? s.isIndexEnabled(serverId) : false,
|
||||
);
|
||||
const [data, setData] = useState<ParsedTrackEnrichment>(EMPTY);
|
||||
const [refreshNonce, setRefreshNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || !trackId || !indexEnabled) {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setData(EMPTY);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
const load = async () => {
|
||||
if (!(await libraryIsReady(serverId))) return;
|
||||
try {
|
||||
const [track, facts] = await Promise.all([
|
||||
libraryGetTrack(serverId, trackId),
|
||||
libraryGetFacts(serverId, trackId, [...ENRICHMENT_FACT_KINDS]),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const parsed = parseTrackEnrichmentFacts(facts, track?.bpm ?? null);
|
||||
setData(parsed);
|
||||
if (enrichmentDisplayComplete(parsed)) {
|
||||
for (const id of timers) clearTimeout(id);
|
||||
timers.length = 0;
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setData(EMPTY);
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
for (const ms of REFETCH_MS) {
|
||||
timers.push(setTimeout(() => { void load(); }, ms));
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const id of timers) clearTimeout(id);
|
||||
};
|
||||
}, [serverId, trackId, indexEnabled, refreshNonce]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || !trackId || !indexEnabled) return;
|
||||
|
||||
let unlisten: (() => void) | undefined;
|
||||
void listen<{ trackId: string; serverId: string }>('analysis:enrichment-updated', ({ payload }) => {
|
||||
if (!payload?.trackId) return;
|
||||
const eventTrackId = normalizeAnalysisTrackId(payload.trackId);
|
||||
const currentId = normalizeAnalysisTrackId(trackId);
|
||||
if (!eventTrackId || eventTrackId !== currentId) return;
|
||||
if (payload.serverId && payload.serverId !== serverId) return;
|
||||
setRefreshNonce(n => n + 1);
|
||||
}).then(fn => {
|
||||
unlisten = fn;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten?.();
|
||||
};
|
||||
}, [serverId, trackId, indexEnabled]);
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
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 '@/features/queue/hooks/useQueueTracks';
|
||||
|
||||
const track = (id: string, over: Partial<Track> = {}): Track =>
|
||||
({ id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1, ...over });
|
||||
|
||||
describe('useQueueTracks selectors', () => {
|
||||
beforeEach(() => {
|
||||
_resetQueueResolverForTest();
|
||||
usePlayerStore.setState({
|
||||
queueItems: [], queueIndex: 0, queueServerId: 's1', currentTrack: null,
|
||||
starredOverrides: {}, userRatingOverrides: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('useQueueTrackAt returns the track at the index, or null', () => {
|
||||
// seedQueue seeds the resolver under serverId 's1' and sets the refs.
|
||||
seedQueue([track('t1'), track('t2')], { serverId: 's1', currentTrack: null });
|
||||
expect(renderHook(() => useQueueTrackAt(1)).result.current?.id).toBe('t2');
|
||||
expect(renderHook(() => useQueueTrackAt(9)).result.current).toBeNull();
|
||||
});
|
||||
|
||||
it('useQueueTrackAt merges session star/rating overrides', () => {
|
||||
seedQueue([track('t1')], { serverId: 's1', currentTrack: null });
|
||||
usePlayerStore.setState({
|
||||
starredOverrides: { t1: true },
|
||||
userRatingOverrides: { t1: 5 },
|
||||
});
|
||||
const { result } = renderHook(() => useQueueTrackAt(0));
|
||||
expect(!!result.current?.starred).toBe(true);
|
||||
expect(result.current?.userRating).toBe(5);
|
||||
});
|
||||
|
||||
it('useCurrentTrack returns the current track', () => {
|
||||
usePlayerStore.setState({ currentTrack: track('cur') });
|
||||
expect(renderHook(() => useCurrentTrack()).result.current?.id).toBe('cur');
|
||||
});
|
||||
|
||||
it('useQueueItems returns the canonical thin refs (serverId + flags)', () => {
|
||||
seedQueue([track('t1'), track('t2', { radioAdded: true })], { serverId: 's1', currentTrack: null });
|
||||
const { result } = renderHook(() => useQueueItems());
|
||||
expect(result.current).toEqual([
|
||||
{ serverId: 's1', trackId: 't1' },
|
||||
{ serverId: 's1', trackId: 't2', radioAdded: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedQueue resolver seeding', () => {
|
||||
beforeEach(() => {
|
||||
_resetQueueResolverForTest();
|
||||
usePlayerStore.setState({ queueItems: [], queueIndex: 0, queueServerId: 's1', currentTrack: null });
|
||||
});
|
||||
|
||||
it('seeds the resolver cache with the queue tracks under the queue server', () => {
|
||||
seedQueue([track('t1'), track('t2')], { serverId: 's1', currentTrack: null });
|
||||
expect(getCachedTrack({ serverId: 's1', trackId: 't1' })?.id).toBe('t1');
|
||||
expect(getCachedTrack({ serverId: 's1', trackId: 't2' })?.id).toBe('t2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useMemo, useSyncExternalStore } from 'react';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import type { QueueItemRef, Track } from '@/store/playerStoreTypes';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import {
|
||||
getQueueResolverVersion,
|
||||
subscribeQueueResolver,
|
||||
} from '@/utils/library/queueTrackResolver';
|
||||
|
||||
/**
|
||||
* Stable queue selectors (queue thin-state). The store is refs-canonical now:
|
||||
* full `Track`s come from the resolver cache (placeholder until a fetch lands),
|
||||
* with session star/rating overrides (F4) merged on read via resolveQueueTrack.
|
||||
*/
|
||||
|
||||
/** The track at a queue index, or null. */
|
||||
export function useQueueTrackAt(idx: number): Track | null {
|
||||
const ref = usePlayerStore(s => s.queueItems[idx] ?? null);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
||||
return useMemo(() => {
|
||||
if (!ref) return null;
|
||||
return resolveQueueTrack(ref);
|
||||
// version drives re-resolution as the cache fills; overrides drive the merge.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ref, starredOverrides, userRatingOverrides, version]);
|
||||
}
|
||||
|
||||
/** The currently playing track, or null. */
|
||||
export function useCurrentTrack(): Track | null {
|
||||
return usePlayerStore(s => s.currentTrack);
|
||||
}
|
||||
|
||||
/** The whole queue as thin refs (the canonical list). */
|
||||
export function useQueueItems(): QueueItemRef[] {
|
||||
return usePlayerStore(s => s.queueItems);
|
||||
}
|
||||
Reference in New Issue
Block a user