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
parent f4f5c1ed31
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 { AudioSink } from "../video-grid/AudioSink";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { MediaDevicesState } from "./devices/useMediaDevices";
import { MediaDevicesState } from "../settings/mediaDevices";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// 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 { useLocationNavigation } from "../useLocationNavigation";
import { LocalMediaInfo, MatrixInfo, VideoPreview } from "./VideoPreview";
import { MediaDevicesState } from "./devices/useMediaDevices";
import { MediaDevicesState } from "../settings/mediaDevices";
interface Props {
matrixInfo: MatrixInfo;

View file

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

View file

@ -24,7 +24,7 @@ import { OverflowMenu } from "./OverflowMenu";
import { Avatar } from "../Avatar";
import styles from "./VideoPreview.module.css";
import { useModalTriggerState } from "../Modal";
import { MediaDevicesState } from "./devices/useMediaDevices";
import { MediaDevicesState } from "../settings/mediaDevices";
export type MatrixInfo = {
userName: string;
@ -36,7 +36,7 @@ export type MatrixInfo = {
export type MediaInfo = {
track: Track; // TODO: Replace it by a more generic `CallFeed` type from JS SDK once we generalise the types.
muted: boolean;
setMuted: (muted: boolean) => void;
toggle: () => void;
};
export type LocalMediaInfo = {
@ -67,7 +67,7 @@ export function VideoPreview({
return () => {
localMediaInfo.video?.track.detach();
};
}, [localMediaInfo]);
}, [localMediaInfo.video?.track, mediaElement]);
return (
<div className={styles.preview} ref={previewRef}>
@ -86,17 +86,13 @@ export function VideoPreview({
{localMediaInfo.audio && (
<MicButton
muted={localMediaInfo.audio?.muted}
onPress={() =>
localMediaInfo.audio?.setMuted(!localMediaInfo.audio?.muted)
}
onPress={localMediaInfo.audio?.toggle}
/>
)}
{localMediaInfo.video && (
<VideoButton
muted={localMediaInfo.video?.muted}
onPress={() =>
localMediaInfo.video?.setMuted(!localMediaInfo.video?.muted)
}
onPress={localMediaInfo.video?.toggle}
/>
)}
<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 { Room, RoomEvent, Track } from "livekit-client";
import { LocalAudioTrack, LocalVideoTrack, Room } from "livekit-client";
import React from "react";
import { useLocalParticipant } from "@livekit/components-react";
import { useMediaDevices, usePreviewDevice } from "@livekit/components-react";
import {
MediaDeviceHandlerCallbacks,
MediaDeviceHandlerEvents,
MediaDevicesManager,
} from "./devices/mediaDevices";
import { MediaDevicesState, useMediaDevices } from "./devices/useMediaDevices";
import { MediaDevicesState, MediaDevices } from "../settings/mediaDevices";
import { LocalMediaInfo, MediaInfo } from "./VideoPreview";
import type TypedEmitter from "typed-emitter";
type LiveKitState = {
mediaDevices: MediaDevicesState;
@ -33,32 +26,35 @@ export function useLiveKit(
return new Room();
});
const [mediaDevicesManager] = React.useState<MediaDevicesManager>(() => {
return new LkMediaDevicesManager(room);
});
// Create a React state to store the available devices and the selected device for each kind.
const mediaDevices = useMediaDevicesState(room);
const { state: mediaDevicesState, selectActiveDevice: selectDeviceFn } =
useMediaDevices(mediaDevicesManager);
// Create local video track.
const [videoEnabled, setVideoEnabled] = React.useState<boolean>(true);
const selectedVideoId = mediaDevices.state.get("videoinput")?.selectedId;
const video = usePreviewDevice(
videoEnabled,
selectedVideoId ?? "",
"videoinput"
);
React.useEffect(() => {
console.log("media devices changed, mediaDevices:", mediaDevicesState);
}, [mediaDevicesState]);
const {
microphoneTrack,
isMicrophoneEnabled,
cameraTrack,
isCameraEnabled,
localParticipant,
} = useLocalParticipant({ room });
// Create local audio track.
const [audioEnabled, setAudioEnabled] = React.useState<boolean>(true);
const selectedAudioId = mediaDevices.state.get("audioinput")?.selectedId;
const audio = usePreviewDevice(
audioEnabled,
selectedAudioId ?? "",
"audioinput"
);
// Create final LiveKit state.
const [state, setState] = React.useState<LiveKitState | undefined>(undefined);
React.useEffect(() => {
// Helper to create local media without the
// Helper to create local media without the copy-paste.
const createLocalMedia = (
track: LocalVideoTrack | LocalAudioTrack | undefined,
enabled: boolean,
track: Track | undefined,
setEnabled
setEnabled: React.Dispatch<React.SetStateAction<boolean>>
): MediaInfo | undefined => {
if (!track) {
return undefined;
@ -67,29 +63,24 @@ export function useLiveKit(
return {
track,
muted: !enabled,
setMuted: async (newState: boolean) => {
if (enabled != newState) {
await setEnabled(newState);
}
toggle: async () => {
setEnabled(!enabled);
},
};
};
const state: LiveKitState = {
mediaDevices: {
state: mediaDevicesState,
selectActiveDevice: selectDeviceFn,
},
mediaDevices: mediaDevices,
localMedia: {
audio: createLocalMedia(
isMicrophoneEnabled,
microphoneTrack?.track,
localParticipant.setMicrophoneEnabled
audio.localTrack,
audioEnabled,
setAudioEnabled
),
video: createLocalMedia(
isCameraEnabled,
cameraTrack?.track,
localParticipant.setCameraEnabled
video.localTrack,
videoEnabled,
setVideoEnabled
),
},
enterRoom: async () => {
@ -105,41 +96,188 @@ export function useLiveKit(
}, [
url,
token,
mediaDevices,
audio.localTrack,
video.localTrack,
audioEnabled,
videoEnabled,
room,
mediaDevicesState,
selectDeviceFn,
localParticipant,
microphoneTrack,
cameraTrack,
isMicrophoneEnabled,
isCameraEnabled,
]);
return state;
}
// Implement the MediaDevicesHandler interface for the LiveKit's Room class by wrapping it, so that
// we can pass the confined version of the `Room` to the `MediaDevicesHandler` consumers.
export class LkMediaDevicesManager
extends (EventEmitter as new () => TypedEmitter<MediaDeviceHandlerCallbacks>)
implements MediaDevicesManager
{
private room: Room;
function useMediaDevicesState(room: Room): MediaDevicesState {
// Video input state.
const videoInputDevices = useMediaDevices({ kind: "videoinput" });
const [selectedVideoInput, setSelectedVideoInput] =
React.useState<string>("");
constructor(room: Room) {
super();
this.room = room;
// Audio input state.
const audioInputDevices = useMediaDevices({ kind: "audioinput" });
const [selectedAudioInput, setSelectedAudioInput] =
React.useState<string>("");
this.room.on(RoomEvent.MediaDevicesChanged, () => {
this.emit(MediaDeviceHandlerEvents.DevicesChanged);
// Audio output state.
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) {
return await Room.getLocalDevices(kind);
}
React.useEffect(() => {
// 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) {
await this.room.switchActiveDevice(kind, deviceId);
}
if (devicesChanged(mediaDevicesState.state, mediaDevices)) {
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 OverflowIcon } from "../icons/Overflow.svg";
import { SelectInput } from "../input/SelectInput";
import { MediaDevicesState } from "../room/devices/useMediaDevices";
import { MediaDevicesState } from "./mediaDevices";
import {
useKeyboardShortcuts,
useSpatialAudio,
@ -63,7 +63,7 @@ export const SettingsModal = (props: Props) => {
return (
<SelectInput
label={caption}
selectedKey={devices.available[devices.selected].deviceId}
selectedKey={devices.selectedId}
onSelectionChange={(id) =>
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>;
};