139 lines
5 KiB
TypeScript
139 lines
5 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useCallback } from "react";
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import {
|
|
IoPlayOutline,
|
|
IoPauseOutline,
|
|
IoPlaySkipForwardOutline,
|
|
IoPlaySkipBackOutline,
|
|
IoCloseOutline,
|
|
} from "react-icons/io5";
|
|
import { IconButton } from "@/components/IconButton";
|
|
import { usePlayer } from "./PlayerContext";
|
|
import { urlFor } from "@/lib/sanityImage";
|
|
|
|
function formatTime(seconds: number): string {
|
|
if (!seconds || !isFinite(seconds)) return "0:00";
|
|
const m = Math.floor(seconds / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
export function Player() {
|
|
const {
|
|
playlist,
|
|
currentIndex,
|
|
isPlaying,
|
|
currentTime,
|
|
audioDuration,
|
|
togglePlayback,
|
|
next,
|
|
previous,
|
|
seekTo,
|
|
stop,
|
|
} = usePlayer();
|
|
|
|
const progressRef = useRef<HTMLDivElement>(null);
|
|
|
|
const track = currentIndex !== null ? playlist[currentIndex] : null;
|
|
const hasPrev = currentIndex !== null && currentIndex > 0;
|
|
const hasNext = currentIndex !== null && currentIndex < playlist.length - 1;
|
|
const displayDuration = track?.trackDuration || audioDuration;
|
|
const progress = audioDuration > 0 ? currentTime / audioDuration : 0;
|
|
|
|
const handleProgressClick = useCallback(
|
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
const bar = progressRef.current;
|
|
if (!bar) return;
|
|
const rect = bar.getBoundingClientRect();
|
|
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
seekTo(fraction);
|
|
},
|
|
[seekTo],
|
|
);
|
|
|
|
const coverUrl = track?.albumCover ? urlFor(track.albumCover).width(96).height(96).url() : null;
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{track && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ type: "tween", ease: "easeInOut", duration: 0.3 }}
|
|
className="fixed right-0 bottom-0 left-0 z-50 border-t border-lighttext/10 bg-lightbg/90 backdrop-blur-xl dark:border-darktext/10 dark:bg-darkbg/90"
|
|
>
|
|
{/* Progress bar at top edge of player */}
|
|
<div
|
|
ref={progressRef}
|
|
onClick={handleProgressClick}
|
|
className="group relative h-1 w-full cursor-pointer bg-lighttext/10 transition-[height] hover:h-2 dark:bg-darktext/10"
|
|
>
|
|
<div
|
|
className="absolute top-0 left-0 h-full bg-trptkblue dark:bg-white"
|
|
style={{ width: `${progress * 100}%` }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex max-w-full items-center gap-4 p-6 md:p-8">
|
|
{/* Album cover */}
|
|
{track.releaseSlug ? (
|
|
<Link href={`/release/${track.releaseSlug}`} className="relative hidden h-16 w-16 shrink-0 overflow-hidden rounded-lg transition-opacity hover:opacity-80 sm:block">
|
|
{coverUrl ? (
|
|
<Image src={coverUrl} alt="" fill className="object-cover" sizes="48px" />
|
|
) : (
|
|
<div className="h-full w-full bg-lighttext/10 dark:bg-darktext/10" />
|
|
)}
|
|
</Link>
|
|
) : (
|
|
<div className="relative hidden h-16 w-16 shrink-0 overflow-hidden rounded-lg sm:block">
|
|
{coverUrl ? (
|
|
<Image src={coverUrl} alt="" fill className="object-cover" sizes="48px" />
|
|
) : (
|
|
<div className="h-full w-full bg-lighttext/10 dark:bg-darktext/10" />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Track info */}
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate">
|
|
<span className="font-silkasb">{track.title}</span>
|
|
{track.movement && <span>: {track.movement}</span>}
|
|
</p>
|
|
<p className="truncate text-lightsec dark:text-darksec">{track.subtitle}</p>
|
|
</div>
|
|
|
|
{/* Time */}
|
|
<span className="hidden shrink-0 text-lightsec tabular-nums sm:block dark:text-darksec">
|
|
{formatTime(currentTime)} / {formatTime(displayDuration)}
|
|
</span>
|
|
|
|
{/* Controls */}
|
|
<div className="flex shrink-0 items-center gap-3">
|
|
<IconButton onClick={previous} disabled={!hasPrev} aria-label="Previous track">
|
|
<IoPlaySkipBackOutline size={18} />
|
|
</IconButton>
|
|
|
|
<IconButton onClick={togglePlayback} aria-label={isPlaying ? "Pause" : "Play"} className="hidden sm:block">
|
|
{isPlaying ? <IoPauseOutline size={18} /> : <IoPlayOutline size={18} />}
|
|
</IconButton>
|
|
|
|
<IconButton onClick={next} disabled={!hasNext} aria-label="Next track">
|
|
<IoPlaySkipForwardOutline size={18} />
|
|
</IconButton>
|
|
|
|
<IconButton onClick={stop} aria-label="Close player">
|
|
<IoCloseOutline size={18} />
|
|
</IconButton>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|