import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import type { ParticipantInfo } from '../hooks/useLiveKit'; import { AudioManager } from '../audio/AudioManager'; import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from './spaceStore'; import { useAuthStore } from './authStore'; import { isElectron } from '../platform/platform'; export interface ScreenShareConfig { height: number | 'native'; fps: number; mode: 'gaming' | 'text'; customBitrateKbps: number | null; shareAudio: boolean; } interface VoiceState { voiceUsers: Map; // channelId → userIds currentVoiceChannelId: string | null; /** * Space and name of the channel the call is in, captured at join time. * `channels` in spaceStore only holds the space the user is *viewing*, so * once they navigate elsewhere the call's channel is no longer resolvable * from it — these keep the voice panel able to name and link to the call. * Transient: intentionally absent from `partialize`. */ currentVoiceSpaceId: string | null; currentVoiceChannelName: string | null; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean; participants: ParticipantInfo[]; speakingParticipantIds: Set; speakingUserIds: Set; connectionError: string | null; isLiveKitConnected: boolean; connectionQuality: 'excellent' | 'good' | 'poor' | 'lost' | 'unknown'; setConnectionQuality: (q: 'excellent' | 'good' | 'poor' | 'lost' | 'unknown') => void; inputVolume: number; // 0-200 (100 = default) outputVolume: number; // 0-200 (100 = default) inputDeviceId: string; outputDeviceId: string; cameraDeviceId: string | null; // null = auto-select on next camera enable focusedParticipantId: string | null; screenShareConfig: ScreenShareConfig; // Per-participant volume (userId → 0-200, 100 = default) participantVolumes: Map; setParticipantVolume: (userId: string, volume: number) => void; getParticipantVolume: (userId: string) => number; // Per-participant local mute (userId → muted?) participantMutes: Map; setParticipantMute: (userId: string, muted: boolean) => void; // Stream widget state streamVolumes: Map; // userId → 0-200 (100 default) streamMutes: Map; // userId → muted? watchingStreams: Set; // userIds we're watching unwatchedCameras: Set; // userIds whose cameras we've opted out of streamWatchers: Map>; // streamerUserId → set of watcher LiveKit identities (in-memory only) recordStreamWatch: (streamerUserId: string, watcherIdentity: string, watching: boolean) => void; clearStreamWatchers: (streamerUserId: string) => void; evictWatcher: (watcherIdentity: string) => void; soundEffectVolume: number; // 0-200 (100 = default) setSoundEffectVolume: (volume: number) => void; messageSoundAllChannels: boolean; // false (default) = DM + mention only; true = every channel setMessageSoundAllChannels: (allChannels: boolean) => void; streamAttenuationEnabled: boolean; // global toggle, default true streamAttenuationStrength: number; // 0-100, default 50 setStreamVolume: (userId: string, volume: number) => void; setStreamMute: (userId: string, muted: boolean) => void; watchStream: (userId: string) => void; unwatchStream: (userId: string) => void; unwatchCamera: (userId: string) => void; rewatchCamera: (userId: string) => void; clearStreamVolume: (userId: string) => void; clearStreamMute: (userId: string) => void; setStreamAttenuationEnabled: (enabled: boolean) => void; setStreamAttenuationStrength: (strength: number) => void; // DM call state incomingCall: { dmChannelId: string | null; callerId: string; callerName: string } | null; outgoingCall: { dmChannelId: string } | null; activeDmCall: { dmChannelId: string } | null; setIncomingCall: (call: { dmChannelId: string | null; callerId: string; callerName: string } | null) => void; setOutgoingCall: (call: { dmChannelId: string } | null) => void; setActiveDmCall: (call: { dmChannelId: string } | null) => void; federatedCallToken: string | null; federatedCallUrl: string | null; federatedCallId: string | null; callOrigin: string | null; setFederatedCallData: (token: string, url: string) => void; clearFederatedCallData: () => void; setFederatedCallId: (id: string | null) => void; setCallOrigin: (origin: string | null) => void; setVoiceUsers: (channelId: string, userIds: string[]) => void; addVoiceUser: (channelId: string, userId: string) => void; removeVoiceUser: (channelId: string, userId: string) => void; setCurrentVoiceChannel: (channelId: string | null, spaceId?: string | null, channelName?: string | null) => void; setParticipants: (participants: ParticipantInfo[]) => void; setSpeakingParticipants: (ids: Set) => void; setConnectionError: (error: string | null) => void; setIsLiveKitConnected: (connected: boolean) => void; setInputVolume: (volume: number) => void; setOutputVolume: (volume: number) => void; setInputDevice: (deviceId: string) => void; setOutputDevice: (deviceId: string) => void; setCameraDeviceId: (deviceId: string | null) => void; pruneStaleDevices: () => Promise; pttActive: boolean; setMuted: (muted: boolean) => void; setPttActive: (active: boolean) => void; toggleMic: () => void; toggleCamera: () => void; toggleScreenShare: () => void; toggleDeafen: () => void; setFocusedParticipant: (id: string | null) => void; setScreenShareConfig: (config: Partial) => void; hwOverdrive: boolean; setHwOverdrive: (enabled: boolean) => void; noiseSuppression: boolean; echoCancellation: boolean; autoGainControl: boolean; rnnoiseEnabled: boolean; setEchoCancellation: (enabled: boolean) => void; setAutoGainControl: (enabled: boolean) => void; setRnnoiseEnabled: (enabled: boolean) => void; deafenedUserIds: Set; setUserDeafened: (userId: string, deafened: boolean) => void; // WebSocket-based voice user status (visible without joining LiveKit) voiceUserStates: Map; setVoiceUserStatus: (userId: string, isMuted: boolean, isDeafened: boolean, isCameraOn: boolean, isScreenSharing: boolean) => void; clearVoiceUserStatus: (userId: string) => void; // Space mute/deafen state (moderator action) spaceMutedUserIds: Set; // Stores "spaceId:userId" spaceDeafenedUserIds: Set; // Stores "spaceId:userId" setSpaceMutedUser: (spaceId: string, userId: string, muted: boolean) => void; setSpaceDeafenedUser: (spaceId: string, userId: string, deafened: boolean) => void; clearSpaceVoiceStates: () => void; // Permission mute state (SPEAK permission revoked while in voice) permissionMutedUserIds: Set; // Stores "spaceId:userId" setPermissionMutedUser: (spaceId: string, userId: string, muted: boolean) => void; getVoiceUsers: (channelId: string) => string[]; clearAllVoiceUsers: () => void; resetSession: () => void; clearVoiceUsersForOrigin: (origin: string) => void; leaveVoice: () => void; handleForceDisconnect: () => void; // Mic permission state — set when getUserMedia rejects with NotAllowedError // (most commonly in iOS PWA standalone, where the gesture-window discipline // is strict). Consumers (`useLiveKit` syncMic) skip publishing the mic // track when this is true; the user appears in voice as a connected // participant who can hear others but is effectively muted at the source // (no track ever published, server cannot un-mute remotely). UI surfaces // a "Grant microphone access" affordance to retry from a fresh user // gesture; on success the flag clears and `useLiveKit` republishes. micPermissionDenied: boolean; setMicPermissionDenied: (denied: boolean) => void; // Gesture-aware connect/disconnect refs — registered by AppLayout from useLiveKit() connectFn: ((channelId: string, isDm?: boolean) => Promise) | null; disconnectFn: (() => Promise) | null; setConnectFn: (fn: ((channelId: string, isDm?: boolean) => Promise) | null) => void; setDisconnectFn: (fn: (() => Promise) | null) => void; reset: () => void; } export const useVoiceStore = create()( persist( (set, get) => ({ voiceUsers: new Map(), currentVoiceChannelId: null, currentVoiceSpaceId: null, currentVoiceChannelName: null, isMuted: false, pttActive: false, isDeafened: false, isCameraOn: false, isScreenSharing: false, micPermissionDenied: false, participants: [], speakingParticipantIds: new Set(), speakingUserIds: new Set(), connectionError: null, isLiveKitConnected: false, connectionQuality: 'unknown', inputVolume: 100, outputVolume: 100, inputDeviceId: 'default', outputDeviceId: 'default', cameraDeviceId: null, focusedParticipantId: null, screenShareConfig: { height: 720, fps: 60, mode: 'gaming', customBitrateKbps: null, shareAudio: !isElectron() }, 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, participantMutes: new Map(), setParticipantMute: (userId, muted) => { set((state) => { const newMap = new Map(state.participantMutes); newMap.set(userId, muted); return { participantMutes: newMap }; }); }, soundEffectVolume: 100, setSoundEffectVolume: (volume) => set({ soundEffectVolume: volume }), messageSoundAllChannels: false, setMessageSoundAllChannels: (allChannels) => set({ messageSoundAllChannels: allChannels }), // Stream widget state streamVolumes: new Map(), streamMutes: new Map(), watchingStreams: new Set(), unwatchedCameras: new Set(), streamWatchers: new Map(), recordStreamWatch: (streamerUserId, watcherIdentity, watching) => { set((state) => { const newMap = new Map(state.streamWatchers); const existing = newMap.get(streamerUserId); const next = new Set(existing ?? []); if (watching) next.add(watcherIdentity); else next.delete(watcherIdentity); if (next.size === 0) newMap.delete(streamerUserId); else newMap.set(streamerUserId, next); return { streamWatchers: newMap }; }); }, clearStreamWatchers: (streamerUserId) => { set((state) => { if (!state.streamWatchers.has(streamerUserId)) return state; const newMap = new Map(state.streamWatchers); newMap.delete(streamerUserId); return { streamWatchers: newMap }; }); }, evictWatcher: (watcherIdentity) => { set((state) => { let mutated = false; const newMap = new Map(state.streamWatchers); for (const [streamerId, watchers] of newMap) { if (watchers.has(watcherIdentity)) { const next = new Set(watchers); next.delete(watcherIdentity); if (next.size === 0) newMap.delete(streamerId); else newMap.set(streamerId, next); mutated = true; } } return mutated ? { streamWatchers: newMap } : state; }); }, streamAttenuationEnabled: false, streamAttenuationStrength: 50, setStreamVolume: (userId, volume) => { set((state) => { const newMap = new Map(state.streamVolumes); newMap.set(userId, volume); return { streamVolumes: newMap }; }); }, setStreamMute: (userId, muted) => { set((state) => { const newMap = new Map(state.streamMutes); newMap.set(userId, muted); return { streamMutes: newMap }; }); }, watchStream: (userId) => { set((state) => { const newSet = new Set(state.watchingStreams); newSet.add(userId); return { watchingStreams: newSet }; }); }, unwatchStream: (userId) => { set((state) => { const newSet = new Set(state.watchingStreams); newSet.delete(userId); return { watchingStreams: newSet }; }); }, unwatchCamera: (userId) => { set((state) => { const newSet = new Set(state.unwatchedCameras); newSet.add(userId); return { unwatchedCameras: newSet }; }); }, rewatchCamera: (userId) => { set((state) => { const newSet = new Set(state.unwatchedCameras); newSet.delete(userId); return { unwatchedCameras: newSet }; }); }, clearStreamVolume: (userId) => { set((state) => { const newMap = new Map(state.streamVolumes); newMap.delete(userId); return { streamVolumes: newMap }; }); }, clearStreamMute: (userId) => { set((state) => { const newMap = new Map(state.streamMutes); newMap.delete(userId); return { streamMutes: newMap }; }); }, setStreamAttenuationEnabled: (enabled) => set({ streamAttenuationEnabled: enabled }), setStreamAttenuationStrength: (strength) => set({ streamAttenuationStrength: strength }), incomingCall: null, outgoingCall: null, activeDmCall: null, federatedCallToken: null, federatedCallUrl: null, federatedCallId: null, callOrigin: null, setIncomingCall: (call) => set({ incomingCall: call }), setOutgoingCall: (call) => set({ outgoingCall: call }), setActiveDmCall: (call) => set({ activeDmCall: call }), setFederatedCallData: (token, url) => set({ federatedCallToken: token, federatedCallUrl: url }), clearFederatedCallData: () => set({ federatedCallToken: null, federatedCallUrl: null, federatedCallId: null, callOrigin: null }), setFederatedCallId: (id) => set({ federatedCallId: id }), setCallOrigin: (origin) => set({ callOrigin: origin }), 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 }; }); }, 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, spaceId = null, channelName = null) => set({ currentVoiceChannelId: channelId, currentVoiceSpaceId: channelId ? spaceId : null, currentVoiceChannelName: channelId ? channelName : null, activeDmCall: null // Clear active DM call when joining a server channel }), setParticipants: (participants) => set({ participants }), setSpeakingParticipants: (ids) => { const userIds = new Set(); for (const id of ids) { const sep = id.indexOf(':'); if (sep !== -1) userIds.add(id.substring(0, sep)); } set({ speakingParticipantIds: ids, speakingUserIds: userIds }); }, setConnectionError: (error) => set({ connectionError: error }), setIsLiveKitConnected: (connected) => set({ isLiveKitConnected: connected }), setConnectionQuality: (quality) => set({ connectionQuality: quality }), setInputVolume: (volume) => { set({ inputVolume: volume }); AudioManager.getInstance().setInputVolume(volume); }, setOutputVolume: (volume) => set({ outputVolume: volume }), setInputDevice: (deviceId) => set({ inputDeviceId: deviceId }), setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }), setCameraDeviceId: (deviceId) => set({ cameraDeviceId: deviceId }), pruneStaleDevices: async () => { try { const devs = await navigator.mediaDevices.enumerateDevices(); // Firefox/Safari hide deviceIds pre-permission. If the enumerated set for a // kind has zero non-empty deviceIds, skip pruning that kind — we cannot // distinguish "stale" from "obscured". const audioInIds = new Set( devs.filter(d => d.kind === 'audioinput' && d.deviceId).map(d => d.deviceId) ); const audioOutIds = new Set( devs.filter(d => d.kind === 'audiooutput' && d.deviceId).map(d => d.deviceId) ); const videoInIds = new Set( devs.filter(d => d.kind === 'videoinput' && d.deviceId).map(d => d.deviceId) ); const s = get(); const next: Partial = {}; if (audioInIds.size > 0 && s.inputDeviceId !== 'default' && !audioInIds.has(s.inputDeviceId)) { next.inputDeviceId = 'default'; } if (audioOutIds.size > 0 && s.outputDeviceId !== 'default' && !audioOutIds.has(s.outputDeviceId)) { next.outputDeviceId = 'default'; } if (videoInIds.size > 0 && s.cameraDeviceId !== null && !videoInIds.has(s.cameraDeviceId)) { next.cameraDeviceId = null; } if (Object.keys(next).length) set(next); } catch { // enumerateDevices unavailable (no MediaDevices API or permission policy denial) — skip } }, setMuted: (muted: boolean) => set({ isMuted: muted }), setPttActive: (active: boolean) => set({ pttActive: active }), toggleMic: () => set((state) => { // User intent toggle — effective state (intent || serverEnforcement) is // computed at broadcast/hardware time, so the mic stays off while server-muted // even if the user toggles isMuted to false. if (state.isMuted && state.isDeafened) { // Unmuting while deafened → clear both (Discord behavior) return { isMuted: false, isDeafened: false }; } return { isMuted: !state.isMuted }; }), toggleDeafen: () => set((state) => { // User intent toggle — same decoupled model as toggleMic. if (state.isDeafened) { // Undeafening → clear both return { isMuted: false, isDeafened: false }; } // Deafening → set both return { isMuted: true, isDeafened: true }; }), toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })), toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })), setFocusedParticipant: (id) => set({ focusedParticipantId: id }), setScreenShareConfig: (config) => set((state) => ({ screenShareConfig: { ...state.screenShareConfig, ...config }, })), // Hardware H.264 override — transient (intentionally excluded from partialize for non-persisted behavior) hwOverdrive: false, setHwOverdrive: (enabled) => set({ hwOverdrive: enabled }), noiseSuppression: true, echoCancellation: true, autoGainControl: true, rnnoiseEnabled: true, setEchoCancellation: (enabled) => set({ echoCancellation: enabled }), setAutoGainControl: (enabled) => set({ autoGainControl: enabled }), setRnnoiseEnabled: (enabled) => set({ rnnoiseEnabled: enabled }), 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, isCameraOn, isScreenSharing) => { set((state) => { const newMap = new Map(state.voiceUserStates); newMap.set(userId, { isMuted, isDeafened, isCameraOn, isScreenSharing }); return { voiceUserStates: newMap }; }); }, clearVoiceUserStatus: (userId) => { set((state) => { const newMap = new Map(state.voiceUserStates); newMap.delete(userId); return { voiceUserStates: newMap }; }); }, spaceMutedUserIds: new Set(), spaceDeafenedUserIds: new Set(), setSpaceMutedUser: (spaceId, userId, muted) => { set((state) => { const newSet = new Set(state.spaceMutedUserIds); const key = `${spaceId}:${userId}`; if (muted) newSet.add(key); else newSet.delete(key); return { spaceMutedUserIds: newSet }; }); }, setSpaceDeafenedUser: (spaceId, userId, deafened) => { set((state) => { const newSet = new Set(state.spaceDeafenedUserIds); const key = `${spaceId}:${userId}`; if (deafened) newSet.add(key); else newSet.delete(key); return { spaceDeafenedUserIds: newSet }; }); }, clearSpaceVoiceStates: () => set({ spaceMutedUserIds: new Set(), spaceDeafenedUserIds: new Set(), permissionMutedUserIds: new Set() }), permissionMutedUserIds: new Set(), setPermissionMutedUser: (spaceId, userId, muted) => { set((state) => { const newSet = new Set(state.permissionMutedUserIds); const key = `${spaceId}:${userId}`; if (muted) newSet.add(key); else newSet.delete(key); return { permissionMutedUserIds: newSet }; }); }, getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [], clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }), resetSession: () => set({ // Connection state hwOverdrive: false, voiceUsers: new Map(), voiceUserStates: new Map(), currentVoiceChannelId: null, currentVoiceSpaceId: null, currentVoiceChannelName: null, participants: [], speakingParticipantIds: new Set(), speakingUserIds: new Set(), connectionError: null, isLiveKitConnected: false, connectionQuality: 'unknown', focusedParticipantId: null, // Call state incomingCall: null, outgoingCall: null, activeDmCall: null, federatedCallToken: null, federatedCallUrl: null, federatedCallId: null, callOrigin: null, // Per-session media state isCameraOn: false, isScreenSharing: false, deafenedUserIds: new Set(), streamVolumes: new Map(), streamMutes: new Map(), watchingStreams: new Set(), unwatchedCameras: new Set(), streamWatchers: new Map(), // Space-enforced restrictions spaceMutedUserIds: new Set(), spaceDeafenedUserIds: new Set(), permissionMutedUserIds: new Set(), // Mic permission gate micPermissionDenied: false, }), clearVoiceUsersForOrigin: (origin: string) => { const { channelOriginMap } = useSpaceStore.getState(); set((state) => { const newVoiceUsers = new Map(state.voiceUsers); for (const [channelId] of newVoiceUsers) { if ((channelOriginMap.get(channelId) ?? '') === origin) { newVoiceUsers.delete(channelId); } } return { voiceUsers: newVoiceUsers }; }); }, // Leave voice without wiping the voiceUsers map (so sidebar still shows others) leaveVoice: () => { const channelId = get().currentVoiceChannelId; const myId = channelId ? getMyUserIdForOrigin(getChannelOrigin(channelId)) : undefined; set((state) => { // Optimistic: immediately remove self from the channel's voice users const voiceUsers = (channelId && myId) ? (() => { const m = new Map(state.voiceUsers); m.set(channelId, (m.get(channelId) ?? []).filter(id => id !== myId)); return m; })() : state.voiceUsers; return { currentVoiceChannelId: null, hwOverdrive: false, isCameraOn: false, isScreenSharing: false, participants: [], speakingParticipantIds: new Set(), speakingUserIds: new Set(), connectionError: null, isLiveKitConnected: false, connectionQuality: 'unknown', focusedParticipantId: null, activeDmCall: null, outgoingCall: null, federatedCallToken: null, federatedCallUrl: null, federatedCallId: null, callOrigin: null, deafenedUserIds: new Set(), participantMutes: new Map(), streamVolumes: new Map(), streamMutes: new Map(), watchingStreams: new Set(), unwatchedCameras: new Set(), streamWatchers: new Map(), voiceUsers, // Reset mic permission flag on leave so the next join attempts a // fresh getUserMedia (the user may have granted permission via // OS settings while disconnected). micPermissionDenied: false, }; }); }, // Gesture-aware connect/disconnect refs — set by AppLayout, read by click handlers connectFn: null, disconnectFn: null, setConnectFn: (fn) => set({ connectFn: fn }), setDisconnectFn: (fn) => set({ disconnectFn: fn }), // Mic permission state setter — see interface comment. setMicPermissionDenied: (denied) => set({ micPermissionDenied: denied }), // Force disconnect: clear local connection state but do NOT touch voiceUsers. // Used for involuntary disconnects (identity collision, server shutdown, etc.) // where the server is the authority on who's actually in voice. handleForceDisconnect: () => { set({ currentVoiceChannelId: null, hwOverdrive: false, isCameraOn: false, isScreenSharing: false, participants: [], speakingParticipantIds: new Set(), speakingUserIds: new Set(), connectionError: null, isLiveKitConnected: false, connectionQuality: 'unknown', focusedParticipantId: null, activeDmCall: null, outgoingCall: null, federatedCallToken: null, federatedCallUrl: null, federatedCallId: null, callOrigin: null, deafenedUserIds: new Set(), participantMutes: new Map(), streamVolumes: new Map(), streamMutes: new Map(), watchingStreams: new Set(), unwatchedCameras: new Set(), streamWatchers: new Map(), micPermissionDenied: false, }); }, reset: () => set({ voiceUsers: new Map(), currentVoiceChannelId: null, isMuted: false, isDeafened: false, isCameraOn: false, isScreenSharing: false, participants: [], speakingParticipantIds: new Set(), speakingUserIds: new Set(), connectionError: null, isLiveKitConnected: false, connectionQuality: 'unknown', inputVolume: 100, outputVolume: 100, inputDeviceId: 'default', outputDeviceId: 'default', cameraDeviceId: null, focusedParticipantId: null, participantVolumes: new Map(), participantMutes: new Map(), incomingCall: null, outgoingCall: null, activeDmCall: null, federatedCallToken: null, federatedCallUrl: null, federatedCallId: null, callOrigin: null, deafenedUserIds: new Set(), voiceUserStates: new Map(), streamVolumes: new Map(), streamMutes: new Map(), watchingStreams: new Set(), unwatchedCameras: new Set(), streamWatchers: new Map(), spaceMutedUserIds: new Set(), spaceDeafenedUserIds: new Set(), permissionMutedUserIds: new Set(), micPermissionDenied: false, }), }), { name: 'backspace-voice-settings', version: 13, migrate: (persistedState: any, version: number) => { if (version === 0) { persistedState.streamAttenuationEnabled = false; } if (version < 2) { persistedState.echoCancellation = true; persistedState.autoGainControl = false; } if (version < 4) { persistedState.rnnoiseEnabled = true; persistedState.noiseSuppression = true; } if (version < 5) { const vq = persistedState.videoQuality as string | undefined; let height: 1080 | 720 | 540 = 720; let fps: 60 | 45 | 30 = 60; if (vq) { if (vq.startsWith('1080')) height = 1080; else if (vq.startsWith('540') || vq.startsWith('360')) height = 540; fps = vq.endsWith('60') ? 60 : 30; } persistedState.screenShareConfig = { height, fps, mode: 'gaming' }; delete persistedState.videoQuality; } if (version < 6) { if (persistedState.screenShareConfig) { persistedState.screenShareConfig.customBitrateKbps = null; } } if (version < 7) { // No data migration needed — Sets will be populated from server on next connect } if (version < 8) { persistedState.soundEffectVolume = 100; } if (version < 9) { delete persistedState.currentVoiceChannelId; } if (version < 10) { if (persistedState.screenShareConfig) { persistedState.screenShareConfig.shareAudio = true; } } if (version < 11) { if (persistedState.screenShareConfig) { persistedState.screenShareConfig.shareAudio = !isElectron(); } } if (version < 12) { // Validate screenShareConfig.height after type widening const cfg = persistedState.screenShareConfig; if (cfg && typeof cfg.height !== 'number' && cfg.height !== 'native') { cfg.height = 1080; } } if (version < 13) { if (persistedState.screenShareConfig) { delete persistedState.screenShareConfig.codec; } } return persistedState; }, storage: createJSONStorage(() => localStorage), // Only persist these keys. Maps and Sets are complex to serialize. // noiseSuppression is intentionally excluded — always true internally, // managed automatically by AudioManager based on RNNoise state. partialize: (state) => ({ isMuted: state.isMuted, isDeafened: state.isDeafened, inputVolume: state.inputVolume, outputVolume: state.outputVolume, inputDeviceId: state.inputDeviceId, outputDeviceId: state.outputDeviceId, cameraDeviceId: state.cameraDeviceId, screenShareConfig: state.screenShareConfig, echoCancellation: state.echoCancellation, autoGainControl: state.autoGainControl, rnnoiseEnabled: state.rnnoiseEnabled, soundEffectVolume: state.soundEffectVolume, messageSoundAllChannels: state.messageSoundAllChannels, streamAttenuationEnabled: state.streamAttenuationEnabled, streamAttenuationStrength: state.streamAttenuationStrength, // Per-user preferences (Map → plain object for JSON) participantVolumes: Object.fromEntries(state.participantVolumes), participantMutes: Object.fromEntries(state.participantMutes), }), merge: (persistedState: any, currentState: VoiceState) => { const merged = { ...currentState, ...persistedState }; // Reconstruct non-persisted Sets/Maps to their defaults merged.spaceMutedUserIds = currentState.spaceMutedUserIds; merged.spaceDeafenedUserIds = currentState.spaceDeafenedUserIds; merged.permissionMutedUserIds = currentState.permissionMutedUserIds; merged.voiceUsers = currentState.voiceUsers; merged.participants = currentState.participants; merged.speakingParticipantIds = currentState.speakingParticipantIds; merged.speakingUserIds = currentState.speakingUserIds; merged.deafenedUserIds = currentState.deafenedUserIds; merged.voiceUserStates = currentState.voiceUserStates; merged.participantVolumes = persistedState?.participantVolumes ? new Map(Object.entries(persistedState.participantVolumes)) : currentState.participantVolumes; merged.participantMutes = persistedState?.participantMutes ? new Map(Object.entries(persistedState.participantMutes)) : currentState.participantMutes; merged.streamVolumes = currentState.streamVolumes; merged.streamMutes = currentState.streamMutes; merged.watchingStreams = currentState.watchingStreams; merged.unwatchedCameras = currentState.unwatchedCameras; return merged; }, } ) );