Fix bluetooth iOS issue (device auto switches from Bluetooth to speaker) (#3383)

* no onAudioDeviceSelect

Signed-off-by: Timo K <toger5@hotmail.de>

* dont restart audio track on ios

Signed-off-by: Timo K <toger5@hotmail.de>

* skip syncing audio input on ios

Signed-off-by: Timo K <toger5@hotmail.de>

* dont pass audio capture defaults when muting unmuting

Signed-off-by: Timo K <toger5@hotmail.de>

* logging on set sink id

Signed-off-by: Timo K <toger5@hotmail.de>

* dont use controlled on ios

Signed-off-by: Timo K <toger5@hotmail.de>

* always show native button on ios

Signed-off-by: Timo K <toger5@hotmail.de>

* revert "dont use controlled on ios"

Signed-off-by: Timo K <toger5@hotmail.de>

* use "" instead of undefined for audio output default and on syncDevice + logging

Signed-off-by: Timo K <toger5@hotmail.de>

* use logger warn

Signed-off-by: Timo K <toger5@hotmail.de>

* dont ignore all sync logic

Signed-off-by: Timo K <toger5@hotmail.de>

* use "default" instead of ""

Signed-off-by: Timo K <toger5@hotmail.de>

* use non controlled audio output again

Signed-off-by: Timo K <toger5@hotmail.de>

* controlled with syncing but no output syncing

Signed-off-by: Timo K <toger5@hotmail.de>

* subscribe to available raw output in controlled case

Signed-off-by: Timo K <toger5@hotmail.de>

* simplification but same solution

Signed-off-by: Timo K <toger5@hotmail.de>

* try "mute with audio capture defaults" again

Signed-off-by: Timo K <toger5@hotmail.de>

* use current device for mute unmute

Signed-off-by: Timo K <toger5@hotmail.de>

* add back restart

Signed-off-by: Timo K <toger5@hotmail.de>

* use default device for unmute again

Signed-off-by: Timo K <toger5@hotmail.de>

* no defaults at all

Signed-off-by: Timo K <toger5@hotmail.de>

* device names request after unmute

Signed-off-by: Timo K <toger5@hotmail.de>

* another approach for mute state request

Signed-off-by: Timo K <toger5@hotmail.de>

* more comment

Signed-off-by: Timo K <toger5@hotmail.de>

* reduce changes and switch input device on sync device

Signed-off-by: Timo K <toger5@hotmail.de>

* more cleanup

Signed-off-by: Timo K <toger5@hotmail.de>

* fix lints

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
This commit is contained in:
Timo
2025-07-03 17:14:09 +02:00
committed by GitHub
parent ca06aa82b2
commit c3eb253059
4 changed files with 38 additions and 11 deletions

View File

@@ -320,16 +320,18 @@ export function useLivekit(
useEffect(() => {
// Sync the requested devices with LiveKit's devices
if (
room !== undefined &&
connectionState === ConnectionState.Connected &&
!controlledAudioDevices
) {
if (room !== undefined && connectionState === ConnectionState.Connected) {
const syncDevice = (
kind: MediaDeviceKind,
selected$: Observable<SelectedDevice | undefined>,
): Subscription =>
selected$.subscribe((device) => {
logger.info(
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
room.getActiveDevice(kind),
" !== ",
device?.id,
);
if (
device !== undefined &&
room.getActiveDevice(kind) !== device.id
@@ -344,7 +346,9 @@ export function useLivekit(
const subscriptions = [
syncDevice("audioinput", devices.audioInput.selected$),
syncDevice("audiooutput", devices.audioOutput.selected$),
!controlledAudioDevices
? syncDevice("audiooutput", devices.audioOutput.selected$)
: undefined,
syncDevice("videoinput", devices.videoInput.selected$),
// Restart the audio input track whenever we detect that the active media
// device has changed to refer to a different hardware device. We do this
@@ -384,7 +388,7 @@ export function useLivekit(
];
return (): void => {
for (const s of subscriptions) s.unsubscribe();
for (const s of subscriptions) s?.unsubscribe();
};
}
}, [room, devices, connectionState, controlledAudioDevices]);

View File

@@ -86,6 +86,14 @@ export function useMuteStates(isJoined: boolean): MuteStates {
const audio = useMuteState(devices.audioInput, () => {
return Config.get().media_devices.enable_audio && !skipLobby && !isJoined;
});
useEffect(() => {
// If audio is enabled, we need to request the device names again,
// because iOS will not be able to switch to the correct device after un-muting.
// This is one of the main changes that makes iOS work with bluetooth audio devices.
if (audio.enabled) {
devices.requestDeviceNames();
}
}, [audio.enabled, devices]);
const isEarpiece = useIsEarpiece();
const video = useMuteState(
devices.videoInput,

View File

@@ -1276,7 +1276,7 @@ export class CallViewModel extends ViewModel {
(available, selected) => {
const selectionType = selected && available.get(selected.id)?.type;
// If we are in any output mode other than spaeker switch to speaker.
// If we are in any output mode other than speaker switch to speaker.
const newSelectionType =
selectionType === "speaker" ? "earpiece" : "speaker";
const newSelection = [...available].find(

View File

@@ -264,6 +264,15 @@ class AudioOutput
class ControlledAudioOutput
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
{
// We need to subscribe to the raw devices so that the OS does update the input
// back to what it was before. otherwise we will switch back to the default
// whenever we allocate a new stream.
public readonly availableRaw$ = availableRawDevices$(
"audiooutput",
this.usingNames$,
this.scope,
);
public readonly available$ = combineLatest(
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
(availableRaw, iosDeviceMenu) => {
@@ -311,14 +320,17 @@ class ControlledAudioOutput
},
).pipe(this.scope.state());
public constructor(private readonly scope: ObservableScope) {
public constructor(
private readonly usingNames$: Observable<boolean>,
private readonly scope: ObservableScope,
) {
this.selected$.subscribe((device) => {
// Let the hosting application know which output device has been selected.
// This information is probably only of interest if the earpiece mode has
// been selected - for example, Element X iOS listens to this to determine
// whether it should enable the proximity sensor.
if (device !== undefined) {
logger.info("[controlled-output] setAudioDeviceSelect called:", device);
logger.info("[controlled-output] onAudioDeviceSelect called:", device);
window.controls.onAudioDeviceSelect?.(device.id);
// Also invoke the deprecated callback for backward compatibility
window.controls.onOutputDeviceSelect?.(device.id);
@@ -327,6 +339,9 @@ class ControlledAudioOutput
this.available$.subscribe((available) => {
logger.info("[controlled-output] available devices:", available);
});
this.availableRaw$.subscribe((availableRaw) => {
logger.info("[controlled-output] available raw devices:", availableRaw);
});
}
}
@@ -393,7 +408,7 @@ export class MediaDevices {
AudioOutputDeviceLabel,
SelectedAudioOutputDevice
> = getUrlParams().controlledAudioDevices
? new ControlledAudioOutput(this.scope)
? new ControlledAudioOutput(this.usingNames$, this.scope)
: new AudioOutput(this.usingNames$, this.scope);
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =