"use client"; import { useRef, useCallback, useEffect } from "react"; import { useAudioPlayer } from "./audio-context"; import { cn } from "@/lib/utils"; import Link from "next/link"; import Image from "next/image"; import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Volume1, X, ExternalLink, Loader2, } from "lucide-react"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const FALLBACK_COVER = "/images/cover.png"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function formatTime(seconds: number): string { if (!isFinite(seconds) || seconds < 0) return "0:00"; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; return `${m}:${String(s).padStart(2, "0")}`; } function VolumeIcon({ volume, isMuted, className, }: { volume: number; isMuted: boolean; className?: string; }) { if (isMuted || volume === 0) return ; if (volume < 0.5) return ; return ; } // --------------------------------------------------------------------------- // Progress Bar // --------------------------------------------------------------------------- function ProgressBar({ currentTime, duration, onSeek, thin = false, }: { currentTime: number; duration: number; onSeek: (time: number) => void; thin?: boolean; }) { const barRef = useRef(null); const draggingRef = useRef(false); const progress = duration > 0 ? (currentTime / duration) * 100 : 0; const calculateSeekTime = useCallback( (clientX: number) => { const bar = barRef.current; if (!bar || duration <= 0) return; const rect = bar.getBoundingClientRect(); const fraction = Math.max( 0, Math.min(1, (clientX - rect.left) / rect.width), ); onSeek(fraction * duration); }, [duration, onSeek], ); const handlePointerDown = useCallback( (e: React.PointerEvent) => { e.preventDefault(); draggingRef.current = true; (e.target as HTMLElement).setPointerCapture(e.pointerId); calculateSeekTime(e.clientX); }, [calculateSeekTime], ); const handlePointerMove = useCallback( (e: React.PointerEvent) => { if (!draggingRef.current) return; calculateSeekTime(e.clientX); }, [calculateSeekTime], ); const handlePointerUp = useCallback(() => { draggingRef.current = false; }, []); if (thin) { return ( ); } return ( {formatTime(currentTime)} {formatTime(duration)} ); } // --------------------------------------------------------------------------- // Transport Button // --------------------------------------------------------------------------- function TransportButton({ onClick, disabled = false, label, size = "default", children, className, }: { onClick: () => void; disabled?: boolean; label: string; size?: "default" | "large"; children: React.ReactNode; className?: string; }) { return ( {children} ); } // --------------------------------------------------------------------------- // Floating Player // --------------------------------------------------------------------------- export function FloatingPlayer() { const { currentTrack, isPlaying, currentTime, duration, volume, isMuted, isLoading, playlist, currentIndex, togglePlayPause, stop, seekTo, setVolume, toggleMute, next, previous, } = useAudioPlayer(); const playerRef = useRef(null); // Add bottom padding to body when player is visible useEffect(() => { if (!currentTrack) return; document.body.style.paddingBottom = "96px"; return () => { document.body.style.paddingBottom = ""; }; }, [currentTrack]); // Keyboard shortcuts when player is focused const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case " ": e.preventDefault(); togglePlayPause(); break; case "ArrowLeft": e.preventDefault(); seekTo(Math.max(0, currentTime - 10)); break; case "ArrowRight": e.preventDefault(); seekTo(Math.min(duration, currentTime + 10)); break; case "ArrowUp": e.preventDefault(); setVolume(Math.min(1, volume + 0.05)); break; case "ArrowDown": e.preventDefault(); setVolume(Math.max(0, volume - 0.05)); break; case "m": case "M": e.preventDefault(); toggleMute(); break; case "Escape": e.preventDefault(); stop(); break; } }, [ togglePlayPause, seekTo, setVolume, toggleMute, stop, currentTime, duration, volume, ], ); if (!currentTrack) return null; const hasNext = currentIndex >= 0 && currentIndex < playlist.length - 1; const hasPrev = currentIndex > 0; const coverSrc = currentTrack.coverImage || FALLBACK_COVER; return ( {/* Thin progress bar — absolutely positioned at top edge */} {/* Time row — desktop (hidden on mobile, mobile has its own) */} {formatTime(currentTime)} {formatTime(duration)} {/* ── Desktop layout ── */} {/* Left: Cover + Track info */} {/* Cover image */} {currentTrack.title} {currentTrack.subtitle && ( {currentTrack.subtitle} )} {/* Center: Transport controls */} {isLoading ? ( ) : isPlaying ? ( ) : ( )} {/* Right: Volume + Source link + Close */} {/* Volume control */} setVolume(Number(e.target.value) / 100)} aria-label="Volume" className={cn( "w-16 h-1 appearance-none bg-white/15 rounded-full cursor-pointer", "[&::-webkit-slider-thumb]:appearance-none", "[&::-webkit-slider-thumb]:size-3", "[&::-webkit-slider-thumb]:rounded-full", "[&::-webkit-slider-thumb]:bg-white", "[&::-webkit-slider-thumb]:cursor-pointer", "[&::-moz-range-thumb]:size-3", "[&::-moz-range-thumb]:rounded-full", "[&::-moz-range-thumb]:bg-white", "[&::-moz-range-thumb]:border-0", "[&::-moz-range-thumb]:cursor-pointer", )} /> {/* Source link */} {currentTrack.sourceUrl && ( )} {/* Close */} {/* ── Mobile layout ── */} {/* Time row — right below seeker */} {formatTime(currentTime)} {formatTime(duration)} {/* Row 1: Cover + Title + Source + Close */} {/* Cover image */} {/* Track info */} {currentTrack.title} {currentTrack.subtitle && ( {currentTrack.subtitle} )} {/* Source link */} {currentTrack.sourceUrl && ( )} {/* Close */} {/* Row 2: Prev + Play/Pause + Next — centered */} {isLoading ? ( ) : isPlaying ? ( ) : ( )} ); }
{currentTrack.title}
{currentTrack.subtitle}