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
* /
2025-08-28 17:45:14 +02:00
import { observeParticipantEvents } from "@livekit/components-core" ;
2024-01-20 20:39:12 -05:00
import {
2025-08-20 13:32:42 +02:00
ConnectionState ,
2025-09-15 17:49:07 +02:00
type BaseKeyProvider ,
2025-09-16 10:13:14 +02:00
type E2EEOptions ,
2025-08-27 14:01:01 +02:00
ExternalE2EEKeyProvider ,
2025-08-28 17:45:14 +02:00
type Room as LivekitRoom ,
2024-12-11 09:27:55 +00:00
type LocalParticipant ,
2024-01-20 20:39:12 -05:00
ParticipantEvent ,
2024-12-11 09:27:55 +00:00
type RemoteParticipant ,
2025-09-24 13:54:54 -04:00
type Participant ,
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 17:45:14 +02:00
type RoomMember ,
2025-08-15 18:32:37 +02:00
RoomStateEvent ,
2025-08-20 13:30:21 +02:00
SyncState ,
2025-08-15 18:32:37 +02:00
type Room as MatrixRoom ,
2025-08-28 18:41:13 +02:00
type EventTimelineSetHandlerMap ,
EventType ,
RoomEvent ,
2025-08-15 18:32:37 +02:00
} from "matrix-js-sdk" ;
2024-09-11 01:27:24 -04:00
import {
2024-12-06 12:28:37 +01:00
BehaviorSubject ,
2023-11-30 22:59:19 -05:00
EMPTY ,
2025-09-03 16:50:43 +02:00
NEVER ,
2024-12-11 09:27:55 +00:00
type Observable ,
2024-05-17 16:38:00 -04:00
Subject ,
2023-11-30 22:59:19 -05:00
combineLatest ,
concat ,
2025-08-29 18:46:24 +02:00
concatMap ,
2024-01-20 20:39:12 -05:00
distinctUntilChanged ,
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 ,
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 ,
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 ,
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-08-28 15:32:46 +02:00
isLivekitFocus ,
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" ;
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-01-20 20:39:12 -05:00
import { ObservableScope } from "./ObservableScope" ;
2024-12-19 15:54:28 +00:00
import {
duplicateTiles ,
playReactionsSound ,
showReactions ,
showNonMemberTiles ,
} 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-12-11 09:27:55 +00:00
import {
type GridTileViewModel ,
type SpotlightTileViewModel ,
} from "./TileViewModel" ;
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-17 04:01:56 +00:00
import { observeSpeaker $ } from "./observeSpeaker" ;
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-09-16 16:52:17 +02:00
import { constant , type Behavior } from "./Behavior" ;
2025-08-27 14:01:01 +02:00
import {
enterRTCSession ,
getLivekitAlias ,
2025-09-16 16:52:17 +02:00
leaveRTCSession ,
2025-08-27 14:01:01 +02:00
makeFocus ,
} from "../rtcSessionHelpers" ;
import { E2eeType } from "../e2ee/e2eeType" ;
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider" ;
2025-08-28 13:52:12 +02:00
import { Connection , PublishConnection } from "./Connection" ;
2025-08-29 18:46:24 +02:00
import { type MuteStates } from "./MuteStates" ;
2025-09-16 16:52:17 +02:00
import { PosthogAnalytics } from "../analytics/PosthogAnalytics" ;
import { getUrlParams } from "../UrlParams" ;
2025-09-23 11:38:34 +02:00
import { type ProcessorState } from "../livekit/TrackProcessorContext" ;
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-11-06 04:36:48 -05:00
export interface GridLayoutMedia {
2024-01-20 20:39:12 -05:00
type : "grid" ;
2024-05-02 16:00:05 -04:00
spotlight? : MediaViewModel [ ] ;
grid : UserMediaViewModel [ ] ;
2024-01-20 20:39:12 -05:00
}
2024-11-06 04:36:48 -05:00
export interface SpotlightLandscapeLayoutMedia {
2024-07-18 11:24:18 -04:00
type : "spotlight-landscape" ;
2024-05-02 16:00:05 -04:00
spotlight : MediaViewModel [ ] ;
grid : UserMediaViewModel [ ] ;
2024-01-20 20:39:12 -05:00
}
2024-11-06 04:36:48 -05:00
export interface SpotlightPortraitLayoutMedia {
2024-07-18 11:24:18 -04:00
type : "spotlight-portrait" ;
2024-07-03 15:08:30 -04:00
spotlight : MediaViewModel [ ] ;
grid : UserMediaViewModel [ ] ;
2024-06-07 12:27:13 -04:00
}
2024-11-06 04:36:48 -05:00
export interface SpotlightExpandedLayoutMedia {
2024-07-18 11:24:18 -04:00
type : "spotlight-expanded" ;
2024-05-02 16:00:05 -04:00
spotlight : MediaViewModel [ ] ;
pip? : UserMediaViewModel ;
2024-01-20 20:39:12 -05:00
}
2024-11-06 04:36:48 -05:00
export interface OneOnOneLayoutMedia {
type : "one-on-one" ;
local : UserMediaViewModel ;
remote : UserMediaViewModel ;
}
export interface PipLayoutMedia {
type : "pip" ;
spotlight : MediaViewModel [ ] ;
}
export type LayoutMedia =
| GridLayoutMedia
| SpotlightLandscapeLayoutMedia
| SpotlightPortraitLayoutMedia
| SpotlightExpandedLayoutMedia
| OneOnOneLayoutMedia
| PipLayoutMedia ;
export interface GridLayout {
type : "grid" ;
spotlight? : SpotlightTileViewModel ;
grid : GridTileViewModel [ ] ;
2024-12-12 17:32:13 -05:00
setVisibleTiles : ( value : number ) = > void ;
2024-11-06 04:36:48 -05:00
}
export interface SpotlightLandscapeLayout {
type : "spotlight-landscape" ;
spotlight : SpotlightTileViewModel ;
grid : GridTileViewModel [ ] ;
2024-12-12 17:32:13 -05:00
setVisibleTiles : ( value : number ) = > void ;
2024-11-06 04:36:48 -05:00
}
export interface SpotlightPortraitLayout {
type : "spotlight-portrait" ;
spotlight : SpotlightTileViewModel ;
grid : GridTileViewModel [ ] ;
2024-12-12 17:32:13 -05:00
setVisibleTiles : ( value : number ) = > void ;
2024-11-06 04:36:48 -05:00
}
export interface SpotlightExpandedLayout {
type : "spotlight-expanded" ;
spotlight : SpotlightTileViewModel ;
pip? : GridTileViewModel ;
}
2024-07-03 15:08:30 -04:00
export interface OneOnOneLayout {
type : "one-on-one" ;
2024-11-06 04:36:48 -05:00
local : GridTileViewModel ;
remote : GridTileViewModel ;
2024-07-03 15:08:30 -04:00
}
2024-01-20 20:39:12 -05:00
export interface PipLayout {
type : "pip" ;
2024-11-06 04:36:48 -05:00
spotlight : SpotlightTileViewModel ;
2024-01-20 20:39:12 -05:00
}
/ * *
* A layout defining the media tiles present on screen and their visual
* arrangement .
* /
export type Layout =
| GridLayout
2024-07-03 15:08:30 -04:00
| SpotlightLandscapeLayout
| SpotlightPortraitLayout
| SpotlightExpandedLayout
2024-06-07 12:27:13 -04:00
| OneOnOneLayout
2024-01-20 20:39:12 -05:00
| PipLayout ;
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
class UserMedia {
private readonly scope = new ObservableScope ( ) ;
public readonly vm : UserMediaViewModel ;
2024-12-17 04:01:56 +00:00
private readonly participant$ : BehaviorSubject <
2024-12-06 12:28:37 +01:00
LocalParticipant | RemoteParticipant | undefined
> ;
2025-07-11 23:53:59 -04:00
public readonly speaker$ : Behavior < boolean > ;
public readonly presenter$ : Behavior < boolean > ;
2024-01-20 20:39:12 -05:00
public constructor (
public readonly id : string ,
member : RoomMember | undefined ,
2024-12-06 12:28:37 +01:00
participant : LocalParticipant | RemoteParticipant | undefined ,
2024-11-04 09:11:44 +00:00
encryptionSystem : EncryptionSystem ,
2024-11-06 11:12:46 +00:00
livekitRoom : LivekitRoom ,
2025-06-12 19:16:37 -04:00
mediaDevices : MediaDevices ,
2025-08-20 18:51:03 +02:00
pretendToBeDisconnected$ : Behavior < boolean > ,
2025-01-14 14:46:39 +00:00
displayname$ : Observable < string > ,
2024-12-19 15:54:28 +00:00
handRaised$ : Observable < Date | null > ,
reaction$ : Observable < ReactionOption | null > ,
2024-01-20 20:39:12 -05:00
) {
2024-12-17 04:01:56 +00:00
this . participant $ = new BehaviorSubject ( participant ) ;
2024-12-06 12:28:37 +01:00
if ( participant ? . isLocal ) {
this . vm = new LocalUserMediaViewModel (
this . id ,
member ,
2025-07-11 23:53:59 -04:00
this . participant $ as Behavior < LocalParticipant > ,
2024-12-06 12:28:37 +01:00
encryptionSystem ,
livekitRoom ,
2025-06-12 19:16:37 -04:00
mediaDevices ,
2025-07-12 00:20:44 -04:00
this . scope . behavior ( displayname $ ) ,
this . scope . behavior ( handRaised $ ) ,
this . scope . behavior ( reaction $ ) ,
2024-12-06 12:28:37 +01:00
) ;
} else {
this . vm = new RemoteUserMediaViewModel (
id ,
member ,
2024-12-17 04:01:56 +00:00
this . participant $ . asObservable ( ) as Observable <
2024-12-06 12:28:37 +01:00
RemoteParticipant | undefined
> ,
encryptionSystem ,
livekitRoom ,
2025-08-20 18:51:03 +02:00
pretendToBeDisconnected $ ,
2025-07-12 00:20:44 -04:00
this . scope . behavior ( displayname $ ) ,
this . scope . behavior ( handRaised $ ) ,
this . scope . behavior ( reaction $ ) ,
2024-12-06 12:28:37 +01:00
) ;
}
2024-01-20 20:39:12 -05:00
2025-07-12 00:20:44 -04:00
this . speaker $ = this . scope . behavior ( observeSpeaker $ ( this . vm . speaking $ ) ) ;
2025-06-18 17:14:21 -04:00
2025-07-12 00:20:44 -04:00
this . presenter $ = this . scope . behavior (
this . participant $ . pipe (
2025-09-24 13:54:54 -04:00
switchMap ( ( p ) = > ( p === undefined ? of ( false ) : sharingScreen $ ( p ) ) ) ,
2025-07-12 00:20:44 -04:00
) ,
) ;
2024-12-06 12:28:37 +01:00
}
public updateParticipant (
newParticipant : LocalParticipant | RemoteParticipant | undefined ,
) : void {
2024-12-17 04:01:56 +00:00
if ( this . participant $ . value !== newParticipant ) {
2024-12-06 12:28:37 +01:00
// Update the BehaviourSubject in the UserMedia.
2024-12-17 04:01:56 +00:00
this . participant $ . next ( newParticipant ) ;
2024-12-06 12:28:37 +01:00
}
2024-01-20 20:39:12 -05:00
}
public destroy ( ) : void {
this . scope . end ( ) ;
2025-08-27 16:56:57 +02:00
2024-01-20 20:39:12 -05:00
this . vm . destroy ( ) ;
}
}
class ScreenShare {
2025-06-18 17:14:21 -04:00
private readonly scope = new ObservableScope ( ) ;
2024-01-20 20:39:12 -05:00
public readonly vm : ScreenShareViewModel ;
2024-12-17 04:01:56 +00:00
private readonly participant$ : BehaviorSubject <
2024-12-06 12:28:37 +01:00
LocalParticipant | RemoteParticipant
> ;
2024-01-20 20:39:12 -05:00
public constructor (
id : string ,
member : RoomMember | undefined ,
participant : LocalParticipant | RemoteParticipant ,
2024-11-04 09:11:44 +00:00
encryptionSystem : EncryptionSystem ,
2025-08-20 18:51:03 +02:00
livekitRoom : LivekitRoom ,
pretendToBeDisconnected$ : Behavior < boolean > ,
2025-06-18 18:33:35 -04:00
displayName$ : Observable < string > ,
2024-01-20 20:39:12 -05:00
) {
2024-12-17 04:01:56 +00:00
this . participant $ = new BehaviorSubject ( participant ) ;
2024-12-06 12:28:37 +01:00
2024-11-04 09:11:44 +00:00
this . vm = new ScreenShareViewModel (
id ,
member ,
2024-12-17 04:01:56 +00:00
this . participant $ . asObservable ( ) ,
2024-11-04 09:11:44 +00:00
encryptionSystem ,
2025-08-20 18:51:03 +02:00
livekitRoom ,
pretendToBeDisconnected $ ,
2025-07-12 00:20:44 -04:00
this . scope . behavior ( displayName $ ) ,
2024-12-06 12:28:37 +01:00
participant . isLocal ,
2024-11-04 09:11:44 +00:00
) ;
2024-01-20 20:39:12 -05:00
}
public destroy ( ) : void {
2025-06-18 17:14:21 -04:00
this . scope . end ( ) ;
2024-01-20 20:39:12 -05:00
this . vm . destroy ( ) ;
}
}
type MediaItem = UserMedia | ScreenShare ;
2023-11-30 22:59:19 -05:00
2025-01-14 14:46:39 +00:00
function getRoomMemberFromRtcMember (
rtcMember : CallMembership ,
2025-08-15 18:32:37 +02:00
room : MatrixRoom ,
2025-01-14 14:46:39 +00:00
) : { 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 ;
2023-11-30 22:59:19 -05:00
2025-01-14 14:46:39 +00:00
if ( ! rtcMember . sender ) {
return { id , member : undefined } ;
}
if (
rtcMember . sender === room . client . getUserId ( ) &&
rtcMember . deviceId === room . client . getDeviceId ( )
) {
id = "local" ;
}
2023-11-30 22:59:19 -05:00
2025-01-14 14:46:39 +00:00
const member = room . getMember ( rtcMember . sender ) ? ? undefined ;
return { id , member } ;
2023-11-30 22:59:19 -05:00
}
2025-09-24 13:54:54 -04:00
function sharingScreen $ ( p : Participant ) : Observable < boolean > {
return observeParticipantEvents (
p ,
ParticipantEvent . TrackPublished ,
ParticipantEvent . TrackUnpublished ,
ParticipantEvent . LocalTrackPublished ,
ParticipantEvent . LocalTrackUnpublished ,
) . pipe ( map ( ( p ) = > p . isScreenShareEnabled ) ) ;
}
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
private readonly localFocus = makeFocus ( this . matrixRTCSession ) ;
private readonly localConnection = this . localFocus . then (
( focus ) = >
2025-08-28 13:52:12 +02:00
new PublishConnection (
2025-08-28 15:32:46 +02:00
focus ,
2025-08-27 14:01:01 +02:00
this . livekitAlias ,
this . matrixRTCSession . room . client ,
this . scope ,
2025-08-28 15:32:46 +02:00
this . membershipsAndFocusMap $ ,
2025-08-28 17:45:14 +02:00
this . mediaDevices ,
2025-08-29 18:46:24 +02:00
this . muteStates ,
2025-09-16 10:13:14 +02:00
this . e2eeLivekitOptions ( ) ,
2025-09-23 11:38:34 +02:00
this . scope . behavior ( this . trackProcessorState $ ) ,
2025-08-27 14:01:01 +02:00
) ,
) ;
2025-09-16 16:52:17 +02:00
public readonly livekitConnectionState $ = this . scope . behavior (
combineLatest ( [ this . localConnection ] ) . pipe (
switchMap ( ( [ c ] ) = > c . connectionState $ ) ,
startWith ( ConnectionState . Disconnected ) ,
) ,
) ;
2025-09-16 11:31:47 +02:00
// TODO-MULTI-SFU make sure that we consider the room memberships here as well (so that here we only have valid memberships)
// this also makes it possible to use this memberships$ list in all observables based on it.
// there should be no other call to: this.matrixRTCSession.memberships!
public 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-08-28 15:32:46 +02:00
private readonly membershipsAndFocusMap $ = this . scope . behavior (
this . memberships $ . pipe (
map ( ( memberships ) = >
memberships . flatMap ( ( m ) = > {
const f = this . matrixRTCSession . resolveActiveFocus ( m ) ;
return f && isLivekitFocus ( f ) ? [ { membership : m , focus : f } ] : [ ] ;
} ) ,
) ,
2025-08-27 14:01:01 +02:00
) ,
) ;
2025-08-28 17:45:14 +02:00
private readonly livekitServiceUrls $ = this . membershipsAndFocusMap $ . pipe (
2025-08-28 15:32:46 +02:00
map ( ( v ) = > new Set ( v . map ( ( { focus } ) = > focus . livekit_service_url ) ) ) ,
) ;
2025-08-28 13:37:17 +02:00
private readonly remoteConnections $ = this . scope . behavior (
2025-08-28 17:45:14 +02:00
combineLatest ( [ this . localFocus , this . livekitServiceUrls $ ] ) . pipe (
2025-08-28 15:32:46 +02:00
accumulate (
new Map < string , Connection > ( ) ,
( prev , [ localFocus , focusUrls ] ) = > {
const stopped = new Map ( prev ) ;
const next = new Map < string , Connection > ( ) ;
for ( const focusUrl of focusUrls ) {
if ( focusUrl !== localFocus . livekit_service_url ) {
stopped . delete ( focusUrl ) ;
let nextConnection = prev . get ( focusUrl ) ;
if ( ! nextConnection ) {
logger . log (
"SFU remoteConnections$ construct new connection: " ,
focusUrl ,
) ;
nextConnection = new Connection (
{
livekit_service_url : focusUrl ,
livekit_alias : this.livekitAlias ,
type : "livekit" ,
} ,
this . livekitAlias ,
this . matrixRTCSession . room . client ,
this . scope ,
this . membershipsAndFocusMap $ ,
2025-09-16 10:13:14 +02:00
this . e2eeLivekitOptions ( ) ,
2025-08-28 15:32:46 +02:00
) ;
} else {
logger . log (
"SFU remoteConnections$ use prev connection: " ,
focusUrl ,
) ;
}
next . set ( focusUrl , nextConnection ) ;
2025-08-28 13:37:17 +02:00
}
}
2025-08-27 14:01:01 +02:00
2025-08-28 15:32:46 +02:00
for ( const connection of stopped . values ( ) ) connection . stop ( ) ;
return next ;
} ,
) ,
2025-08-28 13:37:17 +02:00
) ,
2025-08-27 14:01:01 +02:00
) ;
2025-08-28 18:41:13 +02:00
private readonly join $ = new Subject < void > ( ) ;
2025-08-27 14:01:01 +02:00
public join ( ) : void {
2025-08-28 18:41:13 +02:00
this . join $ . next ( ) ;
2025-08-27 14:01:01 +02:00
}
2025-09-16 16:52:17 +02:00
private readonly leave $ = new Subject <
"decline" | "timeout" | "user" | "allOthersLeft"
> ( ) ;
2025-08-28 18:41:13 +02:00
2025-08-27 14:01:01 +02:00
public leave ( ) : void {
2025-09-16 16:52:17 +02:00
this . leave $ . next ( "user" ) ;
2025-08-27 14:01:01 +02:00
}
2025-09-16 16:52:17 +02:00
private readonly _left $ = new Subject <
"decline" | "timeout" | "user" | "allOthersLeft"
> ( ) ;
public left $ = this . _left $ . asObservable ( ) ;
2025-08-28 18:41:13 +02:00
private readonly connectionInstructions $ = this . join $ . pipe (
2025-08-27 14:01:01 +02:00
switchMap ( ( ) = > this . remoteConnections $ ) ,
startWith ( new Map < string , Connection > ( ) ) ,
pairwise ( ) ,
map ( ( [ prev , next ] ) = > {
const start = new Set ( next . values ( ) ) ;
for ( const connection of prev . values ( ) ) start . delete ( connection ) ;
const stop = new Set ( prev . values ( ) ) ;
for ( const connection of next . values ( ) ) stop . delete ( connection ) ;
return { start , stop } ;
} ) ,
2025-08-29 18:46:24 +02:00
this . scope . share ,
) ;
private readonly startConnection $ = this . connectionInstructions $ . pipe (
concatMap ( ( { start } ) = > start ) ,
) ;
private readonly stopConnection $ = this . connectionInstructions $ . pipe (
concatMap ( ( { stop } ) = > stop ) ,
2025-08-27 14:01:01 +02:00
) ;
2025-09-16 11:31:47 +02:00
public readonly allLivekitRooms $ = this . scope . behavior (
combineLatest ( [
this . remoteConnections $ ,
this . localConnection ,
this . localFocus ,
] ) . pipe (
map ( ( [ remoteConnections , localConnection , localFocus ] ) = >
Array . from ( remoteConnections . entries ( ) )
2025-09-22 14:18:23 +02:00
. map (
( [ index , c ] ) = >
( {
room : c.livekitRoom ,
url : index ,
} ) as { room : LivekitRoom ; url : string ; isLocal? : boolean } ,
)
2025-09-16 11:31:47 +02:00
. concat ( [
{
room : localConnection.livekitRoom ,
url : localFocus.livekit_service_url ,
2025-09-22 14:18:23 +02:00
isLocal : true ,
2025-09-16 11:31:47 +02:00
} ,
] ) ,
) ,
startWith ( [ ] ) ,
) ,
) ;
2025-08-15 18:38:52 +02:00
private readonly userId = this . matrixRoom . client . getUserId ( ) ;
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-08-29 18:46:24 +02:00
private readonly participants $ = this . scope . behavior <
{
participant : LocalParticipant | RemoteParticipant ;
member : RoomMember ;
livekitRoom : LivekitRoom ;
} [ ]
> (
from ( this . localConnection )
. pipe (
2025-08-28 17:45:14 +02:00
switchMap ( ( localConnection ) = > {
const memberError = ( ) : never = > {
throw new Error ( "No room member for call membership" ) ;
} ;
const localParticipant = {
participant : localConnection.livekitRoom.localParticipant ,
member :
this . matrixRoom . getMember ( this . userId ? ? "" ) ? ? memberError ( ) ,
livekitRoom : localConnection.livekitRoom ,
} ;
return this . remoteConnections $ . pipe (
switchMap ( ( connections ) = >
combineLatest (
2025-08-29 18:46:24 +02:00
[ localConnection , . . . connections . values ( ) ] . map ( ( c ) = >
2025-08-28 17:45:14 +02:00
c . publishingParticipants $ . pipe (
map ( ( ps ) = >
ps . map ( ( { participant , membership } ) = > ( {
participant ,
member :
getRoomMemberFromRtcMember (
membership ,
this . matrixRoom ,
) ? . member ? ? memberError ( ) ,
livekitRoom : c.livekitRoom ,
} ) ) ,
) ,
) ,
) ,
) ,
) ,
map ( ( remoteParticipants ) = > [
localParticipant ,
2025-08-29 18:46:24 +02:00
. . . remoteParticipants . flat ( 1 ) ,
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-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-08-29 18:46:24 +02:00
merge (
// Handle call membership changes.
fromEvent (
this . matrixRTCSession ,
MatrixRTCSessionEvent . MembershipsChanged ,
) ,
// Handle room membership changes (and displayname updates)
fromEvent ( this . matrixRoom , RoomStateEvent . Members ) ,
// TODO: do we need: pauseWhen(this.pretendToBeDisconnected$),
) . pipe (
startWith ( null ) ,
map ( ( ) = > {
const memberships = this . matrixRTCSession . memberships ;
const displaynameMap = new Map < string , string > ( [
[ "local" , this . matrixRoom . getMember ( this . userId ! ) ! . rawDisplayName ] ,
] ) ;
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-16 16:52:17 +02: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 [ ] > (
combineLatest ( [
2025-08-28 17:45:14 +02:00
this . participants $ ,
2025-07-12 00:20:44 -04:00
duplicateTiles . value $ ,
2025-09-03 16:50:43 +02:00
this . memberships $ ,
2025-07-12 00:20:44 -04:00
showNonMemberTiles . value $ ,
] ) . pipe (
2025-06-18 17:14:21 -04:00
scan (
(
prevItems ,
2025-08-28 17:45:14 +02:00
[ participants , duplicateTiles , memberships , showNonMemberTiles ] ,
2025-06-18 17:14:21 -04:00
) = > {
2025-08-28 17:45:14 +02:00
const newItems : Map < string , UserMedia | ScreenShare > = new Map (
2025-06-18 17:14:21 -04:00
function * ( this : CallViewModel ) : Iterable < [ string , MediaItem ] > {
2025-08-28 17:45:14 +02:00
for ( const { participant , member , livekitRoom } of participants ) {
const matrixId = participant . isLocal
? "local"
: participant . identity ;
2025-06-18 17:14:21 -04:00
for ( let i = 0 ; i < 1 + duplicateTiles ; i ++ ) {
2025-08-28 17:45:14 +02:00
const mediaId = ` ${ matrixId } : ${ i } ` ;
let prevMedia = prevItems . get ( mediaId ) ;
2025-06-18 17:14:21 -04:00
if ( prevMedia && prevMedia instanceof UserMedia ) {
prevMedia . updateParticipant ( participant ) ;
if ( prevMedia . vm . member === undefined ) {
// We have a previous media created because of the `debugShowNonMember` flag.
// In this case we actually replace the media item.
// This "hack" never occurs if we do not use the `debugShowNonMember` debugging
// option and if we always find a room member for each rtc member (which also
// only fails if we have a fundamental problem)
prevMedia = undefined ;
}
}
2024-01-20 20:39:12 -05:00
yield [
2025-08-28 17:45:14 +02:00
mediaId ,
2025-06-18 17:14:21 -04:00
// 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 (
2025-08-28 17:45:14 +02:00
mediaId ,
2024-11-04 09:11:44 +00:00
member ,
2024-12-06 12:28:37 +01:00
participant ,
2025-08-08 17:15:47 +02:00
this . options . encryptionSystem ,
2025-08-28 17:45:14 +02:00
livekitRoom ,
2025-08-14 16:39:08 +02:00
this . mediaDevices ,
2025-08-20 18:51:03 +02:00
this . pretendToBeDisconnected $ ,
2025-01-14 14:46:39 +00:00
this . memberDisplaynames $ . pipe (
2025-08-28 17:45:14 +02:00
map ( ( m ) = > m . get ( matrixId ) ? ? "[👻]" ) ,
2025-01-14 14:46:39 +00:00
) ,
2025-06-18 17:14:21 -04:00
this . handsRaised $ . pipe (
2025-08-28 17:45:14 +02:00
map ( ( v ) = > v [ matrixId ] ? . time ? ? null ) ,
2025-06-18 17:14:21 -04:00
) ,
this . reactions $ . pipe (
2025-08-28 17:45:14 +02:00
map ( ( v ) = > v [ matrixId ] ? ? undefined ) ,
2025-06-18 17:14:21 -04:00
) ,
2024-11-04 09:11:44 +00:00
) ,
2024-01-20 20:39:12 -05:00
] ;
2025-06-18 17:14:21 -04:00
if ( participant ? . isScreenShareEnabled ) {
2025-08-28 17:45:14 +02:00
const screenShareId = ` ${ mediaId } :screen-share ` ;
2025-06-18 17:14:21 -04:00
yield [
screenShareId ,
prevItems . get ( screenShareId ) ? ?
new ScreenShare (
screenShareId ,
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-08-28 17:45:14 +02:00
map ( ( m ) = > m . get ( matrixId ) ? ? "[👻]" ) ,
2025-06-18 17:14:21 -04:00
) ,
) ,
] ;
}
2024-01-20 20:39:12 -05:00
}
}
2025-06-18 17:14:21 -04:00
} . bind ( this ) ( ) ,
) ;
2024-01-20 20:39:12 -05:00
2025-06-18 17:14:21 -04:00
// Generate non member items (items without a corresponding MatrixRTC member)
// Those items should not be rendered, they are participants in LiveKit that do not have a corresponding
// MatrixRTC members. This cannot be any good:
// - A malicious user impersonates someone
// - Someone injects abusive content
// - The user cannot have encryption keys so it makes no sense to participate
// We can only trust users that have a MatrixRTC member event.
//
// This is still available as a debug option. This can be useful
// - If one wants to test scalability using the LiveKit CLI.
// - If an experimental project does not yet do the MatrixRTC bits.
// - If someone wants to debug if the LiveKit connection works but MatrixRTC room state failed to arrive.
2025-08-28 17:45:14 +02:00
// TODO-MULTI-SFU
// const newNonMemberItems = showNonMemberTiles
// ? new Map(
// function* (
// this: CallViewModel,
// ): Iterable<[string, MediaItem]> {
// for (const participant of remoteParticipants) {
// for (let i = 0; i < 1 + duplicateTiles; i++) {
// const maybeNonMemberParticipantId =
// participant.identity + ":" + i;
// if (!newItems.has(maybeNonMemberParticipantId)) {
// const nonMemberId = maybeNonMemberParticipantId;
// yield [
// nonMemberId,
// prevItems.get(nonMemberId) ??
// new UserMedia(
// nonMemberId,
// undefined,
// participant,
// this.options.encryptionSystem,
// localConnection.livekitRoom,
// this.mediaDevices,
// this.pretendToBeDisconnected$,
// this.memberDisplaynames$.pipe(
// map(
// (m) =>
// m.get(participant.identity) ?? "[👻]",
// ),
// ),
// of(null),
// of(null),
// ),
// ];
// }
// }
// }
// }.bind(this)(),
// )
// : new Map();
// if (newNonMemberItems.size > 0) {
// logger.debug("Added NonMember items: ", newNonMemberItems);
// }
2024-12-13 14:53:08 +00:00
2025-06-18 17:14:21 -04:00
const combinedNew = new Map ( [
2025-08-28 17:45:14 +02:00
// ...newNonMemberItems.entries(),
2025-06-18 17:14:21 -04:00
. . . newItems . entries ( ) ,
] ) ;
2024-12-13 14:53:08 +00:00
2025-06-18 17:14:21 -04:00
for ( const [ id , t ] of prevItems )
if ( ! combinedNew . has ( id ) ) t . destroy ( ) ;
return combinedNew ;
} ,
new Map < string , MediaItem > ( ) ,
) ,
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-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-16 16:52:17 +02:00
/ * *
* This observable tracks the matrix users that are currently in the call .
* There can be just one matrix user with multiple participants ( see also participantChanges $ )
* /
public readonly matrixUserChanges $ = this . userMedia $ . pipe (
map (
( mediaItems ) = >
new Set (
mediaItems
. map ( ( m ) = > m . vm . member ? . userId )
. filter ( ( id ) = > id !== undefined ) ,
) ,
) ,
scan <
Set < string > ,
{
userIds : Set < string > ;
joinedUserIds : Set < string > ;
leftUserIds : Set < string > ;
}
> (
( prevState , userIds ) = > {
const left = new Set (
[ . . . prevState . userIds ] . filter ( ( id ) = > ! userIds . has ( id ) ) ,
) ;
const joined = new Set (
[ . . . userIds ] . filter ( ( id ) = > ! prevState . userIds . has ( id ) ) ,
) ;
return { userIds : userIds , joinedUserIds : joined , leftUserIds : left } ;
} ,
{ userIds : new Set ( ) , joinedUserIds : new Set ( ) , leftUserIds : new Set ( ) } ,
) ,
) ;
private readonly allOthersLeft $ = this . matrixUserChanges $ . pipe (
map ( ( { userIds , leftUserIds } ) = > {
if ( ! this . userId ) {
logger . warn ( "Could not access user ID to compute allOthersLeft" ) ;
return false ;
}
return (
userIds . size === 1 && userIds . has ( this . userId ) && leftUserIds . size > 0
) ;
} ) ,
startWith ( false ) ,
) ;
public readonly autoLeave $ = this . options . autoLeaveWhenOthersLeft
? this . allOthersLeft $
: NEVER ;
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 ) ;
Add simple global controls to put the call in picture-in-picture mode (#2573)
* Stop sharing state observables when the view model is destroyed
By default, observables running with shareReplay will continue running forever even if there are no subscribers. We need to stop them when the view model is destroyed to avoid memory leaks and other unintuitive behavior.
* Hydrate the call view model in a less hacky way
This ensures that only a single view model is created per call, unlike the previous solution which would create extra view models in strict mode which it was unable to dispose of. The other way was invalid because React gives us no way to reliably dispose of a resource created in the render phase. This is essentially a memory leak fix.
* Add simple global controls to put the call in picture-in-picture mode
Our web and mobile apps (will) all support putting calls into a picture-in-picture mode. However, it'd be nice to have a way of doing this that's more explicit than a breakpoint, because PiP views could in theory get fairly large. Specifically, on mobile, we want a way to do this that can tell you whether the call is ongoing, and that works even without the widget API (because we support SPA calls in the Element X apps…)
To this end, I've created a simple global "controls" API on the window. Right now it only has methods for controlling the picture-in-picture state, but in theory we can expand it to also control mute states, which is current possible via the widget API only.
* Fix footer appearing in large PiP views
* Add a method for whether you can enter picture-in-picture mode
* Have the controls emit booleans directly
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
) ,
) ;
Add simple global controls to put the call in picture-in-picture mode (#2573)
* Stop sharing state observables when the view model is destroyed
By default, observables running with shareReplay will continue running forever even if there are no subscribers. We need to stop them when the view model is destroyed to avoid memory leaks and other unintuitive behavior.
* Hydrate the call view model in a less hacky way
This ensures that only a single view model is created per call, unlike the previous solution which would create extra view models in strict mode which it was unable to dispose of. The other way was invalid because React gives us no way to reliably dispose of a resource created in the render phase. This is essentially a memory leak fix.
* Add simple global controls to put the call in picture-in-picture mode
Our web and mobile apps (will) all support putting calls into a picture-in-picture mode. However, it'd be nice to have a way of doing this that's more explicit than a breakpoint, because PiP views could in theory get fairly large. Specifically, on mobile, we want a way to do this that can tell you whether the call is ongoing, and that works even without the widget API (because we support SPA calls in the Element X apps…)
To this end, I've created a simple global "controls" API on the window. Right now it only has methods for controlling the picture-in-picture state, but in theory we can expand it to also control mute states, which is current possible via the widget API only.
* Fix footer appearing in large PiP views
* Add a method for whether you can enter picture-in-picture mode
* Have the controls emit booleans directly
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 (
from ( this . localConnection ) . pipe (
switchMap ( ( c ) = > sharingScreen $ ( c . livekitRoom . localParticipant ) ) ,
startWith ( false ) ,
) ,
) ;
/ * *
* Callback for toggling screen sharing . If null , screen sharing is not
* available .
* /
public readonly toggleScreenSharing =
"getDisplayMedia" in ( navigator . mediaDevices ? ? { } ) &&
! this . urlParams . hideScreensharing
? ( ) : void = >
void this . localConnection . then (
( c ) = >
void c . livekitRoom . localParticipant
. setScreenShareEnabled ( ! this . sharingScreen $ . value , {
audio : true ,
selfBrowserSurface : "include" ,
surfaceSwitching : "include" ,
systemAudio : "include" ,
} )
. catch ( logger . error ) ,
)
: 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-08-27 16:56:57 +02:00
void from ( this . localConnection )
. pipe ( this . scope . bind ( ) )
. subscribe (
( c ) = >
void c
2025-08-28 13:52:12 +02:00
. start ( )
2025-08-27 16:56:57 +02:00
// eslint-disable-next-line no-console
. then ( ( ) = > console . log ( "successfully started publishing" ) )
// eslint-disable-next-line no-console
. catch ( ( e ) = > console . error ( "failed to start publishing" , e ) ) ,
) ;
2025-08-29 18:46:24 +02:00
this . startConnection $
2025-08-27 14:01:01 +02:00
. pipe ( this . scope . bind ( ) )
2025-08-29 18:46:24 +02:00
. subscribe ( ( c ) = > void c . start ( ) ) ;
this . stopConnection $ . pipe ( this . scope . bind ( ) ) . subscribe ( ( c ) = > c . stop ( ) ) ;
2025-08-28 18:41:13 +02:00
combineLatest ( [ this . localFocus , this . join $ ] )
2025-08-27 14:01:01 +02:00
. pipe ( this . scope . bind ( ) )
. subscribe ( ( [ localFocus ] ) = > {
2025-08-27 14:29:22 +02:00
void enterRTCSession (
2025-08-27 14:01:01 +02:00
this . matrixRTCSession ,
localFocus ,
2025-09-16 10:13:14 +02:00
this . options . encryptionSystem . kind !== E2eeType . NONE ,
true ,
true ,
2025-08-27 14:01:01 +02:00
) ;
} ) ;
2025-08-29 18:46:24 +02:00
2025-09-16 16:52:17 +02:00
this . allOthersLeft $
. pipe (
this . scope . bind ( ) ,
filter ( ( l ) = > ( l && this . options . autoLeaveWhenOthersLeft ) ? ? false ) ,
distinctUntilChanged ( ) ,
)
. subscribe ( ( ) = > {
this . leave $ . next ( "allOthersLeft" ) ;
} ) ;
this . callPickupState $ . pipe ( this . scope . bind ( ) ) . subscribe ( ( state ) = > {
if ( state === "timeout" || state === "decline" ) {
this . leave $ . next ( state ) ;
}
} ) ;
this . leave $ . pipe ( this . scope . bind ( ) ) . subscribe ( ( reason ) = > {
2025-09-24 13:54:54 -04:00
const { confineToRoom } = this . urlParams ;
2025-09-16 16:52:17 +02:00
leaveRTCSession ( this . matrixRTCSession , "user" )
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
. then ( ( ) = > {
if ( ! confineToRoom && ! PosthogAnalytics . instance . isEnabled ( ) ) {
this . _left $ . next ( reason ) ;
}
} )
. catch ( ( e ) = > {
logger . error ( "Error leaving RTC session" , e ) ;
} ) ;
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-08-28 17:45:14 +02:00
void this . localConnection . then ( ( localConnection ) = >
this . matrixConnected $ . pipe ( this . scope . bind ( ) ) . subscribe ( ( connected ) = > {
const publications =
localConnection . livekitRoom . localParticipant . trackPublications . values ( ) ;
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-08-28 17:45:14 +02: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
}
}