veza/apps/web/src/components/library/playlists/QueueView.tsx

228 lines
8.2 KiB
TypeScript
Raw Normal View History

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 '../../../components/feedback/ToastProvider';
export const QueueView: React.FC = () => {
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);
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);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
reorderQueue(draggedIndex, index);
setDraggedIndex(index);
};
const handleSavePlaylist = (name: string, _isPublic: boolean) => {
addToast(`Queue saved as "${name}"`, 'success');
// Logic to actually save would connect to backend/context here
};
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-border/50 pb-6 gap-4">
<div>
<h1 className="text-3xl font-display font-bold text-foreground mb-2">
PLAY QUEUE
</h1>
<p className="text-muted-foreground font-mono text-sm">
{queue.length} tracks upcoming
</p>
</div>
<div className="flex gap-4">
<Button
variant="ghost"
onClick={() => setShowSaveModal(true)}
icon={<Save className="w-4 h-4" />}
>
Save Queue
</Button>
<Button
variant="ghost"
className="text-destructive hover:bg-destructive/10"
onClick={clearQueue}
icon={<Trash2 className="w-4 h-4" />}
>
Clear
</Button>
</div>
</div>
{/* Current Track */}
{currentTrack && (
<div>
<h3 className="text-xs font-bold text-muted-foreground uppercase tracking-widest mb-3">
Now Playing
</h3>
<Card
variant="glass"
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-background/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{isPlaying ? (
<Pause className="w-6 h-6 text-foreground" />
) : (
<Play className="w-6 h-6 text-foreground fill-current ml-1" />
)}
</div>
{isPlaying && (
<div className="absolute bottom-1 right-1 flex gap-0.5 items-end h-3">
<div className="w-1 bg-primary animate-[bounce_1s_infinite] h-full"></div>
<div className="w-1 bg-primary animate-[bounce_1.2s_infinite] h-2/3"></div>
<div className="w-1 bg-primary animate-[bounce_0.8s_infinite] h-full"></div>
</div>
)}
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-foreground">
{currentTrack.title}
</h2>
<p className="text-muted-foreground">{currentTrack.artist}</p>
</div>
<div className="text-muted-foreground font-mono text-sm hidden md:block">
{currentTrack.duration}
</div>
</Card>
</div>
)}
{/* Up Next */}
<div>
<div className="flex justify-between items-center mb-3">
<h3 className="text-xs font-bold text-muted-foreground 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-success' : 'text-muted-foreground'}`}
>
Autoplay
</span>
<div
className={`w-8 h-4 rounded-full relative transition-colors ${autoplay ? 'bg-success' : 'bg-muted'}`}
>
<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>
</div>
</div>
<div className="space-y-2">
{queue.length === 0 ? (
<div className="text-center py-12 border-2 border-dashed border-border rounded-xl text-muted-foreground">
<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-success 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-4 bg-card rounded-lg border border-transparent hover:border-border transition-all group ${draggedIndex === i ? 'opacity-50 border-primary' : ''}`}
>
<div className="text-muted-foreground cursor-grab active:cursor-grabbing hover:text-foreground p-1">
<GripVertical className="w-5 h-5" />
</div>
<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-background/50 hidden group-hover:flex items-center justify-center cursor-pointer"
onClick={() => playTrack(track)}
>
<Play className="w-4 h-4 text-foreground fill-current" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-bold text-foreground truncate">
{track.title}
</div>
<div className="text-xs text-muted-foreground truncate">
{track.artist}
</div>
</div>
<div className="text-muted-foreground font-mono text-xs hidden sm:block">
{track.duration}
</div>
<button
className="p-2 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => removeFromQueue(track.id)}
>
<X className="w-4 h-4" />
</button>
</div>
))
)}
</div>
</div>
{showSaveModal && (
<SaveQueueAsPlaylistModal
onClose={() => setShowSaveModal(false)}
onSave={handleSavePlaylist}
/>
)}
</div>
);
};