feat: add transcripts with audio links
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AudioTrack = {
|
||||
url: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
coverImage?: string;
|
||||
sourceUrl?: string;
|
||||
type?: "lecture" | "conversation" | "kirtan" | "music" | string;
|
||||
};
|
||||
|
||||
type AudioPlayerState = {
|
||||
currentTrack: AudioTrack | null;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isLoading: boolean;
|
||||
playlist: AudioTrack[];
|
||||
currentIndex: number;
|
||||
};
|
||||
|
||||
type AudioPlayerActions = {
|
||||
play: (track: AudioTrack) => void;
|
||||
playPlaylist: (tracks: AudioTrack[], startIndex?: number) => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
togglePlayPause: () => void;
|
||||
stop: () => void;
|
||||
seekTo: (time: number) => void;
|
||||
setVolume: (vol: number) => void;
|
||||
toggleMute: () => void;
|
||||
next: () => void;
|
||||
previous: () => void;
|
||||
};
|
||||
|
||||
type AudioPlayerContextValue = AudioPlayerState & AudioPlayerActions;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AudioPlayerContext = createContext<AudioPlayerContextValue | null>(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VOLUME_KEY = "audio-player-volume";
|
||||
const MUTED_KEY = "audio-player-muted";
|
||||
|
||||
function readStoredVolume(): number {
|
||||
if (typeof window === "undefined") return 0.75;
|
||||
try {
|
||||
const raw = localStorage.getItem(VOLUME_KEY);
|
||||
if (raw !== null) {
|
||||
const v = parseFloat(raw);
|
||||
if (isFinite(v) && v >= 0 && v <= 1) return v;
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
return 0.75;
|
||||
}
|
||||
|
||||
function readStoredMuted(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return localStorage.getItem(MUTED_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function persistVolume(vol: number) {
|
||||
try {
|
||||
localStorage.setItem(VOLUME_KEY, String(vol));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function persistMuted(muted: boolean) {
|
||||
try {
|
||||
localStorage.setItem(MUTED_KEY, String(muted));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
// --- State ---
|
||||
const [currentTrack, setCurrentTrack] = useState<AudioTrack | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolumeState] = useState(readStoredVolume);
|
||||
const [isMuted, setIsMuted] = useState(readStoredMuted);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [playlist, setPlaylist] = useState<AudioTrack[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(-1);
|
||||
|
||||
// Keep refs for values accessed in callbacks to avoid stale closures
|
||||
const playlistRef = useRef(playlist);
|
||||
const currentIndexRef = useRef(currentIndex);
|
||||
|
||||
// Sync refs in an effect to avoid "cannot access refs during render"
|
||||
useEffect(() => {
|
||||
playlistRef.current = playlist;
|
||||
}, [playlist]);
|
||||
useEffect(() => {
|
||||
currentIndexRef.current = currentIndex;
|
||||
}, [currentIndex]);
|
||||
|
||||
// --- Sync volume/muted to the <audio> element ---
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
audio.volume = isMuted ? 0 : volume;
|
||||
}, [volume, isMuted]);
|
||||
|
||||
// --- Audio event listeners ---
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
function handleTimeUpdate() {
|
||||
setCurrentTime(audio!.currentTime);
|
||||
}
|
||||
|
||||
function handleLoadedMetadata() {
|
||||
setDuration(audio!.duration);
|
||||
}
|
||||
|
||||
function handleDurationChange() {
|
||||
if (isFinite(audio!.duration)) {
|
||||
setDuration(audio!.duration);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCanPlay() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
function handleWaiting() {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
function handlePlaying() {
|
||||
setIsPlaying(true);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
function handlePause() {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
|
||||
function handleEnded() {
|
||||
setIsPlaying(false);
|
||||
// Auto-play next track if playlist has more
|
||||
const idx = currentIndexRef.current;
|
||||
const pl = playlistRef.current;
|
||||
if (idx >= 0 && idx < pl.length - 1) {
|
||||
const nextIdx = idx + 1;
|
||||
const nextTrack = pl[nextIdx];
|
||||
setCurrentIndex(nextIdx);
|
||||
setCurrentTrack(nextTrack);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setIsLoading(true);
|
||||
if (audio) {
|
||||
audio.src = nextTrack.url;
|
||||
audio.load();
|
||||
audio.play().catch((err) => {
|
||||
console.error("[AudioPlayer] Auto-play next failed:", err);
|
||||
setIsPlaying(false);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleError() {
|
||||
console.error("[AudioPlayer] Audio error:", audio!.error);
|
||||
setIsPlaying(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
audio.addEventListener("timeupdate", handleTimeUpdate);
|
||||
audio.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
audio.addEventListener("durationchange", handleDurationChange);
|
||||
audio.addEventListener("canplay", handleCanPlay);
|
||||
audio.addEventListener("waiting", handleWaiting);
|
||||
audio.addEventListener("playing", handlePlaying);
|
||||
audio.addEventListener("pause", handlePause);
|
||||
audio.addEventListener("ended", handleEnded);
|
||||
audio.addEventListener("error", handleError);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
audio.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
audio.removeEventListener("durationchange", handleDurationChange);
|
||||
audio.removeEventListener("canplay", handleCanPlay);
|
||||
audio.removeEventListener("waiting", handleWaiting);
|
||||
audio.removeEventListener("playing", handlePlaying);
|
||||
audio.removeEventListener("pause", handlePause);
|
||||
audio.removeEventListener("ended", handleEnded);
|
||||
audio.removeEventListener("error", handleError);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
const play = useCallback((track: AudioTrack) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
setCurrentTrack(track);
|
||||
setPlaylist([track]);
|
||||
setCurrentIndex(0);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setIsLoading(true);
|
||||
|
||||
audio.src = track.url;
|
||||
audio.load();
|
||||
audio.play().catch((err) => {
|
||||
console.error("[AudioPlayer] Play failed:", err);
|
||||
setIsPlaying(false);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const playPlaylist = useCallback(
|
||||
(tracks: AudioTrack[], startIndex: number = 0) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || tracks.length === 0) return;
|
||||
|
||||
const idx = Math.max(0, Math.min(startIndex, tracks.length - 1));
|
||||
const track = tracks[idx];
|
||||
|
||||
setPlaylist(tracks);
|
||||
setCurrentIndex(idx);
|
||||
setCurrentTrack(track);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setIsLoading(true);
|
||||
|
||||
audio.src = track.url;
|
||||
audio.load();
|
||||
audio.play().catch((err) => {
|
||||
console.error("[AudioPlayer] PlayPlaylist failed:", err);
|
||||
setIsPlaying(false);
|
||||
setIsLoading(false);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
audioRef.current?.pause();
|
||||
}, []);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !audio.src) return;
|
||||
audio.play().catch((err) => {
|
||||
console.error("[AudioPlayer] Resume failed:", err);
|
||||
setIsPlaying(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !audio.src) return;
|
||||
if (audio.paused) {
|
||||
audio.play().catch((err) => {
|
||||
console.error("[AudioPlayer] Toggle play failed:", err);
|
||||
setIsPlaying(false);
|
||||
});
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.removeAttribute("src");
|
||||
audio.load();
|
||||
}
|
||||
setCurrentTrack(null);
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setIsLoading(false);
|
||||
setPlaylist([]);
|
||||
setCurrentIndex(-1);
|
||||
}, []);
|
||||
|
||||
const seekTo = useCallback((time: number) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const clamped = Math.max(0, Math.min(time, audio.duration || 0));
|
||||
audio.currentTime = clamped;
|
||||
setCurrentTime(clamped);
|
||||
}, []);
|
||||
|
||||
const setVolume = useCallback(
|
||||
(vol: number) => {
|
||||
const clamped = Math.max(0, Math.min(1, vol));
|
||||
setVolumeState(clamped);
|
||||
persistVolume(clamped);
|
||||
// If setting volume while muted, unmute
|
||||
if (isMuted && clamped > 0) {
|
||||
setIsMuted(false);
|
||||
persistMuted(false);
|
||||
}
|
||||
},
|
||||
[isMuted],
|
||||
);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
setIsMuted((prev) => {
|
||||
const next = !prev;
|
||||
persistMuted(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const next = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
const idx = currentIndexRef.current;
|
||||
const pl = playlistRef.current;
|
||||
|
||||
if (!audio || idx < 0 || idx >= pl.length - 1) return;
|
||||
|
||||
const nextIdx = idx + 1;
|
||||
const nextTrack = pl[nextIdx];
|
||||
|
||||
setCurrentIndex(nextIdx);
|
||||
setCurrentTrack(nextTrack);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setIsLoading(true);
|
||||
|
||||
audio.src = nextTrack.url;
|
||||
audio.load();
|
||||
audio.play().catch((err) => {
|
||||
console.error("[AudioPlayer] Next failed:", err);
|
||||
setIsPlaying(false);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const previous = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
const idx = currentIndexRef.current;
|
||||
const pl = playlistRef.current;
|
||||
|
||||
if (!audio || idx < 0) return;
|
||||
|
||||
// If more than 3 seconds in, restart the current track instead
|
||||
if (audio.currentTime > 3) {
|
||||
audio.currentTime = 0;
|
||||
setCurrentTime(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (idx <= 0) {
|
||||
// At start of playlist — just restart
|
||||
audio.currentTime = 0;
|
||||
setCurrentTime(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const prevIdx = idx - 1;
|
||||
const prevTrack = pl[prevIdx];
|
||||
|
||||
setCurrentIndex(prevIdx);
|
||||
setCurrentTrack(prevTrack);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setIsLoading(true);
|
||||
|
||||
audio.src = prevTrack.url;
|
||||
audio.load();
|
||||
audio.play().catch((err) => {
|
||||
console.error("[AudioPlayer] Previous failed:", err);
|
||||
setIsPlaying(false);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// --- Context value ---
|
||||
|
||||
const value: AudioPlayerContextValue = {
|
||||
currentTrack,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isMuted,
|
||||
isLoading,
|
||||
playlist,
|
||||
currentIndex,
|
||||
play,
|
||||
playPlaylist,
|
||||
pause,
|
||||
resume,
|
||||
togglePlayPause,
|
||||
stop,
|
||||
seekTo,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
next,
|
||||
previous,
|
||||
};
|
||||
|
||||
return (
|
||||
<AudioPlayerContext.Provider value={value}>
|
||||
{children}
|
||||
<audio ref={audioRef} preload="metadata" style={{ display: "none" }} />
|
||||
</AudioPlayerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useAudioPlayer(): AudioPlayerContextValue {
|
||||
const ctx = useContext(AudioPlayerContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useAudioPlayer must be used within an <AudioPlayerProvider>",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useCallback, useEffect } from "react";
|
||||
import { useAudioPlayer } from "./audio-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Volume1,
|
||||
X,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FALLBACK_COVER = "/images/cover.png";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) return "0:00";
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0)
|
||||
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function VolumeIcon({
|
||||
volume,
|
||||
isMuted,
|
||||
className,
|
||||
}: {
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
if (isMuted || volume === 0) return <VolumeX className={className} />;
|
||||
if (volume < 0.5) return <Volume1 className={className} />;
|
||||
return <Volume2 className={className} />;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress Bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ProgressBar({
|
||||
currentTime,
|
||||
duration,
|
||||
onSeek,
|
||||
thin = false,
|
||||
}: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onSeek: (time: number) => void;
|
||||
thin?: boolean;
|
||||
}) {
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
const calculateSeekTime = useCallback(
|
||||
(clientX: number) => {
|
||||
const bar = barRef.current;
|
||||
if (!bar || duration <= 0) return;
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const fraction = Math.max(
|
||||
0,
|
||||
Math.min(1, (clientX - rect.left) / rect.width),
|
||||
);
|
||||
onSeek(fraction * duration);
|
||||
},
|
||||
[duration, onSeek],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
draggingRef.current = true;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
calculateSeekTime(e.clientX);
|
||||
},
|
||||
[calculateSeekTime],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
calculateSeekTime(e.clientX);
|
||||
},
|
||||
[calculateSeekTime],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
draggingRef.current = false;
|
||||
}, []);
|
||||
|
||||
if (thin) {
|
||||
return (
|
||||
<div
|
||||
ref={barRef}
|
||||
className="w-full h-2 bg-white/15 cursor-pointer shrink-0 relative"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
role="slider"
|
||||
aria-label="Playback progress"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={duration || 0}
|
||||
aria-valuenow={currentTime}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-primary transition-[width] duration-100 ease-linear"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground min-w-[32px] text-right select-none">
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
<div
|
||||
ref={barRef}
|
||||
className="relative flex-1 h-1 bg-muted rounded-full cursor-pointer group/progress"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
role="slider"
|
||||
aria-label="Playback progress"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={duration || 0}
|
||||
aria-valuenow={currentTime}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-[width] duration-100 ease-linear"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 size-2.5 bg-primary rounded-full opacity-0 group-hover/progress:opacity-100 transition-opacity shadow-sm pointer-events-none"
|
||||
style={{ left: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground min-w-[32px] select-none">
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transport Button
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TransportButton({
|
||||
onClick,
|
||||
disabled = false,
|
||||
label,
|
||||
size = "default",
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
size?: "default" | "large";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-full transition-colors outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
|
||||
"disabled:opacity-30 disabled:pointer-events-none",
|
||||
size === "large"
|
||||
? "size-9 bg-foreground text-background hover:bg-foreground/90"
|
||||
: "size-8 text-foreground hover:bg-muted",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Floating Player
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function FloatingPlayer() {
|
||||
const {
|
||||
currentTrack,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isMuted,
|
||||
isLoading,
|
||||
playlist,
|
||||
currentIndex,
|
||||
togglePlayPause,
|
||||
stop,
|
||||
seekTo,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
next,
|
||||
previous,
|
||||
} = useAudioPlayer();
|
||||
|
||||
const playerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Add bottom padding to body when player is visible
|
||||
useEffect(() => {
|
||||
if (!currentTrack) return;
|
||||
document.body.style.paddingBottom = "96px";
|
||||
return () => {
|
||||
document.body.style.paddingBottom = "";
|
||||
};
|
||||
}, [currentTrack]);
|
||||
|
||||
// Keyboard shortcuts when player is focused
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
togglePlayPause();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
seekTo(Math.max(0, currentTime - 10));
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
seekTo(Math.min(duration, currentTime + 10));
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setVolume(Math.min(1, volume + 0.05));
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setVolume(Math.max(0, volume - 0.05));
|
||||
break;
|
||||
case "m":
|
||||
case "M":
|
||||
e.preventDefault();
|
||||
toggleMute();
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
stop();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
togglePlayPause,
|
||||
seekTo,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
stop,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
],
|
||||
);
|
||||
|
||||
if (!currentTrack) return null;
|
||||
|
||||
const hasNext = currentIndex >= 0 && currentIndex < playlist.length - 1;
|
||||
const hasPrev = currentIndex > 0;
|
||||
const coverSrc = currentTrack.coverImage || FALLBACK_COVER;
|
||||
|
||||
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">
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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",
|
||||
)}
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { AudioPlayerProvider, useAudioPlayer } from "./audio-context";
|
||||
export type { AudioTrack } from "./audio-context";
|
||||
export { FloatingPlayer } from "./floating-player";
|
||||
Reference in New Issue
Block a user