Add indicators to spotlight tile and make spotlight layout responsive

This commit is contained in:
Robin
2024-05-30 13:06:24 -04:00
parent 54c22f4ab2
commit ec1b020d4e
11 changed files with 495 additions and 356 deletions

View File

@@ -15,9 +15,10 @@ limitations under the License.
*/
import { BehaviorSubject, Observable } from "rxjs";
import { ComponentType } from "react";
import { MediaViewModel } from "../state/MediaViewModel";
import { LayoutSystem } from "./Grid";
import { LayoutProps } from "./Grid";
import { Alignment } from "../room/InCallView";
export interface Bounds {
@@ -36,15 +37,28 @@ export interface CallLayoutInputs {
floatingAlignment: BehaviorSubject<Alignment>;
}
export interface GridTileModel {
type: "grid";
vm: MediaViewModel;
}
export interface SpotlightTileModel {
type: "spotlight";
vms: MediaViewModel[];
maximised: boolean;
}
export type TileModel = GridTileModel | SpotlightTileModel;
export interface CallLayoutOutputs<Model> {
/**
* The visually fixed (non-scrolling) layer of the layout.
*/
fixed: LayoutSystem<Model, MediaViewModel[], HTMLDivElement>;
fixed: ComponentType<LayoutProps<Model, TileModel, HTMLDivElement>>;
/**
* The layer of the layout that can overflow and be scrolled.
*/
scrolling: LayoutSystem<Model, MediaViewModel, HTMLDivElement>;
scrolling: ComponentType<LayoutProps<Model, TileModel, HTMLDivElement>>;
}
/**

View File

@@ -42,6 +42,7 @@ import { useMergedRefs } from "../useMergedRefs";
import { TileWrapper } from "./TileWrapper";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { TileSpringUpdate } from "./LegacyGrid";
import { useInitial } from "../useInitial";
interface Rect {
x: number;
@@ -50,11 +51,14 @@ interface Rect {
height: number;
}
interface Tile<Model> extends Rect {
interface Tile<Model> {
id: string;
model: Model;
onDrag: DragCallback | undefined;
}
type PlacedTile<Model> = Tile<Model> & Rect;
interface TileSpring {
opacity: number;
scale: number;
@@ -73,24 +77,14 @@ interface DragState {
cursorY: number;
}
interface SlotProps extends ComponentProps<"div"> {
tile: string;
interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
id: string;
model: Model;
onDrag?: DragCallback;
style?: CSSProperties;
className?: string;
}
/**
* An invisible "slot" for a tile to go in.
*/
export const Slot: FC<SlotProps> = ({ tile, style, className, ...props }) => (
<div
className={classNames(className, styles.slot)}
data-tile={tile}
style={style}
{...props}
/>
);
interface Offset {
x: number;
y: number;
@@ -113,9 +107,13 @@ function offset(element: HTMLElement, relativeTo: Element): Offset {
}
}
export interface LayoutProps<Model, R extends HTMLElement> {
export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
ref: LegacyRef<R>;
model: Model;
model: LayoutModel;
/**
* Component creating an invisible "slot" for a tile to go in.
*/
Slot: ComponentType<SlotProps<TileModel>>;
}
export interface TileProps<Model, R extends HTMLElement> {
@@ -152,25 +150,7 @@ interface Drag {
yRatio: number;
}
type DragCallback = (drag: Drag) => void;
export interface LayoutSystem<LayoutModel, TileModel, R extends HTMLElement> {
/**
* Defines the ID and model of each tile present in the layout.
*/
tiles: (model: LayoutModel) => Map<string, TileModel>;
/**
* A component which creates an invisible layout grid of "slots" for tiles to
* go in. The root element must have a data-generation attribute which
* increments whenever the layout may have changed.
*/
Layout: ComponentType<LayoutProps<LayoutModel, R>>;
/**
* Gets a drag callback for the tile with the given ID. If this is not
* provided or it returns null, the tile is not draggable.
*/
onDrag?: (model: LayoutModel, tile: string) => DragCallback | null;
}
export type DragCallback = (drag: Drag) => void;
interface Props<
LayoutModel,
@@ -183,9 +163,11 @@ interface Props<
*/
model: LayoutModel;
/**
* The system by which to arrange the layout and respond to interactions.
* A component which creates an invisible layout grid of "slots" for tiles to
* go in. The root element must have a data-generation attribute which
* increments whenever the layout may have changed.
*/
system: LayoutSystem<LayoutModel, TileModel, LayoutRef>;
Layout: ComponentType<LayoutProps<LayoutModel, TileModel, LayoutRef>>;
/**
* The component used to render each tile in the layout.
*/
@@ -204,7 +186,7 @@ export function Grid<
TileRef extends HTMLElement,
>({
model,
system: { tiles: getTileModels, Layout, onDrag },
Layout,
Tile,
className,
style,
@@ -223,8 +205,31 @@ export function Grid<
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
const [generation, setGeneration] = useState<number | null>(null);
const tiles = useInitial(() => new Map<string, Tile<TileModel>>());
const prefersReducedMotion = usePrefersReducedMotion();
const Slot: FC<SlotProps<TileModel>> = useMemo(
() =>
function Slot({ id, model, onDrag, style, className, ...props }) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
tiles.set(id, { id, model, onDrag });
return (): void => void tiles.delete(id);
}, [id, model, onDrag]);
return (
<div
ref={ref}
className={classNames(className, styles.slot)}
data-id={id}
style={style}
{...props}
/>
);
},
[tiles],
);
const layoutRef = useCallback(
(e: HTMLElement | null) => {
setLayoutRoot(e);
@@ -247,62 +252,45 @@ export function Grid<
}
}, [layoutRoot, setGeneration]);
const slotRects = useMemo(() => {
const rects = new Map<string, Rect>();
// Combine the tile definitions and slots together to create placed tiles
const placedTiles = useMemo(() => {
const result: PlacedTile<TileModel>[] = [];
if (gridRoot !== null && layoutRoot !== null) {
const slots = layoutRoot.getElementsByClassName(
styles.slot,
) as HTMLCollectionOf<HTMLElement>;
for (const slot of slots)
rects.set(slot.getAttribute("data-tile")!, {
for (const slot of slots) {
const id = slot.getAttribute("data-id")!;
result.push({
...tiles.get(id)!,
...offset(slot, gridRoot),
width: slot.offsetWidth,
height: slot.offsetHeight,
});
}
}
return rects;
return result;
// The rects may change due to the grid updating to a new generation, but
// eslint can't statically verify this
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gridRoot, layoutRoot, generation]);
const tileModels = useMemo(
() => getTileModels(model),
[getTileModels, model],
);
// Combine the tile models and slots together to create placed tiles
const tiles = useMemo<Tile<TileModel>[]>(() => {
const items: Tile<TileModel>[] = [];
for (const [id, model] of tileModels) {
const rect = slotRects.get(id);
if (rect !== undefined) items.push({ id, model, ...rect });
}
return items;
}, [slotRects, tileModels]);
const dragCallbacks = useMemo(
() =>
new Map(
(function* (): Iterable<[string, DragCallback | null]> {
if (onDrag !== undefined)
for (const id of tileModels.keys()) yield [id, onDrag(model, id)];
})(),
),
[onDrag, tileModels, model],
);
}, [gridRoot, layoutRoot, tiles, generation]);
// Drag state is stored in a ref rather than component state, because we use
// react-spring's imperative API during gestures to improve responsiveness
const dragState = useRef<DragState | null>(null);
const [tileTransitions, springRef] = useTransition(
tiles,
placedTiles,
() => ({
key: ({ id }: Tile<TileModel>): string => id,
from: ({ x, y, width, height }: Tile<TileModel>): TileSpringUpdate => ({
from: ({
x,
y,
width,
height,
}: PlacedTile<TileModel>): TileSpringUpdate => ({
opacity: 0,
scale: 0,
zIndex: 1,
@@ -319,7 +307,7 @@ export function Grid<
y,
width,
height,
}: Tile<TileModel>): TileSpringUpdate | null =>
}: PlacedTile<TileModel>): TileSpringUpdate | null =>
id === dragState.current?.tileId
? null
: {
@@ -334,7 +322,7 @@ export function Grid<
}),
// react-spring's types are bugged and can't infer the spring type
) as unknown as [
TransitionFn<Tile<TileModel>, TileSpring>,
TransitionFn<PlacedTile<TileModel>, TileSpring>,
SpringRef<TileSpring>,
];
@@ -342,14 +330,14 @@ export function Grid<
// firing animations manually whenever the tiles array updates
useEffect(() => {
springRef.start();
}, [tiles, springRef]);
}, [placedTiles, springRef]);
const animateDraggedTile = (
endOfGesture: boolean,
callback: DragCallback,
): void => {
const { tileId, tileX, tileY } = dragState.current!;
const tile = tiles.find((t) => t.id === tileId)!;
const tile = placedTiles.find((t) => t.id === tileId)!;
springRef.current
.find((c) => (c.item as Tile<TileModel>).id === tileId)
@@ -416,7 +404,7 @@ export function Grid<
const tileController = springRef.current.find(
(c) => (c.item as Tile<TileModel>).id === tileId,
)!;
const callback = dragCallbacks.get(tileController.item.id);
const callback = tiles.get(tileController.item.id)!.onDrag;
if (callback != null) {
if (dragState.current === null) {
@@ -456,7 +444,7 @@ export function Grid<
if (dragState.current !== null) {
dragState.current.tileY += dy;
dragState.current.cursorY += dy;
animateDraggedTile(false, onDrag!(model, dragState.current.tileId)!);
animateDraggedTile(false, tiles.get(dragState.current.tileId)!.onDrag!);
}
},
{ target: gridRoot ?? undefined },
@@ -468,12 +456,12 @@ export function Grid<
className={classNames(className, styles.grid)}
style={style}
>
<Layout ref={layoutRef} model={model} />
{tileTransitions((spring, { id, model, width, height }) => (
<Layout ref={layoutRef} model={model} Slot={Slot} />
{tileTransitions((spring, { id, model, onDrag, width, height }) => (
<TileWrapper
key={id}
id={id}
onDrag={dragCallbacks.get(id) ? onTileDragRef : null}
onDrag={onDrag ? onTileDragRef : null}
targetWidth={width}
targetHeight={height}
model={model}

View File

@@ -34,6 +34,10 @@ limitations under the License.
height: var(--height);
}
.fixed {
position: relative;
}
.fixed > .slot {
position: absolute;
inline-size: 404px;

View File

@@ -14,16 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { CSSProperties, forwardRef, useMemo } from "react";
import { CSSProperties, forwardRef, useCallback, useMemo } from "react";
import { distinctUntilChanged } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import { GridLayout as GridLayoutModel } from "../state/CallViewModel";
import { Slot } from "./Grid";
import styles from "./GridLayout.module.css";
import { useReactiveState } from "../useReactiveState";
import { useInitial } from "../useInitial";
import { CallLayout } from "./CallLayout";
import { CallLayout, GridTileModel, TileModel } from "./CallLayout";
import { DragCallback } from "./Grid";
interface GridCSSProperties extends CSSProperties {
"--gap": string;
@@ -41,135 +41,144 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
}) => ({
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
// lives
fixed: {
tiles: (model) =>
new Map(
model.spotlight === undefined ? [] : [["spotlight", model.spotlight]],
),
Layout: forwardRef(function GridLayoutFixed({ model }, ref) {
const { width, height } = useObservableEagerState(minBounds);
const alignment = useObservableEagerState(
useInitial(() =>
floatingAlignment.pipe(
distinctUntilChanged(
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
),
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) {
const { width, height } = useObservableEagerState(minBounds);
const alignment = useObservableEagerState(
useInitial(() =>
floatingAlignment.pipe(
distinctUntilChanged(
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
),
),
);
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[model.spotlight === undefined, width, height, alignment],
);
return (
<div
ref={ref}
className={styles.fixed}
data-generation={generation}
style={{ height }}
>
{model.spotlight && (
<Slot
className={styles.slot}
tile="spotlight"
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
)}
</div>
);
}),
onDrag:
),
);
const tileModel: TileModel | undefined = useMemo(
() =>
model.spotlight && {
type: "spotlight",
vms: model.spotlight,
maximised: false,
},
[model.spotlight],
);
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[model.spotlight === undefined, width, height, alignment],
);
const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) =>
floatingAlignment.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
},
[],
);
return (
<div
ref={ref}
className={styles.fixed}
data-generation={generation}
style={{ height }}
>
{tileModel && (
<Slot
className={styles.slot}
id="spotlight"
model={tileModel}
onDrag={onDragSpotlight}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
)}
</div>
);
}),
// The scrolling part of the layout is where all the grid tiles live
scrolling: {
tiles: (model) => new Map(model.grid.map((tile) => [tile.id, tile])),
Layout: forwardRef(function GridLayout({ model }, ref) {
const { width, height: minHeight } = useObservableEagerState(minBounds);
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
const { width, height: minHeight } = useObservableEagerState(minBounds);
// The goal here is to determine the grid size and padding that maximizes
// use of screen space for n tiles without making those tiles too small or
// too cropped (having an extreme aspect ratio)
const [gap, slotWidth, slotHeight] = useMemo(() => {
const gap = width < 800 ? 16 : 20;
const slotMinWidth = width < 500 ? 150 : 180;
// The goal here is to determine the grid size and padding that maximizes
// use of screen space for n tiles without making those tiles too small or
// too cropped (having an extreme aspect ratio)
const [gap, slotWidth, slotHeight] = useMemo(() => {
const gap = width < 800 ? 16 : 20;
const slotMinWidth = width < 500 ? 150 : 180;
let columns = Math.min(
// Don't create more columns than we have items for
model.grid.length,
// The ideal number of columns is given by a packing of equally-sized
// squares into a grid.
// width / column = height / row.
// columns * rows = number of squares.
// ∴ columns = sqrt(width / height * number of squares).
// Except we actually want 16:9-ish slots rather than squares, so we
// divide the width-to-height ratio by the target aspect ratio.
Math.ceil(
Math.sqrt(
(width / minHeight / slotMaxAspectRatio) * model.grid.length,
),
let columns = Math.min(
// Don't create more columns than we have items for
model.grid.length,
// The ideal number of columns is given by a packing of equally-sized
// squares into a grid.
// width / column = height / row.
// columns * rows = number of squares.
// ∴ columns = sqrt(width / height * number of squares).
// Except we actually want 16:9-ish slots rather than squares, so we
// divide the width-to-height ratio by the target aspect ratio.
Math.ceil(
Math.sqrt(
(width / minHeight / slotMaxAspectRatio) * model.grid.length,
),
);
let rows = Math.ceil(model.grid.length / columns);
),
);
let rows = Math.ceil(model.grid.length / columns);
let slotWidth = (width - (columns - 1) * gap) / columns;
let slotHeight = (minHeight - (rows - 1) * gap) / rows;
let slotWidth = (width - (columns - 1) * gap) / columns;
let slotHeight = (minHeight - (rows - 1) * gap) / rows;
// Impose a minimum width and height on the slots
if (slotWidth < slotMinWidth) {
// In this case we want the slot width to determine the number of columns,
// not the other way around. If we take the above equation for the slot
// width (w = (W - (c - 1) * g) / c) and solve for c, we get
// c = (W + g) / (w + g).
columns = Math.floor((width + gap) / (slotMinWidth + gap));
rows = Math.ceil(model.grid.length / columns);
slotWidth = (width - (columns - 1) * gap) / columns;
slotHeight = (minHeight - (rows - 1) * gap) / rows;
// Impose a minimum width and height on the slots
if (slotWidth < slotMinWidth) {
// In this case we want the slot width to determine the number of columns,
// not the other way around. If we take the above equation for the slot
// width (w = (W - (c - 1) * g) / c) and solve for c, we get
// c = (W + g) / (w + g).
columns = Math.floor((width + gap) / (slotMinWidth + gap));
rows = Math.ceil(model.grid.length / columns);
slotWidth = (width - (columns - 1) * gap) / columns;
slotHeight = (minHeight - (rows - 1) * gap) / rows;
}
if (slotHeight < slotMinHeight) slotHeight = slotMinHeight;
// Impose a minimum and maximum aspect ratio on the slots
const slotAspectRatio = slotWidth / slotHeight;
if (slotAspectRatio > slotMaxAspectRatio)
slotWidth = slotHeight * slotMaxAspectRatio;
else if (slotAspectRatio < slotMinAspectRatio)
slotHeight = slotWidth / slotMinAspectRatio;
// TODO: We might now be hitting the minimum height or width limit again
return [gap, slotWidth, slotHeight];
}, [width, minHeight, model.grid.length]);
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[model.grid, width, minHeight],
);
const tileModels: GridTileModel[] = useMemo(
() => model.grid.map((vm) => ({ type: "grid", vm })),
[model.grid],
);
return (
<div
ref={ref}
data-generation={generation}
className={styles.scrolling}
style={
{
width,
"--gap": `${gap}px`,
"--width": `${Math.floor(slotWidth)}px`,
"--height": `${Math.floor(slotHeight)}px`,
} as GridCSSProperties
}
if (slotHeight < slotMinHeight) slotHeight = slotMinHeight;
// Impose a minimum and maximum aspect ratio on the slots
const slotAspectRatio = slotWidth / slotHeight;
if (slotAspectRatio > slotMaxAspectRatio)
slotWidth = slotHeight * slotMaxAspectRatio;
else if (slotAspectRatio < slotMinAspectRatio)
slotHeight = slotWidth / slotMinAspectRatio;
// TODO: We might now be hitting the minimum height or width limit again
return [gap, slotWidth, slotHeight];
}, [width, minHeight, model.grid.length]);
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[model.grid, width, minHeight],
);
return (
<div
ref={ref}
data-generation={generation}
className={styles.scrolling}
style={
{
width,
"--gap": `${gap}px`,
"--width": `${Math.floor(slotWidth)}px`,
"--height": `${Math.floor(slotHeight)}px`,
} as GridCSSProperties
}
>
{model.grid.map((tile) => (
<Slot className={styles.slot} tile={tile.id} />
))}
</div>
);
}),
},
>
{tileModels.map((m) => (
<Slot key={m.vm.id} className={styles.slot} id={m.vm.id} model={m} />
))}
</div>
);
}),
});

View File

@@ -14,18 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.fixed,
.scrolling {
.layer {
margin-inline: var(--inline-content-inset);
display: grid;
--grid-slot-width: 180px;
--grid-gap: 20px;
gap: 30px;
}
.layer[data-orientation="landscape"] {
--grid-slot-width: 180px;
grid-template-columns: 1fr calc(
var(--grid-columns) * var(--grid-slot-width) + (var(--grid-columns) - 1) *
var(--grid-gap)
);
grid-template-rows: minmax(1fr, auto);
gap: 30px;
}
.scrolling {
@@ -41,7 +43,7 @@ limitations under the License.
/* CSS makes us put a condition here, even though all we want to do is
unconditionally select the container so we can use cq units */
@container spotlight (width > 0) {
.spotlight > .slot {
.layer[data-orientation="landscape"] > .spotlight > .slot {
inline-size: min(100cqi, 100cqb * (17 / 9));
block-size: min(100cqb, 100cqi / (4 / 3));
}
@@ -52,38 +54,48 @@ unconditionally select the container so we can use cq units */
flex-wrap: wrap;
gap: var(--grid-gap);
justify-content: center;
}
.layer[data-orientation="landscape"] > .grid {
align-content: center;
}
.grid > .slot {
.layer > .grid > .slot {
inline-size: var(--grid-slot-width);
}
.layer[data-orientation="landscape"] > .grid > .slot {
block-size: 135px;
}
@media (max-width: 600px) {
.fixed,
.scrolling {
margin-inline: 0;
display: block;
}
.spotlight {
inline-size: 100%;
aspect-ratio: 16 / 9;
margin-block-end: var(--cpd-space-4x);
}
.grid {
margin-inline: var(--inline-content-inset);
align-content: start;
}
.grid > .slot {
--grid-columns: 2;
--grid-slot-width: calc(
(100% - (var(--grid-columns) - 1) * var(--grid-gap)) / var(--grid-columns)
);
block-size: unset;
aspect-ratio: 4 / 3;
}
.layer[data-orientation="portrait"] {
margin-inline: 0;
display: block;
}
.layer[data-orientation="portrait"] > .spotlight {
inline-size: 100%;
aspect-ratio: 16 / 9;
margin-block-end: var(--cpd-space-4x);
}
.layer[data-orientation="portrait"] > .spotlight.withIndicators {
margin-block-end: calc(2 * var(--cpd-space-4x) + 2px);
}
.layer[data-orientation="portrait"] > .spotlight > .slot {
inline-size: 100%;
block-size: 100%;
}
.layer[data-orientation="portrait"] > .grid {
margin-inline: var(--inline-content-inset);
align-content: start;
}
.layer[data-orientation="portrait"] > .grid > .slot {
--grid-slot-width: calc(
(100% - (var(--grid-columns) - 1) * var(--grid-gap)) / var(--grid-columns)
);
aspect-ratio: 4 / 3;
}

View File

@@ -14,76 +14,113 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { CSSProperties, forwardRef } from "react";
import { CSSProperties, forwardRef, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
import { CallLayout } from "./CallLayout";
import { CallLayout, GridTileModel, TileModel } from "./CallLayout";
import { SpotlightLayout as SpotlightLayoutModel } from "../state/CallViewModel";
import { useReactiveState } from "../useReactiveState";
import styles from "./SpotlightLayout.module.css";
import { Slot } from "./Grid";
import { useReactiveState } from "../useReactiveState";
interface GridCSSProperties extends CSSProperties {
"--grid-columns": number;
}
const getGridColumns = (gridLength: number): number =>
gridLength > 20 ? 2 : 1;
interface Layout {
orientation: "portrait" | "landscape";
gridColumns: number;
}
function getLayout(gridLength: number, width: number): Layout {
const orientation = width < 800 ? "portrait" : "landscape";
return {
orientation,
gridColumns:
orientation === "portrait"
? Math.floor(width / 190)
: gridLength > 20
? 2
: 1,
};
}
export const makeSpotlightLayout: CallLayout<SpotlightLayoutModel> = ({
minBounds,
}) => ({
fixed: {
tiles: (model) => new Map([["spotlight", model.spotlight]]),
Layout: forwardRef(function SpotlightLayoutFixed({ model }, ref) {
const { width, height } = useObservableEagerState(minBounds);
const gridColumns = getGridColumns(model.grid.length);
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[model.grid.length, width, height],
);
fixed: forwardRef(function SpotlightLayoutFixed({ model, Slot }, ref) {
const { width, height } = useObservableEagerState(minBounds);
const layout = getLayout(model.grid.length, width);
const tileModel: TileModel = useMemo(
() => ({
type: "spotlight",
vms: model.spotlight,
maximised: layout.orientation === "portrait",
}),
[model.spotlight, layout.orientation],
);
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[model.grid.length, width, height],
);
return (
<div
ref={ref}
data-generation={generation}
className={styles.fixed}
style={{ "--grid-columns": gridColumns, height } as GridCSSProperties}
>
<div className={styles.spotlight}>
<Slot className={styles.slot} tile="spotlight" />
</div>
<div className={styles.grid} />
return (
<div
ref={ref}
data-generation={generation}
data-orientation={layout.orientation}
className={classNames(styles.layer, styles.fixed)}
style={
{ "--grid-columns": layout.gridColumns, height } as GridCSSProperties
}
>
<div className={styles.spotlight}>
<Slot className={styles.slot} id="spotlight" model={tileModel} />
</div>
);
}),
},
<div className={styles.grid} />
</div>
);
}),
scrolling: {
tiles: (model) => new Map(model.grid.map((tile) => [tile.id, tile])),
Layout: forwardRef(function SpotlightLayoutScrolling({ model }, ref) {
const { width, height } = useObservableEagerState(minBounds);
const gridColumns = getGridColumns(model.grid.length);
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[model.grid, width, height],
);
scrolling: forwardRef(function SpotlightLayoutScrolling(
{ model, Slot },
ref,
) {
const { width, height } = useObservableEagerState(minBounds);
const layout = getLayout(model.grid.length, width);
const tileModels: GridTileModel[] = useMemo(
() => model.grid.map((vm) => ({ type: "grid", vm })),
[model.grid],
);
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[model.spotlight.length, model.grid, width, height],
);
return (
return (
<div
ref={ref}
data-generation={generation}
data-orientation={layout.orientation}
className={classNames(styles.layer, styles.scrolling)}
style={{ "--grid-columns": layout.gridColumns } as GridCSSProperties}
>
<div
ref={ref}
data-generation={generation}
className={styles.scrolling}
style={{ "--grid-columns": gridColumns } as GridCSSProperties}
>
<div className={styles.spotlight} />
<div className={styles.grid}>
{model.grid.map((tile) => (
<Slot key={tile.id} className={styles.slot} tile={tile.id} />
))}
</div>
className={classNames(styles.spotlight, {
[styles.withIndicators]: model.spotlight.length > 1,
})}
/>
<div className={styles.grid}>
{tileModels.map((m) => (
<Slot
key={m.vm.id}
className={styles.slot}
id={m.vm.id}
model={m}
/>
))}
</div>
);
}),
},
</div>
);
}),
});

View File

@@ -142,3 +142,13 @@ limitations under the License.
inline-size: 100%;
align-self: center;
}
.tile {
position: absolute;
inset-block-start: 0;
}
.tile.maximised {
position: relative;
flex-grow: 1;
}

View File

@@ -25,6 +25,7 @@ import { ConnectionState, Room, Track } from "livekit-client";
import { MatrixClient } from "matrix-js-sdk/src/client";
import {
FC,
PropsWithoutRef,
forwardRef,
useCallback,
useEffect,
@@ -86,7 +87,7 @@ import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType";
import { makeGridLayout } from "../grid/GridLayout";
import { makeSpotlightLayout } from "../grid/SpotlightLayout";
import { CallLayout } from "../grid/CallLayout";
import { CallLayout, GridTileModel, TileModel } from "../grid/CallLayout";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -329,7 +330,10 @@ export const InCallView: FC<InCallViewProps> = ({
vm.layout.pipe(
map((l) => {
let makeLayout: CallLayout<Layout>;
if (l.type === "grid" && l.grid.length !== 2)
if (
l.type === "grid" &&
!(l.grid.length === 2 && l.spotlight === undefined)
)
makeLayout = makeGridLayout as CallLayout<Layout>;
else if (l.type === "spotlight")
makeLayout = makeSpotlightLayout as CallLayout<Layout>;
@@ -352,59 +356,79 @@ export const InCallView: FC<InCallViewProps> = ({
[setLegacyLayout, vm],
);
const showSpeakingIndicators =
const showSpotlightIndicators = useObservable(layout.type === "spotlight");
const showSpeakingIndicators = useObservable(
layout.type === "spotlight" ||
(layout.type === "grid" && layout.grid.length > 2);
const SpotlightTileView = useMemo(
() =>
forwardRef<HTMLDivElement, TileProps<MediaViewModel[], HTMLDivElement>>(
function SpotlightTileView(
{ className, style, targetWidth, targetHeight, model },
ref,
) {
return (
<SpotlightTile
ref={ref}
vms={model}
maximised={false}
fullscreen={false}
onToggleFullscreen={toggleSpotlightFullscreen}
targetWidth={targetWidth}
targetHeight={targetHeight}
className={className}
style={style}
/>
);
},
),
[toggleSpotlightFullscreen],
(layout.type === "grid" && layout.grid.length > 2),
);
const GridTileView = useMemo(
const Tile = useMemo(
() =>
forwardRef<HTMLDivElement, TileProps<MediaViewModel, HTMLDivElement>>(
function GridTileView(
{ className, style, targetWidth, targetHeight, model },
ref,
) {
return (
<GridTile
ref={ref}
vm={model}
maximised={false}
fullscreen={false}
onToggleFullscreen={toggleFullscreen}
onOpenProfile={openProfile}
targetWidth={targetWidth}
targetHeight={targetHeight}
className={className}
style={style}
showSpeakingIndicators={showSpeakingIndicators}
/>
);
},
),
[toggleFullscreen, openProfile, showSpeakingIndicators],
forwardRef<
HTMLDivElement,
PropsWithoutRef<TileProps<TileModel, HTMLDivElement>>
>(function Tile(
{ className, style, targetWidth, targetHeight, model },
ref,
) {
const showSpeakingIndicatorsValue = useObservableEagerState(
showSpeakingIndicators,
);
const showSpotlightIndicatorsValue = useObservableEagerState(
showSpotlightIndicators,
);
return model.type === "grid" ? (
<GridTile
ref={ref}
vm={model.vm}
maximised={false}
fullscreen={false}
onToggleFullscreen={toggleFullscreen}
onOpenProfile={openProfile}
targetWidth={targetWidth}
targetHeight={targetHeight}
className={classNames(className, styles.tile)}
style={style}
showSpeakingIndicators={showSpeakingIndicatorsValue}
/>
) : (
<SpotlightTile
ref={ref}
vms={model.vms}
maximised={model.maximised}
fullscreen={false}
onToggleFullscreen={toggleSpotlightFullscreen}
targetWidth={targetWidth}
targetHeight={targetHeight}
showIndicators={showSpotlightIndicatorsValue}
className={classNames(className, styles.tile)}
style={style}
/>
);
}),
[
toggleFullscreen,
toggleSpotlightFullscreen,
openProfile,
showSpeakingIndicators,
showSpotlightIndicators,
],
);
const LegacyTile = useMemo(
() =>
forwardRef<
HTMLDivElement,
PropsWithoutRef<TileProps<MediaViewModel, HTMLDivElement>>
>(function LegacyTile({ model: legacyModel, ...props }, ref) {
const model: GridTileModel = useMemo(
() => ({ type: "grid", vm: legacyModel }),
[legacyModel],
);
return <Tile ref={ref} model={model} {...props} />;
}),
[Tile],
);
const renderContent = (): JSX.Element => {
@@ -421,17 +445,20 @@ export const InCallView: FC<InCallViewProps> = ({
if (maximisedParticipant.id === "spotlight") {
return (
<SpotlightTile
className={classNames(styles.tile, styles.maximised)}
vms={layout.spotlight!}
maximised={true}
maximised
fullscreen={fullscreen}
onToggleFullscreen={toggleSpotlightFullscreen}
targetWidth={gridBounds.height}
targetHeight={gridBounds.width}
showIndicators={false}
/>
);
}
return (
<GridTile
className={classNames(styles.tile, styles.maximised)}
vm={maximisedParticipant.data}
maximised={true}
fullscreen={fullscreen}
@@ -453,7 +480,7 @@ export const InCallView: FC<InCallViewProps> = ({
items={items}
layout={legacyLayout}
disableAnimations={prefersReducedMotion}
Tile={GridTileView}
Tile={LegacyTile}
/>
);
} else {
@@ -462,15 +489,15 @@ export const InCallView: FC<InCallViewProps> = ({
<Grid
className={styles.scrollingGrid}
model={layout}
system={layoutSystem.scrolling}
Tile={GridTileView}
Layout={layoutSystem.scrolling}
Tile={Tile}
/>
<Grid
className={styles.fixedGrid}
style={{ insetBlockStart: headerBounds.bottom }}
model={layout}
system={layoutSystem.fixed}
Tile={SpotlightTileView}
Layout={layoutSystem.fixed}
Tile={Tile}
/>
</>
);

View File

@@ -15,8 +15,6 @@ limitations under the License.
*/
.tile {
position: absolute;
top: 0;
--media-view-border-radius: var(--cpd-space-4x);
transition: outline-color ease 0.15s;
outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0);
@@ -62,8 +60,6 @@ borders don't support gradients */
}
.tile[data-maximised="true"] {
position: relative;
flex-grow: 1;
--media-view-border-radius: 0;
--media-view-fg-inset: 10px;
}

View File

@@ -15,14 +15,10 @@ limitations under the License.
*/
.tile {
position: absolute;
top: 0;
--border-width: var(--cpd-space-3x);
}
.tile.maximised {
position: relative;
flex-grow: 1;
--border-width: 0px;
}
@@ -54,14 +50,14 @@ limitations under the License.
border-radius: 0;
}
.item {
.contents > .item {
height: 100%;
flex-basis: 100%;
flex-shrink: 0;
--media-view-fg-inset: 10px;
}
.item.snap {
.contents > .item.snap {
scroll-snap-align: start;
}
@@ -151,3 +147,38 @@ limitations under the License.
.tile:has(:focus-visible) > button {
opacity: 1;
}
.indicators {
display: flex;
gap: var(--cpd-space-2x);
position: absolute;
inset-inline-start: 0;
inset-block-end: calc(-1 * var(--cpd-space-6x));
width: 100%;
justify-content: start;
transition: opacity ease 0.15s;
opacity: 0;
}
.indicators.show {
opacity: 1;
}
.maximised .indicators {
inset-block-end: calc(-1 * var(--cpd-space-4x) - 2px);
justify-content: center;
}
.indicators > .item {
inline-size: 32px;
block-size: 2px;
transition: background-color ease 0.15s;
}
.indicators > .item[data-visible="false"] {
background: var(--cpd-color-alpha-gray-600);
}
.indicators > .item[data-visible="true"] {
background: var(--cpd-color-gray-1400);
}

View File

@@ -178,6 +178,7 @@ interface Props {
onToggleFullscreen: () => void;
targetWidth: number;
targetHeight: number;
showIndicators: boolean;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
}
@@ -191,6 +192,7 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
onToggleFullscreen,
targetWidth,
targetHeight,
showIndicators,
className,
style,
},
@@ -307,6 +309,15 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
<ChevronRightIcon aria-hidden width={24} height={24} />
</button>
)}
<div
className={classNames(styles.indicators, {
[styles.show]: showIndicators && vms.length > 1,
})}
>
{vms.map((vm) => (
<div className={styles.item} data-visible={vm.id === visibleId} />
))}
</div>
</animated.div>
);
},