mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
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:
@@ -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) => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user