Use LiveKit's react hooks for devices

More reliable device management.
This commit is contained in:
Daniel Abramov 2023-05-30 20:56:25 +02:00
commit fb9dd7ff71
9 changed files with 229 additions and 250 deletions

View file

@ -63,7 +63,7 @@ import { ParticipantInfo } from "./useGroupCall";
import { TileDescriptor } from "../video-grid/TileDescriptor"; import { TileDescriptor } from "../video-grid/TileDescriptor";
import { AudioSink } from "../video-grid/AudioSink"; import { AudioSink } from "../video-grid/AudioSink";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { MediaDevicesState } from "./devices/useMediaDevices"; import { MediaDevicesState } from "../settings/mediaDevices";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams // There is currently a bug in Safari our our code with cloning and sending MediaStreams

View file

@ -26,7 +26,7 @@ import { UserMenuContainer } from "../UserMenuContainer";
import { Body, Link } from "../typography/Typography"; import { Body, Link } from "../typography/Typography";
import { useLocationNavigation } from "../useLocationNavigation"; import { useLocationNavigation } from "../useLocationNavigation";
import { LocalMediaInfo, MatrixInfo, VideoPreview } from "./VideoPreview"; import { LocalMediaInfo, MatrixInfo, VideoPreview } from "./VideoPreview";
import { MediaDevicesState } from "./devices/useMediaDevices"; import { MediaDevicesState } from "../settings/mediaDevices";
interface Props { interface Props {
matrixInfo: MatrixInfo; matrixInfo: MatrixInfo;

View file

@ -32,7 +32,7 @@ import { InviteModal } from "./InviteModal";
import { TooltipTrigger } from "../Tooltip"; import { TooltipTrigger } from "../Tooltip";
import { FeedbackModal } from "./FeedbackModal"; import { FeedbackModal } from "./FeedbackModal";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { MediaDevicesState } from "./devices/useMediaDevices"; import { MediaDevicesState } from "../settings/mediaDevices";
interface Props { interface Props {
roomId: string; roomId: string;

View file

@ -24,7 +24,7 @@ import { OverflowMenu } from "./OverflowMenu";
import { Avatar } from "../Avatar"; import { Avatar } from "../Avatar";
import styles from "./VideoPreview.module.css"; import styles from "./VideoPreview.module.css";
import { useModalTriggerState } from "../Modal"; import { useModalTriggerState } from "../Modal";
import { MediaDevicesState } from "./devices/useMediaDevices"; import { MediaDevicesState } from "../settings/mediaDevices";
export type MatrixInfo = { export type MatrixInfo = {
userName: string; userName: string;
@ -36,7 +36,7 @@ export type MatrixInfo = {
export type MediaInfo = { export type MediaInfo = {
track: Track; // TODO: Replace it by a more generic `CallFeed` type from JS SDK once we generalise the types. track: Track; // TODO: Replace it by a more generic `CallFeed` type from JS SDK once we generalise the types.
muted: boolean; muted: boolean;
setMuted: (muted: boolean) => void; toggle: () => void;
}; };
export type LocalMediaInfo = { export type LocalMediaInfo = {
@ -67,7 +67,7 @@ export function VideoPreview({
return () => { return () => {
localMediaInfo.video?.track.detach(); localMediaInfo.video?.track.detach();
}; };
}, [localMediaInfo]); }, [localMediaInfo.video?.track, mediaElement]);
return ( return (
<div className={styles.preview} ref={previewRef}> <div className={styles.preview} ref={previewRef}>
@ -86,17 +86,13 @@ export function VideoPreview({
{localMediaInfo.audio && ( {localMediaInfo.audio && (
<MicButton <MicButton
muted={localMediaInfo.audio?.muted} muted={localMediaInfo.audio?.muted}
onPress={() => onPress={localMediaInfo.audio?.toggle}
localMediaInfo.audio?.setMuted(!localMediaInfo.audio?.muted)
}
/> />
)} )}
{localMediaInfo.video && ( {localMediaInfo.video && (
<VideoButton <VideoButton
muted={localMediaInfo.video?.muted} muted={localMediaInfo.video?.muted}
onPress={() => onPress={localMediaInfo.video?.toggle}
localMediaInfo.video?.setMuted(!localMediaInfo.video?.muted)
}
/> />
)} )}
<OverflowMenu <OverflowMenu

View file

@ -1,18 +0,0 @@
import type TypedEmitter from "typed-emitter";
/* This file should become a part of LiveKit JS SDK. */
// Generic interface for all types that are capable of providing and managing media devices.
export interface MediaDevicesManager
extends TypedEmitter<MediaDeviceHandlerCallbacks> {
getDevices(kind: MediaDeviceKind): Promise<MediaDeviceInfo[]>;
setActiveDevice(kind: MediaDeviceKind, deviceId: string): Promise<void>;
}
export type MediaDeviceHandlerCallbacks = {
devicesChanged: () => Promise<void>;
};
export enum MediaDeviceHandlerEvents {
DevicesChanged = "devicesChanged",
}

View file

@ -1,149 +0,0 @@
import { useEffect, useState } from "react";
import { MediaDevicesManager } from "./mediaDevices";
export type MediaDevices = {
available: MediaDeviceInfo[];
selected: number;
};
export type MediaDevicesState = {
state: Map<MediaDeviceKind, MediaDevices>;
selectActiveDevice: (
kind: MediaDeviceKind,
deviceId: string
) => Promise<void>;
};
export function useMediaDevices(
mediaDeviceHandler: MediaDevicesManager
): MediaDevicesState {
// Create a React state to store the available devices and the selected device for each kind.
const [state, setState] = useState<Map<MediaDeviceKind, MediaDevices>>(
new Map()
);
// Update the React state when the available devices change.
useEffect(() => {
// Define a callback that is going to be called each time the available devices change.
const updateDevices = async () => {
const mediaDeviceKinds: MediaDeviceKind[] = [
"audioinput",
"audiooutput",
"videoinput",
];
const newState = new Map(state);
// Request all the available devices for each kind.
for (const kind of mediaDeviceKinds) {
const devices = await mediaDeviceHandler.getDevices(
kind as MediaDeviceKind
);
// If newly requested devices are empty, remove the kind from the React state.
if (devices.length === 0) {
newState.delete(kind);
continue;
}
// Otherwise, check if the current state contains any selected device and find this device in the new list of devices.
// If the device exists, update the React state with the new list of devices and the index of the selected device.
// If the device does not exist, select the first one (default device).
const selectedDevice = state.get(kind);
const newSelectedDeviceIndex = selectedDevice
? devices.findIndex(
(device) =>
device.deviceId ===
selectedDevice.available[selectedDevice.selected].deviceId
)
: 0;
newState.set(kind, {
available: devices,
selected: newSelectedDeviceIndex !== -1 ? newSelectedDeviceIndex : 0,
});
}
if (devicesChanged(state, newState)) {
setState(newState);
}
};
updateDevices();
mediaDeviceHandler.on("devicesChanged", updateDevices);
return () => {
mediaDeviceHandler.off("devicesChanged", updateDevices);
};
}, [mediaDeviceHandler, state]);
const selectActiveDeviceFunc = async (
kind: MediaDeviceKind,
deviceId: string
) => {
await mediaDeviceHandler.setActiveDevice(kind, deviceId);
// Update react state as well.
setState((prevState) => {
const newState = new Map(prevState);
const devices = newState.get(kind);
if (!devices) {
return newState;
}
const newSelectedDeviceIndex = devices.available.findIndex(
(device) => device.deviceId === deviceId
);
newState.set(kind, {
available: devices.available,
selected: newSelectedDeviceIndex,
});
return newState;
});
};
const [selectActiveDevice] = useState<
(kind: MediaDeviceKind, deviceId: string) => Promise<void>
>(selectActiveDeviceFunc);
return {
state,
selectActiveDevice,
};
}
// Determine if any devices changed between the old and new state.
function devicesChanged(
map1: Map<MediaDeviceKind, MediaDevices>,
map2: Map<MediaDeviceKind, MediaDevices>
): boolean {
if (map1.size !== map2.size) {
return true;
}
for (const [key, value] of map1) {
const newValue = map2.get(key);
if (!newValue) {
return true;
}
if (value.selected !== newValue.selected) {
return true;
}
if (value.available.length !== newValue.available.length) {
return true;
}
for (let i = 0; i < value.available.length; i++) {
if (value.available[i].deviceId !== newValue.available[i].deviceId) {
return true;
}
}
}
return false;
}

View file

@ -1,16 +1,9 @@
import { EventEmitter } from "events"; import { LocalAudioTrack, LocalVideoTrack, Room } from "livekit-client";
import { Room, RoomEvent, Track } from "livekit-client";
import React from "react"; import React from "react";
import { useLocalParticipant } from "@livekit/components-react"; import { useMediaDevices, usePreviewDevice } from "@livekit/components-react";
import { import { MediaDevicesState, MediaDevices } from "../settings/mediaDevices";
MediaDeviceHandlerCallbacks,
MediaDeviceHandlerEvents,
MediaDevicesManager,
} from "./devices/mediaDevices";
import { MediaDevicesState, useMediaDevices } from "./devices/useMediaDevices";
import { LocalMediaInfo, MediaInfo } from "./VideoPreview"; import { LocalMediaInfo, MediaInfo } from "./VideoPreview";
import type TypedEmitter from "typed-emitter";
type LiveKitState = { type LiveKitState = {
mediaDevices: MediaDevicesState; mediaDevices: MediaDevicesState;
@ -33,32 +26,35 @@ export function useLiveKit(
return new Room(); return new Room();
}); });
const [mediaDevicesManager] = React.useState<MediaDevicesManager>(() => { // Create a React state to store the available devices and the selected device for each kind.
return new LkMediaDevicesManager(room); const mediaDevices = useMediaDevicesState(room);
});
const { state: mediaDevicesState, selectActiveDevice: selectDeviceFn } = // Create local video track.
useMediaDevices(mediaDevicesManager); const [videoEnabled, setVideoEnabled] = React.useState<boolean>(true);
const selectedVideoId = mediaDevices.state.get("videoinput")?.selectedId;
const video = usePreviewDevice(
videoEnabled,
selectedVideoId ?? "",
"videoinput"
);
React.useEffect(() => { // Create local audio track.
console.log("media devices changed, mediaDevices:", mediaDevicesState); const [audioEnabled, setAudioEnabled] = React.useState<boolean>(true);
}, [mediaDevicesState]); const selectedAudioId = mediaDevices.state.get("audioinput")?.selectedId;
const audio = usePreviewDevice(
const { audioEnabled,
microphoneTrack, selectedAudioId ?? "",
isMicrophoneEnabled, "audioinput"
cameraTrack, );
isCameraEnabled,
localParticipant,
} = useLocalParticipant({ room });
// Create final LiveKit state.
const [state, setState] = React.useState<LiveKitState | undefined>(undefined); const [state, setState] = React.useState<LiveKitState | undefined>(undefined);
React.useEffect(() => { React.useEffect(() => {
// Helper to create local media without the // Helper to create local media without the copy-paste.
const createLocalMedia = ( const createLocalMedia = (
track: LocalVideoTrack | LocalAudioTrack | undefined,
enabled: boolean, enabled: boolean,
track: Track | undefined, setEnabled: React.Dispatch<React.SetStateAction<boolean>>
setEnabled
): MediaInfo | undefined => { ): MediaInfo | undefined => {
if (!track) { if (!track) {
return undefined; return undefined;
@ -67,29 +63,24 @@ export function useLiveKit(
return { return {
track, track,
muted: !enabled, muted: !enabled,
setMuted: async (newState: boolean) => { toggle: async () => {
if (enabled != newState) { setEnabled(!enabled);
await setEnabled(newState);
}
}, },
}; };
}; };
const state: LiveKitState = { const state: LiveKitState = {
mediaDevices: { mediaDevices: mediaDevices,
state: mediaDevicesState,
selectActiveDevice: selectDeviceFn,
},
localMedia: { localMedia: {
audio: createLocalMedia( audio: createLocalMedia(
isMicrophoneEnabled, audio.localTrack,
microphoneTrack?.track, audioEnabled,
localParticipant.setMicrophoneEnabled setAudioEnabled
), ),
video: createLocalMedia( video: createLocalMedia(
isCameraEnabled, video.localTrack,
cameraTrack?.track, videoEnabled,
localParticipant.setCameraEnabled setVideoEnabled
), ),
}, },
enterRoom: async () => { enterRoom: async () => {
@ -105,41 +96,188 @@ export function useLiveKit(
}, [ }, [
url, url,
token, token,
mediaDevices,
audio.localTrack,
video.localTrack,
audioEnabled,
videoEnabled,
room, room,
mediaDevicesState,
selectDeviceFn,
localParticipant,
microphoneTrack,
cameraTrack,
isMicrophoneEnabled,
isCameraEnabled,
]); ]);
return state; return state;
} }
// Implement the MediaDevicesHandler interface for the LiveKit's Room class by wrapping it, so that function useMediaDevicesState(room: Room): MediaDevicesState {
// we can pass the confined version of the `Room` to the `MediaDevicesHandler` consumers. // Video input state.
export class LkMediaDevicesManager const videoInputDevices = useMediaDevices({ kind: "videoinput" });
extends (EventEmitter as new () => TypedEmitter<MediaDeviceHandlerCallbacks>) const [selectedVideoInput, setSelectedVideoInput] =
implements MediaDevicesManager React.useState<string>("");
{
private room: Room;
constructor(room: Room) { // Audio input state.
super(); const audioInputDevices = useMediaDevices({ kind: "audioinput" });
this.room = room; const [selectedAudioInput, setSelectedAudioInput] =
React.useState<string>("");
this.room.on(RoomEvent.MediaDevicesChanged, () => { // Audio output state.
this.emit(MediaDeviceHandlerEvents.DevicesChanged); const audioOutputDevices = useMediaDevices({ kind: "audiooutput" });
const [selectedAudioOut, setSelectedAudioOut] = React.useState<string>("");
// Install hooks, so that we react to changes in the available devices.
React.useEffect(() => {
// Helper type to make the code more readable.
type DeviceHookData = {
kind: MediaDeviceKind;
available: MediaDeviceInfo[];
selected: string;
setSelected: React.Dispatch<React.SetStateAction<string>>;
};
const videoInputHook: DeviceHookData = {
kind: "videoinput",
available: videoInputDevices,
selected: selectedVideoInput,
setSelected: setSelectedVideoInput,
};
const audioInputHook: DeviceHookData = {
kind: "audioinput",
available: audioInputDevices,
selected: selectedAudioInput,
setSelected: setSelectedAudioInput,
};
const audioOutputHook: DeviceHookData = {
kind: "audiooutput",
available: audioOutputDevices,
selected: selectedAudioOut,
setSelected: setSelectedAudioOut,
};
const updateDevice = async (kind: MediaDeviceKind, id: string) => {
try {
await room.switchActiveDevice(kind, id);
} catch (e) {
console.error("Failed to switch device", e);
}
};
for (const hook of [videoInputHook, audioInputHook, audioOutputHook]) {
if (hook.available.length === 0) {
const newSelected = "";
hook.setSelected(newSelected);
updateDevice(hook.kind, newSelected);
continue;
}
const found = hook.available.find(
(device) => device.deviceId === hook.selected
);
if (!found) {
const newSelected = hook.available[0].deviceId;
hook.setSelected(newSelected);
updateDevice(hook.kind, newSelected);
continue;
}
}
}, [
videoInputDevices,
selectedVideoInput,
audioInputDevices,
selectedAudioInput,
audioOutputDevices,
selectedAudioOut,
room,
]);
const selectActiveDevice = async (kind: MediaDeviceKind, id: string) => {
switch (kind) {
case "audioinput":
setSelectedAudioInput(id);
break;
case "videoinput":
setSelectedVideoInput(id);
break;
case "audiooutput":
setSelectedAudioOut(id);
break;
}
};
const [mediaDevicesState, setMediaDevicesState] =
React.useState<MediaDevicesState>(() => {
const state: MediaDevicesState = {
state: new Map(),
selectActiveDevice,
};
return state;
}); });
}
async getDevices(kind: MediaDeviceKind) { React.useEffect(() => {
return await Room.getLocalDevices(kind); // Fill the map of the devices with the current state.
} const mediaDevices = new Map<MediaDeviceKind, MediaDevices>();
mediaDevices.set("audioinput", {
available: audioInputDevices,
selectedId: selectedAudioInput,
});
mediaDevices.set("videoinput", {
available: videoInputDevices,
selectedId: selectedVideoInput,
});
mediaDevices.set("audiooutput", {
available: audioOutputDevices,
selectedId: selectedAudioOut,
});
async setActiveDevice(kind: MediaDeviceKind, deviceId: string) { if (devicesChanged(mediaDevicesState.state, mediaDevices)) {
await this.room.switchActiveDevice(kind, deviceId); const newState: MediaDevicesState = {
} state: mediaDevices,
selectActiveDevice,
};
setMediaDevicesState(newState);
}
}, [
audioInputDevices,
selectedAudioInput,
videoInputDevices,
selectedVideoInput,
audioOutputDevices,
selectedAudioOut,
mediaDevicesState.state,
]);
return mediaDevicesState;
}
// Determine if any devices changed between the old and new state.
function devicesChanged(
map1: Map<MediaDeviceKind, MediaDevices>,
map2: Map<MediaDeviceKind, MediaDevices>
): boolean {
if (map1.size !== map2.size) {
return true;
}
for (const [key, value] of map1) {
const newValue = map2.get(key);
if (!newValue) {
return true;
}
if (value.selectedId !== newValue.selectedId) {
return true;
}
if (value.available.length !== newValue.available.length) {
return true;
}
for (let i = 0; i < value.available.length; i++) {
if (value.available[i].deviceId !== newValue.available[i].deviceId) {
return true;
}
}
}
return false;
} }

View file

@ -26,7 +26,7 @@ import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg"; import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg"; import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg";
import { SelectInput } from "../input/SelectInput"; import { SelectInput } from "../input/SelectInput";
import { MediaDevicesState } from "../room/devices/useMediaDevices"; import { MediaDevicesState } from "./mediaDevices";
import { import {
useKeyboardShortcuts, useKeyboardShortcuts,
useSpatialAudio, useSpatialAudio,
@ -63,7 +63,7 @@ export const SettingsModal = (props: Props) => {
return ( return (
<SelectInput <SelectInput
label={caption} label={caption}
selectedKey={devices.available[devices.selected].deviceId} selectedKey={devices.selectedId}
onSelectionChange={(id) => onSelectionChange={(id) =>
props.mediaDevices.selectActiveDevice(kind, id.toString()) props.mediaDevices.selectActiveDevice(kind, id.toString())
} }

View file

@ -0,0 +1,12 @@
export type MediaDevices = {
available: MediaDeviceInfo[];
selectedId: string;
};
export type MediaDevicesState = {
state: Map<MediaDeviceKind, MediaDevices>;
selectActiveDevice: (
kind: MediaDeviceKind,
deviceId: string
) => Promise<void>;
};