feat(voice): implement stale speaker management and clear functionality
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user