WIP Player
This commit is contained in:
280
frontend/components/AudioPlayer.tsx
Normal file
280
frontend/components/AudioPlayer.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import type { Track } from "@/lib/types"
|
||||
|
||||
interface AudioPlayerProps {
|
||||
track: Track | null
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export default function AudioPlayer({ track, onClose }: AudioPlayerProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [waveformPeaks, setWaveformPeaks] = useState<number[]>([])
|
||||
const [isLoadingWaveform, setIsLoadingWaveform] = useState(false)
|
||||
const [showVolumeSlider, setShowVolumeSlider] = useState(false)
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Load audio and waveform when track changes
|
||||
useEffect(() => {
|
||||
if (!track) {
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
setWaveformPeaks([])
|
||||
return
|
||||
}
|
||||
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
loadWaveform(track.id)
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.load()
|
||||
}
|
||||
}, [track?.id])
|
||||
|
||||
// Update current time as audio plays
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
|
||||
const updateTime = () => setCurrentTime(audio.currentTime)
|
||||
const updateDuration = () => setDuration(audio.duration)
|
||||
const handleEnded = () => setIsPlaying(false)
|
||||
|
||||
audio.addEventListener("timeupdate", updateTime)
|
||||
audio.addEventListener("loadedmetadata", updateDuration)
|
||||
audio.addEventListener("ended", handleEnded)
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener("timeupdate", updateTime)
|
||||
audio.removeEventListener("loadedmetadata", updateDuration)
|
||||
audio.removeEventListener("ended", handleEnded)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadWaveform = async (trackId: string) => {
|
||||
setIsLoadingWaveform(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/audio/waveform/${trackId}`
|
||||
)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setWaveformPeaks(data.peaks || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load waveform:", error)
|
||||
} finally {
|
||||
setIsLoadingWaveform(false)
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!audioRef.current) return
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause()
|
||||
} else {
|
||||
audioRef.current.play()
|
||||
}
|
||||
setIsPlaying(!isPlaying)
|
||||
}
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVolume = parseFloat(e.target.value)
|
||||
setVolume(newVolume)
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = newVolume
|
||||
}
|
||||
if (newVolume === 0) {
|
||||
setIsMuted(true)
|
||||
} else if (isMuted) {
|
||||
setIsMuted(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
if (!audioRef.current) return
|
||||
|
||||
if (isMuted) {
|
||||
audioRef.current.volume = volume
|
||||
setIsMuted(false)
|
||||
} else {
|
||||
audioRef.current.volume = 0
|
||||
setIsMuted(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleWaveformClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!audioRef.current || !progressRef.current) return
|
||||
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const percentage = x / rect.width
|
||||
const newTime = percentage * duration
|
||||
|
||||
audioRef.current.currentTime = newTime
|
||||
setCurrentTime(newTime)
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
if (!isFinite(seconds)) return "0:00"
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
const getVolumeIcon = () => {
|
||||
if (isMuted || volume === 0) return "🔇"
|
||||
if (volume < 0.5) return "🔉"
|
||||
return "🔊"
|
||||
}
|
||||
|
||||
if (!track) {
|
||||
return null
|
||||
}
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="bg-white border-t border-gray-200 shadow-lg" style={{ height: '80px' }}>
|
||||
{/* Hidden audio element */}
|
||||
<audio ref={audioRef} src={`${process.env.NEXT_PUBLIC_API_URL}/api/audio/stream/${track.id}`} />
|
||||
|
||||
<div className="h-full flex items-center gap-3 px-4">
|
||||
{/* Play/Pause button */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="w-10 h-10 flex items-center justify-center bg-orange-500 hover:bg-orange-600 rounded-full transition-colors flex-shrink-0"
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Track info */}
|
||||
<div className="flex-shrink-0 w-48">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">
|
||||
{track.filename}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{track.classification.genre.primary.split("---")[0]} • {Math.round(track.features.tempo_bpm)} BPM
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="text-xs text-gray-500 flex-shrink-0 w-16">
|
||||
{formatTime(currentTime)}
|
||||
</div>
|
||||
|
||||
{/* Waveform */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
ref={progressRef}
|
||||
className="relative h-12 cursor-pointer overflow-hidden flex items-center"
|
||||
onClick={handleWaveformClick}
|
||||
>
|
||||
{isLoadingWaveform ? (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<span className="text-xs text-gray-400">Loading...</span>
|
||||
</div>
|
||||
) : waveformPeaks.length > 0 ? (
|
||||
<div className="flex items-center h-full w-full" style={{ gap: '2px' }}>
|
||||
{waveformPeaks.map((peak, index) => {
|
||||
const isPlayed = (index / waveformPeaks.length) * 100 <= progress
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex-1 flex items-center justify-center"
|
||||
style={{ minWidth: "2px", maxWidth: "3px" }}
|
||||
>
|
||||
<div
|
||||
className={`w-full rounded-full transition-colors ${
|
||||
isPlayed ? "bg-orange-500" : "bg-gray-300"
|
||||
}`}
|
||||
style={{
|
||||
height: `${Math.max(peak * 80, 2)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center h-full w-full px-2">
|
||||
<div className="w-full h-1 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-orange-500 rounded-full transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time remaining */}
|
||||
<div className="text-xs text-gray-500 flex-shrink-0 w-16 text-right">
|
||||
{formatTime(duration)}
|
||||
</div>
|
||||
|
||||
{/* Volume control */}
|
||||
<div
|
||||
className="flex items-center gap-2 flex-shrink-0 relative"
|
||||
onMouseEnter={() => setShowVolumeSlider(true)}
|
||||
onMouseLeave={() => setShowVolumeSlider(false)}
|
||||
>
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
aria-label={isMuted ? "Unmute" : "Mute"}
|
||||
>
|
||||
{getVolumeIcon()}
|
||||
</button>
|
||||
{showVolumeSlider && (
|
||||
<div className="absolute bottom-full right-0 mb-2 bg-white border border-gray-200 rounded-lg shadow-lg p-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-24 h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-orange-500 rotate-[-90deg] origin-center"
|
||||
style={{ width: '80px' }}
|
||||
aria-label="Volume"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors flex-shrink-0"
|
||||
aria-label="Close player"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user