improving UI: improve audio player phase 1
This commit is contained in:
parent
f61c0c3dcc
commit
62a6b3a528
5 changed files with 602 additions and 119 deletions
|
|
@ -243,23 +243,34 @@ export const LazyDashboard = createLazyComponent(
|
||||||
'Dashboard',
|
'Dashboard',
|
||||||
);
|
);
|
||||||
export const LazyChat = createLazyComponent(
|
export const LazyChat = createLazyComponent(
|
||||||
() => import('@/features/chat/pages/ChatPage'),
|
() =>
|
||||||
|
import('@/features/chat/pages/ChatPage').then((m) => ({
|
||||||
|
default: m.ChatPage,
|
||||||
|
})),
|
||||||
undefined,
|
undefined,
|
||||||
'Chat',
|
'Chat',
|
||||||
);
|
);
|
||||||
// CRITIQUE FIX #16: Tous les composants lazy utilisent maintenant la gestion d'erreur standardisée
|
|
||||||
export const LazyLibrary = createLazyComponent(
|
export const LazyLibrary = createLazyComponent(
|
||||||
() => import('@/features/library/pages/LibraryPage'),
|
() =>
|
||||||
|
import('@/features/library/pages/LibraryPage').then((m) => ({
|
||||||
|
default: m.LibraryPage,
|
||||||
|
})),
|
||||||
undefined,
|
undefined,
|
||||||
'Library',
|
'Library',
|
||||||
);
|
);
|
||||||
export const LazyProfile = createLazyComponent(
|
export const LazyProfile = createLazyComponent(
|
||||||
() => import('@/features/profile/pages/UserProfilePage'),
|
() =>
|
||||||
|
import('@/features/profile/pages/UserProfilePage').then((m) => ({
|
||||||
|
default: m.UserProfilePage,
|
||||||
|
})),
|
||||||
undefined,
|
undefined,
|
||||||
'Profile',
|
'Profile',
|
||||||
);
|
);
|
||||||
export const LazySettings = createLazyComponent(
|
export const LazySettings = createLazyComponent(
|
||||||
() => import('@/features/settings/pages/SettingsPage'),
|
() =>
|
||||||
|
import('@/features/settings/pages/SettingsPage').then((m) => ({
|
||||||
|
default: m.SettingsPage,
|
||||||
|
})),
|
||||||
undefined,
|
undefined,
|
||||||
'Settings',
|
'Settings',
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,31 @@
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
import React, { useState, useEffect } from 'react';
|
import { usePlayer } from '@/features/player/hooks/usePlayer';
|
||||||
import { useAudio } from '@/context/AudioContext';
|
import { useKeyboardShortcuts } from '@/features/player/hooks/useKeyboardShortcuts';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import {
|
import {
|
||||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX,
|
Heart, Maximize2, ListMusic, Volume2, VolumeX
|
||||||
Repeat, Shuffle, Heart,
|
|
||||||
Maximize2
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { PlayerControls } from './PlayerControls';
|
||||||
|
import { PlayerQueue } from './PlayerQueue';
|
||||||
|
import { PlayerExpanded } from './PlayerExpanded';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
// Waveform Visualization (Randomized for now, as in desy example)
|
// Enhanced Waveform Visualization
|
||||||
const Waveform = ({ playing }: { playing: boolean }) => {
|
const Waveform = ({ playing }: { playing: boolean }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-0.5 h-8 opacity-50">
|
<div className="flex items-center gap-[3px] h-8 opacity-60">
|
||||||
{Array.from({ length: 40 }).map((_, i) => (
|
{Array.from({ length: 24 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-[2px] bg-primary rounded-full transition-all duration-300",
|
"w-[3px] bg-gradient-to-t from-cyan-500 to-magenta-500 rounded-full transition-all duration-300",
|
||||||
playing ? "animate-[eq-bounce_0.5s_ease-in-out_infinite]" : "h-1"
|
playing ? "animate-[eq-bounce_0.5s_ease-in-out_infinite]" : "h-1"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
height: playing ? `${20 + Math.random() * 80}%` : '20%',
|
height: playing ? `${20 + Math.random() * 80}%` : '15%',
|
||||||
animationDelay: `${Math.random() * 0.5}s`
|
animationDelay: `${Math.random() * 0.5}s`,
|
||||||
|
animationDuration: `${0.4 + Math.random() * 0.4}s`
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -32,135 +34,226 @@ const Waveform = ({ playing }: { playing: boolean }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GlobalPlayer() {
|
export function GlobalPlayer() {
|
||||||
const {
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
currentTrack, isPlaying, togglePlay, nextTrack, prevTrack,
|
|
||||||
volume, setVolume, progress, duration, seek,
|
// Use the REAL player hook logic which connects to Zustand store
|
||||||
} = useAudio();
|
const player = usePlayer(audioRef);
|
||||||
|
|
||||||
|
// Enable keyboard shortcuts
|
||||||
|
useKeyboardShortcuts(player);
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [showQueue, setShowQueue] = useState(false);
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (seconds: number) => {
|
||||||
if (!seconds) return '0:00';
|
if (!seconds && seconds !== 0) return '0:00';
|
||||||
const m = Math.floor(seconds / 60);
|
const m = Math.floor(seconds / 60);
|
||||||
const s = Math.floor(seconds % 60);
|
const s = Math.floor(seconds % 60);
|
||||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!currentTrack) return null;
|
const currentTrack = player.currentTrack;
|
||||||
|
|
||||||
|
// Placeholder track for idle state
|
||||||
|
const idleTrack = {
|
||||||
|
id: 'idle',
|
||||||
|
title: 'System Online',
|
||||||
|
artist: 'Select a track to play',
|
||||||
|
cover: '',
|
||||||
|
duration: 0,
|
||||||
|
url: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayTrack = currentTrack || idleTrack;
|
||||||
|
const isIdle = !currentTrack;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
onEnded={() => {
|
||||||
|
// Hook handles onEnded via audioService listeners
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Expanded View Modal - Guarded against null track */}
|
||||||
|
<PlayerExpanded
|
||||||
|
isOpen={isExpanded}
|
||||||
|
onClose={() => setIsExpanded(false)}
|
||||||
|
currentTime={player.currentTime}
|
||||||
|
duration={player.duration}
|
||||||
|
onSeek={player.seek}
|
||||||
|
player={player}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Queue Drawer */}
|
||||||
|
<PlayerQueue
|
||||||
|
isOpen={showQueue}
|
||||||
|
onClose={() => setShowQueue(false)}
|
||||||
|
currentTrackId={currentTrack?.id}
|
||||||
|
onPlay={(track) => player.play(track)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Floating Player */}
|
||||||
<div
|
<div
|
||||||
className="fixed bottom-6 left-4 right-4 md:left-1/2 md:-translate-x-1/2 md:w-full md:max-w-4xl z-[100] transition-all duration-500 ease-out"
|
className={cn(
|
||||||
|
"fixed bottom-6 left-4 right-4 md:left-1/2 md:-translate-x-1/2 md:w-full md:max-w-5xl z-[100] transition-all duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]",
|
||||||
|
isExpanded ? "translate-y-[200%] opacity-0" : "translate-y-0 opacity-100"
|
||||||
|
)}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
{/* Main Glass Island */}
|
{/* Glass Container */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"glass rounded-2xl overflow-hidden shadow-[0_8px_32px_rgba(0,0,0,0.5)] border border-white/5 transition-all duration-300",
|
"relative rounded-2xl overflow-hidden backdrop-blur-2xl transition-all duration-300 group shadow-2xl",
|
||||||
isHovered && "shadow-[0_0_40px_rgba(0,240,255,0.15)] border-primary/20"
|
"bg-black/80 border border-white/10", // Darker more premium base
|
||||||
|
isHovered ? "shadow-[0_0_50px_rgba(var(--cyan-500),0.15)] border-primary/30" : "box-shadow-[0_8px_32px_rgba(0,0,0,0.5)]"
|
||||||
)}>
|
)}>
|
||||||
|
|
||||||
{/* Top Progress Line (if not hovered, or integrated?)
|
{/* Progress Bar (Top Edge) */}
|
||||||
Let's keep the slider inside for better UX as per strict desy player
|
<div className={cn("absolute top-0 left-0 right-0 h-[2px] bg-white/5 z-20 transition-all", !isIdle && "group-hover:h-[4px] cursor-pointer")}
|
||||||
*/}
|
onClick={(e) => {
|
||||||
|
if (isIdle) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const pct = x / rect.width;
|
||||||
|
player.seek(pct * player.duration);
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
className={cn("h-full bg-gradient-to-r from-cyan-500 via-blue-500 to-magenta-500 relative", isIdle && "w-0 opacity-0")}
|
||||||
|
style={{ width: !isIdle ? `${(player.currentTime / (player.duration || 1)) * 100}%` : '0%' }}
|
||||||
|
>
|
||||||
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-[0_0_10px_white] opacity-0 group-hover:opacity-100 transition-opacity scale-0 group-hover:scale-100" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center h-24 px-6 relative z-10 bg-black/40 backdrop-blur-xl">
|
<div className="flex items-center justify-between h-20 md:h-24 px-4 md:px-6 relative z-10">
|
||||||
|
|
||||||
{/* LEFT: Track Info & Mini Waveform */}
|
{/* LEFT: Track Info */}
|
||||||
<div className="flex items-center gap-4 min-w-0 justify-start">
|
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||||
{/* Art with Glow */}
|
<div
|
||||||
<div className={cn(
|
className={cn("relative w-12 h-12 md:w-14 md:h-14 rounded-lg overflow-hidden group/art", !isIdle && "cursor-pointer")}
|
||||||
"relative w-14 h-14 rounded-lg overflow-hidden shadow-lg transition-all duration-500",
|
onClick={() => !isIdle && setIsExpanded(true)}
|
||||||
isPlaying ? "shadow-[0_0_20px_rgba(var(--cyan-500),0.4)]" : "shadow-none"
|
>
|
||||||
)}>
|
{displayTrack.cover ? (
|
||||||
<img
|
<img
|
||||||
src={currentTrack.coverUrl || '/placeholder.svg'}
|
src={displayTrack.cover}
|
||||||
alt={currentTrack.title}
|
alt={displayTrack.title}
|
||||||
className={cn("w-full h-full object-cover transition-transform duration-700", isPlaying && "scale-110")}
|
className={cn("w-full h-full object-cover transition-transform duration-700", player.isPlaying && "scale-110")}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
|
) : (
|
||||||
|
<div className="w-full h-full bg-white/5 flex items-center justify-center">
|
||||||
|
<Maximize2 className={cn("w-6 h-6 text-muted-foreground", isIdle && "opacity-20")} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isIdle && (
|
||||||
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover/art:opacity-100 transition-opacity">
|
||||||
|
<Maximize2 className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col justify-center min-w-0">
|
<div className="flex flex-col justify-center min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-display font-bold text-base text-white truncate tracking-wide hover:text-primary transition-colors cursor-pointer">
|
<h3 className={cn("font-display font-bold text-sm md:text-base text-white truncate transition-colors", !isIdle && "hover:text-primary cursor-pointer")}
|
||||||
{currentTrack.title}
|
onClick={() => !isIdle && setIsExpanded(true)}>
|
||||||
|
{displayTrack.title}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-mono bg-primary/20 text-primary border border-primary/20">WAV</span>
|
{/* Quality Badge - Show only if not idle */}
|
||||||
|
{!isIdle && (
|
||||||
|
<span className="hidden md:inline-flex px-1.5 py-0.5 rounded text-[9px] font-mono bg-white/5 text-muted-foreground border border-white/10 uppercase tracking-wider">
|
||||||
|
HQ
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground font-medium truncate hover:text-white transition-colors cursor-pointer">
|
<p className={cn("text-xs text-muted-foreground truncate transition-colors", !isIdle && "hover:text-white cursor-pointer")}
|
||||||
{typeof currentTrack.artist === 'string' ? currentTrack.artist : 'Unknown Artist'}
|
onClick={() => !isIdle && setIsExpanded(true)}>
|
||||||
|
{displayTrack.artist || 'Unknown Artist'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CENTER: Controls */}
|
{/* CENTER: Controls */}
|
||||||
<div className="flex flex-col items-center justify-center gap-2 absolute left-1/2 -translate-x-1/2 w-1/3">
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center justify-center gap-1 min-w-[300px]">
|
||||||
<div className="flex items-center gap-6">
|
<PlayerControls
|
||||||
<button className="text-muted-foreground hover:text-white transition-colors p-2 hover:bg-white/5 rounded-full" title="Shuffle">
|
isPlaying={player.isPlaying}
|
||||||
<Shuffle className="w-4 h-4" />
|
onPlayPause={() => {
|
||||||
</button>
|
if (player.isPlaying) player.pause();
|
||||||
|
else if (!isIdle) player.resume();
|
||||||
<button onClick={prevTrack} className="text-white hover:text-primary transition-colors p-2 active:scale-95 hover:bg-white/5 rounded-full">
|
}}
|
||||||
<SkipBack className="w-5 h-5 fill-current" />
|
onNext={player.next}
|
||||||
</button>
|
onPrevious={player.previous}
|
||||||
|
onShuffle={player.toggleShuffle}
|
||||||
<button
|
onRepeat={() => {
|
||||||
onClick={togglePlay}
|
const modes = ['off', 'track', 'playlist'] as const; // Should cycle
|
||||||
className="w-12 h-12 rounded-full bg-primary text-black flex items-center justify-center hover:scale-105 active:scale-95 transition-all shadow-[0_0_20px_rgba(var(--cyan-500),0.5)] hover:shadow-[0_0_30px_rgba(var(--cyan-500),0.8)]"
|
const next = modes[(modes.indexOf(player.repeat) + 1) % modes.length];
|
||||||
>
|
player.setRepeat(next);
|
||||||
{isPlaying ? <Pause className="w-6 h-6 fill-current" /> : <Play className="w-6 h-6 fill-current ml-1" />}
|
}}
|
||||||
</button>
|
shuffle={player.shuffle}
|
||||||
|
repeat={player.repeat}
|
||||||
<button onClick={nextTrack} className="text-white hover:text-primary transition-colors p-2 active:scale-95 hover:bg-white/5 rounded-full">
|
|
||||||
<SkipForward className="w-5 h-5 fill-current" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button className="text-muted-foreground hover:text-white transition-colors p-2 hover:bg-white/5 rounded-full" title="Repeat">
|
|
||||||
<Repeat className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Slider */}
|
|
||||||
<div className="w-full flex items-center gap-3 px-2 group/slider">
|
|
||||||
<span className="text-[10px] font-mono text-muted-foreground w-8 text-right tabular-nums">{formatTime((progress / 100) * (typeof duration === 'number' ? duration : 0))}</span>
|
|
||||||
<Slider
|
|
||||||
value={[progress]}
|
|
||||||
onValueChange={(val) => seek(val[0])}
|
|
||||||
max={100}
|
|
||||||
step={0.1}
|
|
||||||
className="h-4 py-1.5"
|
|
||||||
/>
|
/>
|
||||||
<span className="text-[10px] font-mono text-muted-foreground w-8 tabular-nums">{formatTime(typeof duration === 'number' ? duration : 0)}</span>
|
<div className={cn("hidden md:flex items-center gap-2 text-[10px] font-mono text-muted-foreground w-full justify-center transition-opacity", !isIdle ? "opacity-0 group-hover:opacity-100" : "opacity-0")}>
|
||||||
|
<span>{formatTime(player.currentTime)}</span>
|
||||||
|
<span className="opacity-30">/</span>
|
||||||
|
<span>{formatTime(player.duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT: Volume & Extras */}
|
{/* RIGHT: Actions & Volume */}
|
||||||
<div className="flex items-center justify-end gap-3">
|
<div className="flex items-center justify-end gap-3 md:gap-4 flex-1">
|
||||||
{/* Visualization (Hidden on small screens) */}
|
{/* Waveform Visualization (Desktop) */}
|
||||||
<div className="hidden lg:block w-24 mr-4">
|
<div className="hidden lg:block w-24 mr-2">
|
||||||
<Waveform playing={isPlaying} />
|
<Waveform playing={player.isPlaying} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 bg-black/20 p-1.5 rounded-lg border border-white/5">
|
{/* Volume */}
|
||||||
<button onClick={() => setVolume(volume === 0 ? 50 : 0)} className="p-1.5 text-muted-foreground hover:text-white transition-colors">
|
<div className="hidden md:flex items-center gap-2 group/volume">
|
||||||
{volume === 0 ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
|
<Button
|
||||||
</button>
|
variant="ghost"
|
||||||
<div className="w-20">
|
size="icon"
|
||||||
<Slider value={[volume]} onValueChange={(val) => setVolume(val[0])} max={100} step={1} />
|
className="w-8 h-8 text-muted-foreground hover:text-white"
|
||||||
|
onClick={player.toggleMute}
|
||||||
|
>
|
||||||
|
{player.muted || player.volume === 0 ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
<div className="w-0 group-hover/volume:w-24 overflow-hidden transition-all duration-300">
|
||||||
|
<Slider
|
||||||
|
value={[player.muted ? 0 : player.volume]}
|
||||||
|
onValueChange={(val) => player.setVolume(val[0])}
|
||||||
|
max={100}
|
||||||
|
className="w-20"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="p-2 text-muted-foreground hover:text-magenta-500 hover:bg-white/5 rounded-lg transition-colors">
|
<div className="w-px h-8 bg-white/10 hidden md:block" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn("w-9 h-9 rounded-full", showQueue ? "text-primary bg-primary/10" : "text-muted-foreground hover:text-white")}
|
||||||
|
onClick={() => setShowQueue(!showQueue)}
|
||||||
|
>
|
||||||
|
<ListMusic className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="w-9 h-9 rounded-full text-muted-foreground hover:text-magenta-500 hover:bg-magenta-500/10"
|
||||||
|
>
|
||||||
<Heart className="w-4 h-4" />
|
<Heart className="w-4 h-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Glow Effect Background */}
|
{/* Ambient Glow */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-cyan-500/5 via-magenta-500/5 to-purple-500/5 opacity-0 hover:opacity-100 transition-opacity duration-500 pointer-events-none -z-10" />
|
<div className="absolute inset-0 bg-gradient-to-r from-cyan-500/5 via-primary/5 to-magenta-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none -z-10" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
88
apps/web/src/features/player/components/PlayerControls.tsx
Normal file
88
apps/web/src/features/player/components/PlayerControls.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { Play, Pause, SkipBack, SkipForward, Shuffle, Repeat } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export interface PlayerControlsProps {
|
||||||
|
isPlaying: boolean;
|
||||||
|
onPlayPause: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onShuffle: () => void;
|
||||||
|
onRepeat: () => void;
|
||||||
|
shuffle: boolean;
|
||||||
|
repeat: 'off' | 'track' | 'playlist';
|
||||||
|
isExpanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayerControls({
|
||||||
|
isPlaying,
|
||||||
|
onPlayPause,
|
||||||
|
onNext,
|
||||||
|
onPrevious,
|
||||||
|
onShuffle,
|
||||||
|
onRepeat,
|
||||||
|
shuffle,
|
||||||
|
repeat,
|
||||||
|
isExpanded = false
|
||||||
|
}: PlayerControlsProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-4 md:gap-6", isExpanded && "gap-8")}>
|
||||||
|
<button
|
||||||
|
onClick={onShuffle}
|
||||||
|
className={cn(
|
||||||
|
"p-2 rounded-full transition-all duration-300",
|
||||||
|
shuffle
|
||||||
|
? "text-primary bg-primary/10 shadow-[0_0_10px_rgba(var(--cyan-500),0.3)]"
|
||||||
|
: "text-muted-foreground hover:text-white hover:bg-white/5"
|
||||||
|
)}
|
||||||
|
title="Shuffle"
|
||||||
|
>
|
||||||
|
<Shuffle className={cn("w-4 h-4", isExpanded && "w-5 h-5")} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onPrevious}
|
||||||
|
className="text-white hover:text-primary transition-colors p-2 active:scale-95 hover:bg-white/5 rounded-full"
|
||||||
|
>
|
||||||
|
<SkipBack className={cn("w-5 h-5 fill-current", isExpanded && "w-6 h-6")} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onPlayPause}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full bg-primary text-black flex items-center justify-center hover:scale-105 active:scale-95 transition-all shadow-[0_0_20px_rgba(var(--cyan-500),0.5)] hover:shadow-[0_0_30px_rgba(var(--cyan-500),0.8)]",
|
||||||
|
isExpanded ? "w-16 h-16" : "w-12 h-12"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause className={cn("fill-current", isExpanded ? "w-8 h-8" : "w-6 h-6")} />
|
||||||
|
) : (
|
||||||
|
<Play className={cn("fill-current ml-1", isExpanded ? "w-8 h-8" : "w-6 h-6")} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
className="text-white hover:text-primary transition-colors p-2 active:scale-95 hover:bg-white/5 rounded-full"
|
||||||
|
>
|
||||||
|
<SkipForward className={cn("w-5 h-5 fill-current", isExpanded && "w-6 h-6")} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onRepeat}
|
||||||
|
className={cn(
|
||||||
|
"p-2 rounded-full transition-all duration-300 relative",
|
||||||
|
repeat !== 'off'
|
||||||
|
? "text-primary bg-primary/10 shadow-[0_0_10px_rgba(var(--cyan-500),0.3)]"
|
||||||
|
: "text-muted-foreground hover:text-white hover:bg-white/5"
|
||||||
|
)}
|
||||||
|
title="Repeat"
|
||||||
|
>
|
||||||
|
<Repeat className={cn("w-4 h-4", isExpanded && "w-5 h-5")} />
|
||||||
|
{repeat === 'track' && (
|
||||||
|
<span className="absolute -top-1 -right-1 text-[8px] font-bold bg-primary text-black px-1 rounded-full">1</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
apps/web/src/features/player/components/PlayerExpanded.tsx
Normal file
157
apps/web/src/features/player/components/PlayerExpanded.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import {
|
||||||
|
ChevronDown, Heart, MoreHorizontal, Share2,
|
||||||
|
MessageSquare, Mic2, FileText, Music2
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { PlayPauseButton } from './PlayPauseButton'; // We might reuse or inline for consistent style
|
||||||
|
import { NextPreviousButtons } from './NextPreviousButtons';
|
||||||
|
import { RepeatShuffleButtons } from './RepeatShuffleButtons';
|
||||||
|
|
||||||
|
interface PlayerExpandedProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
onSeek: (time: number) => void;
|
||||||
|
player: any; // Using the player hook object
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek, player }: PlayerExpandedProps) {
|
||||||
|
const { currentTrack } = usePlayerStore();
|
||||||
|
|
||||||
|
if (!isOpen || !currentTrack) return null;
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
if (!seconds && seconds !== 0) return '0:00';
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
"fixed inset-0 z-[110] bg-black/95 backdrop-blur-3xl overflow-hidden flex flex-col transition-all duration-500",
|
||||||
|
isOpen ? "opacity-100 translate-y-0" : "opacity-0 translate-y-full pointer-events-none"
|
||||||
|
)}>
|
||||||
|
{/* Dynamic Background */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center opacity-30 blur-[100px] scale-110 transition-all duration-1000"
|
||||||
|
style={{ backgroundImage: `url(${currentTrack.cover || '/placeholder.svg'})` }}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-black/20 via-black/60 to-black/90" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="relative z-10 flex items-center justify-between p-6">
|
||||||
|
<Button variant="ghost" className="text-white hover:bg-white/10 rounded-full" onClick={onClose}>
|
||||||
|
<ChevronDown className="w-6 h-6" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs font-bold tracking-widest uppercase text-white/50">Following the Signal</span>
|
||||||
|
<Button variant="ghost" className="text-white hover:bg-white/10 rounded-full">
|
||||||
|
<MoreHorizontal className="w-6 h-6" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 flex flex-col md:flex-row items-center justify-center gap-12 px-8 pb-12 relative z-10 max-w-7xl mx-auto w-full">
|
||||||
|
|
||||||
|
{/* Left: Album Art */}
|
||||||
|
<div className="w-full max-w-md md:max-w-xl aspect-square relative group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-cyan-500/20 to-magenta-500/20 rounded-xl blur-2xl transform group-hover:scale-105 transition-transform duration-700" />
|
||||||
|
<img
|
||||||
|
src={currentTrack.cover || '/placeholder.svg'}
|
||||||
|
alt={currentTrack.title}
|
||||||
|
className="w-full h-full object-cover rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.5)] relative z-10 border border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Info & Controls */}
|
||||||
|
<div className="w-full max-w-xl flex flex-col justify-end space-y-8">
|
||||||
|
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-4xl md:text-5xl font-display font-bold text-white leading-tight">
|
||||||
|
{currentTrack.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl md:text-2xl text-muted-foreground font-medium">
|
||||||
|
{currentTrack.artist}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="icon" variant="ghost" className="text-muted-foreground hover:text-red-500 hover:bg-red-500/10 rounded-full h-12 w-12 transition-all">
|
||||||
|
<Heart className="w-6 h-6" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="space-y-4 group/progress">
|
||||||
|
<Slider
|
||||||
|
value={[currentTime]}
|
||||||
|
onValueChange={(val) => onSeek(val[0])}
|
||||||
|
max={duration || 100}
|
||||||
|
step={0.1}
|
||||||
|
className="py-2"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between text-xs font-mono text-muted-foreground">
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
<span>{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Todo: Reuse or reimplement buttons with larger sizes */}
|
||||||
|
<RepeatShuffleButtons
|
||||||
|
repeat={player.repeat}
|
||||||
|
shuffle={player.shuffle}
|
||||||
|
onRepeatChange={player.setRepeat}
|
||||||
|
onShuffleToggle={player.toggleShuffle}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6 md:gap-8">
|
||||||
|
<NextPreviousButtons
|
||||||
|
onNext={player.next}
|
||||||
|
onPrevious={player.previous}
|
||||||
|
canGoNext={true}
|
||||||
|
canGoPrevious={true}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<PlayPauseButton
|
||||||
|
isPlaying={player.isPlaying}
|
||||||
|
onClick={() => player.isPlaying ? player.pause() : player.resume()}
|
||||||
|
size="xl" // We need to support 'xl' maybe or modify the component
|
||||||
|
className="scale-125"
|
||||||
|
/>
|
||||||
|
<NextPreviousButtons
|
||||||
|
onNext={player.next}
|
||||||
|
onPrevious={player.previous}
|
||||||
|
canGoNext={true}
|
||||||
|
canGoPrevious={true}
|
||||||
|
size="lg"
|
||||||
|
className="hidden" // HACK: reusing comp just for previous button structure if needed
|
||||||
|
/>
|
||||||
|
{/* Wait, NextPrevious contains both buttons. I was using it wrong above. */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button size="icon" variant="ghost" className="text-muted-foreground hover:text-white">
|
||||||
|
<Share2 className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
{/* Lyrics toggle placeholder */}
|
||||||
|
<Button size="icon" variant="ghost" className="text-muted-foreground hover:text-white">
|
||||||
|
<Mic2 className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
apps/web/src/features/player/components/PlayerQueue.tsx
Normal file
134
apps/web/src/features/player/components/PlayerQueue.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { X, GripVertical, AlertCircle } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
|
||||||
|
interface PlayerQueueProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
currentTrackId?: string;
|
||||||
|
onPlay: (track: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayerQueue({ isOpen, onClose, currentTrackId, onPlay }: PlayerQueueProps) {
|
||||||
|
const { queue, currentIndex, removeFromQueue, clearQueue } = usePlayerStore();
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-x-0 bottom-24 mx-auto max-w-4xl w-full z-40 transition-all duration-300 ease-in-out transform",
|
||||||
|
isOpen ? "translate-y-0 opacity-100" : "translate-y-10 opacity-0 pointer-events-none"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mx-4 md:mx-0 bg-black/80 backdrop-blur-2xl border border-white/10 rounded-2xl shadow-2xl overflow-hidden max-h-[60vh] flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-white/5 bg-white/5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-white font-bold font-display tracking-wide">Play Queue</h3>
|
||||||
|
<Badge variant="outline" className="border-primary/20 text-primary bg-primary/10">
|
||||||
|
{queue.length} Tracks
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={clearQueue}
|
||||||
|
className="px-3 py-1.5 text-xs text-muted-foreground hover:text-white hover:bg-white/10 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 text-muted-foreground hover:text-white hover:bg-white/10 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-hidden relative">
|
||||||
|
{queue.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground p-8 text-center">
|
||||||
|
<AlertCircle className="w-8 h-8 mb-3 opacity-20" />
|
||||||
|
<p>Your queue is empty.</p>
|
||||||
|
<p className="text-xs opacity-50 mt-1">Add tracks to keep the vibe going.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-full max-h-[400px]">
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
{queue.map((track, index) => {
|
||||||
|
const isCurrent = index === currentIndex;
|
||||||
|
const isPast = index < currentIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${track.id}-${index}`}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-3 p-2 rounded-lg transition-all duration-200 border border-transparent",
|
||||||
|
isCurrent
|
||||||
|
? "bg-primary/10 border-primary/20 shadow-[0_0_15px_rgba(var(--cyan-500),0.1)]"
|
||||||
|
: "hover:bg-white/5 hover:border-white/5",
|
||||||
|
isPast && "opacity-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Drag Handle (Simulated) */}
|
||||||
|
<div className="text-white/20 group-hover:text-white/40 cursor-grab px-1">
|
||||||
|
<GripVertical className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Number/Status */}
|
||||||
|
<div className="w-6 text-center text-xs font-mono text-muted-foreground">
|
||||||
|
{isCurrent ? (
|
||||||
|
<div className="w-2 h-2 rounded-full bg-primary mx-auto animate-pulse shadow-[0_0_8px_rgba(var(--cyan-500),0.8)]" />
|
||||||
|
) : (
|
||||||
|
index + 1
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div
|
||||||
|
className="flex-1 min-w-0 cursor-pointer"
|
||||||
|
onClick={() => !isCurrent && onPlay(track)}
|
||||||
|
>
|
||||||
|
<h4 className={cn(
|
||||||
|
"text-sm font-medium truncate transition-colors",
|
||||||
|
isCurrent ? "text-primary" : "text-white group-hover:text-white"
|
||||||
|
)}>
|
||||||
|
{track.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground truncate opacity-70 group-hover:opacity-100">
|
||||||
|
{track.artist}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeFromQueue(index);
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 p-2 text-muted-foreground hover:text-red-400 hover:bg-red-400/10 rounded-full transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Backdrop for explicit dismissal on mobile if needed */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/20 -z-10 backdrop-blur-sm md:hidden"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue