Merge pull request #2086 from robintown/layout-state

Add data models for the new call layouts
This commit is contained in:
Robin
2024-05-02 16:35:37 -04:00
committed by GitHub
5 changed files with 532 additions and 171 deletions

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2023 New Vector Ltd Copyright 2023-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -16,25 +16,41 @@ limitations under the License.
import { import {
connectedParticipantsObserver, connectedParticipantsObserver,
observeParticipantEvents,
observeParticipantMedia, observeParticipantMedia,
} from "@livekit/components-core"; } from "@livekit/components-core";
import { Room as LivekitRoom, RemoteParticipant } from "livekit-client"; import {
Room as LivekitRoom,
LocalParticipant,
ParticipantEvent,
RemoteParticipant,
} from "livekit-client";
import { Room as MatrixRoom, RoomMember } from "matrix-js-sdk/src/matrix"; import { Room as MatrixRoom, RoomMember } from "matrix-js-sdk/src/matrix";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { import {
BehaviorSubject,
EMPTY, EMPTY,
Observable, Observable,
audit,
combineLatest, combineLatest,
concat, concat,
distinctUntilChanged,
filter,
map,
merge,
mergeAll, mergeAll,
of, of,
sample, sample,
scan, scan,
shareReplay,
startWith, startWith,
takeUntil, switchAll,
switchMap,
throttleTime,
timer,
zip, zip,
} from "rxjs"; } from "rxjs";
import { state } from "@react-rxjs/core"; import { StateObservable, state } from "@react-rxjs/core";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { ViewModel } from "./ViewModel"; import { ViewModel } from "./ViewModel";
@@ -45,14 +61,21 @@ import {
} from "../livekit/useECConnectionState"; } from "../livekit/useECConnectionState";
import { usePrevious } from "../usePrevious"; import { usePrevious } from "../usePrevious";
import { import {
TileViewModel, MediaViewModel,
UserMediaTileViewModel, UserMediaViewModel,
ScreenShareTileViewModel, ScreenShareViewModel,
} from "./TileViewModel"; } from "./MediaViewModel";
import { finalizeValue } from "../observable-utils"; import { finalizeValue } from "../observable-utils";
import { ObservableScope } from "./ObservableScope";
// How long we wait after a focus switch before showing the real participant
// list again
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
// Represents something that should get a tile on the layout, // Represents something that should get a tile on the layout,
// ie. a user's video feed or a screen share feed. // ie. a user's video feed or a screen share feed.
// TODO: This exposes too much information to the view layer, let's keep this
// information internal to the view model and switch to using Tile<T> instead
export interface TileDescriptor<T> { export interface TileDescriptor<T> {
id: string; id: string;
focused: boolean; focused: boolean;
@@ -65,9 +88,123 @@ export interface TileDescriptor<T> {
data: T; data: T;
} }
// How long we wait after a focus switch before showing the real participant export interface GridLayout {
// list again type: "grid";
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000; spotlight?: MediaViewModel[];
grid: UserMediaViewModel[];
}
export interface SpotlightLayout {
type: "spotlight";
spotlight: MediaViewModel[];
grid: UserMediaViewModel[];
}
export interface FullScreenLayout {
type: "full screen";
spotlight: MediaViewModel[];
pip?: UserMediaViewModel;
}
export interface PipLayout {
type: "pip";
spotlight: MediaViewModel[];
}
/**
* A layout defining the media tiles present on screen and their visual
* arrangement.
*/
export type Layout =
| GridLayout
| SpotlightLayout
| FullScreenLayout
| PipLayout;
export type GridMode = "grid" | "spotlight";
export type WindowMode = "normal" | "full screen" | "pip";
/**
* Sorting bins defining the order in which media tiles appear in the layout.
*/
enum SortingBin {
SelfStart,
Presenters,
Speakers,
VideoAndAudio,
Video,
Audio,
NoMedia,
SelfEnd,
}
class UserMedia {
private readonly scope = new ObservableScope();
public readonly vm: UserMediaViewModel;
public readonly speaker: Observable<boolean>;
public readonly presenter: Observable<boolean>;
public constructor(
public readonly id: string,
member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant,
callEncrypted: boolean,
) {
this.vm = new UserMediaViewModel(id, member, participant, callEncrypted);
this.speaker = this.vm.speaking.pipeState(
// Require 1 s of continuous speaking to become a speaker, and 10 s of
// continuous silence to stop being considered a speaker
audit((s) =>
merge(
timer(s ? 1000 : 10000),
// If the speaking flag resets to its original value during this time,
// end the silencing window to stick with that original value
this.vm.speaking.pipe(filter((s1) => s1 !== s)),
),
),
startWith(false),
distinctUntilChanged(),
this.scope.bind(),
// Make this Observable hot so that the timers don't reset when you
// resubscribe
shareReplay(1),
);
this.presenter = observeParticipantEvents(
participant,
ParticipantEvent.TrackPublished,
ParticipantEvent.TrackUnpublished,
ParticipantEvent.LocalTrackPublished,
ParticipantEvent.LocalTrackUnpublished,
).pipe(map((p) => p.isScreenShareEnabled));
}
public destroy(): void {
this.scope.end();
this.vm.destroy();
}
}
class ScreenShare {
public readonly vm: ScreenShareViewModel;
public constructor(
id: string,
member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant,
callEncrypted: boolean,
) {
this.vm = new ScreenShareViewModel(id, member, participant, callEncrypted);
}
public destroy(): void {
this.vm.destroy();
}
}
type MediaItem = UserMedia | ScreenShare;
function findMatrixMember( function findMatrixMember(
room: MatrixRoom, room: MatrixRoom,
@@ -98,160 +235,346 @@ export class CallViewModel extends ViewModel {
// Lists of participants to "hold" on display, even if LiveKit claims that // Lists of participants to "hold" on display, even if LiveKit claims that
// they've left // they've left
private readonly remoteParticipantHolds = zip( private readonly remoteParticipantHolds: Observable<RemoteParticipant[][]> =
this.connectionState, zip(
this.rawRemoteParticipants.pipe(sample(this.connectionState)), this.connectionState,
(s, ps) => { this.rawRemoteParticipants.pipe(sample(this.connectionState)),
// Whenever we switch focuses, we should retain all the previous (s, ps) => {
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to // Whenever we switch focuses, we should retain all the previous
// give their clients time to switch over and avoid jarring layout shifts // participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
if (s === ECAddonConnectionState.ECSwitchingFocus) { // give their clients time to switch over and avoid jarring layout shifts
return concat( if (s === ECAddonConnectionState.ECSwitchingFocus) {
// Hold these participants return concat(
of({ hold: ps }), // Hold these participants
// Wait for time to pass and the connection state to have changed of({ hold: ps }),
Promise.all([ // Wait for time to pass and the connection state to have changed
new Promise<void>((resolve) => Promise.all([
setTimeout(resolve, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS), new Promise<void>((resolve) =>
), setTimeout(resolve, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
new Promise<void>((resolve) => { ),
const subscription = this.connectionState new Promise<void>((resolve) => {
.pipe(takeUntil(this.destroyed)) const subscription = this.connectionState
.subscribe((s) => { .pipe(this.scope.bind())
if (s !== ECAddonConnectionState.ECSwitchingFocus) { .subscribe((s) => {
resolve(); if (s !== ECAddonConnectionState.ECSwitchingFocus) {
subscription.unsubscribe(); resolve();
} subscription.unsubscribe();
}); }
}), });
// Then unhold them }),
]).then(() => Promise.resolve({ unhold: ps })), // Then unhold them
); ]).then(() => Promise.resolve({ unhold: ps })),
} else { );
return EMPTY; } else {
} return EMPTY;
}, }
).pipe( },
mergeAll(), ).pipe(
// Aggregate the hold instructions into a single list showing which mergeAll(),
// participants are being held // Aggregate the hold instructions into a single list showing which
scan( // participants are being held
(holds, instruction) => scan(
"hold" in instruction (holds, instruction) =>
? [instruction.hold, ...holds] "hold" in instruction
: holds.filter((h) => h !== instruction.unhold), ? [instruction.hold, ...holds]
[] as RemoteParticipant[][], : holds.filter((h) => h !== instruction.unhold),
[] as RemoteParticipant[][],
),
startWith([]),
);
private readonly remoteParticipants: Observable<RemoteParticipant[]> =
combineLatest(
[this.rawRemoteParticipants, this.remoteParticipantHolds],
(raw, holds) => {
const result = [...raw];
const resultIds = new Set(result.map((p) => p.identity));
// Incorporate the held participants into the list
for (const hold of holds) {
for (const p of hold) {
if (!resultIds.has(p.identity)) {
result.push(p);
resultIds.add(p.identity);
}
}
}
return result;
},
);
private readonly mediaItems: StateObservable<MediaItem[]> = state(
combineLatest([
this.remoteParticipants,
observeParticipantMedia(this.livekitRoom.localParticipant),
]).pipe(
scan(
(
prevItems,
[remoteParticipants, { participant: localParticipant }],
) => {
let allGhosts = true;
const newItems = new Map(
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
for (const p of [localParticipant, ...remoteParticipants]) {
const member = findMatrixMember(this.matrixRoom, p.identity);
allGhosts &&= member === undefined;
// We always start with a local participant with the empty string as
// their ID before we're connected, this is fine and we'll be in
// "all ghosts" mode.
if (p.identity !== "" && member === undefined) {
logger.warn(
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
);
}
const userMediaId = p.identity;
yield [
userMediaId,
prevItems.get(userMediaId) ??
new UserMedia(userMediaId, member, p, this.encrypted),
];
if (p.isScreenShareEnabled) {
const screenShareId = `${userMediaId}:screen-share`;
yield [
screenShareId,
prevItems.get(screenShareId) ??
new ScreenShare(screenShareId, member, p, this.encrypted),
];
}
}
}.bind(this)(),
);
for (const [id, t] of prevItems) if (!newItems.has(id)) t.destroy();
// If every item is a ghost, that probably means we're still connecting
// and shouldn't bother showing anything yet
return allGhosts ? new Map() : newItems;
},
new Map<string, MediaItem>(),
),
map((ms) => [...ms.values()]),
finalizeValue((ts) => {
for (const t of ts) t.destroy();
}),
), ),
startWith([]),
); );
private readonly remoteParticipants = combineLatest( private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
[this.rawRemoteParticipants, this.remoteParticipantHolds], map((ms) => ms.filter((m): m is UserMedia => m instanceof UserMedia)),
(raw, holds) => { );
const result = [...raw];
const resultIds = new Set(result.map((p) => p.identity));
// Incorporate the held participants into the list private readonly screenShares: Observable<ScreenShare[]> =
for (const hold of holds) { this.mediaItems.pipe(
for (const p of hold) { map((ms) => ms.filter((m): m is ScreenShare => m instanceof ScreenShare)),
if (!resultIds.has(p.identity)) { );
result.push(p);
resultIds.add(p.identity); private readonly spotlightSpeaker: Observable<UserMedia | null> =
this.userMedia.pipe(
switchMap((ms) =>
ms.length === 0
? of([])
: combineLatest(
ms.map((m) => m.vm.speaking.pipe(map((s) => [m, s] as const))),
),
),
scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>(
(prev, ms) =>
// Decide who to spotlight:
// If the previous speaker is still speaking, stick with them rather
// than switching eagerly to someone else
ms.find(([m, s]) => m === prev && s)?.[0] ??
// Otherwise, select anyone who is speaking
ms.find(([, s]) => s)?.[0] ??
// Otherwise, stick with the person who was last speaking
prev ??
// Otherwise, spotlight the local user
ms.find(([m]) => m.vm.local)?.[0] ??
null,
null,
),
distinctUntilChanged(),
throttleTime(800, undefined, { leading: true, trailing: true }),
);
private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe(
switchMap((ms) => {
const bins = ms.map((m) =>
combineLatest(
[m.speaker, m.presenter, m.vm.audioEnabled, m.vm.videoEnabled],
(speaker, presenter, audio, video) => {
let bin: SortingBin;
if (m.vm.local) bin = SortingBin.SelfStart;
else if (presenter) bin = SortingBin.Presenters;
else if (speaker) bin = SortingBin.Speakers;
else if (video)
bin = audio ? SortingBin.VideoAndAudio : SortingBin.Video;
else bin = audio ? SortingBin.Audio : SortingBin.NoMedia;
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),
);
}),
);
private readonly spotlight: Observable<MediaViewModel[]> = combineLatest(
[this.screenShares, this.spotlightSpeaker],
(screenShares, spotlightSpeaker): MediaViewModel[] =>
screenShares.length > 0
? screenShares.map((m) => m.vm)
: spotlightSpeaker === null
? []
: [spotlightSpeaker.vm],
);
// TODO: Make this react to changes in window dimensions and screen
// orientation
private readonly windowMode = of<WindowMode>("normal");
private readonly _gridMode = new BehaviorSubject<GridMode>("grid");
/**
* The layout mode of the media tile grid.
*/
public readonly gridMode = state(this._gridMode);
public setGridMode(value: GridMode): void {
this._gridMode.next(value);
}
public readonly layout: StateObservable<Layout> = state(
combineLatest([this._gridMode, this.windowMode], (gridMode, windowMode) => {
switch (windowMode) {
case "full screen":
throw new Error("unimplemented");
case "pip":
throw new Error("unimplemented");
case "normal": {
switch (gridMode) {
case "grid":
return combineLatest(
[this.grid, this.spotlight, this.screenShares],
(grid, spotlight, screenShares): Layout => ({
type: "grid",
spotlight: screenShares.length > 0 ? spotlight : undefined,
grid,
}),
);
case "spotlight":
return combineLatest(
[this.grid, this.spotlight],
(grid, spotlight): Layout => ({
type: "spotlight",
spotlight,
grid,
}),
);
} }
} }
} }
}).pipe(switchAll()),
return result;
},
); );
/** /**
* The media tiles to be displayed in the call view. * The media tiles to be displayed in the call view.
*/ */
public readonly tiles = state( // TODO: Get rid of this field, replacing it with the 'layout' field above
combineLatest([ // which keeps more details of the layout order internal to the view model
this.remoteParticipants, public readonly tiles: StateObservable<TileDescriptor<MediaViewModel>[]> =
observeParticipantMedia(this.livekitRoom.localParticipant), state(
]).pipe( combineLatest([
scan((ts, [remoteParticipants, { participant: localParticipant }]) => { this.remoteParticipants,
const ps = [localParticipant, ...remoteParticipants]; observeParticipantMedia(this.livekitRoom.localParticipant),
const tilesById = new Map(ts.map((t) => [t.id, t])); ]).pipe(
const now = Date.now(); scan((ts, [remoteParticipants, { participant: localParticipant }]) => {
let allGhosts = true; const ps = [localParticipant, ...remoteParticipants];
const tilesById = new Map(ts.map((t) => [t.id, t]));
const now = Date.now();
let allGhosts = true;
const newTiles = ps.flatMap((p) => { const newTiles = ps.flatMap((p) => {
const userMediaId = p.identity; const userMediaId = p.identity;
const member = findMatrixMember(this.matrixRoom, userMediaId); const member = findMatrixMember(this.matrixRoom, userMediaId);
allGhosts &&= member === undefined; allGhosts &&= member === undefined;
const spokeRecently = const spokeRecently =
p.lastSpokeAt !== undefined && now - +p.lastSpokeAt <= 10000; p.lastSpokeAt !== undefined && now - +p.lastSpokeAt <= 10000;
// We always start with a local participant with the empty string as // We always start with a local participant with the empty string as
// their ID before we're connected, this is fine and we'll be in // their ID before we're connected, this is fine and we'll be in
// "all ghosts" mode. // "all ghosts" mode.
if (userMediaId !== "" && member === undefined) { if (userMediaId !== "" && member === undefined) {
logger.warn( logger.warn(
`Ruh, roh! No matrix member found for SFU participant '${userMediaId}': creating g-g-g-ghost!`, `Ruh, roh! No matrix member found for SFU participant '${userMediaId}': creating g-g-g-ghost!`,
);
}
const userMediaVm =
tilesById.get(userMediaId)?.data ??
new UserMediaTileViewModel(userMediaId, member, p, this.encrypted);
tilesById.delete(userMediaId);
const userMediaTile: TileDescriptor<TileViewModel> = {
id: userMediaId,
focused: false,
isPresenter: p.isScreenShareEnabled,
isSpeaker: (p.isSpeaking || spokeRecently) && !p.isLocal,
hasVideo: p.isCameraEnabled,
local: p.isLocal,
largeBaseSize: false,
data: userMediaVm,
};
if (p.isScreenShareEnabled) {
const screenShareId = `${userMediaId}:screen-share`;
const screenShareVm =
tilesById.get(screenShareId)?.data ??
new ScreenShareTileViewModel(
screenShareId,
member,
p,
this.encrypted,
); );
tilesById.delete(screenShareId); }
const screenShareTile: TileDescriptor<TileViewModel> = { const userMediaVm =
id: screenShareId, tilesById.get(userMediaId)?.data ??
focused: true, new UserMediaViewModel(userMediaId, member, p, this.encrypted);
isPresenter: false, tilesById.delete(userMediaId);
isSpeaker: false,
hasVideo: true, const userMediaTile: TileDescriptor<MediaViewModel> = {
id: userMediaId,
focused: false,
isPresenter: p.isScreenShareEnabled,
isSpeaker: (p.isSpeaking || spokeRecently) && !p.isLocal,
hasVideo: p.isCameraEnabled,
local: p.isLocal, local: p.isLocal,
largeBaseSize: true, largeBaseSize: false,
placeNear: userMediaId, data: userMediaVm,
data: screenShareVm,
}; };
return [userMediaTile, screenShareTile];
} else {
return [userMediaTile];
}
});
// Any tiles left in the map are unused and should be destroyed if (p.isScreenShareEnabled) {
for (const t of tilesById.values()) t.data.destroy(); const screenShareId = `${userMediaId}:screen-share`;
const screenShareVm =
tilesById.get(screenShareId)?.data ??
new ScreenShareViewModel(
screenShareId,
member,
p,
this.encrypted,
);
tilesById.delete(screenShareId);
// If every item is a ghost, that probably means we're still connecting const screenShareTile: TileDescriptor<MediaViewModel> = {
// and shouldn't bother showing anything yet id: screenShareId,
return allGhosts ? [] : newTiles; focused: true,
}, [] as TileDescriptor<TileViewModel>[]), isPresenter: false,
finalizeValue((ts) => { isSpeaker: false,
for (const t of ts) t.data.destroy(); hasVideo: true,
}), local: p.isLocal,
), largeBaseSize: true,
); placeNear: userMediaId,
data: screenShareVm,
};
return [userMediaTile, screenShareTile];
} else {
return [userMediaTile];
}
});
// Any tiles left in the map are unused and should be destroyed
for (const t of tilesById.values()) t.data.destroy();
// If every item is a ghost, that probably means we're still connecting
// and shouldn't bother showing anything yet
return allGhosts ? [] : newTiles;
}, [] as TileDescriptor<MediaViewModel>[]),
finalizeValue((ts) => {
for (const t of ts) t.data.destroy();
}),
),
);
public constructor( public constructor(
// A call is permanently tied to a single Matrix room and LiveKit room // A call is permanently tied to a single Matrix room and LiveKit room

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2023 New Vector Ltd Copyright 2023-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -43,7 +43,6 @@ import {
of, of,
startWith, startWith,
switchMap, switchMap,
takeUntil,
} from "rxjs"; } from "rxjs";
import { ViewModel } from "./ViewModel"; import { ViewModel } from "./ViewModel";
@@ -64,13 +63,13 @@ function observeTrackReference(
); );
} }
abstract class BaseTileViewModel extends ViewModel { abstract class BaseMediaViewModel extends ViewModel {
/** /**
* Whether the tile belongs to the local user. * Whether the media belongs to the local user.
*/ */
public readonly local = this.participant.isLocal; public readonly local = this.participant.isLocal;
/** /**
* The LiveKit video track to be shown on this tile. * The LiveKit video track for this media.
*/ */
public readonly video: StateObservable<TrackReferenceOrPlaceholder>; public readonly video: StateObservable<TrackReferenceOrPlaceholder>;
/** /**
@@ -83,7 +82,7 @@ abstract class BaseTileViewModel extends ViewModel {
// soon as that code is moved into the view models // soon as that code is moved into the view models
public readonly id: string, public readonly id: string,
/** /**
* The Matrix room member to which this tile belongs. * The Matrix room member to which this media belongs.
*/ */
// TODO: Fully separate the data layer from the UI layer by keeping the // TODO: Fully separate the data layer from the UI layer by keeping the
// member object internal // member object internal
@@ -109,14 +108,14 @@ abstract class BaseTileViewModel extends ViewModel {
} }
/** /**
* A tile displaying some media. * Some participant's media.
*/ */
export type TileViewModel = UserMediaTileViewModel | ScreenShareTileViewModel; export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
/** /**
* A tile displaying some participant's user media. * Some participant's user media.
*/ */
export class UserMediaTileViewModel extends BaseTileViewModel { export class UserMediaViewModel extends BaseMediaViewModel {
/** /**
* Whether the video should be mirrored. * Whether the video should be mirrored.
*/ */
@@ -201,7 +200,7 @@ export class UserMediaTileViewModel extends BaseTileViewModel {
combineLatest([this._locallyMuted, this._localVolume], (muted, volume) => combineLatest([this._locallyMuted, this._localVolume], (muted, volume) =>
muted ? 0 : volume, muted ? 0 : volume,
) )
.pipe(takeUntil(this.destroyed)) .pipe(this.scope.bind())
.subscribe((volume) => { .subscribe((volume) => {
(this.participant as RemoteParticipant).setVolume(volume); (this.participant as RemoteParticipant).setVolume(volume);
}); });
@@ -221,9 +220,9 @@ export class UserMediaTileViewModel extends BaseTileViewModel {
} }
/** /**
* A tile displaying some participant's screen share. * Some participant's screen share media.
*/ */
export class ScreenShareTileViewModel extends BaseTileViewModel { export class ScreenShareViewModel extends BaseMediaViewModel {
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,

View File

@@ -0,0 +1,40 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MonoTypeOperatorFunction, Subject, takeUntil } from "rxjs";
/**
* A scope which limits the execution lifetime of its bound Observables.
*/
export class ObservableScope {
private readonly ended = new Subject<void>();
/**
* Binds an Observable to this scope, so that it completes when the scope
* ends.
*/
public bind<T>(): MonoTypeOperatorFunction<T> {
return takeUntil(this.ended);
}
/**
* Ends the scope, causing any bound Observables to complete.
*/
public end(): void {
this.ended.next();
this.ended.complete();
}
}

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2023 New Vector Ltd Copyright 2023-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -14,20 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { Subject } from "rxjs"; import { ObservableScope } from "./ObservableScope";
/** /**
* An MVVM view model. * An MVVM view model.
*/ */
export abstract class ViewModel { export abstract class ViewModel {
protected readonly destroyed = new Subject<void>(); protected readonly scope = new ObservableScope();
/** /**
* Instructs the ViewModel to clean up its resources. If you forget to call * Instructs the ViewModel to clean up its resources. If you forget to call
* this, there may be memory leaks! * this, there may be memory leaks!
*/ */
public destroy(): void { public destroy(): void {
this.destroyed.next(); this.scope.end();
this.destroyed.complete();
} }
} }

View File

@@ -58,10 +58,10 @@ import { Avatar } from "../Avatar";
import styles from "./VideoTile.module.css"; import styles from "./VideoTile.module.css";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
import { import {
ScreenShareTileViewModel, ScreenShareViewModel,
TileViewModel, MediaViewModel,
UserMediaTileViewModel, UserMediaViewModel,
} from "../state/TileViewModel"; } from "../state/MediaViewModel";
import { subscribe } from "../state/subscribe"; import { subscribe } from "../state/subscribe";
import { useMergedRefs } from "../useMergedRefs"; import { useMergedRefs } from "../useMergedRefs";
import { Slider } from "../Slider"; import { Slider } from "../Slider";
@@ -170,7 +170,7 @@ const Tile = forwardRef<HTMLDivElement, TileProps>(
Tile.displayName = "Tile"; Tile.displayName = "Tile";
interface UserMediaTileProps { interface UserMediaTileProps {
vm: UserMediaTileViewModel; vm: UserMediaViewModel;
className?: string; className?: string;
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number; targetWidth: number;
@@ -329,7 +329,7 @@ const UserMediaTile = subscribe<UserMediaTileProps, HTMLDivElement>(
UserMediaTile.displayName = "UserMediaTile"; UserMediaTile.displayName = "UserMediaTile";
interface ScreenShareTileProps { interface ScreenShareTileProps {
vm: ScreenShareTileViewModel; vm: ScreenShareViewModel;
className?: string; className?: string;
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number; targetWidth: number;
@@ -403,7 +403,7 @@ const ScreenShareTile = subscribe<ScreenShareTileProps, HTMLDivElement>(
ScreenShareTile.displayName = "ScreenShareTile"; ScreenShareTile.displayName = "ScreenShareTile";
interface Props { interface Props {
vm: TileViewModel; vm: MediaViewModel;
maximised: boolean; maximised: boolean;
fullscreen: boolean; fullscreen: boolean;
onToggleFullscreen: (itemId: string) => void; onToggleFullscreen: (itemId: string) => void;
@@ -455,7 +455,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
? t("video_tile.sfu_participant_local") ? t("video_tile.sfu_participant_local")
: displayName; : displayName;
if (vm instanceof UserMediaTileViewModel) { if (vm instanceof UserMediaViewModel) {
return ( return (
<UserMediaTile <UserMediaTile
ref={ref} ref={ref}