From 8ff630cb5c295dcbf55ef762b38e32201de6383a Mon Sep 17 00:00:00 2001
From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com>
Date: Wed, 13 May 2026 22:33:02 +0200
Subject: [PATCH] =?UTF-8?q?refactor(queue-panel):=20H4=20=E2=80=94=20split?=
=?UTF-8?q?=20QueuePanel.tsx=201256=20=E2=86=92=20383=20LOC=20across=2011?=
=?UTF-8?q?=20files=20(#667)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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.
---
src/components/QueuePanel.tsx | 1087 ++---------------
.../queuePanel/LoadPlaylistModal.tsx | 102 ++
.../queuePanel/QueueCurrentTrack.tsx | 223 ++++
src/components/queuePanel/QueueHeader.tsx | 111 ++
src/components/queuePanel/QueueList.tsx | 164 +++
.../queuePanel/QueueLufsTargetMenu.tsx | 65 +
src/components/queuePanel/QueueTabBar.tsx | 41 +
src/components/queuePanel/QueueToolbar.tsx | 185 +++
.../queuePanel/SavePlaylistModal.tsx | 35 +
src/hooks/useQueueAutoScroll.ts | 51 +
src/hooks/useQueueLufsTgtPopover.ts | 78 ++
src/hooks/useQueuePanelDrag.ts | 134 ++
src/utils/queuePanelHelpers.tsx | 43 +
13 files changed, 1339 insertions(+), 980 deletions(-)
create mode 100644 src/components/queuePanel/LoadPlaylistModal.tsx
create mode 100644 src/components/queuePanel/QueueCurrentTrack.tsx
create mode 100644 src/components/queuePanel/QueueHeader.tsx
create mode 100644 src/components/queuePanel/QueueList.tsx
create mode 100644 src/components/queuePanel/QueueLufsTargetMenu.tsx
create mode 100644 src/components/queuePanel/QueueTabBar.tsx
create mode 100644 src/components/queuePanel/QueueToolbar.tsx
create mode 100644 src/components/queuePanel/SavePlaylistModal.tsx
create mode 100644 src/hooks/useQueueAutoScroll.ts
create mode 100644 src/hooks/useQueueLufsTgtPopover.ts
create mode 100644 src/hooks/useQueuePanelDrag.ts
create mode 100644 src/utils/queuePanelHelpers.tsx
diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx
index 695406f0..c4fb38ae 100644
--- a/src/components/QueuePanel.tsx
+++ b/src/components/QueuePanel.tsx
@@ -1,18 +1,13 @@
-import { getPlaylists, getPlaylist, updatePlaylist, deletePlaylist } from '../api/subsonicPlaylists';
+import { getPlaylist, updatePlaylist } from '../api/subsonicPlaylists';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
-import { getAlbum } from '../api/subsonicLibrary';
-import type { SubsonicPlaylist } from '../api/subsonicTypes';
-import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '../store/queueUndo';
import { songToTrack } from '../utils/songToTrack';
import type { Track } from '../store/playerStoreTypes';
-import React, { useState, useRef, useMemo, useEffect, useLayoutEffect } from 'react';
-import { createPortal } from 'react-dom';
+import { useState, useRef, useMemo } from 'react';
import { usePlayerStore } from '../store/playerStore';
import { useOrbitStore } from '../store/orbitStore';
import OrbitGuestQueue from './OrbitGuestQueue';
import OrbitQueueHead from './OrbitQueueHead';
import HostApprovalQueue from './HostApprovalQueue';
-import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, MoveRight, Radio, HardDrive, ChevronDown, Info, Share2 } from 'lucide-react';
import { usePlaylistStore } from '../store/playlistStore';
import { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next';
@@ -23,275 +18,25 @@ import { copyTextToClipboard } from '../utils/serverMagicString';
import { showToast } from '../utils/toast';
import { useThemeStore } from '../store/themeStore';
import { useLyricsStore } from '../store/lyricsStore';
-import { useDragDrop, registerQueueDragHitTest } from '../contexts/DragDropContext';
import LyricsPane from './LyricsPane';
import NowPlayingInfo from './NowPlayingInfo';
import { TFunction } from 'i18next';
-import OverlayScrollArea from './OverlayScrollArea';
import { useLuckyMixStore } from '../store/luckyMixStore';
-import { useQueueToolbarStore, QueueToolbarButtonId } from '../store/queueToolbarStore';
-import { loudnessGainPlaceholderUntilCacheDb } from '../utils/loudnessPlaceholder';
-import { effectiveLoudnessPreAnalysisAttenuationDb } from '../utils/loudnessPreAnalysisSlider';
-
-function formatTime(seconds: number): string {
- if (!seconds || isNaN(seconds)) return '0:00';
- const m = Math.floor(seconds / 60);
- const s = Math.floor(seconds % 60);
- return `${m}:${s.toString().padStart(2, '0')}`;
-}
-
-function formatQueueReplayGainParts(track: Track, t: TFunction): string[] {
- const parts: string[] = [];
- const fmtDb = (db: number) => `${db >= 0 ? '+' : ''}${db.toFixed(1)}`;
- if (track.replayGainTrackDb != null) {
- parts.push(t('queue.rgTrack', { db: fmtDb(track.replayGainTrackDb) }));
- }
- if (track.replayGainAlbumDb != null) {
- parts.push(t('queue.rgAlbum', { db: fmtDb(track.replayGainAlbumDb) }));
- }
- if (track.replayGainPeak != null) {
- parts.push(t('queue.rgPeak', { pk: track.replayGainPeak.toFixed(3) }));
- }
- return parts;
-}
-
-function renderStars(rating?: number) {
- if (!rating) return null;
- const stars = [];
- for (let i = 1; i <= 5; i++) {
- stars.push(
-
- );
- }
- return
{stars}
;
-}
-
-function SavePlaylistModal({ onClose, onSave }: { onClose: () => void, onSave: (name: string) => void }) {
- const { t } = useTranslation();
- const [name, setName] = useState('');
- return (
-
-
e.stopPropagation()} style={{ maxWidth: '400px' }}>
-
-
{t('queue.savePlaylist')}
-
setName(e.target.value)}
- autoFocus
- onKeyDown={e => e.key === 'Enter' && name.trim() && onSave(name.trim())}
- style={{ width: '100%', marginBottom: '1rem', padding: '10px 16px' }}
- />
-
-
-
-
-
-
- );
-}
-
-function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (id: string, name: string, mode: 'replace' | 'append') => void }) {
- const { t } = useTranslation();
- const [playlists, setPlaylists] = useState([]);
- const [loading, setLoading] = useState(true);
- const [filter, setFilter] = useState('');
- const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string } | null>(null);
-
- const fetchPlaylists = () => {
- setLoading(true);
- getPlaylists().then(data => {
- setPlaylists(data);
- setLoading(false);
- }).catch(e => {
- console.error(e);
- setLoading(false);
- });
- };
-
- useEffect(() => {
- fetchPlaylists();
- }, []);
-
- const handleDelete = async (id: string, name: string) => {
- setConfirmDelete({ id, name });
- };
-
- const confirmDeletePlaylist = async () => {
- if (!confirmDelete) return;
- await deletePlaylist(confirmDelete.id);
- setConfirmDelete(null);
- fetchPlaylists();
- };
-
- return (
- <>
-
-
e.stopPropagation()} style={{ maxWidth: '560px', width: '90vw' }}>
-
-
{t('queue.loadPlaylist')}
- {!loading && playlists.length > 0 && (
-
setFilter(e.target.value)}
- autoFocus
- style={{ width: '100%', marginBottom: '0.75rem', padding: '8px 14px' }}
- />
- )}
- {loading ? (
-
{t('queue.loading')}
- ) : playlists.length === 0 ? (
-
{t('queue.noPlaylists')}
- ) : (
-
- {playlists.filter(p => p.name.toLowerCase().includes(filter.toLowerCase())).map(p => (
-
-
{p.name}
-
-
-
-
-
-
- ))}
-
- )}
-
-
-
- {confirmDelete && (
- setConfirmDelete(null)} role="dialog" aria-modal="true">
-
e.stopPropagation()} style={{ maxWidth: '360px' }}>
-
-
{t('queue.delete')}
-
- {t('queue.deleteConfirm', { name: confirmDelete.name })}
-
-
-
-
-
-
-
- )}
- >
- );
-}
-
-type DurationMode = 'total' | 'remaining' | 'eta';
-
-interface QueueHeaderProps {
- queue: Track[];
- queueIndex: number;
- activePlaylist: { id: string; name: string } | null;
- isNowPlayingCollapsed: boolean;
- setIsNowPlayingCollapsed: (v: boolean) => void;
- durationMode: DurationMode;
- setDurationMode: (m: DurationMode) => void;
- t: TFunction;
-}
-function QueueHeader({ queue, queueIndex, activePlaylist, isNowPlayingCollapsed, setIsNowPlayingCollapsed, durationMode, setDurationMode, t }: QueueHeaderProps) {
- const currentTime = usePlayerStore((s) => Math.floor(s.currentTime / 30) * 30);
- const isPlaying = usePlayerStore((s) => s.isPlaying);
-
- const totalSecs = useMemo(() =>
- queue.reduce((acc: number, track: Track) => acc + (track.duration || 0), 0),
- [queue]
- );
- const futureTracksDuration = useMemo(() =>
- queue.slice(queueIndex + 1).reduce((acc: number, track: Track) => acc + (track.duration || 0), 0),
- [queue, queueIndex]
- );
-
- const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + futureTracksDuration);
-
- const fmt = (secs: number) => {
- const h = Math.floor(secs / 3600);
- const m = Math.floor((secs % 3600) / 60);
- const s = secs % 60;
- return h > 0 ? `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}` : `${m}:${s.toString().padStart(2, "0")}`;
- };
- const fmtEta = (secs: number) => {
- const finishTime = new Date(Date.now() + secs * 1000);
- return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(finishTime);
- };
-
- let dur: string | null = null;
- if (queue.length > 0) {
- if (durationMode === 'total') dur = fmt(Math.floor(totalSecs));
- else if (durationMode === 'remaining') dur = `-${fmt(Math.floor(remainingSecs))}`;
- else dur = fmtEta(remainingSecs);
- }
-
- const nextMode: DurationMode =
- durationMode === 'total' ? 'remaining' :
- durationMode === 'remaining' ? 'eta' : 'total';
- const nextTooltipKey =
- nextMode === 'total' ? 'queue.showTotal' :
- nextMode === 'remaining' ? 'queue.showRemaining' : 'queue.showEta';
-
- const isEta = durationMode === 'eta';
-
- return (
-
-
-
-
{t("queue.title")}
- {queue.length > 0 && (
-
- ({queueIndex + 1}/{queue.length})
-
- )}
- {dur !== null && (
- setDurationMode(nextMode)}
- data-tooltip={t(nextTooltipKey)}
- style={{
- fontSize: "13px",
- color: isEta ? (isPlaying ? "var(--accent)" : "var(--text-muted)") : "var(--accent)",
- opacity: isEta && !isPlaying ? 0.5 : 1,
- whiteSpace: "nowrap",
- cursor: "pointer",
- userSelect: "none",
- }}
- >
- · {dur}
-
- )}
-
- {activePlaylist && (
-
-
- {activePlaylist.name}
-
- )}
-
-
-
- );
-}
+import { useQueueToolbarStore } from '../store/queueToolbarStore';
+import {
+ DurationMode,
+ formatTime,
+} from '../utils/queuePanelHelpers';
+import { SavePlaylistModal } from './queuePanel/SavePlaylistModal';
+import { LoadPlaylistModal } from './queuePanel/LoadPlaylistModal';
+import { QueueHeader } from './queuePanel/QueueHeader';
+import { QueueCurrentTrack } from './queuePanel/QueueCurrentTrack';
+import { useQueuePanelDrag } from '../hooks/useQueuePanelDrag';
+import { useQueueLufsTgtPopover } from '../hooks/useQueueLufsTgtPopover';
+import { QueueToolbar } from './queuePanel/QueueToolbar';
+import { QueueList } from './queuePanel/QueueList';
+import { QueueTabBar } from './queuePanel/QueueTabBar';
+import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
export default function QueuePanel() {
const orbitRole = useOrbitStore(s => s.role);
@@ -347,7 +92,6 @@ function QueuePanelHostOrSolo() {
const currentCoverSrc = useCachedUrl(currentCoverFetchUrl, currentCoverCacheKey);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const playTrack = usePlayerStore(s => s.playTrack);
- const toggleQueue = usePlayerStore(s => s.toggleQueue);
const clearQueue = usePlayerStore(s => s.clearQueue);
const reorderQueue = usePlayerStore(s => s.reorderQueue);
@@ -377,7 +121,6 @@ function QueuePanelHostOrSolo() {
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
const setInfiniteQueueEnabled = useAuthStore(s => s.setInfiniteQueueEnabled);
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
- const replayGainMode = useAuthStore(s => s.replayGainMode);
const activeTab = useLyricsStore(s => s.activeTab);
const setTab = useLyricsStore(s => s.setTab);
@@ -387,236 +130,47 @@ function QueuePanelHostOrSolo() {
const setIsNowPlayingCollapsed = useAuthStore(s => s.setQueueNowPlayingCollapsed);
const toolbarButtons = useQueueToolbarStore(s => s.buttons);
const [durationMode, setDurationMode] = useState('total');
- const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
- const [lufsTgtOpen, setLufsTgtOpen] = useState(false);
- const [lufsTgtPopStyle, setLufsTgtPopStyle] = useState({});
- const lufsTgtBtnRef = useRef(null);
- const lufsTgtMenuRef = useRef(null);
const expandReplayGain = useThemeStore(s => s.expandReplayGain);
const setExpandReplayGain = useThemeStore(s => s.setExpandReplayGain);
- const crossfadeBtnRef = useRef(null);
- const crossfadePopoverRef = useRef(null);
const reanalyzeLoudnessForTrack = usePlayerStore(s => s.reanalyzeLoudnessForTrack);
const authLoudnessTargetLufs = useAuthStore(s => s.loudnessTargetLufs);
const setLoudnessTargetLufs = useAuthStore(s => s.setLoudnessTargetLufs);
const loudnessPreAnalysisAttenuationDb = useAuthStore(s => s.loudnessPreAnalysisAttenuationDb);
- useEffect(() => {
- if (!showCrossfadePopover) return;
- const handle = (e: MouseEvent) => {
- if (
- crossfadeBtnRef.current?.contains(e.target as Node) ||
- crossfadePopoverRef.current?.contains(e.target as Node)
- ) return;
- setShowCrossfadePopover(false);
- };
- document.addEventListener('mousedown', handle);
- return () => document.removeEventListener('mousedown', handle);
- }, [showCrossfadePopover]);
-
- 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]);
-
- // Tracks which queue index is being psy-dragged for opacity visual feedback
- const psyDragFromIdxRef = useRef(null);
+ const {
+ lufsTgtOpen,
+ setLufsTgtOpen,
+ lufsTgtPopStyle,
+ lufsTgtBtnRef,
+ lufsTgtMenuRef,
+ } = useQueueLufsTgtPopover(expandReplayGain);
const queueListRef = useRef(null);
-
- useLayoutEffect(() => {
- registerQueueListScrollTopReader(() => queueListRef.current?.scrollTop);
- return () => registerQueueListScrollTopReader(null);
- }, []);
-
- 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]);
-
const asideRef = useRef(null);
- 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);
- }, []);
+ const {
+ psyDragFromIdxRef,
+ externalDropTarget,
+ externalDropTargetRef,
+ setExternalDropTarget,
+ isQueueDrag,
+ startDrag,
+ } = useQueuePanelDrag({
+ asideRef,
+ isQueueVisible,
+ reorderQueue,
+ enqueueAt,
+ removeTrack,
+ });
- const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
- /** Only these drag types may be dropped into the queue. */
- const QUEUE_DROP_TYPES = new Set(['song', 'album', 'queue_reorder']);
- const isQueueDrag = isPsyDragging && !!psyPayload && (() => {
- try { return QUEUE_DROP_TYPES.has(JSON.parse(psyPayload.data).type); } catch { return false; }
- })();
- // Keep for the onPsyDrop radio-reject check below
- const isRadioDrag = isPsyDragging && !!psyPayload && (() => {
- try { return JSON.parse(psyPayload.data).type === 'radio'; } catch { return false; }
- })();
-
- useEffect(() => {
- if (!isPsyDragging) {
- externalDropTargetRef.current = null;
- setExternalDropTarget(null);
- }
- }, [isPsyDragging]);
-
- const [externalDropTarget, setExternalDropTarget] = useState<{ idx: number; before: boolean } | null>(null);
- const externalDropTargetRef = useRef<{ idx: number; before: boolean } | null>(null);
-
- // ── Mouse-event DnD: listen for psy-drop custom events ─────────
- 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);
- }, [enqueueAt]);
-
- // 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);
- }, [isQueueVisible, removeTrack]);
-
- 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('[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 }));
- });
- }, [currentTrack, activeTab]);
+ useQueueAutoScroll({
+ queue,
+ queueIndex,
+ currentTrack,
+ activeTab,
+ queueListRef,
+ suppressNextAutoScrollRef,
+ });
const [activePlaylist, setActivePlaylist] = useState<{ id: string; name: string } | null>(null);
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
@@ -709,508 +263,81 @@ function QueuePanelHostOrSolo() {
/>
{currentTrack && !isNowPlayingCollapsed && (
-
- {(() => {
- const baseParts = [
- currentTrack.suffix?.toUpperCase(),
- currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : undefined,
- (() => {
- const bd = currentTrack.bitDepth;
- const sr = currentTrack.samplingRate ? `${currentTrack.samplingRate / 1000} kHz` : '';
- if (bd && sr) return `${bd}/${sr}`;
- if (bd) return `${bd}-bit`;
- if (sr) return sr;
- return undefined;
- })(),
- ].filter(Boolean) as string[];
- const rgParts = formatQueueReplayGainParts(currentTrack, t);
- const baseLine = baseParts.join(' · ');
- const rgLine = rgParts.join(' · ');
- const isLoudnessActive = normalizationEngine === 'loudness' || normalizationEngineLive === 'loudness';
- const liveGainLabel = (() => {
- if (normalizationNowDb != null && Number.isFinite(normalizationNowDb)) {
- return `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB`;
- }
- if (isLoudnessActive && Number.isFinite(loudnessPreAnalysisAttenuationDb)) {
- const preEff = effectiveLoudnessPreAnalysisAttenuationDb(
- loudnessPreAnalysisAttenuationDb,
- authLoudnessTargetLufs,
- );
- const ph = loudnessGainPlaceholderUntilCacheDb(
- authLoudnessTargetLufs,
- preEff,
- );
- return `${ph >= 0 ? '+' : ''}${ph.toFixed(2)} dB`;
- }
- return '—';
- })();
- const tgtNum = normalizationTargetLufs ?? authLoudnessTargetLufs;
- const targetLabel = `${tgtNum} LUFS`;
- if (!baseLine && !rgLine && !playbackSource) return null;
- const showRgLine = !isLoudnessActive && expandReplayGain && !!rgLine;
- const showLufsLine = isLoudnessActive && expandReplayGain;
- return (
-
-
-
- {playbackSource && (
-
- {playbackSource === 'offline' && }
- {playbackSource === 'hot' && }
- {playbackSource === 'stream' && }
-
- )}
- {baseLine && {baseLine}}
- {!isLoudnessActive && rgLine && (
-
- )}
- {isLoudnessActive && (
-
- )}
-
- {showRgLine && (
-
- {t('queue.replayGain')}
- {' · '}{rgLine}
-
- )}
- {showLufsLine && (
-
- Loudness
- {' · '}
-
- {' · '}
- TGT
- {' · '}
-
- {lufsTgtOpen &&
- createPortal(
- e.stopPropagation()}
- >
- {([-10, -12, -14, -16] as const).map((v) => (
-
- ))}
-
,
- document.body,
- )}
-
- )}
-
-
- );
- })()}
-
-
- {currentTrack.coverArt ? (
-

- ) : (
-
- )}
-
-
-
{currentTrack.title}
-
currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
- >{currentTrack.artist}
-
currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
- >{currentTrack.album}
- {currentTrack.year && (
-
{currentTrack.year}
- )}
- {(() => {
- const label = orbitAttributionLabel(currentTrack.id);
- return label ?
{label}
: null;
- })()}
- {renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
-
-
-
+
)}
{activeTab === 'queue' ? (<>
{!isNowPlayingCollapsed && toolbarButtons.some(b => b.visible && b.id !== 'separator') && (
-
- {toolbarButtons.map((btn, idx) => {
- if (!btn.visible) return null;
-
- switch (btn.id as QueueToolbarButtonId) {
- case 'shuffle':
- return (
-
- );
- case 'save':
- return (
-
- );
- case 'load':
- return (
-
- );
- case 'share':
- return (
-
- );
- case 'clear':
- return (
-
- );
- case 'separator':
- return
;
- case 'gapless':
- return (
-
- );
- case 'crossfade':
- return (
-
-
- {showCrossfadePopover && (
-
-
-
- {t('queue.crossfade')}
- {crossfadeSecs.toFixed(1)} s
-
-
{
- setCrossfadeSecs(parseFloat(e.target.value));
- setCrossfadeEnabled(true);
- }}
- className="crossfade-popover-slider"
- />
-
- 0.1s10s
-
-
- )}
-
- );
- case 'infinite':
- return (
-
- );
- default:
- return null;
- }
- })}
-
+
)}
{currentTrack && queue.length > 0 && {t('queue.nextTracks')}
}
-
- {queue.length === 0 ? (
-
- {t('queue.emptyQueue')}
-
- ) : (
- <>
- {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 (
-
- {isFirstRadioAdded && (
-
- {t('queue.radioAdded')}
-
- )}
- {isFirstAutoAdded && (
-
- {t('queue.autoAdded')}
-
- )}
- {
- 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}
- >
-
-
- {isPlaying &&
}
-
{track.title}
-
-
{track.artist}
- {(() => {
- const label = orbitAttributionLabel(track.id);
- return label ?
{label}
: null;
- })()}
-
-
- {formatTime(track.duration)}
-
-
- {luckyRolling && isPlaying && (
-
- )}
-
- );
- })}
- >
- )}
-
+
>) : activeTab === 'lyrics' ? (
) : (
)}
-
-
-
-
-
+
{saveModalOpen && (
void;
+ onLoad: (id: string, name: string, mode: 'replace' | 'append') => void;
+}
+
+export function LoadPlaylistModal({ onClose, onLoad }: Props) {
+ const { t } = useTranslation();
+ const [playlists, setPlaylists] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [filter, setFilter] = useState('');
+ const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string } | null>(null);
+
+ const fetchPlaylists = () => {
+ setLoading(true);
+ getPlaylists().then(data => {
+ setPlaylists(data);
+ setLoading(false);
+ }).catch(e => {
+ console.error(e);
+ setLoading(false);
+ });
+ };
+
+ useEffect(() => {
+ fetchPlaylists();
+ }, []);
+
+ const handleDelete = async (id: string, name: string) => {
+ setConfirmDelete({ id, name });
+ };
+
+ const confirmDeletePlaylist = async () => {
+ if (!confirmDelete) return;
+ await deletePlaylist(confirmDelete.id);
+ setConfirmDelete(null);
+ fetchPlaylists();
+ };
+
+ return (
+ <>
+
+
e.stopPropagation()} style={{ maxWidth: '560px', width: '90vw' }}>
+
+
{t('queue.loadPlaylist')}
+ {!loading && playlists.length > 0 && (
+
setFilter(e.target.value)}
+ autoFocus
+ style={{ width: '100%', marginBottom: '0.75rem', padding: '8px 14px' }}
+ />
+ )}
+ {loading ? (
+
{t('queue.loading')}
+ ) : playlists.length === 0 ? (
+
{t('queue.noPlaylists')}
+ ) : (
+
+ {playlists.filter(p => p.name.toLowerCase().includes(filter.toLowerCase())).map(p => (
+
+
{p.name}
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {confirmDelete && (
+ setConfirmDelete(null)} role="dialog" aria-modal="true">
+
e.stopPropagation()} style={{ maxWidth: '360px' }}>
+
+
{t('queue.delete')}
+
+ {t('queue.deleteConfirm', { name: confirmDelete.name })}
+
+
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/components/queuePanel/QueueCurrentTrack.tsx b/src/components/queuePanel/QueueCurrentTrack.tsx
new file mode 100644
index 00000000..a1f19dd0
--- /dev/null
+++ b/src/components/queuePanel/QueueCurrentTrack.tsx
@@ -0,0 +1,223 @@
+import React from 'react';
+import { ChevronDown, FolderOpen, HardDrive, Music, Waves } from 'lucide-react';
+import type { TFunction } from 'i18next';
+import type { Track } from '../../store/playerStoreTypes';
+import type { LoudnessLufsPreset, NormalizationEngine } from '../../store/authStoreTypes';
+import type { PlaybackSourceKind } from '../../utils/resolvePlaybackUrl';
+import {
+ formatQueueReplayGainParts,
+ renderStars,
+} from '../../utils/queuePanelHelpers';
+import { loudnessGainPlaceholderUntilCacheDb } from '../../utils/loudnessPlaceholder';
+import { effectiveLoudnessPreAnalysisAttenuationDb } from '../../utils/loudnessPreAnalysisSlider';
+import { QueueLufsTargetMenu } from './QueueLufsTargetMenu';
+
+interface Props {
+ currentTrack: Track;
+ currentCoverSrc: string;
+ userRatingOverrides: Record;
+ orbitAttributionLabel: (trackId: string) => string | null;
+ navigate: (to: string) => void;
+ playbackSource: PlaybackSourceKind | null;
+ normalizationEngine: NormalizationEngine;
+ normalizationEngineLive: 'off' | 'replaygain' | 'loudness';
+ normalizationNowDb: number | null;
+ normalizationTargetLufs: number | null;
+ authLoudnessTargetLufs: LoudnessLufsPreset;
+ loudnessPreAnalysisAttenuationDb: number;
+ expandReplayGain: boolean;
+ setExpandReplayGain: (v: boolean) => void;
+ reanalyzeLoudnessForTrack: (id: string) => void | Promise;
+ setLoudnessTargetLufs: (v: LoudnessLufsPreset) => void;
+ lufsTgtOpen: boolean;
+ setLufsTgtOpen: (v: boolean | ((p: boolean) => boolean)) => void;
+ lufsTgtBtnRef: React.RefObject;
+ lufsTgtMenuRef: React.RefObject;
+ lufsTgtPopStyle: React.CSSProperties;
+ t: TFunction;
+}
+
+export function QueueCurrentTrack({
+ currentTrack, currentCoverSrc, userRatingOverrides, orbitAttributionLabel,
+ navigate, playbackSource, normalizationEngine, normalizationEngineLive,
+ normalizationNowDb, normalizationTargetLufs, authLoudnessTargetLufs,
+ loudnessPreAnalysisAttenuationDb, expandReplayGain, setExpandReplayGain,
+ reanalyzeLoudnessForTrack, setLoudnessTargetLufs, lufsTgtOpen, setLufsTgtOpen,
+ lufsTgtBtnRef, lufsTgtMenuRef, lufsTgtPopStyle, t,
+}: Props) {
+ return (
+
+ {(() => {
+ const baseParts = [
+ currentTrack.suffix?.toUpperCase(),
+ currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : undefined,
+ (() => {
+ const bd = currentTrack.bitDepth;
+ const sr = currentTrack.samplingRate ? `${currentTrack.samplingRate / 1000} kHz` : '';
+ if (bd && sr) return `${bd}/${sr}`;
+ if (bd) return `${bd}-bit`;
+ if (sr) return sr;
+ return undefined;
+ })(),
+ ].filter(Boolean) as string[];
+ const rgParts = formatQueueReplayGainParts(currentTrack, t);
+ const baseLine = baseParts.join(' · ');
+ const rgLine = rgParts.join(' · ');
+ const isLoudnessActive = normalizationEngine === 'loudness' || normalizationEngineLive === 'loudness';
+ const liveGainLabel = (() => {
+ if (normalizationNowDb != null && Number.isFinite(normalizationNowDb)) {
+ return `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB`;
+ }
+ if (isLoudnessActive && Number.isFinite(loudnessPreAnalysisAttenuationDb)) {
+ const preEff = effectiveLoudnessPreAnalysisAttenuationDb(
+ loudnessPreAnalysisAttenuationDb,
+ authLoudnessTargetLufs,
+ );
+ const ph = loudnessGainPlaceholderUntilCacheDb(
+ authLoudnessTargetLufs,
+ preEff,
+ );
+ return `${ph >= 0 ? '+' : ''}${ph.toFixed(2)} dB`;
+ }
+ return '—';
+ })();
+ const tgtNum = normalizationTargetLufs ?? authLoudnessTargetLufs;
+ const targetLabel = `${tgtNum} LUFS`;
+ if (!baseLine && !rgLine && !playbackSource) return null;
+ const showRgLine = !isLoudnessActive && expandReplayGain && !!rgLine;
+ const showLufsLine = isLoudnessActive && expandReplayGain;
+ return (
+
+
+
+ {playbackSource && (
+
+ {playbackSource === 'offline' && }
+ {playbackSource === 'hot' && }
+ {playbackSource === 'stream' && }
+
+ )}
+ {baseLine && {baseLine}}
+ {!isLoudnessActive && rgLine && (
+
+ )}
+ {isLoudnessActive && (
+
+ )}
+
+ {showRgLine && (
+
+ {t('queue.replayGain')}
+ {' · '}{rgLine}
+
+ )}
+ {showLufsLine && (
+
+ Loudness
+ {' · '}
+
+ {' · '}
+ TGT
+ {' · '}
+
+ {lufsTgtOpen && (
+ setLufsTgtOpen(false)}
+ />
+ )}
+
+ )}
+
+
+ );
+ })()}
+
+
+ {currentTrack.coverArt ? (
+

+ ) : (
+
+ )}
+
+
+
{currentTrack.title}
+
currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
+ >{currentTrack.artist}
+
currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
+ >{currentTrack.album}
+ {currentTrack.year && (
+
{currentTrack.year}
+ )}
+ {(() => {
+ const label = orbitAttributionLabel(currentTrack.id);
+ return label ?
{label}
: null;
+ })()}
+ {renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
+
+
+
+ );
+}
diff --git a/src/components/queuePanel/QueueHeader.tsx b/src/components/queuePanel/QueueHeader.tsx
new file mode 100644
index 00000000..94795a20
--- /dev/null
+++ b/src/components/queuePanel/QueueHeader.tsx
@@ -0,0 +1,111 @@
+import { useMemo } from 'react';
+import { ChevronDown, ListMusic } from 'lucide-react';
+import type { TFunction } from 'i18next';
+import { usePlayerStore } from '../../store/playerStore';
+import type { Track } from '../../store/playerStoreTypes';
+import type { DurationMode } from '../../utils/queuePanelHelpers';
+
+interface Props {
+ queue: Track[];
+ queueIndex: number;
+ activePlaylist: { id: string; name: string } | null;
+ isNowPlayingCollapsed: boolean;
+ setIsNowPlayingCollapsed: (v: boolean) => void;
+ durationMode: DurationMode;
+ setDurationMode: (m: DurationMode) => void;
+ t: TFunction;
+}
+
+export function QueueHeader({
+ queue, queueIndex, activePlaylist, isNowPlayingCollapsed,
+ setIsNowPlayingCollapsed, durationMode, setDurationMode, t,
+}: Props) {
+ const currentTime = usePlayerStore((s) => Math.floor(s.currentTime / 30) * 30);
+ const isPlaying = usePlayerStore((s) => s.isPlaying);
+
+ const totalSecs = useMemo(() =>
+ queue.reduce((acc: number, track: Track) => acc + (track.duration || 0), 0),
+ [queue]
+ );
+ const futureTracksDuration = useMemo(() =>
+ queue.slice(queueIndex + 1).reduce((acc: number, track: Track) => acc + (track.duration || 0), 0),
+ [queue, queueIndex]
+ );
+
+ const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + futureTracksDuration);
+
+ const fmt = (secs: number) => {
+ const h = Math.floor(secs / 3600);
+ const m = Math.floor((secs % 3600) / 60);
+ const s = secs % 60;
+ return h > 0 ? `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}` : `${m}:${s.toString().padStart(2, "0")}`;
+ };
+ const fmtEta = (secs: number) => {
+ const finishTime = new Date(Date.now() + secs * 1000);
+ return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(finishTime);
+ };
+
+ let dur: string | null = null;
+ if (queue.length > 0) {
+ if (durationMode === 'total') dur = fmt(Math.floor(totalSecs));
+ else if (durationMode === 'remaining') dur = `-${fmt(Math.floor(remainingSecs))}`;
+ else dur = fmtEta(remainingSecs);
+ }
+
+ const nextMode: DurationMode =
+ durationMode === 'total' ? 'remaining' :
+ durationMode === 'remaining' ? 'eta' : 'total';
+ const nextTooltipKey =
+ nextMode === 'total' ? 'queue.showTotal' :
+ nextMode === 'remaining' ? 'queue.showRemaining' : 'queue.showEta';
+
+ const isEta = durationMode === 'eta';
+
+ return (
+
+
+
+
{t("queue.title")}
+ {queue.length > 0 && (
+
+ ({queueIndex + 1}/{queue.length})
+
+ )}
+ {dur !== null && (
+ setDurationMode(nextMode)}
+ data-tooltip={t(nextTooltipKey)}
+ style={{
+ fontSize: "13px",
+ color: isEta ? (isPlaying ? "var(--accent)" : "var(--text-muted)") : "var(--accent)",
+ opacity: isEta && !isPlaying ? 0.5 : 1,
+ whiteSpace: "nowrap",
+ cursor: "pointer",
+ userSelect: "none",
+ }}
+ >
+ · {dur}
+
+ )}
+
+ {activePlaylist && (
+
+
+ {activePlaylist.name}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/queuePanel/QueueList.tsx b/src/components/queuePanel/QueueList.tsx
new file mode 100644
index 00000000..ca69b029
--- /dev/null
+++ b/src/components/queuePanel/QueueList.tsx
@@ -0,0 +1,164 @@
+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;
+ suppressNextAutoScrollRef: React.MutableRefObject;
+ isQueueDrag: boolean;
+ psyDragFromIdxRef: React.MutableRefObject;
+ 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 (
+
+ {queue.length === 0 ? (
+
+ {t('queue.emptyQueue')}
+
+ ) : (
+ <>
+ {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 (
+
+ {isFirstRadioAdded && (
+
+ {t('queue.radioAdded')}
+
+ )}
+ {isFirstAutoAdded && (
+
+ {t('queue.autoAdded')}
+
+ )}
+ {
+ 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}
+ >
+
+
+ {isPlaying &&
}
+
{track.title}
+
+
{track.artist}
+ {(() => {
+ const label = orbitAttributionLabel(track.id);
+ return label ?
{label}
: null;
+ })()}
+
+
+ {formatTime(track.duration)}
+
+
+ {luckyRolling && isPlaying && (
+
+ )}
+
+ );
+ })}
+ >
+ )}
+
+ );
+}
diff --git a/src/components/queuePanel/QueueLufsTargetMenu.tsx b/src/components/queuePanel/QueueLufsTargetMenu.tsx
new file mode 100644
index 00000000..ba8edf19
--- /dev/null
+++ b/src/components/queuePanel/QueueLufsTargetMenu.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import { createPortal } from 'react-dom';
+import type { LoudnessLufsPreset } from '../../store/authStoreTypes';
+
+interface Props {
+ menuRef: React.RefObject;
+ popStyle: React.CSSProperties;
+ authLoudnessTargetLufs: LoudnessLufsPreset;
+ setLoudnessTargetLufs: (v: LoudnessLufsPreset) => void;
+ onClose: () => void;
+}
+
+const LUFS_TARGETS: readonly LoudnessLufsPreset[] = [-10, -12, -14, -16] as const;
+
+export function QueueLufsTargetMenu({
+ menuRef, popStyle, authLoudnessTargetLufs, setLoudnessTargetLufs, onClose,
+}: Props) {
+ return createPortal(
+ e.stopPropagation()}
+ >
+ {LUFS_TARGETS.map((v) => (
+
+ ))}
+
,
+ document.body,
+ );
+}
diff --git a/src/components/queuePanel/QueueTabBar.tsx b/src/components/queuePanel/QueueTabBar.tsx
new file mode 100644
index 00000000..f4e3cb17
--- /dev/null
+++ b/src/components/queuePanel/QueueTabBar.tsx
@@ -0,0 +1,41 @@
+import { Info, ListMusic, MicVocal } from 'lucide-react';
+import type { TFunction } from 'i18next';
+
+type LyricsTab = 'queue' | 'lyrics' | 'info';
+
+interface Props {
+ activeTab: LyricsTab;
+ setTab: (tab: LyricsTab) => void;
+ t: TFunction;
+}
+
+export function QueueTabBar({ activeTab, setTab, t }: Props) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/queuePanel/QueueToolbar.tsx b/src/components/queuePanel/QueueToolbar.tsx
new file mode 100644
index 00000000..16e30834
--- /dev/null
+++ b/src/components/queuePanel/QueueToolbar.tsx
@@ -0,0 +1,185 @@
+import { useEffect, useRef, useState } from 'react';
+import {
+ Check, FolderOpen, Infinity, MoveRight, Save, Share2, Shuffle, Trash2, Waves,
+} from 'lucide-react';
+import type { TFunction } from 'i18next';
+import type { Track } from '../../store/playerStoreTypes';
+import type {
+ QueueToolbarButtonConfig,
+ QueueToolbarButtonId,
+} from '../../store/queueToolbarStore';
+
+interface Props {
+ queue: Track[];
+ activePlaylist: { id: string; name: string } | null;
+ saveState: 'idle' | 'saving' | 'saved';
+ toolbarButtons: QueueToolbarButtonConfig[];
+ shuffleQueue: () => void;
+ handleSave: () => void;
+ handleLoad: () => void;
+ handleCopyQueueShare: () => void;
+ handleClear: () => void;
+ gaplessEnabled: boolean;
+ setGaplessEnabled: (v: boolean) => void;
+ crossfadeEnabled: boolean;
+ setCrossfadeEnabled: (v: boolean) => void;
+ crossfadeSecs: number;
+ setCrossfadeSecs: (v: number) => void;
+ infiniteQueueEnabled: boolean;
+ setInfiniteQueueEnabled: (v: boolean) => void;
+ t: TFunction;
+}
+
+export function QueueToolbar({
+ queue, activePlaylist, saveState, toolbarButtons, shuffleQueue,
+ handleSave, handleLoad, handleCopyQueueShare, handleClear,
+ gaplessEnabled, setGaplessEnabled, crossfadeEnabled, setCrossfadeEnabled,
+ crossfadeSecs, setCrossfadeSecs, infiniteQueueEnabled, setInfiniteQueueEnabled,
+ t,
+}: Props) {
+ const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
+ const crossfadeBtnRef = useRef(null);
+ const crossfadePopoverRef = useRef(null);
+
+ useEffect(() => {
+ if (!showCrossfadePopover) return;
+ const handle = (e: MouseEvent) => {
+ if (
+ crossfadeBtnRef.current?.contains(e.target as Node) ||
+ crossfadePopoverRef.current?.contains(e.target as Node)
+ ) return;
+ setShowCrossfadePopover(false);
+ };
+ document.addEventListener('mousedown', handle);
+ return () => document.removeEventListener('mousedown', handle);
+ }, [showCrossfadePopover]);
+
+ return (
+
+ {toolbarButtons.map((btn) => {
+ if (!btn.visible) return null;
+
+ switch (btn.id as QueueToolbarButtonId) {
+ case 'shuffle':
+ return (
+
+ );
+ case 'save':
+ return (
+
+ );
+ case 'load':
+ return (
+
+ );
+ case 'share':
+ return (
+
+ );
+ case 'clear':
+ return (
+
+ );
+ case 'separator':
+ return
;
+ case 'gapless':
+ return (
+
+ );
+ case 'crossfade':
+ return (
+
+
+ {showCrossfadePopover && (
+
+
+
+ {t('queue.crossfade')}
+ {crossfadeSecs.toFixed(1)} s
+
+
{
+ setCrossfadeSecs(parseFloat(e.target.value));
+ setCrossfadeEnabled(true);
+ }}
+ className="crossfade-popover-slider"
+ />
+
+ 0.1s10s
+
+
+ )}
+
+ );
+ case 'infinite':
+ return (
+
+ );
+ default:
+ return null;
+ }
+ })}
+
+ );
+}
diff --git a/src/components/queuePanel/SavePlaylistModal.tsx b/src/components/queuePanel/SavePlaylistModal.tsx
new file mode 100644
index 00000000..ba04b43b
--- /dev/null
+++ b/src/components/queuePanel/SavePlaylistModal.tsx
@@ -0,0 +1,35 @@
+import { useState } from 'react';
+import { X } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+interface Props {
+ onClose: () => void;
+ onSave: (name: string) => void;
+}
+
+export function SavePlaylistModal({ onClose, onSave }: Props) {
+ const { t } = useTranslation();
+ const [name, setName] = useState('');
+ return (
+
+
e.stopPropagation()} style={{ maxWidth: '400px' }}>
+
+
{t('queue.savePlaylist')}
+
setName(e.target.value)}
+ autoFocus
+ onKeyDown={e => e.key === 'Enter' && name.trim() && onSave(name.trim())}
+ style={{ width: '100%', marginBottom: '1rem', padding: '10px 16px' }}
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/src/hooks/useQueueAutoScroll.ts b/src/hooks/useQueueAutoScroll.ts
new file mode 100644
index 00000000..57d7c4dd
--- /dev/null
+++ b/src/hooks/useQueueAutoScroll.ts
@@ -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;
+ suppressNextAutoScrollRef: React.MutableRefObject;
+}
+
+/** 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('[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]);
+}
diff --git a/src/hooks/useQueueLufsTgtPopover.ts b/src/hooks/useQueueLufsTgtPopover.ts
new file mode 100644
index 00000000..9adff7ca
--- /dev/null
+++ b/src/hooks/useQueueLufsTgtPopover.ts
@@ -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({});
+ const lufsTgtBtnRef = useRef(null);
+ const lufsTgtMenuRef = useRef(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,
+ };
+}
diff --git a/src/hooks/useQueuePanelDrag.ts b/src/hooks/useQueuePanelDrag.ts
new file mode 100644
index 00000000..281f8785
--- /dev/null
+++ b/src/hooks/useQueuePanelDrag.ts
@@ -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;
+ 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(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,
+ };
+}
diff --git a/src/utils/queuePanelHelpers.tsx b/src/utils/queuePanelHelpers.tsx
new file mode 100644
index 00000000..eb52bdc7
--- /dev/null
+++ b/src/utils/queuePanelHelpers.tsx
@@ -0,0 +1,43 @@
+import { Star } from 'lucide-react';
+import type { TFunction } from 'i18next';
+import type { Track } from '../store/playerStoreTypes';
+
+export type DurationMode = 'total' | 'remaining' | 'eta';
+
+export function formatTime(seconds: number): string {
+ if (!seconds || isNaN(seconds)) return '0:00';
+ const m = Math.floor(seconds / 60);
+ const s = Math.floor(seconds % 60);
+ return `${m}:${s.toString().padStart(2, '0')}`;
+}
+
+export function formatQueueReplayGainParts(track: Track, t: TFunction): string[] {
+ const parts: string[] = [];
+ const fmtDb = (db: number) => `${db >= 0 ? '+' : ''}${db.toFixed(1)}`;
+ if (track.replayGainTrackDb != null) {
+ parts.push(t('queue.rgTrack', { db: fmtDb(track.replayGainTrackDb) }));
+ }
+ if (track.replayGainAlbumDb != null) {
+ parts.push(t('queue.rgAlbum', { db: fmtDb(track.replayGainAlbumDb) }));
+ }
+ if (track.replayGainPeak != null) {
+ parts.push(t('queue.rgPeak', { pk: track.replayGainPeak.toFixed(3) }));
+ }
+ return parts;
+}
+
+export function renderStars(rating?: number) {
+ if (!rating) return null;
+ const stars = [];
+ for (let i = 1; i <= 5; i++) {
+ stars.push(
+
+ );
+ }
+ return {stars}
;
+}