1073 lines
36 KiB
TypeScript
1073 lines
36 KiB
TypeScript
"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<ReturnType<typeof setTimeout> | 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 <VolumeX className={className} />;
|
|
if (volume < 0.5) return <Volume1 className={className} />;
|
|
return <Volume2 className={className} />;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Progress Bar
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function ProgressBar({
|
|
currentTime,
|
|
duration,
|
|
onSeek,
|
|
thin = false,
|
|
}: {
|
|
currentTime: number;
|
|
duration: number;
|
|
onSeek: (time: number) => void;
|
|
thin?: boolean;
|
|
}) {
|
|
const barRef = useRef<HTMLDivElement>(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 (
|
|
<div className="px-4 md:px-5 pt-2 shrink-0">
|
|
<div
|
|
ref={barRef}
|
|
className="relative w-full h-4 cursor-pointer flex items-center group/seek touch-none"
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
role="slider"
|
|
aria-label="Playback progress"
|
|
aria-valuemin={0}
|
|
aria-valuemax={duration || 0}
|
|
aria-valuenow={currentTime}
|
|
tabIndex={0}
|
|
>
|
|
<div className="w-full h-1.5 bg-white/15 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-primary transition-[width] duration-100 ease-linear"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
<div
|
|
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 size-3 bg-primary rounded-full ring-2 ring-[#1c1c1e] shadow-md pointer-events-none transition-transform group-hover/seek:scale-125"
|
|
style={{ left: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 w-full">
|
|
<span className="text-[10px] tabular-nums text-muted-foreground min-w-[32px] text-right select-none">
|
|
{formatTime(currentTime)}
|
|
</span>
|
|
<div
|
|
ref={barRef}
|
|
className="relative flex-1 h-1 bg-muted rounded-full cursor-pointer group/progress"
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
role="slider"
|
|
aria-label="Playback progress"
|
|
aria-valuemin={0}
|
|
aria-valuemax={duration || 0}
|
|
aria-valuenow={currentTime}
|
|
tabIndex={0}
|
|
>
|
|
<div
|
|
className="h-full bg-primary rounded-full transition-[width] duration-100 ease-linear"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
<div
|
|
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 size-2.5 bg-primary rounded-full opacity-0 group-hover/progress:opacity-100 transition-opacity shadow-sm pointer-events-none"
|
|
style={{ left: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-[10px] tabular-nums text-muted-foreground min-w-[32px] select-none">
|
|
{formatTime(duration)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
aria-label={label}
|
|
className={cn(
|
|
"inline-flex items-center justify-center rounded-full transition-colors outline-none",
|
|
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
|
|
"disabled:opacity-30 disabled:pointer-events-none",
|
|
size === "large"
|
|
? "size-9 bg-foreground text-background hover:bg-foreground/90"
|
|
: "size-8 text-foreground hover:bg-muted",
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-label={label}
|
|
title={label}
|
|
className={cn(
|
|
"inline-flex items-center justify-center rounded-full transition-colors outline-none",
|
|
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
|
|
className,
|
|
)}
|
|
>
|
|
<span className="relative inline-flex items-center justify-center">
|
|
<Icon className="size-5" strokeWidth={2.2} />
|
|
<span className="absolute text-[8px] font-bold leading-none translate-y-px">
|
|
5
|
|
</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<HTMLDivElement>(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(
|
|
<T extends unknown[]>(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 (
|
|
<AnimatePresence mode="popLayout">
|
|
{minimized ? (
|
|
<motion.button
|
|
key="mini-pill"
|
|
data-audio-player
|
|
type="button"
|
|
onClick={() => {
|
|
// 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 */}
|
|
<Image
|
|
src={coverSrc}
|
|
alt=""
|
|
fill
|
|
className="object-cover"
|
|
unoptimized
|
|
/>
|
|
{/* Dark overlay */}
|
|
<div className="absolute inset-0 bg-black/40" />
|
|
{/* Circular progress ring */}
|
|
<svg
|
|
className="absolute inset-0 size-full -rotate-90"
|
|
viewBox="0 0 56 56"
|
|
>
|
|
<circle
|
|
cx="28"
|
|
cy="28"
|
|
r="26"
|
|
fill="none"
|
|
stroke="white"
|
|
strokeOpacity={0.15}
|
|
strokeWidth="2"
|
|
/>
|
|
<circle
|
|
cx="28"
|
|
cy="28"
|
|
r="26"
|
|
fill="none"
|
|
stroke="white"
|
|
strokeOpacity={0.8}
|
|
strokeWidth="2"
|
|
strokeDasharray={2 * Math.PI * 26}
|
|
strokeDashoffset={2 * Math.PI * 26 * (1 - progress / 100)}
|
|
strokeLinecap="round"
|
|
className="transition-[stroke-dashoffset] duration-300"
|
|
/>
|
|
</svg>
|
|
{/* Audio wave indicator */}
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<AudioWaveAnimation
|
|
paused={!isPlaying}
|
|
colorClassName="bg-white/90"
|
|
/>
|
|
</div>
|
|
</motion.button>
|
|
) : (
|
|
<motion.div
|
|
key="full-player"
|
|
ref={playerRef}
|
|
data-audio-player
|
|
role="region"
|
|
aria-label="Audio player"
|
|
tabIndex={0}
|
|
onKeyDown={handleKeyDown}
|
|
initial={{ opacity: 0, bottom: -80 }}
|
|
animate={{
|
|
opacity: 1,
|
|
bottom: selectionToolbarVisible ? 64 : 20,
|
|
}}
|
|
exit={{ opacity: 0, bottom: -80 }}
|
|
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
|
className={cn(
|
|
// Centered floating bar
|
|
"fixed z-40 left-1/2 -translate-x-1/2",
|
|
// Narrower width
|
|
"w-[calc(100%-2rem)] max-w-[680px]",
|
|
// Forced dark surface for contrast regardless of theme
|
|
"bg-[#1c1c1e] text-white",
|
|
"border border-white/10",
|
|
"rounded-2xl",
|
|
"shadow-2xl shadow-black/25",
|
|
// Clip seeker to rounded top corners
|
|
"overflow-hidden",
|
|
)}
|
|
>
|
|
{/* Thin progress bar — absolutely positioned at top edge */}
|
|
<ProgressBar
|
|
currentTime={currentTime}
|
|
duration={duration}
|
|
onSeek={iSeekTo}
|
|
thin
|
|
/>
|
|
|
|
{/* Time row — desktop (hidden on mobile, mobile has its own) */}
|
|
<div className="hidden md:flex items-center justify-between px-5 pt-1.5">
|
|
<span className="text-[10px] tabular-nums text-white/40 select-none">
|
|
{formatTime(currentTime)}
|
|
</span>
|
|
<span className="text-[10px] tabular-nums text-white/40 select-none">
|
|
{formatTime(duration)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* ── Desktop layout ── */}
|
|
<div className="hidden md:flex items-center h-[64px] px-5 gap-5">
|
|
{/* Left: Cover + Track info */}
|
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
{/* Cover image */}
|
|
<div className="size-12 rounded-lg overflow-hidden bg-muted shrink-0 relative">
|
|
<Image
|
|
src={coverSrc}
|
|
alt=""
|
|
fill
|
|
className="object-cover"
|
|
unoptimized
|
|
/>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-white truncate leading-tight">
|
|
{currentTrack.title}
|
|
</p>
|
|
{currentTrack.subtitle && (
|
|
<p className="text-[11px] text-white/50 truncate leading-tight mt-0.5">
|
|
{currentTrack.subtitle}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Center: Transport controls. Order:
|
|
[prev track]? -5s Play/Pause +5s [next track]?
|
|
Prev/next only render when a real playlist is active. */}
|
|
<div className="flex items-center gap-1.5">
|
|
{hasPlaylist ? (
|
|
<TransportButton
|
|
onClick={iPrevious}
|
|
disabled={!hasPrev && currentTime <= 3}
|
|
label="Previous track"
|
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
|
>
|
|
<SkipBack className="size-4" />
|
|
</TransportButton>
|
|
) : null}
|
|
|
|
<SeekFiveButton
|
|
direction="back"
|
|
onClick={seekBack5}
|
|
className="size-8 text-white/70 hover:text-white hover:bg-white/10"
|
|
/>
|
|
|
|
<TransportButton
|
|
onClick={iTogglePlayPause}
|
|
label={isPlaying ? "Pause" : "Play"}
|
|
size="large"
|
|
className="bg-white text-black hover:bg-white/90"
|
|
>
|
|
{isLoading ? (
|
|
<Loader2 className="size-4 animate-spin" />
|
|
) : isPlaying ? (
|
|
<Pause className="size-4" />
|
|
) : (
|
|
<Play className="size-4 ml-0.5" />
|
|
)}
|
|
</TransportButton>
|
|
|
|
<SeekFiveButton
|
|
direction="forward"
|
|
onClick={seekForward5}
|
|
className="size-8 text-white/70 hover:text-white hover:bg-white/10"
|
|
/>
|
|
|
|
{hasPlaylist ? (
|
|
<TransportButton
|
|
onClick={iNext}
|
|
disabled={!hasNext}
|
|
label="Next track"
|
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
|
>
|
|
<SkipForward className="size-4" />
|
|
</TransportButton>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Right: Volume + Source link + Close */}
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
{/* Volume control */}
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={iToggleMute}
|
|
aria-label={isMuted ? "Unmute" : "Mute"}
|
|
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<VolumeIcon
|
|
volume={volume}
|
|
isMuted={isMuted}
|
|
className="size-4"
|
|
/>
|
|
</button>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
value={isMuted ? 0 : Math.round(volume * 100)}
|
|
onChange={(e) => 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",
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Source link */}
|
|
{currentTrack.sourceUrl && (
|
|
<Link
|
|
href={currentTrack.sourceUrl}
|
|
aria-label="Go to source page"
|
|
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors"
|
|
>
|
|
<ExternalLink className="size-3.5" />
|
|
</Link>
|
|
)}
|
|
|
|
{/* Minimize (playing) or Close (paused) */}
|
|
<button
|
|
type="button"
|
|
onClick={isPlaying ? () => setMinimized(true) : iStop}
|
|
aria-label={isPlaying ? "Minimize player" : "Close player"}
|
|
className="size-7 inline-flex items-center justify-center rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Mobile layout ── */}
|
|
<div
|
|
className="flex md:hidden flex-col px-4 py-3 gap-2.5"
|
|
onPointerDown={resetTimer}
|
|
>
|
|
{/* Time row — right below seeker */}
|
|
<div className="flex items-center justify-between px-0.5">
|
|
<span className="text-[10px] tabular-nums text-white/40 select-none">
|
|
{formatTime(currentTime)}
|
|
</span>
|
|
<span className="text-[10px] tabular-nums text-white/40 select-none">
|
|
{formatTime(duration)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Row 1: Cover + Title + Source + Close */}
|
|
<div className="flex items-center gap-3">
|
|
{/* Cover image */}
|
|
<div className="size-10 rounded-lg overflow-hidden bg-muted shrink-0 relative">
|
|
<Image
|
|
src={coverSrc}
|
|
alt=""
|
|
fill
|
|
className="object-cover"
|
|
unoptimized
|
|
/>
|
|
</div>
|
|
|
|
{/* Track info */}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-white truncate leading-tight">
|
|
{currentTrack.title}
|
|
</p>
|
|
{currentTrack.subtitle && (
|
|
<p className="text-[11px] text-white/50 truncate leading-tight mt-0.5">
|
|
{currentTrack.subtitle}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Source link */}
|
|
{currentTrack.sourceUrl && (
|
|
<Link
|
|
href={currentTrack.sourceUrl}
|
|
aria-label="Go to source page"
|
|
className="size-8 inline-flex items-center justify-center rounded-full text-white/50 hover:text-white hover:bg-white/10 transition-colors shrink-0"
|
|
>
|
|
<ExternalLink className="size-4" />
|
|
</Link>
|
|
)}
|
|
|
|
{/* Minimize (playing) or Close (paused) */}
|
|
<button
|
|
type="button"
|
|
onClick={isPlaying ? () => setMinimized(true) : iStop}
|
|
aria-label={isPlaying ? "Minimize player" : "Close player"}
|
|
className="size-8 inline-flex items-center justify-center rounded-full text-white/50 hover:text-white hover:bg-white/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Row 2 — centered transport. Order mirrors the desktop:
|
|
[prev]? -5s Play/Pause +5s [next]?
|
|
Prev/next render only when a real playlist is active. */}
|
|
<div className="flex items-center justify-center gap-3 py-1">
|
|
{hasPlaylist ? (
|
|
<TransportButton
|
|
onClick={iPrevious}
|
|
disabled={!hasPrev && currentTime <= 3}
|
|
label="Previous track"
|
|
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
|
|
>
|
|
<SkipBack className="size-5" />
|
|
</TransportButton>
|
|
) : null}
|
|
|
|
<SeekFiveButton
|
|
direction="back"
|
|
onClick={seekBack5}
|
|
className="size-10 text-white/70 hover:text-white hover:bg-white/10"
|
|
/>
|
|
|
|
<TransportButton
|
|
onClick={iTogglePlayPause}
|
|
label={isPlaying ? "Pause" : "Play"}
|
|
size="large"
|
|
className="bg-white text-black hover:bg-white/90 !size-12"
|
|
>
|
|
{isLoading ? (
|
|
<Loader2 className="size-5 animate-spin" />
|
|
) : isPlaying ? (
|
|
<Pause className="size-5" />
|
|
) : (
|
|
<Play className="size-5 ml-0.5" />
|
|
)}
|
|
</TransportButton>
|
|
|
|
<SeekFiveButton
|
|
direction="forward"
|
|
onClick={seekForward5}
|
|
className="size-10 text-white/70 hover:text-white hover:bg-white/10"
|
|
/>
|
|
|
|
{hasPlaylist ? (
|
|
<TransportButton
|
|
onClick={iNext}
|
|
disabled={!hasNext}
|
|
label="Next track"
|
|
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
|
|
>
|
|
<SkipForward className="size-5" />
|
|
</TransportButton>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|