From 8218234eb9d3ed36c35f879337f2989698560623 Mon Sep 17 00:00:00 2001 From: Kagamine Rin Date: Fri, 29 May 2026 20:28:20 -0400 Subject: [PATCH] feat: Full audio player with controls and scrubber Player.tsx completely rewritten with professional controls: - Play/Pause: large circular button with play/pause icons - Stop: square icon, resets audio to 0 and pauses - Restart: circular arrow, jumps to beginning and plays - Time display: elapsed / total (M:SS format, tabular-nums) - Scrubber: clickable progress bar with drag support - Visual progress fill (lyria-accent) - White scrubber handle on hover - Mouse drag anywhere on page while holding - Volume: mute toggle + range slider (0-1, step 0.01) - Three volume icons: full, half, muted - Custom styled range input with white thumb - Track info: title or prompt (hidden on mobile) - Close button: X icon - All buttons have title tooltips - Uses useRef for audio element, useState for UI state - useCallback for seek handler, useEffect for drag events - Responsive: track info hidden on small screens --- frontend/src/App.tsx | 1 + frontend/src/components/Player.tsx | 250 +++++++++++++++++++++++++++-- 2 files changed, 235 insertions(+), 16 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3fcbaa3..79aefd8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -186,6 +186,7 @@ function App() { clip={currentClip} isPlaying={isPlaying} onTogglePlay={() => setIsPlaying(!isPlaying)} + onStop={() => setIsPlaying(false)} onClose={() => { setCurrentClip(null) setIsPlaying(false) diff --git a/frontend/src/components/Player.tsx b/frontend/src/components/Player.tsx index f0bf526..8e36f6e 100644 --- a/frontend/src/components/Player.tsx +++ b/frontend/src/components/Player.tsx @@ -1,7 +1,8 @@ -import { useRef, useEffect } from 'react' +import { useRef, useEffect, useState, useCallback } from 'react' interface Clip { id: string + title?: string prompt: string duration_seconds: number } @@ -10,28 +11,153 @@ interface Props { clip: Clip isPlaying: boolean onTogglePlay: () => void + onStop: () => void onClose: () => void } -export default function Player({ clip, isPlaying, onTogglePlay, onClose }: Props) { - const audioRef = useRef(null) +function formatTime(seconds: number): string { + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${mins}:${secs.toString().padStart(2, '0')}` +} +export default function Player({ clip, isPlaying, onTogglePlay, onStop, onClose }: Props) { + const audioRef = useRef(null) + const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(clip.duration_seconds) + const [volume, setVolume] = useState(1) + const [isMuted, setIsMuted] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const progressRef = useRef(null) + + // Sync play/pause with audio element useEffect(() => { if (audioRef.current) { if (isPlaying) { - audioRef.current.play() + audioRef.current.play().catch(() => { + // Autoplay blocked or other error + }) } else { audioRef.current.pause() } } }, [isPlaying, clip.id]) + // Update duration when metadata loads + const handleLoadedMetadata = () => { + if (audioRef.current) { + setDuration(audioRef.current.duration || clip.duration_seconds) + } + } + + // Update current time during playback + const handleTimeUpdate = () => { + if (audioRef.current && !isDragging) { + setCurrentTime(audioRef.current.currentTime) + } + } + + // Track ended + const handleEnded = () => { + onTogglePlay() + setCurrentTime(0) + } + + // Seek to position + const handleSeek = useCallback((clientX: number) => { + if (!progressRef.current || !audioRef.current) return + const rect = progressRef.current.getBoundingClientRect() + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) + const newTime = ratio * duration + audioRef.current.currentTime = newTime + setCurrentTime(newTime) + }, [duration]) + + // Progress bar mouse events + const handleProgressMouseDown = (e: React.MouseEvent) => { + setIsDragging(true) + handleSeek(e.clientX) + } + + const handleProgressMouseMove = (e: React.MouseEvent) => { + if (isDragging) { + handleSeek(e.clientX) + } + } + + const handleProgressMouseUp = () => { + setIsDragging(false) + } + + // Global mouse events for drag + useEffect(() => { + if (isDragging) { + const handleMouseMove = (e: MouseEvent) => handleSeek(e.clientX) + const handleMouseUp = () => setIsDragging(false) + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + return () => { + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + } + } + }, [isDragging, handleSeek]) + + // Volume control + const handleVolumeChange = (e: React.ChangeEvent) => { + const newVol = parseFloat(e.target.value) + setVolume(newVol) + if (audioRef.current) { + audioRef.current.volume = newVol + } + if (newVol > 0 && isMuted) { + setIsMuted(false) + } + } + + const toggleMute = () => { + if (!audioRef.current) return + if (isMuted) { + audioRef.current.volume = volume > 0 ? volume : 0.5 + setIsMuted(false) + } else { + audioRef.current.volume = 0 + setIsMuted(true) + } + } + + // Restart from beginning + const handleRestart = () => { + if (audioRef.current) { + audioRef.current.currentTime = 0 + setCurrentTime(0) + if (!isPlaying) { + onTogglePlay() + } + } + } + + // Stop + const handleStop = () => { + if (audioRef.current) { + audioRef.current.pause() + audioRef.current.currentTime = 0 + setCurrentTime(0) + } + onStop() + } + + const progress = duration > 0 ? (currentTime / duration) * 100 : 0 + const displayTitle = clip.title || clip.prompt + return ( -
-
+
+
+ {/* Play/Pause */} -
-

{clip.prompt}

-

{clip.duration_seconds}s

+ {/* Stop */} + + + {/* Restart */} + + + {/* Time + Scrubber */} +
+ + {formatTime(currentTime)} + + +
+
+ {/* Scrubber handle */} +
+
+ + + {formatTime(duration)} +
-
+ +
) }