Files
element-call-Github/src/state/TileStore.ts
2025-03-13 17:51:41 -04:00

301 lines
9.9 KiB
TypeScript

/*
Copyright 2024 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 } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel";
import { GridTileViewModel, SpotlightTileViewModel } from "./TileViewModel";
import { fillGaps } from "../utils/iter";
import { debugTileLayout } from "../settings/settings";
function debugEntries(entries: GridTileData[]): string[] {
return entries.map((e) => e.media.member?.rawDisplayName ?? "[👻]");
}
let DEBUG_ENABLED = false;
debugTileLayout.value$.subscribe((value) => (DEBUG_ENABLED = value));
class SpotlightTileData {
private readonly media$: BehaviorSubject<MediaViewModel[]>;
public get media(): MediaViewModel[] {
return this.media$.value;
}
public set media(value: MediaViewModel[]) {
this.media$.next(value);
}
private readonly maximised$: BehaviorSubject<boolean>;
public get maximised(): boolean {
return this.maximised$.value;
}
public set maximised(value: boolean) {
this.maximised$.next(value);
}
public readonly vm: SpotlightTileViewModel;
public constructor(media: MediaViewModel[], maximised: boolean) {
this.media$ = new BehaviorSubject(media);
this.maximised$ = new BehaviorSubject(maximised);
this.vm = new SpotlightTileViewModel(this.media$, this.maximised$);
}
public destroy(): void {
this.vm.destroy();
}
}
class GridTileData {
private readonly media$: BehaviorSubject<UserMediaViewModel>;
public get media(): UserMediaViewModel {
return this.media$.value;
}
public set media(value: UserMediaViewModel) {
this.media$.next(value);
}
public readonly vm: GridTileViewModel;
public constructor(media: UserMediaViewModel) {
this.media$ = new BehaviorSubject(media);
this.vm = new GridTileViewModel(this.media$);
}
public destroy(): void {
this.vm.destroy();
}
}
/**
* A collection of tiles to be mapped to a layout.
*/
export class TileStore {
private constructor(
private readonly spotlight: SpotlightTileData | null,
private readonly grid: GridTileData[],
/**
* A number incremented on each update, just for debugging purposes.
*/
public readonly generation: number,
) {}
public readonly spotlightTile = this.spotlight?.vm;
public readonly gridTiles = this.grid.map(({ vm }) => vm);
public readonly gridTilesByMedia = new Map(
this.grid.map(({ vm, media }) => [media, vm]),
);
/**
* Creates an an empty collection of tiles.
*/
public static empty(): TileStore {
return new TileStore(null, [], 0);
}
/**
* Creates a builder which can be used to update the collection, passing
* ownership of the tiles to the updated collection.
*/
public from(visibleTiles: number): TileStoreBuilder {
return new TileStoreBuilder(
this.spotlight,
this.grid,
(spotlight, grid) => new TileStore(spotlight, grid, this.generation + 1),
visibleTiles,
this.generation,
);
}
}
/**
* A builder for a new collection of tiles. Will reuse tiles and destroy unused
* tiles from a previous collection where appropriate.
*/
export class TileStoreBuilder {
private spotlight: SpotlightTileData | null = null;
private readonly prevSpotlightSpeaker =
this.prevSpotlight?.media.length === 1 &&
"speaking" in this.prevSpotlight.media[0] &&
this.prevSpotlight.media[0];
private readonly prevGridByMedia: Map<
MediaViewModel,
[GridTileData, number]
> = new Map(
this.prevGrid.map((entry, i) => [entry.media, [entry, i]] as const),
);
// The total number of grid entries that we have so far
private numGridEntries = 0;
// A sparse array of grid entries which should be kept in the same spots as
// which they appeared in the previous grid
private readonly stationaryGridEntries: GridTileData[] = new Array(
this.prevGrid.length,
);
// Grid entries which should now enter the visible section of the grid
private readonly visibleGridEntries: GridTileData[] = [];
// Grid entries which should now enter the invisible section of the grid
private readonly invisibleGridEntries: GridTileData[] = [];
public constructor(
private readonly prevSpotlight: SpotlightTileData | null,
private readonly prevGrid: GridTileData[],
private readonly construct: (
spotlight: SpotlightTileData | null,
grid: GridTileData[],
) => TileStore,
private readonly visibleTiles: number,
/**
* A number incremented on each update, just for debugging purposes.
*/
private readonly generation: number,
) {}
/**
* Sets the contents of the spotlight tile. If this is never called, there
* will be no spotlight tile.
*/
public registerSpotlight(media: MediaViewModel[], maximised: boolean): void {
if (DEBUG_ENABLED)
logger.debug(
`[TileStore, ${this.generation}] register spotlight: ${media.map((m) => m.member?.rawDisplayName ?? "[👻]")}`,
);
if (this.spotlight !== null) throw new Error("Spotlight already set");
if (this.numGridEntries > 0)
throw new Error("Spotlight must be registered before grid tiles");
// Reuse the previous spotlight tile if it exists
if (this.prevSpotlight === null) {
this.spotlight = new SpotlightTileData(media, maximised);
} else {
this.spotlight = this.prevSpotlight;
this.spotlight.media = media;
this.spotlight.maximised = maximised;
}
}
/**
* Sets up a grid tile for the given media. If this is never called for some
* media, then that media will have no grid tile.
*/
public registerGridTile(media: UserMediaViewModel): void {
if (DEBUG_ENABLED)
logger.debug(
`[TileStore, ${this.generation}] register grid tile: ${media.member?.rawDisplayName ?? "[👻]"}`,
);
if (this.spotlight !== null) {
// We actually *don't* want spotlight speakers to appear in both the
// spotlight and the grid, so they're filtered out here
if (!media.local && this.spotlight.media.includes(media)) return;
// When the spotlight speaker changes, we would see one grid tile appear
// and another grid tile disappear. This would be an undesirable layout
// shift, so instead what we do is take the speaker's grid tile and swap
// the media out, so it can remain where it is in the layout.
if (
media === this.prevSpotlightSpeaker &&
this.spotlight.media.length === 1 &&
"speaking" in this.spotlight.media[0] &&
this.prevSpotlightSpeaker !== this.spotlight.media[0]
) {
const prev = this.prevGridByMedia.get(this.spotlight.media[0]);
if (prev !== undefined) {
const [entry, prevIndex] = prev;
const previouslyVisible = prevIndex < this.visibleTiles;
const nowVisible = this.numGridEntries < this.visibleTiles;
// If it doesn't need to move between the visible/invisible sections of
// the grid, then we can keep it where it was and swap the media
if (previouslyVisible === nowVisible) {
this.stationaryGridEntries[prevIndex] = entry;
// Do the media swap
entry.media = media;
this.prevGridByMedia.delete(this.spotlight.media[0]);
this.prevGridByMedia.set(media, prev);
} else {
// Create a new tile; this will cause a layout shift but I'm not
// sure there's any other straightforward option in this case
(nowVisible
? this.visibleGridEntries
: this.invisibleGridEntries
).push(new GridTileData(media));
}
this.numGridEntries++;
return;
}
}
}
// Was there previously a tile with this same media?
const prev = this.prevGridByMedia.get(media);
if (prev === undefined) {
// Create a new tile
(this.numGridEntries < this.visibleTiles
? this.visibleGridEntries
: this.invisibleGridEntries
).push(new GridTileData(media));
} else {
// Reuse the existing tile
const [entry, prevIndex] = prev;
const previouslyVisible = prevIndex < this.visibleTiles;
const nowVisible = this.numGridEntries < this.visibleTiles;
// If it doesn't need to move between the visible/invisible sections of
// the grid, then we can keep it exactly where it was previously
if (previouslyVisible === nowVisible)
this.stationaryGridEntries[prevIndex] = entry;
// Otherwise, queue this tile to be moved
else
(nowVisible ? this.visibleGridEntries : this.invisibleGridEntries).push(
entry,
);
}
this.numGridEntries++;
}
/**
* Constructs a new collection of all registered tiles, transferring ownership
* of the tiles to the new collection. Any tiles present in the previous
* collection but not the new collection will be destroyed.
*/
public build(): TileStore {
// Piece together the grid
const grid = [
...fillGaps(this.stationaryGridEntries, [
...this.visibleGridEntries,
...this.invisibleGridEntries,
]),
];
if (DEBUG_ENABLED) {
logger.debug(
`[TileStore, ${this.generation}] stationary: ${debugEntries(this.stationaryGridEntries)}`,
);
logger.debug(
`[TileStore, ${this.generation}] visible: ${debugEntries(this.visibleGridEntries)}`,
);
logger.debug(
`[TileStore, ${this.generation}] invisible: ${debugEntries(this.invisibleGridEntries)}`,
);
logger.debug(
`[TileStore, ${this.generation}] result: ${debugEntries(grid)}`,
);
}
// Destroy unused tiles
if (this.spotlight === null && this.prevSpotlight !== null)
this.prevSpotlight.destroy();
const gridEntries = new Set(grid);
for (const entry of this.prevGrid)
if (!gridEntries.has(entry)) entry.destroy();
return this.construct(this.spotlight, grid);
}
}