mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Compare commits
3
Commits
v0.28.0
...
floating-tile
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4441bb5e8c | ||
|
|
f2f49315eb | ||
|
|
99256fa01c |
@@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
|
||||
import { constant } from "../state/Behavior";
|
||||
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 { DeviceLabel } from "../state/MediaDevices";
|
||||
import { createCallFooterViewModel } from "./CallFooterViewModel";
|
||||
@@ -65,10 +64,6 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel {
|
||||
const gridLayout: Layout = {
|
||||
type: "grid",
|
||||
grid: [],
|
||||
spotlightAlignment$: new BehaviorSubject<Alignment>({
|
||||
inline: "end",
|
||||
block: "end",
|
||||
}),
|
||||
setVisibleTiles: (_: number) => {},
|
||||
};
|
||||
|
||||
|
||||
+100
-150
@@ -5,28 +5,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { arrangeTiles, type Bounds } from "./CallLayout";
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import { makeGridLayout } from "./GridLayout";
|
||||
import { makeSpotlightLandscapeLayout } from "./SpotlightLandscapeLayout";
|
||||
import { makeSpotlightPortraitLayout } from "./SpotlightPortraitLayout";
|
||||
import { type DragCallback, type LayoutProps } from "./Grid";
|
||||
import { type LayoutProps } from "./Grid";
|
||||
import { constant } from "../state/Behavior";
|
||||
import {
|
||||
type Alignment,
|
||||
type GridLayout,
|
||||
type SpotlightLandscapeLayout,
|
||||
type SpotlightPortraitLayout,
|
||||
} from "../state/layout-types";
|
||||
import { type Alignment } from "../state/layout-types";
|
||||
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
||||
import {
|
||||
GridTileViewModel,
|
||||
SpotlightTileViewModel,
|
||||
type TileViewModel,
|
||||
} from "../state/TileViewModel";
|
||||
import { makeOneOnOneMobileLayout } from "./OneOnOneMobileLayout";
|
||||
import { makeSpotlightExpandedLayout } from "./SpotlightExpandedLayout";
|
||||
import { makeOneOnOneDesktopLayout } from "./OneOnOneDesktopLayout";
|
||||
|
||||
// Grid supplies these hooks to the layouts it renders; on their own the
|
||||
// layouts have no grid to report to
|
||||
@@ -36,152 +33,105 @@ vi.mock("./Grid", async (importOriginal) => ({
|
||||
useVisibleTiles: (): void => {},
|
||||
}));
|
||||
|
||||
describe("grid layout", () => {
|
||||
it("sizes the tiles from the minimum bounds", () => {
|
||||
const minBounds$ = new BehaviorSubject<Bounds>({ width: 800, height: 600 });
|
||||
const { scrolling: Scrolling } = makeGridLayout({ minBounds$ });
|
||||
render(<Scrolling model={gridModel()} Slot={Slot} />);
|
||||
const layer = screen.getByTestId("slot-0").parentElement!;
|
||||
expect(layer.style.width).toBe("800px");
|
||||
expect(layer.style.getPropertyValue("--width")).toBe(
|
||||
`${Math.floor(arrangeTiles(800, 600, 3).tileWidth)}px`,
|
||||
);
|
||||
const minBounds$ = constant({ width: 800, height: 800 });
|
||||
|
||||
act(() => minBounds$.next({ width: 400, height: 300 }));
|
||||
expect(layer.style.width).toBe("400px");
|
||||
expect(layer.style.getPropertyValue("--width")).toBe(
|
||||
`${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", () => {
|
||||
it("puts the spotlight in the fixed layer and the grid in the scrolling one", () => {
|
||||
const { fixed: Fixed, scrolling: Scrolling } = makeSpotlightLandscapeLayout(
|
||||
{ minBounds$: bounds },
|
||||
);
|
||||
const model = landscapeModel();
|
||||
const { unmount } = render(<Fixed model={model} Slot={Slot} />);
|
||||
expect(screen.getByTestId("slot-spotlight")).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
render(<Scrolling model={model} Slot={Slot} />);
|
||||
expect(slotIds()).toEqual(model.grid.map((tile) => tile.id));
|
||||
});
|
||||
});
|
||||
|
||||
describe("spotlight portrait layout", () => {
|
||||
it("sizes the grid tiles from the minimum width", () => {
|
||||
const minBounds$ = new BehaviorSubject<Bounds>({ width: 300, height: 700 });
|
||||
const { scrolling: Scrolling } = makeSpotlightPortraitLayout({
|
||||
minBounds$,
|
||||
});
|
||||
const model = portraitModel();
|
||||
render(<Scrolling model={model} Slot={Slot} />);
|
||||
expect(slotIds()).toEqual(model.grid.map((tile) => tile.id));
|
||||
const layer = screen.getByTestId(`slot-${model.grid[0].id}`).parentElement!
|
||||
.parentElement!;
|
||||
expect(layer.style.getPropertyValue("--grid-tile-width")).toBe(
|
||||
`${Math.floor(arrangeTiles(300, 300, 3).tileWidth)}px`,
|
||||
);
|
||||
|
||||
act(() => minBounds$.next({ width: 500, height: 700 }));
|
||||
expect(layer.style.getPropertyValue("--grid-tile-width")).toBe(
|
||||
`${Math.floor(arrangeTiles(500, 500, 3).tileWidth)}px`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const bounds = constant<Bounds>({ width: 800, height: 600 });
|
||||
|
||||
// The layouts only pass tile view models through to their slots
|
||||
const media = {} as UserMediaViewModel;
|
||||
const spotlight = new SpotlightTileViewModel(
|
||||
constant([]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
const spotlight = {
|
||||
vm: new SpotlightTileViewModel(
|
||||
constant([]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
),
|
||||
alignment$: constant<Alignment>({ inline: "end", block: "end" }),
|
||||
onDrag: () => {},
|
||||
};
|
||||
const grid = Array.from(
|
||||
{ length: 3 },
|
||||
() => new GridTileViewModel(constant({} as UserMediaViewModel)),
|
||||
);
|
||||
const tiles = (): GridTileViewModel[] =>
|
||||
Array.from({ length: 3 }, () => new GridTileViewModel(constant(media)));
|
||||
const pip = {
|
||||
alignment$: constant<Alignment>({ inline: "end", block: "end" }),
|
||||
onDrag: () => {},
|
||||
vm: { id: "pip" } as GridTileViewModel,
|
||||
size$: constant("sm" as const),
|
||||
};
|
||||
|
||||
function gridModel(): GridLayout {
|
||||
return {
|
||||
type: "grid",
|
||||
spotlight,
|
||||
grid: tiles(),
|
||||
spotlightAlignment$: new BehaviorSubject<Alignment>({
|
||||
inline: "end",
|
||||
block: "end",
|
||||
}),
|
||||
setVisibleTiles: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function landscapeModel(): SpotlightLandscapeLayout {
|
||||
return {
|
||||
type: "spotlight-landscape",
|
||||
spotlight,
|
||||
grid: tiles(),
|
||||
setVisibleTiles: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function portraitModel(): SpotlightPortraitLayout {
|
||||
return {
|
||||
type: "spotlight-portrait",
|
||||
spotlight,
|
||||
grid: tiles(),
|
||||
setVisibleTiles: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// Slots record their drag callback so a test can drag them, and show the id
|
||||
// of the tile they hold
|
||||
const drags = new Map<string, DragCallback | undefined>();
|
||||
const Slot: LayoutProps<unknown, TileViewModel, HTMLDivElement>["Slot"] = ({
|
||||
id,
|
||||
model,
|
||||
onDrag,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
drags.set(id, onDrag);
|
||||
return (
|
||||
<div data-testid={`slot-${id}`} {...props}>
|
||||
{model instanceof GridTileViewModel ? model.id : "spotlight"}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}) => (
|
||||
<div data-testid={`slot-${id}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
function slotIds(): string[] {
|
||||
return screen
|
||||
.getAllByTestId(/^slot-/)
|
||||
.map((slot) => slot.textContent ?? "")
|
||||
.filter((id) => id !== "spotlight");
|
||||
function snapshotLayout<Model>(layout: CallLayout<Model>, model: Model): void {
|
||||
const {
|
||||
foreground,
|
||||
scrolling: Scrolling,
|
||||
fixed: Fixed,
|
||||
} = layout({ minBounds$ });
|
||||
const scrollingLayer = <Scrolling model={model} Slot={Slot} />;
|
||||
const fixedLayer = <Fixed model={model} Slot={Slot} />;
|
||||
const { container } = render(
|
||||
foreground === "fixed" ? (
|
||||
<>
|
||||
{scrollingLayer}
|
||||
{fixedLayer}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{fixedLayer}
|
||||
{scrollingLayer}
|
||||
</>
|
||||
),
|
||||
);
|
||||
expect(container).toMatchSnapshot();
|
||||
}
|
||||
|
||||
test("grid layout", () =>
|
||||
snapshotLayout(makeGridLayout, {
|
||||
type: "grid",
|
||||
spotlight,
|
||||
grid,
|
||||
setVisibleTiles: () => {},
|
||||
}));
|
||||
|
||||
test("spotlight landscape layout", () =>
|
||||
snapshotLayout(makeSpotlightLandscapeLayout, {
|
||||
type: "spotlight-landscape",
|
||||
spotlight,
|
||||
grid,
|
||||
setVisibleTiles: () => {},
|
||||
}));
|
||||
|
||||
test("spotlight portrait layout", () =>
|
||||
snapshotLayout(makeSpotlightPortraitLayout, {
|
||||
type: "spotlight-portrait",
|
||||
spotlight,
|
||||
grid,
|
||||
setVisibleTiles: () => {},
|
||||
}));
|
||||
|
||||
test("spotlight expanded layout", () =>
|
||||
snapshotLayout(makeSpotlightExpandedLayout, {
|
||||
type: "spotlight-expanded",
|
||||
spotlight: spotlight.vm,
|
||||
pip,
|
||||
}));
|
||||
|
||||
test("one-on-one desktop layout", () =>
|
||||
snapshotLayout(makeOneOnOneDesktopLayout, {
|
||||
type: "one-on-one-desktop",
|
||||
spotlight: { id: "spotlight" } as GridTileViewModel,
|
||||
pip,
|
||||
}));
|
||||
|
||||
test("one-on-one mobile layout", () =>
|
||||
snapshotLayout(makeOneOnOneMobileLayout, {
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: spotlight.vm,
|
||||
pip,
|
||||
}));
|
||||
|
||||
+12
-29
@@ -40,6 +40,7 @@ import { useMergedRefs } from "../useMergedRefs";
|
||||
import { TileWrapper } from "./TileWrapper";
|
||||
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { type DragCallback } from "../state/layout-types";
|
||||
|
||||
const MAX_ANIMATED_TILES = 50; // Capped for performance reasons
|
||||
|
||||
@@ -83,7 +84,10 @@ interface DragState {
|
||||
cursorY: number;
|
||||
}
|
||||
|
||||
interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
|
||||
export interface SlotProps<Model> extends Omit<
|
||||
ComponentProps<"div">,
|
||||
"onDrag"
|
||||
> {
|
||||
id: string;
|
||||
model: Model;
|
||||
onDrag?: DragCallback;
|
||||
@@ -181,27 +185,6 @@ export interface TileProps<Model, R extends HTMLElement> {
|
||||
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;
|
||||
}
|
||||
|
||||
export type DragCallback = (drag: Drag) => void;
|
||||
|
||||
interface LayoutMemoProps<
|
||||
LayoutModel,
|
||||
TileModel,
|
||||
@@ -464,13 +447,13 @@ export function Grid<
|
||||
)
|
||||
.catch(logger.error);
|
||||
|
||||
if (endOfGesture)
|
||||
callback({
|
||||
x: tileX,
|
||||
y: tileY,
|
||||
xRatio: tileX / (gridBounds.width - tile.width),
|
||||
yRatio: tileY / (gridBounds.height - tile.height),
|
||||
});
|
||||
callback({
|
||||
x: tileX,
|
||||
y: tileY,
|
||||
xRatio: tileX / (gridBounds.width - tile.width),
|
||||
yRatio: tileY / (gridBounds.height - tile.height),
|
||||
endOfGesture,
|
||||
});
|
||||
};
|
||||
|
||||
// Callback for useDrag. We could call useDrag here, but the default
|
||||
|
||||
+30
-25
@@ -8,15 +8,21 @@ Please see LICENSE in the repository root for full details.
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
type FC,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
|
||||
import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts";
|
||||
import styles from "./GridLayout.module.css";
|
||||
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 {
|
||||
type SpotlightTileViewModel,
|
||||
type TileViewModel,
|
||||
} from "../state/TileViewModel.ts";
|
||||
import { type FloatingTile } from "../state/FloatingTile.ts";
|
||||
|
||||
interface GridCSSProperties extends CSSProperties {
|
||||
"--gap": string;
|
||||
@@ -24,6 +30,27 @@ interface GridCSSProperties extends CSSProperties {
|
||||
"--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
|
||||
* together in a scrolling grid.
|
||||
@@ -37,32 +64,10 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||
// lives
|
||||
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
|
||||
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 (
|
||||
<div ref={ref} className={styles.fixed}>
|
||||
{model.spotlight && (
|
||||
<Slot
|
||||
className={styles.slot}
|
||||
id="spotlight"
|
||||
model={model.spotlight}
|
||||
onDrag={onDragSpotlight}
|
||||
data-block-alignment={alignment.block}
|
||||
data-inline-alignment={alignment.inline}
|
||||
/>
|
||||
<SpotlightSlot model={model.spotlight} Slot={Slot} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useCallback, useMemo } from "react";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts";
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import styles from "./OneOnOneDesktopLayout.module.css";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { useUpdateLayout } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
/**
|
||||
@@ -37,21 +37,12 @@ export const makeOneOnOneDesktopLayout: CallLayout<
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const { width, height } = useBehavior(minBounds$);
|
||||
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||
const pipAlignment = useBehavior(model.pip.alignment$);
|
||||
const { tileWidth, tileHeight } = useMemo(
|
||||
() => arrangeTiles(width, height, 1),
|
||||
[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 (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<Slot
|
||||
@@ -62,9 +53,9 @@ export const makeOneOnOneDesktopLayout: CallLayout<
|
||||
>
|
||||
<Slot
|
||||
className={classNames(styles.slot, styles.local)}
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
onDrag={onDragLocalTile}
|
||||
id={model.pip.vm.id}
|
||||
model={model.pip.vm}
|
||||
onDrag={model.pip.onDrag}
|
||||
data-block-alignment={pipAlignment.block}
|
||||
data-inline-alignment={pipAlignment.inline}
|
||||
/>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
import { type ComponentType, type FC, type ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type OneOnOneMobileLayout as OneOnOneMobileLayoutModel } from "../state/layout-types.ts";
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import styles from "./OneOnOneMobileLayout.module.css";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { type SlotProps, useUpdateLayout } from "./Grid";
|
||||
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
|
||||
@@ -44,30 +73,9 @@ export const makeOneOnOneMobileLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
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 (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
{model.pip && (
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
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 CallLayout } from "./CallLayout";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { type SlotProps, useUpdateLayout } from "./Grid";
|
||||
import styles from "./SpotlightExpandedLayout.module.css";
|
||||
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
|
||||
@@ -28,7 +54,6 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<Slot
|
||||
@@ -46,29 +71,9 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
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 (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
{model.pip && (
|
||||
<Slot
|
||||
className={styles.pip}
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
onDrag={onDragPip}
|
||||
data-block-alignment={pipAlignment.block}
|
||||
data-inline-alignment={pipAlignment.inline}
|
||||
/>
|
||||
)}
|
||||
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
<Slot
|
||||
className={styles.slot}
|
||||
id="spotlight"
|
||||
model={model.spotlight}
|
||||
model={model.spotlight.vm}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.grid} />
|
||||
@@ -59,7 +59,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<SpotlightSlot media$={model.spotlight.media$} />
|
||||
<SpotlightSlot media$={model.spotlight.vm.media$} />
|
||||
<div className={styles.grid}>
|
||||
{model.grid.map((m) => (
|
||||
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
|
||||
|
||||
@@ -43,7 +43,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
||||
<Slot
|
||||
className={styles.slot}
|
||||
id="spotlight"
|
||||
model={model.spotlight}
|
||||
model={model.spotlight.vm}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,7 +65,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
||||
width,
|
||||
model.grid.length,
|
||||
);
|
||||
const withIndicators = useBehavior(model.spotlight.media$).length > 1;
|
||||
const withIndicators = useBehavior(model.spotlight.vm.media$).length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`grid layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_scrolling_e8555d"
|
||||
style="width: 800px; --gap: 20px; --width: 370px; --height: 277px;"
|
||||
>
|
||||
<div
|
||||
class="_slot_e8555d"
|
||||
data-testid="slot-0"
|
||||
/>
|
||||
<div
|
||||
class="_slot_e8555d"
|
||||
data-testid="slot-1"
|
||||
/>
|
||||
<div
|
||||
class="_slot_e8555d"
|
||||
data-testid="slot-2"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="_fixed_e8555d"
|
||||
>
|
||||
<div
|
||||
class="_slot_e8555d"
|
||||
data-block-alignment="end"
|
||||
data-inline-alignment="end"
|
||||
data-testid="slot-spotlight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`one-on-one desktop layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_layer_185815"
|
||||
>
|
||||
<div
|
||||
class="_container_185815"
|
||||
data-testid="slot-spotlight"
|
||||
style="width: 760px; height: 570px;"
|
||||
>
|
||||
<div
|
||||
class="_slot_185815 _local_185815"
|
||||
data-block-alignment="end"
|
||||
data-inline-alignment="end"
|
||||
data-testid="slot-pip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`one-on-one mobile layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_layer_c81c30"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_c81c30"
|
||||
data-testid="slot-spotlight"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="_layer_c81c30"
|
||||
>
|
||||
<div
|
||||
class="_pip_c81c30"
|
||||
data-block-alignment="end"
|
||||
data-inline-alignment="end"
|
||||
data-size="sm"
|
||||
data-testid="slot-pip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`spotlight expanded layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_layer_e70191"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_e70191"
|
||||
data-testid="slot-spotlight"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="_layer_e70191"
|
||||
>
|
||||
<div
|
||||
class="_pip_e70191"
|
||||
data-block-alignment="end"
|
||||
data-inline-alignment="end"
|
||||
data-testid="slot-pip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`spotlight landscape layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_layer_29809c"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_29809c"
|
||||
>
|
||||
<div
|
||||
class="_slot_29809c"
|
||||
data-testid="slot-spotlight"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="_grid_29809c"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="_layer_29809c"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_29809c"
|
||||
/>
|
||||
<div
|
||||
class="_grid_29809c"
|
||||
>
|
||||
<div
|
||||
class="_slot_29809c"
|
||||
data-testid="slot-0"
|
||||
/>
|
||||
<div
|
||||
class="_slot_29809c"
|
||||
data-testid="slot-1"
|
||||
/>
|
||||
<div
|
||||
class="_slot_29809c"
|
||||
data-testid="slot-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`spotlight portrait layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_layer_065bbc"
|
||||
style="--grid-gap: 20px; --grid-tile-width: 370px; --grid-tile-height: 277px;"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_065bbc"
|
||||
/>
|
||||
<div
|
||||
class="_grid_065bbc"
|
||||
>
|
||||
<div
|
||||
class="_slot_065bbc"
|
||||
data-testid="slot-0"
|
||||
/>
|
||||
<div
|
||||
class="_slot_065bbc"
|
||||
data-testid="slot-1"
|
||||
/>
|
||||
<div
|
||||
class="_slot_065bbc"
|
||||
data-testid="slot-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_layer_065bbc"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_065bbc"
|
||||
>
|
||||
<div
|
||||
class="_slot_065bbc"
|
||||
data-testid="slot-spotlight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -176,7 +176,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
case "grid":
|
||||
return combineLatest(
|
||||
[
|
||||
l.spotlight?.media$ ?? constant(undefined),
|
||||
l.spotlight?.vm.media$ ?? constant(undefined),
|
||||
...l.grid.map((vm) => vm.media$),
|
||||
],
|
||||
(spotlight, ...grid) => ({
|
||||
@@ -188,7 +188,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
case "spotlight-landscape":
|
||||
case "spotlight-portrait":
|
||||
return combineLatest(
|
||||
[l.spotlight.media$, ...l.grid.map((vm) => vm.media$)],
|
||||
[l.spotlight.vm.media$, ...l.grid.map((vm) => vm.media$)],
|
||||
(spotlight, ...grid) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight.map((vm) => vm.id),
|
||||
@@ -197,7 +197,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
);
|
||||
case "spotlight-expanded":
|
||||
return combineLatest(
|
||||
[l.spotlight.media$, l.pip?.media$ ?? constant(undefined)],
|
||||
[l.spotlight.media$, l.pip?.vm.media$ ?? constant(undefined)],
|
||||
(spotlight, pip) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight.map((vm) => vm.id),
|
||||
@@ -206,7 +206,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
);
|
||||
case "one-on-one-desktop":
|
||||
return combineLatest(
|
||||
[l.spotlight.media$, l.pip.media$],
|
||||
[l.spotlight.media$, l.pip.vm.media$],
|
||||
(spotlight, pip) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight.id,
|
||||
@@ -217,8 +217,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
return combineLatest(
|
||||
[
|
||||
l.spotlight.media$,
|
||||
l.pip?.media$ ?? constant(undefined),
|
||||
l.pipSize$,
|
||||
l.pip?.vm.media$ ?? constant(undefined),
|
||||
l.pip?.size$ ?? constant(undefined),
|
||||
],
|
||||
(spotlight, pip, pipSize) => ({
|
||||
type: l.type,
|
||||
@@ -480,7 +480,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: "lg",
|
||||
pipSize: undefined,
|
||||
},
|
||||
b: {
|
||||
type: "one-on-one-mobile",
|
||||
@@ -518,7 +518,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: "lg",
|
||||
pipSize: undefined,
|
||||
},
|
||||
});
|
||||
// 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",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: "sm",
|
||||
pipSize: undefined,
|
||||
},
|
||||
b: {
|
||||
type: "spotlight-landscape",
|
||||
@@ -765,7 +765,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: "lg",
|
||||
pipSize: undefined,
|
||||
},
|
||||
b: {
|
||||
// In a larger window, expect the one-on-one desktop layout
|
||||
|
||||
@@ -93,7 +93,6 @@ import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||
import { type HostBridge, nullHostBridge } from "../../HostBridge";
|
||||
import {
|
||||
layoutShallowEquals,
|
||||
type Alignment,
|
||||
type GridLayoutMedia,
|
||||
type Layout,
|
||||
type LayoutMedia,
|
||||
@@ -163,6 +162,7 @@ import {
|
||||
type RingingMediaViewModel,
|
||||
} from "../media/RingingMediaViewModel.ts";
|
||||
import { type GridTileViewModel } from "../TileViewModel.ts";
|
||||
import { createFloatingTile } from "../FloatingTile.ts";
|
||||
|
||||
//TODO
|
||||
// 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",
|
||||
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(
|
||||
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>({
|
||||
const landscapePipTile = createFloatingTile(scope, {
|
||||
inline: "end",
|
||||
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
|
||||
// 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":
|
||||
[layout, newTiles] = gridLikeLayout(
|
||||
media,
|
||||
spotlightAlignment$,
|
||||
floatingSpotlightTile,
|
||||
visibleTiles,
|
||||
setVisibleTiles,
|
||||
prevTiles,
|
||||
@@ -1613,26 +1609,25 @@ export function createCallViewModel$(
|
||||
case "spotlight-expanded":
|
||||
[layout, newTiles] = spotlightExpandedLayout(
|
||||
media,
|
||||
landscapePipAlignment$,
|
||||
landscapePipTile,
|
||||
prevTiles,
|
||||
);
|
||||
break;
|
||||
case "one-on-one-desktop":
|
||||
[layout, newTiles] = oneOnOneDesktopLayout(
|
||||
media,
|
||||
landscapePipAlignment$,
|
||||
landscapePipTile,
|
||||
prevTiles,
|
||||
);
|
||||
pip = layout.pip;
|
||||
pip = layout.pip.vm;
|
||||
break;
|
||||
case "one-on-one-mobile":
|
||||
[layout, newTiles] = oneOnOneMobileLayout(
|
||||
media,
|
||||
portraitPipSize$,
|
||||
portraitPipAlignment$,
|
||||
portraitPipTile,
|
||||
prevTiles,
|
||||
);
|
||||
pip = layout.pip;
|
||||
pip = layout.pip?.vm;
|
||||
break;
|
||||
case "pip":
|
||||
[layout, newTiles] = pipLayout(media, prevTiles);
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}));
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
type Alignment,
|
||||
type Layout,
|
||||
type LayoutMedia,
|
||||
} from "./layout-types.ts";
|
||||
import { type Layout, type LayoutMedia } from "./layout-types.ts";
|
||||
import { type TileStore } from "./TileStore";
|
||||
import { type FloatingTile } from "./FloatingTile.ts";
|
||||
|
||||
export type GridLikeLayoutType =
|
||||
| "grid"
|
||||
@@ -25,7 +20,7 @@ export type GridLikeLayoutType =
|
||||
*/
|
||||
export function gridLikeLayout(
|
||||
media: LayoutMedia & { type: GridLikeLayoutType },
|
||||
spotlightAlignment$: BehaviorSubject<Alignment>,
|
||||
floatingSpotlightTile: FloatingTile,
|
||||
visibleTiles: number,
|
||||
setVisibleTiles: (value: number) => void,
|
||||
prevTiles: TileStore,
|
||||
@@ -42,9 +37,11 @@ export function gridLikeLayout(
|
||||
return [
|
||||
{
|
||||
type: media.type,
|
||||
spotlight: tiles.spotlightTile,
|
||||
spotlight: tiles.spotlightTile && {
|
||||
vm: tiles.spotlightTile,
|
||||
...floatingSpotlightTile,
|
||||
},
|
||||
grid: tiles.gridTiles,
|
||||
spotlightAlignment$,
|
||||
setVisibleTiles,
|
||||
} as Layout & { type: GridLikeLayoutType },
|
||||
tiles,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
type Alignment,
|
||||
type OneOnOneDesktopLayout,
|
||||
type OneOnOneDesktopLayoutMedia,
|
||||
} from "./layout-types";
|
||||
import { type TileStore } from "./TileStore";
|
||||
import { type FloatingTile } from "./FloatingTile";
|
||||
|
||||
/**
|
||||
* Produces a one-on-one desktop layout with the given media.
|
||||
*/
|
||||
export function oneOnOneDesktopLayout(
|
||||
media: OneOnOneDesktopLayoutMedia,
|
||||
pipAlignment$: BehaviorSubject<Alignment>,
|
||||
pipTile: FloatingTile,
|
||||
prevTiles: TileStore,
|
||||
): [OneOnOneDesktopLayout, TileStore] {
|
||||
const update = prevTiles.from(2);
|
||||
@@ -32,8 +30,7 @@ export function oneOnOneDesktopLayout(
|
||||
{
|
||||
type: media.type,
|
||||
spotlight: tiles.gridTilesByMedia.get(media.spotlight)!,
|
||||
pip: tiles.gridTilesByMedia.get(media.pip)!,
|
||||
pipAlignment$,
|
||||
pip: { vm: tiles.gridTilesByMedia.get(media.pip)!, ...pipTile },
|
||||
},
|
||||
tiles,
|
||||
];
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
type Alignment,
|
||||
type OneOnOneMobileLayout,
|
||||
type OneOnOneMobileLayoutMedia,
|
||||
} from "./layout-types";
|
||||
import { type TileStore } from "./TileStore";
|
||||
import { type Behavior } from "./Behavior";
|
||||
import { type FloatingTile } from "./FloatingTile";
|
||||
|
||||
/**
|
||||
* Produces a one-on-one mobile layout with the given media.
|
||||
*/
|
||||
export function oneOnOneMobileLayout(
|
||||
media: OneOnOneMobileLayoutMedia,
|
||||
pipSize$: Behavior<"sm" | "lg">,
|
||||
pipAlignment$: BehaviorSubject<Alignment>,
|
||||
pipTile: FloatingTile & { size$: Behavior<"sm" | "lg"> },
|
||||
prevTiles: TileStore,
|
||||
): [OneOnOneMobileLayout, TileStore] {
|
||||
const update = prevTiles.from(media.pip === undefined ? 0 : 1);
|
||||
@@ -30,13 +27,12 @@ export function oneOnOneMobileLayout(
|
||||
if (media.pip !== undefined) update.registerGridTile(media.pip);
|
||||
const tiles = update.build();
|
||||
|
||||
const pipVm = media.pip && tiles.gridTilesByMedia.get(media.pip);
|
||||
return [
|
||||
{
|
||||
type: media.type,
|
||||
spotlight: tiles.spotlightTile!,
|
||||
pip: media.pip && tiles.gridTilesByMedia.get(media.pip),
|
||||
pipSize$,
|
||||
pipAlignment$,
|
||||
pip: pipVm && { vm: pipVm, ...pipTile },
|
||||
},
|
||||
tiles,
|
||||
];
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
type Alignment,
|
||||
type SpotlightExpandedLayout,
|
||||
type SpotlightExpandedLayoutMedia,
|
||||
} from "./layout-types";
|
||||
import { type TileStore } from "./TileStore";
|
||||
import { type FloatingTile } from "./FloatingTile";
|
||||
|
||||
/**
|
||||
* Produces an expanded spotlight layout with the given media.
|
||||
*/
|
||||
export function spotlightExpandedLayout(
|
||||
media: SpotlightExpandedLayoutMedia,
|
||||
pipAlignment$: BehaviorSubject<Alignment>,
|
||||
pipTile: FloatingTile,
|
||||
prevTiles: TileStore,
|
||||
): [SpotlightExpandedLayout, TileStore] {
|
||||
const update = prevTiles.from(1);
|
||||
@@ -27,12 +25,12 @@ export function spotlightExpandedLayout(
|
||||
if (media.pip !== undefined) update.registerPipTile(media.pip);
|
||||
const tiles = update.build();
|
||||
|
||||
const pipVm = tiles.gridTiles.at(0);
|
||||
return [
|
||||
{
|
||||
type: media.type,
|
||||
spotlight: tiles.spotlightTile!,
|
||||
pip: tiles.gridTiles.at(0),
|
||||
pipAlignment$,
|
||||
pip: pipVm && { vm: pipVm, ...pipTile },
|
||||
},
|
||||
tiles,
|
||||
];
|
||||
|
||||
@@ -6,33 +6,25 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, expect } from "vitest";
|
||||
import {
|
||||
type Alignment,
|
||||
layoutShallowEquals,
|
||||
type Layout,
|
||||
} from "./layout-types";
|
||||
import { layoutShallowEquals, type Layout } from "./layout-types";
|
||||
import {
|
||||
type SpotlightTileViewModel,
|
||||
type GridTileViewModel,
|
||||
} from "./TileViewModel";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type FloatingTile } from "./FloatingTile";
|
||||
|
||||
const spotlightTile = {} as unknown as SpotlightTileViewModel;
|
||||
const gridTile = {} as unknown as GridTileViewModel;
|
||||
const pipAlignment$ = new BehaviorSubject<Alignment>({
|
||||
inline: "end",
|
||||
block: "end",
|
||||
});
|
||||
const pipTile = {} as unknown as FloatingTile & { vm: GridTileViewModel };
|
||||
|
||||
const spotlightExpanded: Layout = {
|
||||
type: "spotlight-expanded",
|
||||
spotlight: spotlightTile,
|
||||
pipAlignment$,
|
||||
};
|
||||
|
||||
const spotlightPortrait: Layout = {
|
||||
type: "spotlight-portrait",
|
||||
spotlight: spotlightTile,
|
||||
spotlight: { vm: spotlightTile },
|
||||
grid: [gridTile],
|
||||
setVisibleTiles: () => {},
|
||||
};
|
||||
@@ -46,12 +38,12 @@ test("layoutShallowEquals detects a missing key", () => {
|
||||
expect(
|
||||
layoutShallowEquals(spotlightExpanded, {
|
||||
...spotlightExpanded,
|
||||
pip: gridTile,
|
||||
pip: pipTile,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
layoutShallowEquals(
|
||||
{ ...spotlightExpanded, pip: gridTile },
|
||||
{ ...spotlightExpanded, pip: pipTile },
|
||||
spotlightExpanded,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
+32
-13
@@ -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.
|
||||
*/
|
||||
|
||||
import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
|
||||
import { type MediaViewModel } from "./media/MediaViewModel.ts";
|
||||
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts";
|
||||
@@ -17,6 +15,7 @@ import {
|
||||
} from "./TileViewModel.ts";
|
||||
import { type Behavior } from "./Behavior.ts";
|
||||
import { shallowEquals as arrayShallowEquals } from "../utils/array.ts";
|
||||
import { type FloatingTile } from "./FloatingTile.ts";
|
||||
|
||||
export interface GridLayoutMedia {
|
||||
type: "grid";
|
||||
@@ -80,24 +79,48 @@ export interface Alignment {
|
||||
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 {
|
||||
type: "grid";
|
||||
spotlight?: SpotlightTileViewModel;
|
||||
spotlight?: FloatingTile & { vm: SpotlightTileViewModel };
|
||||
grid: GridTileViewModel[];
|
||||
spotlightAlignment$: BehaviorSubject<Alignment>;
|
||||
setVisibleTiles: (value: number) => void;
|
||||
}
|
||||
|
||||
export interface SpotlightLandscapeLayout {
|
||||
type: "spotlight-landscape";
|
||||
spotlight: SpotlightTileViewModel;
|
||||
spotlight: { vm: SpotlightTileViewModel };
|
||||
grid: GridTileViewModel[];
|
||||
setVisibleTiles: (value: number) => void;
|
||||
}
|
||||
|
||||
export interface SpotlightPortraitLayout {
|
||||
type: "spotlight-portrait";
|
||||
spotlight: SpotlightTileViewModel;
|
||||
spotlight: { vm: SpotlightTileViewModel };
|
||||
grid: GridTileViewModel[];
|
||||
setVisibleTiles: (value: number) => void;
|
||||
}
|
||||
@@ -105,23 +128,19 @@ export interface SpotlightPortraitLayout {
|
||||
export interface SpotlightExpandedLayout {
|
||||
type: "spotlight-expanded";
|
||||
spotlight: SpotlightTileViewModel;
|
||||
pip?: GridTileViewModel;
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
pip?: FloatingTile & { vm: GridTileViewModel };
|
||||
}
|
||||
|
||||
export interface OneOnOneDesktopLayout {
|
||||
type: "one-on-one-desktop";
|
||||
spotlight: GridTileViewModel;
|
||||
pip: GridTileViewModel;
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
pip: FloatingTile & { vm: GridTileViewModel };
|
||||
}
|
||||
|
||||
export interface OneOnOneMobileLayout {
|
||||
type: "one-on-one-mobile";
|
||||
spotlight: SpotlightTileViewModel;
|
||||
pip?: GridTileViewModel;
|
||||
pipSize$: Behavior<"sm" | "lg">;
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
pip?: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> };
|
||||
}
|
||||
|
||||
export interface PipLayout {
|
||||
|
||||
Reference in New Issue
Block a user