189 lines
11 KiB
TypeScript
189 lines
11 KiB
TypeScript
|
|
|
||
|
|
import React, { useState } from 'react';
|
||
|
|
import { useAudio } from '../../context/AudioContext';
|
||
|
|
import { MiniPlayer } from '../player/MiniPlayer';
|
||
|
|
import { FullPlayer } from '../player/FullPlayer';
|
||
|
|
import { X, ListMusic, Play, GripVertical, Trash2, ArrowUpToLine, ListPlus, Clock, Heart } from 'lucide-react';
|
||
|
|
import { useToast } from '../../context/ToastContext';
|
||
|
|
import { Button } from '../ui/button';
|
||
|
|
|
||
|
|
export const AudioPlayer: React.FC = () => {
|
||
|
|
const { currentTrack, queue, history, reorderQueue, playTrack, playNext, removeFromQueue, addToQueue, clearQueue } = useAudio();
|
||
|
|
const { addToast } = useToast();
|
||
|
|
const [isImmersive, setIsImmersive] = useState(false);
|
||
|
|
const [showQueue, setShowQueue] = useState(false);
|
||
|
|
const [queueTab, setQueueTab] = useState<'up-next' | 'history'>('up-next');
|
||
|
|
const [draggedItemIndex, setDraggedItemIndex] = useState<number | null>(null);
|
||
|
|
|
||
|
|
if (!currentTrack) return null;
|
||
|
|
|
||
|
|
// Queue Drag Handlers
|
||
|
|
const onDragStart = (e: React.DragEvent, index: number) => {
|
||
|
|
setDraggedItemIndex(index);
|
||
|
|
e.dataTransfer.effectAllowed = "move";
|
||
|
|
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 onDragOver = (e: React.DragEvent, index: number) => {
|
||
|
|
e.preventDefault();
|
||
|
|
if (draggedItemIndex === null || draggedItemIndex === index) return;
|
||
|
|
reorderQueue(draggedItemIndex, index);
|
||
|
|
setDraggedItemIndex(index);
|
||
|
|
};
|
||
|
|
|
||
|
|
const onDragEnd = () => setDraggedItemIndex(null);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
{/* IMMERSIVE PLAYER OVERLAY */}
|
||
|
|
{isImmersive && <FullPlayer onClose={() => setIsImmersive(false)} />}
|
||
|
|
|
||
|
|
{/* QUEUE DRAWER */}
|
||
|
|
{showQueue && !isImmersive && (
|
||
|
|
<div className="fixed bottom-24 right-4 w-full md:w-[400px] bg-kodo-graphite/95 backdrop-blur-xl border border-kodo-steel/50 rounded-2xl shadow-2xl z-40 overflow-hidden animate-slideUp max-h-[70vh] flex flex-col ring-1 ring-white/10">
|
||
|
|
<div className="flex items-center justify-between p-4 border-b border-kodo-steel bg-kodo-ink/80">
|
||
|
|
<h3 className="font-bold text-white text-sm tracking-wide flex items-center gap-2">
|
||
|
|
<ListMusic className="w-4 h-4 text-kodo-cyan" /> PLAY QUEUE
|
||
|
|
</h3>
|
||
|
|
<X className="w-5 h-5 text-gray-400 cursor-pointer hover:text-white" onClick={() => setShowQueue(false)} />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex-1 flex flex-col min-h-0">
|
||
|
|
<div className="flex border-b border-kodo-steel bg-kodo-slate/30">
|
||
|
|
<button
|
||
|
|
className={`flex-1 py-3 text-xs font-bold uppercase tracking-wider transition-colors ${queueTab === 'up-next' ? 'text-kodo-cyan border-b-2 border-kodo-cyan bg-white/5' : 'text-gray-500 hover:text-white'}`}
|
||
|
|
onClick={() => setQueueTab('up-next')}
|
||
|
|
>
|
||
|
|
Up Next ({queue.length})
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
className={`flex-1 py-3 text-xs font-bold uppercase tracking-wider transition-colors ${queueTab === 'history' ? 'text-kodo-magenta border-b-2 border-kodo-magenta bg-white/5' : 'text-gray-500 hover:text-white'}`}
|
||
|
|
onClick={() => setQueueTab('history')}
|
||
|
|
>
|
||
|
|
History
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-0">
|
||
|
|
{queueTab === 'up-next' && (
|
||
|
|
<>
|
||
|
|
<div className="p-4 bg-gradient-to-b from-kodo-cyan/5 to-transparent border-b border-kodo-steel/30">
|
||
|
|
<div className="text-[10px] font-bold text-kodo-cyan uppercase tracking-wider mb-2">Now Playing</div>
|
||
|
|
<div className="flex items-center gap-3 group relative">
|
||
|
|
<img
|
||
|
|
src={currentTrack.coverUrl}
|
||
|
|
alt={`Cover art for ${currentTrack.title} by ${currentTrack.artist}`}
|
||
|
|
className="w-12 h-12 rounded shadow-lg"
|
||
|
|
/>
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
<div className="text-sm font-bold text-white truncate">{currentTrack.title}</div>
|
||
|
|
<div className="text-xs text-gray-400 truncate">{currentTrack.artist}</div>
|
||
|
|
</div>
|
||
|
|
<button className="p-2 hover:bg-white/10 rounded-full text-gray-400 hover:text-kodo-magenta" onClick={() => addToast("Saved to Library", "success")}><Heart className="w-4 h-4" /></button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="p-2 space-y-1">
|
||
|
|
{queue.length === 0 && (
|
||
|
|
<div className="text-center text-gray-500 py-12 flex flex-col items-center">
|
||
|
|
<ListMusic className="w-8 h-8 mb-2 opacity-50" />
|
||
|
|
<p className="text-sm italic">Queue is empty</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{queue.map((track, i) => (
|
||
|
|
<div
|
||
|
|
key={track.id}
|
||
|
|
draggable
|
||
|
|
onDragStart={(e) => onDragStart(e, i)}
|
||
|
|
onDragOver={(e) => onDragOver(e, i)}
|
||
|
|
onDragEnd={onDragEnd}
|
||
|
|
className={`flex items-center gap-3 p-2 rounded-lg group transition-colors border border-transparent ${draggedItemIndex === i ? 'bg-kodo-cyan/10 border-kodo-cyan/50' : 'hover:bg-white/5 hover:border-white/5'}`}
|
||
|
|
>
|
||
|
|
<div className="cursor-grab active:cursor-grabbing text-gray-600 hover:text-white p-1">
|
||
|
|
<GripVertical className="w-4 h-4" />
|
||
|
|
</div>
|
||
|
|
<div className="relative w-8 h-8 rounded overflow-hidden flex-shrink-0">
|
||
|
|
<img
|
||
|
|
src={track.coverUrl}
|
||
|
|
alt={`Cover art for ${track.title} by ${track.artist}`}
|
||
|
|
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-3 h-3 text-white fill-current" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex-1 min-w-0 select-none">
|
||
|
|
<div className="text-sm font-medium text-gray-300 group-hover:text-white truncate">{track.title}</div>
|
||
|
|
<div className="text-xs text-gray-500 truncate">{track.artist}</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity gap-1">
|
||
|
|
<button className="p-1.5 hover:text-kodo-cyan" title="Play Next" onClick={(e) => { e.stopPropagation(); playNext(track); removeFromQueue(track.id); }}>
|
||
|
|
<ArrowUpToLine className="w-3.5 h-3.5" />
|
||
|
|
</button>
|
||
|
|
<button className="p-1.5 hover:text-red-500" title="Remove" onClick={(e) => { e.stopPropagation(); removeFromQueue(track.id); }}>
|
||
|
|
<Trash2 className="w-3.5 h-3.5" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{queueTab === 'history' && (
|
||
|
|
<div className="p-2 space-y-1">
|
||
|
|
{history.length === 0 && (
|
||
|
|
<div className="text-center text-gray-500 py-12 flex flex-col items-center">
|
||
|
|
<Clock className="w-8 h-8 mb-2 opacity-50" />
|
||
|
|
<p className="text-sm italic">No history yet</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{[...history].reverse().map((track, i) => (
|
||
|
|
<div key={`${track.id}-${i}`} className="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 group opacity-70 hover:opacity-100 transition-opacity">
|
||
|
|
<div className="w-8 h-8 rounded overflow-hidden flex-shrink-0 grayscale group-hover:grayscale-0 transition-all">
|
||
|
|
<img
|
||
|
|
src={track.coverUrl}
|
||
|
|
alt={`Cover art for ${track.title} by ${track.artist}`}
|
||
|
|
className="w-full h-full object-cover"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
<div className="text-sm font-medium text-gray-300 group-hover:text-white truncate">{track.title}</div>
|
||
|
|
<div className="text-xs text-gray-500 truncate">{track.artist}</div>
|
||
|
|
</div>
|
||
|
|
<button className="p-1.5 text-gray-400 hover:text-kodo-cyan opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => { addToQueue(track); addToast("Added back to Queue"); }}>
|
||
|
|
<ListPlus className="w-4 h-4" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{queueTab === 'up-next' && queue.length > 0 && (
|
||
|
|
<div className="p-3 border-t border-kodo-steel bg-kodo-ink">
|
||
|
|
<Button variant="ghost" size="sm" className="w-full text-xs text-gray-400 hover:text-red-400" onClick={() => { clearQueue(); addToast("Queue Cleared"); }}>
|
||
|
|
Clear Queue
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* MINI PLAYER BAR */}
|
||
|
|
<MiniPlayer
|
||
|
|
onExpand={() => setIsImmersive(true)}
|
||
|
|
onToggleQueue={() => setShowQueue(!showQueue)}
|
||
|
|
isQueueOpen={showQueue}
|
||
|
|
/>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
};
|