Skip to content

Commit 53adfa4

Browse files
robintowntoger5
authored andcommitted
WIP
1 parent 18a59dd commit 53adfa4

File tree

6 files changed

+191
-43
lines changed

6 files changed

+191
-43
lines changed

docs/controls.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
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 output devices
12+
13+
These functions must be used in conjunction with the `controlledOutput` URL parameter in order to have any effect.
14+
15+
- `controls.setOutputDevices(devices: { id: string, name: string }[]): void` Sets the list of available audio outputs.
16+
- `controls.onOutputDeviceSelect: ((id: string) => void) | undefined` Callback called whenever the user or application selects a new audio output.
17+
- `controls.setOutputEnabled(enabled: boolean)` Enables/disables all audio output from the application. This can be useful for temporarily pausing audio while the controlling application is switching output devices. Output is enabled by default.

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+
| `controlledOutput` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio output devices](./controls.md#audio-devices) should be enabled, allowing the list of output 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.) |

src/App.tsx

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,14 @@ import { ClientProvider } from "./ClientContext";
1919
import { ErrorPage, LoadingPage } from "./FullScreenView";
2020
import { DisconnectedBanner } from "./DisconnectedBanner";
2121
import { Initializer } from "./initializer";
22-
import { MediaDevicesProvider } from "./livekit/MediaDevicesContext";
22+
import {
23+
ControlledOutputMediaDevicesProvider,
24+
MediaDevicesProvider,
25+
} from "./livekit/MediaDevicesContext";
2326
import { widget } from "./widget";
2427
import { useTheme } from "./useTheme";
2528
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
29+
import { useUrlParams } from "./UrlParams";
2630

2731
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
2832

@@ -51,6 +55,7 @@ const ThemeProvider: FC<SimpleProviderProps> = ({ children }) => {
5155
};
5256

5357
export const App: FC = () => {
58+
const { controlledOutput } = useUrlParams();
5459
const [loaded, setLoaded] = useState(false);
5560
useEffect(() => {
5661
Initializer.init()
@@ -62,6 +67,20 @@ export const App: FC = () => {
6267
.catch(logger.error);
6368
});
6469

70+
const inner = (
71+
<Sentry.ErrorBoundary
72+
fallback={(error) => <ErrorPage error={error} widget={widget} />}
73+
>
74+
<DisconnectedBanner />
75+
<Routes>
76+
<SentryRoute path="/" element={<HomePage />} />
77+
<SentryRoute path="/login" element={<LoginPage />} />
78+
<SentryRoute path="/register" element={<RegisterPage />} />
79+
<SentryRoute path="*" element={<RoomPage />} />
80+
</Routes>
81+
</Sentry.ErrorBoundary>
82+
);
83+
6584
return (
6685
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
6786
// @ts-ignore
@@ -72,26 +91,15 @@ export const App: FC = () => {
7291
{loaded ? (
7392
<Suspense fallback={null}>
7493
<ClientProvider>
75-
<MediaDevicesProvider>
76-
<ProcessorProvider>
77-
<Sentry.ErrorBoundary
78-
fallback={(error) => (
79-
<ErrorPage error={error} widget={widget} />
80-
)}
81-
>
82-
<DisconnectedBanner />
83-
<Routes>
84-
<SentryRoute path="/" element={<HomePage />} />
85-
<SentryRoute path="/login" element={<LoginPage />} />
86-
<SentryRoute
87-
path="/register"
88-
element={<RegisterPage />}
89-
/>
90-
<SentryRoute path="*" element={<RoomPage />} />
91-
</Routes>
92-
</Sentry.ErrorBoundary>
93-
</ProcessorProvider>
94-
</MediaDevicesProvider>
94+
<ProcessorProvider>
95+
{controlledOutput ? (
96+
<ControlledOutputMediaDevicesProvider>
97+
{inner}
98+
</ControlledOutputMediaDevicesProvider>
99+
) : (
100+
<MediaDevicesProvider>{inner}</MediaDevicesProvider>
101+
)}
102+
</ProcessorProvider>
95103
</ClientProvider>
96104
</Suspense>
97105
) : (

src/UrlParams.ts

Lines changed: 8 additions & 4 deletions
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+
controlledOutput: 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
@@ -156,19 +162,16 @@ export interface UrlParams {
156162
* creating a spa link.
157163
*/
158164
homeserver: string | null;
159-
160165
/**
161166
* The user's intent with respect to the call.
162167
* e.g. if they clicked a Start Call button, this would be `start_call`.
163168
* If it was a Join Call button, it would be `join_existing`.
164169
*/
165170
intent: string | null;
166-
167171
/**
168172
* The rageshake submit URL. This is only used in the embedded package of Element Call.
169173
*/
170174
rageshakeSubmitUrl: string | null;
171-
172175
/**
173176
* The Sentry DSN. This is only used in the embedded package of Element Call.
174177
*/
@@ -281,6 +284,7 @@ export const getUrlParams = (
281284
fontScale: Number.isNaN(fontScale) ? null : fontScale,
282285
allowIceFallback: parser.getFlagParam("allowIceFallback"),
283286
perParticipantE2EE: parser.getFlagParam("perParticipantE2EE"),
287+
controlledOutput: parser.getFlagParam("controlledMediaDevices"),
284288
skipLobby: parser.getFlagParam(
285289
"skipLobby",
286290
isWidget && intent === UserIntent.StartNewCall,

src/controls.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,22 @@ Please see LICENSE in the repository root for full details.
88
import { 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+
setOutputDevices(devices: OutputDevice[]): void;
15+
onOutputDeviceSelect?: (id: string) => void;
16+
setOutputEnabled(enabled: boolean): void;
17+
}
18+
19+
export interface OutputDevice {
20+
id: string;
21+
name: string;
1422
}
1523

1624
export const setPipEnabled$ = new Subject<boolean>();
25+
export const setOutputDevices = new Subject<OutputDevice[]>();
26+
export const setOutputEnabled = new Subject<boolean>();
1727

1828
window.controls = {
1929
canEnterPip(): boolean {
@@ -27,4 +37,14 @@ window.controls = {
2737
if (!setPipEnabled$.observed) throw new Error("No call is running");
2838
setPipEnabled$.next(false);
2939
},
40+
setOutputDevices(devices: OutputDevice[]): void {
41+
if (!setOutputDevices.observed)
42+
throw new Error("Output controls are disabled");
43+
setOutputDevices.next(devices);
44+
},
45+
setOutputEnabled(enabled: boolean): void {
46+
if (!setOutputEnabled.observed)
47+
throw new Error("Output controls are disabled");
48+
setOutputEnabled.next(enabled);
49+
},
3050
};

src/livekit/MediaDevicesContext.tsx

Lines changed: 119 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2023, 2024 New Vector Ltd.
2+
Copyright 2023-2025 New Vector Ltd.
33
44
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
55
Please see LICENSE in the repository root for full details.
@@ -18,7 +18,7 @@ import {
1818
} from "react";
1919
import { createMediaDeviceObserver } from "@livekit/components-core";
2020
import { map, startWith } from "rxjs";
21-
import { useObservableEagerState } from "observable-hooks";
21+
import { useObservable, useObservableEagerState } from "observable-hooks";
2222
import { logger } from "matrix-js-sdk/lib/logger";
2323

2424
import {
@@ -29,6 +29,7 @@ import {
2929
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
3030
type Setting,
3131
} from "../settings/settings";
32+
import { type OutputDevice, setOutputDevices } from "../controls";
3233

3334
export const EARPIECE_CONFIG_ID = "earpiece-id";
3435

@@ -59,12 +60,16 @@ export interface MediaDevice {
5960
select: (deviceId: string) => void;
6061
}
6162

62-
export interface MediaDevices {
63+
interface InputDevices {
6364
audioInput: MediaDevice;
64-
audioOutput: MediaDevice;
6565
videoInput: MediaDevice;
6666
startUsingDeviceNames: () => void;
6767
stopUsingDeviceNames: () => void;
68+
usingNames: boolean;
69+
}
70+
71+
export interface MediaDevices extends Omit<InputDevices, "usingNames"> {
72+
audioOutput: MediaDevice;
6873
}
6974

7075
function useMediaDevice(
@@ -215,11 +220,7 @@ export const devicesStub: MediaDevices = {
215220

216221
export const MediaDevicesContext = createContext<MediaDevices>(devicesStub);
217222

218-
interface Props {
219-
children: JSX.Element;
220-
}
221-
222-
export const MediaDevicesProvider: FC<Props> = ({ children }) => {
223+
function useInputDevices(): InputDevices {
223224
// Counts the number of callers currently using device names.
224225
const [numCallersUsingNames, setNumCallersUsingNames] = useState(0);
225226
const usingNames = numCallersUsingNames > 0;
@@ -229,11 +230,6 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
229230
audioInputSetting,
230231
usingNames,
231232
);
232-
const audioOutput = useMediaDevice(
233-
"audiooutput",
234-
audioOutputSetting,
235-
usingNames,
236-
);
237233
const videoInput = useMediaDevice(
238234
"videoinput",
239235
videoInputSetting,
@@ -249,6 +245,115 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
249245
[setNumCallersUsingNames],
250246
);
251247

248+
return {
249+
audioInput,
250+
videoInput,
251+
startUsingDeviceNames,
252+
stopUsingDeviceNames,
253+
usingNames,
254+
};
255+
}
256+
257+
interface Props {
258+
children: JSX.Element;
259+
}
260+
261+
export const MediaDevicesProvider: FC<Props> = ({ children }) => {
262+
const {
263+
audioInput,
264+
videoInput,
265+
startUsingDeviceNames,
266+
stopUsingDeviceNames,
267+
usingNames,
268+
} = useInputDevices();
269+
270+
const audioOutput = useMediaDevice(
271+
"audiooutput",
272+
audioOutputSetting,
273+
usingNames,
274+
);
275+
276+
const context: MediaDevices = useMemo(
277+
() => ({
278+
audioInput,
279+
audioOutput,
280+
videoInput,
281+
startUsingDeviceNames,
282+
stopUsingDeviceNames,
283+
}),
284+
[
285+
audioInput,
286+
audioOutput,
287+
videoInput,
288+
startUsingDeviceNames,
289+
stopUsingDeviceNames,
290+
],
291+
);
292+
293+
return (
294+
<MediaDevicesContext.Provider value={context}>
295+
{children}
296+
</MediaDevicesContext.Provider>
297+
);
298+
};
299+
300+
function useControlledOutput(): MediaDevice {
301+
const available = useObservableEagerState(
302+
useObservable(() =>
303+
setOutputDevices.pipe(
304+
startWith<OutputDevice[]>([]),
305+
map(
306+
(devices) =>
307+
new Map<string, DeviceLabel>(
308+
devices.map(({ id, name }) => [id, { type: "name", name }]),
309+
),
310+
),
311+
),
312+
),
313+
);
314+
const [preferredId, select] = useSetting(audioOutputSetting);
315+
const selectedId = useMemo(() => {
316+
if (available.size) {
317+
// If the preferred device is available, use it. Or if every available
318+
// device ID is falsy, the browser is probably just being paranoid about
319+
// fingerprinting and we should still try using the preferred device.
320+
// Worst case it is not available and the browser will gracefully fall
321+
// back to some other device for us when requesting the media stream.
322+
// Otherwise, select the first available device.
323+
return (preferredId !== undefined && available.has(preferredId)) ||
324+
(available.size === 1 && available.has(""))
325+
? preferredId
326+
: available.keys().next().value;
327+
}
328+
return undefined;
329+
}, [available, preferredId]);
330+
useEffect(() => {
331+
if (selectedId !== undefined)
332+
window.controls.onOutputDeviceSelect?.(selectedId);
333+
}, [selectedId]);
334+
335+
return useMemo(
336+
() => ({
337+
available,
338+
selectedId,
339+
selectedGroupId: undefined,
340+
select,
341+
}),
342+
[available, selectedId, select],
343+
);
344+
}
345+
346+
export const ControlledOutputMediaDevicesProvider: FC<Props> = ({
347+
children,
348+
}) => {
349+
const {
350+
audioInput,
351+
videoInput,
352+
startUsingDeviceNames,
353+
stopUsingDeviceNames,
354+
} = useInputDevices();
355+
const audioOutput = useControlledOutput();
356+
252357
const context: MediaDevices = useMemo(
253358
() => ({
254359
audioInput,

0 commit comments

Comments
 (0)