Skip to content

Commit 7e32cde

Browse files
toger5robintown
authored andcommitted
Simplify and improve locality of the device name request logic
1 parent 54702e8 commit 7e32cde

File tree

2 files changed

+80
-43
lines changed

2 files changed

+80
-43
lines changed

src/state/MediaDevices.ts

Lines changed: 46 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
availableOutputDevices$ as controlledAvailableOutputDevices$,
3333
} from "../controls";
3434
import { getUrlParams } from "../UrlParams";
35+
import { switchWhen } from "../utils/observable";
3536

3637
// This hardcoded id is used in EX ios! It can only be changed in coordination with
3738
// the ios swift team.
@@ -94,17 +95,29 @@ export const iosDeviceMenu$ = navigator.userAgent.includes("iPhone")
9495

9596
function availableRawDevices$(
9697
kind: MediaDeviceKind,
97-
updateAvailableDeviceRequests$: Observable<boolean>,
98+
usingNames$: Observable<boolean>,
9899
scope: ObservableScope,
99100
): Observable<MediaDeviceInfo[]> {
100-
return updateAvailableDeviceRequests$.pipe(
101-
startWith(false),
102-
switchMap((withPermissions) =>
103-
createMediaDeviceObserver(
104-
kind,
105-
(e) => logger.error("Error creating MediaDeviceObserver", e),
106-
withPermissions,
107-
),
101+
const logError = (e: Error): void =>
102+
logger.error("Error creating MediaDeviceObserver", e);
103+
const devices$ = createMediaDeviceObserver(kind, logError, false);
104+
const devicesWithNames$ = createMediaDeviceObserver(kind, logError, true);
105+
106+
return usingNames$.pipe(
107+
switchMap((withNames) =>
108+
withNames
109+
? // It might be that there is already a media stream running somewhere,
110+
// and so we can do without requesting a second one. Only switch to the
111+
// device observer that explicitly requests the names if we see that
112+
// names are in fact missing from the initial device enumeration.
113+
devices$.pipe(
114+
switchWhen((devices, i) => {
115+
const c = i === 0 && devices.every((d) => !d.label);
116+
if (c) debugger;
117+
return c;
118+
}, devicesWithNames$),
119+
)
120+
: devices$,
108121
),
109122
startWith([]),
110123
scope.state(),
@@ -147,11 +160,7 @@ function selectDevice$<Label>(
147160

148161
class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
149162
private readonly availableRaw$: Observable<MediaDeviceInfo[]> =
150-
availableRawDevices$(
151-
"audioinput",
152-
this.updateAvailableDeviceRequests$,
153-
this.scope,
154-
);
163+
availableRawDevices$("audioinput", this.usingNames$, this.scope);
155164

156165
public readonly available$ = this.availableRaw$.pipe(
157166
map(buildDeviceMap),
@@ -185,7 +194,7 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
185194
}
186195

187196
public constructor(
188-
private readonly updateAvailableDeviceRequests$: Observable<boolean>,
197+
private readonly usingNames$: Observable<boolean>,
189198
private readonly scope: ObservableScope,
190199
) {
191200
this.available$.subscribe((available) => {
@@ -199,7 +208,7 @@ class AudioOutput
199208
{
200209
public readonly available$ = availableRawDevices$(
201210
"audiooutput",
202-
this.updateAvailableDeviceRequests$,
211+
this.usingNames$,
203212
this.scope,
204213
).pipe(
205214
map((availableRaw) => {
@@ -240,7 +249,7 @@ class AudioOutput
240249
}
241250

242251
public constructor(
243-
private readonly updateAvailableDeviceRequests$: Observable<boolean>,
252+
private readonly usingNames$: Observable<boolean>,
244253
private readonly scope: ObservableScope,
245254
) {
246255
this.available$.subscribe((available) => {
@@ -318,7 +327,7 @@ class ControlledAudioOutput
318327
class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
319328
public readonly available$ = availableRawDevices$(
320329
"videoinput",
321-
this.updateAvailableDeviceRequests$,
330+
this.usingNames$,
322331
this.scope,
323332
).pipe(map(buildDeviceMap));
324333

@@ -335,7 +344,7 @@ class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
335344
}
336345

337346
public constructor(
338-
private readonly updateAvailableDeviceRequests$: Observable<boolean>,
347+
private readonly usingNames$: Observable<boolean>,
339348
private readonly scope: ObservableScope,
340349
) {
341350
// This also has the purpose of subscribing to the available devices
@@ -346,48 +355,43 @@ class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
346355
}
347356

348357
export class MediaDevices {
349-
private readonly updateAvailableDeviceRequests$ = new Subject<boolean>();
358+
private readonly deviceNamesRequest$ = new Subject<void>();
350359
/**
351360
* Requests that the media devices be populated with the names of each
352361
* available device, rather than numbered identifiers. This may invoke a
353362
* permissions pop-up, so it should only be called when there is a clear user
354363
* intent to view the device list.
355-
*
356-
* This always updates the `available$` devices for each media type with the current value
357-
* of `enumerateDevices`.
358364
*/
359365
public requestDeviceNames(): void {
360-
void navigator.mediaDevices.enumerateDevices().then((result) => {
361-
// we only actually update the requests$ subject if there are no
362-
// devices with a label, because otherwise we already have the permission
363-
// to access the devices.
364-
this.updateAvailableDeviceRequests$.next(
365-
!result.some((device) => device.label),
366-
);
367-
});
366+
this.deviceNamesRequest$.next();
368367
}
369368

369+
// Start using device names as soon as requested. This will cause LiveKit to
370+
// briefly request device permissions and acquire media streams for each
371+
// device type while calling `enumerateDevices`, which is what browsers want
372+
// you to do to receive device names in lieu of a more explicit permissions
373+
// API. This flag never resets to false, because once permissions are granted
374+
// the first time, the user won't be prompted again until reload of the page.
375+
private readonly usingNames$ = this.deviceNamesRequest$.pipe(
376+
map(() => true),
377+
startWith(false),
378+
this.scope.state(),
379+
);
380+
370381
public readonly audioInput: MediaDevice<
371382
DeviceLabel,
372383
SelectedAudioInputDevice
373-
> = new AudioInput(this.updateAvailableDeviceRequests$, this.scope);
384+
> = new AudioInput(this.usingNames$, this.scope);
374385

375386
public readonly audioOutput: MediaDevice<
376387
AudioOutputDeviceLabel,
377388
SelectedAudioOutputDevice
378389
> = getUrlParams().controlledAudioDevices
379390
? new ControlledAudioOutput(this.scope)
380-
: new AudioOutput(this.updateAvailableDeviceRequests$, this.scope);
391+
: new AudioOutput(this.usingNames$, this.scope);
381392

382393
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
383-
new VideoInput(this.updateAvailableDeviceRequests$, this.scope);
394+
new VideoInput(this.usingNames$, this.scope);
384395

385-
public constructor(private readonly scope: ObservableScope) {
386-
this.updateAvailableDeviceRequests$.subscribe((recompute) => {
387-
logger.info(
388-
"[MediaDevices] updateAvailableDeviceRequests$ changed:",
389-
recompute,
390-
);
391-
});
392-
}
396+
public constructor(private readonly scope: ObservableScope) {}
393397
}

src/utils/observable.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,17 @@ 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 { type Observable, defer, finalize, scan, startWith, tap } from "rxjs";
8+
import {
9+
type Observable,
10+
concat,
11+
defer,
12+
finalize,
13+
map,
14+
scan,
15+
startWith,
16+
takeWhile,
17+
tap,
18+
} from "rxjs";
919

1020
const nothing = Symbol("nothing");
1121

@@ -39,6 +49,29 @@ export function accumulate<State, Event>(
3949
events$.pipe(scan(update, initial), startWith(initial));
4050
}
4151

52+
const switchSymbol = Symbol("switch");
53+
54+
/**
55+
* RxJS operator which behaves like the input Observable (A) until it emits a
56+
* value satisfying the given predicate, then behaves like Observable B.
57+
*
58+
* The switch is immediate; the value that triggers the switch will not be
59+
* present in the output.
60+
*/
61+
export function switchWhen<A, B>(
62+
predicate: (a: A, index: number) => boolean,
63+
b$: Observable<B>,
64+
) {
65+
return (a$: Observable<A>): Observable<A | B> =>
66+
concat(
67+
a$.pipe(
68+
map((a, index) => (predicate(a, index) ? switchSymbol : a)),
69+
takeWhile((a) => a !== switchSymbol),
70+
) as Observable<A>,
71+
b$,
72+
);
73+
}
74+
4275
/**
4376
* Reads the current value of a state Observable without reacting to future
4477
* changes.

0 commit comments

Comments
 (0)