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:
@@ -11,11 +11,10 @@ import { useVoiceStore } from '../../stores/voiceStore';
|
|||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { Username } from '../ui/Username';
|
import { Username } from '../ui/Username';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
|
||||||
import { AudioManager } from '../../audio/AudioManager';
|
import { AudioManager } from '../../audio/AudioManager';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
import { parseFederatedUsername, isSelf } from '../../utils/identity';
|
import { parseFederatedUsername, isSelf } from '../../utils/identity';
|
||||||
import { joinVoiceChannel } from '../../utils/voice';
|
import { joinVoiceChannel, broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice';
|
||||||
|
|
||||||
export function ChannelSidebar() {
|
export function ChannelSidebar() {
|
||||||
const spaces = useSpaceStore((s) => s.spaces);
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
@@ -44,47 +43,19 @@ export function ChannelSidebar() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const handleMicToggle = async () => {
|
const handleMicToggle = async () => {
|
||||||
if (isServerMuted || isServerDeafened) return;
|
|
||||||
const wasDeafened = useVoiceStore.getState().isDeafened;
|
const wasDeafened = useVoiceStore.getState().isDeafened;
|
||||||
toggleMic();
|
toggleMic();
|
||||||
// Read fresh state after the smart toggle (may have cleared deafen too)
|
broadcastVoiceStatus();
|
||||||
const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss } = useVoiceStore.getState();
|
// If unmuting while deafened cleared deafen, broadcast via LiveKit data channel
|
||||||
const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
|
if (wasDeafened && !useVoiceStore.getState().isDeafened) {
|
||||||
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss }, voiceOrigin);
|
broadcastDeafenViaLiveKit();
|
||||||
// If unmuting while deafened cleared deafen, broadcast deafen=false via LiveKit data channel
|
|
||||||
if (wasDeafened && !d) {
|
|
||||||
const room = getActiveRoom();
|
|
||||||
if (room) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
room.localParticipant.publishData(
|
|
||||||
encoder.encode(JSON.stringify({ type: 'deafen', deafened: false })),
|
|
||||||
{ reliable: true }
|
|
||||||
).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeafenToggle = async () => {
|
const handleDeafenToggle = async () => {
|
||||||
if (isServerDeafened) return;
|
|
||||||
const room = getActiveRoom();
|
|
||||||
toggleDeafen();
|
toggleDeafen();
|
||||||
// Read fresh state — smart toggle handles mute coupling
|
broadcastVoiceStatus();
|
||||||
const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss } = useVoiceStore.getState();
|
broadcastDeafenViaLiveKit();
|
||||||
// If server-muted, enforce muted even after undeafen
|
|
||||||
const effectiveMuted = isServerMuted ? true : m;
|
|
||||||
const voiceOrigin2 = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
|
|
||||||
wsSend({ type: 'voice_status', isMuted: effectiveMuted, isDeafened: d, isCameraOn: c, isScreenSharing: ss }, voiceOrigin2);
|
|
||||||
if (room) {
|
|
||||||
try {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
room.localParticipant.publishData(
|
|
||||||
encoder.encode(JSON.stringify({ type: 'deafen', deafened: d })),
|
|
||||||
{ reliable: true }
|
|
||||||
).catch(() => {});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ChannelSidebar] Failed to toggle deafen:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
|
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
||||||
import { useAudioTrackPlayer } from '../../hooks/useAudioTrackPlayer';
|
import { useAudioTrackPlayer } from '../../hooks/useAudioTrackPlayer';
|
||||||
import type { ParticipantInfo } from '../../hooks/useLiveKit';
|
import type { ParticipantInfo } from '../../hooks/useLiveKit';
|
||||||
|
|
||||||
@@ -66,7 +67,9 @@ function AudioTrackElement({
|
|||||||
*/
|
*/
|
||||||
export function GlobalAudioRenderer() {
|
export function GlobalAudioRenderer() {
|
||||||
const participants = useVoiceStore((s) => s.participants);
|
const participants = useVoiceStore((s) => s.participants);
|
||||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
const isDeafenedIntent = useVoiceStore((s) => s.isDeafened);
|
||||||
|
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
|
||||||
|
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||||
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
||||||
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
||||||
const streamVolumes = useVoiceStore((s) => s.streamVolumes);
|
const streamVolumes = useVoiceStore((s) => s.streamVolumes);
|
||||||
@@ -76,6 +79,12 @@ export function GlobalAudioRenderer() {
|
|||||||
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
|
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
|
||||||
const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds);
|
const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds);
|
||||||
|
|
||||||
|
// Compute effective deafened: user intent || server enforcement
|
||||||
|
const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null);
|
||||||
|
const myOriginId = currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : undefined;
|
||||||
|
const serverKey = (spaceId && myOriginId) ? `${spaceId}:${myOriginId}` : '';
|
||||||
|
const isDeafened = isDeafenedIntent || serverDeafenedUserIds.has(serverKey);
|
||||||
|
|
||||||
// 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 && speakingParticipantIds.has(p.identity));
|
const someoneIsSpeaking = participants.some((p) => !p.isLocal && speakingParticipantIds.has(p.identity));
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { wsSend } from '../../hooks/useWebSocket';
|
|||||||
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
||||||
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
||||||
import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
||||||
|
import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice';
|
||||||
|
|
||||||
const btnBase = 'w-10 h-10 flex items-center justify-center rounded-full transition-colors';
|
const btnBase = 'w-10 h-10 flex items-center justify-center rounded-full transition-colors';
|
||||||
const btnDefault = `${btnBase} bg-surface-channel text-txt-secondary hover:bg-surface-elevated hover:text-txt-primary`;
|
const btnDefault = `${btnBase} bg-surface-channel text-txt-secondary hover:bg-surface-elevated hover:text-txt-primary`;
|
||||||
@@ -38,46 +39,20 @@ export function VoiceControlBar() {
|
|||||||
const qualityBtnRef = useRef<HTMLButtonElement>(null);
|
const qualityBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const handleMute = React.useCallback(async () => {
|
const handleMute = React.useCallback(async () => {
|
||||||
if (isServerMuted || isServerDeafened) return;
|
|
||||||
const wasDeafened = useVoiceStore.getState().isDeafened;
|
const wasDeafened = useVoiceStore.getState().isDeafened;
|
||||||
toggleMic();
|
toggleMic();
|
||||||
// Read fresh state after the smart toggle (may have cleared deafen too)
|
broadcastVoiceStatus();
|
||||||
const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss } = useVoiceStore.getState();
|
// If unmuting while deafened cleared deafen, broadcast via LiveKit data channel
|
||||||
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss }, voiceOrigin);
|
if (wasDeafened && !useVoiceStore.getState().isDeafened) {
|
||||||
// If unmuting while deafened cleared deafen, broadcast deafen=false via LiveKit data channel
|
broadcastDeafenViaLiveKit();
|
||||||
if (wasDeafened && !d) {
|
|
||||||
const room = getActiveRoom();
|
|
||||||
if (room) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
room.localParticipant.publishData(
|
|
||||||
encoder.encode(JSON.stringify({ type: 'deafen', deafened: false })),
|
|
||||||
{ reliable: true }
|
|
||||||
).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [toggleMic, voiceOrigin, isServerMuted, isServerDeafened]);
|
}, [toggleMic]);
|
||||||
|
|
||||||
const handleDeafen = React.useCallback(async () => {
|
const handleDeafen = React.useCallback(async () => {
|
||||||
if (isServerDeafened) return;
|
|
||||||
const room = getActiveRoom();
|
|
||||||
toggleDeafen();
|
toggleDeafen();
|
||||||
// Read fresh state — smart toggle handles mute coupling
|
broadcastVoiceStatus();
|
||||||
const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss } = useVoiceStore.getState();
|
broadcastDeafenViaLiveKit();
|
||||||
// If server-muted, enforce muted even after undeafen
|
}, [toggleDeafen]);
|
||||||
const effectiveMuted = isServerMuted ? true : m;
|
|
||||||
wsSend({ type: 'voice_status', isMuted: effectiveMuted, isDeafened: d, isCameraOn: c, isScreenSharing: ss }, voiceOrigin);
|
|
||||||
if (room) {
|
|
||||||
try {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
room.localParticipant.publishData(
|
|
||||||
encoder.encode(JSON.stringify({ type: 'deafen', deafened: d })),
|
|
||||||
{ reliable: true }
|
|
||||||
).catch(() => {});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[VoiceControlBar] Failed to toggle deafen:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [toggleDeafen, isServerMuted, isServerDeafened, voiceOrigin]);
|
|
||||||
|
|
||||||
const handleCamera = async () => {
|
const handleCamera = async () => {
|
||||||
const room = getActiveRoom();
|
const room = getActiveRoom();
|
||||||
@@ -97,9 +72,7 @@ export function VoiceControlBar() {
|
|||||||
await room.localParticipant.setCameraEnabled(false);
|
await room.localParticipant.setCameraEnabled(false);
|
||||||
}
|
}
|
||||||
toggleCamera();
|
toggleCamera();
|
||||||
// Broadcast camera state via WebSocket
|
broadcastVoiceStatus();
|
||||||
const { isMuted: m, isDeafened: d, isScreenSharing: ss } = useVoiceStore.getState();
|
|
||||||
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: willEnable, isScreenSharing: ss }, voiceOrigin);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[VoiceControlBar] Failed to toggle camera:', err);
|
console.error('[VoiceControlBar] Failed to toggle camera:', err);
|
||||||
}
|
}
|
||||||
@@ -111,14 +84,10 @@ export function VoiceControlBar() {
|
|||||||
try {
|
try {
|
||||||
if (!isScreenSharing) {
|
if (!isScreenSharing) {
|
||||||
const started = await startScreenShare(room);
|
const started = await startScreenShare(room);
|
||||||
if (started) {
|
if (started) broadcastVoiceStatus();
|
||||||
const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState();
|
|
||||||
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: true }, voiceOrigin);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
await stopScreenShare(room);
|
await stopScreenShare(room);
|
||||||
const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState();
|
broadcastVoiceStatus();
|
||||||
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: false }, voiceOrigin);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[VoiceControlBar] Failed to toggle screen share:', err);
|
console.error('[VoiceControlBar] Failed to toggle screen share:', err);
|
||||||
@@ -176,7 +145,7 @@ export function VoiceControlBar() {
|
|||||||
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30`
|
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30`
|
||||||
: btnDefault
|
: btnDefault
|
||||||
}
|
}
|
||||||
title={(isServerMuted || isServerDeafened) ? 'Server Muted' : isMuted ? 'Unmute (M)' : 'Mute (M)'}
|
title={(isServerMuted || isServerDeafened) ? (isMuted ? 'Server Muted (self-muted)' : 'Server Muted') : isMuted ? 'Unmute (M)' : 'Mute (M)'}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
|||||||
import { ConnectionInfoPopover } from './ConnectionInfoPopover';
|
import { ConnectionInfoPopover } from './ConnectionInfoPopover';
|
||||||
import { startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
import { startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
|
import { broadcastVoiceStatus } from '../../utils/voice';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VoiceControls renders the voice status + button rows.
|
* VoiceControls renders the voice status + button rows.
|
||||||
@@ -50,9 +51,7 @@ export function VoiceControls() {
|
|||||||
const willEnable = !isCameraOn;
|
const willEnable = !isCameraOn;
|
||||||
await room.localParticipant.setCameraEnabled(willEnable);
|
await room.localParticipant.setCameraEnabled(willEnable);
|
||||||
toggleCamera();
|
toggleCamera();
|
||||||
// Broadcast camera state via WebSocket
|
broadcastVoiceStatus();
|
||||||
const { isMuted: m, isDeafened: d, isScreenSharing: ss } = useVoiceStore.getState();
|
|
||||||
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: willEnable, isScreenSharing: ss }, voiceOrigin);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[VoiceControls] Failed to toggle camera:', err);
|
console.error('[VoiceControls] Failed to toggle camera:', err);
|
||||||
}
|
}
|
||||||
@@ -64,14 +63,10 @@ export function VoiceControls() {
|
|||||||
try {
|
try {
|
||||||
if (!isScreenSharing) {
|
if (!isScreenSharing) {
|
||||||
const started = await startScreenShare(room);
|
const started = await startScreenShare(room);
|
||||||
if (started) {
|
if (started) broadcastVoiceStatus();
|
||||||
const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState();
|
|
||||||
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: true }, voiceOrigin);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
await stopScreenShare(room);
|
await stopScreenShare(room);
|
||||||
const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState();
|
broadcastVoiceStatus();
|
||||||
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: false }, voiceOrigin);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[VoiceControls] Failed to toggle screen share:', err);
|
console.error('[VoiceControls] Failed to toggle screen share:', err);
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import {
|
|||||||
ConnectionQuality,
|
ConnectionQuality,
|
||||||
LocalAudioTrack,
|
LocalAudioTrack,
|
||||||
LocalTrackPublication,
|
LocalTrackPublication,
|
||||||
|
DisconnectReason,
|
||||||
} from 'livekit-client';
|
} from 'livekit-client';
|
||||||
import { getApiForOrigin, getChannelOrigin, useSpaceStore } from '../stores/spaceStore';
|
import { getApiForOrigin, getChannelOrigin, getMyUserIdForOrigin, useSpaceStore } from '../stores/spaceStore';
|
||||||
import { wsSend } from './useWebSocket';
|
import { wsSend } from './useWebSocket';
|
||||||
import { useVoiceStore } from '../stores/voiceStore';
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
|
import { broadcastVoiceStatus } from '../utils/voice';
|
||||||
import { AudioManager } from '../audio/AudioManager';
|
import { AudioManager } from '../audio/AudioManager';
|
||||||
import { SpeakingDetector } from '../audio/SpeakingDetector';
|
import { SpeakingDetector } from '../audio/SpeakingDetector';
|
||||||
import {
|
import {
|
||||||
@@ -138,6 +140,8 @@ export function useLiveKit() {
|
|||||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
||||||
const screenShareConfig = useVoiceStore((s) => s.screenShareConfig);
|
const screenShareConfig = useVoiceStore((s) => s.screenShareConfig);
|
||||||
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
|
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
|
||||||
|
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
|
||||||
|
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
|
||||||
const inputVolume = useVoiceStore((s) => s.inputVolume);
|
const inputVolume = useVoiceStore((s) => s.inputVolume);
|
||||||
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
||||||
const echoCancellation = useVoiceStore((s) => s.echoCancellation);
|
const echoCancellation = useVoiceStore((s) => s.echoCancellation);
|
||||||
@@ -187,8 +191,15 @@ export function useLiveKit() {
|
|||||||
let isPartMuted = !p.isMicrophoneEnabled;
|
let isPartMuted = !p.isMicrophoneEnabled;
|
||||||
|
|
||||||
if (isLocal) {
|
if (isLocal) {
|
||||||
isPartDeafened = useVoiceStore.getState().isDeafened;
|
// Compute effective state: user intent || server enforcement
|
||||||
isPartMuted = useVoiceStore.getState().isMuted;
|
const vs = useVoiceStore.getState();
|
||||||
|
const cvId = vs.currentVoiceChannelId;
|
||||||
|
const localOrigin = cvId ? getChannelOrigin(cvId) : '';
|
||||||
|
const localMyId = cvId ? getMyUserIdForOrigin(localOrigin) : undefined;
|
||||||
|
const localSpaceId = cvId ? useSpaceStore.getState().channelToSpaceMap.get(cvId) : null;
|
||||||
|
const localKey = (localSpaceId && localMyId) ? `${localSpaceId}:${localMyId}` : '';
|
||||||
|
isPartMuted = vs.isMuted || vs.serverMutedUserIds.has(localKey);
|
||||||
|
isPartDeafened = vs.isDeafened || vs.serverDeafenedUserIds.has(localKey);
|
||||||
} else {
|
} else {
|
||||||
isPartDeafened = userState?.isDeafened ?? useVoiceStore.getState().deafenedUserIds.has(userId);
|
isPartDeafened = userState?.isDeafened ?? useVoiceStore.getState().deafenedUserIds.has(userId);
|
||||||
if (userState) isPartMuted = userState.isMuted;
|
if (userState) isPartMuted = userState.isMuted;
|
||||||
@@ -238,6 +249,16 @@ export function useLiveKit() {
|
|||||||
const r = roomRef.current;
|
const r = roomRef.current;
|
||||||
if (!r || !isConnected) return;
|
if (!r || !isConnected) return;
|
||||||
|
|
||||||
|
// Compute effective mute/deafen: user intent || server enforcement
|
||||||
|
const vs = useVoiceStore.getState();
|
||||||
|
const cvId = vs.currentVoiceChannelId;
|
||||||
|
const effOrigin = cvId ? getChannelOrigin(cvId) : '';
|
||||||
|
const effMyId = cvId ? getMyUserIdForOrigin(effOrigin) : undefined;
|
||||||
|
const effSpaceId = cvId ? useSpaceStore.getState().channelToSpaceMap.get(cvId) : null;
|
||||||
|
const effKey = (effSpaceId && effMyId) ? `${effSpaceId}:${effMyId}` : '';
|
||||||
|
const effectiveMuted = isMuted || serverMutedUserIds.has(effKey);
|
||||||
|
const effectiveDeafened = isDeafened || serverDeafenedUserIds.has(effKey);
|
||||||
|
|
||||||
const syncMic = async () => {
|
const syncMic = async () => {
|
||||||
try {
|
try {
|
||||||
const audioManager = AudioManager.getInstance();
|
const audioManager = AudioManager.getInstance();
|
||||||
@@ -249,8 +270,8 @@ export function useLiveKit() {
|
|||||||
const micPub = r.localParticipant.getTrackPublications()
|
const micPub = r.localParticipant.getTrackPublications()
|
||||||
.find(p => p.source === Track.Source.Microphone);
|
.find(p => p.source === Track.Source.Microphone);
|
||||||
|
|
||||||
// If muted or deafened, mute the track in-place (keep it published)
|
// If effectively muted or deafened, mute the track in-place (keep it published)
|
||||||
if (isMuted || isDeafened) {
|
if (effectiveMuted || effectiveDeafened) {
|
||||||
if (micPub?.track && !micPub.isMuted) {
|
if (micPub?.track && !micPub.isMuted) {
|
||||||
await r.localParticipant.setMicrophoneEnabled(false);
|
await r.localParticipant.setMicrophoneEnabled(false);
|
||||||
}
|
}
|
||||||
@@ -302,7 +323,7 @@ export function useLiveKit() {
|
|||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]);
|
}, [isMuted, isDeafened, serverMutedUserIds, serverDeafenedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]);
|
||||||
|
|
||||||
const connect = useCallback(async (channelId: string, isDm?: boolean) => {
|
const connect = useCallback(async (channelId: string, isDm?: boolean) => {
|
||||||
const storedId = isDm ? `dm-${channelId}` : channelId;
|
const storedId = isDm ? `dm-${channelId}` : channelId;
|
||||||
@@ -313,8 +334,7 @@ export function useLiveKit() {
|
|||||||
if (isDm) return;
|
if (isDm) return;
|
||||||
const origin = getChannelOrigin(channelId);
|
const origin = getChannelOrigin(channelId);
|
||||||
wsSend({ type: 'voice_join', channelId }, origin);
|
wsSend({ type: 'voice_join', channelId }, origin);
|
||||||
const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: s } = useVoiceStore.getState();
|
broadcastVoiceStatus(origin);
|
||||||
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: s }, origin);
|
|
||||||
};
|
};
|
||||||
const gen = ++_connectGeneration;
|
const gen = ++_connectGeneration;
|
||||||
|
|
||||||
@@ -361,7 +381,15 @@ export function useLiveKit() {
|
|||||||
const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); };
|
const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); };
|
||||||
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
|
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
|
||||||
guardedUpdate();
|
guardedUpdate();
|
||||||
if (useVoiceStore.getState().isDeafened) {
|
// Notify new participant of our effective deafen state
|
||||||
|
const vsConn = useVoiceStore.getState();
|
||||||
|
const cvIdConn = vsConn.currentVoiceChannelId;
|
||||||
|
const connOrigin = cvIdConn ? getChannelOrigin(cvIdConn) : '';
|
||||||
|
const connMyId = cvIdConn ? getMyUserIdForOrigin(connOrigin) : undefined;
|
||||||
|
const connSpaceId = cvIdConn ? useSpaceStore.getState().channelToSpaceMap.get(cvIdConn) : null;
|
||||||
|
const connKey = (connSpaceId && connMyId) ? `${connSpaceId}:${connMyId}` : '';
|
||||||
|
const effDeaf = vsConn.isDeafened || vsConn.serverDeafenedUserIds.has(connKey);
|
||||||
|
if (effDeaf) {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
newRoom.localParticipant.publishData(
|
newRoom.localParticipant.publishData(
|
||||||
encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })),
|
encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })),
|
||||||
@@ -449,7 +477,7 @@ export function useLiveKit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
newRoom.on(RoomEvent.Disconnected, () => {
|
newRoom.on(RoomEvent.Disconnected, (reason?: DisconnectReason) => {
|
||||||
if (roomRef.current !== newRoom) return;
|
if (roomRef.current !== newRoom) return;
|
||||||
SpeakingDetector.getInstance().clear();
|
SpeakingDetector.getInstance().clear();
|
||||||
setConnectionState(ConnectionState.Disconnected);
|
setConnectionState(ConnectionState.Disconnected);
|
||||||
@@ -458,6 +486,13 @@ export function useLiveKit() {
|
|||||||
useVoiceStore.getState().setParticipants([]);
|
useVoiceStore.getState().setParticipants([]);
|
||||||
useVoiceStore.getState().setSpeakingParticipants(new Set());
|
useVoiceStore.getState().setSpeakingParticipants(new Set());
|
||||||
useVoiceStore.getState().setIsLiveKitConnected(false);
|
useVoiceStore.getState().setIsLiveKitConnected(false);
|
||||||
|
|
||||||
|
// Non-client disconnect (identity collision, server shutdown, kicked, etc.)
|
||||||
|
// → clear voice intent so AppLayout doesn't auto-retry into an infinite loop.
|
||||||
|
// Client-initiated disconnects already clear this via leaveVoice() / VoiceControlBar.
|
||||||
|
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||||
|
useVoiceStore.getState().leaveVoice();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await newRoom.connect(url, token, { autoSubscribe: false });
|
await newRoom.connect(url, token, { autoSubscribe: false });
|
||||||
@@ -495,7 +530,7 @@ export function useLiveKit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateParticipants();
|
updateParticipants();
|
||||||
} catch (err) { if (gen === _connectGeneration) { setConnectionError('Failed to connect'); useVoiceStore.getState().setConnectionError('Failed to connect'); } }
|
} catch (err) { if (gen === _connectGeneration) { setConnectionError('Failed to connect'); useVoiceStore.getState().setConnectionError('Failed to connect'); useVoiceStore.getState().leaveVoice(); } }
|
||||||
finally { if (gen === _connectGeneration) setIsConnecting(false); }
|
finally { if (gen === _connectGeneration) setIsConnecting(false); }
|
||||||
}, [updateParticipants, handleDataReceived]);
|
}, [updateParticipants, handleDataReceived]);
|
||||||
|
|
||||||
@@ -545,7 +580,7 @@ export function useLiveKit() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateParticipants();
|
updateParticipants();
|
||||||
}, [voiceUserStates, isMuted, isDeafened, updateParticipants]);
|
}, [voiceUserStates, isMuted, isDeafened, serverMutedUserIds, serverDeafenedUserIds, updateParticipants]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import { useSpaceStore, getChannelOrigin } from '../stores/spaceStore';
|
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../stores/spaceStore';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../stores/chatStore';
|
||||||
import { useVoiceStore } from '../stores/voiceStore';
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
import { useSocialStore } from '../stores/socialStore';
|
import { useSocialStore } from '../stores/socialStore';
|
||||||
import { useSettingsStore } from '../stores/settingsStore';
|
import { useSettingsStore } from '../stores/settingsStore';
|
||||||
import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@backspace/shared';
|
import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@backspace/shared';
|
||||||
import { resolveAssetUrl, normalizeUserAssets, normalizeMessageAssets } from '../utils/assetUrls';
|
import { resolveAssetUrl, normalizeUserAssets, normalizeMessageAssets } from '../utils/assetUrls';
|
||||||
|
import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../utils/voice';
|
||||||
|
|
||||||
// ─── Connection state ─────────────────────────────────────────────────────────
|
// ─── Connection state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
const { setUser } = useAuthStore.getState();
|
const { setUser } = useAuthStore.getState();
|
||||||
const { populateFromReady, loadSpaceDetail, currentSpaceId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useSpaceStore.getState();
|
const { populateFromReady, loadSpaceDetail, currentSpaceId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useSpaceStore.getState();
|
||||||
const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
|
const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
|
||||||
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, clearVoiceUsersForOrigin, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState();
|
const { addVoiceUser, removeVoiceUser, clearVoiceUsersForOrigin, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState();
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'ready':
|
case 'ready':
|
||||||
@@ -150,12 +151,8 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
useChatStore.getState().setReadStates(event.readStates, channelLastMessageIds, originChannelIds);
|
useChatStore.getState().setReadStates(event.readStates, channelLastMessageIds, originChannelIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear voice state for the reconnecting origin before repopulating
|
// Clear voice state only for the reconnecting origin before repopulating
|
||||||
if (isHome) {
|
clearVoiceUsersForOrigin(origin);
|
||||||
clearAllVoiceUsers();
|
|
||||||
} else {
|
|
||||||
clearVoiceUsersForOrigin(origin);
|
|
||||||
}
|
|
||||||
if (event.voiceStates) {
|
if (event.voiceStates) {
|
||||||
for (const [channelId, userIds] of Object.entries(event.voiceStates)) {
|
for (const [channelId, userIds] of Object.entries(event.voiceStates)) {
|
||||||
setVoiceUsers(channelId, userIds);
|
setVoiceUsers(channelId, userIds);
|
||||||
@@ -203,20 +200,9 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
// Single atomic update
|
// Single atomic update
|
||||||
useVoiceStore.setState({ serverMutedUserIds: nextServerMuted, serverDeafenedUserIds: nextServerDeafened });
|
useVoiceStore.setState({ serverMutedUserIds: nextServerMuted, serverDeafenedUserIds: nextServerDeafened });
|
||||||
|
|
||||||
// Enforce local mute/deafen to match server restrictions (one-directional: only force-mute, never auto-unmute)
|
// With decoupled state, user intent is never force-set by the server.
|
||||||
const myReadyId = useAuthStore.getState().user?.id;
|
// Effective state (intent || serverEnforcement) is computed reactively
|
||||||
if (myReadyId) {
|
// at broadcast and hardware time.
|
||||||
const vs = useVoiceStore.getState();
|
|
||||||
const activeSpaceId = vs.currentVoiceChannelId ? useSpaceStore.getState().channelToSpaceMap.get(vs.currentVoiceChannelId) : null;
|
|
||||||
if (activeSpaceId) {
|
|
||||||
const key = `${activeSpaceId}:${myReadyId}`;
|
|
||||||
if (nextServerDeafened.has(key) && !vs.isDeafened) {
|
|
||||||
useVoiceStore.setState({ isMuted: true, isDeafened: true });
|
|
||||||
} else if (nextServerMuted.has(key) && !vs.isMuted) {
|
|
||||||
useVoiceStore.setState({ isMuted: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-register in voice channel after WS reconnect — the server lost
|
// Re-register in voice channel after WS reconnect — the server lost
|
||||||
@@ -225,14 +211,14 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
// tabs can reconnect WS but not LiveKit — sending voice_join without
|
// tabs can reconnect WS but not LiveKit — sending voice_join without
|
||||||
// an active media plane would create ghost users in the sidebar.
|
// an active media plane would create ghost users in the sidebar.
|
||||||
{
|
{
|
||||||
const { currentVoiceChannelId, isLiveKitConnected, isMuted, isDeafened, isCameraOn, isScreenSharing } = useVoiceStore.getState();
|
const { currentVoiceChannelId, isLiveKitConnected } = useVoiceStore.getState();
|
||||||
if (currentVoiceChannelId && isLiveKitConnected) {
|
if (currentVoiceChannelId && isLiveKitConnected) {
|
||||||
const voiceOrigin = getChannelOrigin(currentVoiceChannelId);
|
const voiceOrigin = getChannelOrigin(currentVoiceChannelId);
|
||||||
if (voiceOrigin === origin) {
|
if (voiceOrigin === origin) {
|
||||||
const myId = event.user.id;
|
const myId = event.user.id;
|
||||||
if (myId) addVoiceUser(currentVoiceChannelId, myId);
|
if (myId) addVoiceUser(currentVoiceChannelId, myId);
|
||||||
wsSend({ type: 'voice_join', channelId: currentVoiceChannelId }, origin);
|
wsSend({ type: 'voice_join', channelId: currentVoiceChannelId }, origin);
|
||||||
wsSend({ type: 'voice_status', isMuted, isDeafened, isCameraOn, isScreenSharing }, origin);
|
broadcastVoiceStatus(origin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,101 +305,20 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
case 'voice_server_muted': {
|
case 'voice_server_muted': {
|
||||||
const { setServerMutedUser } = useVoiceStore.getState();
|
const { setServerMutedUser } = useVoiceStore.getState();
|
||||||
setServerMutedUser(event.spaceId, event.userId, event.muted);
|
setServerMutedUser(event.spaceId, event.userId, event.muted);
|
||||||
|
// Broadcast effective state if this targets the current user
|
||||||
const checkMute = (myUserId: string | undefined) => {
|
const myMuteId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin);
|
||||||
if (event.userId === myUserId) {
|
if (event.userId === myMuteId) broadcastVoiceStatus();
|
||||||
if (event.muted) {
|
|
||||||
// Force-mute the mic
|
|
||||||
const vs = useVoiceStore.getState();
|
|
||||||
if (!vs.isMuted) {
|
|
||||||
useVoiceStore.setState({ isMuted: true });
|
|
||||||
const fresh = useVoiceStore.getState();
|
|
||||||
const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : '';
|
|
||||||
wsSend({ type: 'voice_status', isMuted: true, isDeafened: fresh.isDeafened, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Server unmuted — auto-restore mic unless still server-deafened
|
|
||||||
const vs = useVoiceStore.getState();
|
|
||||||
if (!vs.serverDeafenedUserIds.has(`${event.spaceId}:${myUserId}`) && vs.isMuted) {
|
|
||||||
useVoiceStore.setState({ isMuted: false });
|
|
||||||
const fresh = useVoiceStore.getState();
|
|
||||||
const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : '';
|
|
||||||
wsSend({ type: 'voice_status', isMuted: false, isDeafened: fresh.isDeafened, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isHome) {
|
|
||||||
checkMute(useAuthStore.getState().user?.id);
|
|
||||||
} else {
|
|
||||||
import('../stores/spaceStore').then(({ getMyUserIdForOrigin }) => {
|
|
||||||
checkMute(getMyUserIdForOrigin(origin));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'voice_server_deafened': {
|
case 'voice_server_deafened': {
|
||||||
const { setServerDeafenedUser } = useVoiceStore.getState();
|
const { setServerDeafenedUser } = useVoiceStore.getState();
|
||||||
setServerDeafenedUser(event.spaceId, event.userId, event.deafened);
|
setServerDeafenedUser(event.spaceId, event.userId, event.deafened);
|
||||||
|
// Broadcast effective state if this targets the current user
|
||||||
const checkDeafen = (myUid: string | undefined) => {
|
const myDeafenId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin);
|
||||||
if (event.userId === myUid) {
|
if (event.userId === myDeafenId) {
|
||||||
if (event.deafened) {
|
broadcastVoiceStatus();
|
||||||
// Force-deafen (smart toggle sets both muted+deafened)
|
broadcastDeafenViaLiveKit();
|
||||||
const vs = useVoiceStore.getState();
|
|
||||||
if (!vs.isDeafened) {
|
|
||||||
useVoiceStore.setState({ isMuted: true, isDeafened: true });
|
|
||||||
const fresh = useVoiceStore.getState();
|
|
||||||
const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : '';
|
|
||||||
wsSend({ type: 'voice_status', isMuted: true, isDeafened: true, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin);
|
|
||||||
// Broadcast deafen to in-room participants via LiveKit data channel
|
|
||||||
import('./useLiveKit').then(({ getActiveRoom }) => {
|
|
||||||
const room = getActiveRoom();
|
|
||||||
if (room) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
room.localParticipant.publishData(
|
|
||||||
encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })),
|
|
||||||
{ reliable: true }
|
|
||||||
).catch(() => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Server un-deafened — auto-restore
|
|
||||||
const vs = useVoiceStore.getState();
|
|
||||||
if (vs.isDeafened) {
|
|
||||||
const stillServerMuted = vs.serverMutedUserIds.has(`${event.spaceId}:${myUid}`);
|
|
||||||
useVoiceStore.setState({
|
|
||||||
isDeafened: false,
|
|
||||||
...(stillServerMuted ? {} : { isMuted: false }),
|
|
||||||
});
|
|
||||||
const fresh = useVoiceStore.getState();
|
|
||||||
const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : '';
|
|
||||||
wsSend({ type: 'voice_status', isMuted: fresh.isMuted, isDeafened: false, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin);
|
|
||||||
// Broadcast undeafen via LiveKit data channel
|
|
||||||
import('./useLiveKit').then(({ getActiveRoom }) => {
|
|
||||||
const room = getActiveRoom();
|
|
||||||
if (room) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
room.localParticipant.publishData(
|
|
||||||
encoder.encode(JSON.stringify({ type: 'deafen', deafened: false })),
|
|
||||||
{ reliable: true }
|
|
||||||
).catch(() => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isHome) {
|
|
||||||
checkDeafen(useAuthStore.getState().user?.id);
|
|
||||||
} else {
|
|
||||||
import('../stores/spaceStore').then(({ getMyUserIdForOrigin }) => {
|
|
||||||
checkDeafen(getMyUserIdForOrigin(origin));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,13 +240,9 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }),
|
setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }),
|
||||||
|
|
||||||
toggleMic: () => set((state) => {
|
toggleMic: () => set((state) => {
|
||||||
// Server-muted/deafened users cannot unmute themselves
|
// User intent toggle — effective state (intent || serverEnforcement) is
|
||||||
const origin = state.currentVoiceChannelId ? getChannelOrigin(state.currentVoiceChannelId) : '';
|
// computed at broadcast/hardware time, so the mic stays off while server-muted
|
||||||
const myId = getMyUserIdForOrigin(origin);
|
// even if the user toggles isMuted to false.
|
||||||
const spaceId = state.currentVoiceChannelId ? useSpaceStore.getState().channelToSpaceMap.get(state.currentVoiceChannelId) : null;
|
|
||||||
if (myId && spaceId && state.isMuted && (state.serverMutedUserIds.has(`${spaceId}:${myId}`) || state.serverDeafenedUserIds.has(`${spaceId}:${myId}`))) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
if (state.isMuted && state.isDeafened) {
|
if (state.isMuted && state.isDeafened) {
|
||||||
// Unmuting while deafened → clear both (Discord behavior)
|
// Unmuting while deafened → clear both (Discord behavior)
|
||||||
return { isMuted: false, isDeafened: false };
|
return { isMuted: false, isDeafened: false };
|
||||||
@@ -254,13 +250,7 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
return { isMuted: !state.isMuted };
|
return { isMuted: !state.isMuted };
|
||||||
}),
|
}),
|
||||||
toggleDeafen: () => set((state) => {
|
toggleDeafen: () => set((state) => {
|
||||||
// Server-deafened users cannot undeafen themselves
|
// User intent toggle — same decoupled model as toggleMic.
|
||||||
const origin = state.currentVoiceChannelId ? getChannelOrigin(state.currentVoiceChannelId) : '';
|
|
||||||
const myId = getMyUserIdForOrigin(origin);
|
|
||||||
const spaceId = state.currentVoiceChannelId ? useSpaceStore.getState().channelToSpaceMap.get(state.currentVoiceChannelId) : null;
|
|
||||||
if (myId && spaceId && state.isDeafened && state.serverDeafenedUserIds.has(`${spaceId}:${myId}`)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
if (state.isDeafened) {
|
if (state.isDeafened) {
|
||||||
// Undeafening → clear both
|
// Undeafening → clear both
|
||||||
return { isMuted: false, isDeafened: false };
|
return { isMuted: false, isDeafened: false };
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { Room, Track } from 'livekit-client';
|
|||||||
import { useVoiceStore } from '../stores/voiceStore';
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
import type { ScreenShareConfig } from '../stores/voiceStore';
|
import type { ScreenShareConfig } from '../stores/voiceStore';
|
||||||
import { getStreamingLimits } from '../stores/settingsStore';
|
import { getStreamingLimits } from '../stores/settingsStore';
|
||||||
import { wsSend } from '../hooks/useWebSocket';
|
|
||||||
import { getPublisherPC, getMediaStreamTrack } from './livekitInternals';
|
import { getPublisherPC, getMediaStreamTrack } from './livekitInternals';
|
||||||
|
import { broadcastVoiceStatus } from './voice';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -224,6 +224,5 @@ export async function changeScreenShare(room: Room): Promise<void> {
|
|||||||
|
|
||||||
export function handleScreenShareUnpublished(): void {
|
export function handleScreenShareUnpublished(): void {
|
||||||
useVoiceStore.setState({ isScreenSharing: false });
|
useVoiceStore.setState({ isScreenSharing: false });
|
||||||
const { isMuted, isDeafened, isCameraOn } = useVoiceStore.getState();
|
broadcastVoiceStatus();
|
||||||
wsSend({ type: 'voice_status', isMuted, isDeafened, isCameraOn, isScreenSharing: false });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,65 @@
|
|||||||
import { useVoiceStore } from '../stores/voiceStore';
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
import { getChannelOrigin, getMyUserIdForOrigin } from '../stores/spaceStore';
|
import { getChannelOrigin, getMyUserIdForOrigin, useSpaceStore } from '../stores/spaceStore';
|
||||||
import { wsSend } from '../hooks/useWebSocket';
|
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.
|
* Centralized voice channel join that handles cross-instance cleanup.
|
||||||
* When switching from a channel on Instance A to one on Instance B,
|
* When switching from a channel on Instance A to one on Instance B,
|
||||||
|
|||||||
Reference in New Issue
Block a user