import React, { useState, useRef, useMemo, useEffect } from 'react'; import { Track, usePlayerStore, songToTrack } 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 { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic'; import { usePlaylistStore } from '../store/playlistStore'; import { useCachedUrl } from './CachedImage'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useAuthStore } from '../store/authStore'; import { encodeSharePayload } from '../utils/shareLink'; import { copyTextToClipboard } from '../utils/serverMagicString'; import { showToast } from '../utils/toast'; import { useThemeStore } from '../store/themeStore'; import { useLyricsStore } from '../store/lyricsStore'; import { useDragDrop } from '../contexts/DragDropContext'; import LyricsPane from './LyricsPane'; import NowPlayingInfo from './NowPlayingInfo'; import { TFunction } from 'i18next'; import OverlayScrollArea from './OverlayScrollArea'; import { useLuckyMixStore } from '../store/luckyMixStore'; 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 })}

)} ); } interface QueueHeaderProps { queue: Track[]; queueIndex: number; showRemainingTime: boolean; setShowRemainingTime: React.Dispatch>; activePlaylist: { id: string; name: string } | null; t: TFunction; } function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTime, activePlaylist, t }: QueueHeaderProps) { const currentTime = usePlayerStore((s) => s.currentTime); if (queue.length === 0) return null; const totalSecs = queue.reduce((acc: number, t: any) => acc + (t.duration || 0), 0); const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + queue.slice(queueIndex + 1).reduce((acc: number, t: any) => acc + (t.duration || 0), 0)); 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 dur = showRemainingTime ? `-${fmt(Math.floor(remainingSecs))}` : fmt(Math.floor(totalSecs)); return (

{t("queue.title")}

setShowRemainingTime((v: boolean) => !v)} data-tooltip={showRemainingTime ? t("queue.showTotal") : t("queue.showRemaining")} style={{ fontSize: "13px", color: "var(--accent)", whiteSpace: "nowrap", cursor: "pointer", userSelect: "none", }} > {queue.length} {queue.length === 1 ? t("queue.trackSingular") : t("queue.trackPlural")} · {dur}
{activePlaylist && (
{activePlaylist.name}
)}
); } export default function QueuePanel() { const orbitRole = useOrbitStore(s => s.role); if (orbitRole === 'guest') { return ( ); } return ; } function QueuePanelHostOrSolo() { const { t } = useTranslation(); const navigate = useNavigate(); const orbitRole = useOrbitStore(s => s.role); const orbitState = useOrbitStore(s => s.state); /** trackId → addedBy (host username or guest username) — only populated while * hosting an Orbit session, so the queue rows can surface attribution. */ const orbitAddedByByTrack = useMemo(() => { const map = new Map(); if (orbitRole !== 'host' || !orbitState) return map; if (orbitState.currentTrack) { map.set(orbitState.currentTrack.trackId, orbitState.currentTrack.addedBy); } for (const q of orbitState.queue) map.set(q.trackId, q.addedBy); return map; }, [orbitRole, orbitState]); const orbitHostUsername = orbitState?.host ?? ''; /** Attribution label for a queue row / current track while hosting. Null when * not hosting, when the track isn't part of the Orbit state, or when it * isn't relevant for this user (non-host and we haven't mapped the added-by). */ const orbitAttributionLabel = (trackId: string): string | null => { if (orbitRole !== 'host') return null; const addedBy = orbitAddedByByTrack.get(trackId); if (!addedBy) return null; if (addedBy === orbitHostUsername) return t('orbit.queueAddedByYou'); return t('orbit.queueAddedByUser', { user: addedBy }); }; const queue = usePlayerStore(s => s.queue); const queueIndex = usePlayerStore(s => s.queueIndex); const currentTrack = usePlayerStore(s => s.currentTrack); const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); const currentCoverFetchUrl = useMemo( () => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt] ); const currentCoverCacheKey = useMemo( () => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt] ); 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); const shuffleQueue = usePlayerStore(s => s.shuffleQueue); const enqueue = usePlayerStore(s => s.enqueue); const enqueueAt = usePlayerStore(s => s.enqueueAt); const contextMenu = usePlayerStore(s => s.contextMenu); const playbackSource = usePlayerStore(s => s.currentPlaybackSource); const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled); const crossfadeSecs = useAuthStore(s => s.crossfadeSecs); const gaplessEnabled = useAuthStore(s => s.gaplessEnabled); const infiniteQueueEnabled = useAuthStore(s => s.infiniteQueueEnabled); const setCrossfadeEnabled = useAuthStore(s => s.setCrossfadeEnabled); const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs); const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled); const setInfiniteQueueEnabled = useAuthStore(s => s.setInfiniteQueueEnabled); const activeTab = useLyricsStore(s => s.activeTab); const setTab = useLyricsStore(s => s.setTab); const luckyRolling = useLuckyMixStore(s => s.isRolling); const [showRemainingTime, setShowRemainingTime] = useState(false); const [showCrossfadePopover, setShowCrossfadePopover] = useState(false); const expandReplayGain = useThemeStore(s => s.expandReplayGain); const setExpandReplayGain = useThemeStore(s => s.setExpandReplayGain); 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]); // Tracks which queue index is being psy-dragged for opacity visual feedback const psyDragFromIdxRef = useRef(null); const queueListRef = useRef(null); const asideRef = useRef(null); 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]); useEffect(function queueAutoScroll() { 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]); const [activePlaylist, setActivePlaylist] = useState<{ id: string; name: string } | null>(null); const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle'); const [saveModalOpen, setSaveModalOpen] = useState(false); const [loadModalOpen, setLoadModalOpen] = useState(false); const handleSave = async () => { if (queue.length === 0) return; if (activePlaylist) { setSaveState('saving'); try { await updatePlaylist(activePlaylist.id, queue.map(t => t.id)); setSaveState('saved'); setTimeout(() => setSaveState('idle'), 1500); } catch (e) { console.error('Failed to update playlist', e); setSaveState('idle'); } } else { setSaveModalOpen(true); } }; const handleLoad = () => { setLoadModalOpen(true); }; const handleClear = () => { clearQueue(); setActivePlaylist(null); }; const handleCopyQueueShare = async () => { if (queue.length === 0) { showToast(t('queue.shareQueueEmpty'), 3000, 'info'); return; } const srv = useAuthStore.getState().getBaseUrl(); if (!srv) return; const ids = queue.map(t => t.id); const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids })); if (ok) showToast(t('contextMenu.shareCopied')); else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error'); }; return ( ); }