Merge pull request #2894 from robintown/stable-visibility

Determine which tiles are on screen in a more stable manner
This commit is contained in:
Robin
2024-12-13 09:16:49 -05:00
committed by GitHub
14 changed files with 152 additions and 141 deletions

View File

@@ -24,7 +24,6 @@ import {
createContext, createContext,
forwardRef, forwardRef,
memo, memo,
useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
@@ -54,7 +53,6 @@ interface Tile<Model> {
id: string; id: string;
model: Model; model: Model;
onDrag: DragCallback | undefined; onDrag: DragCallback | undefined;
setVisible: (visible: boolean) => void;
} }
type PlacedTile<Model> = Tile<Model> & Rect; type PlacedTile<Model> = Tile<Model> & Rect;
@@ -88,7 +86,6 @@ interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
id: string; id: string;
model: Model; model: Model;
onDrag?: DragCallback; onDrag?: DragCallback;
onVisibilityChange?: (visible: boolean) => void;
style?: CSSProperties; style?: CSSProperties;
className?: string; className?: string;
} }
@@ -115,24 +112,47 @@ function offset(element: HTMLElement, relativeTo: Element): Offset {
} }
} }
export type VisibleTilesCallback = (visibleTiles: number) => void;
interface LayoutContext { interface LayoutContext {
setGeneration: Dispatch<SetStateAction<number | null>>; setGeneration: Dispatch<SetStateAction<number | null>>;
setVisibleTilesCallback: Dispatch<
SetStateAction<VisibleTilesCallback | null>
>;
} }
const LayoutContext = createContext<LayoutContext | null>(null); const LayoutContext = createContext<LayoutContext | null>(null);
function useLayoutContext(): LayoutContext {
const context = useContext(LayoutContext);
if (context === null)
throw new Error("useUpdateLayout called outside a Grid layout context");
return context;
}
/** /**
* Enables Grid to react to layout changes. You must call this in your Layout * Enables Grid to react to layout changes. You must call this in your Layout
* component or else Grid will not be reactive. * component or else Grid will not be reactive.
*/ */
export function useUpdateLayout(): void { export function useUpdateLayout(): void {
const context = useContext(LayoutContext); const { setGeneration } = useLayoutContext();
if (context === null)
throw new Error("useUpdateLayout called outside a Grid layout context");
// On every render, tell Grid that the layout may have changed // On every render, tell Grid that the layout may have changed
useEffect(() => useEffect(() => setGeneration((prev) => (prev === null ? 0 : prev + 1)));
context.setGeneration((prev) => (prev === null ? 0 : prev + 1)), }
/**
* Asks Grid to call a callback whenever the number of visible tiles may have
* changed.
*/
export function useVisibleTiles(callback: VisibleTilesCallback): void {
const { setVisibleTilesCallback } = useLayoutContext();
useEffect(
() => setVisibleTilesCallback(() => callback),
[callback, setVisibleTilesCallback],
);
useEffect(
() => (): void => setVisibleTilesCallback(null),
[setVisibleTilesCallback],
); );
} }
@@ -245,39 +265,20 @@ export function Grid<
const windowHeight = useObservableEagerState(windowHeightObservable); const windowHeight = useObservableEagerState(windowHeightObservable);
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null); const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
const [generation, setGeneration] = useState<number | null>(null); const [generation, setGeneration] = useState<number | null>(null);
const [visibleTilesCallback, setVisibleTilesCallback] =
useState<VisibleTilesCallback | null>(null);
const tiles = useInitial(() => new Map<string, Tile<TileModel>>()); const tiles = useInitial(() => new Map<string, Tile<TileModel>>());
const prefersReducedMotion = usePrefersReducedMotion(); const prefersReducedMotion = usePrefersReducedMotion();
const Slot: FC<SlotProps<TileModel>> = useMemo( const Slot: FC<SlotProps<TileModel>> = useMemo(
() => () =>
function Slot({ function Slot({ id, model, onDrag, style, className, ...props }) {
id,
model,
onDrag,
onVisibilityChange,
style,
className,
...props
}) {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const prevVisible = useRef<boolean | null>(null);
const setVisible = useCallback(
(visible: boolean) => {
if (
onVisibilityChange !== undefined &&
visible !== prevVisible.current
) {
onVisibilityChange(visible);
prevVisible.current = visible;
}
},
[onVisibilityChange],
);
useEffect(() => { useEffect(() => {
tiles.set(id, { id, model, onDrag, setVisible }); tiles.set(id, { id, model, onDrag });
return (): void => void tiles.delete(id); return (): void => void tiles.delete(id);
}, [id, model, onDrag, setVisible]); }, [id, model, onDrag]);
return ( return (
<div <div
@@ -307,7 +308,10 @@ export function Grid<
[], [],
); );
const context: LayoutContext = useMemo(() => ({ setGeneration }), []); const context: LayoutContext = useMemo(
() => ({ setGeneration, setVisibleTilesCallback }),
[setVisibleTilesCallback],
);
// Combine the tile definitions and slots together to create placed tiles // Combine the tile definitions and slots together to create placed tiles
const placedTiles = useMemo(() => { const placedTiles = useMemo(() => {
@@ -342,9 +346,11 @@ export function Grid<
); );
useEffect(() => { useEffect(() => {
for (const tile of placedTiles) visibleTilesCallback?.(
tile.setVisible(tile.y + tile.height <= visibleHeight); placedTiles.filter((tile) => tile.y + tile.height <= visibleHeight)
}, [placedTiles, visibleHeight]); .length,
);
}, [placedTiles, visibleTilesCallback, visibleHeight]);
// Drag state is stored in a ref rather than component state, because we use // Drag state is stored in a ref rather than component state, because we use
// react-spring's imperative API during gestures to improve responsiveness // react-spring's imperative API during gestures to improve responsiveness

View File

@@ -13,7 +13,7 @@ import { type GridLayout as GridLayoutModel } from "../state/CallViewModel";
import styles from "./GridLayout.module.css"; import styles from "./GridLayout.module.css";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
import { type CallLayout, arrangeTiles } from "./CallLayout"; import { type CallLayout, arrangeTiles } from "./CallLayout";
import { type DragCallback, useUpdateLayout } from "./Grid"; import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid";
interface GridCSSProperties extends CSSProperties { interface GridCSSProperties extends CSSProperties {
"--gap": string; "--gap": string;
@@ -73,6 +73,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
// The scrolling part of the layout is where all the grid tiles live // The scrolling part of the layout is where all the grid tiles live
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) { scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
useUpdateLayout(); useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
const { width, height: minHeight } = useObservableEagerState(minBounds); const { width, height: minHeight } = useObservableEagerState(minBounds);
const { gap, tileWidth, tileHeight } = useMemo( const { gap, tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, minHeight, model.grid.length), () => arrangeTiles(width, minHeight, model.grid.length),
@@ -93,13 +94,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
} }
> >
{model.grid.map((m) => ( {model.grid.map((m) => (
<Slot <Slot key={m.id} className={styles.slot} id={m.id} model={m} />
key={m.id}
className={styles.slot}
id={m.id}
model={m}
onVisibilityChange={m.setVisible}
/>
))} ))}
</div> </div>
); );

View File

@@ -52,7 +52,6 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
<Slot <Slot
id={model.remote.id} id={model.remote.id}
model={model.remote} model={model.remote}
onVisibilityChange={model.remote.setVisible}
className={styles.container} className={styles.container}
style={{ width: tileWidth, height: tileHeight }} style={{ width: tileWidth, height: tileHeight }}
> >
@@ -61,7 +60,6 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
id={model.local.id} id={model.local.id}
model={model.local} model={model.local}
onDrag={onDragLocalTile} onDrag={onDragLocalTile}
onVisibilityChange={model.local.setVisible}
data-block-alignment={pipAlignmentValue.block} data-block-alignment={pipAlignmentValue.block}
data-inline-alignment={pipAlignmentValue.inline} data-inline-alignment={pipAlignmentValue.inline}
/> />

View File

@@ -63,7 +63,6 @@ export const makeSpotlightExpandedLayout: CallLayout<
id={model.pip.id} id={model.pip.id}
model={model.pip} model={model.pip}
onDrag={onDragPip} onDrag={onDragPip}
onVisibilityChange={model.pip.setVisible}
data-block-alignment={pipAlignmentValue.block} data-block-alignment={pipAlignmentValue.block}
data-inline-alignment={pipAlignmentValue.inline} data-inline-alignment={pipAlignmentValue.inline}
/> />

View File

@@ -12,7 +12,7 @@ import classNames from "classnames";
import { type CallLayout } from "./CallLayout"; import { type CallLayout } from "./CallLayout";
import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel"; import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel";
import styles from "./SpotlightLandscapeLayout.module.css"; import styles from "./SpotlightLandscapeLayout.module.css";
import { useUpdateLayout } from "./Grid"; import { useUpdateLayout, useVisibleTiles } from "./Grid";
/** /**
* An implementation of the "spotlight landscape" layout, in which the spotlight * An implementation of the "spotlight landscape" layout, in which the spotlight
@@ -50,6 +50,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
ref, ref,
) { ) {
useUpdateLayout(); useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
useObservableEagerState(minBounds); useObservableEagerState(minBounds);
const withIndicators = const withIndicators =
useObservableEagerState(model.spotlight.media).length > 1; useObservableEagerState(model.spotlight.media).length > 1;
@@ -63,13 +64,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
/> />
<div className={styles.grid}> <div className={styles.grid}>
{model.grid.map((m) => ( {model.grid.map((m) => (
<Slot <Slot key={m.id} className={styles.slot} id={m.id} model={m} />
key={m.id}
className={styles.slot}
id={m.id}
model={m}
onVisibilityChange={m.setVisible}
/>
))} ))}
</div> </div>
</div> </div>

View File

@@ -12,7 +12,7 @@ import classNames from "classnames";
import { type CallLayout, arrangeTiles } from "./CallLayout"; import { type CallLayout, arrangeTiles } from "./CallLayout";
import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel"; import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
import styles from "./SpotlightPortraitLayout.module.css"; import styles from "./SpotlightPortraitLayout.module.css";
import { useUpdateLayout } from "./Grid"; import { useUpdateLayout, useVisibleTiles } from "./Grid";
interface GridCSSProperties extends CSSProperties { interface GridCSSProperties extends CSSProperties {
"--grid-gap": string; "--grid-gap": string;
@@ -54,6 +54,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
ref, ref,
) { ) {
useUpdateLayout(); useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
const { width } = useObservableEagerState(minBounds); const { width } = useObservableEagerState(minBounds);
const { gap, tileWidth, tileHeight } = arrangeTiles( const { gap, tileWidth, tileHeight } = arrangeTiles(
width, width,
@@ -84,13 +85,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
/> />
<div className={styles.grid}> <div className={styles.grid}>
{model.grid.map((m) => ( {model.grid.map((m) => (
<Slot <Slot key={m.id} className={styles.slot} id={m.id} model={m} />
key={m.id}
className={styles.slot}
id={m.id}
model={m}
onVisibilityChange={m.setVisible}
/>
))} ))}
</div> </div>
</div> </div>

View File

@@ -376,16 +376,16 @@ test("screen sharing activates spotlight layout", () => {
test("participants stay in the same order unless to appear/disappear", () => { test("participants stay in the same order unless to appear/disappear", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => { withTestScheduler(({ hot, schedule, expectObservable }) => {
const modeInputMarbles = " a"; const visibilityInputMarbles = "a";
// First Bob speaks, then Dave, then Alice // First Bob speaks, then Dave, then Alice
const aSpeakingInputMarbles = "n- 1998ms - 1999ms y"; const aSpeakingInputMarbles = " n- 1998ms - 1999ms y";
const bSpeakingInputMarbles = "ny 1998ms n 1999ms -"; const bSpeakingInputMarbles = " ny 1998ms n 1999ms -";
const dSpeakingInputMarbles = "n- 1998ms y 1999ms n"; const dSpeakingInputMarbles = " n- 1998ms y 1999ms n";
// Nothing should change when Bob speaks, because Bob is already on screen. // Nothing should change when Bob speaks, because Bob is already on screen.
// When Dave speaks he should switch with Alice because she's the one who // When Dave speaks he should switch with Alice because she's the one who
// hasn't spoken at all. Then when Alice speaks, she should return to her // hasn't spoken at all. Then when Alice speaks, she should return to her
// place at the top. // place at the top.
const expectedLayoutMarbles = "a 1999ms b 1999ms a 57999ms c 1999ms a"; const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]), of([aliceParticipant, bobParticipant, daveParticipant]),
@@ -397,15 +397,12 @@ test("participants stay in the same order unless to appear/disappear", () => {
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })], [daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
]), ]),
(vm) => { (vm) => {
schedule(modeInputMarbles, { schedule(visibilityInputMarbles, {
a: () => { a: () => {
// We imagine that only three tiles (the first three) will be visible // We imagine that only three tiles (the first three) will be visible
// on screen at a time // on screen at a time
vm.layout.subscribe((layout) => { vm.layout.subscribe((layout) => {
if (layout.type === "grid") { if (layout.type === "grid") layout.setVisibleTiles(3);
for (let i = 0; i < layout.grid.length; i++)
layout.grid[i].setVisible(i < 3);
}
}); });
}, },
}); });
@@ -435,6 +432,56 @@ test("participants stay in the same order unless to appear/disappear", () => {
}); });
}); });
test("participants adjust order when space becomes constrained", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => {
// Start with all tiles on screen then shrink to 3
const visibilityInputMarbles = "a-b";
// Bob and Dave speak
const bSpeakingInputMarbles = " ny";
const dSpeakingInputMarbles = " ny";
// Nothing should change when Bob or Dave initially speak, because they are
// on screen. When the screen becomes smaller Alice should move off screen
// to make way for the speakers (specifically, she should swap with Dave).
const expectedLayoutMarbles = " a-b";
withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]),
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
of(ConnectionState.Connected),
new Map([
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
]),
(vm) => {
let setVisibleTiles: ((value: number) => void) | null = null;
vm.layout.subscribe((layout) => {
if (layout.type === "grid") setVisibleTiles = layout.setVisibleTiles;
});
schedule(visibilityInputMarbles, {
a: () => setVisibleTiles!(Infinity),
b: () => setVisibleTiles!(3),
});
expectObservable(summarizeLayout(vm.layout)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`, `${daveId}:0`],
},
b: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${daveId}:0`, `${bobId}:0`, `${aliceId}:0`],
},
},
);
},
);
});
});
test("spotlight speakers swap places", () => { test("spotlight speakers swap places", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => { withTestScheduler(({ hot, schedule, expectObservable }) => {
// Go immediately into spotlight mode for the test // Go immediately into spotlight mode for the test

View File

@@ -143,18 +143,21 @@ export interface GridLayout {
type: "grid"; type: "grid";
spotlight?: SpotlightTileViewModel; spotlight?: SpotlightTileViewModel;
grid: GridTileViewModel[]; grid: GridTileViewModel[];
setVisibleTiles: (value: number) => void;
} }
export interface SpotlightLandscapeLayout { export interface SpotlightLandscapeLayout {
type: "spotlight-landscape"; type: "spotlight-landscape";
spotlight: SpotlightTileViewModel; spotlight: SpotlightTileViewModel;
grid: GridTileViewModel[]; grid: GridTileViewModel[];
setVisibleTiles: (value: number) => void;
} }
export interface SpotlightPortraitLayout { export interface SpotlightPortraitLayout {
type: "spotlight-portrait"; type: "spotlight-portrait";
spotlight: SpotlightTileViewModel; spotlight: SpotlightTileViewModel;
grid: GridTileViewModel[]; grid: GridTileViewModel[];
setVisibleTiles: (value: number) => void;
} }
export interface SpotlightExpandedLayout { export interface SpotlightExpandedLayout {
@@ -223,7 +226,6 @@ enum SortingBin {
interface LayoutScanState { interface LayoutScanState {
layout: Layout | null; layout: Layout | null;
tiles: TileStore; tiles: TileStore;
visibleTiles: Set<GridTileViewModel>;
} }
class UserMedia { class UserMedia {
@@ -891,62 +893,53 @@ export class CallViewModel extends ViewModel {
this.scope.state(), this.scope.state(),
); );
// 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.
private readonly visibleTiles = new Subject<number>();
private readonly setVisibleTiles = (value: number): void =>
this.visibleTiles.next(value);
public readonly layoutInternals: Observable< public readonly layoutInternals: Observable<
LayoutScanState & { layout: Layout } LayoutScanState & { layout: Layout }
> = this.layoutMedia.pipe( > = combineLatest([
// Each layout will produce a set of tiles, and these tiles have an this.layoutMedia,
// observable indicating whether they're visible. We loop this information this.visibleTiles.pipe(startWith(0), distinctUntilChanged()),
// back into the layout process by using switchScan. ]).pipe(
switchScan< scan<
LayoutMedia, [LayoutMedia, number],
LayoutScanState, LayoutScanState & { layout: Layout },
Observable<LayoutScanState & { layout: Layout }> LayoutScanState
>( >(
({ tiles: prevTiles, visibleTiles }, media) => { ({ tiles: prevTiles }, [media, visibleTiles]) => {
let layout: Layout; let layout: Layout;
let newTiles: TileStore; let newTiles: TileStore;
switch (media.type) { switch (media.type) {
case "grid": case "grid":
case "spotlight-landscape": case "spotlight-landscape":
case "spotlight-portrait": case "spotlight-portrait":
[layout, newTiles] = gridLikeLayout(media, visibleTiles, prevTiles); [layout, newTiles] = gridLikeLayout(
break;
case "spotlight-expanded":
[layout, newTiles] = spotlightExpandedLayout(
media, media,
visibleTiles, visibleTiles,
this.setVisibleTiles,
prevTiles, prevTiles,
); );
break; break;
case "spotlight-expanded":
[layout, newTiles] = spotlightExpandedLayout(media, prevTiles);
break;
case "one-on-one": case "one-on-one":
[layout, newTiles] = oneOnOneLayout(media, visibleTiles, prevTiles); [layout, newTiles] = oneOnOneLayout(media, prevTiles);
break; break;
case "pip": case "pip":
[layout, newTiles] = pipLayout(media, visibleTiles, prevTiles); [layout, newTiles] = pipLayout(media, prevTiles);
break; break;
} }
// Take all of the 'visible' observables and combine them into one big return { layout, tiles: newTiles };
// observable array
const visibilities =
newTiles.gridTiles.length === 0
? of([])
: combineLatest(newTiles.gridTiles.map((tile) => tile.visible));
return visibilities.pipe(
map((visibilities) => ({
layout: layout,
tiles: newTiles,
visibleTiles: new Set(
newTiles.gridTiles.filter((_tile, i) => visibilities[i]),
),
})),
);
},
{
layout: null,
tiles: TileStore.empty(),
visibleTiles: new Set(),
}, },
{ layout: null, tiles: TileStore.empty() },
), ),
this.scope.state(), this.scope.state(),
); );

View File

@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
import { type Layout, type LayoutMedia } from "./CallViewModel"; import { type Layout, type LayoutMedia } from "./CallViewModel";
import { type TileStore } from "./TileStore"; import { type TileStore } from "./TileStore";
import { type GridTileViewModel } from "./TileViewModel";
export type GridLikeLayoutType = export type GridLikeLayoutType =
| "grid" | "grid"
@@ -20,7 +19,8 @@ export type GridLikeLayoutType =
*/ */
export function gridLikeLayout( export function gridLikeLayout(
media: LayoutMedia & { type: GridLikeLayoutType }, media: LayoutMedia & { type: GridLikeLayoutType },
visibleTiles: Set<GridTileViewModel>, visibleTiles: number,
setVisibleTiles: (value: number) => void,
prevTiles: TileStore, prevTiles: TileStore,
): [Layout & { type: GridLikeLayoutType }, TileStore] { ): [Layout & { type: GridLikeLayoutType }, TileStore] {
const update = prevTiles.from(visibleTiles); const update = prevTiles.from(visibleTiles);
@@ -37,6 +37,7 @@ export function gridLikeLayout(
type: media.type, type: media.type,
spotlight: tiles.spotlightTile, spotlight: tiles.spotlightTile,
grid: tiles.gridTiles, grid: tiles.gridTiles,
setVisibleTiles,
} as Layout & { type: GridLikeLayoutType }, } as Layout & { type: GridLikeLayoutType },
tiles, tiles,
]; ];

View File

@@ -7,17 +7,15 @@ Please see LICENSE in the repository root for full details.
import { type OneOnOneLayout, type OneOnOneLayoutMedia } from "./CallViewModel"; import { type OneOnOneLayout, type OneOnOneLayoutMedia } from "./CallViewModel";
import { type TileStore } from "./TileStore"; import { type TileStore } from "./TileStore";
import { type GridTileViewModel } from "./TileViewModel";
/** /**
* Produces a one-on-one layout with the given media. * Produces a one-on-one layout with the given media.
*/ */
export function oneOnOneLayout( export function oneOnOneLayout(
media: OneOnOneLayoutMedia, media: OneOnOneLayoutMedia,
visibleTiles: Set<GridTileViewModel>,
prevTiles: TileStore, prevTiles: TileStore,
): [OneOnOneLayout, TileStore] { ): [OneOnOneLayout, TileStore] {
const update = prevTiles.from(visibleTiles); const update = prevTiles.from(2);
update.registerGridTile(media.local); update.registerGridTile(media.local);
update.registerGridTile(media.remote); update.registerGridTile(media.remote);
const tiles = update.build(); const tiles = update.build();

View File

@@ -7,17 +7,15 @@ Please see LICENSE in the repository root for full details.
import { type PipLayout, type PipLayoutMedia } from "./CallViewModel"; import { type PipLayout, type PipLayoutMedia } from "./CallViewModel";
import { type TileStore } from "./TileStore"; import { type TileStore } from "./TileStore";
import { type GridTileViewModel } from "./TileViewModel";
/** /**
* Produces a picture-in-picture layout with the given media. * Produces a picture-in-picture layout with the given media.
*/ */
export function pipLayout( export function pipLayout(
media: PipLayoutMedia, media: PipLayoutMedia,
visibleTiles: Set<GridTileViewModel>,
prevTiles: TileStore, prevTiles: TileStore,
): [PipLayout, TileStore] { ): [PipLayout, TileStore] {
const update = prevTiles.from(visibleTiles); const update = prevTiles.from(0);
update.registerSpotlight(media.spotlight, true); update.registerSpotlight(media.spotlight, true);
const tiles = update.build(); const tiles = update.build();
return [ return [

View File

@@ -10,17 +10,15 @@ import {
type SpotlightExpandedLayoutMedia, type SpotlightExpandedLayoutMedia,
} from "./CallViewModel"; } from "./CallViewModel";
import { type TileStore } from "./TileStore"; import { type TileStore } from "./TileStore";
import { type GridTileViewModel } from "./TileViewModel";
/** /**
* Produces an expanded spotlight layout with the given media. * Produces an expanded spotlight layout with the given media.
*/ */
export function spotlightExpandedLayout( export function spotlightExpandedLayout(
media: SpotlightExpandedLayoutMedia, media: SpotlightExpandedLayoutMedia,
visibleTiles: Set<GridTileViewModel>,
prevTiles: TileStore, prevTiles: TileStore,
): [SpotlightExpandedLayout, TileStore] { ): [SpotlightExpandedLayout, TileStore] {
const update = prevTiles.from(visibleTiles); const update = prevTiles.from(1);
update.registerSpotlight(media.spotlight, true); update.registerSpotlight(media.spotlight, true);
if (media.pip !== undefined) update.registerGridTile(media.pip); if (media.pip !== undefined) update.registerGridTile(media.pip);
const tiles = update.build(); const tiles = update.build();

View File

@@ -101,7 +101,7 @@ export class TileStore {
* Creates a builder which can be used to update the collection, passing * Creates a builder which can be used to update the collection, passing
* ownership of the tiles to the updated collection. * ownership of the tiles to the updated collection.
*/ */
public from(visibleTiles: Set<GridTileViewModel>): TileStoreBuilder { public from(visibleTiles: number): TileStoreBuilder {
return new TileStoreBuilder( return new TileStoreBuilder(
this.spotlight, this.spotlight,
this.grid, this.grid,
@@ -146,7 +146,7 @@ export class TileStoreBuilder {
spotlight: SpotlightTileData | null, spotlight: SpotlightTileData | null,
grid: GridTileData[], grid: GridTileData[],
) => TileStore, ) => TileStore,
private readonly visibleTiles: Set<GridTileViewModel>, private readonly visibleTiles: number,
/** /**
* A number incremented on each update, just for debugging purposes. * A number incremented on each update, just for debugging purposes.
*/ */
@@ -204,10 +204,8 @@ export class TileStoreBuilder {
const prev = this.prevGridByMedia.get(this.spotlight.media[0]); const prev = this.prevGridByMedia.get(this.spotlight.media[0]);
if (prev !== undefined) { if (prev !== undefined) {
const [entry, prevIndex] = prev; const [entry, prevIndex] = prev;
const previouslyVisible = this.visibleTiles.has(entry.vm); const previouslyVisible = prevIndex < this.visibleTiles;
const nowVisible = this.visibleTiles.has( const nowVisible = this.numGridEntries < this.visibleTiles;
this.prevGrid[this.numGridEntries]?.vm,
);
// If it doesn't need to move between the visible/invisible sections of // 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 // the grid, then we can keep it where it was and swap the media
@@ -236,17 +234,15 @@ export class TileStoreBuilder {
const prev = this.prevGridByMedia.get(media); const prev = this.prevGridByMedia.get(media);
if (prev === undefined) { if (prev === undefined) {
// Create a new tile // Create a new tile
(this.visibleTiles.has(this.prevGrid[this.numGridEntries]?.vm) (this.numGridEntries < this.visibleTiles
? this.visibleGridEntries ? this.visibleGridEntries
: this.invisibleGridEntries : this.invisibleGridEntries
).push(new GridTileData(media)); ).push(new GridTileData(media));
} else { } else {
// Reuse the existing tile // Reuse the existing tile
const [entry, prevIndex] = prev; const [entry, prevIndex] = prev;
const previouslyVisible = this.visibleTiles.has(entry.vm); const previouslyVisible = prevIndex < this.visibleTiles;
const nowVisible = this.visibleTiles.has( const nowVisible = this.numGridEntries < this.visibleTiles;
this.prevGrid[this.numGridEntries]?.vm,
);
// If it doesn't need to move between the visible/invisible sections of // 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 // the grid, then we can keep it exactly where it was previously
if (previouslyVisible === nowVisible) if (previouslyVisible === nowVisible)

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { BehaviorSubject, type Observable } from "rxjs"; import { type Observable } from "rxjs";
import { ViewModel } from "./ViewModel"; import { ViewModel } from "./ViewModel";
import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel"; import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel";
@@ -18,14 +18,6 @@ function createId(): string {
export class GridTileViewModel extends ViewModel { export class GridTileViewModel extends ViewModel {
public readonly id = createId(); public readonly id = createId();
private readonly visible_ = new BehaviorSubject(false);
/**
* Whether the tile is visible within the current viewport.
*/
public readonly visible: Observable<boolean> = this.visible_;
public setVisible = (value: boolean): void => this.visible_.next(value);
public constructor(public readonly media: Observable<UserMediaViewModel>) { public constructor(public readonly media: Observable<UserMediaViewModel>) {
super(); super();
} }