veza/apps/web/src/features/player/components/PlayerExpanded.tsx

242 lines
12 KiB
TypeScript
Raw Normal View History

import { useState, useRef, useEffect } 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 { Tooltip } from '@/components/ui/tooltip';
import {
ChevronDown, Heart, MoreHorizontal, Share2,
Mic2, AlignLeft
} 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();
const [showLyrics, setShowLyrics] = useState(false);
const [autoScrollLyrics, setAutoScrollLyrics] = useState(true);
const lyricsScrollRef = useRef<HTMLDivElement>(null);
const lyrics = currentTrack?.lyrics;
// Auto-scroll lyrics to active line (must be before early return - hooks rules)
useEffect(() => {
if (!isOpen || !currentTrack || !autoScrollLyrics || !lyrics?.length || !lyricsScrollRef.current) return;
const activeIndex = lyrics.findIndex(
(line, i) =>
currentTime >= line.time &&
(i === lyrics.length - 1 || currentTime < (lyrics[i + 1]?.time ?? Infinity))
);
if (activeIndex >= 0) {
const el = lyricsScrollRef.current.children[activeIndex] as HTMLElement;
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [isOpen, currentTrack, currentTime, lyrics, autoScrollLyrics]);
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-[var(--sumi-z-popover)] bg-black/95 backdrop-blur-3xl overflow-hidden flex flex-col transition-all duration-[var(--sumi-duration-slow)]",
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-foreground 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-foreground hover:bg-white/10 rounded-full">
<MoreHorizontal className="w-6 h-6" />
</Button>
</div>
{/* Main Content */}
<div className={cn(
"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 transition-all duration-[var(--sumi-duration-slow)]",
showLyrics && "md:gap-8"
)}>
{/* Left: Album Art */}
<div className={cn(
"relative group transition-all duration-[var(--sumi-duration-slow)]",
showLyrics ? "w-full max-w-md md:max-w-sm aspect-square" : "w-full max-w-md md:max-w-xl aspect-square"
)}>
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-secondary/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-cover-depth 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-heading font-bold text-foreground 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-destructive hover:bg-destructive/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] ?? 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="lg"
className="scale-125"
/>
<NextPreviousButtons
onNext={player.next}
onPrevious={player.previous}
canGoNext={true}
canGoPrevious={true}
size="lg"
className="hidden" // NOTE: Reusing component for structure; hidden until 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-foreground">
<Share2 className="w-5 h-5" />
</Button>
<Tooltip content={showLyrics ? "Hide lyrics" : "Show lyrics"}>
<Button
size="icon"
variant="ghost"
className={cn("transition-colors", showLyrics ? "text-primary" : "text-muted-foreground hover:text-foreground")}
onClick={() => setShowLyrics(!showLyrics)}
>
<Mic2 className="w-5 h-5" />
</Button>
</Tooltip>
</div>
</div>
</div>
{/* Lyrics Panel (when toggled and track has lyrics) */}
{showLyrics && (
<div
className={cn(
"group/lyrics w-full md:flex-1 h-layout-lyrics-sm md:h-layout-lyrics flex flex-col relative rounded-xl overflow-hidden border border-white/10 bg-black/30 backdrop-blur-md",
"animate-in slide-in-from-right-4 duration-300"
)}
onMouseEnter={() => setAutoScrollLyrics(false)}
onMouseLeave={() => setAutoScrollLyrics(true)}
>
<div className="absolute top-2 right-2 z-10 opacity-0 group-hover/lyrics:opacity-100 transition-opacity">
<Tooltip content="Auto-scroll">
<Button
variant="ghost"
size="icon"
className={autoScrollLyrics ? "bg-primary/20 text-primary" : "text-muted-foreground"}
onClick={() => setAutoScrollLyrics(!autoScrollLyrics)}
>
<AlignLeft className="w-4 h-4" />
</Button>
</Tooltip>
</div>
{lyrics?.length ? (
<div
ref={lyricsScrollRef}
className="flex-1 overflow-y-auto custom-scrollbar px-6 py-8 space-y-6 text-center"
>
{lyrics.map((line, i) => {
const isActive =
currentTime >= line.time &&
(i === lyrics.length - 1 || currentTime < (lyrics[i + 1]?.time ?? Infinity));
return (
<p
key={i}
className={cn(
"text-xl md:text-2xl font-bold transition-all duration-[var(--sumi-duration-slow)] cursor-pointer hover:text-foreground",
isActive ? "text-foreground scale-105" : "text-white/20"
)}
onClick={() => onSeek(line.time)}
>
{line.text}
</p>
);
})}
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<Mic2 className="w-12 h-12 mb-3 opacity-50" />
<p>No lyrics available for this track.</p>
</div>
)}
</div>
)}
</div>
</div>
);
}