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:
parent
4342f4b027
commit
5b4787cef6
12 changed files with 278 additions and 503 deletions
|
@ -22,17 +22,11 @@ import { useProfile } from "./profile/useProfile";
|
||||||
import { useModalTriggerState } from "./Modal";
|
import { useModalTriggerState } from "./Modal";
|
||||||
import { SettingsModal } from "./settings/SettingsModal";
|
import { SettingsModal } from "./settings/SettingsModal";
|
||||||
import { UserMenu } from "./UserMenu";
|
import { UserMenu } from "./UserMenu";
|
||||||
import { MediaDevicesState } from "./settings/mediaDevices";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
preventNavigation?: boolean;
|
preventNavigation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaDevicesStub: MediaDevicesState = {
|
|
||||||
state: new Map(),
|
|
||||||
selectActiveDevice: () => Promise.resolve(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export function UserMenuContainer({ preventNavigation = false }: Props) {
|
export function UserMenuContainer({ preventNavigation = false }: Props) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
@ -81,9 +75,6 @@ export function UserMenuContainer({ preventNavigation = false }: Props) {
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
client={client}
|
client={client}
|
||||||
defaultTab={defaultSettingsTab}
|
defaultTab={defaultSettingsTab}
|
||||||
// TODO Replace this with real media devices, while making sure this
|
|
||||||
// doesn't cause unnecessary device permission pop-ups
|
|
||||||
mediaDevices={mediaDevicesStub}
|
|
||||||
{...modalProps}
|
{...modalProps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {
|
||||||
VideoPresets,
|
VideoPresets,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
|
|
||||||
const publishOptions: TrackPublishDefaults = {
|
const defaultLiveKitPublishOptions: TrackPublishDefaults = {
|
||||||
audioPreset: AudioPresets.music,
|
audioPreset: AudioPresets.music,
|
||||||
dtx: true,
|
dtx: true,
|
||||||
red: true,
|
red: true,
|
||||||
|
@ -22,7 +22,7 @@ const publishOptions: TrackPublishDefaults = {
|
||||||
backupCodec: { codec: "vp8", encoding: VideoPresets.h360.encoding },
|
backupCodec: { codec: "vp8", encoding: VideoPresets.h360.encoding },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const roomOptions: RoomOptions = {
|
export const defaultLiveKitOptions: RoomOptions = {
|
||||||
// automatically manage subscribed video quality
|
// automatically manage subscribed video quality
|
||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ export const roomOptions: RoomOptions = {
|
||||||
},
|
},
|
||||||
|
|
||||||
// publish settings
|
// publish settings
|
||||||
publishDefaults: publishOptions,
|
publishDefaults: defaultLiveKitPublishOptions,
|
||||||
|
|
||||||
// default LiveKit options that seem to be sane
|
// default LiveKit options that seem to be sane
|
||||||
stopLocalTrackOnUnpublish: true,
|
stopLocalTrackOnUnpublish: true,
|
||||||
|
|
|
@ -1,231 +1,62 @@
|
||||||
import {
|
import { Room, RoomOptions } from "livekit-client";
|
||||||
ConnectionState,
|
import { useLiveKitRoom, useToken } from "@livekit/components-react";
|
||||||
LocalAudioTrack,
|
|
||||||
LocalVideoTrack,
|
|
||||||
Room,
|
|
||||||
} from "livekit-client";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
|
||||||
useMediaDeviceSelect,
|
|
||||||
usePreviewDevice,
|
|
||||||
} from "@livekit/components-react";
|
|
||||||
|
|
||||||
import { MediaDevicesState, MediaDevices } from "../settings/mediaDevices";
|
import { defaultLiveKitOptions } from "./options";
|
||||||
import { LocalMediaInfo, MediaInfo } from "../room/VideoPreview";
|
|
||||||
import { roomOptions } from "./options";
|
|
||||||
import { useDefaultDevices } from "../settings/useSetting";
|
|
||||||
|
|
||||||
type LiveKitState = {
|
export type UserChoices = {
|
||||||
// The state of the media devices (changing the devices will also change them in the room).
|
audio?: DeviceChoices;
|
||||||
mediaDevices: MediaDevicesState;
|
video?: DeviceChoices;
|
||||||
// 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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function emptyToUndef(str) {
|
export type DeviceChoices = {
|
||||||
return str === "" ? undefined : str;
|
selectedId: string;
|
||||||
}
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
// Returns the React state for the LiveKit's Room class.
|
export type LiveKitConfig = {
|
||||||
// The actual return type should be `LiveKitState`, but since this is a React hook, the initialisation is
|
sfuUrl: string;
|
||||||
// delayed (done after the rendering, not during the rendering), because of that this function may return `undefined`.
|
jwtUrl: string;
|
||||||
// But soon this state is changed to the actual `LiveKitState` value.
|
roomName: string;
|
||||||
export function useLiveKit(): LiveKitState | undefined {
|
userName: string;
|
||||||
// TODO: Pass the proper paramters to configure the room (supported codecs, simulcast, adaptive streaming, etc).
|
userIdentity: string;
|
||||||
const [room] = React.useState<Room>(() => {
|
};
|
||||||
return new Room(roomOptions);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a React state to store the available devices and the selected device for each kind.
|
export function useLiveKit(
|
||||||
const mediaDevices = useMediaDevicesState(room);
|
userChoices: UserChoices,
|
||||||
|
config: LiveKitConfig
|
||||||
const [settingsDefaultDevices] = useDefaultDevices();
|
): Room | undefined {
|
||||||
|
const tokenOptions = React.useMemo(
|
||||||
const [videoEnabled, setVideoEnabled] = React.useState<boolean>(true);
|
() => ({
|
||||||
const selectedVideoId = mediaDevices.state.get("videoinput")?.selectedId;
|
userInfo: {
|
||||||
|
name: config.userName,
|
||||||
const [audioEnabled, setAudioEnabled] = React.useState<boolean>(true);
|
identity: config.userIdentity,
|
||||||
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
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
room,
|
}),
|
||||||
};
|
[config.userName, config.userIdentity]
|
||||||
|
|
||||||
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]
|
|
||||||
);
|
);
|
||||||
|
const token = useToken(config.jwtUrl, config.roomName, tokenOptions);
|
||||||
|
|
||||||
const [mediaDevicesState, setMediaDevicesState] =
|
const roomOptions = React.useMemo((): RoomOptions => {
|
||||||
React.useState<MediaDevicesState>(() => {
|
const options = defaultLiveKitOptions;
|
||||||
const state: MediaDevicesState = {
|
options.videoCaptureDefaults = {
|
||||||
state: new Map(),
|
...options.videoCaptureDefaults,
|
||||||
selectActiveDevice,
|
deviceId: userChoices.video?.selectedId,
|
||||||
};
|
};
|
||||||
return state;
|
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(() => {
|
return room;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
90
src/livekit/useMediaDevices.ts
Normal file
90
src/livekit/useMediaDevices.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -26,12 +26,12 @@ import { useGroupCall } from "./useGroupCall";
|
||||||
import { ErrorView, FullScreenView } from "../FullScreenView";
|
import { ErrorView, FullScreenView } from "../FullScreenView";
|
||||||
import { LobbyView } from "./LobbyView";
|
import { LobbyView } from "./LobbyView";
|
||||||
import { MatrixInfo } from "./VideoPreview";
|
import { MatrixInfo } from "./VideoPreview";
|
||||||
import { InCallView } from "./InCallView";
|
import { ActiveCall } from "./InCallView";
|
||||||
import { CallEndedView } from "./CallEndedView";
|
import { CallEndedView } from "./CallEndedView";
|
||||||
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
|
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
|
||||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||||
import { useProfile } from "../profile/useProfile";
|
import { useProfile } from "../profile/useProfile";
|
||||||
import { useLiveKit } from "../livekit/useLiveKit";
|
import { UserChoices } from "../livekit/useLiveKit";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
@ -86,8 +86,6 @@ export function GroupCallView({
|
||||||
roomIdOrAlias,
|
roomIdOrAlias,
|
||||||
};
|
};
|
||||||
|
|
||||||
const lkState = useLiveKit();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (widget && preload) {
|
if (widget && preload) {
|
||||||
// In preload mode, wait for a join action before entering
|
// In preload mode, wait for a join action before entering
|
||||||
|
@ -174,11 +172,15 @@ export function GroupCallView({
|
||||||
}
|
}
|
||||||
}, [groupCall, state, leave]);
|
}, [groupCall, state, leave]);
|
||||||
|
|
||||||
|
const [userChoices, setUserChoices] = useState<UserChoices | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <ErrorView error={error} />;
|
return <ErrorView error={error} />;
|
||||||
} else if (state === GroupCallState.Entered) {
|
} else if (state === GroupCallState.Entered && userChoices) {
|
||||||
return (
|
return (
|
||||||
<InCallView
|
<ActiveCall
|
||||||
groupCall={groupCall}
|
groupCall={groupCall}
|
||||||
client={client}
|
client={client}
|
||||||
participants={participants}
|
participants={participants}
|
||||||
|
@ -186,12 +188,7 @@ export function GroupCallView({
|
||||||
unencryptedEventsFromUsers={unencryptedEventsFromUsers}
|
unencryptedEventsFromUsers={unencryptedEventsFromUsers}
|
||||||
hideHeader={hideHeader}
|
hideHeader={hideHeader}
|
||||||
matrixInfo={matrixInfo}
|
matrixInfo={matrixInfo}
|
||||||
mediaDevices={lkState.mediaDevices}
|
userChoices={userChoices}
|
||||||
livekitRoom={lkState.room}
|
|
||||||
userChoices={{
|
|
||||||
videoMuted: lkState?.localMedia.video?.muted ?? true,
|
|
||||||
audioMuted: lkState?.localMedia.audio?.muted ?? true,
|
|
||||||
}}
|
|
||||||
otelGroupCallMembership={otelGroupCallMembership}
|
otelGroupCallMembership={otelGroupCallMembership}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -227,18 +224,17 @@ export function GroupCallView({
|
||||||
<h1>{t("Loading…")}</h1>
|
<h1>{t("Loading…")}</h1>
|
||||||
</FullScreenView>
|
</FullScreenView>
|
||||||
);
|
);
|
||||||
} else if (lkState) {
|
} else {
|
||||||
return (
|
return (
|
||||||
<LobbyView
|
<LobbyView
|
||||||
matrixInfo={matrixInfo}
|
matrixInfo={matrixInfo}
|
||||||
mediaDevices={lkState.mediaDevices}
|
onEnter={(choices: UserChoices) => {
|
||||||
localMedia={lkState.localMedia}
|
setUserChoices(choices);
|
||||||
onEnter={enter}
|
enter();
|
||||||
|
}}
|
||||||
isEmbedded={isEmbedded}
|
isEmbedded={isEmbedded}
|
||||||
hideHeader={hideHeader}
|
hideHeader={hideHeader}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,6 @@ import { ResizeObserver } from "@juggle/resize-observer";
|
||||||
import {
|
import {
|
||||||
useLocalParticipant,
|
useLocalParticipant,
|
||||||
useParticipants,
|
useParticipants,
|
||||||
useToken,
|
|
||||||
useTracks,
|
useTracks,
|
||||||
} from "@livekit/components-react";
|
} from "@livekit/components-react";
|
||||||
import { usePreventScroll } from "@react-aria/overlays";
|
import { usePreventScroll } from "@react-aria/overlays";
|
||||||
|
@ -58,7 +57,6 @@ import { useShowInspector } from "../settings/useSetting";
|
||||||
import { useModalTriggerState } from "../Modal";
|
import { useModalTriggerState } from "../Modal";
|
||||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||||
import { useUrlParams } from "../UrlParams";
|
import { useUrlParams } from "../UrlParams";
|
||||||
import { MediaDevicesState } from "../settings/mediaDevices";
|
|
||||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||||
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
|
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
|
||||||
import { ElementWidgetActions, widget } from "../widget";
|
import { ElementWidgetActions, widget } from "../widget";
|
||||||
|
@ -77,7 +75,8 @@ import { InviteModal } from "./InviteModal";
|
||||||
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
||||||
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
||||||
import { VideoTile } from "../video-grid/VideoTile";
|
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 ?? {});
|
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
|
||||||
|
@ -85,46 +84,43 @@ const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||||
// For now we can disable screensharing in Safari.
|
// For now we can disable screensharing in Safari.
|
||||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||||
|
|
||||||
const onConnectedCallback = (): void => {
|
interface ActiveCallProps extends Omit<Props, "livekitRoom"> {
|
||||||
console.log("connected to LiveKit room");
|
userChoices: UserChoices;
|
||||||
};
|
}
|
||||||
const onDisconnectedCallback = (): void => {
|
|
||||||
console.log("disconnected from LiveKit room");
|
|
||||||
};
|
|
||||||
const onErrorCallback = (err: Error): void => {
|
|
||||||
console.error("error connecting to LiveKit room", err);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface LocalUserChoices {
|
export function ActiveCall(props: ActiveCallProps) {
|
||||||
videoMuted: boolean;
|
const livekitRoom = useLiveKit(props.userChoices, {
|
||||||
audioMuted: boolean;
|
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 {
|
interface Props {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
groupCall: GroupCall;
|
groupCall: GroupCall;
|
||||||
|
livekitRoom: Room;
|
||||||
participants: Map<RoomMember, Map<string, ParticipantInfo>>;
|
participants: Map<RoomMember, Map<string, ParticipantInfo>>;
|
||||||
onLeave: () => void;
|
onLeave: () => void;
|
||||||
unencryptedEventsFromUsers: Set<string>;
|
unencryptedEventsFromUsers: Set<string>;
|
||||||
hideHeader: boolean;
|
hideHeader: boolean;
|
||||||
matrixInfo: MatrixInfo;
|
matrixInfo: MatrixInfo;
|
||||||
mediaDevices: MediaDevicesState;
|
|
||||||
livekitRoom: Room;
|
|
||||||
userChoices: LocalUserChoices;
|
|
||||||
otelGroupCallMembership: OTelGroupCallMembership;
|
otelGroupCallMembership: OTelGroupCallMembership;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InCallView({
|
export function InCallView({
|
||||||
client,
|
client,
|
||||||
groupCall,
|
groupCall,
|
||||||
|
livekitRoom,
|
||||||
participants,
|
participants,
|
||||||
onLeave,
|
onLeave,
|
||||||
unencryptedEventsFromUsers,
|
unencryptedEventsFromUsers,
|
||||||
hideHeader,
|
hideHeader,
|
||||||
matrixInfo,
|
matrixInfo,
|
||||||
mediaDevices,
|
|
||||||
livekitRoom,
|
|
||||||
userChoices,
|
|
||||||
otelGroupCallMembership,
|
otelGroupCallMembership,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@ -142,41 +138,8 @@ export function InCallView({
|
||||||
[containerRef1, containerRef2]
|
[containerRef1, containerRef2]
|
||||||
);
|
);
|
||||||
|
|
||||||
const userId = client.getUserId();
|
// Managed media devices state coupled with an active room.
|
||||||
const deviceId = client.getDeviceId();
|
const roomMediaDevices = useMediaDevices(livekitRoom);
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
const screenSharingTracks = useTracks(
|
const screenSharingTracks = useTracks(
|
||||||
[{ source: Track.Source.ScreenShare, withPlaceholder: false }],
|
[{ source: Track.Source.ScreenShare, withPlaceholder: false }],
|
||||||
|
@ -211,6 +174,8 @@ export function InCallView({
|
||||||
|
|
||||||
const joinRule = useJoinRule(groupCall.room);
|
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(
|
useCallViewKeyboardShortcuts(
|
||||||
containerRef1,
|
containerRef1,
|
||||||
toggleMicrophone,
|
toggleMicrophone,
|
||||||
|
@ -436,7 +401,7 @@ export function InCallView({
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
client={client}
|
client={client}
|
||||||
roomId={groupCall.room.roomId}
|
roomId={groupCall.room.roomId}
|
||||||
mediaDevices={mediaDevices}
|
mediaDevices={roomMediaDevices}
|
||||||
{...settingsModalProps}
|
{...settingsModalProps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -15,7 +15,6 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { PressEvent } from "@react-types/shared";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import styles from "./LobbyView.module.css";
|
import styles from "./LobbyView.module.css";
|
||||||
|
@ -25,15 +24,13 @@ import { getRoomUrl } from "../matrix-utils";
|
||||||
import { UserMenuContainer } from "../UserMenuContainer";
|
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 { MatrixInfo, VideoPreview } from "./VideoPreview";
|
||||||
import { MediaDevicesState } from "../settings/mediaDevices";
|
import { UserChoices } from "../livekit/useLiveKit";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
matrixInfo: MatrixInfo;
|
matrixInfo: MatrixInfo;
|
||||||
mediaDevices: MediaDevicesState;
|
|
||||||
localMedia: LocalMediaInfo;
|
|
||||||
|
|
||||||
onEnter: (e: PressEvent) => void;
|
onEnter: (userChoices: UserChoices) => void;
|
||||||
isEmbedded: boolean;
|
isEmbedded: boolean;
|
||||||
hideHeader: boolean;
|
hideHeader: boolean;
|
||||||
}
|
}
|
||||||
|
@ -49,6 +46,10 @@ export function LobbyView(props: Props) {
|
||||||
}
|
}
|
||||||
}, [joinCallButtonRef]);
|
}, [joinCallButtonRef]);
|
||||||
|
|
||||||
|
const [userChoices, setUserChoices] = React.useState<UserChoices | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.room}>
|
<div className={styles.room}>
|
||||||
{!props.hideHeader && (
|
{!props.hideHeader && (
|
||||||
|
@ -68,15 +69,14 @@ export function LobbyView(props: Props) {
|
||||||
<div className={styles.joinRoomContent}>
|
<div className={styles.joinRoomContent}>
|
||||||
<VideoPreview
|
<VideoPreview
|
||||||
matrixInfo={props.matrixInfo}
|
matrixInfo={props.matrixInfo}
|
||||||
mediaDevices={props.mediaDevices}
|
onUserChoicesChanged={setUserChoices}
|
||||||
localMediaInfo={props.localMedia}
|
|
||||||
/>
|
/>
|
||||||
<Trans>
|
<Trans>
|
||||||
<Button
|
<Button
|
||||||
ref={joinCallButtonRef}
|
ref={joinCallButtonRef}
|
||||||
className={styles.copyButton}
|
className={styles.copyButton}
|
||||||
size="lg"
|
size="lg"
|
||||||
onPress={props.onEnter}
|
onPress={() => props.onEnter(userChoices!)}
|
||||||
data-testid="lobby_joinCall"
|
data-testid="lobby_joinCall"
|
||||||
>
|
>
|
||||||
Join call now
|
Join call now
|
||||||
|
|
|
@ -37,7 +37,7 @@ limitations under the License.
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 66px;
|
bottom: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
@ -17,16 +17,18 @@ limitations under the License.
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import useMeasure from "react-use-measure";
|
import useMeasure from "react-use-measure";
|
||||||
import { ResizeObserver } from "@juggle/resize-observer";
|
import { ResizeObserver } from "@juggle/resize-observer";
|
||||||
import { Track } from "livekit-client";
|
|
||||||
import { OverlayTriggerState } from "@react-stately/overlays";
|
import { OverlayTriggerState } from "@react-stately/overlays";
|
||||||
|
import { usePreviewDevice } from "@livekit/components-react";
|
||||||
|
|
||||||
import { MicButton, SettingsButton, VideoButton } from "../button";
|
import { MicButton, SettingsButton, VideoButton } from "../button";
|
||||||
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 { SettingsModal } from "../settings/SettingsModal";
|
import { SettingsModal } from "../settings/SettingsModal";
|
||||||
import { MediaDevicesState } from "../settings/mediaDevices";
|
|
||||||
import { useClient } from "../ClientContext";
|
import { useClient } from "../ClientContext";
|
||||||
|
import { useMediaDevices } from "../livekit/useMediaDevices";
|
||||||
|
import { DeviceChoices, UserChoices } from "../livekit/useLiveKit";
|
||||||
|
import { useDefaultDevices } from "../settings/useSetting";
|
||||||
|
|
||||||
export type MatrixInfo = {
|
export type MatrixInfo = {
|
||||||
userName: string;
|
userName: string;
|
||||||
|
@ -35,28 +37,12 @@ export type MatrixInfo = {
|
||||||
roomIdOrAlias: string;
|
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 {
|
interface Props {
|
||||||
matrixInfo: MatrixInfo;
|
matrixInfo: MatrixInfo;
|
||||||
mediaDevices: MediaDevicesState;
|
onUserChoicesChanged: (choices: UserChoices) => void;
|
||||||
localMediaInfo: LocalMediaInfo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoPreview({
|
export function VideoPreview({ matrixInfo, onUserChoicesChanged }: Props) {
|
||||||
matrixInfo,
|
|
||||||
mediaDevices,
|
|
||||||
localMediaInfo,
|
|
||||||
}: Props) {
|
|
||||||
const { client } = useClient();
|
const { client } = useClient();
|
||||||
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
|
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
|
||||||
|
|
||||||
|
@ -75,21 +61,85 @@ export function VideoPreview({
|
||||||
settingsModalState.open();
|
settingsModalState.open();
|
||||||
}, [settingsModalState]);
|
}, [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);
|
const mediaElement = React.useRef(null);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (mediaElement.current) {
|
if (mediaElement.current) {
|
||||||
localMediaInfo.video?.track.attach(mediaElement.current);
|
video?.localTrack?.attach(mediaElement.current);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
localMediaInfo.video?.track.detach();
|
video?.localTrack?.detach();
|
||||||
};
|
};
|
||||||
}, [localMediaInfo.video?.track, mediaElement]);
|
}, [video?.localTrack, mediaElement]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.preview} ref={previewRef}>
|
<div className={styles.preview} ref={previewRef}>
|
||||||
<video ref={mediaElement} muted playsInline disablePictureInPicture />
|
<video ref={mediaElement} muted playsInline disablePictureInPicture />
|
||||||
<>
|
<>
|
||||||
{(localMediaInfo.video?.muted ?? true) && (
|
{(video ? !videoEnabled : true) && (
|
||||||
<div className={styles.avatarContainer}>
|
<div className={styles.avatarContainer}>
|
||||||
<Avatar
|
<Avatar
|
||||||
size={(previewBounds.height - 66) / 2}
|
size={(previewBounds.height - 66) / 2}
|
||||||
|
@ -99,16 +149,16 @@ export function VideoPreview({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.previewButtons}>
|
<div className={styles.previewButtons}>
|
||||||
{localMediaInfo.audio && (
|
{audio.localTrack && (
|
||||||
<MicButton
|
<MicButton
|
||||||
muted={localMediaInfo.audio?.muted}
|
muted={!audioEnabled}
|
||||||
onPress={localMediaInfo.audio?.toggle}
|
onPress={() => setAudioEnabled(!audioEnabled)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{localMediaInfo.video && (
|
{video.localTrack && (
|
||||||
<VideoButton
|
<VideoButton
|
||||||
muted={localMediaInfo.video?.muted}
|
muted={!videoEnabled}
|
||||||
onPress={localMediaInfo.video?.toggle}
|
onPress={() => setVideoEnabled(!videoEnabled)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SettingsButton onPress={openSettings} />
|
<SettingsButton onPress={openSettings} />
|
||||||
|
|
|
@ -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 };
|
|
||||||
}
|
|
|
@ -29,7 +29,6 @@ import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg";
|
||||||
import { ReactComponent as UserIcon } from "../icons/User.svg";
|
import { ReactComponent as UserIcon } from "../icons/User.svg";
|
||||||
import { ReactComponent as FeedbackIcon } from "../icons/Feedback.svg";
|
import { ReactComponent as FeedbackIcon } from "../icons/Feedback.svg";
|
||||||
import { SelectInput } from "../input/SelectInput";
|
import { SelectInput } from "../input/SelectInput";
|
||||||
import { MediaDevicesState } from "./mediaDevices";
|
|
||||||
import {
|
import {
|
||||||
useShowInspector,
|
useShowInspector,
|
||||||
useOptInAnalytics,
|
useOptInAnalytics,
|
||||||
|
@ -42,9 +41,10 @@ import { Body, Caption } from "../typography/Typography";
|
||||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||||
import { ProfileSettingsTab } from "./ProfileSettingsTab";
|
import { ProfileSettingsTab } from "./ProfileSettingsTab";
|
||||||
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
|
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
|
||||||
|
import { MediaDevices, MediaDevicesState } from "../livekit/useMediaDevices";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mediaDevices: MediaDevicesState;
|
mediaDevices?: MediaDevicesState;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
roomId?: string;
|
roomId?: string;
|
||||||
|
@ -63,17 +63,14 @@ export const SettingsModal = (props: Props) => {
|
||||||
const downloadDebugLog = useDownloadDebugLog();
|
const downloadDebugLog = useDownloadDebugLog();
|
||||||
|
|
||||||
// Generate a `SelectInput` with a list of devices for a given device kind.
|
// Generate a `SelectInput` with a list of devices for a given device kind.
|
||||||
const generateDeviceSelection = (kind: MediaDeviceKind, caption: string) => {
|
const generateDeviceSelection = (devices: MediaDevices, caption: string) => {
|
||||||
const devices = props.mediaDevices.state.get(kind);
|
if (devices.available.length == 0) return null;
|
||||||
if (!devices || devices.available.length == 0) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectInput
|
<SelectInput
|
||||||
label={caption}
|
label={caption}
|
||||||
selectedKey={devices.selectedId}
|
selectedKey={devices.selectedId}
|
||||||
onSelectionChange={(id) =>
|
onSelectionChange={(id) => devices.setSelected(id.toString())}
|
||||||
props.mediaDevices.selectActiveDevice(kind, id.toString())
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{devices.available.map(({ deviceId, label }, index) => (
|
{devices.available.map(({ deviceId, label }, index) => (
|
||||||
<Item key={deviceId}>
|
<Item key={deviceId}>
|
||||||
|
@ -106,6 +103,8 @@ export const SettingsModal = (props: Props) => {
|
||||||
</Caption>
|
</Caption>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const devices = props.mediaDevices;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={t("Settings")}
|
title={t("Settings")}
|
||||||
|
@ -128,8 +127,8 @@ export const SettingsModal = (props: Props) => {
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{generateDeviceSelection("audioinput", t("Microphone"))}
|
{devices && generateDeviceSelection(devices.audioIn, t("Microphone"))}
|
||||||
{generateDeviceSelection("audiooutput", t("Speaker"))}
|
{devices && generateDeviceSelection(devices.audioOut, t("Speaker"))}
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem
|
<TabItem
|
||||||
key="video"
|
key="video"
|
||||||
|
@ -140,7 +139,7 @@ export const SettingsModal = (props: Props) => {
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{generateDeviceSelection("videoinput", t("Camera"))}
|
{devices && generateDeviceSelection(devices.videoIn, t("Camera"))}
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem
|
<TabItem
|
||||||
key="profile"
|
key="profile"
|
||||||
|
|
|
@ -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>;
|
|
||||||
};
|
|
Loading…
Add table
Reference in a new issue