reapply preferred device if the OS decides to update the default.

This commit is contained in:
Timo K
2026-04-16 22:04:06 +02:00
parent 9056989f0e
commit a40f9b8a1d
2 changed files with 69 additions and 13 deletions

View File

@@ -158,12 +158,22 @@ window.controls = {
// wrappers for the deprecated controls fields // wrappers for the deprecated controls fields
setOutputEnabled(enabled: boolean): void { setOutputEnabled(enabled: boolean): void {
logger.info(
`[MediaDevices controls] setOutputEnabled DEPRECATED method called (enabled: ${enabled}) from native -> forwarding to setAudioEnabled.`,
);
this.setAudioEnabled(enabled); this.setAudioEnabled(enabled);
}, },
setAvailableOutputDevices(devices: OutputDevice[]): void { setAvailableOutputDevices(devices: OutputDevice[]): void {
logger.info(
"[MediaDevices controls] setAvailableOutputDevices DEPRECATED method called from native -> forwarding to setAvailableAudioDevices",
devices,
);
this.setAvailableAudioDevices(devices); this.setAvailableAudioDevices(devices);
}, },
setOutputDevice(id: string): void { setOutputDevice(id: string): void {
logger.info(
`[MediaDevices controls] setOutputDevice DEPRECATED method called (with id: ${id}) from native -> forwarding to setAudioDevice`,
);
this.setAudioDevice(id); this.setAudioDevice(id);
}, },
}; };

View File

@@ -23,7 +23,11 @@ import {
} from "./MediaDevices.ts"; } from "./MediaDevices.ts";
import type { ObservableScope } from "./ObservableScope.ts"; import type { ObservableScope } from "./ObservableScope.ts";
import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc"; import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import { type Controls, type OutputDevice } from "../controls.ts"; import {
outputDevice$,
type Controls,
type OutputDevice,
} from "../controls.ts";
import { type Behavior } from "./Behavior.ts"; import { type Behavior } from "./Behavior.ts";
type ControllerState = { type ControllerState = {
@@ -36,9 +40,14 @@ type ControllerState = {
*/ */
preferredDeviceId: string | undefined; preferredDeviceId: string | undefined;
/** /**
* The effective selected device, always valid against available devices. * The effective selected device (what is shown in EW UI), always valid against available devices.
*/ */
selectedDeviceId: string | undefined; selectedDeviceId: string | undefined;
/**
* The device that is selected by the android OS.
* This can be different to the selectedDeviceId in cases the OS decides to update it automatically
*/
nativeSelectedDeviceId?: string | undefined;
}; };
/** /**
@@ -47,6 +56,7 @@ type ControllerState = {
*/ */
type ControllerAction = type ControllerAction =
| { type: "selectDevice"; deviceId: string | undefined } | { type: "selectDevice"; deviceId: string | undefined }
| { type: "nativeSideUpdatedDevice"; deviceId: string | undefined }
| { type: "deviceUpdated"; devices: OutputDevice[] }; | { type: "deviceUpdated"; devices: OutputDevice[] };
/** /**
* The implementation of the audio output media device for Android when using the controlled audio output mode. * The implementation of the audio output media device for Android when using the controlled audio output mode.
@@ -118,7 +128,7 @@ export class AndroidControlledAudioOutput implements MediaDevice<
this.available$ = scope.behavior( this.available$ = scope.behavior(
this.controllerState$.pipe( this.controllerState$.pipe(
map((state) => { map((state) => {
this.logger.info("available devices updated:", state.devices); this.logger.info("controller state updated:", state.devices);
return new Map<string, AudioOutputDeviceLabel>( return new Map<string, AudioOutputDeviceLabel>(
state.devices.map((outputDevice) => { state.devices.map((outputDevice) => {
@@ -132,6 +142,7 @@ export class AndroidControlledAudioOutput implements MediaDevice<
// Effect 1: notify host when effective selection changes // Effect 1: notify host when effective selection changes
this.selected$ this.selected$
// It is a behavior so it has built-in distinct until change // It is a behavior so it has built-in distinct until change
// But it wont do anything since SelectedAudioOutputDevice is a obj and its ref always changes.
.pipe(scope.bind()) .pipe(scope.bind())
.subscribe((device) => { .subscribe((device) => {
// Let the hosting application know which output device has been selected. // Let the hosting application know which output device has been selected.
@@ -153,7 +164,7 @@ export class AndroidControlledAudioOutput implements MediaDevice<
selectedDeviceId: undefined, selectedDeviceId: undefined,
}; };
// Merge the two possible inputs observable as a single // Merge the three possible inputs observable as a single
// stream of actions that will update the state of the controller. // stream of actions that will update the state of the controller.
const actions$: Observable<ControllerAction> = merge( const actions$: Observable<ControllerAction> = merge(
this.controlledDevices$.pipe( this.controlledDevices$.pipe(
@@ -168,6 +179,16 @@ export class AndroidControlledAudioOutput implements MediaDevice<
({ type: "selectDevice", deviceId }) satisfies ControllerAction, ({ type: "selectDevice", deviceId }) satisfies ControllerAction,
), ),
), ),
// The Android native code decided to switch to a new default.
outputDevice$.pipe(
map(
(deviceId) =>
({
type: "nativeSideUpdatedDevice",
deviceId,
}) satisfies ControllerAction,
),
),
); );
const initialAction: ControllerAction = { const initialAction: ControllerAction = {
@@ -201,13 +222,29 @@ export class AndroidControlledAudioOutput implements MediaDevice<
currentSelectedId: state.selectedDeviceId, currentSelectedId: state.selectedDeviceId,
preferredDeviceId: action.deviceId, preferredDeviceId: action.deviceId,
}); });
return { return {
...state, ...state,
preferredDeviceId: action.deviceId, preferredDeviceId: action.deviceId,
selectedDeviceId: chosenDevice, selectedDeviceId: chosenDevice,
}; };
} }
case "nativeSideUpdatedDevice": {
// We will stick with what we had in selectedDeviceId.
// Except if the selected is not available anymore, then we update the default selection based on the available devices.
const chosenDevice = this.chooseEffectiveSelection({
previousDevices: state.devices,
availableDevices: state.devices,
currentSelectedId: state.selectedDeviceId,
preferredDeviceId: state.preferredDeviceId,
nativeSelectedDeviceId: action.deviceId,
});
return {
...state,
// This is only needed to detect if we need to reemit
nativeSelectedDeviceId: action.deviceId,
selectedDeviceId: chosenDevice,
};
}
} }
}, initialState), }, initialState),
), ),
@@ -220,6 +257,13 @@ export class AndroidControlledAudioOutput implements MediaDevice<
return this.scope.behavior( return this.scope.behavior(
state$ state$
.pipe( .pipe(
distinctUntilChanged(
(s1, s2) =>
s1?.selectedDeviceId === s2?.selectedDeviceId &&
// if the native changes we also want to reemit.
// Especially if the selectedDeviceId stays the same.
s1?.nativeSelectedDeviceId === s2?.nativeSelectedDeviceId,
),
map((state) => { map((state) => {
if (state.selectedDeviceId) { if (state.selectedDeviceId) {
return { return {
@@ -230,7 +274,6 @@ export class AndroidControlledAudioOutput implements MediaDevice<
} }
return undefined; return undefined;
}), }),
distinctUntilChanged((a, b) => a?.id === b?.id),
) )
.pipe( .pipe(
tap((selected) => { tap((selected) => {
@@ -245,6 +288,7 @@ export class AndroidControlledAudioOutput implements MediaDevice<
availableDevices: OutputDevice[]; availableDevices: OutputDevice[];
currentSelectedId: string | undefined; currentSelectedId: string | undefined;
preferredDeviceId: string | undefined; preferredDeviceId: string | undefined;
nativeSelectedDeviceId?: string;
}): string | undefined { }): string | undefined {
const { const {
previousDevices, previousDevices,
@@ -253,17 +297,19 @@ export class AndroidControlledAudioOutput implements MediaDevice<
preferredDeviceId, preferredDeviceId,
} = args; } = args;
// nativeSelectedDeviceId can be used in case we want to update the selection in the EW UI if native switches to Bluetooth.
this.logger.debug(`chooseEffectiveSelection with args:`, args); this.logger.debug(`chooseEffectiveSelection with args:`, args);
// Take preferredDeviceId in priority or default to the last effective selection. // Take preferredDeviceId in priority or default to the last effective selection.
const activeSelectedDeviceId = preferredDeviceId || currentSelectedId; // || since we also want "" to fallback to currentSelectedId
const targetSelectedDeviceId = preferredDeviceId || currentSelectedId;
const isAvailable = availableDevices.some( const isAvailable = availableDevices.some(
(device) => device.id === activeSelectedDeviceId, (device) => device.id === targetSelectedDeviceId,
); );
// If there is no current device, or it is not available anymore, // If there is no current device, or it is not available anymore,
// choose the default device selection logic. // choose the default device selection logic.
if (activeSelectedDeviceId === undefined || !isAvailable) { if (targetSelectedDeviceId === undefined || !isAvailable) {
this.logger.debug( this.logger.debug(
`No current device or it is not available, using default selection logic.`, `No current device or it is not available, using default selection logic.`,
); );
@@ -285,13 +331,13 @@ export class AndroidControlledAudioOutput implements MediaDevice<
`A new device was added, checking if we should switch to it.`, `A new device was added, checking if we should switch to it.`,
mostPreferredDevice, mostPreferredDevice,
); );
if (mostPreferredDevice.id !== activeSelectedDeviceId) { if (mostPreferredDevice.id !== targetSelectedDeviceId) {
// Given this is automatic switching, we want to be careful and only switch to a more private device // Given this is automatic switching, we want to be careful and only switch to a more private device
// (e.g. from speaker to a BT headset) but not switch from a more private device to a less private one // (e.g. from speaker to a BT headset) but not switch from a more private device to a less private one
// (e.g. from a BT headset to the speaker), as that can be disruptive for the user if it happens unexpectedly. // (e.g. from a BT headset to the speaker), as that can be disruptive for the user if it happens unexpectedly.
if (mostPreferredDevice.isExternalHeadset == true) { if (mostPreferredDevice.isExternalHeadset == true) {
this.logger.info( this.logger.info(
`The currently selected device ${mostPreferredDevice.id} is not the most preferred one, switching to the most preferred one ${activeSelectedDeviceId} instead.`, `The currently selected device ${mostPreferredDevice.id} is not the most preferred one, switching to the most preferred one ${targetSelectedDeviceId} instead.`,
); );
// Let's switch as it is a more private device. // Let's switch as it is a more private device.
return mostPreferredDevice.id; return mostPreferredDevice.id;
@@ -300,7 +346,7 @@ export class AndroidControlledAudioOutput implements MediaDevice<
} }
// no changes // no changes
return activeSelectedDeviceId; return targetSelectedDeviceId;
} }
/** /**
@@ -314,7 +360,7 @@ export class AndroidControlledAudioOutput implements MediaDevice<
*/ */
private chooseDefaultDeviceId(available: OutputDevice[]): string | undefined { private chooseDefaultDeviceId(available: OutputDevice[]): string | undefined {
this.logger.debug( this.logger.debug(
`Android routing logic intent: ${this.initialIntent} finding best default...`, `Android routing logic intent: "${this.initialIntent}" finding best default...`,
); );
if (this.initialIntent === "audio") { if (this.initialIntent === "audio") {
const systemProposed = available[0]; const systemProposed = available[0];