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
+453 -260
View File
@@ -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>
);
}