"use client"; import { useRef, useCallback, useEffect, useState } from "react"; import { useAudioPlayer } from "./audio-context"; import { AudioWaveAnimation } from "./audio-wave"; import { cn } from "@/lib/utils"; import Link from "next/link"; import Image from "next/image"; import { motion, AnimatePresence } from "framer-motion"; import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Volume1, X, ExternalLink, Loader2, } from "lucide-react"; // --------------------------------------------------------------------------- // Mobile auto-minimize hook // --------------------------------------------------------------------------- const MINIMIZE_DELAY = 8000; function useIsMobile() { const [mobile, setMobile] = useState(false); useEffect(() => { const mq = window.matchMedia("(max-width: 767px)"); setMobile(mq.matches); const handler = (e: MediaQueryListEvent) => setMobile(e.matches); mq.addEventListener("change", handler); return () => mq.removeEventListener("change", handler); }, []); return mobile; } /** * Returns [minimized, setMinimized, resetTimer]. * Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity on mobile. * Call `resetTimer()` on any user interaction to restart the countdown. */ function useAutoMinimize(hasTrack: boolean) { const [minimized, setMinimized] = useState(false); const timerRef = useRef | null>(null); const clearTimer = useCallback(() => { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } }, []); const startTimer = useCallback(() => { clearTimer(); timerRef.current = setTimeout(() => setMinimized(true), MINIMIZE_DELAY); }, [clearTimer]); const resetTimer = useCallback(() => { setMinimized(false); startTimer(); }, [startTimer]); // Start timer when track begins useEffect(() => { if (hasTrack) { startTimer(); } else { clearTimer(); setMinimized(false); } return clearTimer; }, [hasTrack, startTimer, clearTimer]); return { minimized, setMinimized, resetTimer } as const; } // --------------------------------------------------------------------------- // 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 ( ); } // --------------------------------------------------------------------------- // 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); const [selectionToolbarVisible, setSelectionToolbarVisible] = useState(false); const isMobile = useIsMobile(); const { minimized, setMinimized, resetTimer } = useAutoMinimize(!!currentTrack); // Watch for the selection toolbar appearing/disappearing in the DOM useEffect(() => { if (!currentTrack) return; const check = () => { const toolbar = document.querySelector("[data-selection-toolbar]"); // The toolbar wrapper is always present; check if it has visible children setSelectionToolbarVisible(!!toolbar && toolbar.childElementCount > 0); }; check(); const observer = new MutationObserver(check); observer.observe(document.body, { childList: true, subtree: true }); return () => observer.disconnect(); }, [currentTrack]); // Add bottom padding to body when player is visible useEffect(() => { if (!currentTrack) return; document.body.style.paddingBottom = "96px"; return () => { document.body.style.paddingBottom = ""; }; }, [currentTrack]); // Wrap interactions to reset the minimize timer const interact = useCallback( (fn: (...args: T) => void) => (...args: T) => { resetTimer(); fn(...args); }, [resetTimer], ); const iTogglePlayPause = interact(togglePlayPause); const iSeekTo = interact(seekTo); const iSetVolume = interact(setVolume); const iToggleMute = interact(toggleMute); const iNext = interact(next); const iPrevious = interact(previous); const iStop = interact(stop); // Minimize when clicking/tapping outside the player useEffect(() => { if (!currentTrack || minimized) return; function handleOutsideClick(e: PointerEvent) { const player = playerRef.current; if (!player) return; if (player.contains(e.target as Node)) return; setMinimized(true); } document.addEventListener("pointerdown", handleOutsideClick); return () => document.removeEventListener("pointerdown", handleOutsideClick); }, [currentTrack, minimized, setMinimized]); // Keyboard shortcuts when player is focused const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case " ": e.preventDefault(); iTogglePlayPause(); break; case "ArrowLeft": e.preventDefault(); iSeekTo(Math.max(0, currentTime - 10)); break; case "ArrowRight": e.preventDefault(); iSeekTo(Math.min(duration, currentTime + 10)); break; case "ArrowUp": e.preventDefault(); iSetVolume(Math.min(1, volume + 0.05)); break; case "ArrowDown": e.preventDefault(); iSetVolume(Math.max(0, volume - 0.05)); break; case "m": case "M": e.preventDefault(); iToggleMute(); break; case "Escape": e.preventDefault(); iStop(); break; } }, [ iTogglePlayPause, iSeekTo, iSetVolume, iToggleMute, iStop, 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; const progress = duration > 0 ? (currentTime / duration) * 100 : 0; // ── Hide entirely when selection toolbar is active ── if (selectionToolbarVisible) return null; // ── Render ── return ( {minimized ? ( { setMinimized(false); resetTimer(); }} aria-label="Expand audio player" initial={{ opacity: 0, scale: 0.5, bottom: -60 }} animate={{ opacity: 1, scale: 1, bottom: selectionToolbarVisible ? 90 : 24, }} exit={{ opacity: 0, scale: 0.5, bottom: -60 }} transition={{ type: "spring", damping: 20, stiffness: 400 }} className={cn( "fixed z-40 right-4", "size-14 rounded-full", "shadow-2xl shadow-black/30", "overflow-hidden", "ring-2 ring-white/20", )} > {/* Cover image */} {/* Dark overlay */}
{/* Circular progress ring */} {/* Audio wave indicator */}
) : ( {/* 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 */}
iSetVolume(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 && ( )} {/* Minimize (playing) or Close (paused) */}
{/* ── 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 && ( )} {/* Minimize (playing) or Close (paused) */}
{/* Row 2: Prev + Play/Pause + Next — centered */}
{isLoading ? ( ) : isPlaying ? ( ) : ( )}
)} ); }