Files
backspace/packages/web/src/utils/voice.ts
T
Jannis Braun 241451ca18 feat: gesture-aware voice connection, remove AppLayout auto-connect
Replace the useEffect-based auto-connect pattern in AppLayout with
direct connect/disconnect calls from user gesture contexts. This is
required for iOS (AudioContext.resume + getUserMedia must happen in
a gesture handler) and aligns with tightening autoplay policies on
desktop browsers.

Architecture:
- voiceStore gains connectFn/disconnectFn refs, registered by AppLayout
  from the single useLiveKit() instance.
- All voice join paths (ChannelSidebar, MobileSpacesScreen, MainContent,
  voice_moved WS handler) pass connectFn to joinVoiceChannel().
- All disconnect paths (VoiceControls, voiceActions, MobileVoiceFullScreen,
  MobileVoiceMiniBar, dm_call_ended/rejected WS handlers, ready handler)
  call disconnectFn() directly.
- dm_call_accepted WS handler calls connectFn() to initiate the DM call
  LiveKit connection.
- The 55-line auto-connect useEffect and lastAttemptedRef are removed.
2026-03-23 21:06:19 +01:00

108 lines
4.6 KiB
TypeScript

import { useVoiceStore } from '../stores/voiceStore';
import { getChannelOrigin, getMyUserIdForOrigin, useSpaceStore } from '../stores/spaceStore';
import { wsSend } from '../hooks/useWebSocket';
// ---------------------------------------------------------------------------
// Effective-state helpers — single source of truth for broadcasts
// ---------------------------------------------------------------------------
/**
* Compute effective mute/deafen by merging user intent with server enforcement,
* then broadcast the effective voice_status over the WebSocket.
*
* @param overrideOrigin Pass explicitly when called from a WS handler that
* knows the origin. Omit to derive from currentVoiceChannelId.
*/
export function broadcastVoiceStatus(overrideOrigin?: string): void {
const vs = useVoiceStore.getState();
const { isMuted, isDeafened, isCameraOn, isScreenSharing, currentVoiceChannelId, spaceMutedUserIds, spaceDeafenedUserIds } = vs;
if (!currentVoiceChannelId) return;
const origin = overrideOrigin ?? getChannelOrigin(currentVoiceChannelId);
const myId = getMyUserIdForOrigin(origin);
const spaceId = useSpaceStore.getState().channelToSpaceMap.get(currentVoiceChannelId);
const spaceKey = (spaceId && myId) ? `${spaceId}:${myId}` : '';
const effectiveMuted = isMuted || spaceMutedUserIds.has(spaceKey);
const effectiveDeafened = isDeafened || spaceDeafenedUserIds.has(spaceKey);
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, spaceDeafenedUserIds } = vs;
if (!currentVoiceChannelId) return;
const origin = getChannelOrigin(currentVoiceChannelId);
const myId = getMyUserIdForOrigin(origin);
const spaceId = useSpaceStore.getState().channelToSpaceMap.get(currentVoiceChannelId);
const spaceKey = (spaceId && myId) ? `${spaceId}:${myId}` : '';
const effectiveDeafened = isDeafened || spaceDeafenedUserIds.has(spaceKey);
import('../hooks/useLiveKit').then(({ getActiveRoom }) => {
const room = getActiveRoom();
if (room) {
const encoder = new TextEncoder();
room.localParticipant.publishData(
encoder.encode(JSON.stringify({ type: 'deafen', deafened: effectiveDeafened })),
{ reliable: true }
).catch(() => {});
}
});
}
// ---------------------------------------------------------------------------
// Voice channel join
// ---------------------------------------------------------------------------
/**
* Centralized voice channel join that handles cross-instance cleanup.
* When switching from a channel on Instance A to one on Instance B,
* this sends an explicit voice_leave to Instance A first so it
* broadcasts a leave event and the client cleans up stale voice state.
*
* @param channelId The channel to join.
* @param connectFn The LiveKit connect function, obtained from
* `useVoiceStore.getState().connectFn`. When provided the
* LiveKit connection is initiated directly within the
* caller's gesture context (required on iOS).
*/
export function joinVoiceChannel(
channelId: string,
connectFn?: (channelId: string, isDm?: boolean) => Promise<void>,
): void {
const { currentVoiceChannelId, setCurrentVoiceChannel, addVoiceUser, removeVoiceUser } = useVoiceStore.getState();
if (currentVoiceChannelId === channelId) return;
// Leave old instance if switching cross-origin
if (currentVoiceChannelId) {
const oldOrigin = getChannelOrigin(currentVoiceChannelId);
const newOrigin = getChannelOrigin(channelId);
if (oldOrigin !== newOrigin) {
wsSend({ type: 'voice_leave' }, oldOrigin);
}
// Optimistic: immediately remove self from old channel (using origin-aware ID)
const myOldId = getMyUserIdForOrigin(oldOrigin);
if (myOldId) removeVoiceUser(currentVoiceChannelId, myOldId);
}
setCurrentVoiceChannel(channelId);
// Optimistic: immediately show self in new channel (using origin-aware ID)
const myNewId = getMyUserIdForOrigin(getChannelOrigin(channelId));
if (myNewId) addVoiceUser(channelId, myNewId);
// Direct connection within gesture context
if (connectFn) {
connectFn(channelId).catch((err) => {
console.error('[voice] Connection failed:', err);
setCurrentVoiceChannel(null);
if (myNewId) removeVoiceUser(channelId, myNewId);
});
}
}