fix: decouple voice intent from server enforcement to prevent involuntary unmute

When a moderator lifted a server mute/deafen, the client was involuntarily
turning on the user's microphone because isMuted/isDeafened conflated user
intent with server enforcement. Now intent (isMuted/isDeafened) is never
mutated by server events. Effective state (intent || serverEnforcement) is
computed at broadcast and hardware time via centralized helpers.
This commit is contained in:
Jannis Braun
2026-03-09 22:25:56 +01:00
parent db1909d785
commit 907f8285cd
9 changed files with 164 additions and 233 deletions
+2 -3
View File
@@ -2,8 +2,8 @@ import { Room, Track } from 'livekit-client';
import { useVoiceStore } from '../stores/voiceStore';
import type { ScreenShareConfig } from '../stores/voiceStore';
import { getStreamingLimits } from '../stores/settingsStore';
import { wsSend } from '../hooks/useWebSocket';
import { getPublisherPC, getMediaStreamTrack } from './livekitInternals';
import { broadcastVoiceStatus } from './voice';
// ---------------------------------------------------------------------------
// Types
@@ -224,6 +224,5 @@ export async function changeScreenShare(room: Room): Promise<void> {
export function handleScreenShareUnpublished(): void {
useVoiceStore.setState({ isScreenSharing: false });
const { isMuted, isDeafened, isCameraOn } = useVoiceStore.getState();
wsSend({ type: 'voice_status', isMuted, isDeafened, isCameraOn, isScreenSharing: false });
broadcastVoiceStatus();
}
+59 -1
View File
@@ -1,7 +1,65 @@
import { useVoiceStore } from '../stores/voiceStore';
import { getChannelOrigin, getMyUserIdForOrigin } from '../stores/spaceStore';
import { getChannelOrigin, getMyUserIdForOrigin, useSpaceStore } from '../stores/spaceStore';
import { wsSend } from '../hooks/useWebSocket';
// ---------------------------------------------------------------------------
// Effective-state helpers — single source of truth for broadcasts
// ---------------------------------------------------------------------------
/**
* Compute effective mute/deafen by merging user intent with server enforcement,
* then broadcast the effective voice_status over the WebSocket.
*
* @param overrideOrigin Pass explicitly when called from a WS handler that
* knows the origin. Omit to derive from currentVoiceChannelId.
*/
export function broadcastVoiceStatus(overrideOrigin?: string): void {
const vs = useVoiceStore.getState();
const { isMuted, isDeafened, isCameraOn, isScreenSharing, currentVoiceChannelId, serverMutedUserIds, serverDeafenedUserIds } = vs;
if (!currentVoiceChannelId) return;
const origin = overrideOrigin ?? getChannelOrigin(currentVoiceChannelId);
const myId = getMyUserIdForOrigin(origin);
const spaceId = useSpaceStore.getState().channelToSpaceMap.get(currentVoiceChannelId);
const serverKey = (spaceId && myId) ? `${spaceId}:${myId}` : '';
const effectiveMuted = isMuted || serverMutedUserIds.has(serverKey);
const effectiveDeafened = isDeafened || serverDeafenedUserIds.has(serverKey);
wsSend({ type: 'voice_status', isMuted: effectiveMuted, isDeafened: effectiveDeafened, isCameraOn, isScreenSharing }, origin);
}
/**
* Broadcast the effective deafen state to in-room participants via the
* LiveKit data channel. Dynamic-imports getActiveRoom to avoid circular deps.
*/
export function broadcastDeafenViaLiveKit(): void {
const vs = useVoiceStore.getState();
const { isDeafened, currentVoiceChannelId, serverDeafenedUserIds } = vs;
if (!currentVoiceChannelId) return;
const origin = getChannelOrigin(currentVoiceChannelId);
const myId = getMyUserIdForOrigin(origin);
const spaceId = useSpaceStore.getState().channelToSpaceMap.get(currentVoiceChannelId);
const serverKey = (spaceId && myId) ? `${spaceId}:${myId}` : '';
const effectiveDeafened = isDeafened || serverDeafenedUserIds.has(serverKey);
import('../hooks/useLiveKit').then(({ getActiveRoom }) => {
const room = getActiveRoom();
if (room) {
const encoder = new TextEncoder();
room.localParticipant.publishData(
encoder.encode(JSON.stringify({ type: 'deafen', deafened: effectiveDeafened })),
{ reliable: true }
).catch(() => {});
}
});
}
// ---------------------------------------------------------------------------
// Voice channel join
// ---------------------------------------------------------------------------
/**
* Centralized voice channel join that handles cross-instance cleanup.
* When switching from a channel on Instance A to one on Instance B,