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