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-11-07 08:44:44 +01:00
type ConnectionState ,
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-11-07 08:44:44 +01:00
import { type Room as MatrixRoom } from "matrix-js-sdk" ;
2024-09-11 01:27:24 -04:00
import {
2023-11-30 22:59:19 -05:00
combineLatest ,
2024-01-20 20:39:12 -05:00
distinctUntilChanged ,
2025-10-13 15:43:12 +02:00
EMPTY ,
2024-01-20 20:39:12 -05:00
filter ,
2024-07-03 15:08:30 -04:00
fromEvent ,
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-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-11-06 15:26:17 +01:00
import { logger as rootLogger } from "matrix-js-sdk/lib/logger" ;
2025-11-07 08:44:44 +01:00
import { type MatrixRTCSession } 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 ,
2025-11-07 08:44:44 +01:00
} from "../MediaViewModel" ;
2025-11-07 17:36:16 -05:00
import { accumulate , generateItems , 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 ,
2025-11-07 08:44:44 +01:00
} from "../../settings/settings" ;
import { isFirefox } from "../../Platform" ;
import { setPipEnabled$ } from "../../controls" ;
import { TileStore } from "../TileStore" ;
import { gridLikeLayout } from "../GridLikeLayout" ;
import { spotlightExpandedLayout } from "../SpotlightExpandedLayout" ;
import { oneOnOneLayout } from "../OneOnOneLayout" ;
import { pipLayout } from "../PipLayout" ;
import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement" ;
2024-12-19 15:54:28 +00:00
import {
type RaisedHandInfo ,
type ReactionInfo ,
type ReactionOption ,
2025-11-07 08:44:44 +01:00
} from "../../reactions" ;
import { shallowEquals } from "../../utils/array" ;
import { type MediaDevices } from "../MediaDevices" ;
2025-11-07 17:36:16 -05:00
import { type Behavior } from "../Behavior" ;
2025-11-07 08:44:44 +01:00
import { E2eeType } from "../../e2ee/e2eeType" ;
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider" ;
import { type MuteStates } from "../MuteStates" ;
import { getUrlParams } from "../../UrlParams" ;
import { type ProcessorState } from "../../livekit/TrackProcessorContext" ;
import { ElementWidgetActions , widget } from "../../widget" ;
import { 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-11-07 08:44:44 +01:00
} from "../layout-types.ts" ;
import { type ElementCallError } from "../../utils/errors.ts" ;
import { type ObservableScope } from "../ObservableScope.ts" ;
2025-11-05 18:57:24 +01:00
import { createLocalMembership$ } from "./localMember/LocalMembership.ts" ;
import { createLocalTransport$ } from "./localMember/LocalTransport.ts" ;
2025-11-06 12:08:46 +01:00
import {
createMemberships$ ,
membershipsAndTransports$ ,
2025-11-07 08:44:44 +01:00
} 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-11-12 12:09:31 +01:00
import {
createMatrixLivekitMembers$ ,
type MatrixLivekitMember ,
} from "./remoteMembers/MatrixLivekitMembers.ts" ;
2025-11-07 08:44:44 +01:00
import {
createCallNotificationLifecycle$ ,
createReceivedDecline$ ,
createSentCallNotification$ ,
} from "./CallNotificationLifecycle.ts" ;
2025-11-11 15:51:48 +01:00
import {
createMatrixMemberMetadata$ ,
createRoomMembers$ ,
} from "./remoteMembers/MatrixMemberMetadata.ts" ;
2025-10-30 01:13:06 +01:00
2025-11-06 15:26:17 +01:00
const logger = rootLogger . getChild ( "[CallViewModel]" );
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 ;
2025-11-07 14:04:40 +01:00
type AudioLivekitItem = {
livekitRoom : LivekitRoom ;
participants : string [];
url : string ;
};
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-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-11-12 15:41:41 -05:00
private readonly livekitKeyProvider = getE2eeKeyProvider (
2025-08-27 14:36:13 +02:00
this . options . encryptionSystem ,
2025-08-27 14:01:01 +02:00
this . matrixRTCSession ,
);
2025-11-14 10:44:16 +01:00
// Each hbar seperates a block of input variables required for the CallViewModel to function.
// The outputs of this block is written under the hbar.
//
// For mocking purposes it is recommended to only mock the functions creating those outputs.
// All other fields are just temp computations for the mentioned output.
// The class does not need anything except the values underneath the bar.
// The creation of the values under the bar are all tested independently and testing the callViewModel Should
// not test their cretation. Call view model only needs:
// - memberships$ via createMemberships$
// - localMembership via createLocalMembership$
// - callLifecycle via createCallNotificationLifecycle$
// - matrixMemberMetadataStore via createMatrixMemberMetadata$
// ------------------------------------------------------------------------
// memberships$
2025-11-06 15:26:17 +01:00
private memberships$ = createMemberships$ ( this . scope , this . matrixRTCSession );
2025-11-14 18:45:07 +01:00
// ------------------------------------------------------------------------
// matrixLivekitMembers$ AND localMembership
2025-11-06 12:08:46 +01:00
private membershipsAndTransports = membershipsAndTransports$ (
this . scope ,
this . 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
});
private connectionFactory = new ECConnectionFactory (
2025-10-30 01:13:06 +01:00
this . matrixRoom . client ,
this . mediaDevices ,
this . trackProcessorState$ ,
2025-11-12 15:41:41 -05:00
this . livekitKeyProvider ,
2025-11-04 20:24:15 +01:00
getUrlParams (). controlledAudioDevices ,
2025-11-14 10:44:16 +01:00
this . options . livekitRoomFactory ,
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 ,
2025-11-14 10:44:16 +01:00
inputTransports$ : this.scope.behavior (
combineLatest (
[ this . localTransport$ , this . membershipsAndTransports . transports$ ],
( localTransport , transports ) => {
const localTransportAsArray = localTransport ? [ localTransport ] : [];
return transports . mapInner (( transports ) => [
... localTransportAsArray ,
... transports ,
]);
},
),
),
2025-11-14 16:18:31 +01:00
logger : logger ,
2025-11-05 18:57:24 +01:00
});
2025-10-30 01:13:06 +01:00
2025-11-05 18:57:24 +01:00
private matrixLivekitMembers$ = createMatrixLivekitMembers$ ({
scope : this.scope ,
2025-11-06 15:26:17 +01:00
membershipsWithTransport$ :
this.membershipsAndTransports.membershipsWithTransport$ ,
2025-11-05 18:57:24 +01:00
connectionManager : this.connectionManager ,
});
private connectOptions$ = this . scope . behavior (
matrixRTCMode . value$ . pipe (
map (( mode ) => ({
2025-11-12 15:41:41 -05:00
encryptMedia : this.livekitKeyProvider !== undefined ,
2025-11-05 18:57:24 +01:00
// 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$ ,
trackProcessorState$ : this.trackProcessorState$ ,
widget ,
2025-11-05 18:57:24 +01:00
options : this.connectOptions$ ,
2025-11-14 16:18:31 +01:00
logger : logger.getChild ( `[ ${ Date . now () } ]` ),
2025-10-30 15:15:49 +01:00
});
2025-11-12 12:09:31 +01:00
private localRtcMembership$ = this . scope . behavior (
this . memberships$ . pipe (
map (
( memberships ) =>
memberships . value . find (
( membership ) =>
membership . userId === this . userId &&
membership . deviceId === this . deviceId ,
) ?? null ,
),
),
);
2025-11-14 10:44:16 +01:00
2025-11-12 12:09:31 +01:00
private localMatrixLivekitMemberUninitialized = {
membership$ : this.localRtcMembership$ ,
participant$ : this.localMembership.participant$ ,
connection$ : this.localMembership.connection$ ,
userId : this.userId ,
};
private localMatrixLivekitMember$ : Behavior < MatrixLivekitMember | null > =
this . scope . behavior (
this . localRtcMembership$ . pipe (
switchMap (( membership ) => {
if ( ! membership ) return of ( null );
return of (
// casting is save here since we know that localRtcMembership$ is !== null since we reached this case.
this . localMatrixLivekitMemberUninitialized as MatrixLivekitMember ,
);
}),
),
);
2025-11-07 08:44:44 +01:00
// ------------------------------------------------------------------------
2025-11-14 10:44:16 +01:00
// callLifecycle
2025-11-07 08:44:44 +01:00
private callLifecycle = createCallNotificationLifecycle$ ({
scope : this.scope ,
memberships$ : this.memberships$ ,
2025-11-10 15:55:01 +01:00
sentCallNotification$ : createSentCallNotification$ (
this . scope ,
this . matrixRTCSession ,
),
receivedDecline$ : createReceivedDecline$ ( this . matrixRoom ),
2025-11-07 08:44:44 +01:00
options : this.options ,
localUser : { userId : this.userId , deviceId : this.deviceId },
});
2025-11-11 15:51:48 +01:00
public autoLeave$ = this . callLifecycle . autoLeave$ ;
2025-11-12 12:09:31 +01:00
2025-11-07 12:32:29 +01:00
// ------------------------------------------------------------------------
2025-11-14 10:44:16 +01:00
// matrixMemberMetadataStore
private matrixMemberMetadataStore = createMatrixMemberMetadata$ (
this . scope ,
this . scope . behavior ( this . memberships$ . pipe ( map (( mems ) => mems . value ))),
createRoomMembers$ ( this . scope , this . matrixRoom ),
);
2025-11-11 15:51:48 +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"
> ();
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 (
2025-11-10 15:55:01 +01:00
switchMap (( membersWithEpoch ) => {
const members = membersWithEpoch . value ;
const a$ = combineLatest (
members . map (( member ) =>
combineLatest ([ member . connection$ , member . participant$ ]). pipe (
map (([ connection , participant ]) => {
// do not render audio for local participant
if ( ! connection || ! participant || participant . isLocal )
return null ;
const livekitRoom = connection . livekitRoom ;
const url = connection . transport . livekit_service_url ;
return { url , livekitRoom , participant : participant.identity };
}),
),
),
);
return a$ ;
}),
2025-11-07 14:04:40 +01:00
map (( members ) =>
2025-11-10 15:55:01 +01:00
members . reduce < AudioLivekitItem [] >(( acc , curr ) => {
if ( ! curr ) return acc ;
2025-11-07 14:04:40 +01:00
2025-11-10 15:55:01 +01:00
const existing = acc . find (( item ) => item . url === curr . url );
2025-11-07 14:04:40 +01:00
if ( existing ) {
2025-11-10 15:55:01 +01:00
existing . participants . push ( curr . participant );
2025-11-07 14:04:40 +01:00
} else {
2025-11-10 15:55:01 +01:00
acc . push ({
livekitRoom : curr.livekitRoom ,
participants : [ curr . participant ],
url : curr.url ,
});
2025-11-07 14:04:40 +01:00
}
return acc ;
}, []),
),
2025-11-05 12:56:58 +01:00
),
2025-11-10 15:55:01 +01:00
[],
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-11-07 17:36:16 -05:00
* List of user media (camera feeds) that we want tiles for.
2024-11-21 11:01:43 +00:00
*/
2025-11-07 19:07:45 +01:00
// TODO this also needs the local participant to be added.
2025-11-07 17:36:16 -05:00
private readonly userMedia$ = this . scope . behavior < UserMedia [] >(
2025-11-12 12:09:31 +01:00
combineLatest ([
this . localMatrixLivekitMember$ ,
this . matrixLivekitMembers$ ,
duplicateTiles . value$ ,
]). pipe (
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-07 17:36:16 -05:00
generateItems (
2025-11-12 12:09:31 +01:00
function * ([
localMatrixLivekitMember ,
{ value : matrixLivekitMembers },
duplicateTiles ,
]) {
2025-11-14 10:44:16 +01:00
let localParticipantId = undefined ;
2025-11-12 12:09:31 +01:00
// add local member if available
if ( localMatrixLivekitMember ) {
2025-11-14 10:44:16 +01:00
const { userId , participant$ , connection$ , membership$ } =
localMatrixLivekitMember ;
localParticipantId = ` ${ userId } : ${ membership$ . value . deviceId } ` ; // should be membership$.value.membershipID which is not optional
2025-11-12 12:09:31 +01:00
// const participantId = membership$.value.membershipID;
2025-11-14 10:44:16 +01:00
if ( localParticipantId ) {
2025-11-12 12:09:31 +01:00
for ( let dup = 0 ; dup < 1 + duplicateTiles ; dup ++ ) {
yield {
2025-11-14 10:44:16 +01:00
keys : [
dup ,
localParticipantId ,
userId ,
participant$ ,
connection$ ,
],
2025-11-12 12:09:31 +01:00
data : undefined ,
};
}
}
}
// add remote members that are available
2025-11-07 17:36:16 -05:00
for ( const {
userId ,
participant$ ,
connection$ ,
2025-11-14 10:44:16 +01:00
membership$ ,
2025-11-12 12:09:31 +01:00
} of matrixLivekitMembers ) {
2025-11-14 10:44:16 +01:00
const participantId = ` ${ userId } : ${ membership$ . value . deviceId } ` ;
if ( participantId === localParticipantId ) continue ;
2025-11-12 12:09:31 +01:00
// const participantId = membership$.value?.identity;
for ( let dup = 0 ; dup < 1 + duplicateTiles ; dup ++ ) {
2025-11-07 17:36:16 -05:00
yield {
2025-11-11 15:51:48 +01:00
keys : [ dup , participantId , userId , participant$ , connection$ ],
2025-11-07 17:36:16 -05:00
data : undefined ,
};
2025-11-12 12:09:31 +01:00
}
}
2025-11-07 17:36:16 -05:00
},
(
scope ,
_data$ ,
dup ,
2025-11-05 17:55:36 +01:00
participantId ,
2025-11-07 17:36:16 -05:00
userId ,
participant$ ,
connection$ ,
) => {
const livekitRoom$ = scope . behavior (
connection$ . pipe ( map (( c ) => c ? . livekitRoom )),
);
const focusUrl$ = scope . behavior (
connection$ . pipe ( map (( c ) => c ? . transport . livekit_service_url )),
);
2025-11-11 15:51:48 +01:00
const displayName$ = scope . behavior (
this . matrixMemberMetadataStore
. createDisplayNameBehavior$ ( userId )
. pipe ( map (( name ) => name ?? userId )),
);
2025-11-07 17:36:16 -05:00
return new UserMedia (
scope ,
` ${ participantId } : ${ dup } ` ,
userId ,
participant$ ,
this . options . encryptionSystem ,
livekitRoom$ ,
focusUrl$ ,
this . mediaDevices ,
this . pretendToBeDisconnected$ ,
displayName$ ,
2025-11-11 15:51:48 +01:00
this . matrixMemberMetadataStore . createAvatarUrlBehavior$ ( userId ),
2025-11-07 17:36:16 -05:00
this . handsRaised$ . pipe ( map (( v ) => v [ participantId ] ? . time ?? null )),
this . reactions$ . pipe ( map (( v ) => v [ participantId ] ?? undefined )),
);
},
),
2025-07-12 00:20:44 -04:00
),
);
2024-01-20 20:39:12 -05:00
2024-11-21 11:01:43 +00:00
/**
2025-11-07 17:36:16 -05:00
* List of all media items (user media and screen share media) that we want
* tiles for.
2024-11-21 11:01:43 +00:00
*/
2025-11-07 17:36:16 -05:00
private readonly mediaItems$ = this . scope . behavior < MediaItem [] >(
this . userMedia$ . pipe (
switchMap (( userMedia ) =>
userMedia . length === 0
? of ([])
: combineLatest (
userMedia . map (( m ) => m . screenShares$ ),
(... screenShares ) => [... userMedia , ... screenShares . flat ( 1 )],
),
),
),
);
/**
* List of MediaItems that we want to display, that are of type ScreenShare
*/
private readonly screenShares$ = this . scope . behavior < ScreenShare [] >(
2025-07-12 00:20:44 -04:00
this . mediaItems$ . pipe (
2025-07-11 23:53:59 -04:00
map (( mediaItems ) =>
2025-11-07 17:36:16 -05:00
mediaItems . filter (( m ) : m is ScreenShare => m instanceof ScreenShare ),
2025-07-11 23:53:59 -04:00
),
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-11-14 10:44:16 +01:00
this . matrixLivekitMembers$ . pipe ( map (( ms ) => ms . value . length )),
2025-08-25 13:49:01 +02:00
);
2025-11-07 12:32:29 +01:00
// only public to expose to the view.
2025-11-14 18:45:07 +01:00
// TODO if we are in "unknown" state we need a loading rendering (or empty screen)
// Otherwise it looks like we already connected and only than the ringing starts which is weird.
2025-11-07 12:32:29 +01:00
public readonly callPickupState$ = this . callLifecycle . callPickupState$ ;
2025-09-15 15:41:15 +01:00
public readonly leaveSoundEffect$ = combineLatest ([
2025-11-07 08:44:44 +01:00
this . callLifecycle . callPickupState$ ,
2025-09-15 15:41:15 +01:00
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
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 (
2025-11-07 08:44:44 +01:00
this . callLifecycle . autoLeave$ ,
2025-09-24 21:26:16 -04:00
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
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 ([
2025-11-07 08:44:44 +01:00
// TODO This also needs epoch logic to dedupe the screenshares and mediaItems emits
2025-07-12 00:20:44 -04:00
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
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
),
2025-11-07 12:32:29 +01:00
"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
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
),
2025-11-07 12:32:29 +01:00
"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-07 08:44:44 +01:00
// reassigned here to make it publicly accessible
public readonly sharingScreen$ = this . localMembership . sharingScreen$ ;
2025-09-24 13:54:54 -04:00
/**
2025-11-12 15:02:19 -05:00
* Callback to toggle screen sharing. If null, screen sharing is not possible.
2025-09-24 13:54:54 -04:00
*/
2025-11-07 08:44:44 +01:00
// reassigned here to make it publicly accessible
2025-09-24 13:54:54 -04:00
public readonly toggleScreenSharing =
2025-11-07 08:44:44 +01:00
this . localMembership . toggleScreenSharing ;
2025-09-24 13:54:54 -04:00
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
}
}