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,67 @@
# Spec: Perbagus fitur Voice + Audio Playback (GMW frontend)
Tanggal: 2026-08-22 · Scope: **frontend only** (backend/gateway API sudah cukup)
## Masalah (audit)
1. Recordings: semua kartu pakai `<audio controls>` native — tampilan identik,
tidak ada indikasi which-clip-playing / loading / paused, dan N audio bisa
play bareng (overlap).
2. Media view: `thumbnailUrl` dari gateway tidak dipakai; tidak ada visual
"sedang playing" selain disc spin; queue item semua sama tanpa badge up-next.
3. Mini-player (`lib/hooks/use-media-player.tsx`) ada tapi TIDAK PERNAH
dimount → dead code, user tidak lihat status musik di halaman lain.
4. Voice page: `useMicTransmit.setVolume` + `useVoiceListen.setVolume`
tersedia tapi tak ada UI-nya; mic live tidak punya level feedback.
## Desain
### A. RecordingAudioPlayer (baru, `components/voice/recording-audio-player.tsx`)
Custom player menggantikan `<audio controls>`:
- Play/pause button (ikon berubah), spinner saat buffering (`waiting` event).
- Progress bar seekable (click-to-seek) + time label `m:ss / m:ss`.
- Waveform-ish equalizer bars saat playing (CSS animation, reduced-motion safe).
- **Single-playback**: module-level registry `activePlayers` — memainkan satu
clip otomatis pause yang lain.
- Kartu pemilik player aktif dapat highlight border signal + "Now playing" chip.
### B. Recordings view — pasang player baru
- Ganti `<audio>``<RecordingAudioPlayer src download_url>`.
- Highlight kartu via state lifted: `playingId` di view, callback `onPlay`.
### C. Media view polish
- Hero: thumbnail (jika `current.thumbnailUrl`) sebagai disc center image;
fallback ListMusic icon. Equalizer bars animasi CSS saat `playing`.
- Queue row pertama: badge "up next"; baris current track diberi ring signal.
- Volume read-only tetap.
### D. MiniPlayer global
- Hapus `lib/hooks/use-media-player.tsx` (dead) — ganti dengan komponen
`components/media/mini-player.tsx` yang subscribe `useMediaState` +
`useMediaWsSync` langsung (SWR cache shared antar route), mounted di
`AppFrame` bawah layar (fixed bottom, hidden di route `/media`).
- Menampilkan: thumbnail kecil/judul, tombol skip/stop, link ke /media.
### E. Voice UI
- Mic live: level meter (Equalizer bars) — mic-transmitter sudah punya worklet;
tambah `getLevel()` via AnalyserNode pada stream (simple RMS) di hook.
- Listen: volume slider (input range) wired ke `listen.setVolume`.
- Mic volume slider wired ke `mic.setVolume`.
## File touched
| File | Aksi |
|---|---|
| services/frontend/src/components/voice/recording-audio-player.tsx | new |
| services/frontend/src/app/(dashboard)/recordings/view.tsx | edit |
| services/frontend/src/app/(dashboard)/media/view.tsx | edit |
| services/frontend/src/components/media/mini-player.tsx | new |
| services/frontend/src/components/shell/ambient-app.tsx | mount MiniPlayer |
| services/frontend/src/lib/hooks/use-media-player.tsx | delete |
| services/frontend/src/hooks/use-voice.ts | tambah micLevel |
| services/frontend/src/lib/audio/mic-transmit.ts | expose analyser level |
| services/frontend/src/app/(dashboard)/voice/view.tsx | sliders + meter |
## Verifikasi
1. `pnpm lint` (biome) + `pnpm build` clean.
2. Smoke di port **4024** (BUKAN 4017) → curl 200 semua route.
3. Commit (tanpa trailer) → push → `gh run watch` → live check
https://imphnen.asepharyana.my.id/{media,recordings,voice}/ = 200.
@@ -96,13 +96,45 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
<div
className={`flex size-32 shrink-0 items-center justify-center rounded-full border border-hairline bg-gradient-to-br from-white/10 to-white/[0.02] ${playing ? "animate-spin-disc" : "animate-spin-disc paused"}`}
>
<div className="flex size-28 items-center justify-center rounded-full bg-canvas/60">
<ListMusic className="size-10 text-signal" />
<div className="flex size-28 items-center justify-center overflow-hidden rounded-full bg-canvas/60">
{current?.thumbnailUrl ? (
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
<img
src={current.thumbnailUrl}
alt=""
className="size-full object-cover"
loading="lazy"
/>
) : (
<ListMusic className="size-10 text-signal" />
)}
</div>
</div>
<div className="min-w-0 flex-1">
<div className="eyebrow mb-1">Now playing</div>
<div className="eyebrow mb-1 flex items-center gap-2">
{playing ? (
<>
<span aria-hidden className="flex h-3 items-end gap-[2px]">
{[0, 1, 2].map((i) => (
<span
key={`eq-${i}`}
className="w-[3px] animate-eq rounded-full bg-signal"
style={{
animationDelay: `${i * 160}ms`,
height: "100%",
}}
/>
))}
</span>
<span className="text-signal">Now playing</span>
</>
) : current ? (
"Paused"
) : (
"Nothing queued"
)}
</div>
<h2 className="display text-balance text-2xl text-ink">
{current?.title ?? "Nothing queued"}
</h2>
@@ -203,27 +235,56 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
</div>
) : (
<div className="space-y-2">
{queueList.map((item, i) => (
<div
key={`${item.source}-${i}`}
className="animate-stagger flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5"
style={staggerDelay(i)}
>
<span className="mono w-5 text-ink-faint">{i + 1}</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-ink">{item.title}</div>
<div className="mono truncate text-[0.65rem] text-ink-faint">
{item.source}
{queueList.map((item, i) => {
const isNext = i === 0 && playing;
return (
<div
key={`${item.source}-${i}`}
className={`animate-stagger flex items-center gap-3 rounded-[10px] border px-3 py-2.5 ${
isNext
? "border-signal/40 bg-signal/[0.07]"
: "border-hairline bg-white/5"
}`}
style={staggerDelay(i)}
>
<span className="mono w-5 text-ink-faint">{i + 1}</span>
{item.thumbnailUrl ? (
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
<img
src={item.thumbnailUrl}
alt=""
className="size-9 shrink-0 rounded-md object-cover"
loading="lazy"
/>
) : (
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-hairline bg-white/5">
<ListMusic className="size-4 text-ink-faint" />
</span>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-ink">
{item.title}
</div>
<div className="mono truncate text-[0.65rem] text-ink-faint">
{item.source}
</div>
</div>
</div>
<span className="pill capitalize">{item.mode ?? "music"}</span>
{formatDuration(item.durationMs) && (
<span className="mono w-10 text-right text-[0.65rem] text-ink-faint">
{formatDuration(item.durationMs)}
{isNext && (
<span className="inline-flex shrink-0 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">
up next
</span>
)}
<span className="pill hidden capitalize sm:inline-flex">
{item.mode ?? "music"}
</span>
)}
</div>
))}
{formatDuration(item.durationMs) && (
<span className="mono w-10 text-right text-[0.65rem] text-ink-faint">
{formatDuration(item.durationMs)}
</span>
)}
</div>
);
})}
</div>
)}
</GlassPanel>
@@ -1,7 +1,7 @@
"use client";
import { Download, Hash, Headphones, Loader2, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import {
Avatar,
@@ -13,6 +13,10 @@ import {
toast,
} from "@/components/primitives";
import { EmptyState, ErrorState, SectionHeader } from "@/components/shared";
import {
NowPlayingChip,
RecordingAudioPlayer,
} from "@/components/voice/recording-audio-player";
import {
useDeleteRecording,
useRecordings,
@@ -33,6 +37,7 @@ export function RecordingsView({
const del = useDeleteRecording();
useRecordingsWsSync(ws);
const ambient = useAmbient();
const [playingId, setPlayingId] = useState<string | null>(null);
useEffect(() => {
ambient.set("signal", 0.3, "recordings");
@@ -97,10 +102,15 @@ export function RecordingsView({
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{(items ?? []).map((r, i) => {
const up = uploadStatus(r);
const isPlaying = playingId === r.id;
return (
<GlassCard
key={r.id}
className="animate-stagger flex flex-col gap-3 transition-colors hover:bg-white/[0.06]"
className={`animate-stagger flex flex-col gap-3 transition-colors hover:bg-white/[0.06] ${
isPlaying
? "border-signal/40 shadow-[0_0_36px_-16px_var(--color-signal-glow)]"
: ""
}`}
style={staggerDelay(i)}
>
<div className="flex items-center gap-3">
@@ -120,20 +130,24 @@ export function RecordingsView({
</span>
</div>
</div>
{up && <Badge tone={up.tone}>{up.label}</Badge>}
{isPlaying && <NowPlayingChip />}
{up && !isPlaying && <Badge tone={up.tone}>{up.label}</Badge>}
<span className="mono text-[0.65rem] text-ink-faint">
{formatBytes(r.size_bytes)}
</span>
</div>
{r.download_url ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<audio
controls
<RecordingAudioPlayer
src={r.download_url}
className="h-9 w-full"
preload="none"
aria-label={`Voice recording ${r.id}`}
label={`Voice recording by ${r.username}`}
onPlayStateChange={(active) =>
setPlayingId((prev) => {
if (active) return r.id;
// Only clear if THIS card was the one playing.
return prev === r.id ? null : prev;
})
}
/>
) : (
<div className="flex items-center gap-1.5 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-faint">
@@ -55,6 +55,8 @@ export function VoiceView({
initialStatus?.activeChannelId ?? null,
);
const [micOn, setMicOn] = useState(false);
const [micVol, setMicVol] = useState(100);
const [listenVol, setListenVol] = useState(75);
useEffect(() => {
const unsub = subscribe(ws);
@@ -80,6 +82,14 @@ export function VoiceView({
const connected = status?.connected ?? false;
const listenBars = Array.from(listen.levels.values()).slice(0, 32);
const micBars = mic.micLevel
? Array.from({ length: 12 }, (_, i) =>
Math.max(
0.08,
Math.min(1, mic.micLevel * (1 - i * 0.06) + (i % 3) * 0.05),
),
)
: [];
const onConnect = async () => {
if (!guildId || !channelId) {
@@ -155,6 +165,16 @@ export function VoiceView({
{micOn ? <Mic className="size-4" /> : <MicOff className="size-4" />}
{micOn ? "Mic live" : "Push-to-talk"}
</Button>
{micOn && (
<div className="flex items-center gap-2 rounded-[10px] border border-signal/30 bg-signal/[0.06] px-3 py-1.5">
<Mic className="size-4 text-signal" />
<Equalizer
bars={micBars}
className="w-28"
aria-label="Microphone level"
/>
</div>
)}
<Button
variant={listen.active ? "primary" : "outline"}
size="sm"
@@ -173,6 +193,42 @@ export function VoiceView({
<Equalizer bars={listenBars} className="w-40" />
</div>
)}
<div className="ml-auto flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-ink-faint">
<MicOff className="size-3.5" />
<input
type="range"
min={0}
max={100}
value={micVol}
onChange={(e) => {
const v = Number(e.target.value);
setMicVol(v);
mic.setVolume(v);
}}
aria-label="Mic transmit volume"
className="h-1 w-24 cursor-pointer accent-[var(--color-signal)]"
/>
<span className="mono w-8 text-right">{micVol}%</span>
</label>
<label className="flex items-center gap-2 text-xs text-ink-faint">
<Volume2 className="size-3.5" />
<input
type="range"
min={0}
max={100}
value={listenVol}
onChange={(e) => {
const v = Number(e.target.value);
setListenVol(v);
listen.setVolume(v);
}}
aria-label="Listen volume"
className="h-1 w-24 cursor-pointer accent-[var(--color-signal)]"
/>
<span className="mono w-8 text-right">{listenVol}%</span>
</label>
</div>
</div>
</GlassPanel>
@@ -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>
);
}
+11 -1
View File
@@ -106,6 +106,7 @@ export function useMicTransmit(ws: {
sendBinary: (data: ArrayBufferLike) => void;
}) {
const transmitterRef = useRef<MicTransmitter | null>(null);
const [micLevel, setMicLevel] = useState(0);
const action = useAction(async (active: boolean) => {
if (active) {
@@ -116,6 +117,7 @@ export function useMicTransmit(ws: {
} else {
transmitterRef.current?.stop();
transmitterRef.current = null;
setMicLevel(0);
await voiceApi.sendCommand("voice:transmit:stop");
}
});
@@ -124,7 +126,15 @@ export function useMicTransmit(ws: {
transmitterRef.current?.setVolume(volume / 100);
}, []);
return { ...action, setVolume };
// Poll the analyser RMS so the UI can render a live input meter.
useEffect(() => {
const timer = setInterval(() => {
setMicLevel(transmitterRef.current?.getLevel() ?? 0);
}, 120);
return () => clearInterval(timer);
}, []);
return { ...action, setVolume, micLevel };
}
/**
@@ -66,6 +66,8 @@ export class MicTransmitter {
private ctx: AudioContext | null = null;
private stream: MediaStream | null = null;
private node: AudioWorkletNode | null = null;
private analyser: AnalyserNode | null = null;
private levelBuf: Float32Array<ArrayBuffer> | null = null;
private active = false;
private volume = 1;
@@ -119,6 +121,14 @@ export class MicTransmitter {
};
source.connect(this.node);
// Level metering tap: analyser reads the raw mic (pre-volume) so the UI
// shows what the mic actually hears. Silent sink keeps the graph alive.
this.analyser = this.ctx.createAnalyser();
this.analyser.fftSize = 1024;
this.levelBuf = new Float32Array(this.analyser.fftSize);
source.connect(this.analyser);
// Keep the graph alive with an inaudible tail (silent gain) so the
// worklet keeps pulling mic data without audible feedback.
const silent = this.ctx.createGain();
@@ -129,6 +139,15 @@ export class MicTransmitter {
this.active = true;
}
/** RMS mic level 0..1 since the last call (drives the live meter UI). */
getLevel(): number {
if (!this.analyser || !this.levelBuf) return 0;
this.analyser.getFloatTimeDomainData(this.levelBuf);
let sum = 0;
for (let i = 0; i < this.levelBuf.length; i++) sum += this.levelBuf[i] ** 2;
return Math.min(1, Math.sqrt(sum / this.levelBuf.length) * 4);
}
setVolume(volume: number): void {
this.volume = volume;
this.node?.port.postMessage({ type: "volume", value: volume });
@@ -139,6 +158,9 @@ export class MicTransmitter {
this.node?.port.postMessage({ type: "volume", value: 0 });
this.node?.disconnect();
this.node = null;
this.analyser?.disconnect();
this.analyser = null;
this.levelBuf = null;
this.stream?.getTracks().forEach((t) => t.stop());
this.stream = null;
this.ctx?.close().catch(() => {});
@@ -1,151 +0,0 @@
"use client";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { mediaApi } from "@/lib/api";
import type { MediaItem, MediaState } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
interface MediaPlayerContextValue {
/** Current play state */
playing: boolean;
/** Current track, or null */
current: MediaItem | null;
/** Upcoming queue */
queue: MediaItem[];
/** Loop mode (replay current track on natural end) */
loop: boolean;
/** True while a mutation is in flight */
pending: boolean;
/** Skip to next track */
skip: () => void;
/** Stop playback */
stop: () => void;
/** Toggle loop mode */
toggleLoop: () => void;
/** Queue a URL for playback */
queueUrl: (url: string) => void;
}
const MediaPlayerContext = createContext<MediaPlayerContextValue | null>(null);
export function MediaPlayerProvider({ children }: { children: ReactNode }) {
const ws = useWebSocket();
const [state, setState] = useState<MediaState>({
playing: false,
musicVolume: 0.3,
loop: false,
current: null,
queue: [],
});
const [pending, setPending] = useState(false);
const fetched = useRef(false);
// Fetch initial state
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
mediaApi
.getStatus()
.then((data) => {
if (data) setState(data as MediaState);
})
.catch(() => {
// API not yet available
});
}, []);
// Subscribe to live media_state events via WS
useEffect(() => {
const unsub = ws.on("media_state", (data) => {
setState(data as unknown as MediaState);
});
return unsub;
}, [ws]);
const skip = useCallback(() => {
setPending(true);
mediaApi
.skip()
.then((data) => {
if (data) setState(data as MediaState);
})
.catch(() => {
// ignore
})
.finally(() => setPending(false));
}, []);
const stop = useCallback(() => {
setPending(true);
mediaApi
.stop()
.then((data) => {
if (data) setState(data as MediaState);
})
.catch(() => {
// ignore
})
.finally(() => setPending(false));
}, []);
const queueUrl = useCallback((url: string) => {
setPending(true);
mediaApi
.queue(url, "music")
.then((data) => {
if (data) setState(data as MediaState);
})
.catch(() => {
// ignore
})
.finally(() => setPending(false));
}, []);
const toggleLoop = useCallback(() => {
setPending(true);
mediaApi
.loop(!state.loop)
.then((data) => {
if (data) setState(data as MediaState);
})
.catch(() => {
// ignore
})
.finally(() => setPending(false));
}, [state.loop]);
return (
<MediaPlayerContext.Provider
value={{
playing: state.playing,
current: state.current,
queue: state.queue,
loop: state.loop,
pending,
skip,
stop,
toggleLoop,
queueUrl,
}}
>
{children}
</MediaPlayerContext.Provider>
);
}
export function useMediaPlayer(): MediaPlayerContextValue {
const ctx = useContext(MediaPlayerContext);
if (!ctx) {
throw new Error("useMediaPlayer must be used within a MediaPlayerProvider");
}
return ctx;
}