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,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,
};
}