diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 074290f..a66b713 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -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 diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index ce5f225..e58e5cb 100644 --- a/src/room/LobbyView.tsx +++ b/src/room/LobbyView.tsx @@ -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; diff --git a/src/room/OverflowMenu.tsx b/src/room/OverflowMenu.tsx index 0e0c3d9..01312e9 100644 --- a/src/room/OverflowMenu.tsx +++ b/src/room/OverflowMenu.tsx @@ -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; diff --git a/src/room/VideoPreview.tsx b/src/room/VideoPreview.tsx index 8142418..29aa589 100644 --- a/src/room/VideoPreview.tsx +++ b/src/room/VideoPreview.tsx @@ -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 diff --git a/src/room/devices/mediaDevices.ts b/src/room/devices/mediaDevices.ts deleted file mode 100644 index 49b1068..0000000 --- a/src/room/devices/mediaDevices.ts +++ /dev/null @@ -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", -} diff --git a/src/room/devices/useMediaDevices.ts b/src/room/devices/useMediaDevices.ts deleted file mode 100644 index 445bb4f..0000000 --- a/src/room/devices/useMediaDevices.ts +++ /dev/null @@ -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; -} diff --git a/src/room/useLiveKit.ts b/src/room/useLiveKit.ts index 23b6eb8..46ba70f 100644 --- a/src/room/useLiveKit.ts +++ b/src/room/useLiveKit.ts @@ -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; } diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 6d44963..1dc9524 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -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()) } diff --git a/src/settings/mediaDevices.ts b/src/settings/mediaDevices.ts new file mode 100644 index 0000000..5bf33b9 --- /dev/null +++ b/src/settings/mediaDevices.ts @@ -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>; +};