import React, { useState, useRef } from 'react'; import { Track, usePlayerStore } from '../store/playerStore'; import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic } from 'lucide-react'; import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useAuthStore } from '../store/authStore'; import { useLyricsStore } from '../store/lyricsStore'; import LyricsPane from './LyricsPane'; 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 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) => void }) { const { t } = useTranslation(); const [playlists, setPlaylists] = useState([]); const [loading, setLoading] = useState(true); 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) => { if (confirm(t('queue.deleteConfirm', { name }))) { await deletePlaylist(id); fetchPlaylists(); } }; return (
e.stopPropagation()} style={{ maxWidth: '400px' }}>

{t('queue.loadPlaylist')}

{loading ? (

{t('queue.loading')}

) : playlists.length === 0 ? (

{t('queue.noPlaylists')}

) : (
{playlists.map(p => (
{p.name}
))}
)}
); } // Module-level fallback for fromIdx — survives the dragend-before-drop race on // macOS WKWebView AND the dataTransfer.getData('') bug on Windows WebView2. let _dragFromIdx: number | null = null; export default function QueuePanel() { const { t } = useTranslation(); const navigate = useNavigate(); const queue = usePlayerStore(s => s.queue); const queueIndex = usePlayerStore(s => s.queueIndex); const currentTrack = usePlayerStore(s => s.currentTrack); const currentTime = usePlayerStore(s => s.currentTime); 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 contextMenu = usePlayerStore(s => s.contextMenu); const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled); const crossfadeSecs = useAuthStore(s => s.crossfadeSecs); const gaplessEnabled = useAuthStore(s => s.gaplessEnabled); const setCrossfadeEnabled = useAuthStore(s => s.setCrossfadeEnabled); const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs); const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled); const activeTab = useLyricsStore(s => s.activeTab); const setTab = useLyricsStore(s => s.setTab); const [showRemainingTime, setShowRemainingTime] = useState(false); 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]); const [draggedIdx, setDraggedIdx] = useState(null); const [dragOverIdx, setDragOverIdx] = useState(null); const isDraggingInternalRef = useRef(false); // Refs mirror state so drop handler always reads fresh values even when // macOS WKWebView fires dragend before drop (spec violation). const draggedIdxRef = useRef(null); const dragOverIdxRef = useRef(null); const queueListRef = useRef(null); const [saveModalOpen, setSaveModalOpen] = useState(false); const [loadModalOpen, setLoadModalOpen] = useState(false); const handleSave = () => { if (queue.length === 0) return; setSaveModalOpen(true); }; const handleLoad = () => { setLoadModalOpen(true); }; const handleClear = () => { clearQueue(); }; const onDragStart = (e: React.DragEvent, index: number) => { isDraggingInternalRef.current = true; draggedIdxRef.current = index; _dragFromIdx = index; setDraggedIdx(index); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'queue_reorder', index })); }; const onDragEnterItem = (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }; const onDragOverItem = (e: React.DragEvent, index: number) => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; dragOverIdxRef.current = index; setDragOverIdx(index); }; const onDragEnd = () => { setDraggedIdx(null); setDragOverIdx(null); isDraggingInternalRef.current = false; draggedIdxRef.current = null; dragOverIdxRef.current = null; // _dragFromIdx intentionally NOT cleared here — drop fires after dragend on // macOS WKWebView, so we need the value to survive into onDropQueue. // It is cleared in onDropQueue after use instead. }; const onDropQueue = async (e: React.DragEvent) => { e.preventDefault(); // Clear visual state immediately isDraggingInternalRef.current = false; draggedIdxRef.current = null; dragOverIdxRef.current = null; setDraggedIdx(null); setDragOverIdx(null); let parsedData: any = null; try { const raw = e.dataTransfer.getData('text/plain'); if (raw) parsedData = JSON.parse(raw); } catch { /* ignore */ } if (parsedData?.type === 'queue_reorder' || _dragFromIdx !== null) { // fromIdx: prefer dataTransfer value; fall back to module-level var for // Windows WebView2 where getData() can return '' in the drop handler. const fromIdx: number = parsedData?.index ?? _dragFromIdx!; _dragFromIdx = null; // toIdx: calculate from drop coordinates — avoids all ref timing issues. // Works even when dragend fires before drop (macOS WKWebView / Windows WebView2). let toIdx = queue.length; if (queueListRef.current) { const items = queueListRef.current.querySelectorAll('[data-queue-idx]'); for (let i = 0; i < items.length; i++) { const rect = items[i].getBoundingClientRect(); if (e.clientY < rect.top + rect.height / 2) { toIdx = parseInt(items[i].dataset.queueIdx!); break; } } } if (fromIdx !== toIdx) reorderQueue(fromIdx, toIdx); return; } // External drop (song / album dragged from elsewhere in the app) _dragFromIdx = null; if (!parsedData) return; if (parsedData.type === 'song') { enqueue([parsedData.track]); } else if (parsedData.type === 'album') { const albumData = await getAlbum(parsedData.id); const tracks: Track[] = albumData.songs.map(s => ({ 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, })); enqueue(tracks); } }; return ( ); }