diff --git a/src/UserMenuContainer.tsx b/src/UserMenuContainer.tsx index 91e85d9..0a116d9 100644 --- a/src/UserMenuContainer.tsx +++ b/src/UserMenuContainer.tsx @@ -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) { )} diff --git a/src/livekit/options.ts b/src/livekit/options.ts index 8bd4789..86db302 100644 --- a/src/livekit/options.ts +++ b/src/livekit/options.ts @@ -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, diff --git a/src/livekit/useLiveKit.ts b/src/livekit/useLiveKit.ts index 7a9178b..ceef7ba 100644 --- a/src/livekit/useLiveKit.ts +++ b/src/livekit/useLiveKit.ts @@ -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(() => { - 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(true); - const selectedVideoId = mediaDevices.state.get("videoinput")?.selectedId; - - const [audioEnabled, setAudioEnabled] = React.useState(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(undefined); - React.useEffect(() => { - // Helper to create local media without the copy-paste. - const createLocalMedia = ( - track: LocalVideoTrack | LocalAudioTrack | undefined, - enabled: boolean, - setEnabled: React.Dispatch> - ): 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(() => { - 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(); - 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; } diff --git a/src/livekit/useMediaDevices.ts b/src/livekit/useMediaDevices.ts new file mode 100644 index 0000000..e5b0a15 --- /dev/null +++ b/src/livekit/useMediaDevices.ts @@ -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; +}; + +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, + }, + }; +} diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 5991d0e..4326b3f 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -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( + undefined + ); + if (error) { return ; - } else if (state === GroupCallState.Entered) { + } else if (state === GroupCallState.Entered && userChoices) { return ( - ); @@ -227,18 +224,17 @@ export function GroupCallView({

{t("Loading…")}

); - } else if (lkState) { + } else { return ( { + setUserChoices(choices); + enter(); + }} isEmbedded={isEmbedded} hideHeader={hideHeader} /> ); - } else { - return null; } } diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index dd93f01..110358f 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -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 { + 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 && ; } interface Props { client: MatrixClient; groupCall: GroupCall; + livekitRoom: Room; participants: Map>; onLeave: () => void; unencryptedEventsFromUsers: Set; 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({ )} diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index 5107da8..721bd5d 100644 --- a/src/room/LobbyView.tsx +++ b/src/room/LobbyView.tsx @@ -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( + undefined + ); + return (
{!props.hideHeader && ( @@ -68,15 +69,14 @@ export function LobbyView(props: Props) {