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 { 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 { buildCoverArtUrl, coverArtCacheKey, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist } 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, 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}
)}
);
}
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 in a hosted session. Bulk-adds (album / playlist enqueue) bypass
* `hostEnqueueToOrbit` and therefore never land in `state.queue`, so we
* default those to "Added by you" rather than showing nothing. */
const orbitAttributionLabel = (trackId: string): string | null => {
if (orbitRole !== 'host' || !orbitState) return null;
const addedBy = orbitAddedByByTrack.get(trackId);
if (!addedBy || 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 removeTrack = usePlayerStore(s => s.removeTrack);
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
const enqueue = usePlayerStore(s => s.enqueue);
const enqueueAt = usePlayerStore(s => s.enqueueAt);
const contextMenu = usePlayerStore(s => s.contextMenu);
// When the user picks a track *from* the queue list, suppress the
// upcoming auto-scroll so their click target stays in view instead of
// the list rebasing onto the next track. Auto-advance (natural playback)
// never sets this flag, so it keeps its original "show what's next" behavior.
const suppressNextAutoScrollRef = useRef(false);
const playbackSource = usePlayerStore(s => s.currentPlaybackSource);
const normalizationNowDb = usePlayerStore(s => s.normalizationNowDb);
const normalizationTargetLufs = usePlayerStore(s => s.normalizationTargetLufs);
const normalizationEngineLive = usePlayerStore(s => s.normalizationEngineLive);
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 normalizationEngine = useAuthStore(s => s.normalizationEngine);
const replayGainMode = useAuthStore(s => s.replayGainMode);
const activeTab = useLyricsStore(s => s.activeTab);
const setTab = useLyricsStore(s => s.setTab);
const luckyRolling = useLuckyMixStore(s => s.isRolling);
const isNowPlayingCollapsed = useAuthStore(s => s.queueNowPlayingCollapsed);
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 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 { 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]);
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 (
);
}