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
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:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Play } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Download, Loader2, Play } from "lucide-react";
|
||||
import { GlassCard } from "@/components/glass/card";
|
||||
import type { VoiceRecording } from "@/lib/types";
|
||||
|
||||
@@ -10,10 +11,36 @@ interface RecordingCardProps {
|
||||
}
|
||||
|
||||
export function RecordingCard({ recording, onPlay }: RecordingCardProps) {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const durationStr = recording.duration_bytes
|
||||
? `${Math.floor(recording.duration_bytes / 60)}:${String(recording.duration_bytes % 60).padStart(2, "0")}`
|
||||
: "--:--";
|
||||
|
||||
// Fetch the file (CORS is open on the uploader) → blob → force download with
|
||||
// the real filename. Falls back to opening the URL in a new tab.
|
||||
const handleDownload = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!recording.download_url || downloading) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const res = await fetch(recording.download_url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const objUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = objUrl;
|
||||
a.download = recording.filename ?? `recording-${recording.id}.mp3`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(objUrl), 30_000);
|
||||
} catch {
|
||||
window.open(recording.download_url, "_blank", "noopener,noreferrer");
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard variant="interactive" className="p-4" onClick={() => onPlay(recording.id)}>
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -50,9 +77,19 @@ export function RecordingCard({ recording, onPlay }: RecordingCardProps) {
|
||||
|
||||
<div className="flex gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{recording.download_url && (
|
||||
<a href={recording.download_url} target="_blank" rel="noopener noreferrer" className="size-7 flex items-center justify-center rounded glass hover:glass-elevated transition-all">
|
||||
<Download className="size-3 text-text-secondary/60" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
title="Download"
|
||||
className="size-7 flex items-center justify-center rounded glass hover:glass-elevated transition-all disabled:opacity-50"
|
||||
>
|
||||
{downloading ? (
|
||||
<Loader2 className="size-3 text-text-secondary/60 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-3 text-text-secondary/60" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { GlassPanel } from "@/components/glass/panel";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface RecordingPlayerProps {
|
||||
url?: string;
|
||||
filename?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RecordingPlayer({ url, onClose }: RecordingPlayerProps) {
|
||||
export function RecordingPlayer({ url, filename, onClose }: RecordingPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (url && audioRef.current) {
|
||||
audioRef.current?.play().catch(() => {});
|
||||
}
|
||||
const audio = audioRef.current;
|
||||
if (!url || !audio) return;
|
||||
setError(false);
|
||||
// Fresh element state: reset src, load, then play (the click that opened
|
||||
// the player counts as a user gesture, so autoplay is allowed).
|
||||
audio.src = url;
|
||||
audio.load();
|
||||
const p = audio.play();
|
||||
if (p) p.catch(() => setError(true));
|
||||
}, [url]);
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
return (
|
||||
<GlassPanel dense className="fixed bottom-20 left-4 z-30 w-72 flex items-center gap-3">
|
||||
<audio ref={audioRef} src={url} controls className="flex-1 h-8 [&::-webkit-media-controls-panel]:bg-transparent" autoPlay />
|
||||
<button type="button" onClick={onClose}>
|
||||
<X className="size-3.5 text-text-secondary/60 hover:text-text-primary" />
|
||||
</button>
|
||||
<GlassPanel dense className="fixed bottom-20 left-4 z-30 w-80 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
controls
|
||||
preload="auto"
|
||||
className="flex-1 h-8 [&::-webkit-media-controls-panel]:bg-transparent"
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
<button type="button" onClick={onClose} className="shrink-0">
|
||||
<X className="size-3.5 text-text-secondary/60 hover:text-text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-0.5">
|
||||
<span className="truncate text-[10px] font-mono text-text-secondary/60">
|
||||
{filename ?? "recording"}
|
||||
</span>
|
||||
{error && (
|
||||
<span className="shrink-0 text-[10px] text-red-400/90">
|
||||
playback failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { GlassCard } from "@/components/glass/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { hashUserId } from "@/hooks";
|
||||
import type { ActiveSpeaker } from "@/lib/types";
|
||||
import { Headphones, HeadphoneOff } from "lucide-react";
|
||||
|
||||
interface ListenControlProps {
|
||||
connected: boolean;
|
||||
active: boolean;
|
||||
levels: Map<number, number>;
|
||||
speakers: ActiveSpeaker[];
|
||||
onToggle: (active: boolean) => void;
|
||||
volume: number;
|
||||
onVolumeChange: (v: number) => void;
|
||||
}
|
||||
|
||||
/** Live bar for one speaker — level 0..1 from the PCM player. */
|
||||
function SpeakerLevel({
|
||||
speaker,
|
||||
level,
|
||||
}: {
|
||||
speaker: ActiveSpeaker;
|
||||
level: number;
|
||||
}) {
|
||||
const [bars, setBars] = useState<number[]>(Array(24).fill(0.06));
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setBars((prev) => {
|
||||
const next = [...prev];
|
||||
for (let i = 0; i < next.length; i++) {
|
||||
const target = level > 0.004 ? level * 0.9 + 0.08 : 0.05;
|
||||
next[i] = next[i] + (target - next[i]) * 0.35;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 60);
|
||||
return () => clearInterval(id);
|
||||
}, [level]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-24 truncate text-[11px] text-text-secondary">
|
||||
{speaker.username}
|
||||
</span>
|
||||
<div className="flex flex-1 items-end gap-[2px] h-6">
|
||||
{bars.map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-t-sm bg-primary/70 transition-[height]"
|
||||
style={{ height: `${Math.max(6, h * 100)}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="w-8 text-right font-mono text-[10px] text-text-secondary/50">
|
||||
{Math.round(level * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListenControl({
|
||||
connected,
|
||||
active,
|
||||
levels,
|
||||
speakers,
|
||||
onToggle,
|
||||
volume,
|
||||
onVolumeChange,
|
||||
}: ListenControlProps) {
|
||||
const activeLevels = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const s of speakers) {
|
||||
const lvl = levels.get(hashUserId(s.userId)) ?? 0;
|
||||
map.set(s.userId, lvl);
|
||||
}
|
||||
return map;
|
||||
}, [speakers, levels]);
|
||||
|
||||
const talking = useMemo(
|
||||
() => [...activeLevels.values()].some((l) => l > 0.004),
|
||||
[activeLevels],
|
||||
);
|
||||
|
||||
return (
|
||||
<GlassCard variant="base">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant={active ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => onToggle(!active)}
|
||||
disabled={!connected}
|
||||
className="h-9"
|
||||
>
|
||||
{active ? (
|
||||
<Headphones className="size-4 mr-1" />
|
||||
) : (
|
||||
<HeadphoneOff className="size-4 mr-1" />
|
||||
)}
|
||||
{active ? "Listening" : "Listen"}
|
||||
</Button>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<span className="text-[10px] text-text-secondary/60 font-mono">Vol</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(Number(e.target.value))}
|
||||
className="flex-1 h-1 appearance-none bg-glass-border rounded-full accent-primary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow-[0_0_8px] [&::-webkit-slider-thumb]:shadow-primary/60"
|
||||
/>
|
||||
<span className="text-[10px] font-mono text-text-secondary w-8 text-right">
|
||||
{volume}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1">
|
||||
{active && speakers.length > 0 ? (
|
||||
speakers.map((s) => (
|
||||
<SpeakerLevel
|
||||
key={s.userId}
|
||||
speaker={s}
|
||||
level={activeLevels.get(s.userId) ?? 0}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[11px] text-text-secondary/40">
|
||||
{!connected
|
||||
? "Connect to a voice channel first."
|
||||
: active
|
||||
? "Listening for Discord voice…"
|
||||
: "Toggle Listen to hear Discord voice."}
|
||||
</span>
|
||||
)}
|
||||
{active && talking && (
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] text-primary/80">
|
||||
<span className="relative flex size-1.5">
|
||||
<span className="absolute inline-flex size-full rounded-full bg-primary opacity-75 live-pulse-ring" />
|
||||
<span className="relative inline-flex size-1.5 rounded-full bg-primary" />
|
||||
</span>
|
||||
live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user