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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user