diff --git a/src/App.tsx b/src/App.tsx index a26513a1..71169c29 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,10 +19,7 @@ import { ClientProvider } from "./ClientContext"; import { ErrorPage, LoadingPage } from "./FullScreenView"; import { DisconnectedBanner } from "./DisconnectedBanner"; import { Initializer } from "./initializer"; -import { - ControlledOutputMediaDevicesProvider, - MediaDevicesProvider, -} from "./livekit/MediaDevicesContext"; +import { MediaDevicesProvider } from "./livekit/MediaDevicesContext"; import { widget } from "./widget"; import { useTheme } from "./useTheme"; import { ProcessorProvider } from "./livekit/TrackProcessorContext"; @@ -55,7 +52,6 @@ const ThemeProvider: FC = ({ children }) => { }; export const App: FC = () => { - // const { controlledOutput } = useUrlParams(); const [loaded, setLoaded] = useState(false); useEffect(() => { Initializer.init() @@ -67,20 +63,6 @@ export const App: FC = () => { .catch(logger.error); }); - const inner = ( - } - > - - - } /> - } /> - } /> - } /> - - - ); - return ( // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -92,13 +74,24 @@ export const App: FC = () => { - {true ? ( - - {inner} - - ) : ( - {inner} - )} + + ( + + )} + > + + + } /> + } /> + } + /> + } /> + + + diff --git a/src/livekit/MediaDevicesContext.tsx b/src/livekit/MediaDevicesContext.tsx index d90e9ba4..3a3dd081 100644 --- a/src/livekit/MediaDevicesContext.tsx +++ b/src/livekit/MediaDevicesContext.tsx @@ -17,7 +17,7 @@ import { type JSX, } from "react"; import { createMediaDeviceObserver } from "@livekit/components-core"; -import { map, startWith } from "rxjs"; +import { combineLatest, map, startWith } from "rxjs"; import { useObservable, useObservableEagerState } from "observable-hooks"; import { logger } from "matrix-js-sdk/lib/logger"; @@ -30,6 +30,7 @@ import { type Setting, } from "../settings/settings"; import { type OutputDevice, setOutputDevices$ } from "../controls"; +import { useUrlParams } from "../UrlParams"; export const EARPIECE_CONFIG_ID = "earpiece-id"; @@ -39,7 +40,7 @@ export type DeviceLabel = | { type: "earpiece" } | { type: "default"; name: string | null }; -export interface MediaDevice { +export interface MediaDeviceHandle { /** * A map from available device IDs to labels. */ @@ -61,35 +62,33 @@ export interface MediaDevice { } interface InputDevices { - audioInput: MediaDevice; - videoInput: MediaDevice; + audioInput: MediaDeviceHandle; + videoInput: MediaDeviceHandle; startUsingDeviceNames: () => void; stopUsingDeviceNames: () => void; usingNames: boolean; } export interface MediaDevices extends Omit { - audioOutput: MediaDevice; -} -function useShowEarpiece(): boolean { - const [alwaysShowIphoneEarpice] = useSetting(alwaysShowIphoneEarpieceSetting); - const m = useMemo( - () => - (navigator.userAgent.match("iPhone")?.length ?? 0) > 0 || - alwaysShowIphoneEarpice, - [alwaysShowIphoneEarpice], - ); - return m; + audioOutput: MediaDeviceHandle; } -function useMediaDevice( +/** + * Hook to get access to a mediaDevice handle for a kind. This allows to list + * the available devices, read and set the selected device. + * @param kind audio input, output or video output. + * @param setting The setting this handles selection should be synced with. + * @param usingNames If the hook should query device names for the associated + * list. + * @returns A handle for the choosen kind. + */ +function useMediaDeviceHandle( kind: MediaDeviceKind, setting: Setting, usingNames: boolean, -): MediaDevice { +): MediaDeviceHandle { // Make sure we don't needlessly reset to a device observer without names, // once permissions are already given - const showEarpiece = useShowEarpiece(); const hasRequestedPermissions = useRef(false); const requestPermissions = usingNames || hasRequestedPermissions.current; hasRequestedPermissions.current ||= usingNames; @@ -133,31 +132,23 @@ function useMediaDevice( kind === "audiooutput" && !available.has("") && !available.has("default") && - (available.size || showEarpiece) + available.size ) available = new Map([ ["", { type: "default", name: availableRaw[0]?.label || null }], ...available, ]); - if (kind === "audiooutput" && showEarpiece) - // On IPhones we have to create a virtual earpiece device, because - // the earpiece is not available as a device ID. - available = new Map([ - ...available, - [EARPIECE_CONFIG_ID, { type: "earpiece" }], - ]); // Note: creating virtual default input devices would be another problem // entirely, because requesting a media stream from deviceId "" won't // automatically track the default device. return available; }), ), - [deviceObserver$, kind, showEarpiece], + [deviceObserver$, kind], ), ); - const [preferredId, setPreferredId] = useSetting(setting); - const [asEarpice, setAsEarpiece] = useState(false); + const [preferredId, select] = useSetting(setting); const selectedId = useMemo(() => { if (available.size) { // If the preferred device is available, use it. Or if every available @@ -187,37 +178,26 @@ function useMediaDevice( ), ); - const select = useCallback( - (id: string) => { - if (id === EARPIECE_CONFIG_ID) { - setAsEarpiece(true); - } else { - setAsEarpiece(false); - setPreferredId(id); - } - }, - [setPreferredId], - ); - return useMemo( () => ({ available, selectedId, - useAsEarpiece: asEarpice, + useAsEarpiece: false, selectedGroupId, select, }), - [available, selectedId, asEarpice, selectedGroupId, select], + [available, selectedId, selectedGroupId, select], ); } -export const deviceStub: MediaDevice = { +export const deviceStub: MediaDeviceHandle = { available: new Map(), selectedId: undefined, selectedGroupId: undefined, select: () => {}, useAsEarpiece: false, }; + export const devicesStub: MediaDevices = { audioInput: deviceStub, audioOutput: deviceStub, @@ -233,12 +213,12 @@ function useInputDevices(): InputDevices { const [numCallersUsingNames, setNumCallersUsingNames] = useState(0); const usingNames = numCallersUsingNames > 0; - const audioInput = useMediaDevice( + const audioInput = useMediaDeviceHandle( "audioinput", audioInputSetting, usingNames, ); - const videoInput = useMediaDevice( + const videoInput = useMediaDeviceHandle( "videoinput", videoInputSetting, usingNames, @@ -275,23 +255,30 @@ export const MediaDevicesProvider: FC = ({ children }) => { usingNames, } = useInputDevices(); - const audioOutput = useMediaDevice( + const { controlledOutput } = useUrlParams(); + + const webViewAudioOutput = useMediaDeviceHandle( "audiooutput", audioOutputSetting, usingNames, ); + const controlledAudioOutput = useControlledOutput(); const context: MediaDevices = useMemo( () => ({ audioInput, - audioOutput, + audioOutput: controlledOutput + ? controlledAudioOutput + : webViewAudioOutput, videoInput, startUsingDeviceNames, stopUsingDeviceNames, }), [ audioInput, - audioOutput, + controlledOutput, + controlledAudioOutput, + webViewAudioOutput, videoInput, startUsingDeviceNames, stopUsingDeviceNames, @@ -305,29 +292,36 @@ export const MediaDevicesProvider: FC = ({ children }) => { ); }; -function useControlledOutput(): MediaDevice { - const showEarpiece = useShowEarpiece(); - - const available = useObservableEagerState( - useObservable(() => - setOutputDevices$.pipe( +function useControlledOutput(): MediaDeviceHandle { + const { available, physicalDeviceForEarpiceMode } = useObservableEagerState( + useObservable(() => { + const showEarpice$ = alwaysShowIphoneEarpieceSetting.value$.pipe( + startWith(alwaysShowIphoneEarpieceSetting.getValue()), + map((v) => v || navigator.userAgent.includes("iPhone")), + ); + const outputDeviceData$ = setOutputDevices$.pipe( startWith([]), map((devices) => { - const devicesMap = new Map( - devices.map(({ id, name }) => [id, { type: "name", name }]), + const physicalDeviceForEarpiceMode = devices.find( + (d) => d.forEarpiece, ); - if (showEarpiece) - devicesMap.set(EARPIECE_CONFIG_ID, { type: "earpiece" }); - return devicesMap; + return { + devicesMap: new Map( + devices.map(({ id, name }) => [id, { type: "name", name }]), + ), + physicalDeviceForEarpiceMode, + }; }), - ), - ), - ); - const earpiceDevice = useObservableEagerState( - setOutputDevices$.pipe( - startWith([]), - map((devices) => devices.find((d) => d.forEarpiece)), - ), + ); + + return combineLatest([outputDeviceData$, showEarpice$]).pipe( + map(([{ devicesMap, physicalDeviceForEarpiceMode }, showEarpiece]) => { + if (showEarpiece && !!physicalDeviceForEarpiceMode) + devicesMap.set(EARPIECE_CONFIG_ID, { type: "earpiece" }); + return { available: devicesMap, physicalDeviceForEarpiceMode }; + }), + ); + }), ); const [preferredId, setPreferredId] = useSetting(audioOutputSetting); @@ -348,74 +342,32 @@ function useControlledOutput(): MediaDevice { return undefined; }, [available, preferredId]); - useEffect(() => { - if (selectedId === EARPIECE_CONFIG_ID) - if (selectedId !== undefined) - window.controls.onOutputDeviceSelect?.(selectedId); - }, [selectedId]); - const [asEarpice, setAsEarpiece] = useState(false); - const select = useCallback( - (id: string) => { - if (id === EARPIECE_CONFIG_ID) { - setAsEarpiece(true); - if (earpiceDevice) setPreferredId(earpiceDevice.id); - } else { - setAsEarpiece(false); - setPreferredId(id); - } - }, - [earpiceDevice, setPreferredId], - ); + useEffect(() => { + let selectForController = selectedId; + const earpiece = selectedId === EARPIECE_CONFIG_ID; + + setAsEarpiece(earpiece); + if (earpiece && physicalDeviceForEarpiceMode !== undefined) + selectForController = physicalDeviceForEarpiceMode.id; + + if (selectForController) + window.controls.onOutputDeviceSelect?.(selectForController); + }, [physicalDeviceForEarpiceMode, selectedId]); return useMemo( () => ({ available: available, selectedId, selectedGroupId: undefined, - select, + select: setPreferredId, useAsEarpiece: asEarpice, }), - [available, selectedId, select, asEarpice], + [available, selectedId, setPreferredId, asEarpice], ); } -export const ControlledOutputMediaDevicesProvider: FC = ({ - children, -}) => { - const { - audioInput, - videoInput, - startUsingDeviceNames, - stopUsingDeviceNames, - } = useInputDevices(); - const audioOutput = useControlledOutput(); - - const context: MediaDevices = useMemo( - () => ({ - audioInput, - audioOutput, - videoInput, - startUsingDeviceNames, - stopUsingDeviceNames, - }), - [ - audioInput, - audioOutput, - videoInput, - startUsingDeviceNames, - stopUsingDeviceNames, - ], - ); - - return ( - - {children} - - ); -}; - export const useMediaDevices = (): MediaDevices => useContext(MediaDevicesContext); diff --git a/src/livekit/useLivekit.ts b/src/livekit/useLivekit.ts index 10de9577..6e8da64f 100644 --- a/src/livekit/useLivekit.ts +++ b/src/livekit/useLivekit.ts @@ -25,7 +25,7 @@ import { defaultLiveKitOptions } from "./options"; import { type SFUConfig } from "./openIDSFU"; import { type MuteStates } from "../room/MuteStates"; import { - type MediaDevice, + type MediaDeviceHandle, type MediaDevices, useMediaDevices, } from "./MediaDevicesContext"; @@ -306,7 +306,10 @@ export function useLivekit( useEffect(() => { // Sync the requested devices with LiveKit's devices if (room !== undefined && connectionState === ConnectionState.Connected) { - const syncDevice = (kind: MediaDeviceKind, device: MediaDevice): void => { + const syncDevice = ( + kind: MediaDeviceKind, + device: MediaDeviceHandle, + ): void => { const id = device.selectedId; // Detect if we're trying to use chrome's default device, in which case diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index c85fb61d..f2f4a204 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -25,6 +25,7 @@ import { } from "matrix-js-sdk/lib/matrixrtc"; import { useNavigate } from "react-router-dom"; import { useObservableEagerState } from "observable-hooks"; +import { startWith } from "rxjs"; import type { IWidgetApiRequest } from "matrix-widget-api"; import { @@ -106,7 +107,9 @@ export const GroupCallView: FC = ({ const [externalError, setExternalError] = useState( null, ); - const muteAllAudioControlled = useObservableEagerState(setOutputEnabled$); + const muteAllAudioControlled = useObservableEagerState( + setOutputEnabled$.pipe(startWith(false)), + ); const [muteAllAudioFromSetting] = useSetting(muteAllAudioSetting); const muteAllAudio = muteAllAudioControlled || muteAllAudioFromSetting; const memberships = useMatrixRTCSessionMemberships(rtcSession); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index edfccbe4..cc777693 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -25,7 +25,7 @@ import { import useMeasure from "react-use-measure"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import classNames from "classnames"; -import { BehaviorSubject, map } from "rxjs"; +import { BehaviorSubject, map, startWith } from "rxjs"; import { useObservable, useObservableEagerState } from "observable-hooks"; import { logger } from "matrix-js-sdk/lib/logger"; import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; @@ -223,7 +223,9 @@ export const InCallView: FC = ({ room: livekitRoom, }); - const muteAllAudioControlled = useObservableEagerState(setOutputEnabled$); + const muteAllAudioControlled = useObservableEagerState( + setOutputEnabled$.pipe(startWith(false)), + ); const [muteAllAudioFromSetting] = useSetting(muteAllAudioSetting); const muteAllAudio = muteAllAudioControlled || muteAllAudioFromSetting; diff --git a/src/room/MuteStates.test.tsx b/src/room/MuteStates.test.tsx index eb066603..65e7d333 100644 --- a/src/room/MuteStates.test.tsx +++ b/src/room/MuteStates.test.tsx @@ -14,7 +14,7 @@ import userEvent from "@testing-library/user-event"; import { useMuteStates } from "./MuteStates"; import { type DeviceLabel, - type MediaDevice, + type MediaDeviceHandle, type MediaDevices, MediaDevicesContext, } from "../livekit/MediaDevicesContext"; @@ -73,7 +73,7 @@ const mockCamera: MediaDeviceInfo = { }, }; -function mockDevices(available: Map): MediaDevice { +function mockDevices(available: Map): MediaDeviceHandle { return { available, selectedId: "", diff --git a/src/room/MuteStates.ts b/src/room/MuteStates.ts index e57ba7d5..6e24fb07 100644 --- a/src/room/MuteStates.ts +++ b/src/room/MuteStates.ts @@ -16,7 +16,7 @@ import { type IWidgetApiRequest } from "matrix-widget-api"; import { logger } from "matrix-js-sdk/lib/logger"; import { - type MediaDevice, + type MediaDeviceHandle, useMediaDevices, } from "../livekit/MediaDevicesContext"; import { useReactiveState } from "../useReactiveState"; @@ -53,7 +53,7 @@ export interface MuteStates { } function useMuteState( - device: MediaDevice, + device: MediaDeviceHandle, enabledByDefault: () => boolean, ): MuteState { const [enabled, setEnabled] = useReactiveState( diff --git a/src/settings/DeviceSelection.tsx b/src/settings/DeviceSelection.tsx index 396b1235..aee043c6 100644 --- a/src/settings/DeviceSelection.tsx +++ b/src/settings/DeviceSelection.tsx @@ -24,12 +24,12 @@ import { Trans, useTranslation } from "react-i18next"; import { EARPIECE_CONFIG_ID, - type MediaDevice, + type MediaDeviceHandle, } from "../livekit/MediaDevicesContext"; import styles from "./DeviceSelection.module.css"; interface Props { - device: MediaDevice; + device: MediaDeviceHandle; title: string; numberedLabel: (number: number) => string; }