- `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

@@ -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<InputDevices, "usingNames"> {
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<string | undefined>,
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<Props> = ({ 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<Props> = ({ 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<OutputDevice[]>([]),
map((devices) => {
const devicesMap = new Map<string, DeviceLabel>(
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<string, DeviceLabel>(
devices.map(({ id, name }) => [id, { type: "name", name }]),
),
physicalDeviceForEarpiceMode,
};
}),
),
),
);
const earpiceDevice = useObservableEagerState(
setOutputDevices$.pipe(
startWith<OutputDevice[]>([]),
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<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 =>
useContext(MediaDevicesContext);

View File

@@ -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