feat: add framer motion for audio player, handle og images for transcripts
This commit is contained in:
@@ -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(" · ");
|
||||||
|
|
||||||
|
// 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({
|
return buildOgMeta({
|
||||||
title: t.name,
|
title: t.name,
|
||||||
detail: detail || undefined,
|
detail: detail || undefined,
|
||||||
quote:
|
quote: hlQuote,
|
||||||
t.content.length > 0
|
});
|
||||||
? t.content
|
}
|
||||||
|
|
||||||
|
// Fallback: find a good Prabhupāda quote from the content
|
||||||
|
const spQuote = t.content
|
||||||
.find((b) => b.speaker === "Prabhupāda" && b.text.length > 50)
|
.find((b) => b.speaker === "Prabhupāda" && b.text.length > 50)
|
||||||
?.text?.replace(/<[^>]+>/g, "")
|
?.text?.replace(/<[^>]+>/g, "")
|
||||||
.slice(0, 200)
|
.slice(0, 200);
|
||||||
: undefined,
|
|
||||||
|
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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
"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,18 +395,105 @@ 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">
|
||||||
|
{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 */}
|
||||||
|
<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}
|
ref={playerRef}
|
||||||
data-audio-player
|
data-audio-player
|
||||||
role="region"
|
role="region"
|
||||||
aria-label="Audio player"
|
aria-label="Audio player"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyDown}
|
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(
|
className={cn(
|
||||||
// Centered floating bar
|
// Centered floating bar
|
||||||
"fixed z-40 bottom-5 left-1/2 -translate-x-1/2",
|
"fixed z-40 left-1/2 -translate-x-1/2",
|
||||||
// Narrower width
|
// Narrower width
|
||||||
"w-[calc(100%-2rem)] max-w-[680px]",
|
"w-[calc(100%-2rem)] max-w-[680px]",
|
||||||
// Forced dark surface for contrast regardless of theme
|
// Forced dark surface for contrast regardless of theme
|
||||||
@@ -311,8 +501,6 @@ export function FloatingPlayer() {
|
|||||||
"border border-white/10",
|
"border border-white/10",
|
||||||
"rounded-2xl",
|
"rounded-2xl",
|
||||||
"shadow-2xl shadow-black/25",
|
"shadow-2xl shadow-black/25",
|
||||||
// Animation
|
|
||||||
"animate-in fade-in-0 slide-in-from-bottom-4 duration-300",
|
|
||||||
// Clip seeker to rounded top corners
|
// Clip seeker to rounded top corners
|
||||||
"overflow-hidden",
|
"overflow-hidden",
|
||||||
)}
|
)}
|
||||||
@@ -321,7 +509,7 @@ export function FloatingPlayer() {
|
|||||||
<ProgressBar
|
<ProgressBar
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
duration={duration}
|
duration={duration}
|
||||||
onSeek={seekTo}
|
onSeek={iSeekTo}
|
||||||
thin
|
thin
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -364,7 +552,7 @@ export function FloatingPlayer() {
|
|||||||
{/* Center: Transport controls */}
|
{/* Center: Transport controls */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<TransportButton
|
<TransportButton
|
||||||
onClick={previous}
|
onClick={iPrevious}
|
||||||
disabled={!hasPrev && currentTime <= 3}
|
disabled={!hasPrev && currentTime <= 3}
|
||||||
label="Previous track"
|
label="Previous track"
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||||
@@ -373,7 +561,7 @@ export function FloatingPlayer() {
|
|||||||
</TransportButton>
|
</TransportButton>
|
||||||
|
|
||||||
<TransportButton
|
<TransportButton
|
||||||
onClick={togglePlayPause}
|
onClick={iTogglePlayPause}
|
||||||
label={isPlaying ? "Pause" : "Play"}
|
label={isPlaying ? "Pause" : "Play"}
|
||||||
size="large"
|
size="large"
|
||||||
className="bg-white text-black hover:bg-white/90"
|
className="bg-white text-black hover:bg-white/90"
|
||||||
@@ -388,7 +576,7 @@ export function FloatingPlayer() {
|
|||||||
</TransportButton>
|
</TransportButton>
|
||||||
|
|
||||||
<TransportButton
|
<TransportButton
|
||||||
onClick={next}
|
onClick={iNext}
|
||||||
disabled={!hasNext}
|
disabled={!hasNext}
|
||||||
label="Next track"
|
label="Next track"
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||||
@@ -403,7 +591,7 @@ export function FloatingPlayer() {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleMute}
|
onClick={iToggleMute}
|
||||||
aria-label={isMuted ? "Unmute" : "Mute"}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -419,7 +607,7 @@ export function FloatingPlayer() {
|
|||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
value={isMuted ? 0 : Math.round(volume * 100)}
|
value={isMuted ? 0 : Math.round(volume * 100)}
|
||||||
onChange={(e) => setVolume(Number(e.target.value) / 100)}
|
onChange={(e) => iSetVolume(Number(e.target.value) / 100)}
|
||||||
aria-label="Volume"
|
aria-label="Volume"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-16 h-1 appearance-none bg-white/15 rounded-full cursor-pointer",
|
"w-16 h-1 appearance-none bg-white/15 rounded-full cursor-pointer",
|
||||||
@@ -451,7 +639,7 @@ export function FloatingPlayer() {
|
|||||||
{/* Close */}
|
{/* Close */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={stop}
|
onClick={iStop}
|
||||||
aria-label="Close player"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -461,7 +649,10 @@ export function FloatingPlayer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Mobile layout ── */}
|
{/* ── Mobile layout ── */}
|
||||||
<div className="flex md:hidden flex-col px-4 py-3 gap-2.5">
|
<div
|
||||||
|
className="flex md:hidden flex-col px-4 py-3 gap-2.5"
|
||||||
|
onPointerDown={resetTimer}
|
||||||
|
>
|
||||||
{/* Time row — right below seeker */}
|
{/* Time row — right below seeker */}
|
||||||
<div className="flex items-center justify-between px-0.5">
|
<div className="flex items-center justify-between px-0.5">
|
||||||
<span className="text-[10px] tabular-nums text-white/40 select-none">
|
<span className="text-[10px] tabular-nums text-white/40 select-none">
|
||||||
@@ -511,7 +702,7 @@ export function FloatingPlayer() {
|
|||||||
{/* Close */}
|
{/* Close */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={stop}
|
onClick={iStop}
|
||||||
aria-label="Close player"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -522,7 +713,7 @@ export function FloatingPlayer() {
|
|||||||
{/* Row 2: Prev + Play/Pause + Next — centered */}
|
{/* Row 2: Prev + Play/Pause + Next — centered */}
|
||||||
<div className="flex items-center justify-center gap-4 py-1">
|
<div className="flex items-center justify-center gap-4 py-1">
|
||||||
<TransportButton
|
<TransportButton
|
||||||
onClick={previous}
|
onClick={iPrevious}
|
||||||
disabled={!hasPrev && currentTime <= 3}
|
disabled={!hasPrev && currentTime <= 3}
|
||||||
label="Previous track"
|
label="Previous track"
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
|
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
|
||||||
@@ -531,7 +722,7 @@ export function FloatingPlayer() {
|
|||||||
</TransportButton>
|
</TransportButton>
|
||||||
|
|
||||||
<TransportButton
|
<TransportButton
|
||||||
onClick={togglePlayPause}
|
onClick={iTogglePlayPause}
|
||||||
label={isPlaying ? "Pause" : "Play"}
|
label={isPlaying ? "Pause" : "Play"}
|
||||||
size="large"
|
size="large"
|
||||||
className="bg-white text-black hover:bg-white/90 !size-12"
|
className="bg-white text-black hover:bg-white/90 !size-12"
|
||||||
@@ -546,7 +737,7 @@ export function FloatingPlayer() {
|
|||||||
</TransportButton>
|
</TransportButton>
|
||||||
|
|
||||||
<TransportButton
|
<TransportButton
|
||||||
onClick={next}
|
onClick={iNext}
|
||||||
disabled={!hasNext}
|
disabled={!hasNext}
|
||||||
label="Next track"
|
label="Next track"
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
|
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
|
||||||
@@ -555,6 +746,8 @@ export function FloatingPlayer() {
|
|||||||
</TransportButton>
|
</TransportButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Generated
-11440
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user