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:
@@ -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 setActiveDmCall = useVoiceStore((s) => s.setActiveDmCall);
|
||||
const leaveVoice = useVoiceStore((s) => s.leaveVoice);
|
||||
const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds);
|
||||
|
||||
const dmChannels = useServerStore((s) => s.dmChannels);
|
||||
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">
|
||||
{otherName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{remoteParticipant?.isSpeaking && (
|
||||
{remoteParticipant && speakingParticipantIds.has(remoteParticipant.identity) && (
|
||||
<div className="absolute inset-0 rounded-full ring-[3px] ring-discord-green" />
|
||||
)}
|
||||
</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">
|
||||
{(authUser?.displayName ?? authUser?.username ?? 'Y').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{localParticipant?.isSpeaking && (
|
||||
{localParticipant && speakingParticipantIds.has(localParticipant.identity) && (
|
||||
<div className="absolute inset-0 rounded-full ring-[3px] ring-discord-green" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -74,9 +74,10 @@ export function GlobalAudioRenderer() {
|
||||
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
|
||||
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
|
||||
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
|
||||
const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds);
|
||||
|
||||
// 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
|
||||
const remoteParticipants = participants.filter((p) => !p.isLocal);
|
||||
|
||||
@@ -65,6 +65,7 @@ export function PictureInPicture() {
|
||||
const participants = useVoiceStore((s) => s.participants);
|
||||
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
|
||||
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
|
||||
const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds);
|
||||
const currentChannelId = useChatStore((s) => s.currentChannelId);
|
||||
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
|
||||
const pipCollapsed = useUIStore((s) => s.pipCollapsed);
|
||||
@@ -105,12 +106,12 @@ export function PictureInPicture() {
|
||||
|
||||
// Fallback participant for avatar (most relevant remote, or first participant)
|
||||
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;
|
||||
const remote = participants.find(p => !p.isLocal);
|
||||
if (remote) return remote;
|
||||
return participants[0] ?? null;
|
||||
}, [participants]);
|
||||
}, [participants, speakingParticipantIds]);
|
||||
|
||||
// Channel name for display
|
||||
const channelName = useMemo(() => {
|
||||
@@ -260,7 +261,7 @@ export function PictureInPicture() {
|
||||
name={displayParticipant.username}
|
||||
size={64}
|
||||
/>
|
||||
{displayParticipant.isSpeaking && (
|
||||
{speakingParticipantIds.has(displayParticipant.identity) && (
|
||||
<div className="absolute -inset-1 rounded-full ring-2 ring-discord-green animate-pulse" />
|
||||
)}
|
||||
</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="flex items-center gap-1.5">
|
||||
<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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { AudioManager } from '../../audio/AudioManager';
|
||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||
import { ConnectionInfoPopover } from './ConnectionInfoPopover';
|
||||
|
||||
/**
|
||||
* VoiceControls renders the voice status + button rows.
|
||||
@@ -20,8 +21,10 @@ export function VoiceControls() {
|
||||
const setRnnoiseEnabled = useVoiceStore((s) => s.setRnnoiseEnabled);
|
||||
const connectionError = useVoiceStore((s) => s.connectionError);
|
||||
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
|
||||
const connectionQuality = useVoiceStore((s) => s.connectionQuality);
|
||||
const channels = useServerStore((s) => s.channels);
|
||||
const [showVideoQuality, setShowVideoQuality] = useState(false);
|
||||
const [showConnectionInfo, setShowConnectionInfo] = useState(false);
|
||||
|
||||
if (!currentVoiceChannelId) return null;
|
||||
|
||||
@@ -73,18 +76,34 @@ export function VoiceControls() {
|
||||
? 'bg-discord-green/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 btnDefaultStyle = 'bg-[#111214] text-discord-text-muted hover:bg-[#1a1b1e] hover:text-discord-text-secondary';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Row 1: Signal icon + status text + disconnect */}
|
||||
<div className="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`}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className={statusColor}>
|
||||
<div className="relative flex items-center gap-2 px-3 pt-3 pb-1">
|
||||
<button
|
||||
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" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}>
|
||||
@@ -96,11 +115,6 @@ export function VoiceControls() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
onClick={handleDisconnect}
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Connection Info Popover */}
|
||||
<ConnectionInfoPopover
|
||||
open={showConnectionInfo}
|
||||
onClose={() => setShowConnectionInfo(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Camera, Screen Share, Video Quality, Noise Suppression */}
|
||||
@@ -153,7 +173,10 @@ export function VoiceControls() {
|
||||
|
||||
{/* Video Quality */}
|
||||
<button
|
||||
onClick={() => setShowVideoQuality(!showVideoQuality)}
|
||||
onClick={() => {
|
||||
setShowVideoQuality(!showVideoQuality);
|
||||
if (!showVideoQuality) setShowConnectionInfo(false);
|
||||
}}
|
||||
className={`${btnBase} ${
|
||||
showVideoQuality
|
||||
? 'bg-[#111214] text-discord-blurple hover:bg-[#1a1b1e]'
|
||||
|
||||
@@ -14,6 +14,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
||||
const { participant } = tile;
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
||||
const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity));
|
||||
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
@@ -70,7 +71,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
||||
return (
|
||||
<div
|
||||
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-1 ring-white/[0.06] hover:ring-white/10'
|
||||
} ${large ? 'h-full w-full' : 'h-full aspect-video'}`}
|
||||
@@ -92,7 +93,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
||||
name={participant.username}
|
||||
size={large ? 100 : 64}
|
||||
/>
|
||||
{participant.isSpeaking && (
|
||||
{isSpeaking && (
|
||||
<div className="absolute -inset-1.5 rounded-full ring-[3px] ring-discord-green animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user