From 64916e43f2657169ea6d51750b68a2fc0180ba40 Mon Sep 17 00:00:00 2001 From: senke Date: Sun, 8 Feb 2026 22:48:08 +0100 Subject: [PATCH] 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 --- .../player/components/GlobalPlayer.tsx | 68 ++++++------- .../player/components/PlayerExpanded.tsx | 95 +++++++++++++++++-- .../player/components/PlayerQueue.tsx | 8 +- apps/web/src/features/player/types.ts | 2 + 4 files changed, 132 insertions(+), 41 deletions(-) diff --git a/apps/web/src/features/player/components/GlobalPlayer.tsx b/apps/web/src/features/player/components/GlobalPlayer.tsx index 8c858a330..10238c6ce 100644 --- a/apps/web/src/features/player/components/GlobalPlayer.tsx +++ b/apps/web/src/features/player/components/GlobalPlayer.tsx @@ -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(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() {
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > - {/* Glass Container */} + {/* Glass Container — same look as Storybook: rounded bar, entrance animation */}
- {/* Progress Bar (Top Edge) */} -
{ if (isIdle) return; const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left; const pct = x / rect.width; player.seek(pct * player.duration); - }}> + }} + >
-
-
+ /> + {!isIdle && ( +
+ )}
-
- +
{/* LEFT: Track Info */} -
+
!isIdle && setIsExpanded(true)} @@ -176,8 +182,8 @@ export function GlobalPlayer() {
- {/* CENTER: Controls */} -
+ {/* CENTER: Controls (in flow to avoid large gap) */} +
{ @@ -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} /> -
+
{formatTime(player.currentTime)} / {formatTime(player.duration)}
- {/* RIGHT: Actions & Volume */} -
- {/* Waveform Visualization (Desktop) */} -
+ {/* RIGHT: Volume + Queue + Like (compact, no huge gap) */} +
+
- {/* Volume */} -
+ {/* Volume — always show icon; slider on hover */} +
-
+
player.setVolume(val[0])} max={100} - className="w-20" + className="w-20 min-w-0" />
-
+
-
diff --git a/apps/web/src/features/player/components/PlayerExpanded.tsx b/apps/web/src/features/player/components/PlayerExpanded.tsx index 7828e34f6..9a8b145f7 100644 --- a/apps/web/src/features/player/components/PlayerExpanded.tsx +++ b/apps/web/src/features/player/components/PlayerExpanded.tsx @@ -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(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 (
{/* Main Content */} -
- +
{/* Left: Album Art */} -
+
- {/* Lyrics toggle placeholder */} -
+ + {/* Lyrics Panel (when toggled and track has lyrics) */} + {showLyrics && ( +
setAutoScrollLyrics(false)} + onMouseLeave={() => setAutoScrollLyrics(true)} + > +
+ +
+ {lyrics?.length ? ( +
+ {lyrics.map((line, i) => { + const isActive = + currentTime >= line.time && + (i === lyrics.length - 1 || currentTime < lyrics[i + 1].time); + return ( +

onSeek(line.time)} + > + {line.text} +

+ ); + })} +
+ ) : ( +
+ +

No lyrics available for this track.

+
+ )} +
+ )}
); diff --git a/apps/web/src/features/player/components/PlayerQueue.tsx b/apps/web/src/features/player/components/PlayerQueue.tsx index 15cab4579..414ecb30d 100644 --- a/apps/web/src/features/player/components/PlayerQueue.tsx +++ b/apps/web/src/features/player/components/PlayerQueue.tsx @@ -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 (
-
+
{/* Header */}
diff --git a/apps/web/src/features/player/types.ts b/apps/web/src/features/player/types.ts index 3b5ba931e..821c60cee 100644 --- a/apps/web/src/features/player/types.ts +++ b/apps/web/src/features/player/types.ts @@ -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 {