feat: add framer motion for audio player, handle og images for transcripts
This commit is contained in:
@@ -7,7 +7,7 @@ import {
|
||||
getTranscription,
|
||||
getAdjacentTranscriptions,
|
||||
} from "@/lib/transcriptions";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { buildOgMeta, ogForTranscription, extractQuoteFromHl } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -18,25 +18,49 @@ import { PlayAudioButton } from "./play-audio-button";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const sp = await searchParams;
|
||||
const t = await getTranscription(slug);
|
||||
if (!t) return {};
|
||||
|
||||
const detail = [t.date, t.place].filter(Boolean).join(" · ");
|
||||
return buildOgMeta({
|
||||
title: t.name,
|
||||
detail: detail || undefined,
|
||||
quote:
|
||||
t.content.length > 0
|
||||
? t.content
|
||||
.find((b) => b.speaker === "Prabhupāda" && b.text.length > 50)
|
||||
?.text?.replace(/<[^>]+>/g, "")
|
||||
.slice(0, 200)
|
||||
: undefined,
|
||||
|
||||
// Build paragraphs from content blocks for hl matching
|
||||
const paragraphs = t.content.map((b) => {
|
||||
const speakerPrefix = b.speaker ? `${b.speaker}: ` : "";
|
||||
return speakerPrefix + b.text.replace(/<[^>]+>/g, "");
|
||||
});
|
||||
|
||||
const hlQuote = extractQuoteFromHl(sp, paragraphs);
|
||||
|
||||
if (hlQuote) {
|
||||
return buildOgMeta({
|
||||
title: t.name,
|
||||
detail: detail || undefined,
|
||||
quote: hlQuote,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: find a good Prabhupāda quote from the content
|
||||
const spQuote = t.content
|
||||
.find((b) => b.speaker === "Prabhupāda" && b.text.length > 50)
|
||||
?.text?.replace(/<[^>]+>/g, "")
|
||||
.slice(0, 200);
|
||||
|
||||
if (spQuote) {
|
||||
return buildOgMeta({
|
||||
title: t.name,
|
||||
detail: detail || undefined,
|
||||
quote: spQuote,
|
||||
});
|
||||
}
|
||||
|
||||
return ogForTranscription(t.name, detail || undefined);
|
||||
}
|
||||
|
||||
export default async function TranscriptionPage({
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useAudioPlayer, type AudioTrack } from "@/components/audio-player";
|
||||
import {
|
||||
useAudioPlayer,
|
||||
type AudioTrack,
|
||||
AudioWaveAnimation,
|
||||
} from "@/components/audio-player";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Play, Pause, Radio } from "lucide-react";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
|
||||
type PlayAudioButtonProps = {
|
||||
audioUrl: string;
|
||||
@@ -85,7 +89,11 @@ export function PlayAudioButton({
|
||||
>
|
||||
{isThisPlaying ? (
|
||||
<>
|
||||
<Radio className="size-4 animate-pulse" />
|
||||
<AudioWaveAnimation
|
||||
paused={false}
|
||||
colorClassName="bg-primary-foreground"
|
||||
size="sm"
|
||||
/>
|
||||
<span>Now Playing</span>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -3,7 +3,7 @@ export const dynamic = "force-dynamic";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { ogForTranscriptions } from "@/lib/og";
|
||||
import {
|
||||
getTranscriptionYears,
|
||||
getTranscriptionPlaces,
|
||||
@@ -14,12 +14,7 @@ import { TranscriptionsNav } from "./transcriptions-nav";
|
||||
import { TranscriptionsBrowser } from "./transcriptions-browser";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return buildOgMeta({
|
||||
title: "Transcriptions",
|
||||
detail: "3,700+ lectures & conversations (1966–1977)",
|
||||
quote:
|
||||
"Transcriptions of Śrīla Prabhupāda's lectures, morning walks, room conversations, and dialogues — the living voice of the Founder-Ācārya preserved in text.",
|
||||
});
|
||||
return ogForTranscriptions();
|
||||
}
|
||||
|
||||
export default async function TranscriptionsPage({
|
||||
|
||||
@@ -17,8 +17,8 @@ export async function generateMetadata({
|
||||
const transcriptions = await getTranscriptionsByYear(year);
|
||||
return buildOgMeta({
|
||||
title: "Transcriptions",
|
||||
detail: `${transcriptions.length} transcriptions from ${year}`,
|
||||
quote: `Lectures and conversations of Śrīla Prabhupāda from ${year}.`,
|
||||
detail: `${transcriptions.length} lectures & conversations from ${year}`,
|
||||
quote: `Lectures and conversations of Śrīla Prabhupāda from ${year} — the living voice of the Founder-Ācārya preserved in text.`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Animated audio wave bars — used in the minimized player pill
|
||||
* and the "Now Playing" button.
|
||||
*
|
||||
* @param paused – when true the bars flatten to a static state
|
||||
* @param colorClassName – Tailwind color class for the bars (default: "bg-current")
|
||||
* @param className – additional classes on the container
|
||||
* @param barCount – number of bars (default: 4)
|
||||
* @param size – "sm" (10px tall) | "md" (12px tall, default) | "lg" (16px tall)
|
||||
*/
|
||||
export function AudioWaveAnimation({
|
||||
paused = false,
|
||||
colorClassName = "bg-current",
|
||||
className,
|
||||
barCount = 4,
|
||||
size = "md",
|
||||
}: {
|
||||
paused?: boolean;
|
||||
colorClassName?: string;
|
||||
className?: string;
|
||||
barCount?: number;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}) {
|
||||
const config = {
|
||||
sm: { height: 10, minH: 2, barW: "w-[2px]", containerH: "h-2.5" },
|
||||
md: { height: 12, minH: 3, barW: "w-[3px]", containerH: "h-3" },
|
||||
lg: { height: 16, minH: 3, barW: "w-[3px]", containerH: "h-4" },
|
||||
}[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-end gap-[2px]", config.containerH, className)}
|
||||
>
|
||||
{Array.from({ length: barCount }, (_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className={cn(config.barW, "rounded-full", colorClassName)}
|
||||
animate={{
|
||||
height: paused
|
||||
? config.minH
|
||||
: [config.minH, config.height, config.minH],
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
repeat: paused ? 0 : Infinity,
|
||||
repeatType: "mirror",
|
||||
ease: "easeInOut",
|
||||
delay: i * 0.15,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useCallback, useEffect } from "react";
|
||||
import { useRef, useCallback, useEffect, 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 } from "framer-motion";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
@@ -18,6 +20,65 @@ import {
|
||||
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 useMobileMinimize(isMobile: boolean, hasTrack: boolean) {
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | 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(() => {
|
||||
if (!isMobile) return;
|
||||
setMinimized(false);
|
||||
startTimer();
|
||||
}, [isMobile, startTimer]);
|
||||
|
||||
// Start timer when track begins or mobile state changes
|
||||
useEffect(() => {
|
||||
if (isMobile && hasTrack) {
|
||||
startTimer();
|
||||
} else {
|
||||
clearTimer();
|
||||
setMinimized(false);
|
||||
}
|
||||
return clearTimer;
|
||||
}, [isMobile, hasTrack, startTimer, clearTimer]);
|
||||
|
||||
return { minimized, setMinimized, resetTimer } as const;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -230,6 +291,30 @@ export function FloatingPlayer() {
|
||||
} = useAudioPlayer();
|
||||
|
||||
const playerRef = useRef<HTMLDivElement>(null);
|
||||
const [selectionToolbarVisible, setSelectionToolbarVisible] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const { minimized, setMinimized, resetTimer } = useMobileMinimize(
|
||||
isMobile,
|
||||
!!currentTrack,
|
||||
);
|
||||
|
||||
// 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(() => {
|
||||
@@ -240,47 +325,65 @@ export function FloatingPlayer() {
|
||||
};
|
||||
}, [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);
|
||||
|
||||
// Keyboard shortcuts when player is focused
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
togglePlayPause();
|
||||
iTogglePlayPause();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
seekTo(Math.max(0, currentTime - 10));
|
||||
iSeekTo(Math.max(0, currentTime - 10));
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
seekTo(Math.min(duration, currentTime + 10));
|
||||
iSeekTo(Math.min(duration, currentTime + 10));
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setVolume(Math.min(1, volume + 0.05));
|
||||
iSetVolume(Math.min(1, volume + 0.05));
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setVolume(Math.max(0, volume - 0.05));
|
||||
iSetVolume(Math.max(0, volume - 0.05));
|
||||
break;
|
||||
case "m":
|
||||
case "M":
|
||||
e.preventDefault();
|
||||
toggleMute();
|
||||
iToggleMute();
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
stop();
|
||||
iStop();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
togglePlayPause,
|
||||
seekTo,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
stop,
|
||||
iTogglePlayPause,
|
||||
iSeekTo,
|
||||
iSetVolume,
|
||||
iToggleMute,
|
||||
iStop,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
@@ -292,269 +395,359 @@ export function FloatingPlayer() {
|
||||
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 (
|
||||
<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">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{isMobile && minimized ? (
|
||||
<motion.button
|
||||
key="mini-pill"
|
||||
data-audio-player
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMinimized(false);
|
||||
resetTimer();
|
||||
}}
|
||||
aria-label="Expand audio player"
|
||||
initial={{ opacity: 0, scale: 0.5, bottom: -60 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
bottom: selectionToolbarVisible ? 90 : 24,
|
||||
}}
|
||||
exit={{ opacity: 0, scale: 0.5, bottom: -60 }}
|
||||
transition={{ type: "spring", damping: 20, stiffness: 400 }}
|
||||
className={cn(
|
||||
"fixed z-40 right-4",
|
||||
"size-14 rounded-full",
|
||||
"shadow-2xl shadow-black/30",
|
||||
"overflow-hidden",
|
||||
"ring-2 ring-white/20",
|
||||
)}
|
||||
>
|
||||
{/* Cover image */}
|
||||
<div className="size-12 rounded-lg overflow-hidden bg-muted shrink-0 relative">
|
||||
<Image
|
||||
src={coverSrc}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
</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>
|
||||
{/* ── 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>
|
||||
|
||||
<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>
|
||||
{/* Center: Transport controls */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 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",
|
||||
<TransportButton
|
||||
onClick={iNext}
|
||||
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={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>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={iStop}
|
||||
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>
|
||||
|
||||
{/* 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"
|
||||
{/* ── Mobile layout ── */}
|
||||
<div
|
||||
className="flex md:hidden flex-col px-4 py-3 gap-2.5"
|
||||
onPointerDown={resetTimer}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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>
|
||||
|
||||
{/* ── 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>
|
||||
|
||||
{/* 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
|
||||
/>
|
||||
{/* 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={iStop}
|
||||
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={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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</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>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { AudioPlayerProvider, useAudioPlayer } from "./audio-context";
|
||||
export type { AudioTrack } from "./audio-context";
|
||||
export { FloatingPlayer } from "./floating-player";
|
||||
export { AudioWaveAnimation } from "./audio-wave";
|
||||
|
||||
Generated
-11440
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"mongodb": "^7.1.1",
|
||||
"next": "16.2.2",
|
||||
|
||||
Reference in New Issue
Block a user