diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 2bd83dfb..341f2286 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -168,40 +168,6 @@ export type GridMode = "grid" | "spotlight"; export type WindowMode = "normal" | "narrow" | "flat" | "pip"; -/** - * Sorting bins defining the order in which media tiles appear in the layout. - */ -enum SortingBin { - /** - * Yourself, when the "always show self" option is on. - */ - SelfAlwaysShown, - /** - * Participants that are sharing their screen. - */ - Presenters, - /** - * Participants that have been speaking recently. - */ - Speakers, - /** - * Participants that have their hand raised. - */ - HandRaised, - /** - * Participants with video. - */ - Video, - /** - * Participants not sharing any video. - */ - NoVideo, - /** - * Yourself, when the "always show self" option is off. - */ - SelfNotAlwaysShown, -} - interface LayoutScanState { layout: Layout | null; tiles: TileStore; @@ -549,6 +515,9 @@ export class CallViewModel extends ViewModel { private readonly userId = this.matrixRoom.client.getUserId()!; private readonly deviceId = this.matrixRoom.client.getDeviceId()!; + /** + * Whether we are connected to the MatrixRTC session. + */ private readonly matrixConnected$ = this.scope.behavior( // To consider ourselves connected to MatrixRTC, we check the following: and$( @@ -583,6 +552,10 @@ export class CallViewModel extends ViewModel { ), ); + /** + * Whether we are "fully" connected to the call. Accounts for both the + * connection to the MatrixRTC session and the LiveKit publish connection. + */ private readonly connected$ = this.scope.behavior( and$( this.matrixConnected$, @@ -600,7 +573,7 @@ export class CallViewModel extends ViewModel { // We are reconnecting if we previously had some successful initial // connection but are now disconnected scan( - ({ connectedPreviously, reconnecting }, connectedNow) => ({ + ({ connectedPreviously }, connectedNow) => ({ connectedPreviously: connectedPreviously || connectedNow, reconnecting: connectedPreviously && !connectedNow, }), @@ -627,7 +600,7 @@ export class CallViewModel extends ViewModel { private readonly participantsByRoom$ = this.scope.behavior< { livekitRoom: LivekitRoom; - url: string; + url: string; // Included for use as a React key participants: { id: string; participant: LocalParticipant | RemoteParticipant | undefined; @@ -1114,31 +1087,7 @@ export class CallViewModel extends ViewModel { this.userMedia$.pipe( switchMap((mediaItems) => { const bins = mediaItems.map((m) => - combineLatest( - [ - m.speaker$, - m.presenter$, - m.vm.videoEnabled$, - m.vm.handRaised$, - m.vm instanceof LocalUserMediaViewModel - ? m.vm.alwaysShow$ - : of(false), - ], - (speaker, presenter, video, handRaised, alwaysShow) => { - 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; - else if (handRaised) bin = SortingBin.HandRaised; - else if (video) bin = SortingBin.Video; - else bin = SortingBin.NoVideo; - - return [m, bin] as const; - }, - ), + 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 diff --git a/src/state/ScreenShare.ts b/src/state/ScreenShare.ts index 66cabf01..e6fa81ec 100644 --- a/src/state/ScreenShare.ts +++ b/src/state/ScreenShare.ts @@ -4,7 +4,7 @@ Copyright 2025 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { BehaviorSubject, type Observable } from "rxjs"; +import { of, type Observable } from "rxjs"; import { type LocalParticipant, type RemoteParticipant, @@ -17,13 +17,14 @@ import type { RoomMember } from "matrix-js-sdk"; import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts"; import type { Behavior } from "./Behavior.ts"; -// TODO Document this +/** + * A screen share media item to be presented in a tile. This is a thin wrapper + * around ScreenShareViewModel which essentially just establishes an + * ObservableScope for behaviors that the view model depends on. + */ export class ScreenShare { private readonly scope = new ObservableScope(); public readonly vm: ScreenShareViewModel; - private readonly participant$: BehaviorSubject< - LocalParticipant | RemoteParticipant - >; public constructor( id: string, @@ -35,12 +36,10 @@ export class ScreenShare { pretendToBeDisconnected$: Behavior, displayName$: Observable, ) { - this.participant$ = new BehaviorSubject(participant); - this.vm = new ScreenShareViewModel( id, member, - this.participant$.asObservable(), + of(participant), encryptionSystem, livekitRoom, focusUrl, diff --git a/src/state/UserMedia.ts b/src/state/UserMedia.ts index 9c5990b2..42016f7c 100644 --- a/src/state/UserMedia.ts +++ b/src/state/UserMedia.ts @@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { BehaviorSubject, map, type Observable, of, switchMap } from "rxjs"; +import { + BehaviorSubject, + combineLatest, + map, + type Observable, + of, + switchMap, +} from "rxjs"; import { type LocalParticipant, type Participant, @@ -29,71 +36,129 @@ import type { ReactionOption } from "../reactions"; import { observeSpeaker$ } from "./observeSpeaker.ts"; /** - * TODO Document this + * Sorting bins defining the order in which media tiles appear in the layout. + */ +enum SortingBin { + /** + * Yourself, when the "always show self" option is on. + */ + SelfAlwaysShown, + /** + * Participants that are sharing their screen. + */ + Presenters, + /** + * Participants that have been speaking recently. + */ + Speakers, + /** + * Participants that have their hand raised. + */ + HandRaised, + /** + * Participants with video. + */ + Video, + /** + * Participants not sharing any video. + */ + NoVideo, + /** + * Yourself, when the "always show self" option is off. + */ + SelfNotAlwaysShown, +} + +/** + * A user media item to be presented in a tile. This is a thin wrapper around + * UserMediaViewModel which additionally determines the media item's sorting bin + * for inclusion in the call layout. */ export class UserMedia { private readonly scope = new ObservableScope(); - public readonly vm: UserMediaViewModel; - private readonly participant$: BehaviorSubject< - LocalParticipant | RemoteParticipant | undefined - >; + private readonly participant$ = new BehaviorSubject(this.initialParticipant); - public readonly speaker$: Behavior; - public readonly presenter$: Behavior; + public readonly vm: UserMediaViewModel = this.participant$.value?.isLocal + ? new LocalUserMediaViewModel( + this.id, + this.member, + this.participant$ as Behavior, + this.encryptionSystem, + this.livekitRoom, + this.focusURL, + this.mediaDevices, + this.scope.behavior(this.displayname$), + this.scope.behavior(this.handRaised$), + this.scope.behavior(this.reaction$), + ) + : new RemoteUserMediaViewModel( + this.id, + this.member, + this.participant$ as Observable, + this.encryptionSystem, + this.livekitRoom, + this.focusURL, + this.pretendToBeDisconnected$, + this.scope.behavior(this.displayname$), + this.scope.behavior(this.handRaised$), + this.scope.behavior(this.reaction$), + ); + + private readonly speaker$ = this.scope.behavior( + observeSpeaker$(this.vm.speaking$), + ); + + private readonly presenter$ = this.scope.behavior( + this.participant$.pipe( + switchMap((p) => (p === undefined ? of(false) : sharingScreen$(p))), + ), + ); + + /** + * Which sorting bin the media item should be placed in. + */ + // This is exposed here rather than by UserMediaViewModel because it's only + // relevant to the layout algorithms; the MediaView component should be + // ignorant of this value. + public readonly bin$ = combineLatest( + [ + this.speaker$, + this.presenter$, + this.vm.videoEnabled$, + this.vm.handRaised$, + this.vm instanceof LocalUserMediaViewModel + ? this.vm.alwaysShow$ + : of(false), + ], + (speaker, presenter, video, handRaised, alwaysShow) => { + if (this.vm.local) + return alwaysShow + ? SortingBin.SelfAlwaysShown + : SortingBin.SelfNotAlwaysShown; + else if (presenter) return SortingBin.Presenters; + else if (speaker) return SortingBin.Speakers; + else if (handRaised) return SortingBin.HandRaised; + else if (video) return SortingBin.Video; + else return SortingBin.NoVideo; + }, + ); public constructor( public readonly id: string, - member: RoomMember, - participant: LocalParticipant | RemoteParticipant | undefined, - encryptionSystem: EncryptionSystem, - livekitRoom: LivekitRoom, - focusURL: string, - mediaDevices: MediaDevices, - pretendToBeDisconnected$: Behavior, - displayname$: Observable, - handRaised$: Observable, - reaction$: Observable, - ) { - this.participant$ = new BehaviorSubject(participant); - - if (participant?.isLocal) { - this.vm = new LocalUserMediaViewModel( - this.id, - member, - this.participant$ as Behavior, - encryptionSystem, - livekitRoom, - focusURL, - mediaDevices, - this.scope.behavior(displayname$), - this.scope.behavior(handRaised$), - this.scope.behavior(reaction$), - ); - } else { - this.vm = new RemoteUserMediaViewModel( - id, - member, - this.participant$.asObservable() as Observable< - RemoteParticipant | undefined - >, - encryptionSystem, - livekitRoom, - focusURL, - pretendToBeDisconnected$, - this.scope.behavior(displayname$), - this.scope.behavior(handRaised$), - this.scope.behavior(reaction$), - ); - } - - this.speaker$ = this.scope.behavior(observeSpeaker$(this.vm.speaking$)); - - this.presenter$ = this.scope.behavior( - this.participant$.pipe( - switchMap((p) => (p === undefined ? of(false) : sharingScreen$(p))), - ), - ); - } + private readonly member: RoomMember, + private readonly initialParticipant: + | LocalParticipant + | RemoteParticipant + | undefined, + private readonly encryptionSystem: EncryptionSystem, + private readonly livekitRoom: LivekitRoom, + private readonly focusURL: string, + private readonly mediaDevices: MediaDevices, + private readonly pretendToBeDisconnected$: Behavior, + private readonly displayname$: Observable, + private readonly handRaised$: Observable, + private readonly reaction$: Observable, + ) {} public updateParticipant( newParticipant: LocalParticipant | RemoteParticipant | undefined,