fix: reliable speaking indicator via direct store writes + polling safety net

Eliminate double-state architecture (useState → useEffect bridge → store)
that lost speaking events due to React 18 batching. ActiveSpeakersChanged
now writes speakingParticipantIds directly to voiceStore; 200ms poll
catches missed SDK events. Each VoiceUser subscribes to its own identity
via fine-grained selector for minimal re-renders. Also adds connection
quality indicator and ConnectionInfoPopover.
This commit is contained in:
Jannis Braun
2026-02-23 02:36:39 +01:00
parent 176f4db27e
commit 9091f08ced
10 changed files with 604 additions and 46 deletions
@@ -93,12 +93,10 @@ export function AppLayout() {
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const activeDmCall = useVoiceStore((s) => s.activeDmCall); const activeDmCall = useVoiceStore((s) => s.activeDmCall);
const setParticipants = useVoiceStore((s) => s.setParticipants);
const { const {
connect: connectVoice, connect: connectVoice,
connectDm: connectDmVoice, connectDm: connectDmVoice,
disconnect: disconnectVoice, disconnect: disconnectVoice,
participants: voiceParticipants,
isConnected: isVoiceConnected, isConnected: isVoiceConnected,
isConnecting: isVoiceConnecting, isConnecting: isVoiceConnecting,
connectedChannelId, connectedChannelId,
@@ -107,11 +105,6 @@ export function AppLayout() {
// Initialize WebSocket // Initialize WebSocket
const { isConnected: isWsConnected } = useWebSocket(); const { isConnected: isWsConnected } = useWebSocket();
// Sync participants to store
useEffect(() => {
setParticipants(voiceParticipants);
}, [voiceParticipants, setParticipants]);
// Track the last channel we attempted to connect to, to prevent effect loops // Track the last channel we attempted to connect to, to prevent effect loops
const lastAttemptedRef = React.useRef<string | null>(null); const lastAttemptedRef = React.useRef<string | null>(null);
@@ -26,6 +26,8 @@ export function MainContent() {
const setVoiceFullscreen = useUIStore((s) => s.setVoiceFullscreen); const setVoiceFullscreen = useUIStore((s) => s.setVoiceFullscreen);
const participants = useVoiceStore((s) => s.participants); const participants = useVoiceStore((s) => s.participants);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
const connectionError = useVoiceStore((s) => s.connectionError);
const showDms = useUIStore((s) => s.showDms); const showDms = useUIStore((s) => s.showDms);
const activeDmCall = useVoiceStore((s) => s.activeDmCall); const activeDmCall = useVoiceStore((s) => s.activeDmCall);
const outgoingCall = useVoiceStore((s) => s.outgoingCall); const outgoingCall = useVoiceStore((s) => s.outgoingCall);
@@ -236,8 +238,16 @@ export function MainContent() {
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" /> <path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" />
</svg> </svg>
<span className="font-bold text-discord-text-primary">{channel.name}</span> <span className="font-bold text-discord-text-primary">{channel.name}</span>
<span className="text-xs text-discord-green font-medium ml-2">Connected</span> {connectionError ? (
<span className="text-xs text-discord-text-muted ml-1">{participants.length} connected</span> <span className="text-xs text-discord-red font-medium ml-2">Connection Failed</span>
) : isLiveKitConnected ? (
<>
<span className="text-xs text-discord-green font-medium ml-2">Connected</span>
<span className="text-xs text-discord-text-muted ml-1">{participants.length} connected</span>
</>
) : (
<span className="text-xs text-discord-yellow font-medium ml-2">Connecting...</span>
)}
</div> </div>
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
<MemberListToggleButton /> <MemberListToggleButton />
@@ -0,0 +1,461 @@
import React, { useState, useEffect, useRef } from 'react';
import { Track } from 'livekit-client';
import { getActiveRoom } from '../../hooks/useLiveKit';
interface ConnectionInfoPopoverProps {
open: boolean;
onClose: () => void;
}
interface ConnectionStats {
ping: number | null;
packetLoss: number | null;
jitter: number | null;
audioUp: number | null;
audioDown: number | null;
videoUp: number | null;
videoDown: number | null;
audioCodec: string | null;
videoCodec: string | null;
resolution: string | null;
fps: number | null;
qualityLimitation: string | null;
serverAddress: string | null;
protocol: string | null;
candidateType: string | null;
}
interface PrevSample {
bytes: number;
timestamp: number;
}
/**
* Discover all unique RTCPeerConnections from the LiveKit Room engine.
* Different livekit-client versions expose the PC at different internal paths.
* We collect all of them and deduplicate, so the stats loop can process
* outbound-rtp and inbound-rtp reports regardless of transport architecture
* (split publisher/subscriber vs unified-plan single PC).
*/
function discoverPeerConnections(room: any): RTCPeerConnection[] {
const engine = room?.engine;
if (!engine) return [];
const pcs: RTCPeerConnection[] = [];
const seen = new WeakSet<object>();
const tryAdd = (val: any) => {
if (val && typeof val.getStats === 'function' && !seen.has(val)) {
seen.add(val);
pcs.push(val);
}
};
// Current livekit-client (1.x+): engine.pcManager.{publisher,subscriber}.pc
tryAdd(engine.pcManager?.publisher?.pc);
tryAdd(engine.pcManager?.subscriber?.pc);
// Private backing field fallback
tryAdd(engine.pcManager?.publisher?._pc);
tryAdd(engine.pcManager?.subscriber?._pc);
// Older livekit-client paths
tryAdd(engine.publisher?.pc);
tryAdd(engine.subscriber?.pc);
// Unified-plan single PC
tryAdd(engine.pc);
tryAdd(room.pc);
return pcs;
}
/** Determine media kind from a WebRTC stats report (handles both spec and legacy fields). */
function reportKind(report: any): 'audio' | 'video' | null {
const k = report.kind ?? report.mediaType;
if (k === 'audio' || k === 'video') return k;
return null;
}
function formatBitrate(bps: number | null): string {
if (bps === null) return '\u2014';
if (bps < 1000) return `${Math.round(bps)} kbps`;
return `${(bps / 1000).toFixed(bps >= 10000 ? 0 : 1)} Mbps`;
}
function pingColor(ms: number): string {
if (ms <= 80) return 'text-discord-green';
if (ms <= 200) return 'text-discord-yellow';
return 'text-discord-red';
}
function lossColor(pct: number): string {
if (pct <= 1) return 'text-discord-green';
if (pct <= 5) return 'text-discord-yellow';
return 'text-discord-red';
}
function jitterColor(ms: number): string {
if (ms <= 30) return 'text-discord-green';
if (ms <= 80) return 'text-discord-yellow';
return 'text-discord-red';
}
export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const prevSampleRef = useRef<Map<string, PrevSample>>(new Map());
const [stats, setStats] = useState<ConnectionStats | null>(null);
// Click-outside to close
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open, onClose]);
// Stats polling — only while open
useEffect(() => {
if (!open) {
prevSampleRef.current.clear();
setStats(null);
return;
}
const poll = async () => {
const room = getActiveRoom();
if (!room) {
setStats(null);
return;
}
const pcs = discoverPeerConnections(room as any);
if (pcs.length === 0) {
setStats(null);
return;
}
const result: ConnectionStats = {
ping: null,
packetLoss: null,
jitter: null,
audioUp: null,
audioDown: null,
videoUp: null,
videoDown: null,
audioCodec: null,
videoCodec: null,
resolution: null,
fps: null,
qualityLimitation: null,
serverAddress: null,
protocol: null,
candidateType: null,
};
const prev = prevSampleRef.current;
const now = performance.now();
// Accumulators for inbound aggregate metrics
let totalPacketsReceived = 0;
let totalPacketsLost = 0;
let totalAudioDown = 0;
let hasAudioDown = false;
let totalVideoDown = 0;
let hasVideoDown = false;
// Track candidate-pair remote ID for Safari fallback (see after forEach)
let selectedCandidatePairRemoteId: string | null = null;
// Process stats from ALL discovered PeerConnections.
// Report types (outbound-rtp, inbound-rtp, candidate-pair, etc.) are
// self-describing, so we don't need to know which PC they came from.
for (const pc of pcs) {
let reports: RTCStatsReport;
try {
reports = await pc.getStats();
} catch {
continue;
}
// Build codec map for this PC's stats (codecId → short name)
const codecMap = new Map<string, string>();
reports.forEach((report: any) => {
if (report.type === 'codec') {
codecMap.set(report.id, report.mimeType?.split('/')[1] ?? report.mimeType ?? '');
}
});
reports.forEach((report: any) => {
const kind = reportKind(report);
// ── RTT from candidate-pair ──
if (
report.type === 'candidate-pair' &&
report.state === 'succeeded'
) {
if (report.currentRoundTripTime != null) {
result.ping = Math.round(report.currentRoundTripTime * 1000);
}
// Safari fallback: collect remote candidate ID for post-loop lookup
if (!result.serverAddress && report.remoteCandidateId) {
selectedCandidatePairRemoteId = report.remoteCandidateId;
}
}
// ── Outbound (publisher) ──
if (report.type === 'outbound-rtp') {
const key = `out-${report.ssrc}`;
const prevEntry = prev.get(key);
if (kind === 'audio') {
if (prevEntry) {
const deltaBits = (report.bytesSent - prevEntry.bytes) * 8;
const deltaMs = now - prevEntry.timestamp;
if (deltaMs > 0) result.audioUp = deltaBits / deltaMs;
}
prev.set(key, { bytes: report.bytesSent, timestamp: now });
if (report.codecId && codecMap.has(report.codecId)) {
result.audioCodec = codecMap.get(report.codecId)!;
}
}
if (kind === 'video') {
if (prevEntry) {
const deltaBits = (report.bytesSent - prevEntry.bytes) * 8;
const deltaMs = now - prevEntry.timestamp;
if (deltaMs > 0) {
const bitrate = deltaBits / deltaMs;
// Only accumulate when bitrate > 0 — filters stale outbound-rtp
// reports that Safari/Chrome retain after camera/screenshare stops
if (bitrate > 0) {
result.videoUp = (result.videoUp ?? 0) + bitrate;
}
}
}
prev.set(key, { bytes: report.bytesSent, timestamp: now });
if (report.codecId && codecMap.has(report.codecId)) {
result.videoCodec = codecMap.get(report.codecId)!;
}
// Resolution + FPS — take the active track (non-zero dimensions)
if (report.frameWidth > 0 && report.frameHeight > 0) {
result.resolution = `${report.frameWidth}\u00d7${report.frameHeight}`;
result.fps = Math.round(report.framesPerSecond || 0);
}
if (report.qualityLimitationReason) {
result.qualityLimitation = report.qualityLimitationReason;
}
}
}
// ── Inbound (subscriber) ──
if (report.type === 'inbound-rtp') {
const key = `in-${report.ssrc}`;
const prevEntry = prev.get(key);
if (kind === 'audio') {
if (result.jitter === null && report.jitter != null) {
result.jitter = Math.round(report.jitter * 1000);
}
totalPacketsReceived += report.packetsReceived || 0;
totalPacketsLost += report.packetsLost || 0;
if (prevEntry) {
const deltaBits = (report.bytesReceived - prevEntry.bytes) * 8;
const deltaMs = now - prevEntry.timestamp;
if (deltaMs > 0) {
totalAudioDown += deltaBits / deltaMs;
hasAudioDown = true;
}
}
prev.set(key, { bytes: report.bytesReceived, timestamp: now });
}
if (kind === 'video') {
totalPacketsReceived += report.packetsReceived || 0;
totalPacketsLost += report.packetsLost || 0;
if (prevEntry) {
const deltaBits = (report.bytesReceived - prevEntry.bytes) * 8;
const deltaMs = now - prevEntry.timestamp;
if (deltaMs > 0) {
const bitrate = deltaBits / deltaMs;
// Only accumulate when bitrate > 0 — filters stale inbound-rtp
if (bitrate > 0) {
totalVideoDown += bitrate;
hasVideoDown = true;
}
}
}
prev.set(key, { bytes: report.bytesReceived, timestamp: now });
}
}
// ── Server address from remote-candidate ──
// Safari may leave `address` empty but populate the legacy `ip` field
if (report.type === 'remote-candidate' && !result.serverAddress) {
const addr = report.address || report.ip;
if (addr) {
result.serverAddress = addr;
result.protocol = report.protocol ?? null;
result.candidateType = report.candidateType ?? null;
}
}
});
// Safari fallback: look up remote candidate by ID from candidate-pair
if (!result.serverAddress && selectedCandidatePairRemoteId) {
const remoteCandidate = reports.get(selectedCandidatePairRemoteId);
if (remoteCandidate) {
const addr = remoteCandidate.address || remoteCandidate.ip;
if (addr) {
result.serverAddress = addr;
result.protocol = remoteCandidate.protocol ?? null;
result.candidateType = remoteCandidate.candidateType ?? null;
}
}
}
}
// Safari fallback: resolution from MediaStreamTrack.getSettings()
// Safari omits frameWidth/frameHeight from outbound-rtp stats entirely
if (!result.resolution && result.videoUp !== null && result.videoUp > 0) {
const localPart = room.localParticipant;
for (const pub of localPart.trackPublications.values()) {
if (pub.source === Track.Source.Camera || pub.source === Track.Source.ScreenShare) {
const mt = pub.track?.mediaStreamTrack;
if (mt && mt.readyState === 'live') {
const s = mt.getSettings();
if (s.width && s.height) {
result.resolution = `${s.width}\u00d7${s.height}`;
result.fps = s.frameRate ? Math.round(s.frameRate) : null;
break;
}
}
}
}
}
// Finalize aggregated inbound metrics
if (hasAudioDown) result.audioDown = totalAudioDown;
if (hasVideoDown) result.videoDown = totalVideoDown;
const totalPackets = totalPacketsReceived + totalPacketsLost;
if (totalPackets > 0) {
result.packetLoss = (totalPacketsLost / totalPackets) * 100;
}
setStats(result);
};
poll();
const interval = setInterval(poll, 1500);
return () => clearInterval(interval);
}, [open]);
if (!open) return null;
const room = getActiveRoom();
const hasVideo = stats && (
(stats.videoUp !== null && stats.videoUp > 0) ||
(stats.videoDown !== null && stats.videoDown > 0) ||
stats.resolution !== null
);
const Row = ({ label, value, colorClass }: { label: string; value: string; colorClass?: string }) => (
<div className="flex items-center justify-between py-[3px]">
<span className="text-[12px] text-discord-text-muted">{label}</span>
<span className={`text-[12px] font-medium ${colorClass ?? 'text-discord-text-secondary'}`}>{value}</span>
</div>
);
const Divider = () => <div className="border-t border-[#2b2d31] my-1" />;
return (
<div
ref={popoverRef}
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-[300px] bg-[#1e1f22] rounded-lg shadow-lg border border-[#111214] z-50 overflow-hidden"
>
<div className="px-3 py-2 border-b border-[#111214]">
<span className="text-[14px] font-bold text-discord-text-primary">Connection Info</span>
</div>
<div className="px-3 py-2">
{!room ? (
<div className="text-[12px] text-discord-text-muted py-2 text-center">Not connected</div>
) : !stats ? (
<div className="text-[12px] text-discord-text-muted py-2 text-center">Gathering stats...</div>
) : (
<>
{/* Network */}
<Row
label="Ping"
value={stats.ping !== null ? `${stats.ping} ms` : '\u2014'}
colorClass={stats.ping !== null ? pingColor(stats.ping) : undefined}
/>
<Row
label="Packet Loss"
value={stats.packetLoss !== null ? `${stats.packetLoss.toFixed(1)}%` : '\u2014'}
colorClass={stats.packetLoss !== null ? lossColor(stats.packetLoss) : undefined}
/>
<Row
label="Jitter"
value={stats.jitter !== null ? `${stats.jitter} ms` : '\u2014'}
colorClass={stats.jitter !== null ? jitterColor(stats.jitter) : undefined}
/>
<Divider />
{/* Bitrates */}
<Row label="Audio \u2191" value={formatBitrate(stats.audioUp)} />
<Row label="Audio \u2193" value={formatBitrate(stats.audioDown)} />
{hasVideo && (
<>
<Row label="Video \u2191" value={formatBitrate(stats.videoUp)} />
<Row label="Video \u2193" value={formatBitrate(stats.videoDown)} />
</>
)}
<Divider />
{/* Codec + media info */}
<Row
label="Codec"
value={[stats.audioCodec, stats.videoCodec].filter(Boolean).join(' / ') || '\u2014'}
/>
{hasVideo && (
<>
<Row
label="Resolution"
value={stats.resolution ? `${stats.resolution}${stats.fps ? ` @${stats.fps}` : ''}` : '\u2014'}
/>
<Row
label="Quality"
value={stats.qualityLimitation ?? '\u2014'}
/>
</>
)}
<Divider />
{/* Server */}
<Row label="Server" value={stats.serverAddress ?? '\u2014'} />
<Row
label="Protocol"
value={
stats.protocol
? `${stats.protocol}${stats.candidateType ? ` (${stats.candidateType})` : ''}`
: '\u2014'
}
/>
</>
)}
</div>
</div>
);
}
@@ -29,6 +29,7 @@ export function DmCallView() {
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare); const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
const setActiveDmCall = useVoiceStore((s) => s.setActiveDmCall); const setActiveDmCall = useVoiceStore((s) => s.setActiveDmCall);
const leaveVoice = useVoiceStore((s) => s.leaveVoice); const leaveVoice = useVoiceStore((s) => s.leaveVoice);
const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds);
const dmChannels = useServerStore((s) => s.dmChannels); const dmChannels = useServerStore((s) => s.dmChannels);
const authUser = useAuthStore((s) => s.user); const authUser = useAuthStore((s) => s.user);
@@ -164,7 +165,7 @@ export function DmCallView() {
<div className="w-24 h-24 rounded-full bg-discord-blurple flex items-center justify-center text-white text-4xl font-bold"> <div className="w-24 h-24 rounded-full bg-discord-blurple flex items-center justify-center text-white text-4xl font-bold">
{otherName.charAt(0).toUpperCase()} {otherName.charAt(0).toUpperCase()}
</div> </div>
{remoteParticipant?.isSpeaking && ( {remoteParticipant && speakingParticipantIds.has(remoteParticipant.identity) && (
<div className="absolute inset-0 rounded-full ring-[3px] ring-discord-green" /> <div className="absolute inset-0 rounded-full ring-[3px] ring-discord-green" />
)} )}
</div> </div>
@@ -203,7 +204,7 @@ export function DmCallView() {
<div className="w-24 h-24 rounded-full bg-discord-blurple flex items-center justify-center text-white text-4xl font-bold"> <div className="w-24 h-24 rounded-full bg-discord-blurple flex items-center justify-center text-white text-4xl font-bold">
{(authUser?.displayName ?? authUser?.username ?? 'Y').charAt(0).toUpperCase()} {(authUser?.displayName ?? authUser?.username ?? 'Y').charAt(0).toUpperCase()}
</div> </div>
{localParticipant?.isSpeaking && ( {localParticipant && speakingParticipantIds.has(localParticipant.identity) && (
<div className="absolute inset-0 rounded-full ring-[3px] ring-discord-green" /> <div className="absolute inset-0 rounded-full ring-[3px] ring-discord-green" />
)} )}
</div> </div>
@@ -74,9 +74,10 @@ export function GlobalAudioRenderer() {
const watchingStreams = useVoiceStore((s) => s.watchingStreams); const watchingStreams = useVoiceStore((s) => s.watchingStreams);
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled); const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength); const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds);
// Determine if someone is currently speaking (for stream attenuation) // Determine if someone is currently speaking (for stream attenuation)
const someoneIsSpeaking = participants.some((p) => !p.isLocal && p.isSpeaking); const someoneIsSpeaking = participants.some((p) => !p.isLocal && speakingParticipantIds.has(p.identity));
// Only render audio for remote participants // Only render audio for remote participants
const remoteParticipants = participants.filter((p) => !p.isLocal); const remoteParticipants = participants.filter((p) => !p.isLocal);
@@ -65,6 +65,7 @@ export function PictureInPicture() {
const participants = useVoiceStore((s) => s.participants); const participants = useVoiceStore((s) => s.participants);
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId); const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
const watchingStreams = useVoiceStore((s) => s.watchingStreams); const watchingStreams = useVoiceStore((s) => s.watchingStreams);
const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds);
const currentChannelId = useChatStore((s) => s.currentChannelId); const currentChannelId = useChatStore((s) => s.currentChannelId);
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen); const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
const pipCollapsed = useUIStore((s) => s.pipCollapsed); const pipCollapsed = useUIStore((s) => s.pipCollapsed);
@@ -105,12 +106,12 @@ export function PictureInPicture() {
// Fallback participant for avatar (most relevant remote, or first participant) // Fallback participant for avatar (most relevant remote, or first participant)
const fallbackParticipant = useMemo(() => { const fallbackParticipant = useMemo(() => {
const speaking = participants.find(p => !p.isLocal && p.isSpeaking); const speaking = participants.find(p => !p.isLocal && speakingParticipantIds.has(p.identity));
if (speaking) return speaking; if (speaking) return speaking;
const remote = participants.find(p => !p.isLocal); const remote = participants.find(p => !p.isLocal);
if (remote) return remote; if (remote) return remote;
return participants[0] ?? null; return participants[0] ?? null;
}, [participants]); }, [participants, speakingParticipantIds]);
// Channel name for display // Channel name for display
const channelName = useMemo(() => { const channelName = useMemo(() => {
@@ -260,7 +261,7 @@ export function PictureInPicture() {
name={displayParticipant.username} name={displayParticipant.username}
size={64} size={64}
/> />
{displayParticipant.isSpeaking && ( {speakingParticipantIds.has(displayParticipant.identity) && (
<div className="absolute -inset-1 rounded-full ring-2 ring-discord-green animate-pulse" /> <div className="absolute -inset-1 rounded-full ring-2 ring-discord-green animate-pulse" />
)} )}
</div> </div>
@@ -297,7 +298,7 @@ export function PictureInPicture() {
<div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 to-transparent"> <div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 to-transparent">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-white text-xs font-semibold truncate">{displayName}</span> <span className="text-white text-xs font-semibold truncate">{displayName}</span>
{displayParticipant?.isSpeaking && ( {displayParticipant && speakingParticipantIds.has(displayParticipant.identity) && (
<div className="w-2 h-2 rounded-full bg-discord-green flex-shrink-0 animate-pulse" /> <div className="w-2 h-2 rounded-full bg-discord-green flex-shrink-0 animate-pulse" />
)} )}
</div> </div>
@@ -5,6 +5,7 @@ import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket'; import { wsSend } from '../../hooks/useWebSocket';
import { AudioManager } from '../../audio/AudioManager'; import { AudioManager } from '../../audio/AudioManager';
import { VideoQualityPopover } from './VideoQualityPopover'; import { VideoQualityPopover } from './VideoQualityPopover';
import { ConnectionInfoPopover } from './ConnectionInfoPopover';
/** /**
* VoiceControls renders the voice status + button rows. * VoiceControls renders the voice status + button rows.
@@ -20,8 +21,10 @@ export function VoiceControls() {
const setRnnoiseEnabled = useVoiceStore((s) => s.setRnnoiseEnabled); const setRnnoiseEnabled = useVoiceStore((s) => s.setRnnoiseEnabled);
const connectionError = useVoiceStore((s) => s.connectionError); const connectionError = useVoiceStore((s) => s.connectionError);
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected); const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
const connectionQuality = useVoiceStore((s) => s.connectionQuality);
const channels = useServerStore((s) => s.channels); const channels = useServerStore((s) => s.channels);
const [showVideoQuality, setShowVideoQuality] = useState(false); const [showVideoQuality, setShowVideoQuality] = useState(false);
const [showConnectionInfo, setShowConnectionInfo] = useState(false);
if (!currentVoiceChannelId) return null; if (!currentVoiceChannelId) return null;
@@ -73,18 +76,34 @@ export function VoiceControls() {
? 'bg-discord-green/20' ? 'bg-discord-green/20'
: 'bg-discord-yellow/20'; : 'bg-discord-yellow/20';
const qualityColor =
connectionQuality === 'excellent' || connectionQuality === 'good'
? 'text-discord-green'
: connectionQuality === 'poor'
? 'text-discord-yellow'
: connectionQuality === 'lost'
? 'text-discord-red'
: statusColor; // 'unknown' falls back to connection-state color
const btnBase = 'flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors'; const btnBase = 'flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors';
const btnDefaultStyle = 'bg-[#111214] text-discord-text-muted hover:bg-[#1a1b1e] hover:text-discord-text-secondary'; const btnDefaultStyle = 'bg-[#111214] text-discord-text-muted hover:bg-[#1a1b1e] hover:text-discord-text-secondary';
return ( return (
<> <>
{/* Row 1: Signal icon + status text + disconnect */} {/* Row 1: Signal icon + status text + disconnect */}
<div className="flex items-center gap-2 px-3 pt-3 pb-1"> <div className="relative flex items-center gap-2 px-3 pt-3 pb-1">
<div className={`w-8 h-8 rounded-lg ${statusBgColor} flex items-center justify-center flex-shrink-0`}> <button
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className={statusColor}> onClick={() => {
setShowConnectionInfo(!showConnectionInfo);
if (!showConnectionInfo) setShowVideoQuality(false);
}}
className={`w-8 h-8 rounded-lg ${statusBgColor} flex items-center justify-center flex-shrink-0 hover:brightness-125 transition-all`}
title="Connection Info"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className={qualityColor}>
<path d="M1.5 21.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM3.14 15.75a.75.75 0 01-.09-1.06A8.46 8.46 0 0112 11a8.46 8.46 0 018.95 3.69.75.75 0 01-1.15.97A6.96 6.96 0 0012 12.5a6.96 6.96 0 00-7.8 3.16.75.75 0 01-1.06.09zM6.37 18.3a.75.75 0 01-.08-1.06A5.46 5.46 0 0112 15a5.46 5.46 0 015.71 2.24.75.75 0 01-1.14.97A3.96 3.96 0 0012 16.5a3.96 3.96 0 00-4.57 1.71.75.75 0 01-1.06.09z" /> <path d="M1.5 21.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM3.14 15.75a.75.75 0 01-.09-1.06A8.46 8.46 0 0112 11a8.46 8.46 0 018.95 3.69.75.75 0 01-1.15.97A6.96 6.96 0 0012 12.5a6.96 6.96 0 00-7.8 3.16.75.75 0 01-1.06.09zM6.37 18.3a.75.75 0 01-.08-1.06A5.46 5.46 0 0112 15a5.46 5.46 0 015.71 2.24.75.75 0 01-1.14.97A3.96 3.96 0 0012 16.5a3.96 3.96 0 00-4.57 1.71.75.75 0 01-1.06.09z" />
</svg> </svg>
</div> </button>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}> <div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}>
@@ -96,11 +115,6 @@ export function VoiceControls() {
</div> </div>
<div className="flex items-center gap-0.5 flex-shrink-0"> <div className="flex items-center gap-0.5 flex-shrink-0">
<button className="w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded" title="Connection Info">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 20h2V8H2v12zm5 0h2V4H7v16zm5 0h2v-8h-2v8zm5 0h2V12h-2v8z" />
</svg>
</button>
<button <button
onClick={handleDisconnect} onClick={handleDisconnect}
className="w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded" className="w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded"
@@ -111,6 +125,12 @@ export function VoiceControls() {
</svg> </svg>
</button> </button>
</div> </div>
{/* Connection Info Popover */}
<ConnectionInfoPopover
open={showConnectionInfo}
onClose={() => setShowConnectionInfo(false)}
/>
</div> </div>
{/* Row 2: Camera, Screen Share, Video Quality, Noise Suppression */} {/* Row 2: Camera, Screen Share, Video Quality, Noise Suppression */}
@@ -153,7 +173,10 @@ export function VoiceControls() {
{/* Video Quality */} {/* Video Quality */}
<button <button
onClick={() => setShowVideoQuality(!showVideoQuality)} onClick={() => {
setShowVideoQuality(!showVideoQuality);
if (!showVideoQuality) setShowConnectionInfo(false);
}}
className={`${btnBase} ${ className={`${btnBase} ${
showVideoQuality showVideoQuality
? 'bg-[#111214] text-discord-blurple hover:bg-[#1a1b1e]' ? 'bg-[#111214] text-discord-blurple hover:bg-[#1a1b1e]'
@@ -14,6 +14,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
const { participant } = tile; const { participant } = tile;
const isDeafened = useVoiceStore((s) => s.isDeafened); const isDeafened = useVoiceStore((s) => s.isDeafened);
const participantVolumes = useVoiceStore((s) => s.participantVolumes); const participantVolumes = useVoiceStore((s) => s.participantVolumes);
const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity));
const [, forceUpdate] = useState(0); const [, forceUpdate] = useState(0);
@@ -70,7 +71,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
return ( return (
<div <div
className={`relative bg-[#111214] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ${ className={`relative bg-[#111214] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ${
participant.isSpeaking isSpeaking
? 'ring-[3px] ring-discord-green shadow-[0_0_12px_rgba(35,165,90,0.25)]' ? 'ring-[3px] ring-discord-green shadow-[0_0_12px_rgba(35,165,90,0.25)]'
: 'ring-1 ring-white/[0.06] hover:ring-white/10' : 'ring-1 ring-white/[0.06] hover:ring-white/10'
} ${large ? 'h-full w-full' : 'h-full aspect-video'}`} } ${large ? 'h-full w-full' : 'h-full aspect-video'}`}
@@ -92,7 +93,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
name={participant.username} name={participant.username}
size={large ? 100 : 64} size={large ? 100 : 64}
/> />
{participant.isSpeaking && ( {isSpeaking && (
<div className="absolute -inset-1.5 rounded-full ring-[3px] ring-discord-green animate-pulse" /> <div className="absolute -inset-1.5 rounded-full ring-[3px] ring-discord-green animate-pulse" />
)} )}
</div> </div>
+73 -18
View File
@@ -8,6 +8,7 @@ import {
RemoteTrackPublication, RemoteTrackPublication,
RemoteAudioTrack, RemoteAudioTrack,
ConnectionState, ConnectionState,
ConnectionQuality,
VideoPresets, VideoPresets,
VideoPreset, VideoPreset,
LocalAudioTrack, LocalAudioTrack,
@@ -21,6 +22,14 @@ import { AudioManager } from '../audio/AudioManager';
* OPENCORD NATIVE OVERDRIVE PIPELINE v32 * OPENCORD NATIVE OVERDRIVE PIPELINE v32
*/ */
function setsEqual(a: Set<string>, b: Set<string>): boolean {
if (a.size !== b.size) return false;
for (const v of a) {
if (!b.has(v)) return false;
}
return true;
}
const QUALITY_MAP: Record<string, VideoPreset> = { const QUALITY_MAP: Record<string, VideoPreset> = {
'1080p60': new VideoPreset(1920, 1080, 12_000_000, 60), '1080p60': new VideoPreset(1920, 1080, 12_000_000, 60),
'1080p': new VideoPreset(1920, 1080, 8_000_000, 30), '1080p': new VideoPreset(1920, 1080, 8_000_000, 30),
@@ -42,7 +51,6 @@ export interface ParticipantInfo {
identity: string; identity: string;
userId: string; userId: string;
username: string; username: string;
isSpeaking: boolean;
isMuted: boolean; isMuted: boolean;
isDeafened: boolean; isDeafened: boolean;
isCameraOn: boolean; isCameraOn: boolean;
@@ -141,7 +149,6 @@ async function applyOverdriveHammer(room: Room, source: Track.Source, preset: Vi
export function useLiveKit() { export function useLiveKit() {
const [room, setRoom] = useState<Room | null>(null); const [room, setRoom] = useState<Room | null>(null);
const [participants, setParticipants] = useState<ParticipantInfo[]>([]);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
const [connectionState, setConnectionState] = useState<ConnectionState>(ConnectionState.Disconnected); const [connectionState, setConnectionState] = useState<ConnectionState>(ConnectionState.Disconnected);
@@ -212,7 +219,6 @@ export function useLiveKit() {
identity: p.identity, identity: p.identity,
userId, userId,
username, username,
isSpeaking: p.isSpeaking,
isMuted: isPartMuted, isMuted: isPartMuted,
isDeafened: isPartDeafened, isDeafened: isPartDeafened,
isCameraOn: !!videoTrack, isCameraOn: !!videoTrack,
@@ -226,7 +232,7 @@ export function useLiveKit() {
}; };
processParticipant(r.localParticipant, true); processParticipant(r.localParticipant, true);
r.remoteParticipants.forEach((p) => processParticipant(p, false)); r.remoteParticipants.forEach((p) => processParticipant(p, false));
setParticipants(allParticipants); useVoiceStore.getState().setParticipants(allParticipants);
}, []); }, []);
const handleDataReceived = useCallback((payload: Uint8Array, participant?: RemoteParticipant) => { const handleDataReceived = useCallback((payload: Uint8Array, participant?: RemoteParticipant) => {
@@ -326,14 +332,17 @@ export function useLiveKit() {
// 1. Reset state immediately to reflect "Loading/Switching" in UI // 1. Reset state immediately to reflect "Loading/Switching" in UI
setRoom(null); setRoom(null);
setParticipants([]); useVoiceStore.getState().setParticipants([]);
useVoiceStore.getState().setSpeakingParticipants(new Set());
setIsConnected(false); setIsConnected(false);
setIsConnecting(true); setIsConnecting(true);
setConnectionState(ConnectionState.Connecting); setConnectionState(ConnectionState.Connecting);
setConnectionError(null); setConnectionError(null);
setConnectedChannelId(null); // Clear this so AppLayout knows we are transitioning setConnectedChannelId(null); // Clear this so AppLayout knows we are transitioning
useVoiceStore.getState().setConnectionError(null);
useVoiceStore.getState().setIsLiveKitConnected(false); useVoiceStore.getState().setIsLiveKitConnected(false);
useVoiceStore.getState().setConnectionQuality('unknown');
// 2. Strictly disconnect previous room (Local Ref OR Global Ref) // 2. Strictly disconnect previous room (Local Ref OR Global Ref)
// This handles cases where AppLayout might have remounted, losing roomRef but leaving _activeRoom alive. // This handles cases where AppLayout might have remounted, losing roomRef but leaving _activeRoom alive.
@@ -400,7 +409,12 @@ export function useLiveKit() {
}); });
newRoom.on(RoomEvent.TrackMuted, guardedUpdate); newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate); newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate); newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers: Participant[]) => {
if (roomRef.current !== newRoom) return;
useVoiceStore.getState().setSpeakingParticipants(
new Set(speakers.map(s => s.identity))
);
});
newRoom.on(RoomEvent.ParticipantMetadataChanged, guardedUpdate); newRoom.on(RoomEvent.ParticipantMetadataChanged, guardedUpdate);
newRoom.on(RoomEvent.TrackPublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => { newRoom.on(RoomEvent.TrackPublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => {
if ( if (
@@ -422,17 +436,22 @@ export function useLiveKit() {
guardedUpdate(); guardedUpdate();
}); });
newRoom.on(RoomEvent.DataReceived, handleDataReceived); newRoom.on(RoomEvent.DataReceived, handleDataReceived);
newRoom.on(RoomEvent.ConnectionQualityChanged, (quality: ConnectionQuality, participant: Participant) => {
if (participant.identity === newRoom.localParticipant.identity) {
useVoiceStore.getState().setConnectionQuality(quality as any);
}
});
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => { newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) { if (roomRef.current === newRoom) {
setConnectionState(state); setConnectionState(state);
const connected = state === ConnectionState.Connected; const connected = state === ConnectionState.Connected;
const connecting = state === ConnectionState.Connecting || state === ConnectionState.Reconnecting; const connecting = state === ConnectionState.Connecting || state === ConnectionState.Reconnecting;
setIsConnected(connected); setIsConnected(connected);
setIsConnecting(connecting); setIsConnecting(connecting);
useVoiceStore.getState().setIsLiveKitConnected(connected); useVoiceStore.getState().setIsLiveKitConnected(connected);
if (connected) { if (connected) {
updateParticipants(); updateParticipants();
} }
@@ -442,7 +461,9 @@ export function useLiveKit() {
if (roomRef.current !== newRoom) return; if (roomRef.current !== newRoom) return;
setConnectionState(ConnectionState.Disconnected); setConnectionState(ConnectionState.Disconnected);
setConnectedChannelId(null); setConnectedChannelId(null);
roomRef.current = null; _activeRoom = null; setIsConnected(false); setRoom(null); setParticipants([]); roomRef.current = null; _activeRoom = null; setIsConnected(false); setRoom(null);
useVoiceStore.getState().setParticipants([]);
useVoiceStore.getState().setSpeakingParticipants(new Set());
useVoiceStore.getState().setIsLiveKitConnected(false); useVoiceStore.getState().setIsLiveKitConnected(false);
}); });
@@ -478,7 +499,7 @@ export function useLiveKit() {
} }
updateParticipants(); updateParticipants();
} catch (err) { if (gen === _connectGeneration) setConnectionError('Failed to connect'); } } catch (err) { if (gen === _connectGeneration) { setConnectionError('Failed to connect'); useVoiceStore.getState().setConnectionError('Failed to connect'); } }
finally { if (gen === _connectGeneration) setIsConnecting(false); } finally { if (gen === _connectGeneration) setIsConnecting(false); }
}, [updateParticipants, handleDataReceived]); }, [updateParticipants, handleDataReceived]);
@@ -490,13 +511,17 @@ export function useLiveKit() {
// 1. Reset state immediately // 1. Reset state immediately
setRoom(null); setRoom(null);
setParticipants([]); useVoiceStore.getState().setParticipants([]);
useVoiceStore.getState().setSpeakingParticipants(new Set());
setIsConnected(false); setIsConnected(false);
setIsConnecting(true); setIsConnecting(true);
setConnectionState(ConnectionState.Connecting); setConnectionState(ConnectionState.Connecting);
setConnectionError(null); setConnectionError(null);
setConnectedChannelId(null); setConnectedChannelId(null);
useVoiceStore.getState().setConnectionError(null);
useVoiceStore.getState().setConnectionQuality('unknown');
// 2. Strictly disconnect previous room (Local Ref OR Global Ref) // 2. Strictly disconnect previous room (Local Ref OR Global Ref)
const roomToDisconnect = roomRef.current || _activeRoom; const roomToDisconnect = roomRef.current || _activeRoom;
@@ -547,7 +572,12 @@ export function useLiveKit() {
}); });
newRoom.on(RoomEvent.TrackMuted, guardedUpdate); newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate); newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate); newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers: Participant[]) => {
if (roomRef.current !== newRoom) return;
useVoiceStore.getState().setSpeakingParticipants(
new Set(speakers.map(s => s.identity))
);
});
newRoom.on(RoomEvent.TrackPublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => { newRoom.on(RoomEvent.TrackPublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => {
if ( if (
publication.source === Track.Source.ScreenShare || publication.source === Track.Source.ScreenShare ||
@@ -567,6 +597,11 @@ export function useLiveKit() {
} }
guardedUpdate(); guardedUpdate();
}); });
newRoom.on(RoomEvent.ConnectionQualityChanged, (quality: ConnectionQuality, participant: Participant) => {
if (participant.identity === newRoom.localParticipant.identity) {
useVoiceStore.getState().setConnectionQuality(quality as any);
}
});
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => { newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) { if (roomRef.current === newRoom) {
setConnectionState(state); setConnectionState(state);
@@ -613,7 +648,7 @@ export function useLiveKit() {
newRoom.remoteParticipants.forEach((p) => p.setVolume(0)); newRoom.remoteParticipants.forEach((p) => p.setVolume(0));
} }
updateParticipants(); updateParticipants();
} catch (err) { if (gen === _connectGeneration) setConnectionError('Failed to connect'); } } catch (err) { if (gen === _connectGeneration) { setConnectionError('Failed to connect'); useVoiceStore.getState().setConnectionError('Failed to connect'); } }
finally { if (gen === _connectGeneration) setIsConnecting(false); } finally { if (gen === _connectGeneration) setIsConnecting(false); }
}, [updateParticipants, handleDataReceived]); }, [updateParticipants, handleDataReceived]);
@@ -629,8 +664,9 @@ export function useLiveKit() {
setIsConnected(false); setIsConnected(false);
setIsConnecting(false); setIsConnecting(false);
setConnectionState(ConnectionState.Disconnected); setConnectionState(ConnectionState.Disconnected);
setParticipants([]); useVoiceStore.getState().setParticipants([]);
useVoiceStore.getState().setIsLiveKitConnected(false); useVoiceStore.getState().setSpeakingParticipants(new Set());
useVoiceStore.getState().setIsLiveKitConnected(false);
} }
}, []); }, []);
@@ -742,5 +778,24 @@ export function useLiveKit() {
return () => { _connectGeneration++; if (roomRef.current) { roomRef.current.disconnect(); roomRef.current = null; _activeRoom = null; } }; return () => { _connectGeneration++; if (roomRef.current) { roomRef.current.disconnect(); roomRef.current = null; _activeRoom = null; } };
}, []); }, []);
return { room, participants, isConnected, isConnecting, connectionState, connectedChannelId, connectionError, connect, connectDm, disconnect, toggleMic, toggleCamera, toggleScreenShare }; // Speaking poll safety net: catches missed ActiveSpeakersChanged events
useEffect(() => {
if (!isConnected) return;
const interval = setInterval(() => {
const r = roomRef.current;
if (!r) return;
const speakingIds = new Set<string>();
if (r.localParticipant.isSpeaking) speakingIds.add(r.localParticipant.identity);
r.remoteParticipants.forEach((p) => {
if (p.isSpeaking) speakingIds.add(p.identity);
});
const current = useVoiceStore.getState().speakingParticipantIds;
if (!setsEqual(current, speakingIds)) {
useVoiceStore.getState().setSpeakingParticipants(speakingIds);
}
}, 200);
return () => clearInterval(interval);
}, [isConnected]);
return { room, isConnected, isConnecting, connectionState, connectedChannelId, connectionError, connect, connectDm, disconnect, toggleMic, toggleCamera, toggleScreenShare };
} }
+12
View File
@@ -11,8 +11,11 @@ interface VoiceState {
isCameraOn: boolean; isCameraOn: boolean;
isScreenSharing: boolean; isScreenSharing: boolean;
participants: ParticipantInfo[]; participants: ParticipantInfo[];
speakingParticipantIds: Set<string>;
connectionError: string | null; connectionError: string | null;
isLiveKitConnected: boolean; isLiveKitConnected: boolean;
connectionQuality: 'excellent' | 'good' | 'poor' | 'lost' | 'unknown';
setConnectionQuality: (q: 'excellent' | 'good' | 'poor' | 'lost' | 'unknown') => void;
inputVolume: number; // 0-200 (100 = default) inputVolume: number; // 0-200 (100 = default)
outputVolume: number; // 0-200 (100 = default) outputVolume: number; // 0-200 (100 = default)
inputDeviceId: string; inputDeviceId: string;
@@ -49,6 +52,7 @@ interface VoiceState {
removeVoiceUser: (channelId: string, userId: string) => void; removeVoiceUser: (channelId: string, userId: string) => void;
setCurrentVoiceChannel: (channelId: string | null) => void; setCurrentVoiceChannel: (channelId: string | null) => void;
setParticipants: (participants: ParticipantInfo[]) => void; setParticipants: (participants: ParticipantInfo[]) => void;
setSpeakingParticipants: (ids: Set<string>) => void;
setConnectionError: (error: string | null) => void; setConnectionError: (error: string | null) => void;
setIsLiveKitConnected: (connected: boolean) => void; setIsLiveKitConnected: (connected: boolean) => void;
setInputVolume: (volume: number) => void; setInputVolume: (volume: number) => void;
@@ -90,8 +94,10 @@ export const useVoiceStore = create<VoiceState>()(
isCameraOn: false, isCameraOn: false,
isScreenSharing: false, isScreenSharing: false,
participants: [], participants: [],
speakingParticipantIds: new Set(),
connectionError: null, connectionError: null,
isLiveKitConnected: false, isLiveKitConnected: false,
connectionQuality: 'unknown',
inputVolume: 100, inputVolume: 100,
outputVolume: 100, outputVolume: 100,
inputDeviceId: 'default', inputDeviceId: 'default',
@@ -202,8 +208,10 @@ export const useVoiceStore = create<VoiceState>()(
}), }),
setParticipants: (participants) => set({ participants }), setParticipants: (participants) => set({ participants }),
setSpeakingParticipants: (ids) => set({ speakingParticipantIds: ids }),
setConnectionError: (error) => set({ connectionError: error }), setConnectionError: (error) => set({ connectionError: error }),
setIsLiveKitConnected: (connected) => set({ isLiveKitConnected: connected }), setIsLiveKitConnected: (connected) => set({ isLiveKitConnected: connected }),
setConnectionQuality: (quality) => set({ connectionQuality: quality }),
setInputVolume: (volume) => { setInputVolume: (volume) => {
set({ inputVolume: volume }); set({ inputVolume: volume });
@@ -264,8 +272,10 @@ export const useVoiceStore = create<VoiceState>()(
isCameraOn: false, isCameraOn: false,
isScreenSharing: false, isScreenSharing: false,
participants: [], participants: [],
speakingParticipantIds: new Set(),
connectionError: null, connectionError: null,
isLiveKitConnected: false, isLiveKitConnected: false,
connectionQuality: 'unknown',
focusedParticipantId: null, focusedParticipantId: null,
activeDmCall: null, activeDmCall: null,
outgoingCall: null, outgoingCall: null,
@@ -283,8 +293,10 @@ export const useVoiceStore = create<VoiceState>()(
isCameraOn: false, isCameraOn: false,
isScreenSharing: false, isScreenSharing: false,
participants: [], participants: [],
speakingParticipantIds: new Set(),
connectionError: null, connectionError: null,
isLiveKitConnected: false, isLiveKitConnected: false,
connectionQuality: 'unknown',
inputVolume: 100, inputVolume: 100,
outputVolume: 100, outputVolume: 100,
inputDeviceId: 'default', inputDeviceId: 'default',