2023-11-30 22:59:19 -05:00
/*
2025-01-14 14:46:39 +00:00
Copyright 2023, 2024, 2025 New Vector Ltd.
2023-11-30 22:59:19 -05:00
2025-02-18 17:59:58 +00:00
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
2024-09-06 10:22:13 +02:00
Please see LICENSE in the repository root for full details.
2023-11-30 22:59:19 -05:00
*/
2024-01-20 20:39:12 -05:00
import {
2025-09-15 17:49:07 +02:00
type BaseKeyProvider ,
2025-10-13 15:43:12 +02:00
ConnectionState ,
2025-09-16 10:13:14 +02:00
type E2EEOptions ,
2025-08-27 14:01:01 +02:00
ExternalE2EEKeyProvider ,
2025-10-13 15:43:12 +02:00
type Room as LivekitRoom ,
2025-10-22 18:50:16 -04:00
type RoomOptions ,
2024-01-20 20:39:12 -05:00
} from "livekit-client" ;
2025-08-27 14:29:22 +02:00
import E2EEWorker from "livekit-client/e2ee-worker?worker" ;
2025-08-15 18:32:37 +02:00
import {
2025-08-28 18:41:13 +02:00
type EventTimelineSetHandlerMap ,
EventType ,
2025-10-13 15:43:12 +02:00
type Room as MatrixRoom ,
2025-08-28 18:41:13 +02:00
RoomEvent ,
2025-08-15 18:32:37 +02:00
} from "matrix-js-sdk" ;
2024-09-11 01:27:24 -04:00
import {
2023-11-30 22:59:19 -05:00
combineLatest ,
concat ,
2024-01-20 20:39:12 -05:00
distinctUntilChanged ,
2025-10-13 15:43:12 +02:00
EMPTY ,
2025-09-03 17:59:16 +02:00
endWith ,
2024-01-20 20:39:12 -05:00
filter ,
2025-08-27 16:56:57 +02:00
from ,
2024-07-03 15:08:30 -04:00
fromEvent ,
2025-09-03 16:50:43 +02:00
ignoreElements ,
2024-01-20 20:39:12 -05:00
map ,
merge ,
2025-10-13 15:43:12 +02:00
NEVER ,
type Observable ,
2023-11-30 22:59:19 -05:00
of ,
2025-09-03 16:50:43 +02:00
pairwise ,
2024-08-08 17:21:47 -04:00
race ,
2023-11-30 22:59:19 -05:00
scan ,
2024-07-03 15:08:30 -04:00
skip ,
2025-09-15 15:41:15 +01:00
skipWhile ,
2023-11-30 22:59:19 -05:00
startWith ,
2025-10-13 15:43:12 +02:00
Subject ,
2024-07-25 17:52:23 -04:00
switchAll ,
2024-01-20 20:39:12 -05:00
switchMap ,
2024-08-08 17:21:47 -04:00
switchScan ,
take ,
2025-09-03 16:50:43 +02:00
takeUntil ,
2025-10-03 14:43:22 -04:00
takeWhile ,
2025-09-24 21:26:16 -04:00
tap ,
2025-09-03 16:50:43 +02:00
throttleTime ,
2024-01-20 20:39:12 -05:00
timer ,
2023-11-30 22:59:19 -05:00
} from "rxjs" ;
2025-03-13 13:58:43 +01:00
import { logger } from "matrix-js-sdk/lib/logger" ;
2024-12-06 12:28:37 +01:00
import {
2024-12-11 09:27:55 +00:00
type MatrixRTCSession ,
2024-12-06 12:28:37 +01:00
MatrixRTCSessionEvent ,
2025-08-26 19:21:27 +02:00
type MatrixRTCSessionEventHandlerMap ,
2025-03-13 13:58:43 +01:00
} from "matrix-js-sdk/lib/matrixrtc" ;
2025-09-24 21:39:36 -04:00
import { type IWidgetApiRequest } from "matrix-widget-api" ;
2023-11-30 22:59:19 -05:00
import {
2024-05-16 12:32:18 -04:00
LocalUserMediaViewModel ,
2024-12-11 09:27:55 +00:00
type MediaViewModel ,
2025-10-14 10:46:57 +02:00
type RemoteUserMediaViewModel ,
2024-01-20 20:39:12 -05:00
ScreenShareViewModel ,
2024-12-11 09:27:55 +00:00
type UserMediaViewModel ,
2024-01-20 20:39:12 -05:00
} from "./MediaViewModel" ;
2025-11-05 12:56:58 +01:00
import { accumulate , generateKeyed$ , pauseWhen } from "../utils/observable" ;
2024-12-19 15:54:28 +00:00
import {
duplicateTiles ,
2025-11-05 12:56:58 +01:00
MatrixRTCMode ,
matrixRTCMode ,
2024-12-19 15:54:28 +00:00
playReactionsSound ,
showReactions ,
} from "../settings/settings" ;
2024-08-08 17:21:47 -04:00
import { isFirefox } from "../Platform" ;
2024-12-17 04:01:56 +00:00
import { setPipEnabled$ } from "../controls" ;
2024-11-06 04:36:48 -05:00
import { TileStore } from "./TileStore" ;
import { gridLikeLayout } from "./GridLikeLayout" ;
import { spotlightExpandedLayout } from "./SpotlightExpandedLayout" ;
import { oneOnOneLayout } from "./OneOnOneLayout" ;
import { pipLayout } from "./PipLayout" ;
2024-12-11 09:27:55 +00:00
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement" ;
2024-12-19 15:54:28 +00:00
import {
type RaisedHandInfo ,
type ReactionInfo ,
type ReactionOption ,
} from "../reactions" ;
2024-12-13 16:40:20 -05:00
import { shallowEquals } from "../utils/array" ;
2025-06-26 05:08:57 -04:00
import { type MediaDevices } from "./MediaDevices" ;
2025-10-13 15:43:12 +02:00
import { type Behavior , constant } from "./Behavior" ;
2025-08-27 14:01:01 +02:00
import { E2eeType } from "../e2ee/e2eeType" ;
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider" ;
2025-08-29 18:46:24 +02:00
import { type MuteStates } from "./MuteStates" ;
2025-09-16 16:52:17 +02:00
import { getUrlParams } from "../UrlParams" ;
2025-09-23 11:38:34 +02:00
import { type ProcessorState } from "../livekit/TrackProcessorContext" ;
2025-09-24 21:26:16 -04:00
import { ElementWidgetActions , widget } from "../widget" ;
2025-10-13 15:43:12 +02:00
import { sharingScreen$ , UserMedia } from "./UserMedia.ts" ;
import { ScreenShare } from "./ScreenShare.ts" ;
2025-10-13 16:24:55 +02:00
import {
2025-10-14 10:46:57 +02:00
type GridLayoutMedia ,
type Layout ,
type LayoutMedia ,
type OneOnOneLayoutMedia ,
type SpotlightExpandedLayoutMedia ,
type SpotlightLandscapeLayoutMedia ,
type SpotlightPortraitLayoutMedia ,
2025-10-13 16:24:55 +02:00
} from "./layout-types.ts" ;
2025-11-05 12:56:58 +01:00
import { type ElementCallError } from "../utils/errors.ts" ;
2025-11-05 17:55:36 +01:00
import { type ObservableScope } from "./ObservableScope.ts" ;
2025-11-05 18:57:24 +01:00
import { createMatrixLivekitMembers$ } from "./remoteMembers/matrixLivekitMerger.ts" ;
import { createLocalMembership$ } from "./localMember/LocalMembership.ts" ;
import { createLocalTransport$ } from "./localMember/LocalTransport.ts" ;
import { createSessionMembershipsAndTransports$ } from "./SessionBehaviors.ts" ;
2025-11-04 20:24:15 +01:00
import { ECConnectionFactory } from "./remoteMembers/ConnectionFactory.ts" ;
2025-11-05 18:57:24 +01:00
import { createConnectionManager$ } from "./remoteMembers/ConnectionManager.ts" ;
2025-10-30 01:13:06 +01:00
//TODO
// Larger rename
// member,membership -> rtcMember
// participant -> livekitParticipant
// matrixLivekitItem -> callMember
// js-sdk
// callMembership -> rtcMembership
2025-08-08 17:15:47 +02:00
export interface CallViewModelOptions {
encryptionSystem : EncryptionSystem ;
autoLeaveWhenOthersLeft? : boolean ;
2025-08-25 13:49:01 +02:00
/**
* If the call is started in a way where we want it to behave like a telephone usecase
* If we sent a notification event, we want the ui to show a ringing state
*/
2025-09-03 16:52:40 +02:00
waitForCallPickup? : boolean ;
2025-10-22 18:50:16 -04:00
/** Optional factory to create LiveKit rooms, mainly for testing purposes. */
livekitRoomFactory ?: ( options? : RoomOptions ) => LivekitRoom ;
/** Optional behavior overriding the local connection state, mainly for testing purposes. */
connectionState$? : Behavior < ConnectionState >;
2025-08-08 17:15:47 +02:00
}
2025-08-25 13:49:01 +02:00
2025-09-03 16:50:43 +02:00
// Do not play any sounds if the participant count has exceeded this
// number.
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8 ;
export const THROTTLE_SOUND_EFFECT_MS = 500 ;
2024-07-25 17:52:23 -04:00
// This is the number of participants that we think constitutes a "small" call
// on mobile. No spotlight tile should be shown below this threshold.
const smallMobileCallThreshold = 3 ;
2024-11-08 10:23:19 -05:00
// How long the footer should be shown for when hovering over or interacting
// with the interface
const showFooterMs = 4000 ;
2024-01-20 20:39:12 -05:00
export type GridMode = "grid" | "spotlight" ;
2024-07-03 15:08:30 -04:00
export type WindowMode = "normal" | "narrow" | "flat" | "pip" ;
2024-01-20 20:39:12 -05:00
2024-11-06 04:36:48 -05:00
interface LayoutScanState {
layout : Layout | null ;
tiles : TileStore ;
}
2024-01-20 20:39:12 -05:00
type MediaItem = UserMedia | ScreenShare ;
2023-11-30 22:59:19 -05:00
2025-10-21 00:07:48 -04:00
/**
* A view model providing all the application logic needed to show the in-call
* UI (may eventually be expanded to cover the lobby and feedback screens in the
* future).
*/
// Throughout this class and related code we must distinguish between MatrixRTC
// state and LiveKit state. We use the common terminology of room "members", RTC
// "memberships", and LiveKit "participants".
2025-10-16 13:57:08 -04:00
export class CallViewModel {
2025-09-24 13:54:54 -04:00
private readonly urlParams = getUrlParams ();
2025-11-04 20:24:15 +01:00
private readonly userId = this . matrixRoom . client . getUserId () ! ;
private readonly deviceId = this . matrixRoom . client . getDeviceId () ! ;
2025-08-28 17:45:14 +02:00
2025-09-15 17:49:07 +02:00
private readonly livekitE2EEKeyProvider = getE2eeKeyProvider (
2025-08-27 14:36:13 +02:00
this . options . encryptionSystem ,
2025-08-27 14:01:01 +02:00
this . matrixRTCSession ,
);
2025-11-05 12:56:58 +01:00
private readonly e2eeLivekitOptions : E2EEOptions | undefined = this
. livekitE2EEKeyProvider
? {
keyProvider : this.livekitE2EEKeyProvider ,
worker : new E2EEWorker (),
}
: undefined ;
2025-11-05 18:57:24 +01:00
private sessionBehaviors = createSessionMembershipsAndTransports$ ({
2025-11-05 17:55:36 +01:00
scope : this.scope ,
matrixRTCSession : this.matrixRTCSession ,
});
2025-11-04 20:24:15 +01:00
private memberships$ = this . sessionBehaviors . memberships$ ;
2025-10-30 01:13:06 +01:00
2025-11-05 18:57:24 +01:00
private localTransport$ = createLocalTransport$ ({
2025-11-04 20:24:15 +01:00
scope : this.scope ,
memberships$ : this.memberships$ ,
client : this.matrixRoom.client ,
roomId : this.matrixRoom.roomId ,
2025-11-05 12:56:58 +01:00
useOldestMember$ : this.scope.behavior (
matrixRTCMode . value$ . pipe ( map (( v ) => v === MatrixRTCMode . Legacy )),
),
2025-11-04 20:24:15 +01:00
});
2025-11-05 17:55:36 +01:00
// ------------------------------------------------------------------------
2025-11-04 20:24:15 +01:00
private connectionFactory = new ECConnectionFactory (
2025-10-30 01:13:06 +01:00
this . matrixRoom . client ,
this . mediaDevices ,
this . trackProcessorState$ ,
2025-11-05 12:56:58 +01:00
this . e2eeLivekitOptions ,
2025-11-04 20:24:15 +01:00
getUrlParams (). controlledAudioDevices ,
);
2025-11-05 17:55:36 +01:00
// Can contain duplicates. The connection manager will take care of this.
2025-11-04 20:24:15 +01:00
private allTransports$ = this . scope . behavior (
combineLatest (
[ this . localTransport$ , this . sessionBehaviors . transports$ ],
2025-11-05 17:55:36 +01:00
( localTransport , transports ) => {
const localTransportAsArray = localTransport ? [ localTransport ] : [];
return [... localTransportAsArray , ... transports ];
},
2025-11-04 20:24:15 +01:00
),
);
2025-11-05 18:57:24 +01:00
private connectionManager = createConnectionManager$ ({
scope : this.scope ,
connectionFactory : this.connectionFactory ,
inputTransports$ : this.allTransports$ ,
});
2025-10-30 01:13:06 +01:00
2025-11-05 17:55:36 +01:00
// ------------------------------------------------------------------------
2025-11-05 18:57:24 +01:00
private matrixLivekitMembers$ = createMatrixLivekitMembers$ ({
scope : this.scope ,
membershipsWithTransport$ : this.sessionBehaviors.membershipsWithTransport$ ,
connectionManager : this.connectionManager ,
matrixRoom : this.matrixRoom ,
userId : this.userId ,
deviceId : this.deviceId ,
});
private connectOptions$ = this . scope . behavior (
matrixRTCMode . value$ . pipe (
map (( mode ) => ({
encryptMedia : this.e2eeLivekitOptions !== undefined ,
// TODO. This might need to get called again on each cahnge of matrixRTCMode...
matrixRTCMode : mode ,
})),
),
2025-10-30 01:13:06 +01:00
);
2025-11-05 18:57:24 +01:00
private localMembership = createLocalMembership$ ({
2025-10-30 15:15:49 +01:00
scope : this.scope ,
muteStates : this.muteStates ,
mediaDevices : this.mediaDevices ,
2025-11-05 12:56:58 +01:00
connectionManager : this.connectionManager ,
matrixRTCSession : this.matrixRTCSession ,
matrixRoom : this.matrixRoom ,
localTransport$ : this.localTransport$ ,
2025-10-30 15:15:49 +01:00
e2eeLivekitOptions : this.e2eeLivekitOptions ,
2025-11-05 12:56:58 +01:00
trackProcessorState$ : this.trackProcessorState$ ,
widget ,
2025-11-05 18:57:24 +01:00
options : this.connectOptions$ ,
2025-10-30 15:15:49 +01:00
});
2025-10-14 10:46:57 +02:00
/**
* If there is a configuration error with the call (e.g. misconfigured E2EE).
* This is a fatal error that prevents the call from being created/joined.
* Should render a blocking error screen.
*/
public get configError$ () : Behavior < ElementCallError | null > {
2025-11-05 12:56:58 +01:00
return this . localMembership . configError$ ;
2025-10-14 10:46:57 +02:00
}
2025-11-05 18:57:24 +01:00
public join = this . localMembership . requestConnect ;
2025-10-03 14:43:22 -04:00
2025-10-30 01:13:06 +01:00
// CODESMELL?
2025-10-03 14:43:22 -04:00
// This is functionally the same Observable as leave$, except here it's
// hoisted to the top of the class. This enables the cyclic dependency between
// leave$ -> autoLeave$ -> callPickupState$ -> livekitConnectionState$ ->
// localConnection$ -> transports$ -> joined$ -> leave$.
private readonly leaveHoisted$ = new Subject <
"user" | "timeout" | "decline" | "allOthersLeft"
> ();
/**
* Whether we are joined to the call. This reflects our local state rather
* than whether all connections are truly up and running.
*/
2025-10-28 21:18:47 +01:00
// DISCUSS ? lets think why we need joined and how to do it better
2025-11-05 12:56:58 +01:00
private readonly joined$ = this . localMembership . connected$ ;
2025-09-16 11:31:47 +02:00
2025-08-20 18:51:03 +02:00
/**
* Whether various media/event sources should pretend to be disconnected from
* all network input, even if their connection still technically works.
*/
// We do this when the app is in the 'reconnecting' state, because it might be
// that the LiveKit connection is still functional while the homeserver is
// down, for example, and we want to avoid making people worry that the app is
// in a split-brained state.
2025-10-28 21:18:47 +01:00
// DISCUSSION own membership manager ALSO this probably can be simplifis
2025-11-05 17:55:36 +01:00
public reconnecting$ = this . localMembership . reconnecting$ ;
private readonly pretendToBeDisconnected$ = this . reconnecting$ ;
2025-08-20 18:51:03 +02:00
2025-11-05 12:56:58 +01:00
public readonly audioParticipants$ = this . scope . behavior (
2025-11-05 17:55:36 +01:00
this . matrixLivekitMembers$ . pipe (
map (( members ) => members . map (( m ) => m . participant )),
2025-11-05 12:56:58 +01:00
),
);
2025-10-08 16:40:06 -04:00
2025-08-20 19:08:44 +02:00
public readonly handsRaised$ = this . scope . behavior (
this . handsRaisedSubject$ . pipe ( pauseWhen ( this . pretendToBeDisconnected$ )),
);
2025-06-23 19:02:36 +02:00
2025-07-12 00:20:44 -04:00
public readonly reactions$ = this . scope . behavior (
this . reactionsSubject$ . pipe (
2025-06-23 19:02:36 +02:00
map (( v ) =>
Object . fromEntries (
Object . entries ( v ). map (([ a , { reactionOption }]) => [
a ,
reactionOption ,
]),
),
),
2025-08-20 18:51:03 +02:00
pauseWhen ( this . pretendToBeDisconnected$ ),
2025-07-12 00:20:44 -04:00
),
);
2025-06-23 19:02:36 +02:00
2024-11-21 11:01:43 +00:00
/**
2025-10-16 13:57:08 -04:00
* List of MediaItems that we want to have tiles for.
2024-11-21 11:01:43 +00:00
*/
2025-10-30 01:13:06 +01:00
// TODO KEEP THIS!! and adapt it to what our membershipManger returns
2025-07-12 00:20:44 -04:00
private readonly mediaItems$ = this . scope . behavior < MediaItem [] >(
2025-10-16 13:57:08 -04:00
generateKeyed$ <
2025-11-05 17:55:36 +01:00
[ typeof this . matrixLivekitMembers$ . value , number ],
2025-10-16 13:57:08 -04:00
MediaItem ,
MediaItem []
> (
2025-10-21 00:27:18 -04:00
// Generate a collection of MediaItems from the list of expected (whether
// present or missing) LiveKit participants.
2025-11-05 17:55:36 +01:00
combineLatest ([ this . matrixLivekitMembers$ , duplicateTiles . value$ ]),
([ matrixLivekitMembers , duplicateTiles ], createOrGet ) => {
2025-10-16 13:57:08 -04:00
const items : MediaItem [] = [];
2025-11-05 17:55:36 +01:00
for ( const {
connection ,
participant ,
member ,
displayName$ ,
participantId ,
} of matrixLivekitMembers ) {
if ( connection === undefined ) {
logger . warn ( "connection is not yet initialised." );
continue ;
}
for ( let i = 0 ; i < 1 + duplicateTiles ; i ++ ) {
const mediaId = ` ${ participantId } : ${ i } ` ;
const lkRoom = connection ? . livekitRoom ;
const url = connection ? . transport . livekit_service_url ;
const dpName$ = displayName$ . pipe ( map (( n ) => n ?? "[👻]" ));
const item = createOrGet (
mediaId ,
( scope ) =>
// We create UserMedia with or without a participant.
// This will be the initial value of a BehaviourSubject.
// Once a participant appears we will update the BehaviourSubject. (see below)
new UserMedia (
scope ,
mediaId ,
member ,
participant ,
this . options . encryptionSystem ,
lkRoom ,
url ,
this . mediaDevices ,
this . pretendToBeDisconnected$ ,
dpName$ ,
this . handsRaised$ . pipe (
map (( v ) => v [ participantId ] ? . time ?? null ),
2025-10-16 13:57:08 -04:00
),
2025-11-05 17:55:36 +01:00
this . reactions$ . pipe (
map (( v ) => v [ participantId ] ?? undefined ),
2025-10-16 13:57:08 -04:00
),
2025-11-05 17:55:36 +01:00
),
);
items . push ( item );
( item as UserMedia ). updateParticipant ( participant );
if ( participant ? . isScreenShareEnabled ) {
const screenShareId = ` ${ mediaId } :screen-share` ;
items . push (
createOrGet (
screenShareId ,
( scope ) =>
new ScreenShare (
scope ,
screenShareId ,
member ,
participant ,
this . options . encryptionSystem ,
lkRoom ,
url ,
this . pretendToBeDisconnected$ ,
dpName$ ,
),
),
);
2025-09-26 13:26:42 -04:00
}
2025-10-16 13:57:08 -04:00
}
}
return items ;
},
2025-07-12 00:20:44 -04:00
),
);
2024-01-20 20:39:12 -05:00
2024-11-21 11:01:43 +00:00
/**
* List of MediaItems that we want to display, that are of type UserMedia
*/
2025-07-12 00:20:44 -04:00
private readonly userMedia$ = this . scope . behavior < UserMedia [] >(
this . mediaItems$ . pipe (
2025-07-11 23:53:59 -04:00
map (( mediaItems ) =>
mediaItems . filter (( m ) : m is UserMedia => m instanceof UserMedia ),
),
2025-07-12 00:20:44 -04:00
),
);
2024-01-20 20:39:12 -05:00
2025-09-03 16:50:43 +02:00
public readonly joinSoundEffect$ = this . userMedia$ . pipe (
pairwise (),
filter (
([ prev , current ]) =>
current . length <= MAX_PARTICIPANT_COUNT_FOR_SOUND &&
current . length > prev . length ,
2025-08-08 17:15:47 +02:00
),
2025-09-03 16:50:43 +02:00
map (() => {}),
throttleTime ( THROTTLE_SOUND_EFFECT_MS ),
);
2025-08-25 13:49:01 +02:00
/**
* The number of participants currently in the call.
*
* - Each participant has a corresponding MatrixRTC membership state event
2025-09-03 16:50:43 +02:00
* - There can be multiple participants for one Matrix user if they join from
* multiple devices.
2025-08-25 13:49:01 +02:00
*/
2025-10-30 01:13:06 +01:00
// TODO KEEP THIS!! and adapt it to what our membershipManger returns
2025-08-25 13:49:01 +02:00
public readonly participantCount$ = this . scope . behavior (
2025-09-03 16:50:43 +02:00
this . memberships$ . pipe ( map (( ms ) => ms . length )),
2025-08-25 13:49:01 +02:00
);
2025-10-30 01:13:06 +01:00
// TODO convert all ring and all others left logic into one callLifecycleTracker$(didSendCallNotification$,matrixLivekitItem$): {autoLeave$,callPickupState$}
2025-10-03 14:43:22 -04:00
private readonly allOthersLeft$ = this . memberships$ . pipe (
pairwise (),
filter (
([ prev , current ]) =>
2025-10-14 12:07:51 -04:00
current . every (( m ) => m . userId === this . userId ) &&
prev . some (( m ) => m . userId !== this . userId ),
2025-10-03 14:43:22 -04:00
),
map (() => {}),
);
2025-09-19 16:42:47 +02:00
private readonly didSendCallNotification$ = fromEvent (
this . matrixRTCSession ,
MatrixRTCSessionEvent . DidSendCallNotification ,
) as Observable <
Parameters <
MatrixRTCSessionEventHandlerMap [ MatrixRTCSessionEvent . DidSendCallNotification ]
>
> ;
2025-09-16 16:52:17 +02:00
2025-08-25 13:49:01 +02:00
/**
2025-09-03 17:59:16 +02:00
* Whenever the RTC session tells us that it intends to ring the remote
* participant's devices, this emits an Observable tracking the current state of
* that ringing process.
2025-08-25 13:49:01 +02:00
*/
2025-09-19 16:42:47 +02:00
// This is a behavior since we need to store the latest state for when we subscribe to this after `didSendCallNotification$`
// has already emitted but we still need the latest observable with a timeout timer that only gets created on after receiving `notificationEvent`.
// A behavior will emit the latest observable with the running timer to new subscribers.
// see also: callPickupState$ and in particular the line: `return this.ring$.pipe(mergeAll());` here we otherwise might get an EMPTY observable if
// `ring$` would not be a behavior.
2025-09-19 17:43:31 +02:00
private readonly ring$ : Behavior < "ringing" | "timeout" | "decline" | null > =
this . scope . behavior (
this . didSendCallNotification$ . pipe (
filter (
([ notificationEvent ]) =>
notificationEvent . notification_type === "ring" ,
),
switchMap (([ notificationEvent ]) => {
const lifetimeMs = notificationEvent ? . lifetime ?? 0 ;
return concat (
lifetimeMs === 0
? // If no lifetime, skip the ring state
of ( null )
: // Ring until lifetime ms have passed
timer ( lifetimeMs ). pipe (
ignoreElements (),
startWith ( "ringing" as const ),
),
// The notification lifetime has timed out, meaning ringing has likely
// stopped on all receiving clients.
of ( "timeout" as const ),
// This makes sure we will not drop into the `endWith("decline" as const)` state
NEVER ,
). pipe (
takeUntil (
(
fromEvent ( this . matrixRoom , RoomEvent . Timeline ) as Observable <
Parameters < EventTimelineSetHandlerMap [ RoomEvent.Timeline ] >
>
). pipe (
filter (
([ event ]) =>
event . getType () === EventType . RTCDecline &&
event . getRelation () ? . rel_type === "m.reference" &&
event . getRelation () ? . event_id ===
notificationEvent . event_id &&
event . getSender () !== this . userId ,
),
2025-09-19 16:42:47 +02:00
),
2025-09-03 17:59:16 +02:00
),
2025-09-19 17:43:31 +02:00
endWith ( "decline" as const ),
);
}),
),
null ,
);
2025-08-25 13:49:01 +02:00
/**
2025-09-03 16:50:43 +02:00
* Whether some Matrix user other than ourself is joined to the call.
2025-08-25 13:49:01 +02:00
*/
2025-09-03 16:50:43 +02:00
private readonly someoneElseJoined$ = this . memberships$ . pipe (
2025-10-14 12:07:51 -04:00
map (( ms ) => ms . some (( m ) => m . userId !== this . userId )),
2025-09-19 17:43:31 +02:00
) as Behavior < boolean >;
2025-08-25 13:49:01 +02:00
/**
2025-08-25 14:31:14 +02:00
* The current call pickup state of the call.
2025-08-25 13:49:01 +02:00
* - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership.
* Then we can conclude if we were the first one to join or not.
2025-09-16 14:16:11 +01:00
* This may also be set if we are disconnected.
2025-09-03 16:50:43 +02:00
* - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening).
2025-08-25 13:49:01 +02:00
* - "timeout": No-one picked up in the defined time this call should be ringing on others devices.
* The call failed. If desired this can be used as a trigger to exit the call.
2025-09-03 16:50:43 +02:00
* - "success": Someone else joined. The call is in a normal state. No audiovisual feedback.
2025-08-25 13:49:01 +02:00
* - null: EC is configured to never show any waiting for answer state.
*/
2025-09-15 15:41:15 +01:00
public readonly callPickupState$ : Behavior <
"unknown" | "ringing" | "timeout" | "decline" | "success" | null
> = this . options . waitForCallPickup
2025-09-03 17:59:16 +02:00
? this . scope . behavior <
"unknown" | "ringing" | "timeout" | "decline" | "success"
> (
2025-09-19 17:43:31 +02:00
combineLatest (
[ this . livekitConnectionState$ , this . someoneElseJoined$ , this . ring$ ],
( livekitConnectionState , someoneElseJoined , ring ) => {
2025-09-16 14:16:11 +01:00
if ( livekitConnectionState === ConnectionState . Disconnected ) {
// Do not ring until we're connected.
2025-09-19 17:43:31 +02:00
return "unknown" as const ;
2025-09-16 14:16:11 +01:00
} else if ( someoneElseJoined ) {
2025-09-19 17:43:31 +02:00
return "success" as const ;
2025-09-16 14:16:11 +01:00
}
// Show the ringing state of the most recent ringing attempt.
2025-09-19 17:43:31 +02:00
// as long as we have not yet sent an RTC notification event, ring will be null -> callPickupState$ = unknown.
return ring ?? ( "unknown" as const );
},
2025-09-03 16:50:43 +02:00
),
)
: constant ( null );
2025-08-25 13:49:01 +02:00
2025-09-15 15:41:15 +01:00
public readonly leaveSoundEffect$ = combineLatest ([
this . callPickupState$ ,
this . userMedia$ ,
]). pipe (
// Until the call is successful, do not play a leave sound.
// If callPickupState$ is null, then we always play the sound as it will not conflict with a decline sound.
skipWhile (([ c ]) => c !== null && c !== "success" ),
map (([, userMedia ]) => userMedia ),
pairwise (),
filter (
([ prev , current ]) =>
current . length <= MAX_PARTICIPANT_COUNT_FOR_SOUND &&
current . length < prev . length ,
),
map (() => {}),
throttleTime ( THROTTLE_SOUND_EFFECT_MS ),
);
2025-09-24 21:26:16 -04:00
// Public for testing
public readonly autoLeave$ = merge (
2025-10-03 14:43:22 -04:00
this . options . autoLeaveWhenOthersLeft
? this . allOthersLeft$ . pipe ( map (() => "allOthersLeft" as const ))
: NEVER ,
2025-09-24 21:26:16 -04:00
this . callPickupState$ . pipe (
filter (( state ) => state === "timeout" || state === "decline" ),
),
2025-09-16 16:52:17 +02:00
);
2025-09-24 21:26:16 -04:00
private readonly userHangup$ = new Subject < void >();
public hangup () : void {
this . userHangup$ . next ();
}
private readonly widgetHangup$ =
widget === null
? NEVER
: (
fromEvent (
widget . lazyActions ,
ElementWidgetActions . HangupCall ,
2025-10-23 19:20:21 +02:00
) as Observable < CustomEvent < IWidgetApiRequest >>
). pipe (
tap (( ev ) => {
widget ! . api . transport . reply ( ev . detail , {});
}),
);
2025-09-24 21:26:16 -04:00
public readonly leave$ : Observable <
"user" | "timeout" | "decline" | "allOthersLeft"
> = merge (
this . autoLeave$ ,
merge ( this . userHangup$ , this . widgetHangup$ ). pipe (
map (() => "user" as const ),
),
2025-10-03 14:43:22 -04:00
). pipe (
this . scope . share ,
tap (( reason ) => this . leaveHoisted$ . next ( reason )),
2025-09-24 21:26:16 -04:00
);
2025-09-15 15:41:15 +01:00
2024-11-21 11:01:43 +00:00
/**
* List of MediaItems that we want to display, that are of type ScreenShare
*/
2025-07-12 00:20:44 -04:00
private readonly screenShares$ = this . scope . behavior < ScreenShare [] >(
this . mediaItems$ . pipe (
2024-07-17 15:37:55 -04:00
map (( mediaItems ) =>
mediaItems . filter (( m ) : m is ScreenShare => m instanceof ScreenShare ),
),
2025-07-12 00:20:44 -04:00
),
);
2025-06-18 17:14:21 -04:00
2025-07-12 00:20:44 -04:00
private readonly spotlightSpeaker$ =
this . scope . behavior < UserMediaViewModel | null >(
this . userMedia$ . pipe (
2025-06-18 17:14:21 -04:00
switchMap (( mediaItems ) =>
mediaItems . length === 0
? of ([])
: combineLatest (
mediaItems . map (( m ) =>
m . vm . speaking$ . pipe ( map (( s ) => [ m , s ] as const )),
),
2024-07-17 15:37:55 -04:00
),
2025-06-18 17:14:21 -04:00
),
scan < ( readonly [ UserMedia , boolean ])[], UserMedia | undefined , null > (
( prev , mediaItems ) => {
// Only remote users that are still in the call should be sticky
const [ stickyMedia , stickySpeaking ] =
( ! prev ? . vm . local && mediaItems . find (([ m ]) => m === prev )) || [];
// Decide who to spotlight:
// If the previous speaker is still speaking, stick with them rather
// than switching eagerly to someone else
return stickySpeaking
? stickyMedia !
: // Otherwise, select any remote user who is speaking
( mediaItems . find (([ m , s ]) => ! m . vm . local && s ) ? .[ 0 ] ??
// Otherwise, stick with the person who was last speaking
stickyMedia ??
// Otherwise, spotlight an arbitrary remote user
mediaItems . find (([ m ]) => ! m . vm . local ) ? .[ 0 ] ??
// Otherwise, spotlight the local user
mediaItems . find (([ m ]) => m . vm . local ) ? .[ 0 ]);
},
null ,
),
map (( speaker ) => speaker ? . vm ?? null ),
2025-07-12 00:20:44 -04:00
),
);
2025-06-18 17:14:21 -04:00
2025-07-12 00:20:44 -04:00
private readonly grid$ = this . scope . behavior < UserMediaViewModel [] >(
this . userMedia$ . pipe (
2024-12-17 04:01:56 +00:00
switchMap (( mediaItems ) => {
const bins = mediaItems . map (( m ) =>
2025-10-14 14:31:35 -04:00
m . bin$ . pipe ( map (( bin ) => [ m , bin ] as const )),
2024-12-17 04:01:56 +00:00
);
// Sort the media by bin order and generate a tile for each one
return bins . length === 0
? of ([])
: combineLatest ( bins , (... bins ) =>
bins . sort (([, bin1 ], [, bin2 ]) => bin1 - bin2 ). map (([ m ]) => m . vm ),
);
}),
distinctUntilChanged ( shallowEquals ),
2025-07-12 00:20:44 -04:00
),
);
2024-01-20 20:39:12 -05:00
2025-07-12 00:20:44 -04:00
private readonly spotlight$ = this . scope . behavior < MediaViewModel [] >(
this . screenShares$ . pipe (
2024-12-06 12:28:37 +01:00
switchMap (( screenShares ) => {
if ( screenShares . length > 0 ) {
return of ( screenShares . map (( m ) => m . vm ));
}
2024-12-17 04:01:56 +00:00
return this . spotlightSpeaker$ . pipe (
2024-12-06 12:28:37 +01:00
map (( speaker ) => ( speaker ? [ speaker ] : [])),
);
}),
2025-08-20 18:51:03 +02:00
distinctUntilChanged < MediaViewModel [] >( shallowEquals ),
2025-07-12 00:20:44 -04:00
),
);
private readonly pip$ = this . scope . behavior < UserMediaViewModel | null >(
combineLatest ([
this . screenShares$ ,
this . spotlightSpeaker$ ,
this . mediaItems$ ,
]). pipe (
2025-06-18 17:14:21 -04:00
switchMap (([ screenShares , spotlight , mediaItems ]) => {
if ( screenShares . length > 0 ) {
return this . spotlightSpeaker$ ;
}
if ( ! spotlight || spotlight . local ) {
return of ( null );
}
2024-12-06 12:28:37 +01:00
2025-06-18 17:14:21 -04:00
const localUserMedia = mediaItems . find (
( m ) => m . vm instanceof LocalUserMediaViewModel ,
) as UserMedia | undefined ;
const localUserMediaViewModel = localUserMedia ? . vm as
| LocalUserMediaViewModel
| undefined ;
if ( ! localUserMediaViewModel ) {
return of ( null );
}
return localUserMediaViewModel . alwaysShow$ . pipe (
map (( alwaysShow ) => {
if ( alwaysShow ) {
return localUserMediaViewModel ;
}
return null ;
}),
);
}),
2025-07-12 00:20:44 -04:00
),
);
2024-12-06 12:28:37 +01:00
2024-12-17 04:01:56 +00:00
private readonly hasRemoteScreenShares$ : Observable < boolean > =
this . spotlight$ . pipe (
2024-11-06 04:36:48 -05:00
map (( spotlight ) =>
spotlight . some (( vm ) => ! vm . local && vm instanceof ScreenShareViewModel ),
),
distinctUntilChanged (),
);
2025-07-12 00:28:24 -04:00
private readonly pipEnabled$ = this . scope . behavior ( setPipEnabled$ , false );
2024-08-27 07:47:20 -04:00
2025-07-12 00:20:44 -04:00
private readonly naturalWindowMode$ = this . scope . behavior < WindowMode >(
fromEvent ( window , "resize" ). pipe (
2025-06-18 17:14:21 -04:00
startWith ( null ),
map (() => {
const height = window . innerHeight ;
const width = window . innerWidth ;
if ( height <= 400 && width <= 340 ) return "pip" ;
// Our layouts for flat windows are better at adapting to a small width
// than our layouts for narrow windows are at adapting to a small height,
// so we give "flat" precedence here
if ( height <= 600 ) return "flat" ;
if ( width <= 600 ) return "narrow" ;
return "normal" ;
}),
2025-07-12 00:20:44 -04:00
),
);
2024-08-27 07:47:20 -04:00
/**
* The general shape of the window.
*/
2025-07-12 00:20:44 -04:00
public readonly windowMode$ = this . scope . behavior < WindowMode >(
this . pipEnabled$ . pipe (
2025-06-18 17:14:21 -04:00
switchMap (( pip ) =>
pip ? of < WindowMode >( "pip" ) : this . naturalWindowMode$ ,
),
2025-07-12 00:20:44 -04:00
),
);
2024-07-03 15:08:30 -04:00
2024-12-17 04:01:56 +00:00
private readonly spotlightExpandedToggle$ = new Subject < void >();
2025-07-12 00:20:44 -04:00
public readonly spotlightExpanded$ = this . scope . behavior < boolean >(
this . spotlightExpandedToggle$ . pipe (
accumulate ( false , ( expanded ) => ! expanded ),
),
);
2024-07-03 15:08:30 -04:00
2024-12-17 04:01:56 +00:00
private readonly gridModeUserSelection$ = new Subject < GridMode >();
2024-05-02 16:00:05 -04:00
/**
* The layout mode of the media tile grid.
*/
2025-07-12 00:20:44 -04:00
public readonly gridMode$ =
2024-05-17 16:38:00 -04:00
// If the user hasn't selected spotlight and somebody starts screen sharing,
// automatically switch to spotlight mode and reset when screen sharing ends
2025-07-12 00:20:44 -04:00
this . scope . behavior < GridMode >(
this . gridModeUserSelection$ . pipe (
2025-06-18 17:14:21 -04:00
startWith ( null ),
switchMap (( userSelection ) =>
( userSelection === "spotlight"
? EMPTY
: combineLatest ([
this . hasRemoteScreenShares$ ,
this . windowMode$ ,
]). pipe (
skip ( userSelection === null ? 0 : 1 ),
map (
([ hasScreenShares , windowMode ]) : GridMode =>
hasScreenShares || windowMode === "flat"
? "spotlight"
: "grid" ,
),
)
). pipe ( startWith ( userSelection ?? "grid" )),
),
2025-07-12 00:20:44 -04:00
),
);
2024-01-20 20:39:12 -05:00
public setGridMode ( value : GridMode ) : void {
2024-12-17 04:01:56 +00:00
this . gridModeUserSelection$ . next ( value );
2024-01-20 20:39:12 -05:00
}
2024-12-17 04:01:56 +00:00
private readonly gridLayoutMedia$ : Observable < GridLayoutMedia > =
combineLatest ([ this . grid$ , this . spotlight$ ], ( grid , spotlight ) => ({
2024-07-25 17:51:00 -04:00
type : "grid" ,
spotlight : spotlight.some (( vm ) => vm instanceof ScreenShareViewModel )
? spotlight
: undefined ,
grid ,
2024-12-17 04:01:56 +00:00
}));
2024-07-25 17:51:00 -04:00
2024-12-17 04:01:56 +00:00
private readonly spotlightLandscapeLayoutMedia$ : Observable < SpotlightLandscapeLayoutMedia > =
combineLatest ([ this . grid$ , this . spotlight$ ], ( grid , spotlight ) => ({
2024-11-06 04:36:48 -05:00
type : "spotlight-landscape" ,
spotlight ,
grid ,
}));
2024-07-25 17:51:00 -04:00
2024-12-17 04:01:56 +00:00
private readonly spotlightPortraitLayoutMedia$ : Observable < SpotlightPortraitLayoutMedia > =
combineLatest ([ this . grid$ , this . spotlight$ ], ( grid , spotlight ) => ({
2024-11-06 04:36:48 -05:00
type : "spotlight-portrait" ,
spotlight ,
grid ,
}));
2024-07-25 17:51:00 -04:00
2024-12-17 04:01:56 +00:00
private readonly spotlightExpandedLayoutMedia$ : Observable < SpotlightExpandedLayoutMedia > =
combineLatest ([ this . spotlight$ , this . pip$ ], ( spotlight , pip ) => ({
2024-07-25 17:51:00 -04:00
type : "spotlight-expanded" ,
spotlight ,
pip : pip ?? undefined ,
2024-11-06 04:36:48 -05:00
}));
2024-07-25 17:51:00 -04:00
2024-12-17 04:01:56 +00:00
private readonly oneOnOneLayoutMedia$ : Observable < OneOnOneLayoutMedia | null > =
this . mediaItems$ . pipe (
2024-11-11 08:25:16 -05:00
map (( mediaItems ) => {
if ( mediaItems . length !== 2 ) return null ;
2024-12-06 12:28:37 +01:00
const local = mediaItems . find (( vm ) => vm . vm . local ) ? . vm as
| LocalUserMediaViewModel
| undefined ;
2024-11-11 08:25:16 -05:00
const remote = mediaItems . find (( vm ) => ! vm . vm . local ) ? . vm as
| RemoteUserMediaViewModel
| undefined ;
// There might not be a remote tile if there are screen shares, or if
// only the local user is in the call and they're using the duplicate
// tiles option
2024-12-06 12:28:37 +01:00
if ( ! remote || ! local ) return null ;
2024-11-11 08:25:16 -05:00
return { type : "one-on-one" , local , remote };
}),
2024-11-06 04:36:48 -05:00
);
2024-07-25 17:51:00 -04:00
2024-12-17 04:01:56 +00:00
private readonly pipLayoutMedia$ : Observable < LayoutMedia > =
this . spotlight$ . pipe ( map (( spotlight ) => ({ type : "pip" , spotlight })));
2024-07-25 17:51:00 -04:00
2024-11-06 04:36:48 -05:00
/**
* The media to be used to produce a layout.
*/
2025-07-12 00:20:44 -04:00
private readonly layoutMedia$ = this . scope . behavior < LayoutMedia >(
this . windowMode$ . pipe (
2024-12-17 04:01:56 +00:00
switchMap (( windowMode ) => {
switch ( windowMode ) {
case "normal" :
return this . gridMode$ . pipe (
switchMap (( gridMode ) => {
switch ( gridMode ) {
case "grid" :
return this . oneOnOneLayoutMedia$ . pipe (
switchMap (( oneOnOne ) =>
oneOnOne === null
? this . gridLayoutMedia$
: of ( oneOnOne ),
),
);
case "spotlight" :
return this . spotlightExpanded$ . pipe (
switchMap (( expanded ) =>
expanded
? this . spotlightExpandedLayoutMedia$
: this.spotlightLandscapeLayoutMedia$ ,
),
);
}
}),
);
case "narrow" :
return this . oneOnOneLayoutMedia$ . pipe (
switchMap (( oneOnOne ) =>
oneOnOne === null
? combineLatest (
[ this . grid$ , this . spotlight$ ],
( grid , spotlight ) =>
grid . length > smallMobileCallThreshold ||
spotlight . some (
( vm ) => vm instanceof ScreenShareViewModel ,
)
? this . spotlightPortraitLayoutMedia$
: this.gridLayoutMedia$ ,
). pipe ( switchAll ())
: // The expanded spotlight layout makes for a better one-on-one
// experience in narrow windows
this . spotlightExpandedLayoutMedia$ ,
),
);
case "flat" :
return this . gridMode$ . pipe (
switchMap (( gridMode ) => {
switch ( gridMode ) {
case "grid" :
// Yes, grid mode actually gets you a "spotlight" layout in
// this window mode.
return this . spotlightLandscapeLayoutMedia$ ;
case "spotlight" :
return this . spotlightExpandedLayoutMedia$ ;
}
}),
);
case "pip" :
return this . pipLayoutMedia$ ;
}
}),
2025-07-12 00:20:44 -04:00
),
);
2024-01-20 20:39:12 -05:00
2024-12-12 17:32:13 -05:00
// There is a cyclical dependency here: the layout algorithms want to know
// which tiles are on screen, but to know which tiles are on screen we have to
// first render a layout. To deal with this we assume initially that no tiles
// are visible, and loop the data back into the layouts with a Subject.
2024-12-17 04:01:56 +00:00
private readonly visibleTiles$ = new Subject < number >();
2024-12-12 17:32:13 -05:00
private readonly setVisibleTiles = ( value : number ) : void =>
2024-12-17 04:01:56 +00:00
this . visibleTiles$ . next ( value );
2024-12-12 17:32:13 -05:00
2025-07-12 00:20:44 -04:00
private readonly layoutInternals$ = this . scope . behavior <
2024-12-11 05:23:42 -05:00
LayoutScanState & { layout : Layout }
2025-07-12 00:20:44 -04:00
> (
combineLatest ([
this . layoutMedia$ ,
this . visibleTiles$ . pipe ( startWith ( 0 ), distinctUntilChanged ()),
]). pipe (
2025-06-18 17:14:21 -04:00
scan <
[ LayoutMedia , number ],
LayoutScanState & { layout : Layout },
LayoutScanState
> (
({ tiles : prevTiles }, [ media , visibleTiles ]) => {
let layout : Layout ;
let newTiles : TileStore ;
switch ( media . type ) {
case "grid" :
case "spotlight-landscape" :
case "spotlight-portrait" :
[ layout , newTiles ] = gridLikeLayout (
media ,
visibleTiles ,
this . setVisibleTiles ,
prevTiles ,
);
break ;
case "spotlight-expanded" :
[ layout , newTiles ] = spotlightExpandedLayout ( media , prevTiles );
break ;
case "one-on-one" :
[ layout , newTiles ] = oneOnOneLayout ( media , prevTiles );
break ;
case "pip" :
[ layout , newTiles ] = pipLayout ( media , prevTiles );
break ;
}
2024-11-06 04:36:48 -05:00
2025-06-18 17:14:21 -04:00
return { layout , tiles : newTiles };
},
{ layout : null , tiles : TileStore.empty () },
),
2025-07-12 00:20:44 -04:00
),
);
2024-12-11 05:23:42 -05:00
/**
* The layout of tiles in the call interface.
*/
2025-07-12 00:20:44 -04:00
public readonly layout$ = this . scope . behavior < Layout >(
this . layoutInternals$ . pipe ( map (({ layout }) => layout )),
);
2024-11-06 04:36:48 -05:00
2024-12-11 05:23:42 -05:00
/**
* The current generation of the tile store, exposed for debugging purposes.
*/
2025-07-12 00:20:44 -04:00
public readonly tileStoreGeneration$ = this . scope . behavior < number >(
this . layoutInternals$ . pipe ( map (({ tiles }) => tiles . generation )),
);
2024-12-11 05:23:42 -05:00
2025-07-12 00:20:44 -04:00
public showSpotlightIndicators$ = this . scope . behavior < boolean >(
this . layout$ . pipe ( map (( l ) => l . type !== "grid" )),
);
2024-07-03 15:08:30 -04:00
2025-07-12 00:20:44 -04:00
public showSpeakingIndicators$ = this . scope . behavior < boolean >(
this . layout$ . pipe (
2025-06-18 17:14:21 -04:00
switchMap (( l ) => {
switch ( l . type ) {
case "spotlight-landscape" :
case "spotlight-portrait" :
// If the spotlight is showing the active speaker, we can do without
// speaking indicators as they're a redundant visual cue. But if
// screen sharing feeds are in the spotlight we still need them.
return l . spotlight . media$ . pipe (
map (( models : MediaViewModel []) =>
models . some (( m ) => m instanceof ScreenShareViewModel ),
2024-08-01 13:49:09 -04:00
),
2025-06-18 17:14:21 -04:00
);
// In expanded spotlight layout, the active speaker is always shown in
// the picture-in-picture tile so there is no need for speaking
// indicators. And in one-on-one layout there's no question as to who is
// speaking.
case "spotlight-expanded" :
case "one-on-one" :
return of ( false );
default :
return of ( true );
}
}),
2025-07-12 00:20:44 -04:00
),
);
public readonly toggleSpotlightExpanded$ = this . scope . behavior <
(() => void ) | null
> (
this . windowMode$ . pipe (
switchMap (( mode ) =>
mode === "normal"
? this . layout$ . pipe (
map (
( l ) =>
l . type === "spotlight-landscape" ||
l . type === "spotlight-expanded" ,
),
)
: of ( false ),
),
distinctUntilChanged (),
map (( enabled ) =>
enabled ? () : void => this . spotlightExpandedToggle$ . next () : null ,
),
),
);
2024-07-26 06:57:49 -04:00
2024-12-17 04:01:56 +00:00
private readonly screenTap$ = new Subject < void >();
private readonly controlsTap$ = new Subject < void >();
private readonly screenHover$ = new Subject < void >();
private readonly screenUnhover$ = new Subject < void >();
2024-08-08 17:21:47 -04:00
/**
* Callback for when the user taps the call view.
*/
public tapScreen () : void {
2024-12-17 04:01:56 +00:00
this . screenTap$ . next ();
2024-08-08 17:21:47 -04:00
}
2024-11-08 10:23:19 -05:00
/**
* Callback for when the user taps the call's controls.
*/
public tapControls () : void {
2024-12-17 04:01:56 +00:00
this . controlsTap$ . next ();
2024-11-08 10:23:19 -05:00
}
2024-08-08 17:21:47 -04:00
/**
* Callback for when the user hovers over the call view.
*/
public hoverScreen () : void {
2024-12-17 04:01:56 +00:00
this . screenHover$ . next ();
2024-08-08 17:21:47 -04:00
}
/**
* Callback for when the user stops hovering over the call view.
*/
public unhoverScreen () : void {
2024-12-17 04:01:56 +00:00
this . screenUnhover$ . next ();
2024-08-08 17:21:47 -04:00
}
2025-07-12 00:20:44 -04:00
public readonly showHeader$ = this . scope . behavior < boolean >(
this . windowMode$ . pipe ( map (( mode ) => mode !== "pip" && mode !== "flat" )),
);
2024-08-08 17:21:47 -04:00
2025-07-12 00:20:44 -04:00
public readonly showFooter$ = this . scope . behavior < boolean >(
this . windowMode$ . pipe (
2025-06-18 17:14:21 -04:00
switchMap (( mode ) => {
switch ( mode ) {
case "pip" :
return of ( false );
case "normal" :
case "narrow" :
return of ( true );
case "flat" :
// Sadly Firefox has some layering glitches that prevent the footer
// from appearing properly. They happen less often if we never hide
// the footer.
if ( isFirefox ()) return of ( true );
// Show/hide the footer in response to interactions
return merge (
this . screenTap$ . pipe ( map (() => "tap screen" as const )),
this . controlsTap$ . pipe ( map (() => "tap controls" as const )),
this . screenHover$ . pipe ( map (() => "hover" as const )),
). pipe (
switchScan (( state , interaction ) => {
switch ( interaction ) {
case "tap screen" :
return state
? // Toggle visibility on tap
of ( false )
: // Hide after a timeout
timer ( showFooterMs ). pipe (
map (() => false ),
startWith ( true ),
);
case "tap controls" :
// The user is interacting with things, so reset the timeout
return timer ( showFooterMs ). pipe (
map (() => false ),
startWith ( true ),
);
case "hover" :
// Show on hover and hide after a timeout
return race (
timer ( showFooterMs ),
this . screenUnhover$ . pipe ( take ( 1 )),
). pipe (
map (() => false ),
startWith ( true ),
);
}
}, false ),
startWith ( false ),
);
}
}),
2025-07-12 00:20:44 -04:00
),
);
2024-12-19 15:54:28 +00:00
2025-06-26 05:08:57 -04:00
/**
* Whether audio is currently being output through the earpiece.
*/
2025-07-12 00:20:44 -04:00
public readonly earpieceMode$ = this . scope . behavior < boolean >(
combineLatest (
[
this . mediaDevices . audioOutput . available$ ,
this . mediaDevices . audioOutput . selected$ ,
],
( available , selected ) =>
selected !== undefined &&
available . get ( selected . id ) ? . type === "earpiece" ,
),
);
2025-06-26 05:08:57 -04:00
/**
* Callback to toggle between the earpiece and the loudspeaker.
*
* This will be `null` in case the target does not exist in the list
* of available audio outputs.
*/
2025-07-12 00:20:44 -04:00
public readonly audioOutputSwitcher$ = this . scope . behavior < {
2025-06-26 05:08:57 -04:00
targetOutput : "earpiece" | "speaker" ;
switch : () => void ;
2025-07-12 00:20:44 -04:00
} | null > (
combineLatest (
[
this . mediaDevices . audioOutput . available$ ,
this . mediaDevices . audioOutput . selected$ ,
],
( available , selected ) => {
const selectionType = selected && available . get ( selected . id ) ? . type ;
2025-07-14 19:03:18 +02:00
// If we are in any output mode other than speaker switch to speaker.
2025-07-12 00:20:44 -04:00
const newSelectionType : "earpiece" | "speaker" =
selectionType === "speaker" ? "earpiece" : "speaker" ;
const newSelection = [... available ]. find (
([, d ]) => d . type === newSelectionType ,
);
if ( newSelection === undefined ) return null ;
2025-06-26 05:08:57 -04:00
2025-07-12 00:20:44 -04:00
const [ id ] = newSelection ;
return {
targetOutput : newSelectionType ,
switch : () : void => this . mediaDevices . audioOutput . select ( id ),
};
},
),
);
2025-06-26 05:08:57 -04:00
2024-12-19 15:54:28 +00:00
/**
* Emits an array of reactions that should be visible on the screen.
*/
2025-10-28 21:18:47 +01:00
// DISCUSSION move this into a reaction file
// const {visibleReactions$, audibleReactions$} = reactionsObservables$(showReactionSetting$, )
2025-07-12 00:20:44 -04:00
public readonly visibleReactions$ = this . scope . behavior (
showReactions . value$ . pipe (
2025-06-18 17:14:21 -04:00
switchMap (( show ) => ( show ? this . reactions$ : of ({}))),
scan <
Record < string , ReactionOption >,
{ sender : string ; emoji : string ; startX : number }[]
> (( acc , latest ) => {
const newSet : { sender : string ; emoji : string ; startX : number }[] = [];
for ( const [ sender , reaction ] of Object . entries ( latest )) {
const startX =
acc . find (( v ) => v . sender === sender && v . emoji ) ? . startX ??
Math . ceil ( Math . random () * 80 ) + 10 ;
newSet . push ({ sender , emoji : reaction.emoji , startX });
}
return newSet ;
}, []),
2025-07-12 00:20:44 -04:00
),
);
2024-12-19 15:54:28 +00:00
/**
* Emits an array of reactions that should be played.
*/
public readonly audibleReactions$ = playReactionsSound . value$ . pipe (
switchMap (( show ) =>
show ? this . reactions$ : of < Record < string , ReactionOption >>({}),
),
map (( reactions ) => Object . values ( reactions ). map (( v ) => v . name )),
scan < string [] , { playing : string []; newSounds : string [] }>(
( acc , latest ) => {
return {
playing : latest.filter (
( v ) => acc . playing . includes ( v ) || acc . newSounds . includes ( v ),
),
newSounds : latest.filter (
( v ) => ! acc . playing . includes ( v ) && ! acc . newSounds . includes ( v ),
),
};
},
{ playing : [], newSounds : [] },
),
map (( v ) => v . newSounds ),
);
/**
* Emits an event every time a new hand is raised in
* the call.
*/
public readonly newHandRaised$ = this . handsRaised$ . pipe (
map (( v ) => Object . keys ( v ). length ),
scan (
( acc , newValue ) => ({
value : newValue ,
playSounds : newValue > acc . value ,
}),
{ value : 0 , playSounds : false },
),
filter (( v ) => v . playSounds ),
);
2024-12-19 12:37:10 +00:00
/**
* Emits an event every time a new screenshare is started in
* the call.
*/
public readonly newScreenShare$ = this . screenShares$ . pipe (
map (( v ) => v . length ),
scan (
( acc , newValue ) => ({
value : newValue ,
playSounds : newValue > acc . value ,
}),
{ value : 0 , playSounds : false },
),
filter (( v ) => v . playSounds ),
);
2024-08-08 17:21:47 -04:00
2025-09-24 13:54:54 -04:00
/**
* Whether we are sharing our screen.
*/
2025-11-05 12:56:58 +01:00
// TODO move to LocalMembership
2025-09-24 13:54:54 -04:00
public readonly sharingScreen$ = this . scope . behavior (
2025-10-03 14:43:22 -04:00
from ( this . localConnection$ ). pipe (
switchMap (( c ) =>
c ? . state === "ready"
? sharingScreen$ ( c . value . livekitRoom . localParticipant )
: of ( false ),
),
2025-09-24 13:54:54 -04:00
),
);
/**
* Callback for toggling screen sharing. If null, screen sharing is not
* available.
*/
2025-11-05 12:56:58 +01:00
// TODO move to LocalMembership
2025-09-24 13:54:54 -04:00
public readonly toggleScreenSharing =
"getDisplayMedia" in ( navigator . mediaDevices ?? {}) &&
! this . urlParams . hideScreensharing
? () : void =>
2025-10-03 14:43:22 -04:00
// Once a connection is ready...
void this . localConnection$
. pipe (
takeWhile (( c ) => c !== null && c . state !== "error" ),
switchMap (( c ) => ( c . state === "ready" ? of ( c . value ) : NEVER )),
take ( 1 ),
this . scope . bind (),
)
// ...toggle screen sharing.
. subscribe (
( c ) =>
void c . livekitRoom . localParticipant
. setScreenShareEnabled ( ! this . sharingScreen$ . value , {
audio : true ,
selfBrowserSurface : "include" ,
surfaceSwitching : "include" ,
systemAudio : "include" ,
})
. catch ( logger . error ),
)
2025-09-24 13:54:54 -04:00
: null ;
2023-11-30 22:59:19 -05:00
public constructor (
2025-10-16 13:57:08 -04:00
private readonly scope : ObservableScope ,
2025-08-27 14:01:01 +02:00
// A call is permanently tied to a single Matrix room
2024-12-06 12:28:37 +01:00
private readonly matrixRTCSession : MatrixRTCSession ,
2025-08-15 18:32:37 +02:00
private readonly matrixRoom : MatrixRoom ,
2025-06-26 05:08:57 -04:00
private readonly mediaDevices : MediaDevices ,
2025-08-29 18:46:24 +02:00
private readonly muteStates : MuteStates ,
2025-08-08 17:15:47 +02:00
private readonly options : CallViewModelOptions ,
2024-12-19 15:54:28 +00:00
private readonly handsRaisedSubject$ : Observable <
Record < string , RaisedHandInfo >
> ,
private readonly reactionsSubject$ : Observable <
Record < string , ReactionInfo >
> ,
2025-10-30 01:13:06 +01:00
private readonly trackProcessorState$ : Behavior < ProcessorState >,
2023-11-30 22:59:19 -05:00
) {
2025-08-27 15:33:41 +02:00
// Join automatically
this . join (); // TODO-MULTI-SFU: Use this view model for the lobby as well, and only call this once 'join' is clicked?
2023-11-30 22:59:19 -05:00
}
}
2025-08-27 18:41:03 +02:00
2025-08-28 17:45:14 +02:00
// TODO-MULTI-SFU // Setup and update the keyProvider which was create by `createRoom` was a thing before. Now we never update if the E2EEsystem changes
// do we need this?
2025-09-15 17:49:07 +02:00
function getE2eeKeyProvider (
2025-08-28 17:45:14 +02:00
e2eeSystem : EncryptionSystem ,
rtcSession : MatrixRTCSession ,
2025-09-15 17:49:07 +02:00
) : BaseKeyProvider | undefined {
2025-08-28 17:45:14 +02:00
if ( e2eeSystem . kind === E2eeType . NONE ) return undefined ;
if ( e2eeSystem . kind === E2eeType . PER_PARTICIPANT ) {
const keyProvider = new MatrixKeyProvider ();
keyProvider . setRTCSession ( rtcSession );
2025-09-15 17:49:07 +02:00
return keyProvider ;
2025-08-28 17:45:14 +02:00
} else if ( e2eeSystem . kind === E2eeType . SHARED_KEY && e2eeSystem . secret ) {
const keyProvider = new ExternalE2EEKeyProvider ();
keyProvider
. setKey ( e2eeSystem . secret )
. catch (( e ) => logger . error ( "Failed to set shared key for E2EE" , e ));
2025-09-15 17:49:07 +02:00
return keyProvider ;
2025-08-28 17:45:14 +02:00
}
}