Files
psysonic/src/components/queuePanel/QueueList.tsx
T
Frank Stellmacher 8ff630cb5c 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.
2026-05-13 22:33:02 +02:00

165 lines
7.1 KiB
TypeScript

import React from 'react';
import { Play } from 'lucide-react';
import type { TFunction } from 'i18next';
import OverlayScrollArea from '../OverlayScrollArea';
import { usePlayerStore } from '../../store/playerStore';
import { useLuckyMixStore } from '../../store/luckyMixStore';
import type { Track, PlayerState } from '../../store/playerStoreTypes';
import { formatTime } from '../../utils/queuePanelHelpers';
type StartDrag = (
payload: { data: string; label: string },
x: number,
y: number,
) => void;
interface Props {
queue: Track[];
queueIndex: number;
contextMenu: PlayerState['contextMenu'];
playTrack: PlayerState['playTrack'];
activeTab: string;
queueListRef: React.RefObject<HTMLDivElement | null>;
suppressNextAutoScrollRef: React.MutableRefObject<boolean>;
isQueueDrag: boolean;
psyDragFromIdxRef: React.MutableRefObject<number | null>;
externalDropTarget: { idx: number; before: boolean } | null;
startDrag: StartDrag;
orbitAttributionLabel: (trackId: string) => string | null;
luckyRolling: boolean;
t: TFunction;
}
export function QueueList({
queue, queueIndex, contextMenu, playTrack, activeTab, queueListRef,
suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget,
startDrag, orbitAttributionLabel, luckyRolling, t,
}: Props) {
return (
<OverlayScrollArea
viewportRef={queueListRef}
className="queue-list-wrap"
viewportClassName="queue-list"
measureDeps={[activeTab, queue.length]}
railInset="panel"
viewportScrollBehaviorAuto={isQueueDrag}
>
{queue.length === 0 ? (
<div className="queue-empty">
{t('queue.emptyQueue')}
</div>
) : (
<>
{queue.map((track, idx) => {
const isPlaying = idx === queueIndex;
const isFirstAutoAdded = track.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
const isFirstRadioAdded = track.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
let dragStyle: React.CSSProperties = {};
if (isQueueDrag && psyDragFromIdxRef.current === idx) {
dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
} else if (isQueueDrag && externalDropTarget?.idx === idx) {
if (externalDropTarget.before) {
dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' };
} else {
dragStyle = { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' };
}
}
return (
<React.Fragment key={`${track.id}-${idx}`}>
{isFirstRadioAdded && (
<div className="queue-divider" style={{ margin: '2px 0' }}>
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.radioAdded')}</span>
</div>
)}
{isFirstAutoAdded && (
<div className="queue-divider" style={{ margin: '2px 0' }}>
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.autoAdded')}</span>
</div>
)}
<div
data-queue-idx={idx}
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);
}}
onContextMenu={(e) => {
e.preventDefault();
usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', idx);
}}
onMouseDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDragFromIdxRef.current = idx;
startDrag({ data: JSON.stringify({ type: 'queue_reorder', index: idx }), label: track.title }, me.clientX, me.clientY);
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
style={dragStyle}
>
<div className="queue-item-info">
<div className="queue-item-title truncate" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{isPlaying && <Play size={10} fill="currentColor" style={{ flexShrink: 0 }} />}
<span className="truncate">{track.title}</span>
</div>
<div className="queue-item-artist truncate">{track.artist}</div>
{(() => {
const label = orbitAttributionLabel(track.id);
return label ? <div className="queue-item-attribution truncate">{label}</div> : null;
})()}
</div>
<div className="queue-item-duration">
{formatTime(track.duration)}
</div>
</div>
{luckyRolling && isPlaying && (
<button
type="button"
className="queue-lucky-loading"
onClick={() => useLuckyMixStore.getState().cancel()}
data-tooltip={t('luckyMix.cancelTooltip')}
aria-label={t('luckyMix.cancelTooltip')}
>
<div className="queue-lucky-loading__dice">
<div className="queue-lucky-cube queue-lucky-cube--a">
<span className="lucky-mix-pip lucky-mix-pip--tl" />
<span className="lucky-mix-pip lucky-mix-pip--tr" />
<span className="lucky-mix-pip lucky-mix-pip--bl" />
<span className="lucky-mix-pip lucky-mix-pip--br" />
</div>
<div className="queue-lucky-cube queue-lucky-cube--b">
<span className="lucky-mix-pip lucky-mix-pip--center" />
</div>
<div className="queue-lucky-cube queue-lucky-cube--c">
<span className="lucky-mix-pip lucky-mix-pip--tl" />
<span className="lucky-mix-pip lucky-mix-pip--center" />
<span className="lucky-mix-pip lucky-mix-pip--br" />
</div>
</div>
</button>
)}
</React.Fragment>
);
})}
</>
)}
</OverlayScrollArea>
);
}