From 6deed231ab973dd3134b346e934374c02f4d6222 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:02:00 +0100 Subject: [PATCH] Ensure voice mute/deafen status persists across disconnects and re-joins --- packages/server/src/ws/handler.ts | 6 +- packages/web/src/hooks/useLiveKit.ts | 4 +- packages/web/src/hooks/useWebSocket.ts | 1 + packages/web/src/stores/voiceStore.ts | 277 +++++++++++++------------ 4 files changed, 153 insertions(+), 135 deletions(-) diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 6b495fbc..eb501557 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -115,16 +115,18 @@ class ConnectionManager { } leaveAllVoice(userId: string): string | null { + let leftChannelId: string | null = null; for (const [channelId, users] of this.voiceStates) { if (users.has(userId)) { users.delete(userId); if (users.size === 0) { this.voiceStates.delete(channelId); } - return channelId; + leftChannelId = channelId; + break; } } - return null; + return leftChannelId; } getVoiceUsers(channelId: string): Set { diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index 14726000..92af8242 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -222,7 +222,7 @@ export function useLiveKit() { } } catch (err) { if (gen === _connectGeneration) setConnectionError('Failed to connect'); } finally { if (gen === _connectGeneration) setIsConnecting(false); } - }, [updateParticipants, handleDataReceived]); + }, [updateParticipants, handleDataReceived, isMuted, isDeafened]); const connectDm = useCallback(async (dmChannelId: string) => { const gen = ++_connectGeneration; @@ -267,7 +267,7 @@ export function useLiveKit() { } } catch (err) { if (gen === _connectGeneration) setConnectionError('Failed to connect'); } finally { if (gen === _connectGeneration) setIsConnecting(false); } - }, [updateParticipants, handleDataReceived]); + }, [updateParticipants, handleDataReceived, isMuted, isDeafened]); const disconnect = useCallback(async () => { _connectGeneration++; diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 42c28dff..c215e9e9 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -56,6 +56,7 @@ function handleEvent(event: ServerEvent): void { { const { currentVoiceChannelId, isMuted: curMuted, isDeafened: curDeafened } = useVoiceStore.getState(); if (currentVoiceChannelId) { + console.log('[WebSocket] Re-syncing voice status on reconnect:', { currentVoiceChannelId, curMuted, curDeafened }); wsSend({ type: 'voice_join', channelId: currentVoiceChannelId }); wsSend({ type: 'voice_status', isMuted: curMuted, isDeafened: curDeafened }); } diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index 716acdaf..b8874866 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; import type { ParticipantInfo } from '../hooks/useLiveKit'; interface VoiceState { @@ -55,149 +56,163 @@ interface VoiceState { reset: () => void; } -export const useVoiceStore = create((set, get) => ({ - voiceUsers: new Map(), - currentVoiceChannelId: null, - isMuted: false, - isDeafened: false, - isCameraOn: false, - isScreenSharing: false, - participants: [], - connectionError: null, - isLiveKitConnected: false, - inputVolume: 100, - outputVolume: 100, - focusedParticipantId: null, - videoQuality: '720p60', - participantVolumes: new Map(), - setParticipantVolume: (userId, volume) => { - set((state) => { - const newMap = new Map(state.participantVolumes); - newMap.set(userId, volume); - return { participantVolumes: newMap }; - }); - }, - getParticipantVolume: (userId) => get().participantVolumes.get(userId) ?? 100, +export const useVoiceStore = create()( + persist( + (set, get) => ({ + voiceUsers: new Map(), + currentVoiceChannelId: null, + isMuted: false, + isDeafened: false, + isCameraOn: false, + isScreenSharing: false, + participants: [], + connectionError: null, + isLiveKitConnected: false, + inputVolume: 100, + outputVolume: 100, + focusedParticipantId: null, + videoQuality: '720p60', + participantVolumes: new Map(), + setParticipantVolume: (userId, volume) => { + set((state) => { + const newMap = new Map(state.participantVolumes); + newMap.set(userId, volume); + return { participantVolumes: newMap }; + }); + }, + getParticipantVolume: (userId) => get().participantVolumes.get(userId) ?? 100, - incomingCall: null, - outgoingCall: null, - activeDmCall: null, + incomingCall: null, + outgoingCall: null, + activeDmCall: null, - setIncomingCall: (call) => set({ incomingCall: call }), - setOutgoingCall: (call) => set({ outgoingCall: call }), - setActiveDmCall: (call) => set({ activeDmCall: call }), + setIncomingCall: (call) => set({ incomingCall: call }), + setOutgoingCall: (call) => set({ outgoingCall: call }), + setActiveDmCall: (call) => set({ activeDmCall: call }), - setVoiceUsers: (channelId, userIds) => { - set((state) => { - const newMap = new Map(state.voiceUsers); - newMap.set(channelId, userIds); - return { voiceUsers: newMap }; - }); - }, + setVoiceUsers: (channelId, userIds) => { + set((state) => { + const newMap = new Map(state.voiceUsers); + newMap.set(channelId, userIds); + return { voiceUsers: newMap }; + }); + }, - addVoiceUser: (channelId, userId) => { - set((state) => { - const newMap = new Map(state.voiceUsers); - const current = newMap.get(channelId) ?? []; - if (!current.includes(userId)) { - newMap.set(channelId, [...current, userId]); - } - return { voiceUsers: newMap }; - }); - }, + addVoiceUser: (channelId, userId) => { + set((state) => { + const newMap = new Map(state.voiceUsers); + const current = newMap.get(channelId) ?? []; + if (!current.includes(userId)) { + newMap.set(channelId, [...current, userId]); + } + return { voiceUsers: newMap }; + }); + }, - removeVoiceUser: (channelId, userId) => { - set((state) => { - const newMap = new Map(state.voiceUsers); - const current = newMap.get(channelId) ?? []; - newMap.set(channelId, current.filter(id => id !== userId)); - return { voiceUsers: newMap }; - }); - }, + removeVoiceUser: (channelId, userId) => { + set((state) => { + const newMap = new Map(state.voiceUsers); + const current = newMap.get(channelId) ?? []; + newMap.set(channelId, current.filter(id => id !== userId)); + return { voiceUsers: newMap }; + }); + }, - setCurrentVoiceChannel: (channelId) => set({ currentVoiceChannelId: channelId }), + setCurrentVoiceChannel: (channelId) => set({ currentVoiceChannelId: channelId }), - setParticipants: (participants) => set({ participants }), - setConnectionError: (error) => set({ connectionError: error }), - setIsLiveKitConnected: (connected) => set({ isLiveKitConnected: connected }), + setParticipants: (participants) => set({ participants }), + setConnectionError: (error) => set({ connectionError: error }), + setIsLiveKitConnected: (connected) => set({ isLiveKitConnected: connected }), - setInputVolume: (volume) => set({ inputVolume: volume }), - setOutputVolume: (volume) => set({ outputVolume: volume }), + setInputVolume: (volume) => set({ inputVolume: volume }), + setOutputVolume: (volume) => set({ outputVolume: volume }), - toggleMic: () => set((state) => ({ isMuted: !state.isMuted })), - toggleDeafen: () => set((state) => ({ isDeafened: !state.isDeafened })), - toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })), - toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })), + toggleMic: () => set((state) => ({ isMuted: !state.isMuted })), + toggleDeafen: () => set((state) => ({ isDeafened: !state.isDeafened })), + toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })), + toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })), - setFocusedParticipant: (id) => set({ focusedParticipantId: id }), - setVideoQuality: (quality) => set({ videoQuality: quality }), - noiseSuppression: true, - toggleNoiseSuppression: () => set((state) => ({ noiseSuppression: !state.noiseSuppression })), - deafenedUserIds: new Set(), - setUserDeafened: (userId, deafened) => { - set((state) => { - const newSet = new Set(state.deafenedUserIds); - if (deafened) newSet.add(userId); else newSet.delete(userId); - return { deafenedUserIds: newSet }; - }); - }, + setFocusedParticipant: (id) => set({ focusedParticipantId: id }), + setVideoQuality: (quality) => set({ videoQuality: quality }), + noiseSuppression: true, + toggleNoiseSuppression: () => set((state) => ({ noiseSuppression: !state.noiseSuppression })), + deafenedUserIds: new Set(), + setUserDeafened: (userId, deafened) => { + set((state) => { + const newSet = new Set(state.deafenedUserIds); + if (deafened) newSet.add(userId); else newSet.delete(userId); + return { deafenedUserIds: newSet }; + }); + }, - voiceUserStates: new Map(), - setVoiceUserStatus: (userId, isMuted, isDeafened) => { - set((state) => { - const newMap = new Map(state.voiceUserStates); - newMap.set(userId, { isMuted, isDeafened }); - return { voiceUserStates: newMap }; - }); - }, - clearVoiceUserStatus: (userId) => { - set((state) => { - const newMap = new Map(state.voiceUserStates); - newMap.delete(userId); - return { voiceUserStates: newMap }; - }); - }, + voiceUserStates: new Map(), + setVoiceUserStatus: (userId, isMuted, isDeafened) => { + set((state) => { + const newMap = new Map(state.voiceUserStates); + newMap.set(userId, { isMuted, isDeafened }); + return { voiceUserStates: newMap }; + }); + }, + clearVoiceUserStatus: (userId) => { + set((state) => { + const newMap = new Map(state.voiceUserStates); + newMap.delete(userId); + return { voiceUserStates: newMap }; + }); + }, - getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [], + getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [], - clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }), + clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }), - // Leave voice without wiping the voiceUsers map (so sidebar still shows others) - leaveVoice: () => set({ - currentVoiceChannelId: null, - isMuted: false, - isDeafened: false, - isCameraOn: false, - isScreenSharing: false, - participants: [], - connectionError: null, - isLiveKitConnected: false, - inputVolume: 100, - outputVolume: 100, - focusedParticipantId: null, - activeDmCall: null, - outgoingCall: null, - deafenedUserIds: new Set(), - }), + // Leave voice without wiping the voiceUsers map (so sidebar still shows others) + leaveVoice: () => set({ + currentVoiceChannelId: null, + isCameraOn: false, + isScreenSharing: false, + participants: [], + connectionError: null, + isLiveKitConnected: false, + focusedParticipantId: null, + activeDmCall: null, + outgoingCall: null, + deafenedUserIds: new Set(), + }), - reset: () => set({ - voiceUsers: new Map(), - currentVoiceChannelId: null, - isMuted: false, - isDeafened: false, - isCameraOn: false, - isScreenSharing: false, - participants: [], - connectionError: null, - isLiveKitConnected: false, - inputVolume: 100, - outputVolume: 100, - focusedParticipantId: null, - participantVolumes: new Map(), - incomingCall: null, - outgoingCall: null, - activeDmCall: null, - deafenedUserIds: new Set(), - voiceUserStates: new Map(), - }), -})); + reset: () => set({ + voiceUsers: new Map(), + currentVoiceChannelId: null, + isMuted: false, + isDeafened: false, + isCameraOn: false, + isScreenSharing: false, + participants: [], + connectionError: null, + isLiveKitConnected: false, + inputVolume: 100, + outputVolume: 100, + focusedParticipantId: null, + participantVolumes: new Map(), + incomingCall: null, + outgoingCall: null, + activeDmCall: null, + deafenedUserIds: new Set(), + voiceUserStates: new Map(), + }), + }), + { + name: 'opencord-voice-settings', + storage: createJSONStorage(() => localStorage), + // Only persist these keys. Maps and Sets are complex to serialize. + partialize: (state) => ({ + currentVoiceChannelId: state.currentVoiceChannelId, + isMuted: state.isMuted, + isDeafened: state.isDeafened, + inputVolume: state.inputVolume, + outputVolume: state.outputVolume, + videoQuality: state.videoQuality, + noiseSuppression: state.noiseSuppression, + }), + } + ) +);