import React, { useState, useRef } from 'react';
import { Track, usePlayerStore } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves } 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';
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 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 [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 (
);
}