mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(queue-panel): H4 — split QueuePanel.tsx 1256 → 383 LOC across 11 files (#667)
* refactor(queue-panel): H4 — extract helpers + Save/LoadPlaylistModal Pure code-move: formatTime, formatQueueReplayGainParts, renderStars and the DurationMode type → utils/queuePanelHelpers.tsx; the two playlist modals → own files under components/queuePanel/. QueuePanel.tsx: 1256 → 1104 LOC. * refactor(queue-panel): H4 — extract QueueHeader Pure code-move: the title/count/duration/collapse-button header → its own component file. No prop or behaviour changes. QueuePanel.tsx: 1104 → 1004 LOC. * refactor(queue-panel): H4 — extract QueueCurrentTrack + QueueLufsTargetMenu The currently-playing track block (cover, info, replay-gain / LUFS badge with its target-listbox portal) moves into two own files. Pure code-move via prop plumbing. setLoudnessTargetLufs is typed as LoudnessLufsPreset throughout the new components. QueuePanel.tsx: 1004 → 812 LOC. * refactor(queue-panel): H4 — extract useQueuePanelDrag hook Moves the psy-drag wiring (hit-test registration, drop-inside dispatch for song/songs/album/queue_reorder payloads, drop-outside removal) into its own hook. Drops the dead isRadioDrag variable since the parsedData.type === 'radio' guard inside onPsyDrop already handles that case. QueuePanel.tsx: 812 → 715 LOC. * refactor(queue-panel): H4 — extract useQueueLufsTgtPopover hook Pulls the LUFS-target popover open-state, button/menu refs, fixed-position recompute on open/resize/scroll, and auto-close-when-RG-collapses out of QueuePanel. QueuePanel.tsx: 715 → 662 LOC. * refactor(queue-panel): H4 — extract QueueToolbar The toolbar-button switch (shuffle/save/load/share/clear/gapless/crossfade/ infinite) and the crossfade popover (with its close-on-outside-click effect) move into one component. crossfadeBtnRef / crossfadePopoverRef and showCrossfadePopover state are now component-local — QueuePanel no longer sees them. QueuePanel.tsx: 662 → 541 LOC. * refactor(queue-panel): H4 — extract QueueList The OverlayScrollArea + queue.map block (with track rows, lucky-mix dice overlay, and radio/auto-added section dividers) moves into its own component. PlayerState['contextMenu'] + PlayerState['playTrack'] are re-used for prop typing, the local StartDrag alias matches the DragDropContext signature. QueuePanel.tsx: 541 → 434 LOC. * refactor(queue-panel): H4 — extract QueueTabBar + useQueueAutoScroll, final cleanup QueueTabBar is the bottom queue/lyrics/info tab switcher. useQueueAutoScroll groups the three list-scroll effects (publish scrollTop reader, restore pending snapshot, scroll next track into view on advance). Drops the dead toggleQueue and replayGainMode selectors plus the now-unused Play, Radio, MicVocal, ListMusic, Info imports and OverlayScrollArea. QueuePanel.tsx: 434 → 383 LOC. Every new file under 400.
This commit is contained in:
committed by
GitHub
parent
34cc311b4d
commit
8ff630cb5c
@@ -0,0 +1,51 @@
|
||||
import React, { useEffect, useLayoutEffect } from 'react';
|
||||
import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '../store/queueUndo';
|
||||
import type { Track } from '../store/playerStoreTypes';
|
||||
|
||||
interface Args {
|
||||
queue: Track[];
|
||||
queueIndex: number;
|
||||
currentTrack: Track | null;
|
||||
activeTab: string;
|
||||
queueListRef: React.RefObject<HTMLDivElement | null>;
|
||||
suppressNextAutoScrollRef: React.MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
/** Queue auto-scroll: keeps the next track in view as playback progresses,
|
||||
* publishes the list's scrollTop to the undo store, and restores any pending
|
||||
* scrollTop snapshot (set when an undo restores a prior queue state). */
|
||||
export function useQueueAutoScroll({
|
||||
queue, queueIndex, currentTrack, activeTab, 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]);
|
||||
|
||||
useEffect(function queueAutoScroll() {
|
||||
if (suppressNextAutoScrollRef.current) {
|
||||
suppressNextAutoScrollRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!queueListRef.current || queueIndex < 0) return;
|
||||
if (activeTab !== 'queue') return;
|
||||
const songs = queueListRef.current!.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||
const nextSong = songs[queueIndex + 1];
|
||||
if (!nextSong) return;
|
||||
nextSong.scrollIntoView({ block: 'start', behavior: 'instant' });
|
||||
requestAnimationFrame(() => {
|
||||
queueListRef.current?.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentTrack, activeTab]);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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(() => {
|
||||
if (!expandReplayGain) setLufsTgtOpen(false);
|
||||
}, [expandReplayGain]);
|
||||
|
||||
return {
|
||||
lufsTgtOpen,
|
||||
setLufsTgtOpen,
|
||||
lufsTgtPopStyle,
|
||||
lufsTgtBtnRef,
|
||||
lufsTgtMenuRef,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { getAlbum } from '../api/subsonicLibrary';
|
||||
import { useDragDrop, registerQueueDragHitTest } from '../contexts/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;
|
||||
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: any = null;
|
||||
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().queue.length;
|
||||
|
||||
if (parsedData.type === 'queue_reorder') {
|
||||
const fromIdx: number = parsedData.index;
|
||||
psyDragFromIdxRef.current = null;
|
||||
if (fromIdx !== insertIdx) reorderQueue(fromIdx, insertIdx);
|
||||
} else if (parsedData.type === 'song') {
|
||||
enqueueAt([parsedData.track], insertIdx);
|
||||
} else if (parsedData.type === 'songs') {
|
||||
enqueueAt(parsedData.tracks as Track[], insertIdx);
|
||||
} else if (parsedData.type === 'album') {
|
||||
const albumData = await getAlbum(parsedData.id);
|
||||
const tracks: Track[] = albumData.songs.map((s: any) => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
|
||||
}));
|
||||
enqueueAt(tracks, 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 = 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user