Skip to content

Commit c4040b2

Browse files
committed
Merge pull request #3270 from element-hq/robin/audio-output-controls
Audio device controls for mobile native audio device selection
1 parent 0719320 commit c4040b2

19 files changed

+396
-105
lines changed

docs/controls.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
11
# Global JS controls
22

3-
A few aspects of Element Call's interface can be controlled through a global API on the `window`:
3+
A few aspects of Element Call's interface can be controlled through a global API on the `window`.
4+
5+
## Picture-in-picture
46

57
- `controls.canEnterPip(): boolean` Determines whether it's possible to enter picture-in-picture mode.
68
- `controls.enablePip(): void` Puts the call interface into picture-in-picture mode. Throws if not in a call.
79
- `controls.disablePip(): void` Takes the call interface out of picture-in-picture mode, restoring it to its natural display mode. Throws if not in a call.
10+
11+
## Audio devices
12+
13+
On mobile platforms (iOS, Android), web views do not reliably support selecting audio output devices such as the main speaker, earpiece, or headset. To address this limitation, the following functions allow the hosting application (e.g., Element Web, Element X) to manage audio devices via exposed JavaScript interfaces. These functions must be enabled using the URL parameter `controlledAudioDevices` to take effect.
14+
15+
- `controls.setAvailableAudioDevices(devices: { id: string, name: string, forEarpiece?: boolean, isEarpiece?: boolean isSpeaker?: boolean, isExternalHeadset?, boolean; }[]): void` Sets the list of available audio outputs. `forEarpiece` is used on iOS only.
16+
It flags the device that should be used if the user selects earpiece mode. This should be the main stereo loudspeaker of the device.
17+
- `controls.onAudioDeviceSelect: ((id: string) => void) | undefined` Callback called whenever the user or application selects a new audio output.
18+
- `controls.setAudioDevice(id: string): void` Sets the selected audio device in Element Call's menu. This should be used if the OS decides to automatically switch to Bluetooth, for example.
19+
- `controls.setAudioEnabled(enabled: boolean)` Enables/disables all audio output from the application. Output is enabled by default.
20+
- `showNativeAudioDevicePicker: (() => void) | undefined`. Callback called whenever the user presses the output button in the settings menu.
21+
This button is only shown on iOS. (`userAgent.includes("iPhone")`)

docs/url-params.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
6363
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
6464
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
6565
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
66+
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
6667
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
6768
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
6869
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |

locales/en/app.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@
173173
"devices": {
174174
"camera": "Camera",
175175
"camera_numbered": "Camera {{n}}",
176+
"change_device_button": "Change audio device",
176177
"default": "Default",
177178
"default_named": "Default <2>({{name}})</2>",
178179
"earpiece": "Earpiece",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@
9999
"i18next-parser": "^9.1.0",
100100
"jsdom": "^26.0.0",
101101
"knip": "^5.27.2",
102-
"livekit-client": "^2.11.3",
102+
"livekit-client": "^2.13.0",
103103
"lodash-es": "^4.17.21",
104104
"loglevel": "^1.9.1",
105105
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=develop",

src/UrlParams.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,15 @@ export interface UrlParams {
124124
*/
125125
password: string | null;
126126
/**
127-
* Whether we the app should use per participant keys for E2EE.
127+
* Whether the app should use per participant keys for E2EE.
128128
*/
129129
perParticipantE2EE: boolean;
130+
/**
131+
* Whether the global JS controls for audio output devices should be enabled,
132+
* allowing the list of output devices to be controlled by the app hosting
133+
* Element Call.
134+
*/
135+
controlledAudioDevices: boolean;
130136
/**
131137
* Setting this flag skips the lobby and brings you in the call directly.
132138
* In the widget this can be combined with preload to pass the device settings
@@ -173,6 +179,7 @@ export interface UrlParams {
173179
* The Sentry DSN. This is only used in the embedded package of Element Call.
174180
*/
175181
sentryDsn: string | null;
182+
176183
/**
177184
* The Sentry environment. This is only used in the embedded package of Element Call.
178185
*/
@@ -281,6 +288,11 @@ export const getUrlParams = (
281288
fontScale: Number.isNaN(fontScale) ? null : fontScale,
282289
allowIceFallback: parser.getFlagParam("allowIceFallback"),
283290
perParticipantE2EE: parser.getFlagParam("perParticipantE2EE"),
291+
controlledAudioDevices: parser.getFlagParam(
292+
"controlledAudioDevices",
293+
// the deprecated property name
294+
parser.getFlagParam("controlledMediaDevices"),
295+
),
284296
skipLobby: parser.getFlagParam(
285297
"skipLobby",
286298
isWidget && intent === UserIntent.StartNewCall,

src/controls.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,55 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
55
Please see LICENSE in the repository root for full details.
66
*/
77

8-
import { Subject } from "rxjs";
8+
import { BehaviorSubject, Subject } from "rxjs";
99

1010
export interface Controls {
11-
canEnterPip: () => boolean;
12-
enablePip: () => void;
13-
disablePip: () => void;
11+
canEnterPip(): boolean;
12+
enablePip(): void;
13+
disablePip(): void;
14+
/** @deprecated use setAvailableAudioDevices instead*/
15+
setAvailableOutputDevices(devices: OutputDevice[]): void;
16+
setAvailableAudioDevices(devices: OutputDevice[]): void;
17+
/** @deprecated use setAudioDevice instead*/
18+
setOutputDevice(id: string): void;
19+
setAudioDevice(id: string): void;
20+
/** @deprecated use onAudioDeviceSelect instead*/
21+
onOutputDeviceSelect?: (id: string) => void;
22+
onAudioDeviceSelect?: (id: string) => void;
23+
/** @deprecated use setAudioEnabled instead*/
24+
setOutputEnabled(enabled: boolean): void;
25+
setAudioEnabled(enabled: boolean): void;
26+
/** @deprecated use showNativeAudioDevicePicker instead*/
27+
showNativeOutputDevicePicker?: () => void;
28+
showNativeAudioDevicePicker?: () => void;
1429
}
1530

31+
export interface OutputDevice {
32+
id: string;
33+
name: string;
34+
forEarpiece?: boolean;
35+
isEarpiece?: boolean;
36+
isSpeaker?: boolean;
37+
isExternalHeadset?: boolean;
38+
}
39+
40+
/**
41+
* If pipMode is enabled, EC will render a adapted call view layout.
42+
*/
1643
export const setPipEnabled$ = new Subject<boolean>();
44+
// BehaviorSubject since the client might set this before we have subscribed (GroupCallView still in "loading" state)
45+
// We want the devices that have been set during loading to be available immediately once loaded.
46+
export const availableOutputDevices$ = new BehaviorSubject<OutputDevice[]>([]);
47+
// BehaviorSubject since the client might set this before we have subscribed (GroupCallView still in "loading" state)
48+
// We want the device that has been set during loading to be available immediately once loaded.
49+
export const outputDevice$ = new BehaviorSubject<string | undefined>(undefined);
50+
/**
51+
* This allows the os to mute the call if the user
52+
* presses the volume down button when it is at the minimum volume.
53+
*
54+
* This should also be used to display a darkened overlay screen letting the user know that audio is muted.
55+
*/
56+
export const setAudioEnabled$ = new Subject<boolean>();
1757

1858
window.controls = {
1959
canEnterPip(): boolean {
@@ -27,4 +67,28 @@ window.controls = {
2767
if (!setPipEnabled$.observed) throw new Error("No call is running");
2868
setPipEnabled$.next(false);
2969
},
70+
setAvailableAudioDevices(devices: OutputDevice[]): void {
71+
availableOutputDevices$.next(devices);
72+
},
73+
setAudioDevice(id: string): void {
74+
outputDevice$.next(id);
75+
},
76+
setAudioEnabled(enabled: boolean): void {
77+
if (!setAudioEnabled$.observed)
78+
throw new Error(
79+
"Output controls are disabled. No setAudioEnabled$ observer",
80+
);
81+
setAudioEnabled$.next(enabled);
82+
},
83+
84+
// wrappers for the deprecated controls fields
85+
setOutputEnabled(enabled: boolean): void {
86+
this.setAudioEnabled(enabled);
87+
},
88+
setAvailableOutputDevices(devices: OutputDevice[]): void {
89+
this.setAvailableAudioDevices(devices);
90+
},
91+
setOutputDevice(id: string): void {
92+
this.setAudioDevice(id);
93+
},
3094
};

0 commit comments

Comments
 (0)