"use client"; import { useRef, useCallback, useEffect, useMemo, useState, useSyncExternalStore, } 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, RotateCcw, RotateCw, Volume2, VolumeX, Volume1, X, ExternalLink, Loader2, } from "lucide-react"; // --------------------------------------------------------------------------- // Mobile auto-minimize hook // --------------------------------------------------------------------------- const MINIMIZE_DELAY = 8000; /** * `useSyncExternalStore` is the React-recommended way to expose values * from external systems (window, media-queries, etc.) without a * mirror-into-state effect. The previous useEffect-+-setState version * tripped React 19's "setState synchronously inside an effect" warning * — this variant has no effect at all. * * On the server we default to false (desktop). The player only mounts * when a track is playing, which is always after hydration, so the * initial-paint value never matters for SSR markup. */ const MOBILE_QUERY = "(max-width: 767px)"; function subscribeMobile(callback: () => void): () => void { const mq = window.matchMedia(MOBILE_QUERY); mq.addEventListener("change", callback); return () => mq.removeEventListener("change", callback); } function getMobileSnapshot(): boolean { return window.matchMedia(MOBILE_QUERY).matches; } function getMobileServerSnapshot(): boolean { return false; } function useIsMobile() { return useSyncExternalStore( subscribeMobile, getMobileSnapshot, getMobileServerSnapshot, ); } /** * Returns [minimized, setMinimized, resetTimer]. * Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity. Call * `resetTimer()` on any user interaction to restart the countdown. * * The `hasTrack`-driven reset of `minimized` is done via the * "adjust state during render based on previous prop" pattern from the * React docs — not via a setState-in-effect, which would trip the * cascading-render warning. The setTimeout's setMinimized(true) is * fine because it fires asynchronously (the rule is about *synchronous* * setState during the effect body). */ function useAutoMinimize(hasTrack: boolean) { const [minimized, setMinimized] = useState(false); const [lastHasTrack, setLastHasTrack] = useState(hasTrack); const timerRef = useRef | null>(null); // Reset `minimized` when hasTrack transitions. Done during render — // no effect required, and the two setState calls only fire when the // comparison actually flips. if (hasTrack !== lastHasTrack) { setLastHasTrack(hasTrack); setMinimized(false); } const clearTimer = useCallback(() => { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } }, []); const startTimer = useCallback(() => { clearTimer(); timerRef.current = setTimeout(() => setMinimized(true), MINIMIZE_DELAY); }, [clearTimer]); // Drive the timer from hasTrack. The effect body itself only calls // start/clear (which schedule, not mutate state); the actual // `setMinimized(true)` lives in the timer callback and fires // asynchronously — outside the effect body, so the rule doesn't apply. useEffect(() => { if (hasTrack) { startTimer(); } else { clearTimer(); } return clearTimer; }, [hasTrack, startTimer, clearTimer]); const resetTimer = useCallback(() => { setMinimized(false); startTimer(); }, [startTimer]); 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) { // Layout, top to bottom: // // outer px-3 pt-2 — gutter so the size-3 thumb at progress=0% // and 100% sits clear of the card's rounded // corners (radius 16px → 6px thumb half-width // fits inside the curve at every y). // inner h-4 — the bar's click/touch target. The visible // h-1.5 bar is centred inside it, and the // thumb is absolute on top. // // The card's overflow-hidden stays — nothing protrudes past the // gutter, so nothing gets clipped. 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 ( ); } // --------------------------------------------------------------------------- // Seek-by-5s Button // --------------------------------------------------------------------------- /** * Quick ±5s seek button — circular-arrow icon with a small "5" overlay * (the standard "skip 5 seconds" affordance in modern audio players). * Lives right beside Play/Pause and is the primary navigation control * within a track now that the prev/next-track buttons are hidden in * single-track contexts (which is currently every context — no caller * passes a real playlist). */ function SeekFiveButton({ direction, onClick, className, }: { direction: "back" | "forward"; onClick: () => void; className?: string; }) { const Icon = direction === "back" ? RotateCcw : RotateCw; const label = direction === "back" ? "Skip back 5 seconds" : "Skip forward 5 seconds"; 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; // Prev/Next-track buttons only make sense when there's an actual // playlist with more than one entry. Everywhere `play(track)` is // called we end up with a single-track playlist, which makes both // buttons permanently disabled — confusing dead controls. Hide them // until a caller invokes `playPlaylist(tracks)` with multiple tracks. const hasPlaylist = playlist.length > 1; const coverSrc = currentTrack.coverImage || FALLBACK_COVER; const progress = duration > 0 ? (currentTime / duration) * 100 : 0; const seekBack5 = () => iSeekTo(Math.max(0, currentTime - 5)); const seekForward5 = () => iSeekTo(Math.min(duration || currentTime + 5, currentTime + 5)); // ── 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. Order: [prev track]? -5s Play/Pause +5s [next track]? Prev/next only render when a real playlist is active. */}
{hasPlaylist ? ( ) : null} {isLoading ? ( ) : isPlaying ? ( ) : ( )} {hasPlaylist ? ( ) : null}
{/* 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 — centered transport. Order mirrors the desktop: [prev]? -5s Play/Pause +5s [next]? Prev/next render only when a real playlist is active. */}
{hasPlaylist ? ( ) : null} {isLoading ? ( ) : isPlaying ? ( ) : ( )} {hasPlaylist ? ( ) : null}
)} ); }