All my Friday work. Demoable!

This commit is contained in:
Robin
2025-08-29 18:46:24 +02:00
committed by Timo K
parent 386dc6c84d
commit e08f16f889
8 changed files with 220 additions and 298 deletions

View File

@@ -60,7 +60,7 @@ import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal"; import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { useWakeLock } from "../useWakeLock"; import { useWakeLock } from "../useWakeLock";
import { useMergedRefs } from "../useMergedRefs"; import { useMergedRefs } from "../useMergedRefs";
import { type MuteStates } from "./MuteStates"; import { type MuteStates } from "../state/MuteStates";
import { type MatrixInfo } from "./VideoPreview"; import { type MatrixInfo } from "./VideoPreview";
import { InviteButton } from "../button/InviteButton"; import { InviteButton } from "../button/InviteButton";
import { LayoutToggle } from "./LayoutToggle"; import { LayoutToggle } from "./LayoutToggle";
@@ -143,6 +143,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.rtcSession, props.rtcSession,
props.matrixRoom, props.matrixRoom,
mediaDevices, mediaDevices,
props.muteStates,
{ {
encryptionSystem: props.e2eeSystem, encryptionSystem: props.e2eeSystem,
autoLeaveWhenOthersLeft, autoLeaveWhenOthersLeft,
@@ -161,6 +162,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.rtcSession, props.rtcSession,
props.matrixRoom, props.matrixRoom,
mediaDevices, mediaDevices,
props.muteStates,
props.e2eeSystem, props.e2eeSystem,
autoLeaveWhenOthersLeft, autoLeaveWhenOthersLeft,
sendNotificationType, sendNotificationType,
@@ -265,22 +267,19 @@ export const InCallView: FC<InCallViewProps> = ({
], ],
); );
const toggleMicrophone = useCallback( const audioEnabled = useBehavior(muteStates.audio.enabled$);
() => muteStates.audio.setEnabled?.((e) => !e), const videoEnabled = useBehavior(muteStates.video.enabled$);
[muteStates], const toggleAudio = useBehavior(muteStates.audio.toggle$);
); const toggleVideo = useBehavior(muteStates.video.toggle$);
const toggleCamera = useCallback( const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
() => muteStates.video.setEnabled?.((e) => !e),
[muteStates],
);
// This function incorrectly assumes that there is a camera and microphone, which is not always the case. // 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! // TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
useCallViewKeyboardShortcuts( useCallViewKeyboardShortcuts(
containerRef1, containerRef1,
toggleMicrophone, toggleAudio,
toggleCamera, toggleVideo,
(muted) => muteStates.audio.setEnabled?.(!muted), setAudioEnabled,
(reaction) => void sendReaction(reaction), (reaction) => void sendReaction(reaction),
() => void toggleRaisedHand(), () => void toggleRaisedHand(),
); );
@@ -764,18 +763,18 @@ export const InCallView: FC<InCallViewProps> = ({
buttons.push( buttons.push(
<MicButton <MicButton
key="audio" key="audio"
muted={!muteStates.audio.enabled} muted={!audioEnabled}
onClick={toggleMicrophone} onClick={toggleAudio ?? undefined}
onTouchEnd={onControlsTouchEnd} onTouchEnd={onControlsTouchEnd}
disabled={muteStates.audio.setEnabled === null} disabled={toggleAudio === null}
data-testid="incall_mute" data-testid="incall_mute"
/>, />,
<VideoButton <VideoButton
key="video" key="video"
muted={!muteStates.video.enabled} muted={!videoEnabled}
onClick={toggleCamera} onClick={toggleVideo ?? undefined}
onTouchEnd={onControlsTouchEnd} onTouchEnd={onControlsTouchEnd}
disabled={muteStates.video.setEnabled === null} disabled={toggleVideo === null}
data-testid="incall_videomute" data-testid="incall_videomute"
/>, />,
); );

View File

@@ -31,7 +31,7 @@ import inCallStyles from "./InCallView.module.css";
import styles from "./LobbyView.module.css"; import styles from "./LobbyView.module.css";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { type MatrixInfo, VideoPreview } from "./VideoPreview"; import { type MatrixInfo, VideoPreview } from "./VideoPreview";
import { type MuteStates } from "./MuteStates"; import { type MuteStates } from "../state/MuteStates";
import { InviteButton } from "../button/InviteButton"; import { InviteButton } from "../button/InviteButton";
import { import {
EndCallButton, EndCallButton,
@@ -50,8 +50,8 @@ import {
useTrackProcessorSync, useTrackProcessorSync,
} from "../livekit/TrackProcessorContext"; } from "../livekit/TrackProcessorContext";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
import { useLatest } from "../useLatest";
import { getValue } from "../utils/observable"; import { getValue } from "../utils/observable";
import { useBehavior } from "../useBehavior";
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
@@ -88,14 +88,10 @@ export const LobbyView: FC<Props> = ({
const { t } = useTranslation(); const { t } = useTranslation();
usePageTitle(matrixInfo.roomName); usePageTitle(matrixInfo.roomName);
const onAudioPress = useCallback( const audioEnabled = useBehavior(muteStates.audio.enabled$);
() => muteStates.audio.setEnabled?.((e) => !e), const videoEnabled = useBehavior(muteStates.video.enabled$);
[muteStates], const toggleAudio = useBehavior(muteStates.audio.toggle$);
); const toggleVideo = useBehavior(muteStates.video.toggle$);
const onVideoPress = useCallback(
() => muteStates.video.setEnabled?.((e) => !e),
[muteStates],
);
const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab); const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
@@ -133,7 +129,7 @@ export const LobbyView: FC<Props> = ({
// re-open the devices when they change (see below). // re-open the devices when they change (see below).
const initialAudioOptions = useInitial( const initialAudioOptions = useInitial(
() => () =>
muteStates.audio.enabled && { audioEnabled && {
deviceId: getValue(devices.audioInput.selected$)?.id, deviceId: getValue(devices.audioInput.selected$)?.id,
}, },
); );
@@ -150,27 +146,21 @@ export const LobbyView: FC<Props> = ({
// We also pass in a clone because livekit mutates the object passed in, // We also pass in a clone because livekit mutates the object passed in,
// which would cause the devices to be re-opened on the next render. // which would cause the devices to be re-opened on the next render.
audio: Object.assign({}, initialAudioOptions), audio: Object.assign({}, initialAudioOptions),
video: muteStates.video.enabled && { video: videoEnabled && {
deviceId: videoInputId, deviceId: videoInputId,
processor: initialProcessor, processor: initialProcessor,
}, },
}), }),
[ [initialAudioOptions, videoEnabled, videoInputId, initialProcessor],
initialAudioOptions,
muteStates.video.enabled,
videoInputId,
initialProcessor,
],
); );
const latestMuteStates = useLatest(muteStates);
const onError = useCallback( const onError = useCallback(
(error: Error) => { (error: Error) => {
logger.error("Error while creating preview Tracks:", error); logger.error("Error while creating preview Tracks:", error);
latestMuteStates.current.audio.setEnabled?.(false); muteStates.audio.setEnabled$.value?.(false);
latestMuteStates.current.video.setEnabled?.(false); muteStates.video.setEnabled$.value?.(false);
}, },
[latestMuteStates], [muteStates],
); );
const tracks = usePreviewTracks(localTrackOptions, onError); const tracks = usePreviewTracks(localTrackOptions, onError);
@@ -217,7 +207,7 @@ export const LobbyView: FC<Props> = ({
<div className={styles.content}> <div className={styles.content}>
<VideoPreview <VideoPreview
matrixInfo={matrixInfo} matrixInfo={matrixInfo}
muteStates={muteStates} videoEnabled={videoEnabled}
videoTrack={videoTrack} videoTrack={videoTrack}
> >
<Button <Button
@@ -239,14 +229,14 @@ export const LobbyView: FC<Props> = ({
{recentsButtonInFooter && recentsButton} {recentsButtonInFooter && recentsButton}
<div className={inCallStyles.buttons}> <div className={inCallStyles.buttons}>
<MicButton <MicButton
muted={!muteStates.audio.enabled} muted={!audioEnabled}
onClick={onAudioPress} onClick={toggleAudio ?? undefined}
disabled={muteStates.audio.setEnabled === null} disabled={toggleAudio === null}
/> />
<VideoButton <VideoButton
muted={!muteStates.video.enabled} muted={!videoEnabled}
onClick={onVideoPress} onClick={toggleVideo ?? undefined}
disabled={muteStates.video.setEnabled === null} disabled={toggleVideo === null}
/> />
<SettingsButton onClick={openSettings} /> <SettingsButton onClick={openSettings} />
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />} {!confineToRoom && <EndCallButton onClick={onLeaveClick} />}

View File

@@ -13,7 +13,6 @@ import { useTranslation } from "react-i18next";
import { TileAvatar } from "../tile/TileAvatar"; import { TileAvatar } from "../tile/TileAvatar";
import styles from "./VideoPreview.module.css"; import styles from "./VideoPreview.module.css";
import { type MuteStates } from "./MuteStates";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
export type MatrixInfo = { export type MatrixInfo = {
@@ -29,14 +28,14 @@ export type MatrixInfo = {
interface Props { interface Props {
matrixInfo: MatrixInfo; matrixInfo: MatrixInfo;
muteStates: MuteStates; videoEnabled: boolean;
videoTrack: LocalVideoTrack | null; videoTrack: LocalVideoTrack | null;
children: ReactNode; children: ReactNode;
} }
export const VideoPreview: FC<Props> = ({ export const VideoPreview: FC<Props> = ({
matrixInfo, matrixInfo,
muteStates, videoEnabled,
videoTrack, videoTrack,
children, children,
}) => { }) => {
@@ -56,8 +55,8 @@ export const VideoPreview: FC<Props> = ({
}, [videoTrack]); }, [videoTrack]);
const cameraIsStarting = useMemo( const cameraIsStarting = useMemo(
() => muteStates.video.enabled && !videoTrack, () => videoEnabled && !videoTrack,
[muteStates.video.enabled, videoTrack], [videoEnabled, videoTrack],
); );
return ( return (
@@ -76,7 +75,7 @@ export const VideoPreview: FC<Props> = ({
tabIndex={-1} tabIndex={-1}
disablePictureInPicture disablePictureInPicture
/> />
{(!muteStates.video.enabled || cameraIsStarting) && ( {(!videoEnabled || cameraIsStarting) && (
<> <>
<div className={styles.avatarContainer}> <div className={styles.avatarContainer}>
{cameraIsStarting && ( {cameraIsStarting && (

View File

@@ -34,6 +34,7 @@ import {
Subject, Subject,
combineLatest, combineLatest,
concat, concat,
concatMap,
distinctUntilChanged, distinctUntilChanged,
endWith, endWith,
filter, filter,
@@ -121,6 +122,7 @@ import { E2eeType } from "../e2ee/e2eeType";
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider"; import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider";
import { type ECConnectionState } from "../livekit/useECConnectionState"; import { type ECConnectionState } from "../livekit/useECConnectionState";
import { Connection, PublishConnection } from "./Connection"; import { Connection, PublishConnection } from "./Connection";
import { type MuteStates } from "./MuteStates";
export interface CallViewModelOptions { export interface CallViewModelOptions {
encryptionSystem: EncryptionSystem; encryptionSystem: EncryptionSystem;
@@ -447,6 +449,7 @@ export class CallViewModel extends ViewModel {
this.scope, this.scope,
this.membershipsAndFocusMap$, this.membershipsAndFocusMap$,
this.mediaDevices, this.mediaDevices,
this.muteStates,
this.livekitE2EERoomOptions, this.livekitE2EERoomOptions,
), ),
); );
@@ -536,6 +539,14 @@ export class CallViewModel extends ViewModel {
return { start, stop }; return { start, stop };
}), }),
this.scope.share,
);
private readonly startConnection$ = this.connectionInstructions$.pipe(
concatMap(({ start }) => start),
);
private readonly stopConnection$ = this.connectionInstructions$.pipe(
concatMap(({ stop }) => stop),
); );
private readonly userId = this.matrixRoom.client.getUserId(); private readonly userId = this.matrixRoom.client.getUserId();
@@ -623,15 +634,15 @@ export class CallViewModel extends ViewModel {
), ),
); );
private readonly participants$ = this.scope private readonly participants$ = this.scope.behavior<
.behavior< {
{ participant: LocalParticipant | RemoteParticipant;
participant: LocalParticipant | RemoteParticipant; member: RoomMember;
member: RoomMember; livekitRoom: LivekitRoom;
livekitRoom: LivekitRoom; }[]
}[] >(
>( from(this.localConnection)
from(this.localConnection).pipe( .pipe(
switchMap((localConnection) => { switchMap((localConnection) => {
const memberError = (): never => { const memberError = (): never => {
throw new Error("No room member for call membership"); throw new Error("No room member for call membership");
@@ -645,7 +656,7 @@ export class CallViewModel extends ViewModel {
return this.remoteConnections$.pipe( return this.remoteConnections$.pipe(
switchMap((connections) => switchMap((connections) =>
combineLatest( combineLatest(
[...connections.values()].map((c) => [localConnection, ...connections.values()].map((c) =>
c.publishingParticipants$.pipe( c.publishingParticipants$.pipe(
map((ps) => map((ps) =>
ps.map(({ participant, membership }) => ({ ps.map(({ participant, membership }) => ({
@@ -663,14 +674,14 @@ export class CallViewModel extends ViewModel {
), ),
), ),
map((remoteParticipants) => [ map((remoteParticipants) => [
...remoteParticipants.flat(1),
localParticipant, localParticipant,
...remoteParticipants.flat(1),
]), ]),
); );
}), }),
), )
) .pipe(startWith([]), pauseWhen(this.pretendToBeDisconnected$)),
.pipe(startWith([]), pauseWhen(this.pretendToBeDisconnected$)); );
/** /**
* Displaynames for each member of the call. This will disambiguate * Displaynames for each member of the call. This will disambiguate
@@ -681,18 +692,23 @@ export class CallViewModel extends ViewModel {
// than on Chrome/Firefox). This means it is important that we multicast the result so that we // than on Chrome/Firefox). This means it is important that we multicast the result so that we
// don't do this work more times than we need to. This is achieved by converting to a behavior: // don't do this work more times than we need to. This is achieved by converting to a behavior:
public readonly memberDisplaynames$ = this.scope.behavior( public readonly memberDisplaynames$ = this.scope.behavior(
// React to call memberships and also display name updates merge(
// (calculateDisplayName implicitly depends on the room member data) // Handle call membership changes.
combineLatest( fromEvent(
[ this.matrixRTCSession,
this.memberships$, MatrixRTCSessionEvent.MembershipsChanged,
fromEvent(this.matrixRoom, RoomStateEvent.Members).pipe( ),
startWith(null), // Handle room membership changes (and displayname updates)
pauseWhen(this.pretendToBeDisconnected$), fromEvent(this.matrixRoom, RoomStateEvent.Members),
), // TODO: do we need: pauseWhen(this.pretendToBeDisconnected$),
],
(memberships, _members) => { ).pipe(
const displaynameMap = new Map<string, string>(); startWith(null),
map(() => {
const memberships = this.matrixRTCSession.memberships;
const displaynameMap = new Map<string, string>([
["local", this.matrixRoom.getMember(this.userId!)!.rawDisplayName],
]);
const room = this.matrixRoom; const room = this.matrixRoom;
// We only consider RTC members for disambiguation as they are the only visible members. // We only consider RTC members for disambiguation as they are the only visible members.
@@ -1753,6 +1769,7 @@ export class CallViewModel extends ViewModel {
private readonly matrixRTCSession: MatrixRTCSession, private readonly matrixRTCSession: MatrixRTCSession,
private readonly matrixRoom: MatrixRoom, private readonly matrixRoom: MatrixRoom,
private readonly mediaDevices: MediaDevices, private readonly mediaDevices: MediaDevices,
private readonly muteStates: MuteStates,
private readonly options: CallViewModelOptions, private readonly options: CallViewModelOptions,
private readonly handsRaisedSubject$: Observable< private readonly handsRaisedSubject$: Observable<
Record<string, RaisedHandInfo> Record<string, RaisedHandInfo>
@@ -1774,12 +1791,12 @@ export class CallViewModel extends ViewModel {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
.catch((e) => console.error("failed to start publishing", e)), .catch((e) => console.error("failed to start publishing", e)),
); );
this.connectionInstructions$
this.startConnection$
.pipe(this.scope.bind()) .pipe(this.scope.bind())
.subscribe(({ start, stop }) => { .subscribe((c) => void c.start());
for (const connection of start) void connection.start(); this.stopConnection$.pipe(this.scope.bind()).subscribe((c) => c.stop());
for (const connection of stop) connection.stop();
});
combineLatest([this.localFocus, this.join$]) combineLatest([this.localFocus, this.join$])
.pipe(this.scope.bind()) .pipe(this.scope.bind())
.subscribe(([localFocus]) => { .subscribe(([localFocus]) => {
@@ -1789,6 +1806,7 @@ export class CallViewModel extends ViewModel {
this.options.encryptionSystem.kind !== E2eeType.PER_PARTICIPANT, this.options.encryptionSystem.kind !== E2eeType.PER_PARTICIPANT,
); );
}); });
this.join$.pipe(this.scope.bind()).subscribe(() => { this.join$.pipe(this.scope.bind()).subscribe(() => {
leaveRTCSession( leaveRTCSession(
this.matrixRTCSession, this.matrixRTCSession,
@@ -1861,6 +1879,7 @@ function getE2eeOptions(
e2eeSystem: EncryptionSystem, e2eeSystem: EncryptionSystem,
rtcSession: MatrixRTCSession, rtcSession: MatrixRTCSession,
): E2EEOptions | undefined { ): E2EEOptions | undefined {
return undefined;
if (e2eeSystem.kind === E2eeType.NONE) return undefined; if (e2eeSystem.kind === E2eeType.NONE) return undefined;
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) { if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {

View File

@@ -6,13 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { connectedParticipantsObserver } from "@livekit/components-core"; import {
connectedParticipantsObserver,
connectionStateObserver,
} from "@livekit/components-core";
import { import {
ConnectionState, ConnectionState,
Room as LivekitRoom, Room as LivekitRoom,
type RoomOptions,
type E2EEOptions, type E2EEOptions,
RoomEvent,
Track, Track,
} from "livekit-client"; } from "livekit-client";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
@@ -21,10 +22,7 @@ import {
type CallMembership, type CallMembership,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { import {
BehaviorSubject,
combineLatest, combineLatest,
filter,
fromEvent,
map, map,
NEVER, NEVER,
type Observable, type Observable,
@@ -35,12 +33,12 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { type SelectedDevice, type MediaDevices } from "./MediaDevices"; import { type SelectedDevice, type MediaDevices } from "./MediaDevices";
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU"; import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
import { constant, type Behavior } from "./Behavior"; import { type Behavior } from "./Behavior";
import { type ObservableScope } from "./ObservableScope"; import { type ObservableScope } from "./ObservableScope";
import { defaultLiveKitOptions } from "../livekit/options"; import { defaultLiveKitOptions } from "../livekit/options";
import { getValue } from "../utils/observable"; import { getValue } from "../utils/observable";
import { getUrlParams } from "../UrlParams"; import { getUrlParams } from "../UrlParams";
import { type MuteStates } from "../room/MuteStates"; import { type MuteStates } from "./MuteStates";
export class Connection { export class Connection {
protected stopped = false; protected stopped = false;
@@ -64,7 +62,7 @@ export class Connection {
public readonly participantsIncludingSubscribers$; public readonly participantsIncludingSubscribers$;
public readonly publishingParticipants$; public readonly publishingParticipants$;
public livekitRoom: LivekitRoom; public readonly livekitRoom: LivekitRoom;
public connectionState$: Behavior<ConnectionState>; public connectionState$: Behavior<ConnectionState>;
public constructor( public constructor(
@@ -76,11 +74,14 @@ export class Connection {
{ membership: CallMembership; focus: LivekitFocus }[] { membership: CallMembership; focus: LivekitFocus }[]
>, >,
e2eeLivekitOptions: E2EEOptions | undefined, e2eeLivekitOptions: E2EEOptions | undefined,
livekitRoom: LivekitRoom | undefined = undefined,
) { ) {
this.livekitRoom = new LivekitRoom({ this.livekitRoom =
...defaultLiveKitOptions, livekitRoom ??
e2ee: e2eeLivekitOptions, new LivekitRoom({
}); ...defaultLiveKitOptions,
e2ee: e2eeLivekitOptions,
});
this.participantsIncludingSubscribers$ = this.scope.behavior( this.participantsIncludingSubscribers$ = this.scope.behavior(
connectedParticipantsObserver(this.livekitRoom), connectedParticipantsObserver(this.livekitRoom),
[], [],
@@ -112,10 +113,7 @@ export class Connection {
[], [],
); );
this.connectionState$ = this.scope.behavior<ConnectionState>( this.connectionState$ = this.scope.behavior<ConnectionState>(
fromEvent<ConnectionState>( connectionStateObserver(this.livekitRoom),
this.livekitRoom,
RoomEvent.ConnectionStateChanged,
),
); );
} }
} }
@@ -128,8 +126,8 @@ export class PublishConnection extends Connection {
if (!this.stopped) { if (!this.stopped) {
const tracks = await this.livekitRoom.localParticipant.createTracks({ const tracks = await this.livekitRoom.localParticipant.createTracks({
audio: true, audio: this.muteStates.audio.enabled$.value,
video: true, video: this.muteStates.video.enabled$.value,
}); });
for (const track of tracks) { for (const track of tracks) {
await this.livekitRoom.localParticipant.publishTrack(track); await this.livekitRoom.localParticipant.publishTrack(track);
@@ -142,53 +140,32 @@ export class PublishConnection extends Connection {
this.stopped = true; this.stopped = true;
} }
public readonly participantsIncludingSubscribers$ = this.scope.behavior(
connectedParticipantsObserver(this.livekitRoom),
[],
);
private readonly muteStates$: Behavior<MuteStates>;
private updatingMuteStates$ = new BehaviorSubject(false);
public constructor( public constructor(
protected readonly focus: LivekitFocus, focus: LivekitFocus,
protected readonly livekitAlias: string, livekitAlias: string,
protected readonly client: MatrixClient, client: MatrixClient,
protected readonly scope: ObservableScope, scope: ObservableScope,
protected readonly membershipsFocusMap$: Behavior< membershipsFocusMap$: Behavior<
{ membership: CallMembership; focus: LivekitFocus }[] { membership: CallMembership; focus: LivekitFocus }[]
>, >,
protected readonly devices: MediaDevices, devices: MediaDevices,
private readonly muteStates: MuteStates,
e2eeLivekitOptions: E2EEOptions | undefined, e2eeLivekitOptions: E2EEOptions | undefined,
) { ) {
super(
focus,
livekitAlias,
client,
scope,
membershipsFocusMap$,
e2eeLivekitOptions,
);
// TODO-MULTI-SFU use actual mute states
this.muteStates$ = constant({
audio: { enabled: true, setEnabled: (enabled) => {} },
video: { enabled: true, setEnabled: (enabled) => {} },
});
logger.info("[LivekitRoom] Create LiveKit room"); logger.info("[LivekitRoom] Create LiveKit room");
const { controlledAudioDevices } = getUrlParams(); const { controlledAudioDevices } = getUrlParams();
const roomOptions: RoomOptions = { const room = new LivekitRoom({
...defaultLiveKitOptions, ...defaultLiveKitOptions,
videoCaptureDefaults: { videoCaptureDefaults: {
...defaultLiveKitOptions.videoCaptureDefaults, ...defaultLiveKitOptions.videoCaptureDefaults,
deviceId: getValue(this.devices.videoInput.selected$)?.id, deviceId: devices.videoInput.selected$.value?.id,
// TODO-MULTI-SFU add processor support back // TODO-MULTI-SFU add processor support back
// processor, // processor,
}, },
audioCaptureDefaults: { audioCaptureDefaults: {
...defaultLiveKitOptions.audioCaptureDefaults, ...defaultLiveKitOptions.audioCaptureDefaults,
deviceId: getValue(devices.audioInput.selected$)?.id, deviceId: devices.audioInput.selected$.value?.id,
}, },
audioOutput: { audioOutput: {
// When using controlled audio devices, we don't want to set the // When using controlled audio devices, we don't want to set the
@@ -199,150 +176,38 @@ export class PublishConnection extends Connection {
: getValue(devices.audioOutput.selected$)?.id, : getValue(devices.audioOutput.selected$)?.id,
}, },
e2ee: e2eeLivekitOptions, e2ee: e2eeLivekitOptions,
}; });
// We have to create the room manually here due to a bug inside
// @livekit/components-react. JSON.stringify() is used in deps of a
// useEffect() with an argument that references itself, if E2EE is enabled
const room = new LivekitRoom(roomOptions);
room.setE2EEEnabled(e2eeLivekitOptions !== undefined).catch((e) => { room.setE2EEEnabled(e2eeLivekitOptions !== undefined).catch((e) => {
logger.error("Failed to set E2EE enabled on room", e); logger.error("Failed to set E2EE enabled on room", e);
}); });
this.livekitRoom = room;
// sync mute states TODO-MULTI_SFU This possibly can be simplified quite a bit. super(
combineLatest([ focus,
this.connectionState$, livekitAlias,
this.muteStates$, client,
this.updatingMuteStates$, scope,
]) membershipsFocusMap$,
.pipe( e2eeLivekitOptions,
filter(([_c, _m, updating]) => !updating), room,
this.scope.bind(), );
)
.subscribe(([connectionState, muteStates, _]) => {
// Sync the requested mute states with LiveKit's mute states. We do it this
// way around rather than using LiveKit as the source of truth, so that the
// states can be consistent throughout the lobby and loading screens.
// It's important that we only do this in the connected state, because
// LiveKit's internal mute states aren't consistent during connection setup,
// and setting tracks to be enabled during this time causes errors.
if (
this.livekitRoom !== undefined &&
connectionState === ConnectionState.Connected
) {
const participant = this.livekitRoom.localParticipant;
enum MuteDevice { this.muteStates.audio.setHandler(async (desired) => {
Microphone, try {
Camera, await this.livekitRoom.localParticipant.setMicrophoneEnabled(desired);
} } catch (e) {
logger.error("Failed to update LiveKit audio input mute state", e);
const syncMuteState = async ( }
iterCount: number, return this.livekitRoom.localParticipant.isMicrophoneEnabled;
type: MuteDevice, });
): Promise<void> => { this.muteStates.video.setHandler(async (desired) => {
// The approach for muting is to always bring the actual livekit state in sync with the button try {
// This allows for a very predictable and reactive behavior for the user. await this.livekitRoom.localParticipant.setCameraEnabled(desired);
// (the new state is the old state when pressing the button n times (where n is even)) } catch (e) {
// (the new state is different to the old state when pressing the button n times (where n is uneven)) logger.error("Failed to update LiveKit video input mute state", e);
// In case there are issues with the device there might be situations where setMicrophoneEnabled/setCameraEnabled }
// return immediately. This should be caught with the Error("track with new mute state could not be published"). return this.livekitRoom.localParticipant.isCameraEnabled;
// For now we are still using an iterCount to limit the recursion loop to 10. });
// This could happen if the device just really does not want to turn on (hardware based issue) // TODO-MULTI-SFU: Unset mute state handlers on destroy
// but the mute button is in unmute state.
// For now our fail mode is to just stay in this state.
// TODO: decide for a UX on how that fail mode should be treated (disable button, hide button, sync button back to muted without user input)
if (iterCount > 10) {
logger.error(
"Stop trying to sync the input device with current mute state after 10 failed tries",
);
return;
}
let devEnabled;
let btnEnabled;
switch (type) {
case MuteDevice.Microphone:
devEnabled = participant.isMicrophoneEnabled;
btnEnabled = muteStates.audio.enabled;
break;
case MuteDevice.Camera:
devEnabled = participant.isCameraEnabled;
btnEnabled = muteStates.video.enabled;
break;
}
if (devEnabled !== btnEnabled && !this.updatingMuteStates$.value) {
this.updatingMuteStates$.next(true);
try {
let trackPublication;
switch (type) {
case MuteDevice.Microphone:
trackPublication = await participant.setMicrophoneEnabled(
btnEnabled,
this.livekitRoom.options.audioCaptureDefaults,
);
break;
case MuteDevice.Camera:
trackPublication = await participant.setCameraEnabled(
btnEnabled,
this.livekitRoom.options.videoCaptureDefaults,
);
break;
}
if (trackPublication) {
// await participant.setMicrophoneEnabled can return immediately in some instances,
// so that participant.isMicrophoneEnabled !== buttonEnabled.current.audio still holds true.
// This happens if the device is still in a pending state
// "sleeping" here makes sure we let react do its thing so that participant.isMicrophoneEnabled is updated,
// so we do not end up in a recursion loop.
await new Promise((r) => setTimeout(r, 100));
// track got successfully changed to mute/unmute
// Run the check again after the change is done. Because the user
// can update the state (presses mute button) while the device is enabling
// itself we need might need to update the mute state right away.
// This async recursion makes sure that setCamera/MicrophoneEnabled is
// called as little times as possible.
await syncMuteState(iterCount + 1, type);
} else {
throw new Error(
"track with new mute state could not be published",
);
}
} catch (e) {
if ((e as DOMException).name === "NotAllowedError") {
logger.error(
"Fatal error while syncing mute state: resetting",
e,
);
if (type === MuteDevice.Microphone) {
muteStates.audio.setEnabled?.(false);
} else {
muteStates.video.setEnabled?.(false);
}
} else {
logger.error(
"Failed to sync audio mute state with LiveKit (will retry to sync in 1s):",
e,
);
setTimeout(() => {
this.updatingMuteStates$.next(false);
}, 1000);
}
}
}
};
syncMuteState(0, MuteDevice.Microphone).catch((e) => {
logger.error("Failed to sync audio mute state with LiveKit", e);
});
syncMuteState(0, MuteDevice.Camera).catch((e) => {
logger.error("Failed to sync video mute state with LiveKit", e);
});
}
});
const syncDevice = ( const syncDevice = (
kind: MediaDeviceKind, kind: MediaDeviceKind,

View File

@@ -8,12 +8,14 @@ Please see LICENSE in the repository root for full details.
import { type IWidgetApiRequest } from "matrix-widget-api"; import { type IWidgetApiRequest } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { import {
BehaviorSubject,
combineLatest, combineLatest,
distinctUntilChanged, distinctUntilChanged,
firstValueFrom,
fromEvent, fromEvent,
map, map,
merge, merge,
type Observable, Observable,
of, of,
Subject, Subject,
switchMap, switchMap,
@@ -25,7 +27,6 @@ import { ElementWidgetActions, widget } from "../widget";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams"; import { getUrlParams } from "../UrlParams";
import { type ObservableScope } from "./ObservableScope"; import { type ObservableScope } from "./ObservableScope";
import { accumulate } from "../utils/observable";
import { type Behavior } from "./Behavior"; import { type Behavior } from "./Behavior";
interface MuteStateData { interface MuteStateData {
@@ -34,12 +35,25 @@ interface MuteStateData {
toggle: (() => void) | null; toggle: (() => void) | null;
} }
export type Handler = (desired: boolean) => Promise<boolean>;
const defaultHandler: Handler = async (desired) => Promise.resolve(desired);
class MuteState<Label, Selected> { class MuteState<Label, Selected> {
private readonly enabledByDefault$ = private readonly enabledByDefault$ =
this.enabledByConfig && !getUrlParams().skipLobby this.enabledByConfig && !getUrlParams().skipLobby
? this.joined$.pipe(map((isJoined) => !isJoined)) ? this.joined$.pipe(map((isJoined) => !isJoined))
: of(false); : of(false);
private readonly handler$ = new BehaviorSubject(defaultHandler);
public setHandler(handler: Handler): void {
if (this.handler$.value !== defaultHandler)
throw new Error("Multiple mute state handlers are not supported");
this.handler$.next(handler);
}
public unsetHandler(): void {
this.handler$.next(defaultHandler);
}
private readonly data$ = this.scope.behavior<MuteStateData>( private readonly data$ = this.scope.behavior<MuteStateData>(
this.device.available$.pipe( this.device.available$.pipe(
map((available) => available.size > 0), map((available) => available.size > 0),
@@ -50,20 +64,49 @@ class MuteState<Label, Selected> {
if (!devicesConnected) if (!devicesConnected)
return { enabled$: of(false), set: null, toggle: null }; return { enabled$: of(false), set: null, toggle: null };
// Assume the default value only once devices are actually connected
let enabled = enabledByDefault;
const set$ = new Subject<boolean>(); const set$ = new Subject<boolean>();
const toggle$ = new Subject<void>(); const toggle$ = new Subject<void>();
const desired$ = merge(set$, toggle$.pipe(map(() => !enabled)));
const enabled$ = new Observable<boolean>((subscriber) => {
subscriber.next(enabled);
let latestDesired = enabledByDefault;
let syncing = false;
const sync = async (): Promise<void> => {
if (enabled === latestDesired) syncing = false;
else {
const previouslyEnabled = enabled;
enabled = await firstValueFrom(
this.handler$.pipe(
switchMap(async (handler) => handler(latestDesired)),
),
);
if (enabled === previouslyEnabled) {
syncing = false;
} else {
subscriber.next(enabled);
syncing = true;
sync();
}
}
};
const s = desired$.subscribe((desired) => {
latestDesired = desired;
if (syncing === false) {
syncing = true;
sync();
}
});
return (): void => s.unsubscribe();
});
return { return {
set: (enabled: boolean): void => set$.next(enabled), set: (enabled: boolean): void => set$.next(enabled),
toggle: (): void => toggle$.next(), toggle: (): void => toggle$.next(),
// Assume the default value only once devices are actually connected enabled$,
enabled$: merge(
set$,
toggle$.pipe(map(() => "toggle" as const)),
).pipe(
accumulate(enabledByDefault, (prev, update) =>
update === "toggle" ? !prev : update,
),
),
}; };
}, },
), ),

View File

@@ -9,6 +9,7 @@ import {
BehaviorSubject, BehaviorSubject,
distinctUntilChanged, distinctUntilChanged,
type Observable, type Observable,
share,
Subject, Subject,
takeUntil, takeUntil,
} from "rxjs"; } from "rxjs";
@@ -35,6 +36,12 @@ export class ObservableScope {
return this.bindImpl; return this.bindImpl;
} }
private readonly shareImpl: MonoTypeOperator = share({ resetOnError: false, resetOnComplete: false, resetOnRefCountZero: false })
/**
* Shares (multicasts) the Observable as a hot Observable.
*/
public readonly share: MonoTypeOperator = (input$) => input$.pipe(this.bindImpl, this.shareImpl)
/** /**
* Converts an Observable to a Behavior. If no initial value is specified, the * Converts an Observable to a Behavior. If no initial value is specified, the
* Observable must synchronously emit an initial value. * Observable must synchronously emit an initial value.

View File

@@ -29,9 +29,9 @@ const KeyToReactionMap: Record<string, ReactionOption> = Object.fromEntries(
export function useCallViewKeyboardShortcuts( export function useCallViewKeyboardShortcuts(
focusElement: RefObject<HTMLElement | null>, focusElement: RefObject<HTMLElement | null>,
toggleMicrophoneMuted: () => void, toggleAudio: (() => void) | null,
toggleLocalVideoMuted: () => void, toggleVideo: (() => void) | null,
setMicrophoneMuted: (muted: boolean) => void, setAudioEnabled: ((enabled: boolean) => void) | null,
sendReaction: (reaction: ReactionOption) => void, sendReaction: (reaction: ReactionOption) => void,
toggleHandRaised: () => void, toggleHandRaised: () => void,
): void { ): void {
@@ -52,15 +52,15 @@ export function useCallViewKeyboardShortcuts(
if (event.key === "m") { if (event.key === "m") {
event.preventDefault(); event.preventDefault();
toggleMicrophoneMuted(); toggleAudio?.();
} else if (event.key == "v") { } else if (event.key === "v") {
event.preventDefault(); event.preventDefault();
toggleLocalVideoMuted(); toggleVideo?.();
} else if (event.key === " ") { } else if (event.key === " ") {
event.preventDefault(); event.preventDefault();
if (!spacebarHeld.current) { if (!spacebarHeld.current) {
spacebarHeld.current = true; spacebarHeld.current = true;
setMicrophoneMuted(false); setAudioEnabled?.(true);
} }
} else if (event.key === "h") { } else if (event.key === "h") {
event.preventDefault(); event.preventDefault();
@@ -72,9 +72,9 @@ export function useCallViewKeyboardShortcuts(
}, },
[ [
focusElement, focusElement,
toggleLocalVideoMuted, toggleVideo,
toggleMicrophoneMuted, toggleAudio,
setMicrophoneMuted, setAudioEnabled,
sendReaction, sendReaction,
toggleHandRaised, toggleHandRaised,
], ],
@@ -95,10 +95,10 @@ export function useCallViewKeyboardShortcuts(
if (event.key === " ") { if (event.key === " ") {
spacebarHeld.current = false; spacebarHeld.current = false;
setMicrophoneMuted(true); setAudioEnabled?.(false);
} }
}, },
[focusElement, setMicrophoneMuted], [focusElement, setAudioEnabled],
), ),
); );
@@ -108,8 +108,8 @@ export function useCallViewKeyboardShortcuts(
useCallback(() => { useCallback(() => {
if (spacebarHeld.current) { if (spacebarHeld.current) {
spacebarHeld.current = false; spacebarHeld.current = false;
setMicrophoneMuted(true); setAudioEnabled?.(true);
} }
}, [setMicrophoneMuted, spacebarHeld]), }, [setAudioEnabled, spacebarHeld]),
); );
} }