"use client"; import { useRef, useCallback, useEffect, useMemo, 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, useMotionValue } 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"; // Minimized pill (size-14 = 3.5rem = 56px) drag + persistence. const MINI_PILL_SIZE = 56; const MINI_EDGE_MARGIN = 16; // matches the old `right-4 bottom-6` resting state const MINI_POS_STORAGE_KEY = "spsays:audio-mini-pos"; // Past this drag distance (px), treat the gesture as "drag" and suppress // the click-to-expand that would otherwise fire on pointer-up. const MINI_DRAG_THRESHOLD = 4; type MiniPos = { x: number; y: number }; function clampMiniPos(p: MiniPos): MiniPos { if (typeof window === "undefined") return p; const maxX = window.innerWidth - MINI_PILL_SIZE - MINI_EDGE_MARGIN; const maxY = window.innerHeight - MINI_PILL_SIZE - MINI_EDGE_MARGIN; return { x: Math.max(MINI_EDGE_MARGIN, Math.min(maxX, p.x)), y: Math.max(MINI_EDGE_MARGIN, Math.min(maxY, p.y)), }; } function defaultMiniPos(): MiniPos { // Resting position when nothing's been persisted yet: bottom-right // corner, matching the old static layout (`right-4 bottom-6`). if (typeof window === "undefined") return { x: 0, y: 0 }; return { x: window.innerWidth - MINI_PILL_SIZE - MINI_EDGE_MARGIN, y: window.innerHeight - MINI_PILL_SIZE - 24, }; } function loadMiniPos(): MiniPos | null { if (typeof window === "undefined") return null; try { const raw = localStorage.getItem(MINI_POS_STORAGE_KEY); if (!raw) return null; const p = JSON.parse(raw); if (typeof p?.x !== "number" || typeof p?.y !== "number") return null; return clampMiniPos(p); } catch { return null; } } function saveMiniPos(p: MiniPos) { if (typeof window === "undefined") return; try { localStorage.setItem(MINI_POS_STORAGE_KEY, JSON.stringify(p)); } catch { // Storage may be unavailable (Safari private mode, quota). Drop the // write silently — drag-and-position still works for the session. } } // --------------------------------------------------------------------------- // 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); // Minimized pill position lives in motion values so framer's `drag` // can mutate them directly without round-tripping through React state. // Initial position is computed once (lazy useMemo) — the player only // renders when a track is playing, which is always after hydration, so // window access is safe here. const initialMiniPos = useMemo( () => loadMiniPos() ?? defaultMiniPos(), [], ); const miniX = useMotionValue(initialMiniPos.x); const miniY = useMotionValue(initialMiniPos.y); // Drag constraints — recomputed each render so a window resize between // renders gets picked up the next time the user touches the pill. const [miniBounds, setMiniBounds] = useState<{ top: number; left: number; right: number; bottom: number; }>(() => { if (typeof window === "undefined") { return { top: 0, left: 0, right: 0, bottom: 0 }; } return { top: MINI_EDGE_MARGIN, left: MINI_EDGE_MARGIN, right: window.innerWidth - MINI_PILL_SIZE - MINI_EDGE_MARGIN, bottom: window.innerHeight - MINI_PILL_SIZE - MINI_EDGE_MARGIN, }; }); // Re-clamp on window resize so a saved position from a wider viewport // doesn't strand the pill off-screen after rotation / DevTools open. useEffect(() => { function onResize() { setMiniBounds({ top: MINI_EDGE_MARGIN, left: MINI_EDGE_MARGIN, right: window.innerWidth - MINI_PILL_SIZE - MINI_EDGE_MARGIN, bottom: window.innerHeight - MINI_PILL_SIZE - MINI_EDGE_MARGIN, }); const clamped = clampMiniPos({ x: miniX.get(), y: miniY.get() }); miniX.set(clamped.x); miniY.set(clamped.y); saveMiniPos(clamped); } window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, [miniX, miniY]); // Tracks whether the most-recent pointer interaction was a drag (vs a // tap) so the click handler can suppress expand-on-drag-end. const miniWasDraggedRef = useRef(false); // 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 ? ( { // Suppress the click that fires at the end of every drag — // only an actual tap should expand the player. if (miniWasDraggedRef.current) { miniWasDraggedRef.current = false; return; } setMinimized(false); resetTimer(); }} aria-label="Expand audio player" drag dragMomentum={false} dragElastic={0} dragConstraints={miniBounds} onDragStart={() => { miniWasDraggedRef.current = false; }} onDragEnd={(_, info) => { // Distinguish a real drag from the small jitter that a tap // sometimes produces — only the former should suppress click. if ( Math.abs(info.offset.x) > MINI_DRAG_THRESHOLD || Math.abs(info.offset.y) > MINI_DRAG_THRESHOLD ) { miniWasDraggedRef.current = true; } const clamped = clampMiniPos({ x: miniX.get(), y: miniY.get(), }); miniX.set(clamped.x); miniY.set(clamped.y); saveMiniPos(clamped); }} initial={{ opacity: 0, scale: 0.5 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.5 }} transition={{ type: "spring", damping: 20, stiffness: 400 }} style={{ // Position purely via the motion-value transform so framer's // drag controller mutates the same channel that paints it. // `top: 0; left: 0` anchors the transform origin to the // viewport corner; `x`/`y` then carry the pill anywhere. position: "fixed", top: 0, left: 0, x: miniX, y: miniY, }} className={cn( "z-40", "size-14 rounded-full", "shadow-2xl shadow-black/30", "overflow-hidden", "ring-2 ring-white/20", // Grab affordance + touch-action: none so the browser doesn't // intercept touch as a page scroll and starve the drag. "cursor-grab active:cursor-grabbing touch-none", )} > {/* 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 ? ( ) : ( )}
)} ); }