2026-01-07 09:31:02 +00:00
|
|
|
|
|
|
|
|
import React, { useState } from 'react';
|
|
|
|
|
import { useAudio } from '../../../context/AudioContext';
|
|
|
|
|
import { Card } from '../../ui/card';
|
|
|
|
|
import { Button } from '../../ui/button';
|
|
|
|
|
import { SaveQueueAsPlaylistModal } from './SaveQueueAsPlaylistModal';
|
|
|
|
|
import { Play, Pause, X, GripVertical, Trash2, Save, ListMusic } from 'lucide-react';
|
|
|
|
|
import { useToast } from '../../../context/ToastContext';
|
|
|
|
|
|
|
|
|
|
export const QueueView: React.FC = () => {
|
2026-01-07 10:15:48 +00:00
|
|
|
const { queue, currentTrack, reorderQueue, removeFromQueue, clearQueue, playTrack, isPlaying, togglePlay, autoplay, toggleAutoplay } = useAudio();
|
|
|
|
|
const { addToast } = useToast();
|
|
|
|
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
|
|
|
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
2026-01-07 09:31:02 +00:00
|
|
|
|
2026-01-07 10:15:48 +00:00
|
|
|
const handleDragStart = (e: React.DragEvent, index: number) => {
|
|
|
|
|
setDraggedIndex(index);
|
|
|
|
|
e.dataTransfer.effectAllowed = "move";
|
|
|
|
|
// Transparent ghost image
|
|
|
|
|
const ghost = document.createElement("div");
|
|
|
|
|
ghost.style.opacity = "0";
|
|
|
|
|
document.body.appendChild(ghost);
|
|
|
|
|
e.dataTransfer.setDragImage(ghost, 0, 0);
|
|
|
|
|
setTimeout(() => document.body.removeChild(ghost), 0);
|
|
|
|
|
};
|
2026-01-07 09:31:02 +00:00
|
|
|
|
2026-01-07 10:15:48 +00:00
|
|
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (draggedIndex === null || draggedIndex === index) return;
|
|
|
|
|
reorderQueue(draggedIndex, index);
|
|
|
|
|
setDraggedIndex(index);
|
|
|
|
|
};
|
2026-01-07 09:31:02 +00:00
|
|
|
|
2026-01-07 10:15:48 +00:00
|
|
|
const handleSavePlaylist = (name: string, _isPublic: boolean) => {
|
|
|
|
|
addToast(`Queue saved as "${name}"`, "success");
|
|
|
|
|
// Logic to actually save would connect to backend/context here
|
|
|
|
|
};
|
2026-01-07 09:31:02 +00:00
|
|
|
|
2026-01-07 10:15:48 +00:00
|
|
|
return (
|
|
|
|
|
<div className="max-w-4xl mx-auto space-y-6 animate-fadeIn pb-20">
|
|
|
|
|
<div className="flex flex-col md:flex-row justify-between items-end border-b border-kodo-steel/50 pb-6 gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-3xl font-display font-bold text-white mb-2">PLAY QUEUE</h1>
|
|
|
|
|
<p className="text-gray-400 font-mono text-sm">{queue.length} tracks upcoming</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-3">
|
|
|
|
|
<Button variant="ghost" onClick={() => setShowSaveModal(true)} icon={<Save className="w-4 h-4" />}>Save Queue</Button>
|
|
|
|
|
<Button variant="ghost" className="text-kodo-red hover:bg-kodo-red/10" onClick={clearQueue} icon={<Trash2 className="w-4 h-4" />}>Clear</Button>
|
|
|
|
|
</div>
|
2026-01-07 09:31:02 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-01-07 10:15:48 +00:00
|
|
|
{/* Current Track */}
|
|
|
|
|
{currentTrack && (
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-widest mb-3">Now Playing</h3>
|
|
|
|
|
<Card variant="gaming" className="flex items-center gap-4 p-4 border-l-4 border-l-kodo-cyan">
|
|
|
|
|
<div className="relative w-16 h-16 rounded overflow-hidden flex-shrink-0 group cursor-pointer" onClick={togglePlay}>
|
|
|
|
|
<img src={currentTrack.coverUrl} className="w-full h-full object-cover" />
|
|
|
|
|
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
|
|
|
|
{isPlaying ? <Pause className="w-6 h-6 text-white" /> : <Play className="w-6 h-6 text-white fill-current ml-1" />}
|
2026-01-07 09:31:02 +00:00
|
|
|
</div>
|
2026-01-07 10:15:48 +00:00
|
|
|
{isPlaying && (
|
|
|
|
|
<div className="absolute bottom-1 right-1 flex gap-0.5 items-end h-3">
|
|
|
|
|
<div className="w-1 bg-kodo-cyan animate-[bounce_1s_infinite] h-full"></div>
|
|
|
|
|
<div className="w-1 bg-kodo-cyan animate-[bounce_1.2s_infinite] h-2/3"></div>
|
|
|
|
|
<div className="w-1 bg-kodo-cyan animate-[bounce_0.8s_infinite] h-full"></div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<h2 className="text-xl font-bold text-white">{currentTrack.title}</h2>
|
|
|
|
|
<p className="text-kodo-cyan">{currentTrack.artist}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-gray-500 font-mono text-sm hidden md:block">
|
|
|
|
|
{currentTrack.duration}
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-07 09:31:02 +00:00
|
|
|
|
2026-01-07 10:15:48 +00:00
|
|
|
{/* Up Next */}
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex justify-between items-center mb-3">
|
|
|
|
|
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-widest">Up Next</h3>
|
|
|
|
|
<div
|
|
|
|
|
className="flex items-center gap-2 cursor-pointer group"
|
|
|
|
|
onClick={toggleAutoplay}
|
|
|
|
|
>
|
|
|
|
|
<span className={`text-xs font-bold ${autoplay ? 'text-kodo-lime' : 'text-gray-500'}`}>Autoplay</span>
|
|
|
|
|
<div className={`w-8 h-4 rounded-full relative transition-colors ${autoplay ? 'bg-kodo-lime' : 'bg-gray-700'}`}>
|
|
|
|
|
<div className={`absolute top-0.5 w-3 h-3 bg-white rounded-full transition-all ${autoplay ? 'left-4.5' : 'left-0.5'}`}></div>
|
|
|
|
|
</div>
|
2026-01-07 09:31:02 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-01-07 10:15:48 +00:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
{queue.length === 0 ? (
|
|
|
|
|
<div className="text-center py-12 border-2 border-dashed border-kodo-steel rounded-xl text-gray-500">
|
|
|
|
|
<ListMusic className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
|
|
|
|
<p className="text-sm">Queue is empty. Add tracks to keep the vibe going.</p>
|
|
|
|
|
{autoplay && <p className="text-xs text-kodo-lime mt-2">Autoplay is on. We'll pick a song for you.</p>}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
queue.map((track, i) => (
|
|
|
|
|
<div
|
|
|
|
|
key={`${track.id}-${i}`}
|
|
|
|
|
draggable
|
|
|
|
|
onDragStart={(e) => handleDragStart(e, i)}
|
|
|
|
|
onDragOver={(e) => handleDragOver(e, i)}
|
|
|
|
|
onDragEnd={() => setDraggedIndex(null)}
|
|
|
|
|
className={`flex items-center gap-4 p-3 bg-kodo-ink rounded-lg border border-transparent hover:border-kodo-steel transition-all group ${draggedIndex === i ? 'opacity-50 border-kodo-cyan' : ''}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="text-gray-600 cursor-grab active:cursor-grabbing hover:text-white p-1">
|
|
|
|
|
<GripVertical className="w-5 h-5" />
|
2026-01-07 09:31:02 +00:00
|
|
|
</div>
|
2026-01-07 10:15:48 +00:00
|
|
|
<div className="w-10 h-10 rounded overflow-hidden flex-shrink-0 relative">
|
|
|
|
|
<img src={track.coverUrl} className="w-full h-full object-cover" />
|
|
|
|
|
<div className="absolute inset-0 bg-black/50 hidden group-hover:flex items-center justify-center cursor-pointer" onClick={() => playTrack(track)}>
|
|
|
|
|
<Play className="w-4 h-4 text-white fill-current" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="text-sm font-bold text-white truncate">{track.title}</div>
|
|
|
|
|
<div className="text-xs text-gray-400 truncate">{track.artist}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-gray-500 font-mono text-xs hidden sm:block">
|
|
|
|
|
{track.duration}
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
className="p-2 text-gray-500 hover:text-kodo-red opacity-0 group-hover:opacity-100 transition-opacity"
|
|
|
|
|
onClick={() => removeFromQueue(track.id)}
|
|
|
|
|
>
|
|
|
|
|
<X className="w-4 h-4" />
|
|
|
|
|
</button>
|
2026-01-07 09:31:02 +00:00
|
|
|
</div>
|
2026-01-07 10:15:48 +00:00
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-01-07 09:31:02 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-01-07 10:15:48 +00:00
|
|
|
{showSaveModal && (
|
|
|
|
|
<SaveQueueAsPlaylistModal
|
|
|
|
|
onClose={() => setShowSaveModal(false)}
|
|
|
|
|
onSave={handleSavePlaylist}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2026-01-07 09:31:02 +00:00
|
|
|
};
|