feat: add framer motion for audio player, handle og images for transcripts

This commit is contained in:
gosangam
2026-05-09 11:59:48 +05:30
parent 9eb2a06b50
commit 326ba090ca
10 changed files with 1187 additions and 11826 deletions
+35 -11
View File
@@ -7,7 +7,7 @@ import {
getTranscription, getTranscription,
getAdjacentTranscriptions, getAdjacentTranscriptions,
} from "@/lib/transcriptions"; } from "@/lib/transcriptions";
import { buildOgMeta } from "@/lib/og"; import { buildOgMeta, ogForTranscription, extractQuoteFromHl } from "@/lib/og";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -18,25 +18,49 @@ import { PlayAudioButton } from "./play-audio-button";
export async function generateMetadata({ export async function generateMetadata({
params, params,
searchParams,
}: { }: {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}): Promise<Metadata> { }): Promise<Metadata> {
const { slug } = await params; const { slug } = await params;
const sp = await searchParams;
const t = await getTranscription(slug); const t = await getTranscription(slug);
if (!t) return {}; if (!t) return {};
const detail = [t.date, t.place].filter(Boolean).join(" · "); const detail = [t.date, t.place].filter(Boolean).join(" · ");
return buildOgMeta({
title: t.name, // Build paragraphs from content blocks for hl matching
detail: detail || undefined, const paragraphs = t.content.map((b) => {
quote: const speakerPrefix = b.speaker ? `${b.speaker}: ` : "";
t.content.length > 0 return speakerPrefix + b.text.replace(/<[^>]+>/g, "");
? t.content
.find((b) => b.speaker === "Prabhupāda" && b.text.length > 50)
?.text?.replace(/<[^>]+>/g, "")
.slice(0, 200)
: undefined,
}); });
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({ export default async function TranscriptionPage({
@@ -1,8 +1,12 @@
"use client"; "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 { cn } from "@/lib/utils";
import { Play, Pause, Radio } from "lucide-react"; import { Play, Pause } from "lucide-react";
type PlayAudioButtonProps = { type PlayAudioButtonProps = {
audioUrl: string; audioUrl: string;
@@ -85,7 +89,11 @@ export function PlayAudioButton({
> >
{isThisPlaying ? ( {isThisPlaying ? (
<> <>
<Radio className="size-4 animate-pulse" /> <AudioWaveAnimation
paused={false}
colorClassName="bg-primary-foreground"
size="sm"
/>
<span>Now Playing</span> <span>Now Playing</span>
</> </>
) : ( ) : (
+2 -7
View File
@@ -3,7 +3,7 @@ export const dynamic = "force-dynamic";
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { buildOgMeta } from "@/lib/og"; import { ogForTranscriptions } from "@/lib/og";
import { import {
getTranscriptionYears, getTranscriptionYears,
getTranscriptionPlaces, getTranscriptionPlaces,
@@ -14,12 +14,7 @@ import { TranscriptionsNav } from "./transcriptions-nav";
import { TranscriptionsBrowser } from "./transcriptions-browser"; import { TranscriptionsBrowser } from "./transcriptions-browser";
export function generateMetadata(): Metadata { export function generateMetadata(): Metadata {
return buildOgMeta({ return ogForTranscriptions();
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.",
});
} }
export default async function TranscriptionsPage({ export default async function TranscriptionsPage({
+2 -2
View File
@@ -17,8 +17,8 @@ export async function generateMetadata({
const transcriptions = await getTranscriptionsByYear(year); const transcriptions = await getTranscriptionsByYear(year);
return buildOgMeta({ return buildOgMeta({
title: "Transcriptions", title: "Transcriptions",
detail: `${transcriptions.length} transcriptions from ${year}`, detail: `${transcriptions.length} lectures & conversations from ${year}`,
quote: `Lectures and conversations of Śrīla Prabhupāda from ${year}.`, quote: `Lectures and conversations of Śrīla Prabhupāda from ${year} — the living voice of the Founder-Ācārya preserved in text.`,
}); });
} }
+59
View File
@@ -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>
);
}
+453 -260
View File
@@ -1,10 +1,12 @@
"use client"; "use client";
import { useRef, useCallback, useEffect } from "react"; import { useRef, useCallback, useEffect, useState } from "react";
import { useAudioPlayer } from "./audio-context"; import { useAudioPlayer } from "./audio-context";
import { AudioWaveAnimation } from "./audio-wave";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { motion, AnimatePresence } from "framer-motion";
import { import {
Play, Play,
Pause, Pause,
@@ -18,6 +20,65 @@ import {
Loader2, Loader2,
} from "lucide-react"; } 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 // Constants
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -230,6 +291,30 @@ export function FloatingPlayer() {
} = useAudioPlayer(); } = useAudioPlayer();
const playerRef = useRef<HTMLDivElement>(null); 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 // Add bottom padding to body when player is visible
useEffect(() => { useEffect(() => {
@@ -240,47 +325,65 @@ export function FloatingPlayer() {
}; };
}, [currentTrack]); }, [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 // Keyboard shortcuts when player is focused
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
switch (e.key) { switch (e.key) {
case " ": case " ":
e.preventDefault(); e.preventDefault();
togglePlayPause(); iTogglePlayPause();
break; break;
case "ArrowLeft": case "ArrowLeft":
e.preventDefault(); e.preventDefault();
seekTo(Math.max(0, currentTime - 10)); iSeekTo(Math.max(0, currentTime - 10));
break; break;
case "ArrowRight": case "ArrowRight":
e.preventDefault(); e.preventDefault();
seekTo(Math.min(duration, currentTime + 10)); iSeekTo(Math.min(duration, currentTime + 10));
break; break;
case "ArrowUp": case "ArrowUp":
e.preventDefault(); e.preventDefault();
setVolume(Math.min(1, volume + 0.05)); iSetVolume(Math.min(1, volume + 0.05));
break; break;
case "ArrowDown": case "ArrowDown":
e.preventDefault(); e.preventDefault();
setVolume(Math.max(0, volume - 0.05)); iSetVolume(Math.max(0, volume - 0.05));
break; break;
case "m": case "m":
case "M": case "M":
e.preventDefault(); e.preventDefault();
toggleMute(); iToggleMute();
break; break;
case "Escape": case "Escape":
e.preventDefault(); e.preventDefault();
stop(); iStop();
break; break;
} }
}, },
[ [
togglePlayPause, iTogglePlayPause,
seekTo, iSeekTo,
setVolume, iSetVolume,
toggleMute, iToggleMute,
stop, iStop,
currentTime, currentTime,
duration, duration,
volume, volume,
@@ -292,269 +395,359 @@ export function FloatingPlayer() {
const hasNext = currentIndex >= 0 && currentIndex < playlist.length - 1; const hasNext = currentIndex >= 0 && currentIndex < playlist.length - 1;
const hasPrev = currentIndex > 0; const hasPrev = currentIndex > 0;
const coverSrc = currentTrack.coverImage || FALLBACK_COVER; 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 ( return (
<div <AnimatePresence mode="popLayout">
ref={playerRef} {isMobile && minimized ? (
data-audio-player <motion.button
role="region" key="mini-pill"
aria-label="Audio player" data-audio-player
tabIndex={0} type="button"
onKeyDown={handleKeyDown} onClick={() => {
className={cn( setMinimized(false);
// Centered floating bar resetTimer();
"fixed z-40 bottom-5 left-1/2 -translate-x-1/2", }}
// Narrower width aria-label="Expand audio player"
"w-[calc(100%-2rem)] max-w-[680px]", initial={{ opacity: 0, scale: 0.5, bottom: -60 }}
// Forced dark surface for contrast regardless of theme animate={{
"bg-[#1c1c1e] text-white", opacity: 1,
"border border-white/10", scale: 1,
"rounded-2xl", bottom: selectionToolbarVisible ? 90 : 24,
"shadow-2xl shadow-black/25", }}
// Animation exit={{ opacity: 0, scale: 0.5, bottom: -60 }}
"animate-in fade-in-0 slide-in-from-bottom-4 duration-300", transition={{ type: "spring", damping: 20, stiffness: 400 }}
// Clip seeker to rounded top corners className={cn(
"overflow-hidden", "fixed z-40 right-4",
)} "size-14 rounded-full",
> "shadow-2xl shadow-black/30",
{/* Thin progress bar — absolutely positioned at top edge */} "overflow-hidden",
<ProgressBar "ring-2 ring-white/20",
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 */} {/* Cover image */}
<div className="size-12 rounded-lg overflow-hidden bg-muted shrink-0 relative"> <Image
<Image src={coverSrc}
src={coverSrc} alt=""
alt="" fill
fill className="object-cover"
className="object-cover" unoptimized
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>
<div className="min-w-0"> </motion.button>
<p className="text-sm font-medium text-white truncate leading-tight"> ) : (
{currentTrack.title} <motion.div
</p> key="full-player"
{currentTrack.subtitle && ( ref={playerRef}
<p className="text-[11px] text-white/50 truncate leading-tight mt-0.5"> data-audio-player
{currentTrack.subtitle} role="region"
</p> 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>
</div>
{/* Center: Transport controls */} {/* ── Desktop layout ── */}
<div className="flex items-center gap-1.5"> <div className="hidden md:flex items-center h-[64px] px-5 gap-5">
<TransportButton {/* Left: Cover + Track info */}
onClick={previous} <div className="flex items-center gap-3 min-w-0 flex-1">
disabled={!hasPrev && currentTime <= 3} {/* Cover image */}
label="Previous track" <div className="size-12 rounded-lg overflow-hidden bg-muted shrink-0 relative">
className="text-white/60 hover:text-white hover:bg-white/10" <Image
> src={coverSrc}
<SkipBack className="size-4" /> alt=""
</TransportButton> 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 {/* Center: Transport controls */}
onClick={togglePlayPause} <div className="flex items-center gap-1.5">
label={isPlaying ? "Pause" : "Play"} <TransportButton
size="large" onClick={iPrevious}
className="bg-white text-black hover:bg-white/90" disabled={!hasPrev && currentTime <= 3}
> label="Previous track"
{isLoading ? ( className="text-white/60 hover:text-white hover:bg-white/10"
<Loader2 className="size-4 animate-spin" /> >
) : isPlaying ? ( <SkipBack className="size-4" />
<Pause className="size-4" /> </TransportButton>
) : (
<Play className="size-4 ml-0.5" />
)}
</TransportButton>
<TransportButton <TransportButton
onClick={next} onClick={iTogglePlayPause}
disabled={!hasNext} label={isPlaying ? "Pause" : "Play"}
label="Next track" size="large"
className="text-white/60 hover:text-white hover:bg-white/10" className="bg-white text-black hover:bg-white/90"
> >
<SkipForward className="size-4" /> {isLoading ? (
</TransportButton> <Loader2 className="size-4 animate-spin" />
</div> ) : isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</TransportButton>
{/* Right: Volume + Source link + Close */} <TransportButton
<div className="flex items-center gap-1.5 shrink-0"> onClick={iNext}
{/* Volume control */} disabled={!hasNext}
<div className="flex items-center gap-1"> label="Next track"
<button className="text-white/60 hover:text-white hover:bg-white/10"
type="button" >
onClick={toggleMute} <SkipForward className="size-4" />
aria-label={isMuted ? "Unmute" : "Mute"} </TransportButton>
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" </div>
>
<VolumeIcon {/* Right: Volume + Source link + Close */}
volume={volume} <div className="flex items-center gap-1.5 shrink-0">
isMuted={isMuted} {/* Volume control */}
className="size-4" <div className="flex items-center gap-1">
/> <button
</button> type="button"
<input onClick={iToggleMute}
type="range" aria-label={isMuted ? "Unmute" : "Mute"}
min={0} 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"
max={100} >
step={1} <VolumeIcon
value={isMuted ? 0 : Math.round(volume * 100)} volume={volume}
onChange={(e) => setVolume(Number(e.target.value) / 100)} isMuted={isMuted}
aria-label="Volume" className="size-4"
className={cn( />
"w-16 h-1 appearance-none bg-white/15 rounded-full cursor-pointer", </button>
"[&::-webkit-slider-thumb]:appearance-none", <input
"[&::-webkit-slider-thumb]:size-3", type="range"
"[&::-webkit-slider-thumb]:rounded-full", min={0}
"[&::-webkit-slider-thumb]:bg-white", max={100}
"[&::-webkit-slider-thumb]:cursor-pointer", step={1}
"[&::-moz-range-thumb]:size-3", value={isMuted ? 0 : Math.round(volume * 100)}
"[&::-moz-range-thumb]:rounded-full", onChange={(e) => iSetVolume(Number(e.target.value) / 100)}
"[&::-moz-range-thumb]:bg-white", aria-label="Volume"
"[&::-moz-range-thumb]:border-0", className={cn(
"[&::-moz-range-thumb]:cursor-pointer", "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> </div>
{/* Source link */} {/* ── Mobile layout ── */}
{currentTrack.sourceUrl && ( <div
<Link className="flex md:hidden flex-col px-4 py-3 gap-2.5"
href={currentTrack.sourceUrl} onPointerDown={resetTimer}
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" /> {/* Time row — right below seeker */}
</button> <div className="flex items-center justify-between px-0.5">
</div> <span className="text-[10px] tabular-nums text-white/40 select-none">
</div> {formatTime(currentTime)}
</span>
<span className="text-[10px] tabular-nums text-white/40 select-none">
{formatTime(duration)}
</span>
</div>
{/* ── Mobile layout ── */} {/* Row 1: Cover + Title + Source + Close */}
<div className="flex md:hidden flex-col px-4 py-3 gap-2.5"> <div className="flex items-center gap-3">
{/* Time row — right below seeker */} {/* Cover image */}
<div className="flex items-center justify-between px-0.5"> <div className="size-10 rounded-lg overflow-hidden bg-muted shrink-0 relative">
<span className="text-[10px] tabular-nums text-white/40 select-none"> <Image
{formatTime(currentTime)} src={coverSrc}
</span> alt=""
<span className="text-[10px] tabular-nums text-white/40 select-none"> fill
{formatTime(duration)} className="object-cover"
</span> unoptimized
</div> />
</div>
{/* Row 1: Cover + Title + Source + Close */} {/* Track info */}
<div className="flex items-center gap-3"> <div className="flex-1 min-w-0">
{/* Cover image */} <p className="text-sm font-medium text-white truncate leading-tight">
<div className="size-10 rounded-lg overflow-hidden bg-muted shrink-0 relative"> {currentTrack.title}
<Image </p>
src={coverSrc} {currentTrack.subtitle && (
alt="" <p className="text-[11px] text-white/50 truncate leading-tight mt-0.5">
fill {currentTrack.subtitle}
className="object-cover" </p>
unoptimized )}
/> </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> </div>
</motion.div>
{/* Track info */} )}
<div className="flex-1 min-w-0"> </AnimatePresence>
<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>
); );
} }
+1
View File
@@ -1,3 +1,4 @@
export { AudioPlayerProvider, useAudioPlayer } from "./audio-context"; export { AudioPlayerProvider, useAudioPlayer } from "./audio-context";
export type { AudioTrack } from "./audio-context"; export type { AudioTrack } from "./audio-context";
export { FloatingPlayer } from "./floating-player"; export { FloatingPlayer } from "./floating-player";
export { AudioWaveAnimation } from "./audio-wave";
-11440
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -15,6 +15,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"framer-motion": "^12.38.0",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"mongodb": "^7.1.1", "mongodb": "^7.1.1",
"next": "16.2.2", "next": "16.2.2",
+623 -103
View File
File diff suppressed because it is too large Load Diff