Android Audio:
- Use inCommunication mode with forceHandleAudioRouting
- Explicit selectAudioOutput('speaker') after session start
- Keeps echo cancellation while forcing speaker output
Profile Modal:
- Add KeyboardAvoidingView for deployment ID input
- Prevents modal buttons from being hidden by keyboard
Co-Authored-By: Claude <noreply@anthropic.com>
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
/**
|
|
* Audio Session Configuration Helpers (iOS + Android)
|
|
*
|
|
* CRITICAL: This must be configured BEFORE connecting to LiveKit room!
|
|
* Without proper AudioSession setup, microphone won't work on iOS.
|
|
* On Android, this controls speaker/earpiece routing.
|
|
*/
|
|
|
|
import { Platform } from 'react-native';
|
|
|
|
// AudioSession module - use 'any' to avoid complex typing issues with @livekit/react-native
|
|
// The actual AudioSession from LiveKit has specific enum types that are hard to match statically
|
|
let audioSessionModule: any = null;
|
|
|
|
/**
|
|
* Import AudioSession module lazily
|
|
* This is needed because @livekit/react-native must be imported after registerGlobals()
|
|
*/
|
|
async function getAudioSession(): Promise<any | null> {
|
|
if (!audioSessionModule) {
|
|
const livekit = await import('@livekit/react-native');
|
|
audioSessionModule = livekit.AudioSession;
|
|
}
|
|
|
|
return audioSessionModule;
|
|
}
|
|
|
|
/**
|
|
* Configure AudioSession for bidirectional voice call (iOS + Android)
|
|
*
|
|
* MUST be called BEFORE connecting to LiveKit room!
|
|
*
|
|
* iOS Configuration:
|
|
* - Category: playAndRecord (both speaker and mic)
|
|
* - Mode: voiceChat (optimized for voice calls)
|
|
* - Options: Bluetooth, speaker, mix with others
|
|
*
|
|
* Android Configuration:
|
|
* - audioTypeOptions: communication (for voice calls)
|
|
* - forceHandleAudioRouting: true (to control speaker/earpiece)
|
|
*/
|
|
export async function configureAudioForVoiceCall(): Promise<void> {
|
|
console.log(`[AudioSession] Configuring for voice call on ${Platform.OS}...`);
|
|
|
|
try {
|
|
const AudioSession = await getAudioSession();
|
|
if (!AudioSession) {
|
|
console.error('[AudioSession] Failed to get AudioSession module');
|
|
return;
|
|
}
|
|
|
|
if (Platform.OS === 'ios') {
|
|
// iOS-specific configuration - FORCE SPEAKER OUTPUT
|
|
// Using videoChat mode + defaultSpeakerOutput option for guaranteed speaker
|
|
console.log('[AudioSession] Configuring iOS for SPEAKER output...');
|
|
|
|
try {
|
|
// Primary config: videoChat mode with defaultSpeakerOutput
|
|
await AudioSession.setAppleAudioConfiguration({
|
|
audioCategory: 'playAndRecord',
|
|
audioCategoryOptions: [
|
|
'allowBluetooth',
|
|
'mixWithOthers',
|
|
'defaultToSpeaker', // KEY: Forces speaker as default output
|
|
],
|
|
audioMode: 'videoChat', // videoChat mode uses speaker by default
|
|
});
|
|
console.log('[AudioSession] iOS videoChat + defaultToSpeaker configured!');
|
|
} catch (err) {
|
|
console.warn('[AudioSession] Primary iOS config failed, trying fallback:', err);
|
|
// Fallback: just videoChat without defaultToSpeaker option
|
|
await AudioSession.setAppleAudioConfiguration({
|
|
audioCategory: 'playAndRecord',
|
|
audioCategoryOptions: ['allowBluetooth', 'mixWithOthers'],
|
|
audioMode: 'videoChat',
|
|
});
|
|
}
|
|
|
|
console.log('[AudioSession] Starting iOS audio session...');
|
|
await AudioSession.startAudioSession();
|
|
|
|
// Additionally set default output to speaker (belt and suspenders)
|
|
try {
|
|
console.log('[AudioSession] Setting iOS default output to speaker...');
|
|
await AudioSession.configureAudio({
|
|
ios: {
|
|
defaultOutput: 'speaker',
|
|
},
|
|
});
|
|
console.log('[AudioSession] iOS speaker output set!');
|
|
} catch (outputErr) {
|
|
console.warn('[AudioSession] Could not set speaker output:', outputErr);
|
|
}
|
|
} else if (Platform.OS === 'android') {
|
|
// Android-specific configuration - FORCE SPEAKER OUTPUT
|
|
// SOLUTION: Use 'inCommunication' for echo cancellation + forceHandleAudioRouting + explicit speaker selection
|
|
console.log('[AudioSession] Configuring Android audio for SPEAKER with echo cancellation...');
|
|
|
|
await AudioSession.configureAudio({
|
|
android: {
|
|
// Force speaker as preferred output
|
|
preferredOutputList: ['speaker'],
|
|
// CRITICAL: This flag forces audio routing even in communication mode
|
|
forceHandleAudioRouting: true,
|
|
audioTypeOptions: {
|
|
manageAudioFocus: true,
|
|
// Use 'inCommunication' for echo cancellation (important for voice calls!)
|
|
audioMode: 'inCommunication',
|
|
audioFocusMode: 'gain',
|
|
// Voice call stream type for proper routing
|
|
audioStreamType: 'voiceCall',
|
|
audioAttributesUsageType: 'voiceCommunication',
|
|
audioAttributesContentType: 'speech',
|
|
},
|
|
},
|
|
});
|
|
|
|
console.log('[AudioSession] Starting Android audio session...');
|
|
await AudioSession.startAudioSession();
|
|
|
|
// CRITICAL: Explicitly select speaker AFTER session starts
|
|
// This overrides the default earpiece routing of inCommunication mode
|
|
try {
|
|
console.log('[AudioSession] Explicitly selecting speaker output...');
|
|
await AudioSession.selectAudioOutput('speaker');
|
|
console.log('[AudioSession] Speaker output explicitly selected!');
|
|
} catch (speakerErr) {
|
|
console.warn('[AudioSession] selectAudioOutput failed, trying showAudioRoutePicker:', speakerErr);
|
|
// Fallback: try to show audio route picker or use alternative method
|
|
try {
|
|
if (AudioSession.showAudioRoutePicker) {
|
|
await AudioSession.showAudioRoutePicker();
|
|
}
|
|
} catch (pickerErr) {
|
|
console.warn('[AudioSession] showAudioRoutePicker also failed:', pickerErr);
|
|
}
|
|
}
|
|
|
|
console.log('[AudioSession] Android speaker mode with echo cancellation configured!');
|
|
}
|
|
|
|
console.log('[AudioSession] Configuration complete!');
|
|
} catch (error) {
|
|
console.error('[AudioSession] Configuration error:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop AudioSession (iOS + Android)
|
|
*
|
|
* Should be called when disconnecting from voice call
|
|
*/
|
|
export async function stopAudioSession(): Promise<void> {
|
|
if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
|
|
return;
|
|
}
|
|
|
|
console.log(`[AudioSession] Stopping audio session on ${Platform.OS}...`);
|
|
|
|
try {
|
|
const AudioSession = await getAudioSession();
|
|
if (!AudioSession) {
|
|
return;
|
|
}
|
|
|
|
await AudioSession.stopAudioSession();
|
|
console.log('[AudioSession] Stopped');
|
|
} catch (error) {
|
|
console.error('[AudioSession] Error stopping:', error);
|
|
// Don't throw - cleanup errors are not critical
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reconfigure audio session after remote track arrives (iOS + Android)
|
|
*
|
|
* Sometimes the OS needs a kick to properly route audio after remote participant joins
|
|
*/
|
|
export async function reconfigureAudioForPlayback(): Promise<void> {
|
|
if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
|
|
return;
|
|
}
|
|
|
|
console.log(`[AudioSession] Reconfiguring for playback (SPEAKER) on ${Platform.OS}...`);
|
|
|
|
try {
|
|
const AudioSession = await getAudioSession();
|
|
if (!AudioSession) {
|
|
return;
|
|
}
|
|
|
|
if (Platform.OS === 'ios') {
|
|
// Reconfigure iOS - force speaker output
|
|
await AudioSession.setAppleAudioConfiguration({
|
|
audioCategory: 'playAndRecord',
|
|
audioCategoryOptions: [
|
|
'allowBluetooth',
|
|
'mixWithOthers',
|
|
'defaultToSpeaker', // Force speaker
|
|
],
|
|
audioMode: 'videoChat', // videoChat = speaker by default
|
|
});
|
|
|
|
// Also set default output to speaker
|
|
await AudioSession.configureAudio({
|
|
ios: {
|
|
defaultOutput: 'speaker',
|
|
},
|
|
});
|
|
console.log('[AudioSession] iOS reconfigured for speaker playback');
|
|
} else if (Platform.OS === 'android') {
|
|
// Reconfigure Android - force speaker while keeping echo cancellation
|
|
await AudioSession.configureAudio({
|
|
android: {
|
|
preferredOutputList: ['speaker'],
|
|
forceHandleAudioRouting: true,
|
|
audioTypeOptions: {
|
|
manageAudioFocus: true,
|
|
audioMode: 'inCommunication', // Keep for echo cancellation
|
|
audioFocusMode: 'gain',
|
|
audioStreamType: 'voiceCall',
|
|
audioAttributesUsageType: 'voiceCommunication',
|
|
audioAttributesContentType: 'speech',
|
|
},
|
|
},
|
|
});
|
|
|
|
// Explicitly select speaker output
|
|
try {
|
|
await AudioSession.selectAudioOutput('speaker');
|
|
console.log('[AudioSession] Android speaker explicitly selected');
|
|
} catch (err) {
|
|
console.warn('[AudioSession] selectAudioOutput failed in reconfigure:', err);
|
|
}
|
|
|
|
console.log('[AudioSession] Android reconfigured for speaker playback');
|
|
}
|
|
|
|
console.log('[AudioSession] Reconfigured successfully');
|
|
} catch (error) {
|
|
console.error('[AudioSession] Reconfigure error:', error);
|
|
// Don't throw - this is a best-effort operation
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Switch audio output between speaker and earpiece (iOS + Android)
|
|
*
|
|
* @param useSpeaker - true for speaker, false for earpiece
|
|
*/
|
|
export async function setAudioOutput(useSpeaker: boolean): Promise<void> {
|
|
console.log(`[AudioSession] Setting audio output to ${useSpeaker ? 'SPEAKER' : 'EARPIECE'} on ${Platform.OS}...`);
|
|
|
|
try {
|
|
const AudioSession = await getAudioSession();
|
|
if (!AudioSession) {
|
|
console.error('[AudioSession] Failed to get AudioSession module');
|
|
return;
|
|
}
|
|
|
|
if (Platform.OS === 'ios') {
|
|
// iOS: Use videoChat mode + defaultToSpeaker for speaker, voiceChat for earpiece
|
|
await AudioSession.setAppleAudioConfiguration({
|
|
audioCategory: 'playAndRecord',
|
|
audioCategoryOptions: useSpeaker
|
|
? ['allowBluetooth', 'mixWithOthers', 'defaultToSpeaker']
|
|
: ['allowBluetooth', 'mixWithOthers'],
|
|
audioMode: useSpeaker ? 'videoChat' : 'voiceChat',
|
|
});
|
|
|
|
// Also set default output
|
|
await AudioSession.configureAudio({
|
|
ios: {
|
|
defaultOutput: useSpeaker ? 'speaker' : 'earpiece',
|
|
},
|
|
});
|
|
} else if (Platform.OS === 'android') {
|
|
// Android: Keep inCommunication mode for echo cancellation, use explicit output selection
|
|
await AudioSession.configureAudio({
|
|
android: {
|
|
preferredOutputList: useSpeaker ? ['speaker'] : ['earpiece'],
|
|
forceHandleAudioRouting: true,
|
|
audioTypeOptions: {
|
|
manageAudioFocus: true,
|
|
// Always use inCommunication for echo cancellation
|
|
audioMode: 'inCommunication',
|
|
audioFocusMode: 'gain',
|
|
audioStreamType: 'voiceCall',
|
|
audioAttributesUsageType: 'voiceCommunication',
|
|
audioAttributesContentType: 'speech',
|
|
},
|
|
},
|
|
});
|
|
|
|
// Explicitly select output device
|
|
try {
|
|
await AudioSession.selectAudioOutput(useSpeaker ? 'speaker' : 'earpiece');
|
|
} catch (err) {
|
|
console.warn('[AudioSession] selectAudioOutput failed:', err);
|
|
}
|
|
}
|
|
|
|
console.log(`[AudioSession] Audio output set to ${useSpeaker ? 'SPEAKER' : 'EARPIECE'}`);
|
|
} catch (error) {
|
|
console.error('[AudioSession] setAudioOutput error:', error);
|
|
}
|
|
}
|