LiveKit Device Usage Refactoring (#1120)

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Timo K <toger5@hotmail.de>
This commit is contained in:
Daniel Abramov 2023-06-16 18:07:13 +02:00 committed by GitHub
parent 4342f4b027
commit 5b4787cef6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 278 additions and 503 deletions

View file

@ -22,17 +22,11 @@ import { useProfile } from "./profile/useProfile";
import { useModalTriggerState } from "./Modal";
import { SettingsModal } from "./settings/SettingsModal";
import { UserMenu } from "./UserMenu";
import { MediaDevicesState } from "./settings/mediaDevices";
interface Props {
preventNavigation?: boolean;
}
const mediaDevicesStub: MediaDevicesState = {
state: new Map(),
selectActiveDevice: () => Promise.resolve(),
};
export function UserMenuContainer({ preventNavigation = false }: Props) {
const location = useLocation();
const history = useHistory();
@ -81,9 +75,6 @@ export function UserMenuContainer({ preventNavigation = false }: Props) {
<SettingsModal
client={client}
defaultTab={defaultSettingsTab}
// TODO Replace this with real media devices, while making sure this
// doesn't cause unnecessary device permission pop-ups
mediaDevices={mediaDevicesStub}
{...modalProps}
/>
)}

View file

@ -8,7 +8,7 @@ import {
VideoPresets,
} from "livekit-client";
const publishOptions: TrackPublishDefaults = {
const defaultLiveKitPublishOptions: TrackPublishDefaults = {
audioPreset: AudioPresets.music,
dtx: true,
red: true,
@ -22,7 +22,7 @@ const publishOptions: TrackPublishDefaults = {
backupCodec: { codec: "vp8", encoding: VideoPresets.h360.encoding },
} as const;
export const roomOptions: RoomOptions = {
export const defaultLiveKitOptions: RoomOptions = {
// automatically manage subscribed video quality
adaptiveStream: true,
@ -35,7 +35,7 @@ export const roomOptions: RoomOptions = {
},
// publish settings
publishDefaults: publishOptions,
publishDefaults: defaultLiveKitPublishOptions,
// default LiveKit options that seem to be sane
stopLocalTrackOnUnpublish: true,

View file

@ -1,231 +1,62 @@
import {
ConnectionState,
LocalAudioTrack,
LocalVideoTrack,
Room,
} from "livekit-client";
import { Room, RoomOptions } from "livekit-client";
import { useLiveKitRoom, useToken } from "@livekit/components-react";
import React from "react";
import {
useMediaDeviceSelect,
usePreviewDevice,
} from "@livekit/components-react";
import { MediaDevicesState, MediaDevices } from "../settings/mediaDevices";
import { LocalMediaInfo, MediaInfo } from "../room/VideoPreview";
import { roomOptions } from "./options";
import { useDefaultDevices } from "../settings/useSetting";
import { defaultLiveKitOptions } from "./options";
type LiveKitState = {
// The state of the media devices (changing the devices will also change them in the room).
mediaDevices: MediaDevicesState;
// The local media (audio and video) that can be referenced in an e.g. lobby view.
localMedia: LocalMediaInfo;
// A reference to the newly constructed (but not yet entered) room for future use with the LiveKit hooks.
// TODO: Abstract this away, so that the user doesn't have to deal with the LiveKit room directly.
room: Room;
export type UserChoices = {
audio?: DeviceChoices;
video?: DeviceChoices;
};
function emptyToUndef(str) {
return str === "" ? undefined : str;
}
export type DeviceChoices = {
selectedId: string;
enabled: boolean;
};
// Returns the React state for the LiveKit's Room class.
// The actual return type should be `LiveKitState`, but since this is a React hook, the initialisation is
// delayed (done after the rendering, not during the rendering), because of that this function may return `undefined`.
// But soon this state is changed to the actual `LiveKitState` value.
export function useLiveKit(): LiveKitState | undefined {
// TODO: Pass the proper paramters to configure the room (supported codecs, simulcast, adaptive streaming, etc).
const [room] = React.useState<Room>(() => {
return new Room(roomOptions);
});
export type LiveKitConfig = {
sfuUrl: string;
jwtUrl: string;
roomName: string;
userName: string;
userIdentity: string;
};
// Create a React state to store the available devices and the selected device for each kind.
const mediaDevices = useMediaDevicesState(room);
const [settingsDefaultDevices] = useDefaultDevices();
const [videoEnabled, setVideoEnabled] = React.useState<boolean>(true);
const selectedVideoId = mediaDevices.state.get("videoinput")?.selectedId;
const [audioEnabled, setAudioEnabled] = React.useState<boolean>(true);
const selectedAudioId = mediaDevices.state.get("audioinput")?.selectedId;
// trigger permission popup first,
// useEffect(() => {
// navigator.mediaDevices.getUserMedia({
// video: { deviceId: selectedVideoId ?? settingsDefaultDevices.videoinput },
// audio: { deviceId: selectedAudioId ?? settingsDefaultDevices.audioinput },
// });
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, []);
// then start the preview device (no permission should be triggered agian)
// Create local video track.
const video = usePreviewDevice(
videoEnabled,
selectedVideoId ?? settingsDefaultDevices.videoinput,
"videoinput"
);
// Create local audio track.
const audio = usePreviewDevice(
audioEnabled,
selectedAudioId ?? settingsDefaultDevices.audioinput,
"audioinput"
);
// Create final LiveKit state.
const [state, setState] = React.useState<LiveKitState | undefined>(undefined);
React.useEffect(() => {
// Helper to create local media without the copy-paste.
const createLocalMedia = (
track: LocalVideoTrack | LocalAudioTrack | undefined,
enabled: boolean,
setEnabled: React.Dispatch<React.SetStateAction<boolean>>
): MediaInfo | undefined => {
if (!track) {
return undefined;
}
return {
track,
muted: !enabled,
toggle: async () => {
setEnabled(!enabled);
},
};
};
const state: LiveKitState = {
mediaDevices: mediaDevices,
localMedia: {
audio: createLocalMedia(
audio.localTrack,
audioEnabled,
setAudioEnabled
),
video: createLocalMedia(
video.localTrack,
videoEnabled,
setVideoEnabled
),
export function useLiveKit(
userChoices: UserChoices,
config: LiveKitConfig
): Room | undefined {
const tokenOptions = React.useMemo(
() => ({
userInfo: {
name: config.userName,
identity: config.userIdentity,
},
room,
};
setState(state);
}, [
mediaDevices,
audio.localTrack,
video.localTrack,
audioEnabled,
videoEnabled,
room,
]);
return state;
}
// if a room is passed this only affects the device selection inside a call. Without room it changes what we see in the lobby
function useMediaDevicesState(room: Room): MediaDevicesState {
let connectedRoom: Room;
if (room.state !== ConnectionState.Disconnected) {
connectedRoom = room;
}
const {
devices: videoDevices,
activeDeviceId: activeVideoDevice,
setActiveMediaDevice: setActiveVideoDevice,
} = useMediaDeviceSelect({ kind: "videoinput", room: connectedRoom });
const {
devices: audioDevices,
activeDeviceId: activeAudioDevice,
setActiveMediaDevice: setActiveAudioDevice,
} = useMediaDeviceSelect({
kind: "audioinput",
room: connectedRoom,
});
const {
devices: audioOutputDevices,
activeDeviceId: activeAudioOutputDevice,
setActiveMediaDevice: setActiveAudioOutputDevice,
} = useMediaDeviceSelect({
kind: "audiooutput",
room: connectedRoom,
});
const selectActiveDevice = React.useCallback(
async (kind: MediaDeviceKind, id: string) => {
switch (kind) {
case "audioinput":
setActiveAudioDevice(id);
break;
case "videoinput":
setActiveVideoDevice(id);
break;
case "audiooutput":
setActiveAudioOutputDevice(id);
break;
}
},
[setActiveVideoDevice, setActiveAudioOutputDevice, setActiveAudioDevice]
}),
[config.userName, config.userIdentity]
);
const token = useToken(config.jwtUrl, config.roomName, tokenOptions);
const [mediaDevicesState, setMediaDevicesState] =
React.useState<MediaDevicesState>(() => {
const state: MediaDevicesState = {
state: new Map(),
selectActiveDevice,
};
return state;
});
const roomOptions = React.useMemo((): RoomOptions => {
const options = defaultLiveKitOptions;
options.videoCaptureDefaults = {
...options.videoCaptureDefaults,
deviceId: userChoices.video?.selectedId,
};
options.audioCaptureDefaults = {
...options.audioCaptureDefaults,
deviceId: userChoices.audio?.selectedId,
};
return options;
}, [userChoices.video, userChoices.audio]);
const [settingsDefaultDevices, setDefaultDevices] = useDefaultDevices();
const { room } = useLiveKitRoom({
token,
serverUrl: config.sfuUrl,
audio: userChoices.audio?.enabled ?? false,
video: userChoices.video?.enabled ?? false,
options: roomOptions,
});
React.useEffect(() => {
const state = new Map<MediaDeviceKind, MediaDevices>();
state.set("videoinput", {
available: videoDevices,
selectedId:
emptyToUndef(activeVideoDevice) ??
emptyToUndef(settingsDefaultDevices.videoinput) ??
videoDevices[0]?.deviceId,
});
state.set("audioinput", {
available: audioDevices,
selectedId:
emptyToUndef(activeAudioDevice) ??
emptyToUndef(settingsDefaultDevices.audioinput) ??
audioDevices[0]?.deviceId,
});
state.set("audiooutput", {
available: audioOutputDevices,
selectedId:
emptyToUndef(activeAudioOutputDevice) ??
emptyToUndef(settingsDefaultDevices.audiooutput) ??
audioOutputDevices[0]?.deviceId,
});
setDefaultDevices({
audioinput: state.get("audioinput").selectedId,
videoinput: state.get("videoinput").selectedId,
audiooutput: state.get("audiooutput").selectedId,
});
setMediaDevicesState({
state,
selectActiveDevice,
});
}, [
videoDevices,
activeVideoDevice,
audioDevices,
activeAudioDevice,
audioOutputDevices,
activeAudioOutputDevice,
selectActiveDevice,
setDefaultDevices,
settingsDefaultDevices.audioinput,
settingsDefaultDevices.videoinput,
settingsDefaultDevices.audiooutput,
]);
return mediaDevicesState;
return room;
}

View file

@ -0,0 +1,90 @@
import { useMediaDeviceSelect } from "@livekit/components-react";
import { Room } from "livekit-client";
import { useEffect } from "react";
import { useDefaultDevices } from "../settings/useSetting";
export type MediaDevices = {
available: MediaDeviceInfo[];
selectedId: string;
setSelected: (deviceId: string) => Promise<void>;
};
export type MediaDevicesState = {
audioIn: MediaDevices;
audioOut: MediaDevices;
videoIn: MediaDevices;
};
// if a room is passed this only affects the device selection inside a call. Without room it changes what we see in the lobby
export function useMediaDevices(room?: Room): MediaDevicesState {
const {
devices: videoDevices,
activeDeviceId: activeVideoDevice,
setActiveMediaDevice: setActiveVideoDevice,
} = useMediaDeviceSelect({ kind: "videoinput", room });
const {
devices: audioDevices,
activeDeviceId: activeAudioDevice,
setActiveMediaDevice: setActiveAudioDevice,
} = useMediaDeviceSelect({
kind: "audioinput",
room,
});
const {
devices: audioOutputDevices,
activeDeviceId: activeAudioOutputDevice,
setActiveMediaDevice: setActiveAudioOutputDevice,
} = useMediaDeviceSelect({
kind: "audiooutput",
room,
});
const [settingsDefaultDevices, setSettingsDefaultDevices] =
useDefaultDevices();
useEffect(() => {
setSettingsDefaultDevices({
audioinput:
activeAudioDevice != ""
? activeAudioDevice
: settingsDefaultDevices.audioinput,
videoinput:
activeVideoDevice != ""
? activeVideoDevice
: settingsDefaultDevices.videoinput,
audiooutput:
activeAudioOutputDevice != ""
? activeAudioOutputDevice
: settingsDefaultDevices.audiooutput,
});
}, [
activeAudioDevice,
activeAudioOutputDevice,
activeVideoDevice,
setSettingsDefaultDevices,
settingsDefaultDevices.audioinput,
settingsDefaultDevices.audiooutput,
settingsDefaultDevices.videoinput,
]);
return {
audioIn: {
available: audioDevices,
selectedId: activeAudioDevice,
setSelected: setActiveAudioDevice,
},
audioOut: {
available: audioOutputDevices,
selectedId: activeAudioOutputDevice,
setSelected: setActiveAudioOutputDevice,
},
videoIn: {
available: videoDevices,
selectedId: activeVideoDevice,
setSelected: setActiveVideoDevice,
},
};
}

View file

@ -26,12 +26,12 @@ import { useGroupCall } from "./useGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView";
import { LobbyView } from "./LobbyView";
import { MatrixInfo } from "./VideoPreview";
import { InCallView } from "./InCallView";
import { ActiveCall } from "./InCallView";
import { CallEndedView } from "./CallEndedView";
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useProfile } from "../profile/useProfile";
import { useLiveKit } from "../livekit/useLiveKit";
import { UserChoices } from "../livekit/useLiveKit";
declare global {
interface Window {
@ -86,8 +86,6 @@ export function GroupCallView({
roomIdOrAlias,
};
const lkState = useLiveKit();
useEffect(() => {
if (widget && preload) {
// In preload mode, wait for a join action before entering
@ -174,11 +172,15 @@ export function GroupCallView({
}
}, [groupCall, state, leave]);
const [userChoices, setUserChoices] = useState<UserChoices | undefined>(
undefined
);
if (error) {
return <ErrorView error={error} />;
} else if (state === GroupCallState.Entered) {
} else if (state === GroupCallState.Entered && userChoices) {
return (
<InCallView
<ActiveCall
groupCall={groupCall}
client={client}
participants={participants}
@ -186,12 +188,7 @@ export function GroupCallView({
unencryptedEventsFromUsers={unencryptedEventsFromUsers}
hideHeader={hideHeader}
matrixInfo={matrixInfo}
mediaDevices={lkState.mediaDevices}
livekitRoom={lkState.room}
userChoices={{
videoMuted: lkState?.localMedia.video?.muted ?? true,
audioMuted: lkState?.localMedia.audio?.muted ?? true,
}}
userChoices={userChoices}
otelGroupCallMembership={otelGroupCallMembership}
/>
);
@ -227,18 +224,17 @@ export function GroupCallView({
<h1>{t("Loading…")}</h1>
</FullScreenView>
);
} else if (lkState) {
} else {
return (
<LobbyView
matrixInfo={matrixInfo}
mediaDevices={lkState.mediaDevices}
localMedia={lkState.localMedia}
onEnter={enter}
onEnter={(choices: UserChoices) => {
setUserChoices(choices);
enter();
}}
isEmbedded={isEmbedded}
hideHeader={hideHeader}
/>
);
} else {
return null;
}
}

View file

@ -18,7 +18,6 @@ import { ResizeObserver } from "@juggle/resize-observer";
import {
useLocalParticipant,
useParticipants,
useToken,
useTracks,
} from "@livekit/components-react";
import { usePreventScroll } from "@react-aria/overlays";
@ -58,7 +57,6 @@ import { useShowInspector } from "../settings/useSetting";
import { useModalTriggerState } from "../Modal";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useUrlParams } from "../UrlParams";
import { MediaDevicesState } from "../settings/mediaDevices";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { ElementWidgetActions, widget } from "../widget";
@ -77,7 +75,8 @@ import { InviteModal } from "./InviteModal";
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { VideoTile } from "../video-grid/VideoTile";
import { useRoom } from "./useRoom";
import { UserChoices, useLiveKit } from "../livekit/useLiveKit";
import { useMediaDevices } from "../livekit/useMediaDevices";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
@ -85,46 +84,43 @@ const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// For now we can disable screensharing in Safari.
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const onConnectedCallback = (): void => {
console.log("connected to LiveKit room");
};
const onDisconnectedCallback = (): void => {
console.log("disconnected from LiveKit room");
};
const onErrorCallback = (err: Error): void => {
console.error("error connecting to LiveKit room", err);
};
interface ActiveCallProps extends Omit<Props, "livekitRoom"> {
userChoices: UserChoices;
}
interface LocalUserChoices {
videoMuted: boolean;
audioMuted: boolean;
export function ActiveCall(props: ActiveCallProps) {
const livekitRoom = useLiveKit(props.userChoices, {
sfuUrl: Config.get().livekit!.server_url,
jwtUrl: `${Config.get().livekit!.jwt_service_url}/token`,
roomName: props.matrixInfo.roomName,
userName: props.matrixInfo.userName,
userIdentity: `${props.client.getUserId()}:${props.client.getDeviceId()}`,
});
return livekitRoom && <InCallView {...props} livekitRoom={livekitRoom} />;
}
interface Props {
client: MatrixClient;
groupCall: GroupCall;
livekitRoom: Room;
participants: Map<RoomMember, Map<string, ParticipantInfo>>;
onLeave: () => void;
unencryptedEventsFromUsers: Set<string>;
hideHeader: boolean;
matrixInfo: MatrixInfo;
mediaDevices: MediaDevicesState;
livekitRoom: Room;
userChoices: LocalUserChoices;
otelGroupCallMembership: OTelGroupCallMembership;
}
export function InCallView({
client,
groupCall,
livekitRoom,
participants,
onLeave,
unencryptedEventsFromUsers,
hideHeader,
matrixInfo,
mediaDevices,
livekitRoom,
userChoices,
otelGroupCallMembership,
}: Props) {
const { t } = useTranslation();
@ -142,41 +138,8 @@ export function InCallView({
[containerRef1, containerRef2]
);
const userId = client.getUserId();
const deviceId = client.getDeviceId();
const options = useMemo(
() => ({
userInfo: {
name: matrixInfo.userName,
identity: `${userId}:${deviceId}`,
},
}),
[matrixInfo.userName, userId, deviceId]
);
const token = useToken(
`${Config.get().livekit.jwt_service_url}/token`,
matrixInfo.roomName,
options
);
// TODO: move the room creation into the useRoom hook and out of the useLiveKit hook.
// This would than allow to not have those 4 lines
livekitRoom.options.audioCaptureDefaults.deviceId =
mediaDevices.state.get("audioinput").selectedId;
livekitRoom.options.videoCaptureDefaults.deviceId =
mediaDevices.state.get("videoinput").selectedId;
// Uses a hook to connect to the LiveKit room (on unmount the room will be left) and publish local media tracks (default).
useRoom({
token,
serverUrl: Config.get().livekit.server_url,
room: livekitRoom,
audio: !userChoices.audioMuted,
video: !userChoices.videoMuted,
simulateParticipants: 10,
onConnected: onConnectedCallback,
onDisconnected: onDisconnectedCallback,
onError: onErrorCallback,
});
// Managed media devices state coupled with an active room.
const roomMediaDevices = useMediaDevices(livekitRoom);
const screenSharingTracks = useTracks(
[{ source: Track.Source.ScreenShare, withPlaceholder: false }],
@ -211,6 +174,8 @@ export function InCallView({
const joinRule = useJoinRule(groupCall.room);
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
useCallViewKeyboardShortcuts(
containerRef1,
toggleMicrophone,
@ -436,7 +401,7 @@ export function InCallView({
<SettingsModal
client={client}
roomId={groupCall.room.roomId}
mediaDevices={mediaDevices}
mediaDevices={roomMediaDevices}
{...settingsModalProps}
/>
)}

View file

@ -15,7 +15,6 @@ limitations under the License.
*/
import React from "react";
import { PressEvent } from "@react-types/shared";
import { Trans, useTranslation } from "react-i18next";
import styles from "./LobbyView.module.css";
@ -25,15 +24,13 @@ import { getRoomUrl } from "../matrix-utils";
import { UserMenuContainer } from "../UserMenuContainer";
import { Body, Link } from "../typography/Typography";
import { useLocationNavigation } from "../useLocationNavigation";
import { LocalMediaInfo, MatrixInfo, VideoPreview } from "./VideoPreview";
import { MediaDevicesState } from "../settings/mediaDevices";
import { MatrixInfo, VideoPreview } from "./VideoPreview";
import { UserChoices } from "../livekit/useLiveKit";
interface Props {
matrixInfo: MatrixInfo;
mediaDevices: MediaDevicesState;
localMedia: LocalMediaInfo;
onEnter: (e: PressEvent) => void;
onEnter: (userChoices: UserChoices) => void;
isEmbedded: boolean;
hideHeader: boolean;
}
@ -49,6 +46,10 @@ export function LobbyView(props: Props) {
}
}, [joinCallButtonRef]);
const [userChoices, setUserChoices] = React.useState<UserChoices | undefined>(
undefined
);
return (
<div className={styles.room}>
{!props.hideHeader && (
@ -68,15 +69,14 @@ export function LobbyView(props: Props) {
<div className={styles.joinRoomContent}>
<VideoPreview
matrixInfo={props.matrixInfo}
mediaDevices={props.mediaDevices}
localMediaInfo={props.localMedia}
onUserChoicesChanged={setUserChoices}
/>
<Trans>
<Button
ref={joinCallButtonRef}
className={styles.copyButton}
size="lg"
onPress={props.onEnter}
onPress={() => props.onEnter(userChoices!)}
data-testid="lobby_joinCall"
>
Join call now

View file

@ -37,7 +37,7 @@ limitations under the License.
top: 0;
left: 0;
right: 0;
bottom: 66px;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;

View file

@ -17,16 +17,18 @@ limitations under the License.
import React, { useCallback } from "react";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { Track } from "livekit-client";
import { OverlayTriggerState } from "@react-stately/overlays";
import { usePreviewDevice } from "@livekit/components-react";
import { MicButton, SettingsButton, VideoButton } from "../button";
import { Avatar } from "../Avatar";
import styles from "./VideoPreview.module.css";
import { useModalTriggerState } from "../Modal";
import { SettingsModal } from "../settings/SettingsModal";
import { MediaDevicesState } from "../settings/mediaDevices";
import { useClient } from "../ClientContext";
import { useMediaDevices } from "../livekit/useMediaDevices";
import { DeviceChoices, UserChoices } from "../livekit/useLiveKit";
import { useDefaultDevices } from "../settings/useSetting";
export type MatrixInfo = {
userName: string;
@ -35,28 +37,12 @@ export type MatrixInfo = {
roomIdOrAlias: string;
};
export type MediaInfo = {
track: Track; // TODO: Replace it by a more generic `CallFeed` type from JS SDK once we generalise the types.
muted: boolean;
toggle: () => void;
};
export type LocalMediaInfo = {
audio?: MediaInfo;
video?: MediaInfo;
};
interface Props {
matrixInfo: MatrixInfo;
mediaDevices: MediaDevicesState;
localMediaInfo: LocalMediaInfo;
onUserChoicesChanged: (choices: UserChoices) => void;
}
export function VideoPreview({
matrixInfo,
mediaDevices,
localMediaInfo,
}: Props) {
export function VideoPreview({ matrixInfo, onUserChoicesChanged }: Props) {
const { client } = useClient();
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
@ -75,21 +61,85 @@ export function VideoPreview({
settingsModalState.open();
}, [settingsModalState]);
// Fetch user media devices.
const mediaDevices = useMediaDevices();
// Create local media tracks.
const [videoEnabled, setVideoEnabled] = React.useState<boolean>(true);
const [audioEnabled, setAudioEnabled] = React.useState<boolean>(true);
const [videoId, audioId] = [
mediaDevices.videoIn.selectedId,
mediaDevices.audioIn.selectedId,
];
const [defaultDevices] = useDefaultDevices();
const video = usePreviewDevice(
videoEnabled,
videoId != "" ? videoId : defaultDevices.videoinput,
"videoinput"
);
const audio = usePreviewDevice(
audioEnabled,
audioId != "" ? audioId : defaultDevices.audiooutput,
"audioinput"
);
const activeVideoId = video?.selectedDevice?.deviceId;
const activeAudioId = audio?.selectedDevice?.deviceId;
React.useEffect(() => {
const createChoices = (
enabled: boolean,
deviceId?: string
): DeviceChoices | undefined => {
if (deviceId === undefined) {
return undefined;
}
return {
selectedId: deviceId,
enabled,
};
};
onUserChoicesChanged({
video: createChoices(videoEnabled, activeVideoId),
audio: createChoices(audioEnabled, activeAudioId),
});
}, [
onUserChoicesChanged,
activeVideoId,
videoEnabled,
activeAudioId,
audioEnabled,
]);
const [selectVideo, selectAudio] = [
mediaDevices.videoIn.setSelected,
mediaDevices.audioIn.setSelected,
];
React.useEffect(() => {
if (activeVideoId && activeVideoId !== "") {
selectVideo(activeVideoId);
}
if (activeAudioId && activeAudioId !== "") {
selectAudio(activeAudioId);
}
}, [selectVideo, selectAudio, activeVideoId, activeAudioId]);
const mediaElement = React.useRef(null);
React.useEffect(() => {
if (mediaElement.current) {
localMediaInfo.video?.track.attach(mediaElement.current);
video?.localTrack?.attach(mediaElement.current);
}
return () => {
localMediaInfo.video?.track.detach();
video?.localTrack?.detach();
};
}, [localMediaInfo.video?.track, mediaElement]);
}, [video?.localTrack, mediaElement]);
return (
<div className={styles.preview} ref={previewRef}>
<video ref={mediaElement} muted playsInline disablePictureInPicture />
<>
{(localMediaInfo.video?.muted ?? true) && (
{(video ? !videoEnabled : true) && (
<div className={styles.avatarContainer}>
<Avatar
size={(previewBounds.height - 66) / 2}
@ -99,16 +149,16 @@ export function VideoPreview({
</div>
)}
<div className={styles.previewButtons}>
{localMediaInfo.audio && (
{audio.localTrack && (
<MicButton
muted={localMediaInfo.audio?.muted}
onPress={localMediaInfo.audio?.toggle}
muted={!audioEnabled}
onPress={() => setAudioEnabled(!audioEnabled)}
/>
)}
{localMediaInfo.video && (
{video.localTrack && (
<VideoButton
muted={localMediaInfo.video?.muted}
onPress={localMediaInfo.video?.toggle}
muted={!videoEnabled}
onPress={() => setVideoEnabled(!videoEnabled)}
/>
)}
<SettingsButton onPress={openSettings} />

View file

@ -1,135 +0,0 @@
import * as React from "react";
import {
ConnectionState,
MediaDeviceFailure,
Room,
RoomEvent,
} from "livekit-client";
import { LiveKitRoomProps } from "@livekit/components-react/src/components/LiveKitRoom";
import { logger } from "matrix-js-sdk/src/logger";
const defaultRoomProps: Partial<LiveKitRoomProps> = {
connect: true,
audio: false,
video: false,
};
export function useRoom(props: LiveKitRoomProps) {
const {
token,
serverUrl,
options,
room: passedRoom,
connectOptions,
connect,
audio,
video,
screen,
onConnected,
onDisconnected,
onError,
onMediaDeviceFailure,
} = { ...defaultRoomProps, ...props };
if (options && passedRoom) {
logger.warn(
"when using a manually created room, the options object will be ignored. set the desired options directly when creating the room instead."
);
}
const [room, setRoom] = React.useState<Room | undefined>();
React.useEffect(() => {
setRoom(passedRoom ?? new Room(options));
}, [options, passedRoom]);
React.useEffect(() => {
if (!room) return;
const onSignalConnected = () => {
const localP = room.localParticipant;
try {
logger.debug("trying to publish local tracks");
localP.setMicrophoneEnabled(
!!audio,
typeof audio !== "boolean" ? audio : undefined
);
localP.setCameraEnabled(
!!video,
typeof video !== "boolean" ? video : undefined
);
localP.setScreenShareEnabled(
!!screen,
typeof screen !== "boolean" ? screen : undefined
);
} catch (e) {
logger.warn(e);
onError?.(e as Error);
}
};
const onMediaDeviceError = (e: Error) => {
const mediaDeviceFailure = MediaDeviceFailure.getFailure(e);
onMediaDeviceFailure?.(mediaDeviceFailure);
};
room.on(RoomEvent.SignalConnected, onSignalConnected);
room.on(RoomEvent.MediaDevicesError, onMediaDeviceError);
return () => {
room.off(RoomEvent.SignalConnected, onSignalConnected);
room.off(RoomEvent.MediaDevicesError, onMediaDeviceError);
};
}, [room, audio, video, screen, onError, onMediaDeviceFailure]);
React.useEffect(() => {
if (!room) return;
if (!token) {
logger.debug("no token yet");
return;
}
if (!serverUrl) {
logger.warn("no livekit url provided");
onError?.(Error("no livekit url provided"));
return;
}
if (connect) {
logger.debug("connecting");
room.connect(serverUrl, token, connectOptions).catch((e) => {
logger.warn(e);
onError?.(e as Error);
});
} else {
logger.debug("disconnecting because connect is false");
room.disconnect();
}
}, [connect, token, connectOptions, room, onError, serverUrl]);
React.useEffect(() => {
if (!room) return;
const connectionStateChangeListener = (state: ConnectionState) => {
switch (state) {
case ConnectionState.Disconnected:
if (onDisconnected) onDisconnected();
break;
case ConnectionState.Connected:
if (onConnected) onConnected();
break;
default:
break;
}
};
room.on(RoomEvent.ConnectionStateChanged, connectionStateChangeListener);
return () => {
room.off(RoomEvent.ConnectionStateChanged, connectionStateChangeListener);
};
}, [token, onConnected, onDisconnected, room]);
React.useEffect(() => {
if (!room) return;
return () => {
logger.info("disconnecting on onmount");
room.disconnect();
};
}, [room]);
return { room };
}

View file

@ -29,7 +29,6 @@ import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg";
import { ReactComponent as UserIcon } from "../icons/User.svg";
import { ReactComponent as FeedbackIcon } from "../icons/Feedback.svg";
import { SelectInput } from "../input/SelectInput";
import { MediaDevicesState } from "./mediaDevices";
import {
useShowInspector,
useOptInAnalytics,
@ -42,9 +41,10 @@ import { Body, Caption } from "../typography/Typography";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { ProfileSettingsTab } from "./ProfileSettingsTab";
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
import { MediaDevices, MediaDevicesState } from "../livekit/useMediaDevices";
interface Props {
mediaDevices: MediaDevicesState;
mediaDevices?: MediaDevicesState;
isOpen: boolean;
client: MatrixClient;
roomId?: string;
@ -63,17 +63,14 @@ export const SettingsModal = (props: Props) => {
const downloadDebugLog = useDownloadDebugLog();
// Generate a `SelectInput` with a list of devices for a given device kind.
const generateDeviceSelection = (kind: MediaDeviceKind, caption: string) => {
const devices = props.mediaDevices.state.get(kind);
if (!devices || devices.available.length == 0) return null;
const generateDeviceSelection = (devices: MediaDevices, caption: string) => {
if (devices.available.length == 0) return null;
return (
<SelectInput
label={caption}
selectedKey={devices.selectedId}
onSelectionChange={(id) =>
props.mediaDevices.selectActiveDevice(kind, id.toString())
}
onSelectionChange={(id) => devices.setSelected(id.toString())}
>
{devices.available.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
@ -106,6 +103,8 @@ export const SettingsModal = (props: Props) => {
</Caption>
);
const devices = props.mediaDevices;
return (
<Modal
title={t("Settings")}
@ -128,8 +127,8 @@ export const SettingsModal = (props: Props) => {
</>
}
>
{generateDeviceSelection("audioinput", t("Microphone"))}
{generateDeviceSelection("audiooutput", t("Speaker"))}
{devices && generateDeviceSelection(devices.audioIn, t("Microphone"))}
{devices && generateDeviceSelection(devices.audioOut, t("Speaker"))}
</TabItem>
<TabItem
key="video"
@ -140,7 +139,7 @@ export const SettingsModal = (props: Props) => {
</>
}
>
{generateDeviceSelection("videoinput", t("Camera"))}
{devices && generateDeviceSelection(devices.videoIn, t("Camera"))}
</TabItem>
<TabItem
key="profile"

View file

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