ui(components): sidebar-aware player bar, synced lyrics, queue positioning
GlobalPlayer: sidebar-aware floating bar (lg:left-main-expanded/collapsed), centered controls in flex flow, always-visible progress track, entrance animation (slide-in-from-bottom-4 + fade-in), compact responsive layout. PlayerExpanded: new synced lyrics panel with toggle, auto-scroll, click-to-seek. Album art shrinks when lyrics are displayed. PlayerQueue: sidebar-aware positioning matching GlobalPlayer pattern. types.ts: add lyrics field (time/text pairs) to Track interface. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
bf4112c76f
commit
64916e43f2
4 changed files with 132 additions and 41 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { usePlayer } from '@/features/player/hooks/usePlayer';
|
||||
import { useKeyboardShortcuts } from '@/features/player/hooks/useKeyboardShortcuts';
|
||||
import { useUIStore } from '@/stores/ui';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
|
|
@ -35,6 +36,7 @@ const Waveform = ({ playing }: { playing: boolean }) => {
|
|||
|
||||
export function GlobalPlayer() {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const { sidebarOpen } = useUIStore();
|
||||
|
||||
// Use the REAL player hook logic which connects to Zustand store
|
||||
const player = usePlayer(audioRef);
|
||||
|
|
@ -99,40 +101,44 @@ export function GlobalPlayer() {
|
|||
<div
|
||||
data-testid="global-player"
|
||||
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-[var(--duration-slow)] ease-[var(--ease-out)]",
|
||||
isExpanded ? "translate-y-[200%] opacity-0" : "translate-y-0 opacity-100"
|
||||
"fixed bottom-6 left-4 right-4 z-[100] transition-all duration-[var(--duration-slow)] ease-[var(--ease-out)]",
|
||||
"lg:right-4",
|
||||
sidebarOpen ? "lg:left-main-expanded" : "lg:left-main-collapsed",
|
||||
isExpanded ? "translate-y-[200%] opacity-0 pointer-events-none" : "translate-y-0 opacity-100"
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Glass Container */}
|
||||
{/* Glass Container — same look as Storybook: rounded bar, entrance animation */}
|
||||
<div className={cn(
|
||||
"relative rounded-2xl overflow-hidden backdrop-blur-2xl transition-all duration-[var(--duration-normal)] group shadow-2xl",
|
||||
"bg-black/80 border border-white/10",
|
||||
"relative rounded-2xl overflow-hidden backdrop-blur-2xl transition-all duration-[var(--duration-normal)] group shadow-2xl player-bar-entrance",
|
||||
"bg-black/80 border border-white/10 animate-in slide-in-from-bottom-4 fade-in duration-500",
|
||||
isHovered ? "shadow-player-hover border-primary/30" : "shadow-xl"
|
||||
)}>
|
||||
|
||||
{/* Progress Bar (Top Edge) */}
|
||||
<div className={cn("absolute top-0 left-0 right-0 h-0.5 bg-white/5 z-20 transition-all", !isIdle && "group-hover:h-1 cursor-pointer")}
|
||||
{/* Progress Bar (Top Edge) — always visible track, fill when playing */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-1 bg-white/10 z-20 cursor-pointer transition-all group-hover:h-1.5"
|
||||
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")}
|
||||
className={cn("h-full bg-gradient-to-r from-cyan-500 via-blue-500 to-magenta-500 rounded-r transition-[width] duration-[var(--duration-fast)]", isIdle && "w-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-player-thumb opacity-0 group-hover:opacity-100 transition-opacity scale-0 group-hover:scale-100" />
|
||||
</div>
|
||||
/>
|
||||
{!isIdle && (
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2.5 h-2.5 bg-white rounded-full shadow-player-thumb opacity-0 group-hover:opacity-100 transition-opacity scale-0 group-hover:scale-100 pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between h-20 md:h-24 px-4 md:px-6 relative z-10">
|
||||
|
||||
<div className="flex items-center justify-between gap-4 h-20 md:h-24 px-4 md:px-6 relative z-10">
|
||||
{/* LEFT: Track Info */}
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0 shrink-0 max-w-[45%]">
|
||||
<div
|
||||
className={cn("relative w-12 h-12 md:w-14 md:h-14 rounded-lg overflow-hidden group/art", !isIdle && "cursor-pointer")}
|
||||
onClick={() => !isIdle && setIsExpanded(true)}
|
||||
|
|
@ -176,8 +182,8 @@ export function GlobalPlayer() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* CENTER: Controls */}
|
||||
<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-80">
|
||||
{/* CENTER: Controls (in flow to avoid large gap) */}
|
||||
<div className="flex flex-col items-center justify-center gap-0.5 flex-shrink-0">
|
||||
<PlayerControls
|
||||
isPlaying={player.isPlaying}
|
||||
onPlayPause={() => {
|
||||
|
|
@ -188,29 +194,28 @@ export function GlobalPlayer() {
|
|||
onPrevious={player.previous}
|
||||
onShuffle={player.toggleShuffle}
|
||||
onRepeat={() => {
|
||||
const modes = ['off', 'track', 'playlist'] as const; // Should cycle
|
||||
const modes = ['off', 'track', 'playlist'] as const;
|
||||
const next = modes[(modes.indexOf(player.repeat) + 1) % modes.length];
|
||||
player.setRepeat(next);
|
||||
}}
|
||||
shuffle={player.shuffle}
|
||||
repeat={player.repeat}
|
||||
/>
|
||||
<div className={cn("hidden md:flex items-center gap-2 text-xs font-mono text-muted-foreground w-full justify-center transition-opacity duration-[var(--duration-normal)]", !isIdle ? "opacity-0 group-hover:opacity-100" : "opacity-0")}>
|
||||
<div className={cn("flex items-center gap-1.5 text-xs font-mono text-muted-foreground", !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>
|
||||
|
||||
{/* RIGHT: Actions & Volume */}
|
||||
<div className="flex items-center justify-end gap-3 md:gap-4 flex-1">
|
||||
{/* Waveform Visualization (Desktop) */}
|
||||
<div className="hidden lg:block w-24 mr-2">
|
||||
{/* RIGHT: Volume + Queue + Like (compact, no huge gap) */}
|
||||
<div className="flex items-center justify-end gap-2 md:gap-3 flex-1 min-w-0 max-w-[45%]">
|
||||
<div className="hidden lg:block w-16 flex-shrink-0">
|
||||
<Waveform playing={player.isPlaying} />
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="hidden md:flex items-center gap-2 group/volume">
|
||||
{/* Volume — always show icon; slider on hover */}
|
||||
<div className="flex items-center gap-1.5 group/volume flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -219,22 +224,22 @@ export function GlobalPlayer() {
|
|||
>
|
||||
{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-[var(--duration-normal)]">
|
||||
<div className="w-0 md:group-hover/volume:w-20 overflow-hidden transition-all duration-[var(--duration-normal)]">
|
||||
<Slider
|
||||
value={[player.muted ? 0 : player.volume]}
|
||||
onValueChange={(val) => player.setVolume(val[0])}
|
||||
max={100}
|
||||
className="w-20"
|
||||
className="w-20 min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-8 bg-white/10 hidden md:block" />
|
||||
<div className="w-px h-6 bg-white/10 flex-shrink-0" />
|
||||
|
||||
<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")}
|
||||
className={cn("w-9 h-9 rounded-full flex-shrink-0", showQueue ? "text-primary bg-primary/10" : "text-muted-foreground hover:text-white")}
|
||||
onClick={() => setShowQueue(!showQueue)}
|
||||
>
|
||||
<ListMusic className="w-4 h-4" />
|
||||
|
|
@ -243,11 +248,10 @@ export function GlobalPlayer() {
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-9 h-9 rounded-full text-muted-foreground hover:text-magenta-500 hover:bg-magenta-500/10"
|
||||
className="w-9 h-9 rounded-full flex-shrink-0 text-muted-foreground hover:text-magenta-500 hover:bg-magenta-500/10"
|
||||
>
|
||||
<Heart className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import React from 'react';
|
||||
import React, { 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 {
|
||||
ChevronDown, Heart, MoreHorizontal, Share2,
|
||||
MessageSquare, Mic2, FileText, Music2
|
||||
Mic2, AlignLeft
|
||||
} from 'lucide-react';
|
||||
import { PlayPauseButton } from './PlayPauseButton'; // We might reuse or inline for consistent style
|
||||
import { NextPreviousButtons } from './NextPreviousButtons';
|
||||
|
|
@ -22,9 +22,13 @@ interface PlayerExpandedProps {
|
|||
|
||||
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);
|
||||
|
||||
if (!isOpen || !currentTrack) return null;
|
||||
|
||||
const lyrics = currentTrack.lyrics;
|
||||
const formatTime = (seconds: number) => {
|
||||
if (!seconds && seconds !== 0) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
|
|
@ -32,6 +36,20 @@ export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek,
|
|||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Auto-scroll lyrics to active line
|
||||
useEffect(() => {
|
||||
if (!autoScrollLyrics || !lyrics?.length || !lyricsScrollRef.current) return;
|
||||
const activeIndex = lyrics.findIndex(
|
||||
(line, i) =>
|
||||
currentTime >= line.time &&
|
||||
(i === lyrics.length - 1 || currentTime < lyrics[i + 1].time)
|
||||
);
|
||||
if (activeIndex >= 0) {
|
||||
const el = lyricsScrollRef.current.children[activeIndex] as HTMLElement;
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [currentTime, lyrics, autoScrollLyrics]);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"fixed inset-0 z-[110] bg-black/95 backdrop-blur-3xl overflow-hidden flex flex-col transition-all duration-[var(--duration-slow)]",
|
||||
|
|
@ -58,10 +76,15 @@ export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek,
|
|||
</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">
|
||||
|
||||
<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(--duration-slow)]",
|
||||
showLyrics && "md:gap-8"
|
||||
)}>
|
||||
{/* Left: Album Art */}
|
||||
<div className="w-full max-w-md md:max-w-xl aspect-square relative group">
|
||||
<div className={cn(
|
||||
"relative group transition-all duration-[var(--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-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'}
|
||||
|
|
@ -144,13 +167,71 @@ export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek,
|
|||
<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">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn("transition-colors", showLyrics ? "text-primary" : "text-muted-foreground hover:text-white")}
|
||||
onClick={() => setShowLyrics(!showLyrics)}
|
||||
title={showLyrics ? "Hide lyrics" : "Show lyrics"}
|
||||
>
|
||||
<Mic2 className="w-5 h-5" />
|
||||
</Button>
|
||||
</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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={autoScrollLyrics ? "bg-primary/20 text-primary" : "text-muted-foreground"}
|
||||
onClick={() => setAutoScrollLyrics(!autoScrollLyrics)}
|
||||
title="Auto-scroll"
|
||||
>
|
||||
<AlignLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</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);
|
||||
return (
|
||||
<p
|
||||
key={i}
|
||||
className={cn(
|
||||
"text-xl md:text-2xl font-bold transition-all duration-[var(--duration-slow)] cursor-pointer hover:text-white",
|
||||
isActive ? "text-white 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useUIStore } from '@/stores/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { X, GripVertical, AlertCircle } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
|
@ -14,17 +15,20 @@ interface PlayerQueueProps {
|
|||
|
||||
export function PlayerQueue({ isOpen, onClose, currentTrackId, onPlay }: PlayerQueueProps) {
|
||||
const { queue, currentIndex, removeFromQueue, clearQueue } = usePlayerStore();
|
||||
const { sidebarOpen } = useUIStore();
|
||||
|
||||
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-[var(--duration-normal)] ease-[var(--ease-out)] transform",
|
||||
"fixed bottom-24 left-4 right-4 z-40 transition-all duration-[var(--duration-normal)] ease-[var(--ease-out)] transform",
|
||||
sidebarOpen ? "lg:left-main-expanded" : "lg:left-main-collapsed",
|
||||
"lg:right-4",
|
||||
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-layout-drawer flex flex-col">
|
||||
<div className="max-w-4xl mx-auto bg-black/80 backdrop-blur-2xl border border-white/10 rounded-2xl shadow-2xl overflow-hidden max-h-layout-drawer 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">
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export interface Track {
|
|||
url: string;
|
||||
cover?: string;
|
||||
genre?: string;
|
||||
/** Synced lyrics: time (seconds) and text */
|
||||
lyrics?: { time: number; text: string }[];
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
|
|
|
|||
Loading…
Reference in a new issue