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
This commit is contained in:
1 parent
4fab947d6c
commit
8218234eb9
2 files changed
+235
-16
No files matched your search
@@ -186,6 +186,7 @@ function App() {
|
|||||||
clip={currentClip}
|
clip={currentClip}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
onTogglePlay={() => setIsPlaying(!isPlaying)}
|
onTogglePlay={() => setIsPlaying(!isPlaying)}
|
||||||
|
onStop={() => setIsPlaying(false)}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setCurrentClip(null)
|
setCurrentClip(null)
|
||||||
setIsPlaying(false)
|
setIsPlaying(false)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useRef, useEffect } from 'react'
|
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||||
|
|
||||||
interface Clip {
|
interface Clip {
|
||||||
id: string
|
id: string
|
||||||
|
title?: string
|
||||||
prompt: string
|
prompt: string
|
||||||
duration_seconds: number
|
duration_seconds: number
|
||||||
}
|
}
|
||||||
@@ -10,28 +11,153 @@ interface Props {
|
|||||||
clip: Clip
|
clip: Clip
|
||||||
isPlaying: boolean
|
isPlaying: boolean
|
||||||
onTogglePlay: () => void
|
onTogglePlay: () => void
|
||||||
|
onStop: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Player({ clip, isPlaying, onTogglePlay, onClose }: Props) {
|
function formatTime(seconds: number): string {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null)
|
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<HTMLAudioElement>(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<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Sync play/pause with audio element
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
audioRef.current.play()
|
audioRef.current.play().catch(() => {
|
||||||
|
// Autoplay blocked or other error
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
audioRef.current.pause()
|
audioRef.current.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isPlaying, clip.id])
|
}, [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<HTMLInputElement>) => {
|
||||||
|
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 (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-lyria-panel border-t border-white/10 px-6 py-3">
|
<div className="fixed bottom-0 left-0 right-0 bg-lyria-panel border-t border-white/10 px-4 py-3 z-40">
|
||||||
<div className="max-w-6xl mx-auto flex items-center gap-4">
|
<div className="max-w-6xl mx-auto flex items-center gap-3">
|
||||||
|
{/* Play/Pause */}
|
||||||
<button
|
<button
|
||||||
onClick={onTogglePlay}
|
onClick={onTogglePlay}
|
||||||
className="w-10 h-10 rounded-full bg-lyria-accent flex items-center justify-center text-white hover:bg-lyria-accent/90 transition-colors shrink-0"
|
className="w-9 h-9 rounded-full bg-lyria-accent flex items-center justify-center text-white hover:bg-lyria-accent/90 transition-colors shrink-0"
|
||||||
|
title={isPlaying ? 'Pause' : 'Play'}
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -44,27 +170,119 @@ export default function Player({ clip, isPlaying, onTogglePlay, onClose }: Props
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
{/* Stop */}
|
||||||
<p className="text-sm text-white truncate">{clip.prompt}</p>
|
<button
|
||||||
<p className="text-xs text-lyria-muted">{clip.duration_seconds}s</p>
|
onClick={handleStop}
|
||||||
|
className="w-7 h-7 rounded flex items-center justify-center text-lyria-muted hover:text-white hover:bg-white/5 transition-colors shrink-0"
|
||||||
|
title="Stop"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<rect x="6" y="6" width="12" height="12" rx="1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Restart */}
|
||||||
|
<button
|
||||||
|
onClick={handleRestart}
|
||||||
|
className="w-7 h-7 rounded flex items-center justify-center text-lyria-muted hover:text-white hover:bg-white/5 transition-colors shrink-0"
|
||||||
|
title="Play from beginning"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Time + Scrubber */}
|
||||||
|
<div className="flex-1 min-w-0 flex items-center gap-3">
|
||||||
|
<span className="text-xs text-lyria-muted tabular-nums shrink-0 w-10 text-right">
|
||||||
|
{formatTime(currentTime)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={progressRef}
|
||||||
|
className="flex-1 h-2 bg-white/10 rounded-full cursor-pointer relative group"
|
||||||
|
onMouseDown={handleProgressMouseDown}
|
||||||
|
onMouseMove={handleProgressMouseMove}
|
||||||
|
onMouseUp={handleProgressMouseUp}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 bg-lyria-accent rounded-full transition-all duration-75"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
{/* Scrubber handle */}
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style={{ left: `calc(${progress}% - 6px)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-xs text-lyria-muted tabular-nums shrink-0 w-10">
|
||||||
|
{formatTime(duration)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<audio
|
{/* Track info */}
|
||||||
ref={audioRef}
|
<div className="hidden sm:block min-w-0 max-w-[200px]">
|
||||||
src={`/api/clips/${clip.id}/audio`}
|
<p className="text-xs text-white truncate">{displayTitle}</p>
|
||||||
onEnded={() => onTogglePlay()}
|
</div>
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
{/* Volume */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={toggleMute}
|
||||||
|
className="text-lyria-muted hover:text-white transition-colors"
|
||||||
|
title={isMuted ? 'Unmute' : 'Mute'}
|
||||||
|
>
|
||||||
|
{isMuted || volume === 0 ? (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
|
||||||
|
</svg>
|
||||||
|
) : volume < 0.5 ? (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={isMuted ? 0 : volume}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
className="w-20 h-1 bg-white/20 rounded-full appearance-none cursor-pointer
|
||||||
|
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2.5 [&::-webkit-slider-thumb]:h-2.5
|
||||||
|
[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full
|
||||||
|
[&::-moz-range-thumb]:w-2.5 [&::-moz-range-thumb]:h-2.5 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Close */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-lyria-muted hover:text-white transition-colors shrink-0"
|
className="text-lyria-muted hover:text-white transition-colors shrink-0"
|
||||||
|
title="Close player"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={`/api/clips/${clip.id}/audio`}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onEnded={handleEnded}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user