Make drag & drop performant in all layouts

While also giving the logic for drag & drop of a floating tile a common home in the view model layer.
This commit is contained in:
Robin
2026-09-28 19:53:30 +02:00
parent 99256fa01c
commit f2f49315eb
19 changed files with 290 additions and 264 deletions
+1 -6
View File
@@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details.
*/ */
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test"; import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
import { constant } from "../state/Behavior"; import { constant } from "../state/Behavior";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel"; import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import type { Alignment, Layout } from "../state/layout-types"; import type { Layout } from "../state/layout-types";
import type { SpotlightTileViewModel } from "../state/TileViewModel"; import type { SpotlightTileViewModel } from "../state/TileViewModel";
import type { DeviceLabel } from "../state/MediaDevices"; import type { DeviceLabel } from "../state/MediaDevices";
import { createCallFooterViewModel } from "./CallFooterViewModel"; import { createCallFooterViewModel } from "./CallFooterViewModel";
@@ -65,10 +64,6 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel {
const gridLayout: Layout = { const gridLayout: Layout = {
type: "grid", type: "grid",
grid: [], grid: [],
spotlightAlignment$: new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
}),
setVisibleTiles: (_: number) => {}, setVisibleTiles: (_: number) => {},
}; };
+9 -41
View File
@@ -13,7 +13,7 @@ import { arrangeTiles, type Bounds } from "./CallLayout";
import { makeGridLayout } from "./GridLayout"; import { makeGridLayout } from "./GridLayout";
import { makeSpotlightLandscapeLayout } from "./SpotlightLandscapeLayout"; import { makeSpotlightLandscapeLayout } from "./SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "./SpotlightPortraitLayout"; import { makeSpotlightPortraitLayout } from "./SpotlightPortraitLayout";
import { type DragCallback, type LayoutProps } from "./Grid"; import { type LayoutProps } from "./Grid";
import { constant } from "../state/Behavior"; import { constant } from "../state/Behavior";
import { import {
type Alignment, type Alignment,
@@ -53,33 +53,6 @@ describe("grid layout", () => {
`${Math.floor(arrangeTiles(400, 300, 3).tileWidth)}px`, `${Math.floor(arrangeTiles(400, 300, 3).tileWidth)}px`,
); );
}); });
it("moves the spotlight only when a drag reaches another corner", () => {
const model = gridModel();
const { fixed: Fixed } = makeGridLayout({ minBounds$: bounds });
render(<Fixed model={model} Slot={Slot} />);
const alignments: Alignment[] = [];
model.spotlightAlignment$.subscribe((a) => alignments.push(a));
const drag = drags.get("spotlight")!;
// Still in the bottom right corner
act(() => drag({ x: 0, y: 0, xRatio: 0.9, yRatio: 0.9 }));
// Over to the bottom left
act(() => drag({ x: 0, y: 0, xRatio: 0.1, yRatio: 0.9 }));
// And a little further, still bottom left
act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.8 }));
// Up to the top left
act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.1 }));
expect(alignments).toEqual([
{ inline: "end", block: "end" },
{ inline: "start", block: "end" },
{ inline: "start", block: "start" },
]);
const slot = screen.getByTestId("slot-spotlight");
expect(slot.getAttribute("data-inline-alignment")).toBe("start");
expect(slot.getAttribute("data-block-alignment")).toBe("start");
});
}); });
describe("spotlight landscape layout", () => { describe("spotlight landscape layout", () => {
@@ -123,11 +96,15 @@ const bounds = constant<Bounds>({ width: 800, height: 600 });
// The layouts only pass tile view models through to their slots // The layouts only pass tile view models through to their slots
const media = {} as UserMediaViewModel; const media = {} as UserMediaViewModel;
const spotlight = new SpotlightTileViewModel( const spotlight = {
vm: new SpotlightTileViewModel(
constant([]), constant([]),
constant(false), constant(false),
constant("solid"), constant("solid"),
); ),
alignment$: constant<Alignment>({ inline: "end", block: "end" }),
onDrag: () => {},
};
const tiles = (): GridTileViewModel[] => const tiles = (): GridTileViewModel[] =>
Array.from({ length: 3 }, () => new GridTileViewModel(constant(media))); Array.from({ length: 3 }, () => new GridTileViewModel(constant(media)));
@@ -136,10 +113,6 @@ function gridModel(): GridLayout {
type: "grid", type: "grid",
spotlight, spotlight,
grid: tiles(), grid: tiles(),
spotlightAlignment$: new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
}),
setVisibleTiles: () => {}, setVisibleTiles: () => {},
}; };
} }
@@ -162,22 +135,17 @@ function portraitModel(): SpotlightPortraitLayout {
}; };
} }
// Slots record their drag callback so a test can drag them, and show the id // Slots show the id of the tile they hold
// of the tile they hold
const drags = new Map<string, DragCallback | undefined>();
const Slot: LayoutProps<unknown, TileViewModel, HTMLDivElement>["Slot"] = ({ const Slot: LayoutProps<unknown, TileViewModel, HTMLDivElement>["Slot"] = ({
id, id,
model, model,
onDrag, onDrag,
...props ...props
}) => { }) => (
drags.set(id, onDrag);
return (
<div data-testid={`slot-${id}`} {...props}> <div data-testid={`slot-${id}`} {...props}>
{model instanceof GridTileViewModel ? model.id : "spotlight"} {model instanceof GridTileViewModel ? model.id : "spotlight"}
</div> </div>
); );
};
function slotIds(): string[] { function slotIds(): string[] {
return screen return screen
+5 -26
View File
@@ -40,6 +40,7 @@ import { useMergedRefs } from "../useMergedRefs";
import { TileWrapper } from "./TileWrapper"; import { TileWrapper } from "./TileWrapper";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion"; import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
import { type DragCallback } from "../state/layout-types";
const MAX_ANIMATED_TILES = 50; // Capped for performance reasons const MAX_ANIMATED_TILES = 50; // Capped for performance reasons
@@ -83,7 +84,10 @@ interface DragState {
cursorY: number; cursorY: number;
} }
interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> { export interface SlotProps<Model> extends Omit<
ComponentProps<"div">,
"onDrag"
> {
id: string; id: string;
model: Model; model: Model;
onDrag?: DragCallback; onDrag?: DragCallback;
@@ -181,31 +185,6 @@ export interface TileProps<Model, R extends HTMLElement> {
model: Model; model: Model;
} }
interface Drag {
/**
* The X coordinate of the dragged tile in grid space.
*/
x: number;
/**
* The Y coordinate of the dragged tile in grid space.
*/
y: number;
/**
* The X coordinate of the dragged tile, as a scalar of the grid width.
*/
xRatio: number;
/**
* The Y coordinate of the dragged tile, as a scalar of the grid height.
*/
yRatio: number;
/**
* Whether this event ends the drag gesture.
*/
endOfGesture: boolean
}
export type DragCallback = (drag: Drag) => void;
interface LayoutMemoProps< interface LayoutMemoProps<
LayoutModel, LayoutModel,
TileModel, TileModel,
+30 -25
View File
@@ -8,15 +8,21 @@ Please see LICENSE in the repository root for full details.
import { import {
type CSSProperties, type CSSProperties,
type ReactNode, type ReactNode,
useCallback,
useMemo, useMemo,
type FC,
type ComponentType,
} from "react"; } from "react";
import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts"; import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts";
import styles from "./GridLayout.module.css"; import styles from "./GridLayout.module.css";
import { type CallLayout, arrangeTiles } from "./CallLayout"; import { type CallLayout, arrangeTiles } from "./CallLayout";
import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid"; import { type SlotProps, useUpdateLayout, useVisibleTiles } from "./Grid";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
import {
type SpotlightTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
import { type FloatingTile } from "../state/FloatingTile.ts";
interface GridCSSProperties extends CSSProperties { interface GridCSSProperties extends CSSProperties {
"--gap": string; "--gap": string;
@@ -24,6 +30,27 @@ interface GridCSSProperties extends CSSProperties {
"--height": string; "--height": string;
} }
interface SpotlightSlotProps {
model: FloatingTile & { vm: SpotlightTileViewModel };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const SpotlightSlot: FC<SpotlightSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={styles.slot}
id="spotlight"
model={model.vm}
onDrag={model.onDrag}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/** /**
* An implementation of the "grid" layout, in which all participants are shown * An implementation of the "grid" layout, in which all participants are shown
* together in a scrolling grid. * together in a scrolling grid.
@@ -37,32 +64,10 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
// lives // lives
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode { fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
useUpdateLayout(); useUpdateLayout();
const alignment = useBehavior(model.spotlightAlignment$);
const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) => {
const block = yRatio < 0.5 ? "start" : "end";
const inline = xRatio < 0.5 ? "start" : "end";
// A drag reports the same alignment on every move; only a change is
// worth a re-render
const current = model.spotlightAlignment$.value;
if (current.block !== block || current.inline !== inline)
model.spotlightAlignment$.next({ block, inline });
},
[model.spotlightAlignment$],
);
return ( return (
<div ref={ref} className={styles.fixed}> <div ref={ref} className={styles.fixed}>
{model.spotlight && ( {model.spotlight && (
<Slot <SpotlightSlot model={model.spotlight} Slot={Slot} />
className={styles.slot}
id="spotlight"
model={model.spotlight}
onDrag={onDragSpotlight}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
)} )}
</div> </div>
); );
+6 -15
View File
@@ -6,13 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type ReactNode, useCallback, useMemo } from "react"; import { type ReactNode, useMemo } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts"; import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts";
import { type CallLayout, arrangeTiles } from "./CallLayout"; import { type CallLayout, arrangeTiles } from "./CallLayout";
import styles from "./OneOnOneDesktopLayout.module.css"; import styles from "./OneOnOneDesktopLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid"; import { useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
/** /**
@@ -37,21 +37,12 @@ export const makeOneOnOneDesktopLayout: CallLayout<
}): ReactNode { }): ReactNode {
useUpdateLayout(); useUpdateLayout();
const { width, height } = useBehavior(minBounds$); const { width, height } = useBehavior(minBounds$);
const pipAlignment = useBehavior(model.pipAlignment$); const pipAlignment = useBehavior(model.pip.alignment$);
const { tileWidth, tileHeight } = useMemo( const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1), () => arrangeTiles(width, height, 1),
[width, height], [width, height],
); );
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return ( return (
<div ref={ref} className={styles.layer}> <div ref={ref} className={styles.layer}>
<Slot <Slot
@@ -62,9 +53,9 @@ export const makeOneOnOneDesktopLayout: CallLayout<
> >
<Slot <Slot
className={classNames(styles.slot, styles.local)} className={classNames(styles.slot, styles.local)}
id={model.pip.id} id={model.pip.vm.id}
model={model.pip} model={model.pip.vm}
onDrag={onDragLocalTile} onDrag={model.pip.onDrag}
data-block-alignment={pipAlignment.block} data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline} data-inline-alignment={pipAlignment.inline}
/> />
+32 -24
View File
@@ -6,14 +6,43 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type ReactNode, useCallback } from "react"; import { type ComponentType, type FC, type ReactNode } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { type OneOnOneMobileLayout as OneOnOneMobileLayoutModel } from "../state/layout-types.ts"; import { type OneOnOneMobileLayout as OneOnOneMobileLayoutModel } from "../state/layout-types.ts";
import { type CallLayout } from "./CallLayout"; import { type CallLayout } from "./CallLayout";
import styles from "./OneOnOneMobileLayout.module.css"; import styles from "./OneOnOneMobileLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid"; import { type SlotProps, useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
import { type FloatingTile } from "../state/FloatingTile.ts";
import {
type GridTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
import { type Behavior } from "../state/Behavior.ts";
interface PipSlotProps {
model: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const PipSlot: FC<PipSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const size = useBehavior(model.size$);
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={classNames(styles.pip)}
id={model.vm.id}
model={model.vm}
onDrag={model.onDrag}
data-size={size}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/** /**
* An implementation of the "one-on-one" layout for mobile platforms, in which * An implementation of the "one-on-one" layout for mobile platforms, in which
@@ -44,30 +73,9 @@ export const makeOneOnOneMobileLayout: CallLayout<
Slot, Slot,
}): ReactNode { }): ReactNode {
useUpdateLayout(); useUpdateLayout();
const pipSize = useBehavior(model.pipSize$);
const pipAlignment = useBehavior(model.pipAlignment$);
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return ( return (
<div ref={ref} className={styles.layer}> <div ref={ref} className={styles.layer}>
{model.pip && ( {model.pip && <PipSlot model={model.pip} Slot={Slot} />}
<Slot
className={classNames(styles.pip)}
id={model.pip.id}
model={model.pip}
onDrag={onDragLocalTile}
data-size={pipSize}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
)}
</div> </div>
); );
}, },
+29 -24
View File
@@ -5,13 +5,39 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type ReactNode, useCallback } from "react"; import { type ReactNode, type ComponentType, type FC } from "react";
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/layout-types.ts"; import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/layout-types.ts";
import { type CallLayout } from "./CallLayout"; import { type CallLayout } from "./CallLayout";
import { type DragCallback, useUpdateLayout } from "./Grid"; import { type SlotProps, useUpdateLayout } from "./Grid";
import styles from "./SpotlightExpandedLayout.module.css"; import styles from "./SpotlightExpandedLayout.module.css";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
import { type FloatingTile } from "../state/FloatingTile.ts";
import {
type GridTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
interface PipSlotProps {
model: FloatingTile & { vm: GridTileViewModel };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const PipSlot: FC<PipSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={styles.pip}
id={model.vm.id}
model={model.vm}
onDrag={model.onDrag}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/** /**
* An implementation of the "expanded spotlight" layout, in which the spotlight * An implementation of the "expanded spotlight" layout, in which the spotlight
@@ -28,7 +54,6 @@ export const makeSpotlightExpandedLayout: CallLayout<
Slot, Slot,
}): ReactNode { }): ReactNode {
useUpdateLayout(); useUpdateLayout();
return ( return (
<div ref={ref} className={styles.layer}> <div ref={ref} className={styles.layer}>
<Slot <Slot
@@ -46,29 +71,9 @@ export const makeSpotlightExpandedLayout: CallLayout<
Slot, Slot,
}): ReactNode { }): ReactNode {
useUpdateLayout(); useUpdateLayout();
const pipAlignment = useBehavior(model.pipAlignment$);
const onDragPip: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return ( return (
<div ref={ref} className={styles.layer}> <div ref={ref} className={styles.layer}>
{model.pip && ( {model.pip && <PipSlot model={model.pip} Slot={Slot} />}
<Slot
className={styles.pip}
id={model.pip.id}
model={model.pip}
onDrag={onDragPip}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
)}
</div> </div>
); );
}, },
+2 -2
View File
@@ -40,7 +40,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
<Slot <Slot
className={styles.slot} className={styles.slot}
id="spotlight" id="spotlight"
model={model.spotlight} model={model.spotlight.vm}
/> />
</div> </div>
<div className={styles.grid} /> <div className={styles.grid} />
@@ -59,7 +59,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
return ( return (
<div ref={ref} className={styles.layer}> <div ref={ref} className={styles.layer}>
<SpotlightSlot media$={model.spotlight.media$} /> <SpotlightSlot media$={model.spotlight.vm.media$} />
<div className={styles.grid}> <div className={styles.grid}>
{model.grid.map((m) => ( {model.grid.map((m) => (
<Slot key={m.id} className={styles.slot} id={m.id} model={m} /> <Slot key={m.id} className={styles.slot} id={m.id} model={m} />
+2 -2
View File
@@ -43,7 +43,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
<Slot <Slot
className={styles.slot} className={styles.slot}
id="spotlight" id="spotlight"
model={model.spotlight} model={model.spotlight.vm}
/> />
</div> </div>
</div> </div>
@@ -65,7 +65,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
width, width,
model.grid.length, model.grid.length,
); );
const withIndicators = useBehavior(model.spotlight.media$).length > 1; const withIndicators = useBehavior(model.spotlight.vm.media$).length > 1;
return ( return (
<div <div
+10 -10
View File
@@ -176,7 +176,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
case "grid": case "grid":
return combineLatest( return combineLatest(
[ [
l.spotlight?.media$ ?? constant(undefined), l.spotlight?.vm.media$ ?? constant(undefined),
...l.grid.map((vm) => vm.media$), ...l.grid.map((vm) => vm.media$),
], ],
(spotlight, ...grid) => ({ (spotlight, ...grid) => ({
@@ -188,7 +188,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
case "spotlight-landscape": case "spotlight-landscape":
case "spotlight-portrait": case "spotlight-portrait":
return combineLatest( return combineLatest(
[l.spotlight.media$, ...l.grid.map((vm) => vm.media$)], [l.spotlight.vm.media$, ...l.grid.map((vm) => vm.media$)],
(spotlight, ...grid) => ({ (spotlight, ...grid) => ({
type: l.type, type: l.type,
spotlight: spotlight.map((vm) => vm.id), spotlight: spotlight.map((vm) => vm.id),
@@ -197,7 +197,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
); );
case "spotlight-expanded": case "spotlight-expanded":
return combineLatest( return combineLatest(
[l.spotlight.media$, l.pip?.media$ ?? constant(undefined)], [l.spotlight.media$, l.pip?.vm.media$ ?? constant(undefined)],
(spotlight, pip) => ({ (spotlight, pip) => ({
type: l.type, type: l.type,
spotlight: spotlight.map((vm) => vm.id), spotlight: spotlight.map((vm) => vm.id),
@@ -206,7 +206,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
); );
case "one-on-one-desktop": case "one-on-one-desktop":
return combineLatest( return combineLatest(
[l.spotlight.media$, l.pip.media$], [l.spotlight.media$, l.pip.vm.media$],
(spotlight, pip) => ({ (spotlight, pip) => ({
type: l.type, type: l.type,
spotlight: spotlight.id, spotlight: spotlight.id,
@@ -217,8 +217,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
return combineLatest( return combineLatest(
[ [
l.spotlight.media$, l.spotlight.media$,
l.pip?.media$ ?? constant(undefined), l.pip?.vm.media$ ?? constant(undefined),
l.pipSize$, l.pip?.size$ ?? constant(undefined),
], ],
(spotlight, pip, pipSize) => ({ (spotlight, pip, pipSize) => ({
type: l.type, type: l.type,
@@ -480,7 +480,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile", type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`], spotlight: [`${aliceId}:0`],
pip: undefined, pip: undefined,
pipSize: "lg", pipSize: undefined,
}, },
b: { b: {
type: "one-on-one-mobile", type: "one-on-one-mobile",
@@ -518,7 +518,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile", type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`], spotlight: [`${aliceId}:0`],
pip: undefined, pip: undefined,
pipSize: "lg", pipSize: undefined,
}, },
}); });
// It wouldn't be clear whether Alice or Bob is the remote video tile, // It wouldn't be clear whether Alice or Bob is the remote video tile,
@@ -566,7 +566,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile", type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`], spotlight: [`${aliceId}:0`],
pip: undefined, pip: undefined,
pipSize: "sm", pipSize: undefined,
}, },
b: { b: {
type: "spotlight-landscape", type: "spotlight-landscape",
@@ -765,7 +765,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile", type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`], spotlight: [`${aliceId}:0`],
pip: undefined, pip: undefined,
pipSize: "lg", pipSize: undefined,
}, },
b: { b: {
// In a larger window, expect the one-on-one desktop layout // In a larger window, expect the one-on-one desktop layout
+20 -25
View File
@@ -93,7 +93,6 @@ import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { type HostBridge, nullHostBridge } from "../../HostBridge"; import { type HostBridge, nullHostBridge } from "../../HostBridge";
import { import {
layoutShallowEquals, layoutShallowEquals,
type Alignment,
type GridLayoutMedia, type GridLayoutMedia,
type Layout, type Layout,
type LayoutMedia, type LayoutMedia,
@@ -163,6 +162,7 @@ import {
type RingingMediaViewModel, type RingingMediaViewModel,
} from "../media/RingingMediaViewModel.ts"; } from "../media/RingingMediaViewModel.ts";
import { type GridTileViewModel } from "../TileViewModel.ts"; import { type GridTileViewModel } from "../TileViewModel.ts";
import { createFloatingTile } from "../FloatingTile.ts";
//TODO //TODO
// Larger rename // Larger rename
@@ -1548,32 +1548,28 @@ export function createCallViewModel$(
); );
/** /**
* The alignment of the floating spotlight tile, if present. * State of the floating spotlight tile, if present.
*/ */
const spotlightAlignment$ = new BehaviorSubject<Alignment>({ const floatingSpotlightTile = createFloatingTile(scope, {
inline: "end", inline: "end",
block: "end", block: "end",
}); });
/** /**
* The size of the small picture-in-picture tile, if present, when in portrait. * State of the small picture-in-picture tile, if present, when in landscape.
*/ */
const portraitPipSize$ = scope.behavior( const landscapePipTile = createFloatingTile(scope, {
showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))),
);
/**
* The alignment of the small picture-in-picture tile, if present, when in portrait.
*/
const portraitPipAlignment$ = new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
});
/**
* The alignment of the small picture-in-picture tile, if present, when in landscape.
*/
const landscapePipAlignment$ = new BehaviorSubject<Alignment>({
inline: "end", inline: "end",
block: "start", block: "start",
}); });
/**
* State of the small picture-in-picture tile, if present, when in portrait.
*/
const portraitPipTile = {
...createFloatingTile(scope, { inline: "end", block: "end" }),
size$: scope.behavior(
showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))),
),
};
// There is a cyclical dependency here: the layout algorithms want to know // 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 // which tiles are on screen, but to know which tiles are on screen we have to
@@ -1603,7 +1599,7 @@ export function createCallViewModel$(
case "spotlight-portrait": case "spotlight-portrait":
[layout, newTiles] = gridLikeLayout( [layout, newTiles] = gridLikeLayout(
media, media,
spotlightAlignment$, floatingSpotlightTile,
visibleTiles, visibleTiles,
setVisibleTiles, setVisibleTiles,
prevTiles, prevTiles,
@@ -1613,26 +1609,25 @@ export function createCallViewModel$(
case "spotlight-expanded": case "spotlight-expanded":
[layout, newTiles] = spotlightExpandedLayout( [layout, newTiles] = spotlightExpandedLayout(
media, media,
landscapePipAlignment$, landscapePipTile,
prevTiles, prevTiles,
); );
break; break;
case "one-on-one-desktop": case "one-on-one-desktop":
[layout, newTiles] = oneOnOneDesktopLayout( [layout, newTiles] = oneOnOneDesktopLayout(
media, media,
landscapePipAlignment$, landscapePipTile,
prevTiles, prevTiles,
); );
pip = layout.pip; pip = layout.pip.vm;
break; break;
case "one-on-one-mobile": case "one-on-one-mobile":
[layout, newTiles] = oneOnOneMobileLayout( [layout, newTiles] = oneOnOneMobileLayout(
media, media,
portraitPipSize$, portraitPipTile,
portraitPipAlignment$,
prevTiles, prevTiles,
); );
pip = layout.pip; pip = layout.pip?.vm;
break; break;
case "pip": case "pip":
[layout, newTiles] = pipLayout(media, prevTiles); [layout, newTiles] = pipLayout(media, prevTiles);
+38
View File
@@ -0,0 +1,38 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { test } from "vitest";
import { withTestScheduler } from "../utils/test";
import { createFloatingTile } from "./FloatingTile";
test("FloatingTile changes alignment on drag", () =>
withTestScheduler(({ scope, expectObservable, schedule }) => {
// Tile starts in the top left
const { alignment$, onDrag } = createFloatingTile(scope, {
inline: "start",
block: "start",
});
// Drag to all four corners, and then repeatedly into the top left.
schedule("-↗↘↙↖↖", {
"↗": () =>
onDrag({ x: 100, y: 0, xRatio: 1, yRatio: 0, endOfGesture: false }),
"↘": () =>
onDrag({ x: 100, y: 100, xRatio: 1, yRatio: 1, endOfGesture: false }),
"↙": () =>
onDrag({ x: 0, y: 100, xRatio: 0, yRatio: 1, endOfGesture: false }),
"↖": () =>
onDrag({ x: 0, y: 0, xRatio: 0, yRatio: 0, endOfGesture: false }),
});
// Alignment should change accordingly. Notably, the alignment should *not*
// update when repeatedly dragged into the same corner, for performance.
expectObservable(alignment$).toBe("↖↗↘↙↖-", {
"↗": { inline: "end", block: "start" },
"↘": { inline: "end", block: "end" },
"↙": { inline: "start", block: "end" },
"↖": { inline: "start", block: "start" },
});
}));
+43
View File
@@ -0,0 +1,43 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { distinctUntilChanged, map, startWith, Subject } from "rxjs";
import { type Behavior } from "./Behavior";
import { type DragCallback, type Alignment, type Drag } from "./layout-types";
import { type ObservableScope } from "./ObservableScope";
/**
* Represents a floating tile which may be dragged to different corners of the
* call interface.
*/
export interface FloatingTile {
alignment$: Behavior<Alignment>;
onDrag: DragCallback;
}
export function createFloatingTile(
scope: ObservableScope,
defaultAlignment: Alignment,
): FloatingTile {
const dragEvents$ = new Subject<Drag>();
return {
alignment$: scope.behavior(
dragEvents$.pipe(
map<Drag, Alignment>(({ xRatio, yRatio }) => ({
inline: xRatio < 0.5 ? "start" : "end",
block: yRatio < 0.5 ? "start" : "end",
})),
startWith(defaultAlignment),
distinctUntilChanged(
(a1, a2) => a1.inline === a2.inline && a1.block === a2.block,
),
),
),
onDrag: (drag) => dragEvents$.next(drag),
};
}
+7 -10
View File
@@ -5,14 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type BehaviorSubject } from "rxjs"; import { type Layout, type LayoutMedia } from "./layout-types.ts";
import {
type Alignment,
type Layout,
type LayoutMedia,
} from "./layout-types.ts";
import { type TileStore } from "./TileStore"; import { type TileStore } from "./TileStore";
import { type FloatingTile } from "./FloatingTile.ts";
export type GridLikeLayoutType = export type GridLikeLayoutType =
| "grid" | "grid"
@@ -25,7 +20,7 @@ export type GridLikeLayoutType =
*/ */
export function gridLikeLayout( export function gridLikeLayout(
media: LayoutMedia & { type: GridLikeLayoutType }, media: LayoutMedia & { type: GridLikeLayoutType },
spotlightAlignment$: BehaviorSubject<Alignment>, floatingSpotlightTile: FloatingTile,
visibleTiles: number, visibleTiles: number,
setVisibleTiles: (value: number) => void, setVisibleTiles: (value: number) => void,
prevTiles: TileStore, prevTiles: TileStore,
@@ -42,9 +37,11 @@ export function gridLikeLayout(
return [ return [
{ {
type: media.type, type: media.type,
spotlight: tiles.spotlightTile, spotlight: tiles.spotlightTile && {
vm: tiles.spotlightTile,
...floatingSpotlightTile,
},
grid: tiles.gridTiles, grid: tiles.gridTiles,
spotlightAlignment$,
setVisibleTiles, setVisibleTiles,
} as Layout & { type: GridLikeLayoutType }, } as Layout & { type: GridLikeLayoutType },
tiles, tiles,
+3 -6
View File
@@ -6,21 +6,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type BehaviorSubject } from "rxjs";
import { import {
type Alignment,
type OneOnOneDesktopLayout, type OneOnOneDesktopLayout,
type OneOnOneDesktopLayoutMedia, type OneOnOneDesktopLayoutMedia,
} from "./layout-types"; } from "./layout-types";
import { type TileStore } from "./TileStore"; import { type TileStore } from "./TileStore";
import { type FloatingTile } from "./FloatingTile";
/** /**
* Produces a one-on-one desktop layout with the given media. * Produces a one-on-one desktop layout with the given media.
*/ */
export function oneOnOneDesktopLayout( export function oneOnOneDesktopLayout(
media: OneOnOneDesktopLayoutMedia, media: OneOnOneDesktopLayoutMedia,
pipAlignment$: BehaviorSubject<Alignment>, pipTile: FloatingTile,
prevTiles: TileStore, prevTiles: TileStore,
): [OneOnOneDesktopLayout, TileStore] { ): [OneOnOneDesktopLayout, TileStore] {
const update = prevTiles.from(2); const update = prevTiles.from(2);
@@ -32,8 +30,7 @@ export function oneOnOneDesktopLayout(
{ {
type: media.type, type: media.type,
spotlight: tiles.gridTilesByMedia.get(media.spotlight)!, spotlight: tiles.gridTilesByMedia.get(media.spotlight)!,
pip: tiles.gridTilesByMedia.get(media.pip)!, pip: { vm: tiles.gridTilesByMedia.get(media.pip)!, ...pipTile },
pipAlignment$,
}, },
tiles, tiles,
]; ];
+4 -8
View File
@@ -6,23 +6,20 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type BehaviorSubject } from "rxjs";
import { import {
type Alignment,
type OneOnOneMobileLayout, type OneOnOneMobileLayout,
type OneOnOneMobileLayoutMedia, type OneOnOneMobileLayoutMedia,
} from "./layout-types"; } from "./layout-types";
import { type TileStore } from "./TileStore"; import { type TileStore } from "./TileStore";
import { type Behavior } from "./Behavior"; import { type Behavior } from "./Behavior";
import { type FloatingTile } from "./FloatingTile";
/** /**
* Produces a one-on-one mobile layout with the given media. * Produces a one-on-one mobile layout with the given media.
*/ */
export function oneOnOneMobileLayout( export function oneOnOneMobileLayout(
media: OneOnOneMobileLayoutMedia, media: OneOnOneMobileLayoutMedia,
pipSize$: Behavior<"sm" | "lg">, pipTile: FloatingTile & { size$: Behavior<"sm" | "lg"> },
pipAlignment$: BehaviorSubject<Alignment>,
prevTiles: TileStore, prevTiles: TileStore,
): [OneOnOneMobileLayout, TileStore] { ): [OneOnOneMobileLayout, TileStore] {
const update = prevTiles.from(media.pip === undefined ? 0 : 1); const update = prevTiles.from(media.pip === undefined ? 0 : 1);
@@ -30,13 +27,12 @@ export function oneOnOneMobileLayout(
if (media.pip !== undefined) update.registerGridTile(media.pip); if (media.pip !== undefined) update.registerGridTile(media.pip);
const tiles = update.build(); const tiles = update.build();
const pipVm = media.pip && tiles.gridTilesByMedia.get(media.pip);
return [ return [
{ {
type: media.type, type: media.type,
spotlight: tiles.spotlightTile!, spotlight: tiles.spotlightTile!,
pip: media.pip && tiles.gridTilesByMedia.get(media.pip), pip: pipVm && { vm: pipVm, ...pipTile },
pipSize$,
pipAlignment$,
}, },
tiles, tiles,
]; ];
+4 -6
View File
@@ -5,21 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type BehaviorSubject } from "rxjs";
import { import {
type Alignment,
type SpotlightExpandedLayout, type SpotlightExpandedLayout,
type SpotlightExpandedLayoutMedia, type SpotlightExpandedLayoutMedia,
} from "./layout-types"; } from "./layout-types";
import { type TileStore } from "./TileStore"; import { type TileStore } from "./TileStore";
import { type FloatingTile } from "./FloatingTile";
/** /**
* 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,
pipAlignment$: BehaviorSubject<Alignment>, pipTile: FloatingTile,
prevTiles: TileStore, prevTiles: TileStore,
): [SpotlightExpandedLayout, TileStore] { ): [SpotlightExpandedLayout, TileStore] {
const update = prevTiles.from(1); const update = prevTiles.from(1);
@@ -27,12 +25,12 @@ export function spotlightExpandedLayout(
if (media.pip !== undefined) update.registerPipTile(media.pip); if (media.pip !== undefined) update.registerPipTile(media.pip);
const tiles = update.build(); const tiles = update.build();
const pipVm = tiles.gridTiles.at(0);
return [ return [
{ {
type: media.type, type: media.type,
spotlight: tiles.spotlightTile!, spotlight: tiles.spotlightTile!,
pip: tiles.gridTiles.at(0), pip: pipVm && { vm: pipVm, ...pipTile },
pipAlignment$,
}, },
tiles, tiles,
]; ];
+6 -14
View File
@@ -6,33 +6,25 @@ Please see LICENSE in the repository root for full details.
*/ */
import { test, expect } from "vitest"; import { test, expect } from "vitest";
import { import { layoutShallowEquals, type Layout } from "./layout-types";
type Alignment,
layoutShallowEquals,
type Layout,
} from "./layout-types";
import { import {
type SpotlightTileViewModel, type SpotlightTileViewModel,
type GridTileViewModel, type GridTileViewModel,
} from "./TileViewModel"; } from "./TileViewModel";
import { BehaviorSubject } from "rxjs"; import { type FloatingTile } from "./FloatingTile";
const spotlightTile = {} as unknown as SpotlightTileViewModel; const spotlightTile = {} as unknown as SpotlightTileViewModel;
const gridTile = {} as unknown as GridTileViewModel; const gridTile = {} as unknown as GridTileViewModel;
const pipAlignment$ = new BehaviorSubject<Alignment>({ const pipTile = {} as unknown as FloatingTile & { vm: GridTileViewModel };
inline: "end",
block: "end",
});
const spotlightExpanded: Layout = { const spotlightExpanded: Layout = {
type: "spotlight-expanded", type: "spotlight-expanded",
spotlight: spotlightTile, spotlight: spotlightTile,
pipAlignment$,
}; };
const spotlightPortrait: Layout = { const spotlightPortrait: Layout = {
type: "spotlight-portrait", type: "spotlight-portrait",
spotlight: spotlightTile, spotlight: { vm: spotlightTile },
grid: [gridTile], grid: [gridTile],
setVisibleTiles: () => {}, setVisibleTiles: () => {},
}; };
@@ -46,12 +38,12 @@ test("layoutShallowEquals detects a missing key", () => {
expect( expect(
layoutShallowEquals(spotlightExpanded, { layoutShallowEquals(spotlightExpanded, {
...spotlightExpanded, ...spotlightExpanded,
pip: gridTile, pip: pipTile,
}), }),
).toBe(false); ).toBe(false);
expect( expect(
layoutShallowEquals( layoutShallowEquals(
{ ...spotlightExpanded, pip: gridTile }, { ...spotlightExpanded, pip: pipTile },
spotlightExpanded, spotlightExpanded,
), ),
).toBe(false); ).toBe(false);
+32 -13
View File
@@ -5,8 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type BehaviorSubject } from "rxjs";
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts"; import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
import { type MediaViewModel } from "./media/MediaViewModel.ts"; import { type MediaViewModel } from "./media/MediaViewModel.ts";
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts"; import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts";
@@ -17,6 +15,7 @@ import {
} from "./TileViewModel.ts"; } from "./TileViewModel.ts";
import { type Behavior } from "./Behavior.ts"; import { type Behavior } from "./Behavior.ts";
import { shallowEquals as arrayShallowEquals } from "../utils/array.ts"; import { shallowEquals as arrayShallowEquals } from "../utils/array.ts";
import { type FloatingTile } from "./FloatingTile.ts";
export interface GridLayoutMedia { export interface GridLayoutMedia {
type: "grid"; type: "grid";
@@ -80,24 +79,48 @@ export interface Alignment {
block: "start" | "end"; block: "start" | "end";
} }
export interface Drag {
/**
* The X offset of the dragged tile relative to its container.
*/
x: number;
/**
* The Y offset of the dragged tile relative to its container.
*/
y: number;
/**
* The X coordinate of the dragged tile, as a scalar of the container's width.
*/
xRatio: number;
/**
* The Y coordinate of the dragged tile, as a scalar of the container's height.
*/
yRatio: number;
/**
* Whether this event ends the drag gesture.
*/
endOfGesture: boolean;
}
export type DragCallback = (drag: Drag) => void;
export interface GridLayout { export interface GridLayout {
type: "grid"; type: "grid";
spotlight?: SpotlightTileViewModel; spotlight?: FloatingTile & { vm: SpotlightTileViewModel };
grid: GridTileViewModel[]; grid: GridTileViewModel[];
spotlightAlignment$: BehaviorSubject<Alignment>;
setVisibleTiles: (value: number) => void; setVisibleTiles: (value: number) => void;
} }
export interface SpotlightLandscapeLayout { export interface SpotlightLandscapeLayout {
type: "spotlight-landscape"; type: "spotlight-landscape";
spotlight: SpotlightTileViewModel; spotlight: { vm: SpotlightTileViewModel };
grid: GridTileViewModel[]; grid: GridTileViewModel[];
setVisibleTiles: (value: number) => void; setVisibleTiles: (value: number) => void;
} }
export interface SpotlightPortraitLayout { export interface SpotlightPortraitLayout {
type: "spotlight-portrait"; type: "spotlight-portrait";
spotlight: SpotlightTileViewModel; spotlight: { vm: SpotlightTileViewModel };
grid: GridTileViewModel[]; grid: GridTileViewModel[];
setVisibleTiles: (value: number) => void; setVisibleTiles: (value: number) => void;
} }
@@ -105,23 +128,19 @@ export interface SpotlightPortraitLayout {
export interface SpotlightExpandedLayout { export interface SpotlightExpandedLayout {
type: "spotlight-expanded"; type: "spotlight-expanded";
spotlight: SpotlightTileViewModel; spotlight: SpotlightTileViewModel;
pip?: GridTileViewModel; pip?: FloatingTile & { vm: GridTileViewModel };
pipAlignment$: BehaviorSubject<Alignment>;
} }
export interface OneOnOneDesktopLayout { export interface OneOnOneDesktopLayout {
type: "one-on-one-desktop"; type: "one-on-one-desktop";
spotlight: GridTileViewModel; spotlight: GridTileViewModel;
pip: GridTileViewModel; pip: FloatingTile & { vm: GridTileViewModel };
pipAlignment$: BehaviorSubject<Alignment>;
} }
export interface OneOnOneMobileLayout { export interface OneOnOneMobileLayout {
type: "one-on-one-mobile"; type: "one-on-one-mobile";
spotlight: SpotlightTileViewModel; spotlight: SpotlightTileViewModel;
pip?: GridTileViewModel; pip?: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> };
pipSize$: Behavior<"sm" | "lg">;
pipAlignment$: BehaviorSubject<Alignment>;
} }
export interface PipLayout { export interface PipLayout {