Merge pull request #2382 from robintown/spotlight-layout
New spotlight layout
This commit is contained in:
69
src/grid/CallLayout.ts
Normal file
69
src/grid/CallLayout.ts
Normal 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>;
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
101
src/grid/SpotlightLayout.module.css
Normal file
101
src/grid/SpotlightLayout.module.css
Normal 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;
|
||||||
|
}
|
||||||
126
src/grid/SpotlightLayout.tsx
Normal file
126
src/grid/SpotlightLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user