Merge pull request #2382 from robintown/spotlight-layout

New spotlight layout
This commit is contained in:
Robin
2024-07-18 08:50:31 -04:00
committed by GitHub
13 changed files with 876 additions and 394 deletions

69
src/grid/CallLayout.ts Normal file
View File

@@ -0,0 +1,69 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { BehaviorSubject, Observable } from "rxjs";
import { ComponentType } from "react";
import { MediaViewModel } from "../state/MediaViewModel";
import { LayoutProps } from "./Grid";
import { Alignment } from "../room/InCallView";
export interface Bounds {
width: number;
height: number;
}
export interface CallLayoutInputs {
/**
* The minimum bounds of the layout area.
*/
minBounds: Observable<Bounds>;
/**
* The alignment of the floating tile, if any.
*/
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: ComponentType<LayoutProps<Model, TileModel, HTMLDivElement>>;
/**
* The layer of the layout that can overflow and be scrolled.
*/
scrolling: ComponentType<LayoutProps<Model, TileModel, HTMLDivElement>>;
}
/**
* A layout system for media tiles.
*/
export type CallLayout<Model> = (
inputs: CallLayoutInputs,
) => CallLayoutOutputs<Model>;

View File

@@ -42,6 +42,7 @@ import { useMergedRefs } from "../useMergedRefs";
import { TileWrapper } from "./TileWrapper"; import { TileWrapper } from "./TileWrapper";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion"; import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { TileSpringUpdate } from "./LegacyGrid"; import { TileSpringUpdate } from "./LegacyGrid";
import { useInitial } from "../useInitial";
interface Rect { interface Rect {
x: number; x: number;
@@ -50,11 +51,14 @@ interface Rect {
height: number; height: number;
} }
interface Tile<Model> extends Rect { interface Tile<Model> {
id: string; id: string;
model: Model; model: Model;
onDrag: DragCallback | undefined;
} }
type PlacedTile<Model> = Tile<Model> & Rect;
interface TileSpring { interface TileSpring {
opacity: number; opacity: number;
scale: number; scale: number;
@@ -73,27 +77,43 @@ interface DragState {
cursorY: number; cursorY: number;
} }
interface SlotProps extends ComponentProps<"div"> { interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
tile: string; id: string;
model: Model;
onDrag?: DragCallback;
style?: CSSProperties; style?: CSSProperties;
className?: string; className?: string;
} }
/** interface Offset {
* An invisible "slot" for a tile to go in. x: number;
*/ y: number;
export const Slot: FC<SlotProps> = ({ tile, style, className, ...props }) => ( }
<div
className={classNames(className, styles.slot)}
data-tile={tile}
style={style}
{...props}
/>
);
export interface LayoutProps<Model, R extends HTMLElement> { /**
* Gets the offset of one element relative to an ancestor.
*/
function offset(element: HTMLElement, relativeTo: Element): Offset {
if (
!(element.offsetParent instanceof HTMLElement) ||
element.offsetParent === relativeTo
) {
return { x: element.offsetLeft, y: element.offsetTop };
} else {
const o = offset(element.offsetParent, relativeTo);
o.x += element.offsetLeft;
o.y += element.offsetTop;
return o;
}
}
export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
ref: LegacyRef<R>; 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> { export interface TileProps<Model, R extends HTMLElement> {
@@ -130,25 +150,7 @@ interface Drag {
yRatio: number; yRatio: number;
} }
type DragCallback = (drag: Drag) => void; export 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;
}
interface Props< interface Props<
LayoutModel, LayoutModel,
@@ -161,9 +163,11 @@ interface Props<
*/ */
model: LayoutModel; 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. * The component used to render each tile in the layout.
*/ */
@@ -182,7 +186,7 @@ export function Grid<
TileRef extends HTMLElement, TileRef extends HTMLElement,
>({ >({
model, model,
system: { tiles: getTileModels, Layout, onDrag }, Layout,
Tile, Tile,
className, className,
style, style,
@@ -201,8 +205,31 @@ export function Grid<
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null); const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
const [generation, setGeneration] = useState<number | null>(null); const [generation, setGeneration] = useState<number | null>(null);
const tiles = useInitial(() => new Map<string, Tile<TileModel>>());
const prefersReducedMotion = usePrefersReducedMotion(); 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( const layoutRef = useCallback(
(e: HTMLElement | null) => { (e: HTMLElement | null) => {
setLayoutRoot(e); setLayoutRoot(e);
@@ -225,63 +252,45 @@ export function Grid<
} }
}, [layoutRoot, setGeneration]); }, [layoutRoot, setGeneration]);
const slotRects = useMemo(() => { // Combine the tile definitions and slots together to create placed tiles
const rects = new Map<string, Rect>(); const placedTiles = useMemo(() => {
const result: PlacedTile<TileModel>[] = [];
if (layoutRoot !== null) { if (gridRoot !== null && layoutRoot !== null) {
const slots = layoutRoot.getElementsByClassName( const slots = layoutRoot.getElementsByClassName(
styles.slot, styles.slot,
) as HTMLCollectionOf<HTMLElement>; ) as HTMLCollectionOf<HTMLElement>;
for (const slot of slots) for (const slot of slots) {
rects.set(slot.getAttribute("data-tile")!, { const id = slot.getAttribute("data-id")!;
x: slot.offsetLeft, result.push({
y: slot.offsetTop, ...tiles.get(id)!,
...offset(slot, gridRoot),
width: slot.offsetWidth, width: slot.offsetWidth,
height: slot.offsetHeight, height: slot.offsetHeight,
}); });
}
} }
return rects; return result;
// The rects may change due to the grid being resized or rerendered, but // The rects may change due to the grid updating to a new generation, but
// eslint can't statically verify this // eslint can't statically verify this
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutRoot, generation]); }, [gridRoot, layoutRoot, tiles, 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],
);
// Drag state is stored in a ref rather than component state, because we use // Drag state is stored in a ref rather than component state, because we use
// react-spring's imperative API during gestures to improve responsiveness // react-spring's imperative API during gestures to improve responsiveness
const dragState = useRef<DragState | null>(null); const dragState = useRef<DragState | null>(null);
const [tileTransitions, springRef] = useTransition( const [tileTransitions, springRef] = useTransition(
tiles, placedTiles,
() => ({ () => ({
key: ({ id }: Tile<TileModel>): string => id, key: ({ id }: Tile<TileModel>): string => id,
from: ({ x, y, width, height }: Tile<TileModel>): TileSpringUpdate => ({ from: ({
x,
y,
width,
height,
}: PlacedTile<TileModel>): TileSpringUpdate => ({
opacity: 0, opacity: 0,
scale: 0, scale: 0,
zIndex: 1, zIndex: 1,
@@ -298,7 +307,7 @@ export function Grid<
y, y,
width, width,
height, height,
}: Tile<TileModel>): TileSpringUpdate | null => }: PlacedTile<TileModel>): TileSpringUpdate | null =>
id === dragState.current?.tileId id === dragState.current?.tileId
? null ? null
: { : {
@@ -313,7 +322,7 @@ export function Grid<
}), }),
// react-spring's types are bugged and can't infer the spring type // react-spring's types are bugged and can't infer the spring type
) as unknown as [ ) as unknown as [
TransitionFn<Tile<TileModel>, TileSpring>, TransitionFn<PlacedTile<TileModel>, TileSpring>,
SpringRef<TileSpring>, SpringRef<TileSpring>,
]; ];
@@ -321,14 +330,14 @@ export function Grid<
// firing animations manually whenever the tiles array updates // firing animations manually whenever the tiles array updates
useEffect(() => { useEffect(() => {
springRef.start(); springRef.start();
}, [tiles, springRef]); }, [placedTiles, springRef]);
const animateDraggedTile = ( const animateDraggedTile = (
endOfGesture: boolean, endOfGesture: boolean,
callback: DragCallback, callback: DragCallback,
): void => { ): void => {
const { tileId, tileX, tileY } = dragState.current!; const { tileId, tileX, tileY } = dragState.current!;
const tile = tiles.find((t) => t.id === tileId)!; const tile = placedTiles.find((t) => t.id === tileId)!;
springRef.current springRef.current
.find((c) => (c.item as Tile<TileModel>).id === tileId) .find((c) => (c.item as Tile<TileModel>).id === tileId)
@@ -395,7 +404,7 @@ export function Grid<
const tileController = springRef.current.find( const tileController = springRef.current.find(
(c) => (c.item as Tile<TileModel>).id === tileId, (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 (callback != null) {
if (dragState.current === null) { if (dragState.current === null) {
@@ -435,7 +444,7 @@ export function Grid<
if (dragState.current !== null) { if (dragState.current !== null) {
dragState.current.tileY += dy; dragState.current.tileY += dy;
dragState.current.cursorY += dy; dragState.current.cursorY += dy;
animateDraggedTile(false, onDrag!(model, dragState.current.tileId)!); animateDraggedTile(false, tiles.get(dragState.current.tileId)!.onDrag!);
} }
}, },
{ target: gridRoot ?? undefined }, { target: gridRoot ?? undefined },
@@ -447,12 +456,12 @@ export function Grid<
className={classNames(className, styles.grid)} className={classNames(className, styles.grid)}
style={style} style={style}
> >
<Layout ref={layoutRef} model={model} /> <Layout ref={layoutRef} model={model} Slot={Slot} />
{tileTransitions((spring, { id, model, width, height }) => ( {tileTransitions((spring, { id, model, onDrag, width, height }) => (
<TileWrapper <TileWrapper
key={id} key={id}
id={id} id={id}
onDrag={dragCallbacks.get(id) ? onTileDragRef : null} onDrag={onDrag ? onTileDragRef : null}
targetWidth={width} targetWidth={width}
targetHeight={height} targetHeight={height}
model={model} model={model}

View File

@@ -14,6 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.fixed,
.scrolling {
margin-inline: var(--inline-content-inset);
}
.scrolling { .scrolling {
box-sizing: border-box; box-sizing: border-box;
block-size: 100%; block-size: 100%;
@@ -22,7 +27,6 @@ limitations under the License.
justify-content: center; justify-content: center;
align-content: center; align-content: center;
gap: var(--gap); gap: var(--gap);
box-sizing: border-box;
} }
.scrolling > .slot { .scrolling > .slot {
@@ -30,6 +34,10 @@ limitations under the License.
height: var(--height); height: var(--height);
} }
.fixed {
position: relative;
}
.fixed > .slot { .fixed > .slot {
position: absolute; position: absolute;
inline-size: 404px; inline-size: 404px;

View File

@@ -14,22 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { CSSProperties, forwardRef, useMemo } from "react"; import { CSSProperties, forwardRef, useCallback, useMemo } from "react";
import { BehaviorSubject, Observable, distinctUntilChanged } from "rxjs"; import { distinctUntilChanged } from "rxjs";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import { GridLayout as GridLayoutModel } from "../state/CallViewModel"; import { GridLayout as GridLayoutModel } from "../state/CallViewModel";
import { MediaViewModel } from "../state/MediaViewModel";
import { LayoutSystem, Slot } from "./Grid";
import styles from "./GridLayout.module.css"; import styles from "./GridLayout.module.css";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
import { Alignment } from "../room/InCallView";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
import { CallLayout, GridTileModel, TileModel } from "./CallLayout";
export interface Bounds { import { DragCallback } from "./Grid";
width: number;
height: number;
}
interface GridCSSProperties extends CSSProperties { interface GridCSSProperties extends CSSProperties {
"--gap": string; "--gap": string;
@@ -37,150 +31,154 @@ interface GridCSSProperties extends CSSProperties {
"--height": string; "--height": string;
} }
interface GridLayoutSystems {
scrolling: LayoutSystem<GridLayoutModel, MediaViewModel, HTMLDivElement>;
fixed: LayoutSystem<GridLayoutModel, MediaViewModel[], HTMLDivElement>;
}
const slotMinHeight = 130; const slotMinHeight = 130;
const slotMaxAspectRatio = 17 / 9; const slotMaxAspectRatio = 17 / 9;
const slotMinAspectRatio = 4 / 3; const slotMinAspectRatio = 4 / 3;
export const gridLayoutSystems = ( export const makeGridLayout: CallLayout<GridLayoutModel> = ({
minBounds: Observable<Bounds>, minBounds,
floatingAlignment: BehaviorSubject<Alignment>, floatingAlignment,
): GridLayoutSystems => ({ }) => ({
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile // The "fixed" (non-scrolling) part of the layout is where the spotlight tile
// lives // lives
fixed: { fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) {
tiles: (model) => const { width, height } = useObservableEagerState(minBounds);
new Map( const alignment = useObservableEagerState(
model.spotlight === undefined ? [] : [["spotlight", model.spotlight]], useInitial(() =>
), floatingAlignment.pipe(
Layout: forwardRef(function GridLayoutFixed({ model }, ref) { distinctUntilChanged(
const { width, height } = useObservableEagerState(minBounds); (a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
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), const tileModel: TileModel | undefined = useMemo(
[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:
() => () =>
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 }) => ({ xRatio, yRatio }) =>
floatingAlignment.next({ floatingAlignment.next({
block: yRatio < 0.5 ? "start" : "end", block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 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 // The scrolling part of the layout is where all the grid tiles live
scrolling: { scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
tiles: (model) => new Map(model.grid.map((tile) => [tile.id, tile])), const { width, height: minHeight } = useObservableEagerState(minBounds);
Layout: forwardRef(function GridLayout({ model }, ref) {
const { width, height: minHeight } = useObservableEagerState(minBounds);
// The goal here is to determine the grid size and padding that maximizes // 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 // use of screen space for n tiles without making those tiles too small or
// too cropped (having an extreme aspect ratio) // too cropped (having an extreme aspect ratio)
const [gap, slotWidth, slotHeight] = useMemo(() => { const [gap, slotWidth, slotHeight] = useMemo(() => {
const gap = width < 800 ? 16 : 20; const gap = width < 800 ? 16 : 20;
const slotMinWidth = width < 500 ? 150 : 180; const slotMinWidth = width < 500 ? 150 : 180;
let columns = Math.min( let columns = Math.min(
// Don't create more columns than we have items for // Don't create more columns than we have items for
model.grid.length, model.grid.length,
// The ideal number of columns is given by a packing of equally-sized // The ideal number of columns is given by a packing of equally-sized
// squares into a grid. // squares into a grid.
// width / column = height / row. // width / column = height / row.
// columns * rows = number of squares. // columns * rows = number of squares.
// ∴ columns = sqrt(width / height * number of squares). // ∴ columns = sqrt(width / height * number of squares).
// Except we actually want 16:9-ish slots rather than squares, so we // Except we actually want 16:9-ish slots rather than squares, so we
// divide the width-to-height ratio by the target aspect ratio. // divide the width-to-height ratio by the target aspect ratio.
Math.ceil( Math.ceil(
Math.sqrt( Math.sqrt(
(width / minHeight / slotMaxAspectRatio) * model.grid.length, (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 slotWidth = (width - (columns - 1) * gap) / columns;
let slotHeight = (minHeight - (rows - 1) * gap) / rows; let slotHeight = (minHeight - (rows - 1) * gap) / rows;
// Impose a minimum width and height on the slots // Impose a minimum width and height on the slots
if (slotWidth < slotMinWidth) { if (slotWidth < slotMinWidth) {
// In this case we want the slot width to determine the number of columns, // 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 // 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 // width (w = (W - (c - 1) * g) / c) and solve for c, we get
// c = (W + g) / (w + g). // c = (W + g) / (w + g).
columns = Math.floor((width + gap) / (slotMinWidth + gap)); columns = Math.floor((width + gap) / (slotMinWidth + gap));
rows = Math.ceil(model.grid.length / columns); rows = Math.ceil(model.grid.length / columns);
slotWidth = (width - (columns - 1) * gap) / columns; slotWidth = (width - (columns - 1) * gap) / columns;
slotHeight = (minHeight - (rows - 1) * gap) / rows; 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 {tileModels.map((m) => (
const slotAspectRatio = slotWidth / slotHeight; <Slot key={m.vm.id} className={styles.slot} id={m.vm.id} model={m} />
if (slotAspectRatio > slotMaxAspectRatio) ))}
slotWidth = slotHeight * slotMaxAspectRatio; </div>
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>
);
}),
},
}); });

View File

@@ -0,0 +1,101 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.layer {
margin-inline: var(--inline-content-inset);
display: grid;
--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);
}
.scrolling {
block-size: 100%;
}
.spotlight {
container: spotlight / size;
display: grid;
place-items: center;
}
/* 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) {
.layer[data-orientation="landscape"] > .spotlight > .slot {
inline-size: min(100cqi, 100cqb * (17 / 9));
block-size: min(100cqb, 100cqi / (4 / 3));
}
}
.grid {
display: flex;
flex-wrap: wrap;
gap: var(--grid-gap);
justify-content: center;
}
.layer[data-orientation="landscape"] > .grid {
align-content: center;
}
.layer > .grid > .slot {
inline-size: var(--grid-slot-width);
}
.layer[data-orientation="landscape"] > .grid > .slot {
block-size: 135px;
}
.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

@@ -0,0 +1,126 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { CSSProperties, forwardRef, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
import { CallLayout, GridTileModel, TileModel } from "./CallLayout";
import { SpotlightLayout as SpotlightLayoutModel } from "../state/CallViewModel";
import styles from "./SpotlightLayout.module.css";
import { useReactiveState } from "../useReactiveState";
interface GridCSSProperties extends CSSProperties {
"--grid-columns": number;
}
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: 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}
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: 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 (
<div
ref={ref}
data-generation={generation}
data-orientation={layout.orientation}
className={classNames(styles.layer, styles.scrolling)}
style={{ "--grid-columns": layout.gridColumns } as GridCSSProperties}
>
<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

@@ -125,7 +125,7 @@ limitations under the License.
.fixedGrid { .fixedGrid {
position: absolute; position: absolute;
inline-size: calc(100% - 2 * var(--inline-content-inset)); inline-size: 100%;
align-self: center; align-self: center;
/* Disable pointer events so the overlay doesn't block interaction with /* Disable pointer events so the overlay doesn't block interaction with
elements behind it */ elements behind it */
@@ -139,6 +139,16 @@ limitations under the License.
.scrollingGrid { .scrollingGrid {
position: relative; position: relative;
flex-grow: 1; flex-grow: 1;
inline-size: calc(100% - 2 * var(--inline-content-inset)); inline-size: 100%;
align-self: center; 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 { MatrixClient } from "matrix-js-sdk/src/client";
import { import {
FC, FC,
PropsWithoutRef,
forwardRef, forwardRef,
useCallback, useCallback,
useEffect, useEffect,
@@ -35,7 +36,7 @@ import {
import useMeasure from "react-use-measure"; import useMeasure from "react-use-measure";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import classNames from "classnames"; import classNames from "classnames";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject, map } from "rxjs";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -73,17 +74,20 @@ import { ECConnectionState } from "../livekit/useECConnectionState";
import { useOpenIDSFU } from "../livekit/openIDSFU"; import { useOpenIDSFU } from "../livekit/openIDSFU";
import { import {
GridMode, GridMode,
Layout,
TileDescriptor, TileDescriptor,
useCallViewModel, useCallViewModel,
} from "../state/CallViewModel"; } from "../state/CallViewModel";
import { Grid, TileProps } from "../grid/Grid"; import { Grid, TileProps } from "../grid/Grid";
import { MediaViewModel } from "../state/MediaViewModel"; import { MediaViewModel } from "../state/MediaViewModel";
import { gridLayoutSystems } from "../grid/GridLayout";
import { useObservable } from "../state/useObservable"; import { useObservable } from "../state/useObservable";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
import { SpotlightTile } from "../tile/SpotlightTile"; import { SpotlightTile } from "../tile/SpotlightTile";
import { EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { makeGridLayout } from "../grid/GridLayout";
import { makeSpotlightLayout } from "../grid/SpotlightLayout";
import { CallLayout, GridTileModel, TileModel } from "../grid/CallLayout";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -302,6 +306,7 @@ export const InCallView: FC<InCallViewProps> = ({
const [headerRef, headerBounds] = useMeasure(); const [headerRef, headerBounds] = useMeasure();
const [footerRef, footerBounds] = useMeasure(); const [footerRef, footerBounds] = useMeasure();
const gridBounds = useMemo( const gridBounds = useMemo(
() => ({ () => ({
width: footerBounds.width, width: footerBounds.width,
@@ -315,11 +320,32 @@ export const InCallView: FC<InCallViewProps> = ({
], ],
); );
const gridBoundsObservable = useObservable(gridBounds); const gridBoundsObservable = useObservable(gridBounds);
const floatingAlignment = useInitial( const floatingAlignment = useInitial(
() => new BehaviorSubject(defaultAlignment), () => new BehaviorSubject(defaultAlignment),
); );
const { fixed, scrolling } = useInitial(() =>
gridLayoutSystems(gridBoundsObservable, floatingAlignment), const layoutSystem = useObservableEagerState(
useInitial(() =>
vm.layout.pipe(
map((l) => {
let makeLayout: CallLayout<Layout>;
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>;
else return null; // Not yet implemented
return makeLayout({
minBounds: gridBoundsObservable,
floatingAlignment,
});
}),
),
),
); );
const setGridMode = useCallback( const setGridMode = useCallback(
@@ -330,59 +356,79 @@ export const InCallView: FC<InCallViewProps> = ({
[setLegacyLayout, vm], [setLegacyLayout, vm],
); );
const showSpeakingIndicators = const showSpotlightIndicators = useObservable(layout.type === "spotlight");
const showSpeakingIndicators = useObservable(
layout.type === "spotlight" || layout.type === "spotlight" ||
(layout.type === "grid" && layout.grid.length > 2); (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],
); );
const GridTileView = useMemo(
const Tile = useMemo(
() => () =>
forwardRef<HTMLDivElement, TileProps<MediaViewModel, HTMLDivElement>>( forwardRef<
function GridTileView( HTMLDivElement,
{ className, style, targetWidth, targetHeight, model }, PropsWithoutRef<TileProps<TileModel, HTMLDivElement>>
ref, >(function Tile(
) { { className, style, targetWidth, targetHeight, model },
return ( ref,
<GridTile ) {
ref={ref} const showSpeakingIndicatorsValue = useObservableEagerState(
vm={model} showSpeakingIndicators,
maximised={false} );
fullscreen={false} const showSpotlightIndicatorsValue = useObservableEagerState(
onToggleFullscreen={toggleFullscreen} showSpotlightIndicators,
onOpenProfile={openProfile} );
targetWidth={targetWidth}
targetHeight={targetHeight} return model.type === "grid" ? (
className={className} <GridTile
style={style} ref={ref}
showSpeakingIndicators={showSpeakingIndicators} vm={model.vm}
/> maximised={false}
); fullscreen={false}
}, onToggleFullscreen={toggleFullscreen}
), onOpenProfile={openProfile}
[toggleFullscreen, openProfile, showSpeakingIndicators], 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 => { const renderContent = (): JSX.Element => {
@@ -399,17 +445,20 @@ export const InCallView: FC<InCallViewProps> = ({
if (maximisedParticipant.id === "spotlight") { if (maximisedParticipant.id === "spotlight") {
return ( return (
<SpotlightTile <SpotlightTile
className={classNames(styles.tile, styles.maximised)}
vms={layout.spotlight!} vms={layout.spotlight!}
maximised={true} maximised
fullscreen={fullscreen} fullscreen={fullscreen}
onToggleFullscreen={toggleSpotlightFullscreen} onToggleFullscreen={toggleSpotlightFullscreen}
targetWidth={gridBounds.height} targetWidth={gridBounds.height}
targetHeight={gridBounds.width} targetHeight={gridBounds.width}
showIndicators={false}
/> />
); );
} }
return ( return (
<GridTile <GridTile
className={classNames(styles.tile, styles.maximised)}
vm={maximisedParticipant.data} vm={maximisedParticipant.data}
maximised={true} maximised={true}
fullscreen={fullscreen} fullscreen={fullscreen}
@@ -423,39 +472,35 @@ export const InCallView: FC<InCallViewProps> = ({
); );
} }
// The only new layout we've implemented so far is grid layout for non-1:1 if (layoutSystem === null) {
// calls. All other layouts use the legacy grid system for now. // This new layout doesn't yet have an implemented layout system, so fall
if ( // back to the legacy grid system
legacyLayout === "grid" &&
layout.type === "grid" &&
!(layout.grid.length === 2 && layout.spotlight === undefined)
) {
return (
<>
<Grid
className={styles.scrollingGrid}
model={layout}
system={scrolling}
Tile={GridTileView}
/>
<Grid
className={styles.fixedGrid}
style={{ insetBlockStart: headerBounds.bottom }}
model={layout}
system={fixed}
Tile={SpotlightTileView}
/>
</>
);
} else {
return ( return (
<LegacyGrid <LegacyGrid
items={items} items={items}
layout={legacyLayout} layout={legacyLayout}
disableAnimations={prefersReducedMotion} disableAnimations={prefersReducedMotion}
Tile={GridTileView} Tile={LegacyTile}
/> />
); );
} else {
return (
<>
<Grid
className={styles.scrollingGrid}
model={layout}
Layout={layoutSystem.scrolling}
Tile={Tile}
/>
<Grid
className={styles.fixedGrid}
style={{ insetBlockStart: headerBounds.bottom }}
model={layout}
Layout={layoutSystem.fixed}
Tile={Tile}
/>
</>
);
} }
}; };

View File

@@ -28,12 +28,13 @@ import {
import { Room as MatrixRoom, RoomMember } from "matrix-js-sdk/src/matrix"; import { Room as MatrixRoom, RoomMember } from "matrix-js-sdk/src/matrix";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { import {
BehaviorSubject,
EMPTY, EMPTY,
Observable, Observable,
Subject,
audit, audit,
combineLatest, combineLatest,
concat, concat,
concatMap,
distinctUntilChanged, distinctUntilChanged,
filter, filter,
map, map,
@@ -44,10 +45,10 @@ import {
scan, scan,
shareReplay, shareReplay,
startWith, startWith,
switchAll,
switchMap, switchMap,
throttleTime, throttleTime,
timer, timer,
withLatestFrom,
zip, zip,
} from "rxjs"; } from "rxjs";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
@@ -406,6 +407,13 @@ export class CallViewModel extends ViewModel {
map((mediaItems) => map((mediaItems) =>
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare), mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
), ),
shareReplay(1),
);
private readonly hasRemoteScreenShares: Observable<boolean> =
this.screenShares.pipe(
map((ms) => ms.some((m) => !m.vm.local)),
distinctUntilChanged(),
); );
private readonly spotlightSpeaker: Observable<UserMedia | null> = private readonly spotlightSpeaker: Observable<UserMedia | null> =
@@ -422,11 +430,13 @@ export class CallViewModel extends ViewModel {
scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>( scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>(
(prev, mediaItems) => (prev, mediaItems) =>
// Decide who to spotlight: // Decide who to spotlight:
// If the previous speaker is still speaking, stick with them rather // If the previous speaker (not the local user) is still speaking,
// than switching eagerly to someone else // stick with them rather than switching eagerly to someone else
mediaItems.find(([m, s]) => m === prev && s)?.[0] ?? (prev === null || prev.vm.local
// Otherwise, select anyone who is speaking ? null
mediaItems.find(([, s]) => s)?.[0] ?? : mediaItems.find(([m, s]) => m === prev && s)?.[0]) ??
// Otherwise, select any remote user who is speaking
mediaItems.find(([m, s]) => !m.vm.local && s)?.[0] ??
// Otherwise, stick with the person who was last speaking // Otherwise, stick with the person who was last speaking
prev ?? prev ??
// Otherwise, spotlight the local user // Otherwise, spotlight the local user
@@ -435,7 +445,8 @@ export class CallViewModel extends ViewModel {
null, null,
), ),
distinctUntilChanged(), distinctUntilChanged(),
throttleTime(800, undefined, { leading: true, trailing: true }), shareReplay(1),
throttleTime(1600, undefined, { leading: true, trailing: true }),
); );
private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe( private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe(
@@ -490,49 +501,66 @@ export class CallViewModel extends ViewModel {
// orientation // orientation
private readonly windowMode = of<WindowMode>("normal"); private readonly windowMode = of<WindowMode>("normal");
private readonly _gridMode = new BehaviorSubject<GridMode>("grid"); private readonly gridModeUserSelection = new Subject<GridMode>();
/** /**
* The layout mode of the media tile grid. * The layout mode of the media tile grid.
*/ */
public readonly gridMode: Observable<GridMode> = this._gridMode; public readonly gridMode: Observable<GridMode> = merge(
// Always honor a manual user selection
this.gridModeUserSelection,
// If the user hasn't selected spotlight and somebody starts screen sharing,
// automatically switch to spotlight mode and reset when screen sharing ends
this.hasRemoteScreenShares.pipe(
withLatestFrom(this.gridModeUserSelection.pipe(startWith(null))),
concatMap(([hasScreenShares, userSelection]) =>
userSelection === "spotlight"
? EMPTY
: of<GridMode>(hasScreenShares ? "spotlight" : "grid"),
),
),
).pipe(distinctUntilChanged(), shareReplay(1));
public setGridMode(value: GridMode): void { public setGridMode(value: GridMode): void {
this._gridMode.next(value); this.gridModeUserSelection.next(value);
} }
public readonly layout: Observable<Layout> = combineLatest( public readonly layout: Observable<Layout> = this.windowMode.pipe(
[this._gridMode, this.windowMode], switchMap((windowMode) => {
(gridMode, windowMode) => {
switch (windowMode) { switch (windowMode) {
case "full screen": case "full screen":
throw new Error("unimplemented"); throw new Error("unimplemented");
case "pip": case "pip":
throw new Error("unimplemented"); throw new Error("unimplemented");
case "normal": { case "normal":
switch (gridMode) { return this.gridMode.pipe(
case "grid": switchMap((gridMode) => {
return combineLatest( switch (gridMode) {
[this.grid, this.spotlight, this.screenShares], case "grid":
(grid, spotlight, screenShares): Layout => ({ return combineLatest(
type: "grid", [this.grid, this.spotlight, this.screenShares],
spotlight: screenShares.length > 0 ? spotlight : undefined, (grid, spotlight, screenShares): Layout => ({
grid, type: "grid",
}), spotlight:
); screenShares.length > 0 ? spotlight : undefined,
case "spotlight": grid,
return combineLatest( }),
[this.grid, this.spotlight], );
(grid, spotlight): Layout => ({ case "spotlight":
type: "spotlight", return combineLatest(
spotlight, [this.grid, this.spotlight],
grid, (grid, spotlight): Layout => ({
}), type: "spotlight",
); spotlight,
} grid,
} }),
);
}
}),
);
} }
}, }),
).pipe(switchAll(), shareReplay(1)); shareReplay(1),
);
/** /**
* The media tiles to be displayed in the call view. * The media tiles to be displayed in the call view.

View File

@@ -15,8 +15,6 @@ limitations under the License.
*/ */
.tile { .tile {
position: absolute;
top: 0;
--media-view-border-radius: var(--cpd-space-4x); --media-view-border-radius: var(--cpd-space-4x);
transition: outline-color ease 0.15s; transition: outline-color ease 0.15s;
outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0); 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"] { .tile[data-maximised="true"] {
position: relative;
flex-grow: 1;
--media-view-border-radius: 0; --media-view-border-radius: 0;
--media-view-fg-inset: 10px; --media-view-fg-inset: 10px;
} }

View File

@@ -71,6 +71,7 @@ interface MediaTileProps
vm: MediaViewModel; vm: MediaViewModel;
videoEnabled: boolean; videoEnabled: boolean;
videoFit: "contain" | "cover"; videoFit: "contain" | "cover";
mirror: boolean;
nameTagLeadingIcon?: ReactNode; nameTagLeadingIcon?: ReactNode;
primaryButton: ReactNode; primaryButton: ReactNode;
secondaryButton?: ReactNode; secondaryButton?: ReactNode;
@@ -87,7 +88,6 @@ const MediaTile = forwardRef<HTMLDivElement, MediaTileProps>(
className={classNames(className, styles.tile)} className={classNames(className, styles.tile)}
data-maximised={maximised} data-maximised={maximised}
video={video} video={video}
mirror={false}
member={vm.member} member={vm.member}
unencryptedWarning={unencryptedWarning} unencryptedWarning={unencryptedWarning}
{...props} {...props}
@@ -100,6 +100,7 @@ MediaTile.displayName = "MediaTile";
interface UserMediaTileProps extends TileProps { interface UserMediaTileProps extends TileProps {
vm: UserMediaViewModel; vm: UserMediaViewModel;
mirror: boolean;
showSpeakingIndicators: boolean; showSpeakingIndicators: boolean;
menuStart?: ReactNode; menuStart?: ReactNode;
menuEnd?: ReactNode; menuEnd?: ReactNode;
@@ -202,7 +203,7 @@ interface LocalUserMediaTileProps extends TileProps {
} }
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>( const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
({ vm, onOpenProfile, className, ...props }, ref) => { ({ vm, onOpenProfile, ...props }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const mirror = useObservableEagerState(vm.mirror); const mirror = useObservableEagerState(vm.mirror);
const alwaysShow = useObservableEagerState(vm.alwaysShow); const alwaysShow = useObservableEagerState(vm.alwaysShow);
@@ -220,6 +221,7 @@ const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
<UserMediaTile <UserMediaTile
ref={ref} ref={ref}
vm={vm} vm={vm}
mirror={mirror}
menuStart={ menuStart={
<ToggleMenuItem <ToggleMenuItem
Icon={VisibilityOnIcon} Icon={VisibilityOnIcon}
@@ -236,7 +238,6 @@ const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
onSelect={onOpenProfile} onSelect={onOpenProfile}
/> />
} }
className={classNames(className, { [styles.mirror]: mirror })}
{...props} {...props}
/> />
); );
@@ -270,6 +271,7 @@ const RemoteUserMediaTile = forwardRef<
<UserMediaTile <UserMediaTile
ref={ref} ref={ref}
vm={vm} vm={vm}
mirror={false}
menuStart={ menuStart={
<> <>
<ToggleMenuItem <ToggleMenuItem
@@ -321,7 +323,9 @@ const ScreenShareTile = forwardRef<HTMLDivElement, ScreenShareTileProps>(
<MediaTile <MediaTile
ref={ref} ref={ref}
vm={vm} vm={vm}
videoEnabled
videoFit="contain" videoFit="contain"
mirror={false}
primaryButton={ primaryButton={
!vm.local && ( !vm.local && (
<button <button
@@ -336,7 +340,6 @@ const ScreenShareTile = forwardRef<HTMLDivElement, ScreenShareTileProps>(
</button> </button>
) )
} }
videoEnabled
{...props} {...props}
/> />
); );

View File

@@ -15,14 +15,10 @@ limitations under the License.
*/ */
.tile { .tile {
position: absolute;
top: 0;
--border-width: var(--cpd-space-3x); --border-width: var(--cpd-space-3x);
} }
.tile.maximised { .tile.maximised {
position: relative;
flex-grow: 1;
--border-width: 0px; --border-width: 0px;
} }
@@ -54,14 +50,14 @@ limitations under the License.
border-radius: 0; border-radius: 0;
} }
.item { .contents > .item {
height: 100%; height: 100%;
flex-basis: 100%; flex-basis: 100%;
flex-shrink: 0; flex-shrink: 0;
--media-view-fg-inset: 10px; --media-view-fg-inset: 10px;
} }
.item.snap { .contents > .item.snap {
scroll-snap-align: start; scroll-snap-align: start;
} }
@@ -151,3 +147,38 @@ limitations under the License.
.tile:has(:focus-visible) > button { .tile:has(:focus-visible) > button {
opacity: 1; 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

@@ -16,6 +16,7 @@ limitations under the License.
import { import {
ComponentProps, ComponentProps,
RefAttributes,
forwardRef, forwardRef,
useCallback, useCallback,
useEffect, useEffect,
@@ -28,17 +29,20 @@ import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?r
import ChevronLeftIcon from "@vector-im/compound-design-tokens/icons/chevron-left.svg?react"; import ChevronLeftIcon from "@vector-im/compound-design-tokens/icons/chevron-left.svg?react";
import ChevronRightIcon from "@vector-im/compound-design-tokens/icons/chevron-right.svg?react"; import ChevronRightIcon from "@vector-im/compound-design-tokens/icons/chevron-right.svg?react";
import { animated } from "@react-spring/web"; import { animated } from "@react-spring/web";
import { Observable, map, of } from "rxjs"; import { Observable, map } from "rxjs";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
import { TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { RoomMember } from "matrix-js-sdk";
import { MediaView } from "./MediaView"; import { MediaView } from "./MediaView";
import styles from "./SpotlightTile.module.css"; import styles from "./SpotlightTile.module.css";
import { import {
LocalUserMediaViewModel, LocalUserMediaViewModel,
MediaViewModel, MediaViewModel,
RemoteUserMediaViewModel, ScreenShareViewModel,
UserMediaViewModel,
useNameData, useNameData,
} from "../state/MediaViewModel"; } from "../state/MediaViewModel";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
@@ -47,12 +51,63 @@ import { useObservableRef } from "../state/useObservable";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";
// Screen share video is always enabled interface SpotlightItemBaseProps {
const videoEnabledDefault = of(true); className?: string;
// Never mirror screen share video "data-id": string;
const mirrorDefault = of(false); targetWidth: number;
// Never crop screen share video targetHeight: number;
const cropVideoDefault = of(false); video: TrackReferenceOrPlaceholder;
member: RoomMember | undefined;
unencryptedWarning: boolean;
nameTag: string;
displayName: string;
}
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
videoEnabled: boolean;
videoFit: "contain" | "cover";
}
interface SpotlightLocalUserMediaItemProps
extends SpotlightUserMediaItemBaseProps {
vm: LocalUserMediaViewModel;
}
const SpotlightLocalUserMediaItem = forwardRef<
HTMLDivElement,
SpotlightLocalUserMediaItemProps
>(({ vm, ...props }, ref) => {
const mirror = useObservableEagerState(vm.mirror);
return <MediaView ref={ref} mirror={mirror} {...props} />;
});
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps {
vm: UserMediaViewModel;
}
const SpotlightUserMediaItem = forwardRef<
HTMLDivElement,
SpotlightUserMediaItemProps
>(({ vm, ...props }, ref) => {
const videoEnabled = useObservableEagerState(vm.videoEnabled);
const cropVideo = useObservableEagerState(vm.cropVideo);
const baseProps: SpotlightUserMediaItemBaseProps = {
videoEnabled,
videoFit: cropVideo ? "cover" : "contain",
...props,
};
return vm instanceof LocalUserMediaViewModel ? (
<SpotlightLocalUserMediaItem ref={ref} vm={vm} {...baseProps} />
) : (
<MediaView mirror={false} {...baseProps} />
);
});
SpotlightUserMediaItem.displayName = "SpotlightUserMediaItem";
interface SpotlightItemProps { interface SpotlightItemProps {
vm: MediaViewModel; vm: MediaViewModel;
@@ -71,21 +126,6 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const { displayName, nameTag } = useNameData(vm); const { displayName, nameTag } = useNameData(vm);
const video = useObservableEagerState(vm.video); const video = useObservableEagerState(vm.video);
const videoEnabled = useObservableEagerState(
vm instanceof LocalUserMediaViewModel ||
vm instanceof RemoteUserMediaViewModel
? vm.videoEnabled
: videoEnabledDefault,
);
const mirror = useObservableEagerState(
vm instanceof LocalUserMediaViewModel ? vm.mirror : mirrorDefault,
);
const cropVideo = useObservableEagerState(
vm instanceof LocalUserMediaViewModel ||
vm instanceof RemoteUserMediaViewModel
? vm.cropVideo
: cropVideoDefault,
);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning); const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
// Hook this item up to the intersection observer // Hook this item up to the intersection observer
@@ -103,22 +143,28 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
}; };
}, [intersectionObserver]); }, [intersectionObserver]);
return ( const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
ref,
"data-id": vm.id,
className: classNames(styles.item, { [styles.snap]: snap }),
targetWidth,
targetHeight,
video,
member: vm.member,
unencryptedWarning,
nameTag,
displayName,
};
return vm instanceof ScreenShareViewModel ? (
<MediaView <MediaView
ref={ref} videoEnabled
data-id={vm.id} videoFit="contain"
className={classNames(styles.item, { [styles.snap]: snap })} mirror={false}
targetWidth={targetWidth} {...baseProps}
targetHeight={targetHeight}
video={video}
videoFit={cropVideo ? "cover" : "contain"}
mirror={mirror}
member={vm.member}
videoEnabled={videoEnabled}
unencryptedWarning={unencryptedWarning}
nameTag={nameTag}
displayName={displayName}
/> />
) : (
<SpotlightUserMediaItem vm={vm} {...baseProps} />
); );
}, },
); );
@@ -132,6 +178,7 @@ interface Props {
onToggleFullscreen: () => void; onToggleFullscreen: () => void;
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
showIndicators: boolean;
className?: string; className?: string;
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
} }
@@ -145,6 +192,7 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
onToggleFullscreen, onToggleFullscreen,
targetWidth, targetWidth,
targetHeight, targetHeight,
showIndicators,
className, className,
style, style,
}, },
@@ -156,8 +204,9 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
const [visibleId, setVisibleId] = useState(vms[0].id); const [visibleId, setVisibleId] = useState(vms[0].id);
const latestVms = useLatest(vms); const latestVms = useLatest(vms);
const latestVisibleId = useLatest(visibleId); const latestVisibleId = useLatest(visibleId);
const canGoBack = visibleId !== vms[0].id; const visibleIndex = vms.findIndex((vm) => vm.id === visibleId);
const canGoToNext = visibleId !== vms[vms.length - 1].id; const canGoBack = visibleIndex > 0;
const canGoToNext = visibleIndex !== -1 && visibleIndex < vms.length - 1;
// To keep track of which item is visible, we need an intersection observer // To keep track of which item is visible, we need an intersection observer
// hooked up to the root element and the items. Because the items will run // hooked up to the root element and the items. Because the items will run
@@ -261,6 +310,15 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
<ChevronRightIcon aria-hidden width={24} height={24} /> <ChevronRightIcon aria-hidden width={24} height={24} />
</button> </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> </animated.div>
); );
}, },