Two mirror-image bugs from voice/DM-call transitions leaving stale state. DM call → space channel (stuck "Connecting…"): The last participant to leave a DM call for a space channel receives a `dm_call_ended` echo (server empties the DM room on their `voice_join`). The handlers called `disconnectFn()` unconditionally, tearing down the space room they had just connected to. Route `dm_call_ended` / `dm_call_rejected` / terminal `dm_call_undeliverable` through a new `teardownDmCall()` that only disconnects LiveKit when not in a space channel (`currentVoiceChannelId` null). Space channel → DM call (still shown as "in" the voice channel): 1. Entering a DM call never cleared `currentVoiceChannelId`, so `VoiceChannel` mapped the DM call's live LiveKit participants onto the old space channel. Add `clearSpaceVoiceForDmCall()`, called in `connect()` when `isDm`, restoring the invariant that a DM call has no `currentVoiceChannelId`. 2. `dm_call_accepted` gated the caller's connect on `!isLiveKitConnected`, so a caller already in a space channel was never connected to the DM room. Gate on `wasOutgoingCall` only (connect() de-dupes same-room). Tests: teardownDmCall.test.ts, clearSpaceVoiceForDmCall.test.ts. Docs: docs/systems/voice.md.
258 lines
12 KiB
TypeScript
258 lines
12 KiB
TypeScript
import { useVoiceStore } from '../stores/voiceStore';
|
|
import { getChannelOrigin, getMyUserIdForOrigin, useSpaceStore } from '../stores/spaceStore';
|
|
import { wsSend } from '../hooks/useWebSocket';
|
|
import { AudioManager } from '../audio/AudioManager';
|
|
import { useUIStore } from '../stores/uiStore';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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(() => {});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clear the client-side presence of a *space* voice channel when the local user
|
|
* transitions into a DM call.
|
|
*
|
|
* A DM call and a space voice channel are mutually exclusive: the invariant is
|
|
* that a DM call has **no** `currentVoiceChannelId` (only `activeDmCall`). The
|
|
* space→DM transition must therefore drop the space channel's client state, the
|
|
* mirror of `joinVoiceChannel` clearing `activeDmCall` on the DM→space
|
|
* transition.
|
|
*
|
|
* Why this is necessary even though the server already drops us from the space
|
|
* room (`dm_call_start` / `dm_call_accept` → `leaveCurrentRoom` →
|
|
* `broadcastRoomLeave`): `VoiceChannel` renders the occupant list for the
|
|
* channel equal to `currentVoiceChannelId` from the *live LiveKit participants*
|
|
* (its "our channel is the source of truth" branch). If `currentVoiceChannelId`
|
|
* still points at the old space channel, the DM call's participants get mapped
|
|
* onto it and the local user appears to still be sitting in the space channel.
|
|
*
|
|
* Clears `currentVoiceChannelId` directly (not via `setCurrentVoiceChannel`,
|
|
* which would also wipe the `activeDmCall` the caller/acceptor just set) and
|
|
* optimistically removes self from the old channel's `voiceUsers` so the
|
|
* sidebar updates immediately, without waiting for the server's leave
|
|
* broadcast.
|
|
*/
|
|
export function clearSpaceVoiceForDmCall(): void {
|
|
const { currentVoiceChannelId, removeVoiceUser } = useVoiceStore.getState();
|
|
if (!currentVoiceChannelId) return;
|
|
|
|
const origin = getChannelOrigin(currentVoiceChannelId);
|
|
const myId = getMyUserIdForOrigin(origin);
|
|
if (myId) removeVoiceUser(currentVoiceChannelId, myId);
|
|
|
|
useVoiceStore.setState({ currentVoiceChannelId: null });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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.
|
|
*
|
|
* **iOS user-gesture discipline.** `getUserMedia({audio:…})` is fired
|
|
* synchronously here (before any await crosses the gesture boundary) so
|
|
* iOS Safari surfaces the microphone permission prompt immediately on
|
|
* the user's tap. The previous flow only acquired the mic in the
|
|
* `useLiveKit` `syncMic` effect, which fires AFTER `room.connect()`
|
|
* (token fetch + WS handshake) completes — many awaits past the
|
|
* activation window. iOS PWA standalone is especially strict and would
|
|
* silently never surface the prompt; the user appeared stuck on
|
|
* "Waiting for others to join…" until they locked/unlocked the device,
|
|
* which iOS treats as a fresh activation context that finally allowed
|
|
* the queued prompt to surface.
|
|
*
|
|
* **Denial path.** If the user denies the prompt (NotAllowedError),
|
|
* `voiceStore.micPermissionDenied` is set to true and we proceed with
|
|
* the LiveKit connect anyway. The user appears in the voice channel
|
|
* normally, can hear other participants, but no microphone track is
|
|
* ever published — `useLiveKit.syncMic` skips the publish branch when
|
|
* the flag is set. UI surfaces a "Grant microphone access" affordance
|
|
* (`MobileVoiceFullScreen`, `VoiceControlBar`) that retries
|
|
* `getUserMedia` from a fresh user gesture; on success the flag clears
|
|
* and `useLiveKit.republishMicrophone` is called directly. The flag
|
|
* resets to `false` automatically on `leaveVoice()` /
|
|
* `handleForceDisconnect()` so a rejoin attempts a fresh prompt.
|
|
*
|
|
* @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);
|
|
|
|
// Pre-arm the microphone INSIDE the user-gesture context. This must
|
|
// happen before `connectFn` so the call to `setInputDevice` (which is
|
|
// routed through `inputSwitchChain.then(...)` and ends in
|
|
// `getUserMedia`) is invoked while the activation window is still open.
|
|
// Reset the prior denial flag so a re-attempt isn't pre-vetoed by
|
|
// syncMic, and clear AudioManager's cached denial so the next
|
|
// `getUserMedia` actually fires (rather than re-throwing the cached
|
|
// error from a previous denial in this session).
|
|
const voiceState = useVoiceStore.getState();
|
|
voiceState.setMicPermissionDenied(false);
|
|
const audioManager = AudioManager.getInstance();
|
|
audioManager.clearInputDenial();
|
|
// Resume the AudioContext synchronously inside the gesture too — iOS
|
|
// requires `AudioContext.resume()` to be invoked from a user
|
|
// activation. Fire-and-forget; `useLiveKit.connect` also calls this
|
|
// and will await the same context.
|
|
audioManager.resumeContext().catch((err) => {
|
|
console.warn('[voice] AudioContext resume failed:', err);
|
|
});
|
|
// Fire-and-forget. `setInputDevice` is internally serialized via
|
|
// `inputSwitchChain` so the later syncMic call short-circuits to the
|
|
// already-acquired stream rather than re-prompting. On denial we
|
|
// record the flag — syncMic will then skip the publish branch.
|
|
audioManager.setInputDevice(voiceState.inputDeviceId).catch((err: unknown) => {
|
|
const name = err instanceof Error ? err.name : '';
|
|
if (name === 'NotAllowedError') {
|
|
useVoiceStore.getState().setMicPermissionDenied(true);
|
|
useUIStore.getState().addToast(
|
|
'Microphone access denied. You joined as a listener — tap "Allow microphone" to grant access.',
|
|
'warning',
|
|
);
|
|
} else if (name === 'NotFoundError') {
|
|
// No mic hardware available. Proceed as listener.
|
|
useVoiceStore.getState().setMicPermissionDenied(true);
|
|
useUIStore.getState().addToast(
|
|
'No microphone detected. You joined as a listener.',
|
|
'info',
|
|
);
|
|
} else {
|
|
console.error('[voice] Mic pre-arm failed:', err);
|
|
}
|
|
});
|
|
|
|
// Direct connection within gesture context
|
|
if (connectFn) {
|
|
connectFn(channelId).catch((err) => {
|
|
console.error('[voice] Connection failed:', err);
|
|
setCurrentVoiceChannel(null);
|
|
if (myNewId) removeVoiceUser(channelId, myNewId);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-attempt microphone permission acquisition after a previous denial.
|
|
* Must be called from a user-gesture handler (button click, etc.) for iOS
|
|
* Safari to actually surface the permission prompt. On success, clears the
|
|
* `micPermissionDenied` flag and the next `useLiveKit` syncMic tick (or an
|
|
* external `republishMicrophone` call) publishes the freshly acquired
|
|
* stream.
|
|
*
|
|
* Returns `true` when the mic was acquired, `false` on any error
|
|
* (NotAllowedError, NotFoundError, etc.).
|
|
*/
|
|
export async function requestMicPermission(): Promise<boolean> {
|
|
const audioManager = AudioManager.getInstance();
|
|
const inputDeviceId = useVoiceStore.getState().inputDeviceId;
|
|
// Clear AudioManager's cached denial so the next `setInputDevice` call
|
|
// actually fires `getUserMedia` instead of re-throwing the cached error.
|
|
audioManager.clearInputDenial();
|
|
try {
|
|
// Resume context first — iOS may have suspended it during the denied
|
|
// state.
|
|
await audioManager.resumeContext();
|
|
await audioManager.setInputDevice(inputDeviceId);
|
|
useVoiceStore.getState().setMicPermissionDenied(false);
|
|
return true;
|
|
} catch (err: unknown) {
|
|
const name = err instanceof Error ? err.name : '';
|
|
if (name === 'NotAllowedError') {
|
|
useUIStore.getState().addToast(
|
|
'Microphone permission still denied. Open Settings → Safari to grant access.',
|
|
'warning',
|
|
);
|
|
} else if (name === 'NotFoundError') {
|
|
useUIStore.getState().addToast(
|
|
'No microphone detected.',
|
|
'warning',
|
|
);
|
|
} else {
|
|
useUIStore.getState().addToast(
|
|
'Could not access the microphone.',
|
|
'warning',
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
}
|