feat(livekit): improve native audio/video handling and stability
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:
@@ -8,7 +8,10 @@ export const messageKeys = {
|
||||
export function useUnreadCountQuery() {
|
||||
return useQuery({
|
||||
queryKey: messageKeys.unread,
|
||||
queryFn: () => messageManager.fetchUnreadCount(),
|
||||
queryFn: async () => {
|
||||
await messageManager.fetchUnreadCount();
|
||||
return null;
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,38 @@
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
// Register WebRTC globals for LiveKit (native only — web has built-in WebRTC)
|
||||
if (Platform.OS !== 'web') {
|
||||
// Use @livekit/react-native's registerGlobals which sets up:
|
||||
// - WebRTC native modules (via @livekit/react-native-webrtc)
|
||||
// - iOS audio session management
|
||||
// - LiveKitReactNativeGlobal (so livekit-client detects RN environment)
|
||||
// - RN-specific polyfills (Promise.allSettled, webstreams, crypto, etc.)
|
||||
const { registerGlobals } = require('@livekit/react-native');
|
||||
registerGlobals();
|
||||
}
|
||||
|
||||
if (typeof globalThis.Event === 'undefined') {
|
||||
(globalThis as any).Event = class Event {
|
||||
type: string;
|
||||
bubbles: boolean;
|
||||
cancelable: boolean;
|
||||
defaultPrevented: boolean;
|
||||
target: any;
|
||||
currentTarget: any;
|
||||
constructor(type: string, opts?: { bubbles?: boolean; cancelable?: boolean }) {
|
||||
this.type = type;
|
||||
this.bubbles = opts?.bubbles ?? false;
|
||||
this.cancelable = opts?.cancelable ?? false;
|
||||
this.defaultPrevented = false;
|
||||
this.target = null;
|
||||
this.currentTarget = null;
|
||||
}
|
||||
preventDefault() { this.defaultPrevented = true; }
|
||||
stopPropagation() {}
|
||||
stopImmediatePropagation() {}
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof globalThis.DOMException === 'undefined') {
|
||||
(globalThis as any).DOMException = class DOMException extends Error {
|
||||
constructor(message?: string, name?: string) {
|
||||
@@ -5,4 +40,4 @@ if (typeof globalThis.DOMException === 'undefined') {
|
||||
this.name = name || 'DOMException';
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import '../../polyfills';
|
||||
import { Platform } from 'react-native';
|
||||
import { Room, RoomEvent, Track, ConnectionState, LocalParticipant, RemoteParticipant, RemoteTrackPublication, TrackPublication, Participant as LKParticipant } from 'livekit-client';
|
||||
|
||||
export interface LiveKitServiceConfig {
|
||||
@@ -44,11 +45,26 @@ class LiveKitServiceImpl {
|
||||
return this.room?.remoteParticipants ?? new Map();
|
||||
}
|
||||
|
||||
async connect(url: string, token: string): Promise<void> {
|
||||
async connect(url: string, token: string, isVideoCall: boolean = false): Promise<void> {
|
||||
if (this.room) {
|
||||
await this.disconnect();
|
||||
}
|
||||
|
||||
// Configure native audio session before connecting (iOS only)
|
||||
if (Platform.OS === 'ios') {
|
||||
try {
|
||||
const { AudioSession } = require('@livekit/react-native');
|
||||
await AudioSession.configureAudio({
|
||||
ios: {
|
||||
defaultOutput: isVideoCall ? 'speaker' : 'default',
|
||||
},
|
||||
});
|
||||
await AudioSession.startAudioSession();
|
||||
} catch (err) {
|
||||
console.warn('[LiveKit] Audio session configuration failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
this.room = new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
@@ -87,6 +103,14 @@ class LiveKitServiceImpl {
|
||||
await this.room.disconnect();
|
||||
this.room = null;
|
||||
this._connectionStatus = 'disconnected';
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
try {
|
||||
const { AudioSession } = require('@livekit/react-native');
|
||||
await AudioSession.stopAudioSession();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
this.emit('connectionStatusChanged', 'disconnected');
|
||||
this.emit('disconnected', undefined);
|
||||
}
|
||||
@@ -98,7 +122,20 @@ class LiveKitServiceImpl {
|
||||
|
||||
async setVideoEnabled(enabled: boolean): Promise<void> {
|
||||
if (!this.room?.localParticipant) return;
|
||||
await this.room.localParticipant.setCameraEnabled(enabled);
|
||||
console.log('[LiveKit] setVideoEnabled called:', enabled);
|
||||
try {
|
||||
await this.room.localParticipant.setCameraEnabled(enabled);
|
||||
console.log('[LiveKit] setVideoEnabled succeeded:', enabled);
|
||||
} catch (err) {
|
||||
console.error('[LiveKit] setVideoEnabled failed:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setSpeakerOn(speakerOn: boolean): Promise<void> {
|
||||
if (Platform.OS === 'web' || Platform.OS === 'android') return;
|
||||
const AudioSession = require('@livekit/react-native').AudioSession;
|
||||
await AudioSession.selectAudioOutput(speakerOn ? 'force_speaker' : 'default');
|
||||
}
|
||||
|
||||
isMuted(): boolean {
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
Reference in New Issue
Block a user