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
@@ -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>