feat(voice): implement stale speaker management and clear functionality

This commit is contained in:
asepharyana
2026-08-26 23:16:57 +07:00
parent 611ba39973
commit 4f4f92706c
7 changed files with 178 additions and 28 deletions
@@ -53,6 +53,32 @@ export function VoiceView({
);
const [micVol, setMicVol] = useState(100);
const [listenVol, setListenVol] = useState(75);
const [micActive, setMicActive] = useState(false);
const [listenActive, setListenActive] = useState(false);
const toggleMic = async () => {
const next = !micActive;
try {
await mic.mutateAsync(next);
setMicActive(next);
toast({
title: next ? "Mic activated" : "Mic deactivated",
tone: next ? "signal" : "neutral",
});
} catch (e) {
toast({ title: "Mic toggle failed", description: String(e), tone: "vermilion" });
}
};
const toggleListen = () => {
const next = !listenActive;
listen.toggle(next);
setListenActive(next);
toast({
title: next ? "Monitor activated" : "Monitor deactivated",
tone: next ? "signal" : "neutral",
});
};
const containerRef = useStaggerReveal<HTMLDivElement>(".voice-tile", {
stagger: 0.04,
@@ -202,12 +228,21 @@ export function VoiceView({
<div>
<SectionHeader eyebrow="Telemetry" title="Input / Output Mix" />
<div className="mt-4 space-y-4">
{/* Mic Toggle */}
<div>
<div className="flex justify-between text-xs font-medium text-ink-soft">
<span className="flex items-center gap-1.5">
<Mic className="size-3.5 text-signal" />
Mic Sensitivity
</span>
<div className="flex items-center justify-between">
<button
type="button"
onClick={toggleMic}
className={`flex items-center gap-1.5 rounded-[8px] px-3 py-1.5 text-xs font-medium transition-all ${
micActive
? "bg-signal/15 text-signal border border-signal/40 glow-pulse"
: "bg-surface-2 text-ink-muted border border-hairline hover:border-signal/30 hover:text-ink"
}`}
>
<Mic className="size-3.5" />
{micActive ? "MIC LIVE" : "MIC OFF"}
</button>
<span className="font-mono text-[11px] text-ink-muted">
{micVol}%
</span>
@@ -224,14 +259,32 @@ export function VoiceView({
}}
className="mt-2 h-1.5 w-full appearance-none rounded-full bg-surface-2 accent-signal"
/>
{/* Live mic level meter */}
{micActive && (
<div className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-surface-2">
<div
className="h-full rounded-full bg-signal transition-all duration-100"
style={{ width: `${Math.min(100, mic.micLevel * 100)}%` }}
/>
</div>
)}
</div>
{/* Listen Toggle */}
<div>
<div className="flex justify-between text-xs font-medium text-ink-soft">
<span className="flex items-center gap-1.5">
<Volume2 className="size-3.5 text-success" />
Monitor Output
</span>
<div className="flex items-center justify-between">
<button
type="button"
onClick={toggleListen}
className={`flex items-center gap-1.5 rounded-[8px] px-3 py-1.5 text-xs font-medium transition-all ${
listenActive
? "bg-success/15 text-success border border-success/40 glow-pulse"
: "bg-surface-2 text-ink-muted border border-hairline hover:border-success/30 hover:text-ink"
}`}
>
<Volume2 className="size-3.5" />
{listenActive ? "MONITOR LIVE" : "MONITOR OFF"}
</button>
<span className="font-mono text-[11px] text-ink-muted">
{listenVol}%
</span>
@@ -248,6 +301,24 @@ export function VoiceView({
}}
className="mt-2 h-1.5 w-full appearance-none rounded-full bg-surface-2 accent-success"
/>
{/* Per-speaker level meters */}
{listenActive && listen.levels.size > 0 && (
<div className="mt-2 space-y-1">
{Array.from(listen.levels.entries()).map(([hash, level]) => (
<div key={hash} className="flex items-center gap-2">
<span className="font-mono text-[9px] text-ink-faint w-8">
#{hash.toString(16).slice(-3)}
</span>
<div className="h-1 flex-1 overflow-hidden rounded-full bg-surface-2">
<div
className="h-full rounded-full bg-success transition-all duration-100"
style={{ width: `${Math.min(100, level * 100)}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
@@ -7,9 +7,11 @@ import type { ActiveSpeaker } from "@/lib/types";
export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) {
const containerRef = useRef<HTMLDivElement>(null);
const n = speakers.length;
const speaking = speakers.filter((s) => s.speaking).length;
const live = speaking > 0;
// Only show actively speaking users on the stage orbit
const activeSpeakers = speakers.filter((s) => s.speaking);
const n = activeSpeakers.length;
const totalConnected = speakers.length;
const live = n > 0;
// CSS stagger reveal for speaker nodes
useEffect(() => {
@@ -92,11 +94,11 @@ export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) {
className={`size-7 transition-colors ${live ? "text-signal animate-breathe" : "text-ink-faint"}`}
/>
<span className="font-mono mt-1 text-[11px] font-bold tracking-wider text-ink uppercase">
{live ? `${speaking} SPEAKING` : `${n} CONNECTED`}
{live ? `${n} SPEAKING` : `${totalConnected} CONNECTED`}
</span>
</div>
{speakers.map((s, i) => {
{activeSpeakers.map((s, i) => {
const angle = (i / Math.max(n, 1)) * Math.PI * 2 - Math.PI / 2;
const radius = 44;
const x = 50 + radius * Math.cos(angle);
+27 -4
View File
@@ -47,6 +47,18 @@ const SPEAKERS_KEY = ["voice-speakers"] as const;
* This replaces the old per-browser model where each tab accumulated speakers
* only from events it happened to receive while mounted.
*/
const SPEAKER_TTL_MS = 30_000;
/** Remove speakers that haven't been active recently. */
function filterStale(speakers: ActiveSpeaker[]): ActiveSpeaker[] {
const now = Date.now();
return speakers.filter((s) => {
if (s.speaking) return true;
if (s.lastActiveAt && now - s.lastActiveAt > SPEAKER_TTL_MS) return false;
return true;
});
}
export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) {
const {
data: speakers,
@@ -65,7 +77,7 @@ export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) {
const unsubSnapshot = ws.on("voice_state", (data) => {
const state = data as { activeSpeakers?: ActiveSpeaker[] };
if (Array.isArray(state?.activeSpeakers)) {
void mutate(state.activeSpeakers, { revalidate: false });
void mutate(filterStale(state.activeSpeakers), { revalidate: false });
}
});
const unsub = ws.on("voice_active_user", (data) => {
@@ -74,12 +86,13 @@ export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) {
(prev: ActiveSpeaker[] | undefined) => {
const arr = prev ?? [];
const idx = arr.findIndex((s) => s.userId === speaker.userId);
const next = [...arr];
if (idx >= 0) {
const next = [...arr];
next[idx] = speaker;
return next;
} else {
next.push(speaker);
}
return [...arr, speaker];
return filterStale(next);
},
{ revalidate: false },
);
@@ -92,6 +105,16 @@ export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) {
[mutate],
);
// Periodic stale speaker cleanup (every 10s)
useEffect(() => {
const timer = setInterval(() => {
void mutate((prev) => (prev ? filterStale(prev) : prev), {
revalidate: false,
});
}, 10_000);
return () => clearInterval(timer);
}, [mutate]);
return { speakers: speakers ?? [], subscribe, error, isValidating };
}
+2
View File
@@ -24,4 +24,6 @@ export interface ActiveSpeaker {
username: string;
avatar?: string | null;
speaking: boolean;
/** Epoch ms of most recent activity. Stale speakers are auto-expired. */
lastActiveAt?: number;
}