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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user