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() {
|
export function useUnreadCountQuery() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: messageKeys.unread,
|
queryKey: messageKeys.unread,
|
||||||
queryFn: () => messageManager.fetchUnreadCount(),
|
queryFn: async () => {
|
||||||
|
await messageManager.fetchUnreadCount();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
staleTime: 30 * 1000,
|
staleTime: 30 * 1000,
|
||||||
refetchInterval: 60 * 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') {
|
if (typeof globalThis.DOMException === 'undefined') {
|
||||||
(globalThis as any).DOMException = class DOMException extends Error {
|
(globalThis as any).DOMException = class DOMException extends Error {
|
||||||
constructor(message?: string, name?: string) {
|
constructor(message?: string, name?: string) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import '../../polyfills';
|
import '../../polyfills';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
import { Room, RoomEvent, Track, ConnectionState, LocalParticipant, RemoteParticipant, RemoteTrackPublication, TrackPublication, Participant as LKParticipant } from 'livekit-client';
|
import { Room, RoomEvent, Track, ConnectionState, LocalParticipant, RemoteParticipant, RemoteTrackPublication, TrackPublication, Participant as LKParticipant } from 'livekit-client';
|
||||||
|
|
||||||
export interface LiveKitServiceConfig {
|
export interface LiveKitServiceConfig {
|
||||||
@@ -44,11 +45,26 @@ class LiveKitServiceImpl {
|
|||||||
return this.room?.remoteParticipants ?? new Map();
|
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) {
|
if (this.room) {
|
||||||
await this.disconnect();
|
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({
|
this.room = new Room({
|
||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
dynacast: true,
|
dynacast: true,
|
||||||
@@ -87,6 +103,14 @@ class LiveKitServiceImpl {
|
|||||||
await this.room.disconnect();
|
await this.room.disconnect();
|
||||||
this.room = null;
|
this.room = null;
|
||||||
this._connectionStatus = 'disconnected';
|
this._connectionStatus = 'disconnected';
|
||||||
|
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
try {
|
||||||
|
const { AudioSession } = require('@livekit/react-native');
|
||||||
|
await AudioSession.stopAudioSession();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
this.emit('connectionStatusChanged', 'disconnected');
|
this.emit('connectionStatusChanged', 'disconnected');
|
||||||
this.emit('disconnected', undefined);
|
this.emit('disconnected', undefined);
|
||||||
}
|
}
|
||||||
@@ -98,7 +122,20 @@ class LiveKitServiceImpl {
|
|||||||
|
|
||||||
async setVideoEnabled(enabled: boolean): Promise<void> {
|
async setVideoEnabled(enabled: boolean): Promise<void> {
|
||||||
if (!this.room?.localParticipant) return;
|
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 {
|
isMuted(): boolean {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ let callTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|||||||
let initCallUnsub: (() => void) | null = null;
|
let initCallUnsub: (() => void) | null = null;
|
||||||
let unsubInvited: (() => void) | null = null;
|
let unsubInvited: (() => void) | null = null;
|
||||||
let liveKitUnsubs: (() => void)[] = [];
|
let liveKitUnsubs: (() => void)[] = [];
|
||||||
|
let pendingVideoEnabled: boolean | null = null;
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const CALL_LIFETIME_MS = 55000;
|
const CALL_LIFETIME_MS = 55000;
|
||||||
@@ -100,6 +101,7 @@ function cleanupProcessedCallIds() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch LiveKit token from the backend and connect to the room.
|
* 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> {
|
async function joinLiveKitRoom(callId: string, videoEnabled: boolean): Promise<void> {
|
||||||
const res = await api.get<{ token: string; url: string }>('/calls/token', {
|
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');
|
throw new Error('Invalid LiveKit token response');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store video preference for enabling after connection
|
||||||
|
pendingVideoEnabled = videoEnabled;
|
||||||
|
|
||||||
setupLiveKitEvents(callId);
|
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
|
// Enable microphone
|
||||||
await liveKitService.setVideoEnabled(videoEnabled);
|
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);
|
wsService.sendCallReady(callId);
|
||||||
|
|
||||||
|
// Start duration timer immediately
|
||||||
if (durationTimer) clearInterval(durationTimer);
|
if (durationTimer) clearInterval(durationTimer);
|
||||||
durationTimer = setInterval(() => {
|
durationTimer = setInterval(() => {
|
||||||
callStore.setState((s) => ({ callDuration: s.callDuration + 1 }));
|
callStore.setState((s) => ({ callDuration: s.callDuration + 1 }));
|
||||||
}, 1000);
|
}, 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());
|
processedCallIds.set(msg.call_id, Date.now());
|
||||||
|
|
||||||
if (currentCall?.id === msg.call_id) {
|
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');
|
get().endCall('ended');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -523,7 +552,7 @@ export const callStore = create<CallState>((set, get) => ({
|
|||||||
status: 'calling',
|
status: 'calling',
|
||||||
duration: 0,
|
duration: 0,
|
||||||
isMuted: false,
|
isMuted: false,
|
||||||
isSpeakerOn: false,
|
isSpeakerOn: callType === 'video',
|
||||||
isVideoEnabled: callType === 'video',
|
isVideoEnabled: callType === 'video',
|
||||||
isPeerVideoEnabled: false,
|
isPeerVideoEnabled: false,
|
||||||
isInitiator: true,
|
isInitiator: true,
|
||||||
@@ -579,7 +608,7 @@ export const callStore = create<CallState>((set, get) => ({
|
|||||||
status: 'connecting',
|
status: 'connecting',
|
||||||
duration: 0,
|
duration: 0,
|
||||||
isMuted: false,
|
isMuted: false,
|
||||||
isSpeakerOn: false,
|
isSpeakerOn: isVideoCall,
|
||||||
isVideoEnabled: isVideoCall,
|
isVideoEnabled: isVideoCall,
|
||||||
isPeerVideoEnabled: false,
|
isPeerVideoEnabled: false,
|
||||||
isInitiator: false,
|
isInitiator: false,
|
||||||
@@ -654,14 +683,16 @@ export const callStore = create<CallState>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleSpeaker: () => {
|
toggleSpeaker: async () => {
|
||||||
const { currentCall } = get();
|
const { currentCall } = get();
|
||||||
if (!currentCall) return;
|
if (!currentCall) return;
|
||||||
|
const newSpeakerOn = !currentCall.isSpeakerOn;
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
currentCall: s.currentCall
|
currentCall: s.currentCall
|
||||||
? { ...s.currentCall, isSpeakerOn: !s.currentCall.isSpeakerOn }
|
? { ...s.currentCall, isSpeakerOn: newSpeakerOn }
|
||||||
: null,
|
: null,
|
||||||
}));
|
}));
|
||||||
|
await liveKitService.setSpeakerOn(newSpeakerOn);
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleMinimize: () => {
|
toggleMinimize: () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user