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:
Psychotoxical
2026-06-30 08:40:51 +02:00
parent 7ad196711e
commit d5bbabac1d
20 changed files with 89 additions and 84 deletions
@@ -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,
};
}
+144
View File
@@ -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);
}