Files
spsays-training/components/audio-player/floating-player.tsx
T

561 lines
17 KiB
TypeScript

"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 <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) {
return (
<div
ref={barRef}
className="w-full h-2 bg-white/15 cursor-pointer shrink-0 relative"
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 transition-[width] duration-100 ease-linear"
style={{ width: `${progress}%` }}
/>
</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>
);
}
// ---------------------------------------------------------------------------
// 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);
// 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 (
<div
ref={playerRef}
data-audio-player
role="region"
aria-label="Audio player"
tabIndex={0}
onKeyDown={handleKeyDown}
className={cn(
// Centered floating bar
"fixed z-40 bottom-5 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",
// Animation
"animate-in fade-in-0 slide-in-from-bottom-4 duration-300",
// Clip seeker to rounded top corners
"overflow-hidden",
)}
>
{/* Thin progress bar — absolutely positioned at top edge */}
<ProgressBar
currentTime={currentTime}
duration={duration}
onSeek={seekTo}
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 */}
<div className="flex items-center gap-1.5">
<TransportButton
onClick={previous}
disabled={!hasPrev && currentTime <= 3}
label="Previous track"
className="text-white/60 hover:text-white hover:bg-white/10"
>
<SkipBack className="size-4" />
</TransportButton>
<TransportButton
onClick={togglePlayPause}
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>
<TransportButton
onClick={next}
disabled={!hasNext}
label="Next track"
className="text-white/60 hover:text-white hover:bg-white/10"
>
<SkipForward className="size-4" />
</TransportButton>
</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={toggleMute}
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) => 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",
)}
/>
</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>
)}
{/* Close */}
<button
type="button"
onClick={stop}
aria-label="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">
{/* 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>
)}
{/* Close */}
<button
type="button"
onClick={stop}
aria-label="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: Prev + Play/Pause + Next — centered */}
<div className="flex items-center justify-center gap-4 py-1">
<TransportButton
onClick={previous}
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>
<TransportButton
onClick={togglePlayPause}
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>
<TransportButton
onClick={next}
disabled={!hasNext}
label="Next track"
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
>
<SkipForward className="size-5" />
</TransportButton>
</div>
</div>
</div>
);
}