- `MediaDevice`->`MediaDeviceHandle`
 - use just one provider and switch inside the
 MediaDevicesProvider between: controlledAudioOutput, webViewAudioOutput
 - fix muteAllAudio
This commit is contained in:
Timo
2025-05-15 15:34:15 +02:00
parent c8091ac111
commit 7fa534d70d
8 changed files with 113 additions and 160 deletions

View File

@@ -19,10 +19,7 @@ import { ClientProvider } from "./ClientContext";
import { ErrorPage, LoadingPage } from "./FullScreenView"; import { ErrorPage, LoadingPage } from "./FullScreenView";
import { DisconnectedBanner } from "./DisconnectedBanner"; import { DisconnectedBanner } from "./DisconnectedBanner";
import { Initializer } from "./initializer"; import { Initializer } from "./initializer";
import { import { MediaDevicesProvider } from "./livekit/MediaDevicesContext";
ControlledOutputMediaDevicesProvider,
MediaDevicesProvider,
} from "./livekit/MediaDevicesContext";
import { widget } from "./widget"; import { widget } from "./widget";
import { useTheme } from "./useTheme"; import { useTheme } from "./useTheme";
import { ProcessorProvider } from "./livekit/TrackProcessorContext"; import { ProcessorProvider } from "./livekit/TrackProcessorContext";
@@ -55,7 +52,6 @@ const ThemeProvider: FC<SimpleProviderProps> = ({ children }) => {
}; };
export const App: FC = () => { export const App: FC = () => {
// const { controlledOutput } = useUrlParams();
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
useEffect(() => { useEffect(() => {
Initializer.init() Initializer.init()
@@ -67,20 +63,6 @@ export const App: FC = () => {
.catch(logger.error); .catch(logger.error);
}); });
const inner = (
<Sentry.ErrorBoundary
fallback={(error) => <ErrorPage error={error} widget={widget} />}
>
<DisconnectedBanner />
<Routes>
<SentryRoute path="/" element={<HomePage />} />
<SentryRoute path="/login" element={<LoginPage />} />
<SentryRoute path="/register" element={<RegisterPage />} />
<SentryRoute path="*" element={<RoomPage />} />
</Routes>
</Sentry.ErrorBoundary>
);
return ( return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
@@ -92,13 +74,24 @@ export const App: FC = () => {
<Suspense fallback={null}> <Suspense fallback={null}>
<ClientProvider> <ClientProvider>
<ProcessorProvider> <ProcessorProvider>
{true ? ( <MediaDevicesProvider>
<ControlledOutputMediaDevicesProvider> <Sentry.ErrorBoundary
{inner} fallback={(error) => (
</ControlledOutputMediaDevicesProvider> <ErrorPage error={error} widget={widget} />
) : ( )}
<MediaDevicesProvider>{inner}</MediaDevicesProvider> >
)} <DisconnectedBanner />
<Routes>
<SentryRoute path="/" element={<HomePage />} />
<SentryRoute path="/login" element={<LoginPage />} />
<SentryRoute
path="/register"
element={<RegisterPage />}
/>
<SentryRoute path="*" element={<RoomPage />} />
</Routes>
</Sentry.ErrorBoundary>
</MediaDevicesProvider>
</ProcessorProvider> </ProcessorProvider>
</ClientProvider> </ClientProvider>
</Suspense> </Suspense>

View File

@@ -17,7 +17,7 @@ import {
type JSX, type JSX,
} from "react"; } from "react";
import { createMediaDeviceObserver } from "@livekit/components-core"; import { createMediaDeviceObserver } from "@livekit/components-core";
import { map, startWith } from "rxjs"; import { combineLatest, map, startWith } from "rxjs";
import { useObservable, useObservableEagerState } from "observable-hooks"; import { useObservable, useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
@@ -30,6 +30,7 @@ import {
type Setting, type Setting,
} from "../settings/settings"; } from "../settings/settings";
import { type OutputDevice, setOutputDevices$ } from "../controls"; import { type OutputDevice, setOutputDevices$ } from "../controls";
import { useUrlParams } from "../UrlParams";
export const EARPIECE_CONFIG_ID = "earpiece-id"; export const EARPIECE_CONFIG_ID = "earpiece-id";
@@ -39,7 +40,7 @@ export type DeviceLabel =
| { type: "earpiece" } | { type: "earpiece" }
| { type: "default"; name: string | null }; | { type: "default"; name: string | null };
export interface MediaDevice { export interface MediaDeviceHandle {
/** /**
* A map from available device IDs to labels. * A map from available device IDs to labels.
*/ */
@@ -61,35 +62,33 @@ export interface MediaDevice {
} }
interface InputDevices { interface InputDevices {
audioInput: MediaDevice; audioInput: MediaDeviceHandle;
videoInput: MediaDevice; videoInput: MediaDeviceHandle;
startUsingDeviceNames: () => void; startUsingDeviceNames: () => void;
stopUsingDeviceNames: () => void; stopUsingDeviceNames: () => void;
usingNames: boolean; usingNames: boolean;
} }
export interface MediaDevices extends Omit<InputDevices, "usingNames"> { export interface MediaDevices extends Omit<InputDevices, "usingNames"> {
audioOutput: MediaDevice; audioOutput: MediaDeviceHandle;
}
function useShowEarpiece(): boolean {
const [alwaysShowIphoneEarpice] = useSetting(alwaysShowIphoneEarpieceSetting);
const m = useMemo(
() =>
(navigator.userAgent.match("iPhone")?.length ?? 0) > 0 ||
alwaysShowIphoneEarpice,
[alwaysShowIphoneEarpice],
);
return m;
} }
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, kind: MediaDeviceKind,
setting: Setting<string | undefined>, setting: Setting<string | undefined>,
usingNames: boolean, usingNames: boolean,
): MediaDevice { ): MediaDeviceHandle {
// Make sure we don't needlessly reset to a device observer without names, // Make sure we don't needlessly reset to a device observer without names,
// once permissions are already given // once permissions are already given
const showEarpiece = useShowEarpiece();
const hasRequestedPermissions = useRef(false); const hasRequestedPermissions = useRef(false);
const requestPermissions = usingNames || hasRequestedPermissions.current; const requestPermissions = usingNames || hasRequestedPermissions.current;
hasRequestedPermissions.current ||= usingNames; hasRequestedPermissions.current ||= usingNames;
@@ -133,31 +132,23 @@ function useMediaDevice(
kind === "audiooutput" && kind === "audiooutput" &&
!available.has("") && !available.has("") &&
!available.has("default") && !available.has("default") &&
(available.size || showEarpiece) available.size
) )
available = new Map([ available = new Map([
["", { type: "default", name: availableRaw[0]?.label || null }], ["", { type: "default", name: availableRaw[0]?.label || null }],
...available, ...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 // Note: creating virtual default input devices would be another problem
// entirely, because requesting a media stream from deviceId "" won't // entirely, because requesting a media stream from deviceId "" won't
// automatically track the default device. // automatically track the default device.
return available; return available;
}), }),
), ),
[deviceObserver$, kind, showEarpiece], [deviceObserver$, kind],
), ),
); );
const [preferredId, setPreferredId] = useSetting(setting); const [preferredId, select] = useSetting(setting);
const [asEarpice, setAsEarpiece] = useState(false);
const selectedId = useMemo(() => { const selectedId = useMemo(() => {
if (available.size) { if (available.size) {
// If the preferred device is available, use it. Or if every available // 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( return useMemo(
() => ({ () => ({
available, available,
selectedId, selectedId,
useAsEarpiece: asEarpice, useAsEarpiece: false,
selectedGroupId, selectedGroupId,
select, select,
}), }),
[available, selectedId, asEarpice, selectedGroupId, select], [available, selectedId, selectedGroupId, select],
); );
} }
export const deviceStub: MediaDevice = { export const deviceStub: MediaDeviceHandle = {
available: new Map(), available: new Map(),
selectedId: undefined, selectedId: undefined,
selectedGroupId: undefined, selectedGroupId: undefined,
select: () => {}, select: () => {},
useAsEarpiece: false, useAsEarpiece: false,
}; };
export const devicesStub: MediaDevices = { export const devicesStub: MediaDevices = {
audioInput: deviceStub, audioInput: deviceStub,
audioOutput: deviceStub, audioOutput: deviceStub,
@@ -233,12 +213,12 @@ function useInputDevices(): InputDevices {
const [numCallersUsingNames, setNumCallersUsingNames] = useState(0); const [numCallersUsingNames, setNumCallersUsingNames] = useState(0);
const usingNames = numCallersUsingNames > 0; const usingNames = numCallersUsingNames > 0;
const audioInput = useMediaDevice( const audioInput = useMediaDeviceHandle(
"audioinput", "audioinput",
audioInputSetting, audioInputSetting,
usingNames, usingNames,
); );
const videoInput = useMediaDevice( const videoInput = useMediaDeviceHandle(
"videoinput", "videoinput",
videoInputSetting, videoInputSetting,
usingNames, usingNames,
@@ -275,23 +255,30 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
usingNames, usingNames,
} = useInputDevices(); } = useInputDevices();
const audioOutput = useMediaDevice( const { controlledOutput } = useUrlParams();
const webViewAudioOutput = useMediaDeviceHandle(
"audiooutput", "audiooutput",
audioOutputSetting, audioOutputSetting,
usingNames, usingNames,
); );
const controlledAudioOutput = useControlledOutput();
const context: MediaDevices = useMemo( const context: MediaDevices = useMemo(
() => ({ () => ({
audioInput, audioInput,
audioOutput, audioOutput: controlledOutput
? controlledAudioOutput
: webViewAudioOutput,
videoInput, videoInput,
startUsingDeviceNames, startUsingDeviceNames,
stopUsingDeviceNames, stopUsingDeviceNames,
}), }),
[ [
audioInput, audioInput,
audioOutput, controlledOutput,
controlledAudioOutput,
webViewAudioOutput,
videoInput, videoInput,
startUsingDeviceNames, startUsingDeviceNames,
stopUsingDeviceNames, stopUsingDeviceNames,
@@ -305,29 +292,36 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
); );
}; };
function useControlledOutput(): MediaDevice { function useControlledOutput(): MediaDeviceHandle {
const showEarpiece = useShowEarpiece(); const { available, physicalDeviceForEarpiceMode } = useObservableEagerState(
useObservable(() => {
const available = useObservableEagerState( const showEarpice$ = alwaysShowIphoneEarpieceSetting.value$.pipe(
useObservable(() => startWith(alwaysShowIphoneEarpieceSetting.getValue()),
setOutputDevices$.pipe( map((v) => v || navigator.userAgent.includes("iPhone")),
);
const outputDeviceData$ = setOutputDevices$.pipe(
startWith<OutputDevice[]>([]), startWith<OutputDevice[]>([]),
map((devices) => { map((devices) => {
const devicesMap = new Map<string, DeviceLabel>( const physicalDeviceForEarpiceMode = devices.find(
devices.map(({ id, name }) => [id, { type: "name", name }]), (d) => d.forEarpiece,
); );
if (showEarpiece) return {
devicesMap.set(EARPIECE_CONFIG_ID, { type: "earpiece" }); devicesMap: new Map<string, DeviceLabel>(
return devicesMap; devices.map(({ id, name }) => [id, { type: "name", name }]),
),
physicalDeviceForEarpiceMode,
};
}), }),
), );
),
); return combineLatest([outputDeviceData$, showEarpice$]).pipe(
const earpiceDevice = useObservableEagerState( map(([{ devicesMap, physicalDeviceForEarpiceMode }, showEarpiece]) => {
setOutputDevices$.pipe( if (showEarpiece && !!physicalDeviceForEarpiceMode)
startWith<OutputDevice[]>([]), devicesMap.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
map((devices) => devices.find((d) => d.forEarpiece)), return { available: devicesMap, physicalDeviceForEarpiceMode };
), }),
);
}),
); );
const [preferredId, setPreferredId] = useSetting(audioOutputSetting); const [preferredId, setPreferredId] = useSetting(audioOutputSetting);
@@ -348,74 +342,32 @@ function useControlledOutput(): MediaDevice {
return undefined; return undefined;
}, [available, preferredId]); }, [available, preferredId]);
useEffect(() => {
if (selectedId === EARPIECE_CONFIG_ID)
if (selectedId !== undefined)
window.controls.onOutputDeviceSelect?.(selectedId);
}, [selectedId]);
const [asEarpice, setAsEarpiece] = useState(false); const [asEarpice, setAsEarpiece] = useState(false);
const select = useCallback( useEffect(() => {
(id: string) => { let selectForController = selectedId;
if (id === EARPIECE_CONFIG_ID) { const earpiece = selectedId === EARPIECE_CONFIG_ID;
setAsEarpiece(true);
if (earpiceDevice) setPreferredId(earpiceDevice.id); setAsEarpiece(earpiece);
} else { if (earpiece && physicalDeviceForEarpiceMode !== undefined)
setAsEarpiece(false); selectForController = physicalDeviceForEarpiceMode.id;
setPreferredId(id);
} if (selectForController)
}, window.controls.onOutputDeviceSelect?.(selectForController);
[earpiceDevice, setPreferredId], }, [physicalDeviceForEarpiceMode, selectedId]);
);
return useMemo( return useMemo(
() => ({ () => ({
available: available, available: available,
selectedId, selectedId,
selectedGroupId: undefined, selectedGroupId: undefined,
select, select: setPreferredId,
useAsEarpiece: asEarpice, useAsEarpiece: asEarpice,
}), }),
[available, selectedId, select, asEarpice], [available, selectedId, setPreferredId, asEarpice],
); );
} }
export const ControlledOutputMediaDevicesProvider: FC<Props> = ({
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 (
<MediaDevicesContext.Provider value={context}>
{children}
</MediaDevicesContext.Provider>
);
};
export const useMediaDevices = (): MediaDevices => export const useMediaDevices = (): MediaDevices =>
useContext(MediaDevicesContext); useContext(MediaDevicesContext);

View File

@@ -25,7 +25,7 @@ import { defaultLiveKitOptions } from "./options";
import { type SFUConfig } from "./openIDSFU"; import { type SFUConfig } from "./openIDSFU";
import { type MuteStates } from "../room/MuteStates"; import { type MuteStates } from "../room/MuteStates";
import { import {
type MediaDevice, type MediaDeviceHandle,
type MediaDevices, type MediaDevices,
useMediaDevices, useMediaDevices,
} from "./MediaDevicesContext"; } from "./MediaDevicesContext";
@@ -306,7 +306,10 @@ export function useLivekit(
useEffect(() => { useEffect(() => {
// Sync the requested devices with LiveKit's devices // Sync the requested devices with LiveKit's devices
if (room !== undefined && connectionState === ConnectionState.Connected) { if (room !== undefined && connectionState === ConnectionState.Connected) {
const syncDevice = (kind: MediaDeviceKind, device: MediaDevice): void => { const syncDevice = (
kind: MediaDeviceKind,
device: MediaDeviceHandle,
): void => {
const id = device.selectedId; const id = device.selectedId;
// Detect if we're trying to use chrome's default device, in which case // Detect if we're trying to use chrome's default device, in which case

View File

@@ -25,6 +25,7 @@ import {
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import { startWith } from "rxjs";
import type { IWidgetApiRequest } from "matrix-widget-api"; import type { IWidgetApiRequest } from "matrix-widget-api";
import { import {
@@ -106,7 +107,9 @@ export const GroupCallView: FC<Props> = ({
const [externalError, setExternalError] = useState<ElementCallError | null>( const [externalError, setExternalError] = useState<ElementCallError | null>(
null, null,
); );
const muteAllAudioControlled = useObservableEagerState(setOutputEnabled$); const muteAllAudioControlled = useObservableEagerState(
setOutputEnabled$.pipe(startWith(false)),
);
const [muteAllAudioFromSetting] = useSetting(muteAllAudioSetting); const [muteAllAudioFromSetting] = useSetting(muteAllAudioSetting);
const muteAllAudio = muteAllAudioControlled || muteAllAudioFromSetting; const muteAllAudio = muteAllAudioControlled || muteAllAudioFromSetting;
const memberships = useMatrixRTCSessionMemberships(rtcSession); const memberships = useMatrixRTCSessionMemberships(rtcSession);

View File

@@ -25,7 +25,7 @@ import {
import useMeasure from "react-use-measure"; import useMeasure from "react-use-measure";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import classNames from "classnames"; import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs"; import { BehaviorSubject, map, startWith } from "rxjs";
import { useObservable, useObservableEagerState } from "observable-hooks"; import { useObservable, useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
@@ -223,7 +223,9 @@ export const InCallView: FC<InCallViewProps> = ({
room: livekitRoom, room: livekitRoom,
}); });
const muteAllAudioControlled = useObservableEagerState(setOutputEnabled$); const muteAllAudioControlled = useObservableEagerState(
setOutputEnabled$.pipe(startWith(false)),
);
const [muteAllAudioFromSetting] = useSetting(muteAllAudioSetting); const [muteAllAudioFromSetting] = useSetting(muteAllAudioSetting);
const muteAllAudio = muteAllAudioControlled || muteAllAudioFromSetting; const muteAllAudio = muteAllAudioControlled || muteAllAudioFromSetting;

View File

@@ -14,7 +14,7 @@ import userEvent from "@testing-library/user-event";
import { useMuteStates } from "./MuteStates"; import { useMuteStates } from "./MuteStates";
import { import {
type DeviceLabel, type DeviceLabel,
type MediaDevice, type MediaDeviceHandle,
type MediaDevices, type MediaDevices,
MediaDevicesContext, MediaDevicesContext,
} from "../livekit/MediaDevicesContext"; } from "../livekit/MediaDevicesContext";
@@ -73,7 +73,7 @@ const mockCamera: MediaDeviceInfo = {
}, },
}; };
function mockDevices(available: Map<string, DeviceLabel>): MediaDevice { function mockDevices(available: Map<string, DeviceLabel>): MediaDeviceHandle {
return { return {
available, available,
selectedId: "", selectedId: "",

View File

@@ -16,7 +16,7 @@ import { type IWidgetApiRequest } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { import {
type MediaDevice, type MediaDeviceHandle,
useMediaDevices, useMediaDevices,
} from "../livekit/MediaDevicesContext"; } from "../livekit/MediaDevicesContext";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
@@ -53,7 +53,7 @@ export interface MuteStates {
} }
function useMuteState( function useMuteState(
device: MediaDevice, device: MediaDeviceHandle,
enabledByDefault: () => boolean, enabledByDefault: () => boolean,
): MuteState { ): MuteState {
const [enabled, setEnabled] = useReactiveState<boolean | undefined>( const [enabled, setEnabled] = useReactiveState<boolean | undefined>(

View File

@@ -24,12 +24,12 @@ import { Trans, useTranslation } from "react-i18next";
import { import {
EARPIECE_CONFIG_ID, EARPIECE_CONFIG_ID,
type MediaDevice, type MediaDeviceHandle,
} from "../livekit/MediaDevicesContext"; } from "../livekit/MediaDevicesContext";
import styles from "./DeviceSelection.module.css"; import styles from "./DeviceSelection.module.css";
interface Props { interface Props {
device: MediaDevice; device: MediaDeviceHandle;
title: string; title: string;
numberedLabel: (number: number) => string; numberedLabel: (number: number) => string;
} }