Files
backspace/packages/web/src/stores/voiceStore.ts
T
devsyncwrld c70b0095a9 feat(voice): jump to the call from the voice panel
The channel name under 'Voice Connected' was a plain div. Making it navigate
needed more than an onClick: voiceStore never recorded which space the call
was in, and spaceStore.channels only holds the space currently being viewed —
so after navigating away the call's channel was unresolvable, which is also
why the label degraded to a generic 'Voice Channel'.

Capture space and channel name at join time (the only moment they are
reliable) and use them for both the label and the jump. Covers space calls
and DM calls.
2026-08-31 11:40:45 -03:00

827 lines
33 KiB
TypeScript

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<string, string[]>; // 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<string>;
speakingUserIds: Set<string>;
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<string, number>;
setParticipantVolume: (userId: string, volume: number) => void;
getParticipantVolume: (userId: string) => number;
// Per-participant local mute (userId → muted?)
participantMutes: Map<string, boolean>;
setParticipantMute: (userId: string, muted: boolean) => void;
// Stream widget state
streamVolumes: Map<string, number>; // userId → 0-200 (100 default)
streamMutes: Map<string, boolean>; // userId → muted?
watchingStreams: Set<string>; // userIds we're watching
unwatchedCameras: Set<string>; // userIds whose cameras we've opted out of
streamWatchers: Map<string, Set<string>>; // 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<string>) => 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<void>;
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<ScreenShareConfig>) => 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<string>;
setUserDeafened: (userId: string, deafened: boolean) => void;
// WebSocket-based voice user status (visible without joining LiveKit)
voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
setVoiceUserStatus: (userId: string, isMuted: boolean, isDeafened: boolean, isCameraOn: boolean, isScreenSharing: boolean) => void;
clearVoiceUserStatus: (userId: string) => void;
// Space mute/deafen state (moderator action)
spaceMutedUserIds: Set<string>; // Stores "spaceId:userId"
spaceDeafenedUserIds: Set<string>; // 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<string>; // 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<void>) | null;
disconnectFn: (() => Promise<void>) | null;
setConnectFn: (fn: ((channelId: string, isDm?: boolean) => Promise<void>) | null) => void;
setDisconnectFn: (fn: (() => Promise<void>) | null) => void;
reset: () => void;
}
export const useVoiceStore = create<VoiceState>()(
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<string>();
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<VoiceState> = {};
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;
},
}
)
);