feat(livekit): improve native audio/video handling and stability
All checks were successful
Frontend CI / ota-ios (push) Successful in 1m55s
Frontend CI / ota-android (push) Successful in 1m57s
Frontend CI / build-and-push-web (push) Successful in 4m8s
Frontend CI / build-android-apk (push) Successful in 12m20s

Implement robust WebRTC polyfills and native audio session management for
LiveKit on mobile platforms. This includes configuring iOS audio sessions
to prevent issues during calls and implementing a delayed video
activation strategy to avoid main thread deadlocks on iOS.

- Add WebRTC and environment polyfills in `src/polyfills.ts`
- Implement iOS-specific `AudioSession` configuration in `LiveKitService`
- Add support for toggling speaker output on iOS
- Update `callStore` to handle video enablement after connection settles
- Fix `useUnreadCountQuery` to correctly handle async fetching
This commit is contained in:
2026-06-02 13:28:16 +08:00
parent 8ee6e77cb4
commit d6a94c7b4d
4 changed files with 118 additions and 12 deletions

View File

@@ -81,6 +81,7 @@ let callTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
let initCallUnsub: (() => void) | null = null;
let unsubInvited: (() => void) | null = null;
let liveKitUnsubs: (() => void)[] = [];
let pendingVideoEnabled: boolean | null = null;
// Constants
const CALL_LIFETIME_MS = 55000;
@@ -100,6 +101,7 @@ function cleanupProcessedCallIds() {
/**
* Fetch LiveKit token from the backend and connect to the room.
* Connects audio-only first, then enables video separately.
*/
async function joinLiveKitRoom(callId: string, videoEnabled: boolean): Promise<void> {
const res = await api.get<{ token: string; url: string }>('/calls/token', {
@@ -111,12 +113,35 @@ async function joinLiveKitRoom(callId: string, videoEnabled: boolean): Promise<v
throw new Error('Invalid LiveKit token response');
}
// Store video preference for enabling after connection
pendingVideoEnabled = videoEnabled;
setupLiveKitEvents(callId);
await liveKitService.connect(url, token);
// Always connect as audio-only first
await liveKitService.connect(url, token, videoEnabled);
// Enable/disable video based on call type
await liveKitService.setVideoEnabled(videoEnabled);
// Enable microphone
await liveKitService.setMuted(false);
// Video will be enabled by enablePendingVideo() called from connected event
}
/**
* Enable video after a delay to avoid iOS main thread deadlock.
* Called from the connected event handler.
*/
function enablePendingVideo(): void {
if (!pendingVideoEnabled) return;
const enable = pendingVideoEnabled;
pendingVideoEnabled = null;
// Use a longer timeout to let the JS thread fully settle
setTimeout(() => {
liveKitService.setVideoEnabled(enable).catch((err) => {
console.warn('[CallStore] Failed to enable video:', err);
});
}, 1000);
}
/**
@@ -139,10 +164,14 @@ function setupLiveKitEvents(callId: string): void {
wsService.sendCallReady(callId);
// Start duration timer immediately
if (durationTimer) clearInterval(durationTimer);
durationTimer = setInterval(() => {
callStore.setState((s) => ({ callDuration: s.callDuration + 1 }));
}, 1000);
// Schedule video enable after settling (avoids iOS deadlock)
enablePendingVideo();
})
);
@@ -414,7 +443,7 @@ export const callStore = create<CallState>((set, get) => ({
processedCallIds.set(msg.call_id, Date.now());
if (currentCall?.id === msg.call_id) {
console.log('[CallStore] Call ended, duration:', msg.duration);
console.log('[CallStore] Call ended, reason:', msg.reason, 'ended_by:', msg.ended_by, 'duration:', msg.duration);
get().endCall('ended');
return;
}
@@ -523,7 +552,7 @@ export const callStore = create<CallState>((set, get) => ({
status: 'calling',
duration: 0,
isMuted: false,
isSpeakerOn: false,
isSpeakerOn: callType === 'video',
isVideoEnabled: callType === 'video',
isPeerVideoEnabled: false,
isInitiator: true,
@@ -579,7 +608,7 @@ export const callStore = create<CallState>((set, get) => ({
status: 'connecting',
duration: 0,
isMuted: false,
isSpeakerOn: false,
isSpeakerOn: isVideoCall,
isVideoEnabled: isVideoCall,
isPeerVideoEnabled: false,
isInitiator: false,
@@ -654,14 +683,16 @@ export const callStore = create<CallState>((set, get) => ({
}));
},
toggleSpeaker: () => {
toggleSpeaker: async () => {
const { currentCall } = get();
if (!currentCall) return;
const newSpeakerOn = !currentCall.isSpeakerOn;
set((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, isSpeakerOn: !s.currentCall.isSpeakerOn }
? { ...s.currentCall, isSpeakerOn: newSpeakerOn }
: null,
}));
await liveKitService.setSpeakerOn(newSpeakerOn);
},
toggleMinimize: () => {