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-17 11:37:58 +01:00
import { type RoomMember , 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-17 14:30:16 +01:00
import {
createLocalMembership $ ,
type LocalMemberConnectionState ,
} from "./localMember/LocalMembership.ts" ;
2025-11-05 18:57:24 +01:00
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 {
2025-11-17 11:37:58 +01:00
type AutoLeaveReason ,
2025-11-07 08:44:44 +01:00
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-11-17 11:37:58 +01:00
2025-10-21 00:07:48 -04:00
/ * *
* A view model providing all the application logic needed to show the in - call
* UI ( may eventually be expanded to cover the lobby and feedback screens in the
* future ) .
* /
// Throughout this class and related code we must distinguish between MatrixRTC
// state and LiveKit state. We use the common terminology of room "members", RTC
// "memberships", and LiveKit "participants".
2025-10-16 13:57:08 -04:00
export class CallViewModel {
2025-11-17 11:37:58 +01:00
// lifecycle
public autoLeave$ : Observable < AutoLeaveReason > ;
public callPickupState$ : Behavior <
"unknown" | "ringing" | "timeout" | "decline" | "success" | null
> ;
public leave$ : Observable < "user" | AutoLeaveReason > ;
public hangup : ( ) = > void ;
// joining
2025-11-17 14:30:16 +01:00
public join : ( ) = > LocalMemberConnectionState ;
2025-11-17 11:37:58 +01:00
// screen sharing
public toggleScreenSharing : ( ( ) = > void ) | null ;
public sharingScreen$ : Behavior < boolean > ;
// UI interactions
public tapScreen : ( ) = > void ;
public tapControls : ( ) = > void ;
public hoverScreen : ( ) = > void ;
public unhoverScreen : ( ) = > void ;
// errors
public configError$ : Behavior < ElementCallError | null > ;
// participants and counts
public participantCount$ : Behavior < number > ;
public audioParticipants$ : Behavior < AudioLivekitItem [ ] > ;
public handsRaised$ : Behavior < Record < string , RaisedHandInfo > > ;
public reactions$ : Behavior < Record < string , ReactionOption > > ;
public isOneOnOneWith$ : Behavior < Pick <
RoomMember ,
"userId" | "getMxcAvatarUrl" | "rawDisplayName"
> | null > ;
public localUserIsAlone$ : Behavior < boolean > ;
// sounds and events
public joinSoundEffect$ : Observable < void > ;
public leaveSoundEffect$ : Observable < void > ;
public newHandRaised$ : Observable < { value : number ; playSounds : boolean } > ;
public newScreenShare$ : Observable < { value : number ; playSounds : boolean } > ;
public audibleReactions$ : Observable < string [ ] > ;
public visibleReactions$ : Behavior <
{ sender : string ; emoji : string ; startX : number } [ ]
> ;
// window/layout
public windowMode$ : Behavior < WindowMode > ;
public spotlightExpanded$ : Behavior < boolean > ;
public toggleSpotlightExpanded$ : Behavior < ( ( ) = > void ) | null > ;
public gridMode$ : Behavior < GridMode > ;
public setGridMode : ( value : GridMode ) = > void ;
// media view models and layout
public grid$ : Behavior < UserMediaViewModel [ ] > ;
public spotlight$ : Behavior < MediaViewModel [ ] > ;
public pip$ : Behavior < UserMediaViewModel | null > ;
public layout$ : Behavior < Layout > ;
public tileStoreGeneration$ : Behavior < number > ;
public showSpotlightIndicators$ : Behavior < boolean > ;
public showSpeakingIndicators$ : Behavior < boolean > ;
// header/footer visibility
public showHeader$ : Behavior < boolean > ;
public showFooter$ : Behavior < boolean > ;
// audio routing
public earpieceMode$ : Behavior < boolean > ;
public audioOutputSwitcher$ : Behavior < {
targetOutput : "earpiece" | "speaker" ;
switch : ( ) = > void ;
} | null > ;
// connection state
public reconnecting$ : Behavior < boolean > ;
// THIS has to be the last public field declaration
public constructor (
scope : ObservableScope ,
// A call is permanently tied to a single Matrix room
matrixRTCSession : MatrixRTCSession ,
matrixRoom : MatrixRoom ,
mediaDevices : MediaDevices ,
muteStates : MuteStates ,
options : CallViewModelOptions ,
handsRaisedSubject$ : Observable < Record < string , RaisedHandInfo > > ,
reactionsSubject$ : Observable < Record < string , ReactionInfo > > ,
trackProcessorState$ : Behavior < ProcessorState > ,
) {
const userId = matrixRoom . client . getUserId ( ) ! ;
const deviceId = matrixRoom . client . getDeviceId ( ) ! ;
const livekitKeyProvider = getE2eeKeyProvider (
options . encryptionSystem ,
matrixRTCSession ,
) ;
// 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$
const memberships $ = createMemberships $ ( scope , matrixRTCSession ) ;
// ------------------------------------------------------------------------
// matrixLivekitMembers$ AND localMembership
const membershipsAndTransports = membershipsAndTransports $ (
scope ,
memberships $ ,
) ;
const localTransport $ = createLocalTransport $ ( {
scope : scope ,
memberships$ : memberships$ ,
client : matrixRoom.client ,
roomId : matrixRoom.roomId ,
useOldestMember$ : scope.behavior (
matrixRTCMode . value $ . pipe ( map ( ( v ) = > v === MatrixRTCMode . Legacy ) ) ,
2025-11-14 10:44:16 +01:00
) ,
2025-11-17 11:37:58 +01:00
} ) ;
const connectionFactory = new ECConnectionFactory (
matrixRoom . client ,
mediaDevices ,
trackProcessorState $ ,
livekitKeyProvider ,
getUrlParams ( ) . controlledAudioDevices ,
options . livekitRoomFactory ,
) ;
const connectionManager = createConnectionManager $ ( {
scope : scope ,
connectionFactory : connectionFactory ,
inputTransports$ : scope.behavior (
combineLatest (
[ localTransport $ , membershipsAndTransports . transports $ ] ,
( localTransport , transports ) = > {
const localTransportAsArray = localTransport
? [ localTransport ]
: [ ] ;
return transports . mapInner ( ( transports ) = > [
. . . localTransportAsArray ,
. . . transports ,
] ) ;
} ,
) ,
2025-11-12 12:09:31 +01:00
) ,
2025-11-17 11:37:58 +01:00
logger : logger ,
} ) ;
const matrixLivekitMembers $ = createMatrixLivekitMembers $ ( {
scope : scope ,
membershipsWithTransport $ :
membershipsAndTransports . membershipsWithTransport $ ,
connectionManager : connectionManager ,
} ) ;
const connectOptions $ = scope . behavior (
matrixRTCMode . value $ . pipe (
map ( ( mode ) = > ( {
encryptMedia : livekitKeyProvider !== undefined ,
// TODO. This might need to get called again on each cahnge of matrixRTCMode...
matrixRTCMode : mode ,
} ) ) ,
2025-11-12 12:09:31 +01:00
) ,
) ;
2025-11-17 11:37:58 +01:00
const localMembership = createLocalMembership $ ( {
scope : scope ,
muteStates : muteStates ,
mediaDevices : mediaDevices ,
connectionManager : connectionManager ,
matrixRTCSession : matrixRTCSession ,
matrixRoom : matrixRoom ,
localTransport$ : localTransport$ ,
trackProcessorState$ : trackProcessorState$ ,
widget ,
options : connectOptions$ ,
logger : logger.getChild ( ` [ ${ Date . now ( ) } ] ` ) ,
} ) ;
const localRtcMembership $ = scope . behavior (
memberships $ . pipe (
map (
( memberships ) = >
memberships . value . find (
( membership ) = >
membership . userId === userId &&
membership . deviceId === deviceId ,
) ? ? null ,
) ,
) ,
) ;
2025-11-11 15:51:48 +01:00
2025-11-17 11:37:58 +01:00
const localMatrixLivekitMemberUninitialized = {
membership$ : localRtcMembership$ ,
participant$ : localMembership.participant$ ,
connection$ : localMembership.connection$ ,
userId : userId ,
} ;
const localMatrixLivekitMember$ : Behavior < MatrixLivekitMember | null > =
scope . behavior (
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.
localMatrixLivekitMemberUninitialized as MatrixLivekitMember ,
) ;
} ) ,
) ,
) ;
2025-10-14 10:46:57 +02:00
2025-11-17 11:37:58 +01:00
// ------------------------------------------------------------------------
// callLifecycle
2025-10-03 14:43:22 -04:00
2025-11-17 11:37:58 +01:00
const callLifecycle = createCallNotificationLifecycle $ ( {
scope : scope ,
memberships$ : memberships$ ,
sentCallNotification$ : createSentCallNotification$ (
scope ,
matrixRTCSession ,
) ,
receivedDecline$ : createReceivedDecline$ ( matrixRoom ) ,
options : options ,
localUser : { userId : userId , deviceId : deviceId } ,
} ) ;
// ------------------------------------------------------------------------
// matrixMemberMetadataStore
const matrixRoomMembers $ = createRoomMembers $ ( scope , matrixRoom ) ;
const matrixMemberMetadataStore = createMatrixMemberMetadata $ (
scope ,
scope . behavior ( memberships $ . pipe ( map ( ( mems ) = > mems . value ) ) ) ,
matrixRoomMembers $ ,
) ;
2025-10-03 14:43:22 -04:00
2025-11-17 11:37:58 +01:00
/ * *
* Returns the Member { userId , getMxcAvatarUrl , rawDisplayName } of the other user in the call , if it ' s a one - on - one call .
* /
const isOneOnOneWith $ = scope . behavior (
matrixRoomMembers $ . pipe (
map ( ( roomMembersMap ) = > {
const otherMembers = Array . from ( roomMembersMap . values ( ) ) . filter (
( member ) = > member . userId !== userId ,
) ;
return otherMembers . length === 1 ? otherMembers [ 0 ] : null ;
} ) ,
) ,
) ;
const localUserIsAlone $ = scope . behavior (
matrixRoomMembers $ . pipe (
map (
( roomMembersMap ) = >
roomMembersMap . size === 1 &&
roomMembersMap . get ( userId ) !== undefined ,
) ,
) ,
) ;
// CODESMELL?
// 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$.
const leaveHoisted $ = new Subject <
"user" | "timeout" | "decline" | "allOthersLeft"
> ( ) ;
/ * *
* 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.
// DISCUSSION own membership manager ALSO this probably can be simplifis
2025-11-17 14:39:24 +01:00
const reconnecting $ = localMembership . reconnecting $ ;
2025-11-17 11:37:58 +01:00
const pretendToBeDisconnected $ = reconnecting $ ;
2025-11-17 14:39:24 +01:00
const audioParticipants $ = scope . behavior (
2025-11-17 11:37:58 +01:00
matrixLivekitMembers $ . pipe (
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 ,
} ;
} ) ,
) ,
2025-11-10 15:55:01 +01:00
) ,
2025-11-17 11:37:58 +01:00
) ;
return a $ ;
} ) ,
map ( ( members ) = >
members . reduce < AudioLivekitItem [ ] > ( ( acc , curr ) = > {
if ( ! curr ) return acc ;
const existing = acc . find ( ( item ) = > item . url === curr . url ) ;
if ( existing ) {
existing . participants . push ( curr . participant ) ;
} else {
acc . push ( {
livekitRoom : curr.livekitRoom ,
participants : [ curr . participant ] ,
url : curr.url ,
} ) ;
}
return acc ;
} , [ ] ) ,
) ,
2025-11-07 14:04:40 +01:00
) ,
2025-11-17 11:37:58 +01:00
[ ] ,
) ;
2025-11-17 14:39:24 +01:00
const handsRaised $ = scope . behavior (
2025-11-17 11:37:58 +01:00
handsRaisedSubject $ . pipe ( pauseWhen ( pretendToBeDisconnected $ ) ) ,
) ;
2025-11-17 14:39:24 +01:00
const reactions $ = scope . behavior (
2025-11-17 11:37:58 +01:00
reactionsSubject $ . pipe (
map ( ( v ) = >
Object . fromEntries (
Object . entries ( v ) . map ( ( [ a , { reactionOption } ] ) = > [
a ,
reactionOption ,
] ) ,
) ,
2025-06-23 19:02:36 +02:00
) ,
2025-11-17 11:37:58 +01:00
pauseWhen ( pretendToBeDisconnected $ ) ,
2025-06-23 19:02:36 +02:00
) ,
2025-11-17 11:37:58 +01:00
) ;
2025-06-23 19:02:36 +02:00
2025-11-17 11:37:58 +01:00
/ * *
* List of user media ( camera feeds ) that we want tiles for .
* /
// TODO this also needs the local participant to be added.
const userMedia $ = scope . behavior < UserMedia [ ] > (
combineLatest ( [
localMatrixLivekitMember $ ,
matrixLivekitMembers $ ,
duplicateTiles . value $ ,
] ) . pipe (
// Generate a collection of MediaItems from the list of expected (whether
// present or missing) LiveKit participants.
generateItems (
function * ( [
localMatrixLivekitMember ,
{ value : matrixLivekitMembers } ,
duplicateTiles ,
] ) {
let localParticipantId = undefined ;
// add local member if available
if ( localMatrixLivekitMember ) {
const { userId , participant $ , connection $ , membership $ } =
localMatrixLivekitMember ;
localParticipantId = ` ${ userId } : ${ membership $ . value . deviceId } ` ; // should be membership$.value.membershipID which is not optional
// const participantId = membership$.value.membershipID;
if ( localParticipantId ) {
for ( let dup = 0 ; dup < 1 + duplicateTiles ; dup ++ ) {
yield {
keys : [
dup ,
localParticipantId ,
userId ,
participant $ ,
connection $ ,
] ,
data : undefined ,
} ;
}
}
}
// add remote members that are available
for ( const {
userId ,
participant $ ,
connection $ ,
membership $ ,
} of matrixLivekitMembers ) {
const participantId = ` ${ userId } : ${ membership $ . value . deviceId } ` ;
if ( participantId === localParticipantId ) continue ;
// const participantId = membership$.value?.identity;
2025-11-12 12:09:31 +01:00
for ( let dup = 0 ; dup < 1 + duplicateTiles ; dup ++ ) {
yield {
2025-11-17 11:37:58 +01:00
keys : [ dup , participantId , userId , participant $ , connection $ ] ,
2025-11-12 12:09:31 +01:00
data : undefined ,
} ;
}
}
2025-11-17 11:37:58 +01:00
} ,
(
scope ,
_data $ ,
dup ,
participantId ,
2025-11-07 17:36:16 -05:00
userId ,
participant $ ,
connection $ ,
2025-11-17 11:37:58 +01:00
) = > {
const livekitRoom $ = scope . behavior (
connection $ . pipe ( map ( ( c ) = > c ? . livekitRoom ) ) ,
) ;
const focusUrl $ = scope . behavior (
connection $ . pipe ( map ( ( c ) = > c ? . transport . livekit_service_url ) ) ,
) ;
const displayName $ = scope . behavior (
matrixMemberMetadataStore
. createDisplayNameBehavior $ ( userId )
. pipe ( map ( ( name ) = > name ? ? userId ) ) ,
) ;
2025-11-07 17:36:16 -05:00
2025-11-17 11:37:58 +01:00
return new UserMedia (
scope ,
` ${ participantId } : ${ dup } ` ,
userId ,
participant $ ,
options . encryptionSystem ,
livekitRoom $ ,
focusUrl $ ,
mediaDevices ,
pretendToBeDisconnected $ ,
displayName $ ,
matrixMemberMetadataStore . createAvatarUrlBehavior $ ( userId ) ,
handsRaised $ . pipe ( map ( ( v ) = > v [ participantId ] ? . time ? ? null ) ) ,
reactions $ . pipe ( map ( ( v ) = > v [ participantId ] ? ? undefined ) ) ,
) ;
} ,
) ,
2025-11-07 17:36:16 -05:00
) ,
2025-11-17 11:37:58 +01:00
) ;
2024-01-20 20:39:12 -05:00
2025-11-17 11:37:58 +01:00
/ * *
* List of all media items ( user media and screen share media ) that we want
* tiles for .
* /
const mediaItems $ = scope . behavior < MediaItem [ ] > (
userMedia $ . pipe (
switchMap ( ( userMedia ) = >
userMedia . length === 0
? of ( [ ] )
: combineLatest (
userMedia . map ( ( m ) = > m . screenShares $ ) ,
( . . . screenShares ) = > [ . . . userMedia , . . . screenShares . flat ( 1 ) ] ,
) ,
) ,
2025-11-07 17:36:16 -05:00
) ,
2025-11-17 11:37:58 +01:00
) ;
2025-11-07 17:36:16 -05:00
2025-11-17 11:37:58 +01:00
/ * *
* List of MediaItems that we want to display , that are of type ScreenShare
* /
const screenShares $ = scope . behavior < ScreenShare [ ] > (
mediaItems $ . pipe (
map ( ( mediaItems ) = >
mediaItems . filter ( ( m ) : m is ScreenShare = > m instanceof ScreenShare ) ,
) ,
2025-07-11 23:53:59 -04:00
) ,
2025-11-17 11:37:58 +01:00
) ;
2025-09-03 16:50:43 +02:00
2025-11-17 14:39:24 +01:00
const joinSoundEffect $ = userMedia $ . pipe (
2025-11-17 11:37:58 +01:00
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
2025-11-17 11:37:58 +01:00
/ * *
* The number of participants currently in the call .
*
* - Each participant has a corresponding MatrixRTC membership state event
* - There can be multiple participants for one Matrix user if they join from
* multiple devices .
* /
2025-11-17 14:39:24 +01:00
const participantCount $ = scope . behavior (
2025-11-17 11:37:58 +01:00
matrixLivekitMembers $ . pipe ( map ( ( ms ) = > ms . value . length ) ) ,
) ;
// only public to expose to the view.
// 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-17 14:39:24 +01:00
const callPickupState $ = callLifecycle . callPickupState $ ;
2025-11-17 11:37:58 +01:00
2025-11-17 14:39:24 +01:00
const leaveSoundEffect $ = combineLatest ( [
2025-11-17 11:37:58 +01:00
callLifecycle . callPickupState $ ,
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 ) ,
) ;
const userHangup $ = new Subject < void > ( ) ;
const widgetHangup $ =
widget === null
? NEVER
: (
fromEvent (
widget . lazyActions ,
ElementWidgetActions . HangupCall ,
) as Observable < CustomEvent < IWidgetApiRequest > >
) . pipe (
tap ( ( ev ) = > {
widget ! . api . transport . reply ( ev . detail , { } ) ;
} ) ,
) ;
const leave$ : Observable < "user" | "timeout" | "decline" | "allOthersLeft" > =
merge (
callLifecycle . autoLeave $ ,
merge ( userHangup $ , widgetHangup $ ) . pipe ( map ( ( ) = > "user" as const ) ) ,
) . pipe (
scope . share ,
tap ( ( reason ) = > leaveHoisted $ . next ( reason ) ) ,
) ;
const spotlightSpeaker $ = scope . behavior < UserMediaViewModel | null > (
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-11-17 11:37:58 +01:00
const grid $ = scope . behavior < UserMediaViewModel [ ] > (
userMedia $ . pipe (
switchMap ( ( mediaItems ) = > {
const bins = mediaItems . map ( ( m ) = >
m . bin $ . pipe ( map ( ( bin ) = > [ 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-06-18 17:14:21 -04:00
2025-11-17 11:37:58 +01:00
const spotlight $ = scope . behavior < MediaViewModel [ ] > (
screenShares $ . pipe (
switchMap ( ( screenShares ) = > {
if ( screenShares . length > 0 ) {
return of ( screenShares . map ( ( m ) = > m . vm ) ) ;
}
return spotlightSpeaker $ . pipe (
map ( ( speaker ) = > ( speaker ? [ speaker ] : [ ] ) ) ,
) ;
} ) ,
distinctUntilChanged < MediaViewModel [ ] > ( shallowEquals ) ,
) ,
) ;
const pip $ = scope . behavior < UserMediaViewModel | null > (
combineLatest ( [
// TODO This also needs epoch logic to dedupe the screenshares and mediaItems emits
screenShares $ ,
spotlightSpeaker $ ,
mediaItems $ ,
] ) . pipe (
switchMap ( ( [ screenShares , spotlight , mediaItems ] ) = > {
if ( screenShares . length > 0 ) {
return spotlightSpeaker $ ;
}
if ( ! spotlight || spotlight . local ) {
return of ( null ) ;
}
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 ;
}
2024-12-06 12:28:37 +01:00
2025-11-17 11:37:58 +01:00
return null ;
} ) ,
) ;
} ) ,
) ,
) ;
const hasRemoteScreenShares$ : Observable < boolean > = spotlight $ . pipe (
2024-11-06 04:36:48 -05:00
map ( ( spotlight ) = >
spotlight . some ( ( vm ) = > ! vm . local && vm instanceof ScreenShareViewModel ) ,
) ,
distinctUntilChanged ( ) ,
) ;
2025-11-17 11:37:58 +01:00
const pipEnabled $ = scope . behavior ( setPipEnabled $ , false ) ;
const naturalWindowMode $ = scope . behavior < WindowMode > (
fromEvent ( window , "resize" ) . pipe (
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-06-18 17:14:21 -04:00
) ,
2025-11-17 11:37:58 +01:00
"normal" ,
) ;
2024-07-03 15:08:30 -04:00
2025-11-17 11:37:58 +01:00
/ * *
* The general shape of the window .
* /
2025-11-17 14:39:24 +01:00
const windowMode $ = scope . behavior < WindowMode > (
2025-11-17 11:37:58 +01:00
pipEnabled $ . pipe (
switchMap ( ( pip ) = > ( pip ? of < WindowMode > ( "pip" ) : naturalWindowMode $ ) ) ,
2025-07-12 00:20:44 -04:00
) ,
) ;
2024-01-20 20:39:12 -05:00
2025-11-17 11:37:58 +01:00
const spotlightExpandedToggle $ = new Subject < void > ( ) ;
2025-11-17 14:39:24 +01:00
const spotlightExpanded $ = scope . behavior < boolean > (
2025-11-17 11:37:58 +01:00
spotlightExpandedToggle $ . pipe ( accumulate ( false , ( expanded ) = > ! expanded ) ) ,
) ;
2024-01-20 20:39:12 -05:00
2025-11-17 11:37:58 +01:00
const gridModeUserSelection $ = new Subject < GridMode > ( ) ;
/ * *
* The layout mode of the media tile grid .
* /
2025-11-17 14:39:24 +01:00
const gridMode $ =
2025-11-17 11:37:58 +01:00
// If the user hasn't selected spotlight and somebody starts screen sharing,
// automatically switch to spotlight mode and reset when screen sharing ends
scope . behavior < GridMode > (
gridModeUserSelection $ . pipe (
switchMap ( ( userSelection ) = >
( userSelection === "spotlight"
? EMPTY
: combineLatest ( [ hasRemoteScreenShares $ , windowMode $ ] ) . pipe (
skip ( userSelection === null ? 0 : 1 ) ,
map (
( [ hasScreenShares , windowMode ] ) : GridMode = >
hasScreenShares || windowMode === "flat"
? "spotlight"
: "grid" ,
) ,
)
) . pipe ( startWith ( userSelection ? ? "grid" ) ) ,
) ,
) ,
"grid" ,
) ;
2025-11-17 14:39:24 +01:00
const setGridMode = ( value : GridMode ) : void = > {
2025-11-17 11:37:58 +01:00
gridModeUserSelection $ . next ( value ) ;
} ;
const gridLayoutMedia$ : Observable < GridLayoutMedia > = combineLatest (
[ grid $ , spotlight $ ] ,
( grid , spotlight ) = > ( {
type : "grid" ,
spotlight : spotlight.some ( ( vm ) = > vm instanceof ScreenShareViewModel )
? spotlight
: undefined ,
grid ,
2024-11-11 08:25:16 -05:00
} ) ,
2024-11-06 04:36:48 -05:00
) ;
2024-07-25 17:51:00 -04:00
2025-11-17 11:37:58 +01:00
const spotlightLandscapeLayoutMedia$ : Observable < SpotlightLandscapeLayoutMedia > =
combineLatest ( [ grid $ , spotlight $ ] , ( grid , spotlight ) = > ( {
type : "spotlight-landscape" ,
spotlight ,
grid ,
} ) ) ;
const spotlightPortraitLayoutMedia$ : Observable < SpotlightPortraitLayoutMedia > =
combineLatest ( [ grid $ , spotlight $ ] , ( grid , spotlight ) = > ( {
type : "spotlight-portrait" ,
spotlight ,
grid ,
} ) ) ;
const spotlightExpandedLayoutMedia$ : Observable < SpotlightExpandedLayoutMedia > =
combineLatest ( [ spotlight $ , pip $ ] , ( spotlight , pip ) = > ( {
type : "spotlight-expanded" ,
spotlight ,
pip : pip ? ? undefined ,
} ) ) ;
const oneOnOneLayoutMedia$ : Observable < OneOnOneLayoutMedia | null > =
mediaItems $ . pipe (
map ( ( mediaItems ) = > {
if ( mediaItems . length !== 2 ) return null ;
const local = mediaItems . find ( ( vm ) = > vm . vm . local ) ? . vm as
| LocalUserMediaViewModel
| undefined ;
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
if ( ! remote || ! local ) return null ;
return { type : "one-on-one" , local , remote } ;
} ) ,
) ;
2024-07-25 17:51:00 -04:00
2025-11-17 11:37:58 +01:00
const pipLayoutMedia$ : Observable < LayoutMedia > = spotlight $ . pipe (
map ( ( spotlight ) = > ( { type : "pip" , spotlight } ) ) ,
) ;
/ * *
* The media to be used to produce a layout .
* /
const layoutMedia $ = scope . behavior < LayoutMedia > (
windowMode $ . pipe (
switchMap ( ( windowMode ) = > {
switch ( windowMode ) {
case "normal" :
return gridMode $ . pipe (
switchMap ( ( gridMode ) = > {
switch ( gridMode ) {
case "grid" :
return oneOnOneLayoutMedia $ . pipe (
switchMap ( ( oneOnOne ) = >
oneOnOne === null ? gridLayoutMedia$ : of ( oneOnOne ) ,
) ,
) ;
case "spotlight" :
return spotlightExpanded $ . pipe (
switchMap ( ( expanded ) = >
expanded
? spotlightExpandedLayoutMedia $
: spotlightLandscapeLayoutMedia $ ,
) ,
) ;
}
} ) ,
) ;
case "narrow" :
return oneOnOneLayoutMedia $ . pipe (
switchMap ( ( oneOnOne ) = >
oneOnOne === null
? combineLatest ( [ grid $ , spotlight $ ] , ( grid , spotlight ) = >
2024-12-17 04:01:56 +00:00
grid . length > smallMobileCallThreshold ||
spotlight . some (
( vm ) = > vm instanceof ScreenShareViewModel ,
)
2025-11-17 11:37:58 +01:00
? spotlightPortraitLayoutMedia $
: gridLayoutMedia $ ,
) . pipe ( switchAll ( ) )
: // The expanded spotlight layout makes for a better one-on-one
// experience in narrow windows
spotlightExpandedLayoutMedia $ ,
) ,
) ;
case "flat" :
return gridMode $ . pipe (
switchMap ( ( gridMode ) = > {
switch ( gridMode ) {
case "grid" :
// Yes, grid mode actually gets you a "spotlight" layout in
// this window mode.
return spotlightLandscapeLayoutMedia $ ;
case "spotlight" :
return spotlightExpandedLayoutMedia $ ;
}
} ) ,
2025-06-18 17:14:21 -04:00
) ;
case "pip" :
2025-11-17 11:37:58 +01:00
return pipLayoutMedia $ ;
2025-06-18 17:14:21 -04:00
}
2025-11-17 11:37:58 +01:00
} ) ,
2025-06-18 17:14:21 -04:00
) ,
2025-11-17 11:37:58 +01:00
) ;
2024-12-11 05:23:42 -05:00
2025-11-17 11:37:58 +01: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.
const visibleTiles $ = new Subject < number > ( ) ;
const setVisibleTiles = ( value : number ) : void = > visibleTiles $ . next ( value ) ;
const layoutInternals $ = scope . behavior <
LayoutScanState & { layout : Layout }
> (
combineLatest ( [
layoutMedia $ ,
visibleTiles $ . pipe ( startWith ( 0 ) , distinctUntilChanged ( ) ) ,
] ) . pipe (
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 ,
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-11-17 11:37:58 +01:00
return { layout , tiles : newTiles } ;
} ,
{ layout : null , tiles : TileStore.empty ( ) } ,
) ,
2025-07-12 00:20:44 -04:00
) ,
2025-11-17 11:37:58 +01:00
) ;
2024-07-26 06:57:49 -04:00
2025-11-17 11:37:58 +01:00
/ * *
* The layout of tiles in the call interface .
* /
2025-11-17 14:39:24 +01:00
const layout $ = scope . behavior < Layout > (
2025-11-17 11:37:58 +01:00
layoutInternals $ . pipe ( map ( ( { layout } ) = > layout ) ) ,
) ;
2024-08-08 17:21:47 -04:00
2025-11-17 11:37:58 +01:00
/ * *
* The current generation of the tile store , exposed for debugging purposes .
* /
2025-11-17 14:39:24 +01:00
const tileStoreGeneration $ = scope . behavior < number > (
2025-11-17 11:37:58 +01:00
layoutInternals $ . pipe ( map ( ( { tiles } ) = > tiles . generation ) ) ,
) ;
2024-08-08 17:21:47 -04:00
2025-11-17 14:39:24 +01:00
const showSpotlightIndicators $ = scope . behavior < boolean > (
2025-11-17 11:37:58 +01:00
layout $ . pipe ( map ( ( l ) = > l . type !== "grid" ) ) ,
) ;
2024-11-08 10:23:19 -05:00
2025-11-17 14:39:24 +01:00
const showSpeakingIndicators $ = scope . behavior < boolean > (
2025-11-17 11:37:58 +01:00
layout $ . pipe (
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 ) ,
) ,
) ;
// 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 ) ;
}
} ) ,
) ,
) ;
2024-08-08 17:21:47 -04:00
2025-11-17 14:39:24 +01:00
const toggleSpotlightExpanded $ = scope . behavior < ( ( ) = > void ) | null > (
2025-11-17 11:37:58 +01:00
windowMode $ . pipe (
switchMap ( ( mode ) = >
mode === "normal"
? layout $ . pipe (
map (
( l ) = >
l . type === "spotlight-landscape" ||
l . type === "spotlight-expanded" ,
) ,
)
: of ( false ) ,
) ,
distinctUntilChanged ( ) ,
map ( ( enabled ) = >
enabled ? ( ) : void = > spotlightExpandedToggle $ . next ( ) : null ,
) ,
) ,
) ;
2024-08-08 17:21:47 -04:00
2025-11-17 11:37:58 +01:00
const screenTap $ = new Subject < void > ( ) ;
const controlsTap $ = new Subject < void > ( ) ;
const screenHover $ = new Subject < void > ( ) ;
const screenUnhover $ = new Subject < void > ( ) ;
2024-12-19 15:54:28 +00:00
2025-11-17 14:39:24 +01:00
const showHeader $ = scope . behavior < boolean > (
2025-11-17 11:37:58 +01:00
windowMode $ . pipe ( map ( ( mode ) = > mode !== "pip" && mode !== "flat" ) ) ,
) ;
2025-06-26 05:08:57 -04:00
2025-11-17 14:39:24 +01:00
const showFooter $ = scope . behavior < boolean > (
2025-11-17 11:37:58 +01:00
windowMode $ . pipe (
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 (
screenTap $ . pipe ( map ( ( ) = > "tap screen" as const ) ) ,
controlsTap $ . pipe ( map ( ( ) = > "tap controls" as const ) ) ,
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 ) ,
screenUnhover $ . pipe ( take ( 1 ) ) ,
) . pipe (
map ( ( ) = > false ) ,
startWith ( true ) ,
) ;
}
} , false ) ,
startWith ( false ) ,
) ;
}
} ) ,
) ,
) ;
2025-06-26 05:08:57 -04:00
2025-11-17 11:37:58 +01:00
/ * *
* Whether audio is currently being output through the earpiece .
* /
2025-11-17 14:39:24 +01:00
const earpieceMode $ = scope . behavior < boolean > (
2025-11-17 11:37:58 +01:00
combineLatest (
[
mediaDevices . audioOutput . available $ ,
mediaDevices . audioOutput . selected $ ,
] ,
( available , selected ) = >
selected !== undefined &&
available . get ( selected . id ) ? . type === "earpiece" ,
) ,
) ;
2024-12-19 15:54:28 +00:00
2025-11-17 11:37:58 +01: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-11-17 14:39:24 +01:00
const audioOutputSwitcher $ = scope . behavior < {
2025-11-17 11:37:58 +01:00
targetOutput : "earpiece" | "speaker" ;
switch : ( ) = > void ;
} | null > (
combineLatest (
[
mediaDevices . audioOutput . available $ ,
mediaDevices . audioOutput . selected $ ,
] ,
( available , selected ) = > {
const selectionType = selected && available . get ( selected . id ) ? . type ;
// If we are in any output mode other than speaker switch to speaker.
const newSelectionType : "earpiece" | "speaker" =
selectionType === "speaker" ? "earpiece" : "speaker" ;
const newSelection = [ . . . available ] . find (
( [ , d ] ) = > d . type === newSelectionType ,
) ;
if ( newSelection === undefined ) return null ;
2024-12-19 15:54:28 +00:00
2025-11-17 11:37:58 +01:00
const [ id ] = newSelection ;
return {
targetOutput : newSelectionType ,
switch : ( ) : void = > mediaDevices . audioOutput . select ( id ) ,
} ;
} ,
) ,
) ;
2024-12-19 15:54:28 +00:00
2025-11-17 11:37:58 +01:00
/ * *
* Emits an array of reactions that should be visible on the screen .
* /
// DISCUSSION move this into a reaction file
// const {visibleReactions$, audibleReactions$} = reactionsObservables$(showReactionSetting$, )
const visibleReactions $ = scope . behavior (
showReactions . value $ . pipe (
switchMap ( ( show ) = > ( show ? 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 ;
} , [ ] ) ,
) ,
) ;
2024-08-08 17:21:47 -04:00
2025-11-17 11:37:58 +01:00
/ * *
* Emits an array of reactions that should be played .
* /
const audibleReactions $ = playReactionsSound . value $ . pipe (
switchMap ( ( show ) = >
show ? 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 ) ,
) ;
2025-09-24 13:54:54 -04:00
2025-11-17 11:37:58 +01:00
const newHandRaised $ = 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 ) ,
) ;
2025-09-24 13:54:54 -04:00
2025-11-17 11:37:58 +01:00
const newScreenShare $ = screenShares $ . pipe (
map ( ( v ) = > v . length ) ,
scan (
( acc , newValue ) = > ( {
value : newValue ,
playSounds : newValue > acc . value ,
} ) ,
{ value : 0 , playSounds : false } ,
) ,
filter ( ( v ) = > v . playSounds ) ,
) ;
/ * *
* Whether we are sharing our screen .
* /
// reassigned here to make it publicly accessible
const sharingScreen $ = localMembership . sharingScreen $ ;
/ * *
* Callback to toggle screen sharing . If null , screen sharing is not possible .
* /
// reassigned here to make it publicly accessible
const toggleScreenSharing = localMembership . toggleScreenSharing ;
const join = localMembership . requestConnect ;
join ( ) ; // TODO-MULTI-SFU: Use this view model for the lobby as well, and only call this once 'join' is clicked?
this . autoLeave $ = callLifecycle . autoLeave $ ;
this . callPickupState $ = callPickupState $ ;
this . leave $ = leave $ ;
this . hangup = ( ) : void = > userHangup $ . next ( ) ;
this . join = join ;
this . toggleScreenSharing = toggleScreenSharing ;
this . sharingScreen $ = sharingScreen $ ;
this . tapScreen = ( ) : void = > screenTap $ . next ( ) ;
this . tapControls = ( ) : void = > controlsTap $ . next ( ) ;
this . hoverScreen = ( ) : void = > screenHover $ . next ( ) ;
this . unhoverScreen = ( ) : void = > screenUnhover $ . next ( ) ;
this . configError $ = localMembership . configError $ ;
this . participantCount $ = participantCount $ ;
this . audioParticipants $ = audioParticipants $ ;
this . isOneOnOneWith $ = isOneOnOneWith $ ;
this . localUserIsAlone $ = localUserIsAlone $ ;
this . handsRaised $ = handsRaised $ ;
this . reactions $ = reactions $ ;
this . joinSoundEffect $ = joinSoundEffect $ ;
this . leaveSoundEffect $ = leaveSoundEffect $ ;
this . newHandRaised $ = newHandRaised $ ;
this . newScreenShare $ = newScreenShare $ ;
this . audibleReactions $ = audibleReactions $ ;
this . visibleReactions $ = visibleReactions $ ;
this . windowMode $ = windowMode $ ;
this . spotlightExpanded $ = spotlightExpanded $ ;
this . toggleSpotlightExpanded $ = toggleSpotlightExpanded $ ;
this . gridMode $ = gridMode $ ;
this . setGridMode = setGridMode ;
this . grid $ = grid $ ;
this . spotlight $ = spotlight $ ;
this . pip $ = pip $ ;
this . layout $ = layout $ ;
this . tileStoreGeneration $ = tileStoreGeneration $ ;
this . showSpotlightIndicators $ = showSpotlightIndicators $ ;
this . showSpeakingIndicators $ = showSpeakingIndicators $ ;
this . showHeader $ = showHeader $ ;
this . showFooter $ = showFooter $ ;
this . earpieceMode $ = earpieceMode $ ;
this . audioOutputSwitcher $ = audioOutputSwitcher $ ;
this . reconnecting $ = reconnecting $ ;
2023-11-30 22:59:19 -05: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
}
}