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
@@ -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>