veza/apps/web/src/components/layout/AudioPlayer.tsx
senke 39b2b642d2 feat(web): UI premium Discord/Spotify-like — tokens, shadows, focus, layout
Plan UI premium 6–8 semaines (design system, shell, Storybook, a11y):

- Design system: DESIGN_TOKENS.md, APP_SHELL.md, FULL_LAYOUT_PAGE.md. Single source
  for layout/shell (index.css), shadows (design-system.css), durations/easing.
- Tokens: shadow-cover-depth, shadow-gold-glow, shadow-fab-glow; layout max-height
  (max-h-layout-drawer, max-h-layout-panel, max-h-layout-list). All duration-200/300/500
  replaced by --duration-fast/normal/slow. Arbitrary shadows replaced by token classes.
- Shell & player: Sidebar, Header, GlobalPlayer, MiniPlayer, PlayerQueue, PlayerControls,
  AudioPlayer use tokens; focus-visible on Sidebar, PlayerQueue, DropdownMenuTrigger/Item,
  TabsTrigger. Typography: text-[10px]/[9px] → text-xs where applicable.
- ESLint: no-restricted-syntax (warn) for w-/h-/rounded-/shadow-/text-/spacing arbitrary.
- Scripts: report-arbitrary-values.mjs, capture/compare/generate visual; visual-complete.spec.ts.
- Stories full layout: Dashboard, Playlists, Library, Settings, Profile in DashboardLayout.stories.
- .cursorrules + README: DESIGN_TOKENS, APP_SHELL, visual commands, no arbitrary without justification.
- apps/web/.gitignore: e2e test artifacts (test-results-visual, playwright-report-visual).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 17:15:58 +01:00

264 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 '../../components/feedback/ToastProvider';
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-96 bg-card/95 backdrop-blur-xl border border-border/50 rounded-xl shadow-2xl z-40 overflow-hidden animate-slideUp max-h-layout-panel flex flex-col ring-1 ring-white/10">
<div className="flex items-center justify-between p-4 border-b border-border bg-muted/80">
<h3 className="font-bold text-foreground text-sm tracking-wide flex items-center gap-2">
<ListMusic className="w-4 h-4 text-kodo-steel" /> PLAY QUEUE
</h3>
<X
className="w-5 h-5 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => setShowQueue(false)}
/>
</div>
<div className="flex-1 flex flex-col min-h-0">
<div className="flex border-b border-border bg-muted/30">
<button
className={`flex-1 py-4 text-xs font-bold uppercase tracking-wider transition-colors ${queueTab === 'up-next' ? 'text-primary border-b-2 border-primary bg-muted/50' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => setQueueTab('up-next')}
>
Up Next ({queue.length})
</button>
<button
className={`flex-1 py-4 text-xs font-bold uppercase tracking-wider transition-colors ${queueTab === 'history' ? 'text-primary border-b-2 border-primary bg-muted/50' : 'text-muted-foreground hover:text-foreground'}`}
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-primary/5 border-b border-border/30">
<div className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-2">
Now Playing
</div>
<div className="flex items-center gap-4 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-muted-foreground truncate">
{currentTrack.artist}
</div>
</div>
<button
className="p-2 hover:bg-white/10 rounded-full text-muted-foreground 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-muted-foreground 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-4 p-2 rounded-lg group transition-colors border border-transparent ${draggedItemIndex === i ? 'bg-primary/10 border-primary/50' : 'hover:bg-white/5 hover:border-white/5'}`}
>
<div className="cursor-grab active:cursor-grabbing text-muted-foreground 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-foreground group-hover:text-foreground truncate">
{track.title}
</div>
<div className="text-xs text-muted-foreground 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-white"
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-destructive"
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-muted-foreground 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-4 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-foreground group-hover:text-foreground truncate">
{track.title}
</div>
<div className="text-xs text-muted-foreground truncate">
{track.artist}
</div>
</div>
<button
className="p-1.5 text-muted-foreground hover:text-white 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-4 border-t border-border bg-muted">
<Button
variant="ghost"
size="sm"
className="w-full text-xs text-muted-foreground hover:text-destructive"
onClick={() => {
clearQueue();
addToast('Queue Cleared');
}}
>
Clear Queue
</Button>
</div>
)}
</div>
</div>
)}
{/* MINI PLAYER BAR */}
<MiniPlayer
onExpand={() => setIsImmersive(true)}
onToggleQueue={() => setShowQueue(!showQueue)}
isQueueOpen={showQueue}
/>
</>
);
};