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 ,
2024-12-11 09:27:55 +00:00
type LocalParticipant ,
2025-10-08 16:40:06 -04:00
RemoteParticipant ,
2025-10-13 15:43:12 +02:00
type Room as LivekitRoom ,
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-20 13:30:21 +02:00
ClientEvent ,
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-10-13 15:43:12 +02:00
type RoomMember ,
RoomStateEvent ,
SyncState ,
2025-08-15 18:32:37 +02:00
} from "matrix-js-sdk" ;
2025-10-03 14:43:22 -04:00
import { deepCompare } from "matrix-js-sdk/lib/utils" ;
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 ,
2025-10-03 14:43:22 -04:00
repeat ,
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 {
2025-01-14 14:46:39 +00:00
type CallMembership ,
2025-09-30 16:47:45 +02:00
isLivekitTransport ,
2025-10-03 14:43:22 -04:00
type LivekitTransport ,
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-08-20 13:33:36 +02:00
MembershipManagerEvent ,
2025-08-20 20:47:20 +02:00
Status ,
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 { ViewModel } from "./ViewModel" ;
import {
2024-05-16 12:32:18 -04:00
LocalUserMediaViewModel ,
2024-12-11 09:27:55 +00:00
type MediaViewModel ,
2024-05-16 12:32:18 -04:00
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-08-20 18:51:03 +02:00
import {
accumulate ,
and$ ,
finalizeValue ,
pauseWhen ,
} from "../utils/observable" ;
2024-12-19 15:54:28 +00:00
import {
duplicateTiles ,
2025-10-03 14:43:22 -04:00
multiSfu ,
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-01-14 14:46:39 +00:00
import { calculateDisplayName , shouldDisambiguate } from "../utils/displayname" ;
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 {
enterRTCSession ,
getLivekitAlias ,
2025-10-03 14:43:22 -04:00
makeTransport ,
2025-08-27 14:01:01 +02:00
} from "../rtcSessionHelpers" ;
import { E2eeType } from "../e2ee/e2eeType" ;
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider" ;
2025-10-07 16:24:02 +02:00
import {
type Connection ,
type ConnectionOpts ,
RemoteConnection ,
} from "./Connection" ;
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-09-30 11:33:45 +02:00
import { PublishConnection } from "./PublishConnection.ts" ;
2025-10-07 16:00:59 +02:00
import { type Async , async $ , mapAsync , ready } from "./Async" ;
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 {
GridLayoutMedia ,
Layout ,
LayoutMedia ,
OneOnOneLayoutMedia ,
SpotlightExpandedLayoutMedia ,
SpotlightLandscapeLayoutMedia ,
SpotlightPortraitLayoutMedia ,
} from "./layout-types.ts" ;
2024-01-20 20:39:12 -05:00
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-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-05-02 16:32:48 -04:00
* Sorting bins defining the order in which media tiles appear in the layout.
2024-01-20 20:39:12 -05:00
*/
2024-05-02 16:32:48 -04:00
enum SortingBin {
2024-07-17 15:37:41 -04:00
/**
* Yourself, when the "always show self" option is on.
*/
2024-05-16 13:55:31 -04:00
SelfAlwaysShown ,
2024-07-17 15:37:41 -04:00
/**
* Participants that are sharing their screen.
*/
2024-01-20 20:39:12 -05:00
Presenters ,
2024-07-17 15:37:41 -04:00
/**
* Participants that have been speaking recently.
*/
2024-01-20 20:39:12 -05:00
Speakers ,
2024-12-19 15:54:28 +00:00
/**
* Participants that have their hand raised.
*/
HandRaised ,
2024-07-17 15:37:41 -04:00
/**
2024-07-26 05:27:22 -04:00
* Participants with video.
2024-07-17 15:37:41 -04:00
*/
2024-01-20 20:39:12 -05:00
Video ,
2024-07-17 15:37:41 -04:00
/**
2024-07-26 05:27:22 -04:00
* Participants not sharing any video.
2024-07-17 15:37:41 -04:00
*/
2024-07-26 05:27:22 -04:00
NoVideo ,
2024-07-17 15:37:41 -04:00
/**
* Yourself, when the "always show self" option is off.
*/
2024-05-16 13:55:31 -04:00
SelfNotAlwaysShown ,
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
export class CallViewModel extends ViewModel {
2025-09-24 13:54:54 -04:00
private readonly urlParams = getUrlParams ();
2025-08-28 17:45:14 +02:00
private readonly livekitAlias = getLivekitAlias ( this . matrixRTCSession );
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-09-16 10:13:14 +02:00
private readonly e2eeLivekitOptions = () : E2EEOptions | undefined =>
this . livekitE2EEKeyProvider
? {
keyProvider : this.livekitE2EEKeyProvider ,
worker : new E2EEWorker (),
}
: undefined ;
2025-08-27 14:01:01 +02:00
2025-10-03 14:43:22 -04:00
private readonly join$ = new Subject < void >();
2025-08-27 14:01:01 +02:00
2025-10-03 14:43:22 -04:00
public join () : void {
this . join$ . next ();
}
2025-08-27 14:01:01 +02:00
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.
*/
private readonly joined$ = this . scope . behavior (
this . join$ . pipe (
map (() => true ),
// Using takeUntil with the repeat operator is perfectly valid.
// eslint-disable-next-line rxjs/no-unsafe-takeuntil
takeUntil ( this . leaveHoisted$ ),
endWith ( false ),
repeat (),
startWith ( false ),
2025-09-16 16:52:17 +02:00
),
);
2025-10-01 10:06:43 +02:00
2025-09-24 21:39:36 -04:00
/**
* The MatrixRTC session participants.
*/
// Note that MatrixRTCSession already filters the call memberships by users
// that are joined to the room; we don't need to perform extra filtering here.
2025-09-25 21:29:02 -04:00
private readonly memberships$ = this . scope . behavior (
2025-09-15 17:49:07 +02:00
fromEvent (
this . matrixRTCSession ,
MatrixRTCSessionEvent . MembershipsChanged ,
). pipe (
startWith ( null ),
map (() => this . matrixRTCSession . memberships ),
),
);
2025-10-03 14:43:22 -04:00
/**
* The transport that we would personally prefer to publish on (if not for the
* transport preferences of others, perhaps).
*/
private readonly preferredTransport = makeTransport ( this . matrixRTCSession );
/**
* Lists the transports used by ourselves, plus all other MatrixRTC session
* members.
*/
private readonly transports$ : Behavior < {
local : Async < LivekitTransport >;
remote : { membership : CallMembership ; transport : LivekitTransport }[];
} | null > = this . scope . behavior (
this . joined$ . pipe (
switchMap (( joined ) =>
joined
? combineLatest (
[
2025-10-07 16:00:59 +02:00
async $ ( this . preferredTransport ),
2025-10-03 14:43:22 -04:00
this . memberships$ ,
multiSfu . value$ ,
],
( preferred , memberships , multiSfu ) => {
2025-10-08 01:04:58 -04:00
const oldestMembership =
this . matrixRTCSession . getOldestMembership ();
2025-10-03 14:43:22 -04:00
const remote = memberships . flatMap (( m ) => {
if ( m . sender === this . userId && m . deviceId === this . deviceId )
return [];
2025-10-08 01:04:58 -04:00
const t = m . getTransport ( oldestMembership ?? m );
2025-10-03 14:43:22 -04:00
return t && isLivekitTransport ( t )
? [{ membership : m , transport : t }]
: [];
});
let local = preferred ;
if ( ! multiSfu ) {
const oldest = this . matrixRTCSession . getOldestMembership ();
if ( oldest !== undefined ) {
const selection = oldest . getTransport ( oldest );
2025-10-10 11:09:41 +02:00
// TODO selection can be null if no transport is configured should we report an error?
if ( selection && isLivekitTransport ( selection ))
local = ready ( selection );
2025-10-03 14:43:22 -04:00
}
}
return { local , remote };
},
)
: of ( null ),
2025-08-28 15:32:46 +02:00
),
2025-08-27 14:01:01 +02:00
),
);
2025-10-03 14:43:22 -04:00
/**
* Lists the transports used by each MatrixRTC session member other than
* ourselves.
*/
private readonly remoteTransports$ = this . scope . behavior (
this . transports$ . pipe ( map (( transports ) => transports ? . remote ?? [])),
2025-08-28 15:32:46 +02:00
);
2025-10-03 14:43:22 -04:00
/**
* The transport over which we should be actively publishing our media.
2025-10-08 17:35:53 -04:00
* null when not joined.
2025-10-03 14:43:22 -04:00
*/
2025-10-08 17:35:53 -04:00
private readonly localTransport$ : Behavior < Async < LivekitTransport > | null > =
2025-10-03 14:43:22 -04:00
this . scope . behavior (
this . transports$ . pipe (
map (( transports ) => transports ? . local ?? null ),
2025-10-08 17:35:53 -04:00
distinctUntilChanged < Async < LivekitTransport > | null >( deepCompare ),
2025-10-03 14:43:22 -04:00
),
);
2025-10-08 17:35:53 -04:00
/**
* The local connection over which we will publish our media. It could
* possibly also have some remote users' media available on it.
* null when not joined.
*/
private readonly localConnection$ : Behavior < Async < PublishConnection > | null > =
2025-10-08 14:30:52 +02:00
this . scope . behavior (
this . localTransport$ . pipe (
map (
( transport ) =>
transport &&
mapAsync ( transport , ( transport ) => {
const opts : ConnectionOpts = {
transport ,
client : this.matrixRTCSession.room.client ,
scope : this.scope ,
remoteTransports$ : this.remoteTransports$ ,
};
return new PublishConnection (
2025-10-07 16:24:02 +02:00
opts ,
this . mediaDevices ,
this . muteStates ,
this . e2eeLivekitOptions (),
this . scope . behavior ( this . trackProcessorState$ ),
2025-10-08 14:30:52 +02:00
);
}),
),
2025-08-28 15:32:46 +02:00
),
2025-10-08 14:30:52 +02:00
);
2025-08-28 15:32:46 +02:00
2025-10-03 14:43:22 -04:00
public readonly livekitConnectionState$ = this . scope . behavior (
this . localConnection$ . pipe (
switchMap (( c ) =>
c ? . state === "ready"
2025-10-07 16:24:02 +02:00
? // TODO mapping to ConnectionState for compatibility, but we should use the full state?
2025-10-08 18:10:26 -04:00
c . value . focusConnectionState$ . pipe (
2025-10-07 16:24:02 +02:00
map (( s ) => {
if ( s . state === "ConnectedToLkRoom" ) return s . connectionState ;
return ConnectionState . Disconnected ;
}),
distinctUntilChanged (),
)
2025-10-03 14:43:22 -04:00
: of ( ConnectionState . Disconnected ),
),
),
);
/**
* Connections for each transport in use by one or more session members that
* is *distinct* from the local transport.
*/
2025-08-28 13:37:17 +02:00
private readonly remoteConnections$ = this . scope . behavior (
2025-10-03 14:43:22 -04:00
this . transports$ . pipe (
accumulate ( new Map < string , Connection >(), ( prev , transports ) => {
const next = new Map < string , Connection >();
// Until the local transport becomes ready we have no idea which
// transports will actually need a dedicated remote connection
if ( transports ? . local . state === "ready" ) {
2025-10-08 01:04:58 -04:00
const oldestMembership = this . matrixRTCSession . getOldestMembership ();
2025-10-03 14:43:22 -04:00
const localServiceUrl = transports . local . value . livekit_service_url ;
const remoteServiceUrls = new Set (
transports . remote . flatMap (({ membership , transport }) => {
2025-10-08 01:04:58 -04:00
const t = membership . getTransport ( oldestMembership ?? membership );
2025-10-03 14:43:22 -04:00
return t &&
isLivekitTransport ( t ) &&
t . livekit_service_url !== localServiceUrl
? [ t . livekit_service_url ]
: [];
}),
);
for ( const remoteServiceUrl of remoteServiceUrls ) {
let nextConnection = prev . get ( remoteServiceUrl );
if ( ! nextConnection ) {
logger . log (
"SFU remoteConnections$ construct new connection: " ,
remoteServiceUrl ,
);
2025-10-07 10:33:31 +02:00
const args : ConnectionOpts = {
transport : {
type : "livekit" ,
2025-10-03 14:43:22 -04:00
livekit_service_url : remoteServiceUrl ,
livekit_alias : this.livekitAlias ,
},
2025-10-07 10:33:31 +02:00
client : this.matrixRTCSession.room.client ,
scope : this.scope ,
remoteTransports$ : this.remoteTransports$ ,
2025-10-07 16:24:02 +02:00
};
nextConnection = new RemoteConnection (
args ,
this . e2eeLivekitOptions (),
);
2025-10-03 14:43:22 -04:00
} else {
logger . log (
"SFU remoteConnections$ use prev connection: " ,
remoteServiceUrl ,
);
2025-08-28 13:37:17 +02:00
}
2025-10-03 14:43:22 -04:00
next . set ( remoteServiceUrl , nextConnection );
2025-08-28 13:37:17 +02:00
}
2025-10-03 14:43:22 -04:00
}
2025-08-27 14:01:01 +02:00
2025-10-03 14:43:22 -04:00
return next ;
}),
map (( transports ) => [... transports . values ()]),
2025-08-28 13:37:17 +02:00
),
2025-08-27 14:01:01 +02:00
);
2025-10-03 14:43:22 -04:00
/**
* A list of the connections that should be active at any given time.
*/
private readonly connections$ = this . scope . behavior < Connection [] >(
combineLatest (
[ this . localConnection$ , this . remoteConnections$ ],
( local , remote ) => [
...( local ? . state === "ready" ? [ local . value ] : []),
... remote . values (),
],
),
);
2025-08-27 14:01:01 +02:00
2025-10-03 21:00:45 -04:00
/**
* Emits with connections whenever they should be started or stopped.
*/
2025-10-03 14:43:22 -04:00
private readonly connectionInstructions$ = this . connections$ . pipe (
2025-08-27 14:01:01 +02:00
pairwise (),
map (([ prev , next ]) => {
const start = new Set ( next . values ());
2025-10-03 14:43:22 -04:00
for ( const connection of prev ) start . delete ( connection );
2025-08-27 14:01:01 +02:00
const stop = new Set ( prev . values ());
2025-10-03 14:43:22 -04:00
for ( const connection of next ) stop . delete ( connection );
2025-08-27 14:01:01 +02:00
return { start , stop };
}),
);
2025-09-16 11:31:47 +02:00
public readonly allLivekitRooms$ = this . scope . behavior (
2025-10-03 14:43:22 -04:00
this . connections$ . pipe (
map (( connections ) =>
[... connections . values ()]. map (( c ) => ({
room : c.livekitRoom ,
2025-10-07 10:33:31 +02:00
url : c.localTransport.livekit_service_url ,
2025-10-03 14:43:22 -04:00
isLocal : c instanceof PublishConnection ,
})),
2025-09-16 11:31:47 +02:00
),
),
);
2025-10-10 11:09:41 +02:00
private readonly userId = this . matrixRoom . client . getUserId () ! ;
private readonly deviceId = this . matrixRoom . client . getDeviceId () ! ;
2025-08-15 18:38:52 +02:00
2025-08-20 13:30:21 +02:00
private readonly matrixConnected$ = this . scope . behavior (
// To consider ourselves connected to MatrixRTC, we check the following:
and$ (
// The client is connected to the sync loop
(
fromEvent ( this . matrixRoom . client , ClientEvent . Sync ) as Observable <
[ SyncState ]
>
). pipe (
startWith ([ this . matrixRoom . client . getSyncState ()]),
map (([ state ]) => state === SyncState . Syncing ),
),
2025-08-20 20:47:20 +02:00
// Room state observed by session says we're connected
fromEvent (
this . matrixRTCSession ,
MembershipManagerEvent . StatusChanged ,
). pipe (
startWith ( null ),
2025-09-03 13:03:48 +02:00
map (() => this . matrixRTCSession . membershipStatus === Status . Connected ),
2025-08-15 18:38:52 +02:00
),
2025-08-20 13:33:36 +02:00
// Also watch out for warnings that we've likely hit a timeout and our
// delayed leave event is being sent (this condition is here because it
// provides an earlier warning than the sync loop timeout, and we wouldn't
// see the actual leave event until we reconnect to the sync loop)
2025-08-20 20:47:20 +02:00
fromEvent (
this . matrixRTCSession ,
MembershipManagerEvent . ProbablyLeft ,
2025-08-20 13:33:36 +02:00
). pipe (
2025-08-20 20:47:20 +02:00
startWith ( null ),
2025-09-03 13:03:48 +02:00
map (() => this . matrixRTCSession . probablyLeft !== true ),
2025-08-20 13:33:36 +02:00
),
2025-08-15 18:38:52 +02:00
),
);
2025-08-20 13:32:42 +02:00
private readonly connected$ = this . scope . behavior (
and$ (
this . matrixConnected$ ,
2025-09-16 16:52:17 +02:00
this . livekitConnectionState$ . pipe (
map (( state ) => state === ConnectionState . Connected ),
),
2025-08-20 13:32:42 +02:00
),
);
2025-08-20 13:30:21 +02:00
/**
* Whether we should tell the user that we're reconnecting to the call.
*/
2025-08-15 18:38:52 +02:00
public readonly reconnecting$ = this . scope . behavior (
2025-08-20 13:30:21 +02:00
this . connected$ . pipe (
2025-08-15 18:38:52 +02:00
// We are reconnecting if we previously had some successful initial
// connection but are now disconnected
scan (
({ connectedPreviously , reconnecting }, connectedNow ) => ({
connectedPreviously : connectedPreviously || connectedNow ,
reconnecting : connectedPreviously && ! connectedNow ,
}),
{ connectedPreviously : false , reconnecting : false },
),
map (({ reconnecting }) => reconnecting ),
),
2025-08-08 17:15: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.
private readonly pretendToBeDisconnected$ = this . reconnecting$ ;
2025-10-03 14:43:22 -04:00
/**
* Lists, for each LiveKit room, the LiveKit participants whose media should
* be presented.
*/
2025-10-08 16:40:06 -04:00
private readonly participantsByRoom$ = this . scope . behavior <
2025-08-29 18:46:24 +02:00
{
livekitRoom : LivekitRoom ;
2025-09-25 21:29:02 -04:00
url : string ;
participants : {
2025-10-03 19:14:48 -04:00
id : string ;
participant : LocalParticipant | RemoteParticipant | undefined ;
2025-09-25 21:29:02 -04:00
member : RoomMember ;
}[];
2025-08-29 18:46:24 +02:00
}[]
> (
2025-10-03 14:43:22 -04:00
// TODO: Move this logic into Connection/PublishConnection if possible
2025-10-08 14:30:52 +02:00
this . localConnection$
2025-08-29 18:46:24 +02:00
. pipe (
2025-10-08 17:35:53 -04:00
switchMap (( localConnection ) => {
if ( localConnection ? . state !== "ready" ) return [];
2025-08-28 17:45:14 +02:00
const memberError = () : never => {
throw new Error ( "No room member for call membership" );
};
const localParticipant = {
2025-10-03 19:14:48 -04:00
id : "local" ,
2025-10-08 17:35:53 -04:00
participant : localConnection.value.livekitRoom.localParticipant ,
2025-08-28 17:45:14 +02:00
member :
this.matrixRoom.getMember ( this . userId ?? "" ) ?? memberError (),
};
2025-09-25 21:29:02 -04:00
2025-08-28 17:45:14 +02:00
return this . remoteConnections$ . pipe (
2025-10-03 14:43:22 -04:00
switchMap (( remoteConnections ) =>
2025-08-28 17:45:14 +02:00
combineLatest (
2025-10-08 17:35:53 -04:00
[ localConnection . value , ... remoteConnections ]. map (( c ) =>
2025-08-28 17:45:14 +02:00
c . publishingParticipants$ . pipe (
2025-09-25 21:29:02 -04:00
map (( ps ) => {
const participants : {
2025-10-03 19:14:48 -04:00
id : string ;
participant :
| LocalParticipant
| RemoteParticipant
| undefined ;
2025-09-25 21:29:02 -04:00
member : RoomMember ;
}[] = ps . map (({ participant , membership }) => ({
2025-10-03 19:14:48 -04:00
id : ` ${ membership . sender } : ${ membership . deviceId } ` ,
2025-08-28 17:45:14 +02:00
participant ,
member :
getRoomMemberFromRtcMember (
membership ,
this . matrixRoom ,
) ? . member ?? memberError (),
2025-09-25 21:29:02 -04:00
}));
2025-10-08 17:35:53 -04:00
if ( c === localConnection . value )
2025-09-25 21:29:02 -04:00
participants . push ( localParticipant );
return {
2025-08-28 17:45:14 +02:00
livekitRoom : c.livekitRoom ,
2025-10-07 10:33:31 +02:00
url : c.localTransport.livekit_service_url ,
2025-09-25 21:29:02 -04:00
participants ,
};
}),
2025-08-28 17:45:14 +02:00
),
),
),
),
2025-08-28 10:34:43 +02:00
);
2025-08-28 17:45:14 +02:00
}),
2025-08-29 18:46:24 +02:00
)
. pipe ( startWith ([]), pauseWhen ( this . pretendToBeDisconnected$ )),
);
2025-08-20 18:51:03 +02:00
2025-10-08 16:40:06 -04:00
/**
* Lists, for each LiveKit room, the LiveKit participants whose audio should
* be rendered.
*/
// (This is effectively just participantsByRoom$ with a stricter type)
public readonly audioParticipants$ = this . scope . behavior (
this . participantsByRoom$ . pipe (
map (( data ) =>
data . map (({ livekitRoom , url , participants }) => ({
livekitRoom ,
url ,
participants : participants.flatMap (({ participant }) =>
participant instanceof RemoteParticipant ? [ participant ] : [],
),
})),
),
),
);
2025-01-14 14:46:39 +00:00
/**
* Displaynames for each member of the call. This will disambiguate
* any displaynames that clashes with another member. Only members
* joined to the call are considered here.
*/
2025-08-20 18:51:03 +02:00
// It turns out that doing the disambiguation above is rather expensive on Safari (10x slower
// 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:
public readonly memberDisplaynames$ = this . scope . behavior (
2025-09-24 21:39:36 -04:00
combineLatest (
[
// Handle call membership changes
this . memberships$ ,
// Additionally handle display name changes (implicitly reacting to them)
fromEvent ( this . matrixRoom , RoomStateEvent . Members ). pipe (
startWith ( null ),
),
// TODO: do we need: pauseWhen(this.pretendToBeDisconnected$),
],
( memberships , _displaynames ) => {
2025-08-29 18:46:24 +02:00
const displaynameMap = new Map < string , string >([
2025-10-10 11:09:41 +02:00
[
"local" ,
this . matrixRoom . getMember ( this . userId ) ? . rawDisplayName ??
this . userId ,
],
2025-08-29 18:46:24 +02:00
]);
2025-08-20 18:51:03 +02:00
const room = this . matrixRoom ;
// We only consider RTC members for disambiguation as they are the only visible members.
for ( const rtcMember of memberships ) {
const matrixIdentifier = ` ${ rtcMember . sender } : ${ rtcMember . deviceId } ` ;
const { member } = getRoomMemberFromRtcMember ( rtcMember , room );
if ( ! member ) {
logger . error (
"Could not find member for media id:" ,
matrixIdentifier ,
);
continue ;
}
const disambiguate = shouldDisambiguate ( member , memberships , room );
displaynameMap . set (
matrixIdentifier ,
calculateDisplayName ( member , disambiguate ),
);
2025-01-14 14:46:39 +00:00
}
2025-08-20 18:51:03 +02:00
return displaynameMap ;
2025-09-24 21:39:36 -04:00
},
2025-08-20 18:51:03 +02:00
),
2025-07-12 00:20:44 -04:00
);
2025-01-14 14:46:39 +00: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
/**
* List of MediaItems that we want to display
*/
2025-07-12 00:20:44 -04:00
private readonly mediaItems$ = this . scope . behavior < MediaItem [] >(
2025-10-03 14:43:22 -04:00
combineLatest ([ this . participantsByRoom$ , duplicateTiles . value$ ]). pipe (
scan (( prevItems , [ participantsByRoom , duplicateTiles ]) => {
2025-09-26 13:26:42 -04:00
const newItems : Map < string , UserMedia | ScreenShare > = new Map (
function * ( this : CallViewModel ) : Iterable < [ string , MediaItem ] > {
for ( const { livekitRoom , participants } of participantsByRoom ) {
2025-10-03 19:14:48 -04:00
for ( const { id , participant , member } of participants ) {
2025-09-26 13:26:42 -04:00
for ( let i = 0 ; i < 1 + duplicateTiles ; i ++ ) {
2025-10-03 19:14:48 -04:00
const mediaId = ` ${ id } : ${ i } ` ;
const prevMedia = prevItems . get ( mediaId );
if ( prevMedia instanceof UserMedia )
2025-09-26 13:26:42 -04:00
prevMedia . updateParticipant ( participant );
yield [
mediaId ,
// 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 above)
prevMedia ??
new UserMedia (
mediaId ,
member ,
participant ,
this . options . encryptionSystem ,
livekitRoom ,
this . mediaDevices ,
this . pretendToBeDisconnected$ ,
this . memberDisplaynames$ . pipe (
2025-10-03 19:14:48 -04:00
map (( m ) => m . get ( id ) ?? "[👻]" ),
2025-09-26 13:26:42 -04:00
),
2025-10-03 19:14:48 -04:00
this . handsRaised$ . pipe ( map (( v ) => v [ id ] ? . time ?? null )),
this . reactions$ . pipe ( map (( v ) => v [ id ] ?? undefined )),
2025-09-26 13:26:42 -04:00
),
];
2025-06-18 17:14:21 -04:00
2025-09-26 13:26:42 -04:00
if ( participant ? . isScreenShareEnabled ) {
const screenShareId = ` ${ mediaId } :screen-share` ;
2025-06-18 17:14:21 -04:00
yield [
2025-09-26 13:26:42 -04:00
screenShareId ,
prevItems . get ( screenShareId ) ??
new ScreenShare (
screenShareId ,
2025-06-18 17:14:21 -04:00
member ,
participant ,
2025-08-08 17:15:47 +02:00
this . options . encryptionSystem ,
2025-08-28 17:45:14 +02:00
livekitRoom ,
2025-08-20 18:51:03 +02:00
this . pretendToBeDisconnected$ ,
2025-06-18 17:14:21 -04:00
this . memberDisplaynames$ . pipe (
2025-10-03 19:14:48 -04:00
map (( m ) => m . get ( id ) ?? "[👻]" ),
2025-06-18 17:14:21 -04:00
),
),
];
}
2024-01-20 20:39:12 -05:00
}
}
2025-09-26 13:26:42 -04:00
}
}. bind ( this )(),
);
2024-01-20 20:39:12 -05:00
2025-09-26 13:26:42 -04:00
for ( const [ id , t ] of prevItems ) if ( ! newItems . has ( id )) t . destroy ();
return newItems ;
}, new Map < string , MediaItem >()),
2025-06-18 17:14:21 -04:00
map (( mediaItems ) => [... mediaItems . values ()]),
finalizeValue (( ts ) => {
for ( const t of ts ) t . destroy ();
}),
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
*/
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-03 14:43:22 -04:00
private readonly allOthersLeft$ = this . memberships$ . pipe (
pairwise (),
filter (
([ prev , current ]) =>
current . every (( m ) => m . sender === this . userId ) &&
prev . some (( m ) => m . sender !== this . userId ),
),
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 (
map (( ms ) => ms . some (( m ) => m . sender !== 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 ,
) as Observable < [ CustomEvent < IWidgetApiRequest >] >
). pipe ( tap (([ ev ]) => widget ! . api . transport . reply ( ev . detail , {})));
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 ) =>
combineLatest (
[
m . speaker$ ,
m . presenter$ ,
m . vm . videoEnabled$ ,
2024-12-19 15:54:28 +00:00
m . vm . handRaised$ ,
2024-12-17 04:01:56 +00:00
m . vm instanceof LocalUserMediaViewModel
? m.vm.alwaysShow$
: of ( false ),
],
2024-12-19 15:54:28 +00:00
( speaker , presenter , video , handRaised , alwaysShow ) => {
2024-12-17 04:01:56 +00:00
let bin : SortingBin ;
if ( m . vm . local )
bin = alwaysShow
? SortingBin.SelfAlwaysShown
: SortingBin.SelfNotAlwaysShown ;
else if ( presenter ) bin = SortingBin . Presenters ;
else if ( speaker ) bin = SortingBin . Speakers ;
2024-12-19 15:54:28 +00:00
else if ( handRaised ) bin = SortingBin . HandRaised ;
2024-12-17 04:01:56 +00:00
else if ( video ) bin = SortingBin . Video ;
else bin = SortingBin . NoVideo ;
return [ m , bin ] as const ;
},
),
);
// 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-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.
*/
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.
*/
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-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-09-23 11:38:34 +02:00
private readonly trackProcessorState$ : Observable < ProcessorState >,
2023-11-30 22:59:19 -05:00
) {
super ();
2025-08-15 18:38:52 +02:00
2025-10-03 14:43:22 -04:00
// Start and stop local and remote connections as needed
2025-10-03 21:00:45 -04:00
this . connectionInstructions$
2025-08-27 14:01:01 +02:00
. pipe ( this . scope . bind ())
2025-10-03 21:00:45 -04:00
. subscribe (({ start , stop }) => {
for ( const c of stop ) {
2025-10-07 16:24:02 +02:00
logger . info (
`Disconnecting from ${ c . localTransport . livekit_service_url } ` ,
);
2025-10-07 16:00:59 +02:00
c . stop (). catch (( err ) => {
// TODO: better error handling
2025-10-08 17:35:53 -04:00
logger . error (
`Fail to stop connection to ${ c . localTransport . livekit_service_url } ` ,
err ,
);
2025-10-07 16:24:02 +02:00
});
2025-10-03 21:00:45 -04:00
}
for ( const c of start ) {
c . start (). then (
() =>
2025-10-07 16:24:02 +02:00
logger . info (
`Connected to ${ c . localTransport . livekit_service_url } ` ,
),
2025-10-03 21:00:45 -04:00
( e ) =>
logger . error (
2025-10-07 10:33:31 +02:00
`Failed to start connection to ${ c . localTransport . livekit_service_url } ` ,
2025-10-03 21:00:45 -04:00
e ,
2025-09-25 16:29:56 -04:00
),
);
2025-10-03 21:00:45 -04:00
}
2025-08-27 14:01:01 +02:00
});
2025-08-29 18:46:24 +02:00
2025-10-03 14:43:22 -04:00
// Start and stop session membership as needed
2025-10-03 21:00:45 -04:00
this . scope . reconcile ( this . localTransport$ , async ( localTransport ) => {
2025-10-03 14:43:22 -04:00
if ( localTransport ? . state === "ready" ) {
2025-10-03 21:00:45 -04:00
try {
2025-10-13 15:43:12 +02:00
await enterRTCSession ( this . matrixRTCSession , localTransport . value , {
encryptMedia : this.options.encryptionSystem.kind !== E2eeType . NONE ,
useExperimentalToDeviceTransport : true ,
useNewMembershipManager : true ,
useMultiSfu : multiSfu.value$.value ,
});
2025-10-03 21:00:45 -04:00
} catch ( e ) {
logger . error ( "Error entering RTC session" , e );
}
// Update our member event when our mute state changes.
const muteSubscription = this . muteStates . video . enabled$ . subscribe (
( videoEnabled ) =>
// TODO: Ensure that these calls are serialized in case of
// fast video toggling
void this . matrixRTCSession . updateCallIntent (
videoEnabled ? "video" : "audio" ,
),
2025-09-24 21:26:16 -04:00
);
2025-08-29 18:46:24 +02:00
2025-10-03 21:00:45 -04:00
return async () : Promise < void > => {
muteSubscription . unsubscribe ();
2025-10-03 14:43:22 -04:00
// Only sends Matrix leave event. The LiveKit session will disconnect
// as soon as either the stopConnection$ handler above gets to it or
// the view model is destroyed.
2025-10-03 21:00:45 -04:00
try {
await this . matrixRTCSession . leaveRoomSession ();
} catch ( e ) {
logger . error ( "Error leaving RTC session" , e );
}
try {
await widget ? . api . transport . send (
ElementWidgetActions . HangupCall ,
{},
2025-10-03 14:43:22 -04:00
);
2025-10-03 21:00:45 -04:00
} catch ( e ) {
logger . error ( "Failed to send hangup action" , e );
}
};
2025-10-03 14:43:22 -04:00
}
2025-08-28 18:41:13 +02:00
});
2025-08-27 14:01:01 +02:00
2025-08-20 20:47:20 +02:00
// Pause upstream of all local media tracks when we're disconnected from
// MatrixRTC, because it can be an unpleasant surprise for the app to say
// 'reconnecting' and yet still be transmitting your media to others.
// We use matrixConnected$ rather than reconnecting$ because we want to
// pause tracks during the initial joining sequence too until we're sure
// that our own media is displayed on screen.
2025-10-03 14:43:22 -04:00
combineLatest ([ this . localConnection$ , this . matrixConnected$ ])
. pipe ( this . scope . bind ())
. subscribe (([ connection , connected ]) => {
if ( connection ? . state !== "ready" ) return ;
2025-08-28 17:45:14 +02:00
const publications =
2025-10-03 14:43:22 -04:00
connection . value . livekitRoom . localParticipant . trackPublications . values ();
2025-08-28 17:45:14 +02:00
if ( connected ) {
for ( const p of publications ) {
if ( p . track ? . isUpstreamPaused === true ) {
const kind = p . track . kind ;
logger . log (
`Resuming ${ kind } track (MatrixRTC connection present)` ,
2025-08-15 18:38:52 +02:00
);
2025-08-28 17:45:14 +02:00
p . track
. resumeUpstream ()
. catch (( e ) =>
logger . error (
`Failed to resume ${ kind } track after MatrixRTC reconnection` ,
e ,
),
);
}
2025-08-15 18:38:52 +02:00
}
2025-08-28 17:45:14 +02:00
} else {
for ( const p of publications ) {
if ( p . track ? . isUpstreamPaused === false ) {
const kind = p . track . kind ;
logger . log (
`Pausing ${ kind } track (uncertain MatrixRTC connection)` ,
2025-08-15 18:38:52 +02:00
);
2025-08-28 17:45:14 +02:00
p . track
. pauseUpstream ()
. catch (( e ) =>
logger . error (
`Failed to pause ${ kind } track after entering uncertain MatrixRTC connection` ,
e ,
),
);
}
2025-08-15 18:38:52 +02:00
}
}
2025-10-03 14:43:22 -04: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
}
}
2025-10-13 16:24:55 +02:00
function getRoomMemberFromRtcMember (
rtcMember : CallMembership ,
room : MatrixRoom ,
) : { id : string ; member : RoomMember | undefined } {
// WARN! This is not exactly the sender but the user defined in the state key.
// This will be available once we change to the new "member as object" format in the MatrixRTC object.
let id = rtcMember . sender + ":" + rtcMember . deviceId ;
if ( ! rtcMember . sender ) {
return { id , member : undefined };
}
if (
rtcMember . sender === room . client . getUserId () &&
rtcMember . deviceId === room . client . getDeviceId ()
) {
id = "local" ;
}
const member = room . getMember ( rtcMember . sender ) ?? undefined ;
return { id , member };
}