diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index eab29262..8172a3ee 100644 --- a/src/room/LobbyView.tsx +++ b/src/room/LobbyView.tsx @@ -184,6 +184,16 @@ export const LobbyView: FC = ({ null) as LocalVideoTrack | null, [tracks], ); + + useEffect(() => { + if (videoTrack && videoInputId === undefined) { + // If we have a video track but no videoInputId, + // we have to update the available devices. So that we select the first + // available video input device as the default instead of the `""` id. + devices.requestDeviceNames(); + } + }, [devices, videoInputId, videoTrack]); + useTrackProcessorSync(videoTrack); const showSwitchCamera = useShowSwitchCamera( useObservable( diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index f441918b..884e1ef7 100644 --- a/src/state/MediaDevices.ts +++ b/src/state/MediaDevices.ts @@ -18,7 +18,7 @@ import { type Observable, } from "rxjs"; import { createMediaDeviceObserver } from "@livekit/components-core"; -import { logger } from "matrix-js-sdk/lib/logger"; +import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { audioInput as audioInputSetting, @@ -33,10 +33,12 @@ import { } from "../controls"; import { getUrlParams } from "../UrlParams"; import { platform } from "../Platform"; +import { switchWhen } from "../utils/observable"; // This hardcoded id is used in EX ios! It can only be changed in coordination with // the ios swift team. const EARPIECE_CONFIG_ID = "earpiece-id"; +const logger = rootLogger.getChild("[MediaDevices]"); export type DeviceLabel = | { type: "name"; name: string } @@ -96,13 +98,25 @@ function availableRawDevices$( usingNames$: Observable, scope: ObservableScope, ): Observable { + const logError = (e: Error): void => + logger.error("Error creating MediaDeviceObserver", e); + const devices$ = createMediaDeviceObserver(kind, logError, false); + const devicesWithNames$ = createMediaDeviceObserver(kind, logError, true); + return usingNames$.pipe( - switchMap((usingNames) => - createMediaDeviceObserver( - kind, - (e) => logger.error("Error creating MediaDeviceObserver", e), - usingNames, - ), + switchMap((withNames) => + withNames + ? // It might be that there is already a media stream running somewhere, + // and so we can do without requesting a second one. Only switch to the + // device observer that explicitly requests the names if we see that + // names are in fact missing from the initial device enumeration. + devices$.pipe( + switchWhen( + (devices, i) => i === 0 && devices.every((d) => !d.label), + devicesWithNames$, + ), + ) + : devices$, ), startWith([]), scope.state(), @@ -181,7 +195,11 @@ class AudioInput implements MediaDevice { public constructor( private readonly usingNames$: Observable, private readonly scope: ObservableScope, - ) {} + ) { + this.available$.subscribe((available) => { + logger.info("[audio-input] available devices:", available); + }); + } } class AudioOutput @@ -232,7 +250,11 @@ class AudioOutput public constructor( private readonly usingNames$: Observable, private readonly scope: ObservableScope, - ) {} + ) { + this.available$.subscribe((available) => { + logger.info("[audio-output] available devices:", available); + }); + } } class ControlledAudioOutput @@ -298,6 +320,9 @@ class ControlledAudioOutput window.controls.onOutputDeviceSelect?.(device.id); } }); + this.available$.subscribe((available) => { + logger.info("[controlled-output] available devices:", available); + }); } } @@ -323,7 +348,12 @@ class VideoInput implements MediaDevice { public constructor( private readonly usingNames$: Observable, private readonly scope: ObservableScope, - ) {} + ) { + // This also has the purpose of subscribing to the available devices + this.available$.subscribe((available) => { + logger.info("[video-input] available devices:", available); + }); + } } export class MediaDevices { diff --git a/src/utils/observable.ts b/src/utils/observable.ts index efda2182..22f7c455 100644 --- a/src/utils/observable.ts +++ b/src/utils/observable.ts @@ -5,7 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type Observable, defer, finalize, scan, startWith, tap } from "rxjs"; +import { + type Observable, + concat, + defer, + finalize, + map, + scan, + startWith, + takeWhile, + tap, +} from "rxjs"; const nothing = Symbol("nothing"); @@ -39,6 +49,29 @@ export function accumulate( events$.pipe(scan(update, initial), startWith(initial)); } +const switchSymbol = Symbol("switch"); + +/** + * RxJS operator which behaves like the input Observable (A) until it emits a + * value satisfying the given predicate, then behaves like Observable B. + * + * The switch is immediate; the value that triggers the switch will not be + * present in the output. + */ +export function switchWhen( + predicate: (a: A, index: number) => boolean, + b$: Observable, +) { + return (a$: Observable): Observable => + concat( + a$.pipe( + map((a, index) => (predicate(a, index) ? switchSymbol : a)), + takeWhile((a) => a !== switchSymbol), + ) as Observable, + b$, + ); +} + /** * Reads the current value of a state Observable without reacting to future * changes.