WIP Player

This commit is contained in:
2025-12-23 08:46:33 +01:00
parent 6c47f0760e
commit 359c8ccccc
2 changed files with 294 additions and 7 deletions

View File

@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query"
import { getTracks, getStats } from "@/lib/api"
import type { FilterParams, Track } from "@/lib/types"
import FilterPanel from "@/components/FilterPanel"
import AudioPlayer from "@/components/AudioPlayer"
// Helper function to format Discogs genre labels (e.g., "Pop---Ballad" -> ["Pop", "Ballad"])
function formatGenre(genre: string): { category: string; subgenre: string } {
@@ -54,6 +55,7 @@ function extractFilterOptions(tracks: Track[]) {
export default function Home() {
const [filters, setFilters] = useState<FilterParams>({})
const [page, setPage] = useState(0)
const [currentTrack, setCurrentTrack] = useState<Track | null>(null)
const limit = 50
const { data: tracksData, isLoading: isLoadingTracks } = useQuery({
@@ -87,7 +89,7 @@ export default function Home() {
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<main className={`max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 ${currentTrack ? 'pb-32' : ''}`}>
{/* Stats */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
@@ -220,14 +222,12 @@ export default function Home() {
)}
</div>
<div className="ml-4 flex gap-2">
<a
href={`${process.env.NEXT_PUBLIC_API_URL}/api/audio/stream/${track.id}`}
target="_blank"
rel="noopener noreferrer"
<button
onClick={() => setCurrentTrack(track)}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
>
Play
</a>
{currentTrack?.id === track.id ? '▶ Playing' : 'Play'}
</button>
<a
href={`${process.env.NEXT_PUBLIC_API_URL}/api/audio/download/${track.id}`}
download
@@ -282,6 +282,13 @@ export default function Home() {
</ol>
</div>
</main>
{/* Fixed Audio Player at bottom */}
{currentTrack && (
<div className="fixed bottom-0 left-0 right-0 z-50 shadow-2xl">
<AudioPlayer track={currentTrack} onClose={() => setCurrentTrack(null)} />
</div>
)}
</div>
)
}

View 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>
)
}