WIP Player
waveform ok, autoplay, download
This commit is contained in:
@@ -89,7 +89,7 @@ export default function Home() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className={`max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 ${currentTrack ? 'pb-32' : ''}`}>
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-32">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||||
@@ -283,12 +283,10 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Fixed Audio Player at bottom */}
|
{/* Fixed Audio Player at bottom - always visible */}
|
||||||
{currentTrack && (
|
<div className="fixed bottom-0 left-0 right-0 z-50">
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-50 shadow-2xl">
|
<AudioPlayer track={currentTrack} />
|
||||||
<AudioPlayer track={currentTrack} onClose={() => setCurrentTrack(null)} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import type { Track } from "@/lib/types"
|
|||||||
|
|
||||||
interface AudioPlayerProps {
|
interface AudioPlayerProps {
|
||||||
track: Track | null
|
track: Track | null
|
||||||
onClose?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AudioPlayer({ track, onClose }: AudioPlayerProps) {
|
export default function AudioPlayer({ track }: AudioPlayerProps) {
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
const [currentTime, setCurrentTime] = useState(0)
|
const [currentTime, setCurrentTime] = useState(0)
|
||||||
const [duration, setDuration] = useState(0)
|
const [duration, setDuration] = useState(0)
|
||||||
@@ -16,7 +15,6 @@ export default function AudioPlayer({ track, onClose }: AudioPlayerProps) {
|
|||||||
const [isMuted, setIsMuted] = useState(false)
|
const [isMuted, setIsMuted] = useState(false)
|
||||||
const [waveformPeaks, setWaveformPeaks] = useState<number[]>([])
|
const [waveformPeaks, setWaveformPeaks] = useState<number[]>([])
|
||||||
const [isLoadingWaveform, setIsLoadingWaveform] = useState(false)
|
const [isLoadingWaveform, setIsLoadingWaveform] = useState(false)
|
||||||
const [showVolumeSlider, setShowVolumeSlider] = useState(false)
|
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null)
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
const progressRef = useRef<HTMLDivElement>(null)
|
const progressRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -30,12 +28,18 @@ export default function AudioPlayer({ track, onClose }: AudioPlayerProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsPlaying(false)
|
|
||||||
setCurrentTime(0)
|
setCurrentTime(0)
|
||||||
loadWaveform(track.id)
|
loadWaveform(track.id)
|
||||||
|
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.load()
|
audioRef.current.load()
|
||||||
|
// Autoplay when track loads
|
||||||
|
audioRef.current.play().then(() => {
|
||||||
|
setIsPlaying(true)
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
console.error("Autoplay failed:", error)
|
||||||
|
setIsPlaying(false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [track?.id])
|
}, [track?.id])
|
||||||
|
|
||||||
@@ -77,7 +81,7 @@ export default function AudioPlayer({ track, onClose }: AudioPlayerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
if (!audioRef.current) return
|
if (!audioRef.current || !track) return
|
||||||
|
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
audioRef.current.pause()
|
audioRef.current.pause()
|
||||||
@@ -113,7 +117,7 @@ export default function AudioPlayer({ track, onClose }: AudioPlayerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleWaveformClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleWaveformClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (!audioRef.current || !progressRef.current) return
|
if (!audioRef.current || !progressRef.current || !track) return
|
||||||
|
|
||||||
const rect = progressRef.current.getBoundingClientRect()
|
const rect = progressRef.current.getBoundingClientRect()
|
||||||
const x = e.clientX - rect.left
|
const x = e.clientX - rect.left
|
||||||
@@ -131,28 +135,19 @@ export default function AudioPlayer({ track, onClose }: AudioPlayerProps) {
|
|||||||
return `${mins}:${secs.toString().padStart(2, "0")}`
|
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
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border-t border-gray-200 shadow-lg" style={{ height: '80px' }}>
|
<div className="bg-gray-50 border-t border-gray-300 shadow-lg" style={{ height: '80px' }}>
|
||||||
{/* Hidden audio element */}
|
{/* Hidden audio element */}
|
||||||
<audio ref={audioRef} src={`${process.env.NEXT_PUBLIC_API_URL}/api/audio/stream/${track.id}`} />
|
{track && <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">
|
<div className="h-full flex items-center gap-3 px-4">
|
||||||
{/* Play/Pause button */}
|
{/* Play/Pause button */}
|
||||||
<button
|
<button
|
||||||
onClick={togglePlay}
|
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"
|
disabled={!track}
|
||||||
|
className="w-10 h-10 flex items-center justify-center bg-orange-500 hover:bg-orange-600 disabled:bg-gray-300 disabled:cursor-not-allowed rounded-full transition-colors flex-shrink-0"
|
||||||
aria-label={isPlaying ? "Pause" : "Play"}
|
aria-label={isPlaying ? "Pause" : "Play"}
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
@@ -168,12 +163,18 @@ export default function AudioPlayer({ track, onClose }: AudioPlayerProps) {
|
|||||||
|
|
||||||
{/* Track info */}
|
{/* Track info */}
|
||||||
<div className="flex-shrink-0 w-48">
|
<div className="flex-shrink-0 w-48">
|
||||||
|
{track ? (
|
||||||
|
<>
|
||||||
<div className="text-sm font-medium text-gray-900 truncate">
|
<div className="text-sm font-medium text-gray-900 truncate">
|
||||||
{track.filename}
|
{track.filename}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
{track.classification.genre.primary.split("---")[0]} • {Math.round(track.features.tempo_bpm)} BPM
|
{track.classification.genre.primary.split("---")[0]} • {Math.round(track.features.tempo_bpm)} BPM
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-400">No track selected</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time */}
|
{/* Time */}
|
||||||
@@ -185,7 +186,7 @@ export default function AudioPlayer({ track, onClose }: AudioPlayerProps) {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
ref={progressRef}
|
ref={progressRef}
|
||||||
className="relative h-12 cursor-pointer overflow-hidden flex items-center"
|
className="relative h-12 cursor-pointer overflow-hidden flex items-center bg-gray-100 rounded"
|
||||||
onClick={handleWaveformClick}
|
onClick={handleWaveformClick}
|
||||||
>
|
>
|
||||||
{isLoadingWaveform ? (
|
{isLoadingWaveform ? (
|
||||||
@@ -193,21 +194,26 @@ export default function AudioPlayer({ track, onClose }: AudioPlayerProps) {
|
|||||||
<span className="text-xs text-gray-400">Loading...</span>
|
<span className="text-xs text-gray-400">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
) : waveformPeaks.length > 0 ? (
|
) : waveformPeaks.length > 0 ? (
|
||||||
<div className="flex items-center h-full w-full" style={{ gap: '2px' }}>
|
<div className="flex items-center h-full w-full gap-[1px] px-1">
|
||||||
{waveformPeaks.map((peak, index) => {
|
{waveformPeaks.map((peak: number, index: number) => {
|
||||||
const isPlayed = (index / waveformPeaks.length) * 100 <= progress
|
const isPlayed = (index / waveformPeaks.length) * 100 <= progress
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex-1 flex items-center justify-center"
|
className="flex items-center justify-center"
|
||||||
style={{ minWidth: "2px", maxWidth: "3px" }}
|
style={{
|
||||||
|
width: "2px",
|
||||||
|
height: "100%",
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`w-full rounded-full transition-colors ${
|
className={`rounded-sm transition-colors ${
|
||||||
isPlayed ? "bg-orange-500" : "bg-gray-300"
|
isPlayed ? "bg-orange-500" : "bg-gray-400"
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
height: `${Math.max(peak * 80, 2)}%`,
|
width: "2px",
|
||||||
|
height: `${Math.max(peak * 70, 4)}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,7 +222,7 @@ export default function AudioPlayer({ track, onClose }: AudioPlayerProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center h-full w-full px-2">
|
<div className="flex items-center h-full w-full px-2">
|
||||||
<div className="w-full h-1 bg-gray-200 rounded-full">
|
<div className="w-full h-1 bg-gray-300 rounded-full">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-orange-500 rounded-full transition-all"
|
className="h-full bg-orange-500 rounded-full transition-all"
|
||||||
style={{ width: `${progress}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
@@ -233,20 +239,26 @@ export default function AudioPlayer({ track, onClose }: AudioPlayerProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Volume control */}
|
{/* Volume control */}
|
||||||
<div
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
className="flex items-center gap-2 flex-shrink-0 relative"
|
|
||||||
onMouseEnter={() => setShowVolumeSlider(true)}
|
|
||||||
onMouseLeave={() => setShowVolumeSlider(false)}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleMute}
|
onClick={toggleMute}
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-gray-900 transition-colors rounded hover:bg-gray-200"
|
||||||
aria-label={isMuted ? "Unmute" : "Mute"}
|
aria-label={isMuted ? "Unmute" : "Mute"}
|
||||||
>
|
>
|
||||||
{getVolumeIcon()}
|
{isMuted || volume === 0 ? (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
||||||
|
</svg>
|
||||||
|
) : volume < 0.5 ? (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M7 9v6h4l5 5V4l-5 5H7z"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{showVolumeSlider && (
|
|
||||||
<div className="absolute bottom-full right-0 mb-2 bg-white border border-gray-200 rounded-lg shadow-lg p-2">
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -254,25 +266,26 @@ export default function AudioPlayer({ track, onClose }: AudioPlayerProps) {
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
value={isMuted ? 0 : volume}
|
value={isMuted ? 0 : volume}
|
||||||
onChange={handleVolumeChange}
|
onChange={handleVolumeChange}
|
||||||
className="w-24 h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-orange-500 rotate-[-90deg] origin-center"
|
className="w-20 h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer accent-orange-500"
|
||||||
style={{ width: '80px' }}
|
style={{
|
||||||
|
background: `linear-gradient(to right, #f97316 0%, #f97316 ${(isMuted ? 0 : volume) * 100}%, #d1d5db ${(isMuted ? 0 : volume) * 100}%, #d1d5db 100%)`
|
||||||
|
}}
|
||||||
aria-label="Volume"
|
aria-label="Volume"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Close button */}
|
{/* Download button */}
|
||||||
{onClose && (
|
{track && (
|
||||||
<button
|
<a
|
||||||
onClick={onClose}
|
href={`${process.env.NEXT_PUBLIC_API_URL}/api/audio/download/${track.id}`}
|
||||||
className="text-gray-400 hover:text-gray-600 transition-colors flex-shrink-0"
|
download
|
||||||
aria-label="Close player"
|
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-gray-900 transition-colors rounded hover:bg-gray-200 flex-shrink-0"
|
||||||
|
aria-label="Download"
|
||||||
>
|
>
|
||||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user