feat(frontend): perbagus voice & audio playback UX

- Recordings: custom RecordingAudioPlayer (play/pause, buffering spinner,
  click-to-seek, time label, eq bars, single-playback antar kartu) +
  highlight kartu now-playing
- Media: thumbnail di disc hero + queue row, equalizer saat playing,
  badge 'up next', label Paused vs Now playing
- MiniPlayer global di AppFrame (fixed bottom-right, hidden on /media)
  menggantikan use-media-player.tsx dead provider (dihapus)
- Voice: mic level meter live (AnalyserNode RMS) + slider mic/listen volume
This commit is contained in:
asepharyana
2026-08-22 12:53:18 +07:00
parent df69b3f05d
commit 4e0c21d86c
10 changed files with 677 additions and 184 deletions
@@ -0,0 +1,163 @@
"use client";
import { ListMusic, SkipForward, Square } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import {
useMediaLoop,
useMediaSkip,
useMediaState,
useMediaStop,
useMediaWsSync,
} from "@/hooks";
import { formatDuration } from "@/lib/format";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
/**
* Persistent now-playing bar, fixed above the mobile dock / bottom of the
* viewport. Hidden on the /media route (the full player lives there) and
* entirely when nothing is queued. Shares the SWR media-state cache with
* every other consumer, so state stays consistent across routes.
*/
export function MiniPlayer() {
const ws = useWebSocket();
const pathname = usePathname();
const { data: media } = useMediaState();
useMediaWsSync(ws);
const skip = useMediaSkip();
const stop = useMediaStop();
const loop = useMediaLoop();
const ambient = useAmbient();
const hidden = pathname === "/media";
const current = hidden ? null : (media?.current ?? null);
const playing = media?.playing ?? false;
const queueLen = (media?.queue ?? []).length;
// Keep the ambient tint in sync while the bar is visible on non-media routes.
useEffect(() => {
if (hidden || !current) return;
ambient.set(
playing ? "signal" : "amber",
playing ? 0.4 : 0.2,
"mini-player",
);
}, [hidden, current, playing, ambient]);
if (!current) return null;
return (
<div
className={cn(
"pointer-events-auto fixed inset-x-3 bottom-[calc(4.5rem+env(safe-area-inset-bottom))] z-40",
"md:inset-x-auto md:right-5 md:bottom-5 md:w-[22rem]",
"animate-fade-up",
)}
>
<div className="glass flex items-center gap-3 rounded-[14px] px-3 py-2.5 shadow-[0_12px_40px_-16px_oklch(0_0_0/0.7)]">
<Link
href="/media"
className="flex min-w-0 flex-1 items-center gap-3"
aria-label="Open full media player"
>
<span className="relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full border border-hairline bg-white/5">
{current.thumbnailUrl ? (
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
<img
src={current.thumbnailUrl}
alt=""
className={cn(
"size-full object-cover",
playing && "animate-spin-disc",
)}
loading="lazy"
/>
) : (
<ListMusic
className={cn(
"size-4",
playing ? "text-signal" : "text-ink-faint",
)}
/>
)}
{playing && (
<span
aria-hidden
className="absolute -inset-1 rounded-full border border-signal/30 animate-pulse-ring"
/>
)}
</span>
<span className="min-w-0 flex-1">
<span className="eyebrow block !text-[0.55rem] leading-tight">
{playing ? (
<span className="inline-flex items-center gap-1.5">
<span aria-hidden className="flex h-2 items-end gap-[2px]">
{[0, 1].map((i) => (
<span
key={`eq-${i}`}
className="w-[3px] animate-eq rounded-full bg-signal"
style={{
animationDelay: `${i * 180}ms`,
height: "100%",
}}
/>
))}
</span>
now playing
</span>
) : (
"paused"
)}
</span>
<span className="block truncate text-sm text-ink">
{current.title}
</span>
{current.durationMs != null && (
<span className="mono block text-[0.6rem] text-ink-faint">
{formatDuration(current.durationMs)}
{queueLen > 0 && ` · ${queueLen} in queue`}
</span>
)}
</span>
</Link>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => skip.mutate()}
disabled={skip.isPending}
aria-label="Skip to next track"
className="flex size-8 items-center justify-center rounded-full text-ink-soft transition-colors hover:bg-white/10 hover:text-signal active:scale-95"
>
<SkipForward className="size-4" />
</button>
<button
type="button"
onClick={() => stop.mutate()}
disabled={stop.isPending}
aria-label="Stop playback"
className="flex size-8 items-center justify-center rounded-full text-ink-faint transition-colors hover:bg-vermilion/15 hover:text-vermilion active:scale-95"
>
<Square className="size-3.5" />
</button>
<button
type="button"
onClick={() => loop.mutate(!media?.loop)}
aria-pressed={!!media?.loop}
aria-label="Toggle loop"
className={`hidden size-8 items-center justify-center rounded-full text-xs transition-colors sm:flex ${
media?.loop
? "bg-signal/15 text-signal"
: "text-ink-faint hover:bg-white/10 hover:text-ink"
}`}
>
</button>
</div>
</div>
</div>
);
}
@@ -1,3 +1,4 @@
import { MiniPlayer } from "@/components/media/mini-player";
import { MobileNav } from "./mobile-nav";
import { NavRail } from "./nav-rail";
import { TopBar } from "./topbar";
@@ -9,7 +10,8 @@ import { TopBar } from "./topbar";
*
* < md the side rail collapses (hidden) and a bottom tab bar (MobileNav)
* takes over navigation; the content region gains bottom padding so the last
* panel never hides behind the dock.
* panel never hides behind the dock. A persistent MiniPlayer floats at the
* bottom-right whenever a media track is loaded outside /media.
*/
export function AppFrame({ children }: { children: React.ReactNode }) {
return (
@@ -22,6 +24,7 @@ export function AppFrame({ children }: { children: React.ReactNode }) {
</main>
</div>
<MobileNav />
<MiniPlayer />
</div>
);
}
@@ -0,0 +1,248 @@
"use client";
import { Loader2, Pause, Play, Signal, Volume2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
/**
* Single-playback registry: playing one clip pauses every other instance.
* Module-level so it survives across cards without a context provider.
*/
const activePlayers = new Set<() => void>();
function registerPlayer(pause: () => void): () => void {
activePlayers.add(pause);
return () => activePlayers.delete(pause);
}
function formatTime(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return "0:00";
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
interface Props {
src: string;
label?: string;
/** Lifted state: parent highlights the card that owns the active player. */
onPlayStateChange?: (playing: boolean) => void;
className?: string;
}
/**
* Custom recording player replacing native `<audio controls>`:
* play/pause with buffering spinner, click-to-seek progress bar, time label,
* animated equalizer bars while playing, and single-playback enforcement
* (starting one clip pauses all others).
*/
export function RecordingAudioPlayer({
src,
label = "Voice recording",
onPlayStateChange,
className,
}: Props) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [playing, setPlaying] = useState(false);
const [buffering, setBuffering] = useState(false);
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
const audio = new Audio();
audio.preload = "metadata";
audio.src = src;
audioRef.current = audio;
const onLoadedMeta = () => setDuration(audio.duration || 0);
const onTime = () => setCurrent(audio.currentTime);
const onEnd = () => {
setPlaying(false);
setBuffering(false);
setCurrent(0);
audio.currentTime = 0;
};
const onPause = () => {
setPlaying(false);
setBuffering(false);
};
const onPlaying = () => {
setPlaying(true);
setBuffering(false);
};
const onWaiting = () => setBuffering(true);
audio.addEventListener("loadedmetadata", onLoadedMeta);
audio.addEventListener("durationchange", onLoadedMeta);
audio.addEventListener("timeupdate", onTime);
audio.addEventListener("ended", onEnd);
audio.addEventListener("pause", onPause);
audio.addEventListener("playing", onPlaying);
audio.addEventListener("play", onWaiting);
audio.addEventListener("waiting", onWaiting);
// Single playback: while this player is active, pause any other that starts.
const pauseThis = () => audio.pause();
let unregister: (() => void) | null = null;
const onPlayEvt = () => {
for (const other of activePlayers) {
if (other !== pauseThis) other();
}
unregister?.();
unregister = registerPlayer(pauseThis);
};
audio.addEventListener("play", onPlayEvt);
return () => {
unregister?.();
audio.pause();
audio.removeEventListener("loadedmetadata", onLoadedMeta);
audio.removeEventListener("durationchange", onLoadedMeta);
audio.removeEventListener("timeupdate", onTime);
audio.removeEventListener("ended", onEnd);
audio.removeEventListener("pause", onPause);
audio.removeEventListener("playing", onPlaying);
audio.removeEventListener("play", onWaiting);
audio.removeEventListener("waiting", onWaiting);
audio.removeEventListener("play", onPlayEvt);
audio.src = "";
audioRef.current = null;
};
}, [src]);
useEffect(() => {
onPlayStateChange?.(playing || buffering);
}, [playing, buffering, onPlayStateChange]);
const toggle = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) {
setBuffering(true);
void audio.play().catch(() => setBuffering(false));
} else {
audio.pause();
}
}, []);
const seek = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const audio = audioRef.current;
if (!audio || !Number.isFinite(audio.duration)) return;
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.min(
1,
Math.max(0, (e.clientX - rect.left) / rect.width),
);
audio.currentTime = ratio * audio.duration;
setCurrent(audio.currentTime);
}, []);
const pct = duration > 0 ? (current / duration) * 100 : 0;
return (
<div
className={cn(
"rounded-[10px] border bg-white/[0.04] px-3 py-2.5 transition-colors",
playing || buffering
? "border-signal/40 shadow-[0_0_24px_-10px_var(--color-signal-glow)]"
: "border-hairline",
className,
)}
role="group"
aria-label={label}
>
<div className="flex items-center gap-3">
<button
type="button"
onClick={toggle}
aria-pressed={playing}
aria-label={playing ? "Pause" : "Play"}
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-full border transition-all active:scale-95",
playing || buffering
? "border-signal/50 bg-signal/15 text-signal"
: "border-hairline bg-white/5 text-ink-soft hover:border-signal/40 hover:text-ink",
)}
>
{buffering ? (
<Loader2 className="size-4 animate-spin" />
) : playing ? (
<Pause className="size-4" />
) : (
<Play className="size-4 translate-x-[1px]" />
)}
</button>
{/* seekable progress */}
<div className="min-w-0 flex-1">
<div
role="slider"
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
aria-valuenow={Math.round(current)}
tabIndex={0}
onClick={seek}
onKeyDown={(e) => {
const audio = audioRef.current;
if (!audio || !Number.isFinite(audio.duration)) return;
if (e.key === "ArrowRight")
audio.currentTime = Math.min(
audio.duration,
audio.currentTime + 5,
);
if (e.key === "ArrowLeft")
audio.currentTime = Math.max(0, audio.currentTime - 5);
}}
className="group relative h-4 cursor-pointer"
>
<div className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-white/10">
<div
className={cn(
"h-full rounded-full transition-[width]",
(playing || buffering) && "bg-signal/80",
!playing && !buffering && "bg-signal/40",
)}
style={{ width: `${pct}%` }}
/>
</div>
{(playing || buffering) && (
<span
className="absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-signal shadow-[0_0_8px_var(--color-signal-glow)] transition-[left]"
style={{ left: `${pct}%` }}
/>
)}
</div>
<div className="mono mt-1 flex items-center justify-between text-[0.6rem] text-ink-faint">
<span>{formatTime(current)}</span>
{/* equalizer bars while playing */}
{(playing || buffering) && (
<span className="flex h-3 items-end gap-[2px]" aria-hidden>
{[0, 1, 2, 3].map((i) => (
<span
key={`eq-${i}`}
className="w-[3px] animate-eq rounded-full bg-signal"
style={{ animationDelay: `${i * 140}ms`, height: "100%" }}
/>
))}
</span>
)}
<span className="inline-flex items-center gap-1">
<Volume2 className="size-3" />
{formatTime(duration)}
</span>
</div>
</div>
</div>
</div>
);
}
/** Small "now playing" chip used by the card header. */
export function NowPlayingChip() {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-signal/40 bg-signal/10 px-2 py-0.5 text-[0.6rem] font-medium text-signal">
<Signal className="size-3 animate-pulse" />
now playing
</span>
);
}