merge: wip(mobile): in-progress mobile + voice polish into main

Brings the parked mobile voice + screenshare polish from wip/mobile-polish
(commit dbb9b2c) into main. Conflicts in MainContent.tsx and mobile-ui.md
expected per the WIP commit message — resolved manually.
This commit is contained in:
Jannis Braun
2026-05-12 13:36:08 +02:00
13 changed files with 1241 additions and 232 deletions
+115
View File
@@ -1,6 +1,8 @@
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
@@ -66,6 +68,30 @@ export function broadcastDeafenViaLiveKit(): void {
* 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
@@ -96,6 +122,49 @@ export function joinVoiceChannel(
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) => {
@@ -105,3 +174,49 @@ export function joinVoiceChannel(
});
}
}
/**
* 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;
}
}