feat(fe): play Discord voice live + fix recording play/download
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m42s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 4m11s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 11m4s

Voice page (connection tab):
- ListenControl baru: toggle Listen (Headphones) — mulai PcmPlayer dari
  user gesture, subscribe onPcm WS, volume slider, bar level per-user
  REAL dari PCM (bukan random).
- lib/audio/pcm-player.ts (baru): ScriptProcessorNode mixer — ring buffer
  2s per user (hash FNV-1a sama dengan gateway), upsampling 24k→48k
  linear, mix semua user ke mono, gain volume, cleanup ring diam 5s.
- useVoiceListen + hashUserId di hooks; auto-stop saat disconnect.

Recordings:
- recording-player: reset src+load+play() eksplisit (bukan autoPlay doang),
  tampilkan filename + error state 'playback failed' kalau file rusak.
- recording-card: tombol Download fetch blob (CORS tele open) → objectURL
  → force download dengan nama asli; fallback buka tab baru kalau fetch
  gagal; spinner saat mendownload.

Verified: FE tsc 0, next build 10/10 static pages.
This commit is contained in:
asepharyana
2026-08-01 16:30:56 +07:00
parent 6ce784471e
commit 762e78d6b6
8 changed files with 474 additions and 22 deletions
@@ -153,6 +153,7 @@ export default function RecordingsPage() {
<RecordingPlayer
url={currentTrack?.download_url ?? undefined}
filename={currentTrack?.filename ?? undefined}
onClose={() => setPlayingId(null)}
/>
</div>
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { SubNav } from "@/components/layout/sub-nav";
import { VoiceActivityTimeline } from "@/components/voice/activity-timeline";
import { VoiceConnectionCard } from "@/components/voice/connection-card";
import { ListenControl } from "@/components/voice/listen-control";
import { MicControl } from "@/components/voice/mic-control";
import { SpeakerWaveform } from "@/components/voice/speaker-waveform";
import {
@@ -13,6 +14,7 @@ import {
useVoiceChannels,
useVoiceConnect,
useVoiceDisconnect,
useVoiceListen,
useVoiceStatus,
} from "@/hooks";
import { useWebSocket } from "@/lib/ws/context";
@@ -29,9 +31,11 @@ export default function VoicePage() {
const connectMut = useVoiceConnect();
const disconnectMut = useVoiceDisconnect();
const micMut = useMicTransmit(ws);
const listen = useVoiceListen(ws);
const [selectedChannel, setSelectedChannel] = useState("");
const [micActive, setMicActive] = useState(false);
const [volume, setVolume] = useState(75);
const [listenVolume, setListenVolume] = useState(75);
const [tab, setTab] = useState<VoiceTab>("connection");
useEffect(() => {
@@ -111,6 +115,7 @@ export default function VoicePage() {
setMicActive(false);
void micMut.mutateAsync(false).catch(() => {});
}
if (listen.active) listen.toggle(false);
disconnectMut.mutate(undefined);
}}
connecting={connectMut.isPending}
@@ -119,13 +124,27 @@ export default function VoicePage() {
{tab === "connection" && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<SpeakerWaveform speakers={activeSpeakers} />
<MicControl
connected={connected}
active={micActive}
onToggle={handleMicToggle}
volume={volume}
onVolumeChange={handleVolumeChange}
/>
<div className="space-y-4">
<ListenControl
connected={connected}
active={listen.active}
levels={listen.levels}
speakers={speakers}
onToggle={(on) => listen.toggle(on)}
volume={listenVolume}
onVolumeChange={(v) => {
setListenVolume(v);
listen.setVolume(v);
}}
/>
<MicControl
connected={connected}
active={micActive}
onToggle={handleMicToggle}
volume={volume}
onVolumeChange={handleVolumeChange}
/>
</div>
</div>
)}