From ec1b020d4eaf5eb6adac96802f1208b8d381979d Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 30 May 2024 13:06:24 -0400 Subject: [PATCH] Add indicators to spotlight tile and make spotlight layout responsive --- src/grid/CallLayout.ts | 20 ++- src/grid/Grid.tsx | 152 ++++++++--------- src/grid/GridLayout.module.css | 4 + src/grid/GridLayout.tsx | 245 ++++++++++++++-------------- src/grid/SpotlightLayout.module.css | 76 +++++---- src/grid/SpotlightLayout.tsx | 143 ++++++++++------ src/room/InCallView.module.css | 10 ++ src/room/InCallView.tsx | 143 +++++++++------- src/tile/GridTile.module.css | 4 - src/tile/SpotlightTile.module.css | 43 ++++- src/tile/SpotlightTile.tsx | 11 ++ 11 files changed, 495 insertions(+), 356 deletions(-) diff --git a/src/grid/CallLayout.ts b/src/grid/CallLayout.ts index 287f116d..c4412677 100644 --- a/src/grid/CallLayout.ts +++ b/src/grid/CallLayout.ts @@ -15,9 +15,10 @@ limitations under the License. */ import { BehaviorSubject, Observable } from "rxjs"; +import { ComponentType } from "react"; import { MediaViewModel } from "../state/MediaViewModel"; -import { LayoutSystem } from "./Grid"; +import { LayoutProps } from "./Grid"; import { Alignment } from "../room/InCallView"; export interface Bounds { @@ -36,15 +37,28 @@ export interface CallLayoutInputs { floatingAlignment: BehaviorSubject; } +export interface GridTileModel { + type: "grid"; + vm: MediaViewModel; +} + +export interface SpotlightTileModel { + type: "spotlight"; + vms: MediaViewModel[]; + maximised: boolean; +} + +export type TileModel = GridTileModel | SpotlightTileModel; + export interface CallLayoutOutputs { /** * The visually fixed (non-scrolling) layer of the layout. */ - fixed: LayoutSystem; + fixed: ComponentType>; /** * The layer of the layout that can overflow and be scrolled. */ - scrolling: LayoutSystem; + scrolling: ComponentType>; } /** diff --git a/src/grid/Grid.tsx b/src/grid/Grid.tsx index b6cf8fcd..2e6a48ae 100644 --- a/src/grid/Grid.tsx +++ b/src/grid/Grid.tsx @@ -42,6 +42,7 @@ import { useMergedRefs } from "../useMergedRefs"; import { TileWrapper } from "./TileWrapper"; import { usePrefersReducedMotion } from "../usePrefersReducedMotion"; import { TileSpringUpdate } from "./LegacyGrid"; +import { useInitial } from "../useInitial"; interface Rect { x: number; @@ -50,11 +51,14 @@ interface Rect { height: number; } -interface Tile extends Rect { +interface Tile { id: string; model: Model; + onDrag: DragCallback | undefined; } +type PlacedTile = Tile & Rect; + interface TileSpring { opacity: number; scale: number; @@ -73,24 +77,14 @@ interface DragState { cursorY: number; } -interface SlotProps extends ComponentProps<"div"> { - tile: string; +interface SlotProps extends Omit, "onDrag"> { + id: string; + model: Model; + onDrag?: DragCallback; style?: CSSProperties; className?: string; } -/** - * An invisible "slot" for a tile to go in. - */ -export const Slot: FC = ({ tile, style, className, ...props }) => ( -
-); - interface Offset { x: number; y: number; @@ -113,9 +107,13 @@ function offset(element: HTMLElement, relativeTo: Element): Offset { } } -export interface LayoutProps { +export interface LayoutProps { ref: LegacyRef; - model: Model; + model: LayoutModel; + /** + * Component creating an invisible "slot" for a tile to go in. + */ + Slot: ComponentType>; } export interface TileProps { @@ -152,25 +150,7 @@ interface Drag { yRatio: number; } -type DragCallback = (drag: Drag) => void; - -export interface LayoutSystem { - /** - * Defines the ID and model of each tile present in the layout. - */ - tiles: (model: LayoutModel) => Map; - /** - * 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>; - /** - * Gets a drag callback for the tile with the given ID. If this is not - * provided or it returns null, the tile is not draggable. - */ - onDrag?: (model: LayoutModel, tile: string) => DragCallback | null; -} +export type DragCallback = (drag: Drag) => void; interface Props< LayoutModel, @@ -183,9 +163,11 @@ interface Props< */ model: LayoutModel; /** - * The system by which to arrange the layout and respond to interactions. + * A component which creates an invisible layout grid of "slots" for tiles to + * go in. The root element must have a data-generation attribute which + * increments whenever the layout may have changed. */ - system: LayoutSystem; + Layout: ComponentType>; /** * The component used to render each tile in the layout. */ @@ -204,7 +186,7 @@ export function Grid< TileRef extends HTMLElement, >({ model, - system: { tiles: getTileModels, Layout, onDrag }, + Layout, Tile, className, style, @@ -223,8 +205,31 @@ export function Grid< const [layoutRoot, setLayoutRoot] = useState(null); const [generation, setGeneration] = useState(null); + const tiles = useInitial(() => new Map>()); const prefersReducedMotion = usePrefersReducedMotion(); + const Slot: FC> = useMemo( + () => + function Slot({ id, model, onDrag, style, className, ...props }) { + const ref = useRef(null); + useEffect(() => { + tiles.set(id, { id, model, onDrag }); + return (): void => void tiles.delete(id); + }, [id, model, onDrag]); + + return ( +
+ ); + }, + [tiles], + ); + const layoutRef = useCallback( (e: HTMLElement | null) => { setLayoutRoot(e); @@ -247,62 +252,45 @@ export function Grid< } }, [layoutRoot, setGeneration]); - const slotRects = useMemo(() => { - const rects = new Map(); + // Combine the tile definitions and slots together to create placed tiles + const placedTiles = useMemo(() => { + const result: PlacedTile[] = []; if (gridRoot !== null && layoutRoot !== null) { const slots = layoutRoot.getElementsByClassName( styles.slot, ) as HTMLCollectionOf; - for (const slot of slots) - rects.set(slot.getAttribute("data-tile")!, { + for (const slot of slots) { + const id = slot.getAttribute("data-id")!; + result.push({ + ...tiles.get(id)!, ...offset(slot, gridRoot), width: slot.offsetWidth, height: slot.offsetHeight, }); + } } - return rects; + return result; // The rects may change due to the grid updating to a new generation, but // eslint can't statically verify this // eslint-disable-next-line react-hooks/exhaustive-deps - }, [gridRoot, layoutRoot, generation]); - - const tileModels = useMemo( - () => getTileModels(model), - [getTileModels, model], - ); - - // Combine the tile models and slots together to create placed tiles - const tiles = useMemo[]>(() => { - const items: Tile[] = []; - for (const [id, model] of tileModels) { - const rect = slotRects.get(id); - if (rect !== undefined) items.push({ id, model, ...rect }); - } - return items; - }, [slotRects, tileModels]); - - const dragCallbacks = useMemo( - () => - new Map( - (function* (): Iterable<[string, DragCallback | null]> { - if (onDrag !== undefined) - for (const id of tileModels.keys()) yield [id, onDrag(model, id)]; - })(), - ), - [onDrag, tileModels, model], - ); + }, [gridRoot, layoutRoot, tiles, generation]); // Drag state is stored in a ref rather than component state, because we use // react-spring's imperative API during gestures to improve responsiveness const dragState = useRef(null); const [tileTransitions, springRef] = useTransition( - tiles, + placedTiles, () => ({ key: ({ id }: Tile): string => id, - from: ({ x, y, width, height }: Tile): TileSpringUpdate => ({ + from: ({ + x, + y, + width, + height, + }: PlacedTile): TileSpringUpdate => ({ opacity: 0, scale: 0, zIndex: 1, @@ -319,7 +307,7 @@ export function Grid< y, width, height, - }: Tile): TileSpringUpdate | null => + }: PlacedTile): TileSpringUpdate | null => id === dragState.current?.tileId ? null : { @@ -334,7 +322,7 @@ export function Grid< }), // react-spring's types are bugged and can't infer the spring type ) as unknown as [ - TransitionFn, TileSpring>, + TransitionFn, TileSpring>, SpringRef, ]; @@ -342,14 +330,14 @@ export function Grid< // firing animations manually whenever the tiles array updates useEffect(() => { springRef.start(); - }, [tiles, springRef]); + }, [placedTiles, springRef]); const animateDraggedTile = ( endOfGesture: boolean, callback: DragCallback, ): void => { const { tileId, tileX, tileY } = dragState.current!; - const tile = tiles.find((t) => t.id === tileId)!; + const tile = placedTiles.find((t) => t.id === tileId)!; springRef.current .find((c) => (c.item as Tile).id === tileId) @@ -416,7 +404,7 @@ export function Grid< const tileController = springRef.current.find( (c) => (c.item as Tile).id === tileId, )!; - const callback = dragCallbacks.get(tileController.item.id); + const callback = tiles.get(tileController.item.id)!.onDrag; if (callback != null) { if (dragState.current === null) { @@ -456,7 +444,7 @@ export function Grid< if (dragState.current !== null) { dragState.current.tileY += dy; dragState.current.cursorY += dy; - animateDraggedTile(false, onDrag!(model, dragState.current.tileId)!); + animateDraggedTile(false, tiles.get(dragState.current.tileId)!.onDrag!); } }, { target: gridRoot ?? undefined }, @@ -468,12 +456,12 @@ export function Grid< className={classNames(className, styles.grid)} style={style} > - - {tileTransitions((spring, { id, model, width, height }) => ( + + {tileTransitions((spring, { id, model, onDrag, width, height }) => ( .slot { position: absolute; inline-size: 404px; diff --git a/src/grid/GridLayout.tsx b/src/grid/GridLayout.tsx index 75f1e726..3861457e 100644 --- a/src/grid/GridLayout.tsx +++ b/src/grid/GridLayout.tsx @@ -14,16 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { CSSProperties, forwardRef, useMemo } from "react"; +import { CSSProperties, forwardRef, useCallback, useMemo } from "react"; import { distinctUntilChanged } from "rxjs"; import { useObservableEagerState } from "observable-hooks"; import { GridLayout as GridLayoutModel } from "../state/CallViewModel"; -import { Slot } from "./Grid"; import styles from "./GridLayout.module.css"; import { useReactiveState } from "../useReactiveState"; import { useInitial } from "../useInitial"; -import { CallLayout } from "./CallLayout"; +import { CallLayout, GridTileModel, TileModel } from "./CallLayout"; +import { DragCallback } from "./Grid"; interface GridCSSProperties extends CSSProperties { "--gap": string; @@ -41,135 +41,144 @@ export const makeGridLayout: CallLayout = ({ }) => ({ // The "fixed" (non-scrolling) part of the layout is where the spotlight tile // lives - fixed: { - tiles: (model) => - new Map( - model.spotlight === undefined ? [] : [["spotlight", model.spotlight]], - ), - Layout: forwardRef(function GridLayoutFixed({ model }, ref) { - const { width, height } = useObservableEagerState(minBounds); - const alignment = useObservableEagerState( - useInitial(() => - floatingAlignment.pipe( - distinctUntilChanged( - (a1, a2) => a1.block === a2.block && a1.inline === a2.inline, - ), + fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) { + const { width, height } = useObservableEagerState(minBounds); + const alignment = useObservableEagerState( + useInitial(() => + floatingAlignment.pipe( + distinctUntilChanged( + (a1, a2) => a1.block === a2.block && a1.inline === a2.inline, ), ), - ); - const [generation] = useReactiveState( - (prev) => (prev === undefined ? 0 : prev + 1), - [model.spotlight === undefined, width, height, alignment], - ); - - return ( -
- {model.spotlight && ( - - )} -
- ); - }), - onDrag: + ), + ); + const tileModel: TileModel | undefined = useMemo( () => + model.spotlight && { + type: "spotlight", + vms: model.spotlight, + maximised: false, + }, + [model.spotlight], + ); + const [generation] = useReactiveState( + (prev) => (prev === undefined ? 0 : prev + 1), + [model.spotlight === undefined, width, height, alignment], + ); + + const onDragSpotlight: DragCallback = useCallback( ({ xRatio, yRatio }) => floatingAlignment.next({ block: yRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end", }), - }, + [], + ); + + return ( +
+ {tileModel && ( + + )} +
+ ); + }), // The scrolling part of the layout is where all the grid tiles live - scrolling: { - tiles: (model) => new Map(model.grid.map((tile) => [tile.id, tile])), - Layout: forwardRef(function GridLayout({ model }, ref) { - const { width, height: minHeight } = useObservableEagerState(minBounds); + scrolling: forwardRef(function GridLayout({ model, Slot }, ref) { + const { width, height: minHeight } = useObservableEagerState(minBounds); - // The goal here is to determine the grid size and padding that maximizes - // use of screen space for n tiles without making those tiles too small or - // too cropped (having an extreme aspect ratio) - const [gap, slotWidth, slotHeight] = useMemo(() => { - const gap = width < 800 ? 16 : 20; - const slotMinWidth = width < 500 ? 150 : 180; + // The goal here is to determine the grid size and padding that maximizes + // use of screen space for n tiles without making those tiles too small or + // too cropped (having an extreme aspect ratio) + const [gap, slotWidth, slotHeight] = useMemo(() => { + const gap = width < 800 ? 16 : 20; + const slotMinWidth = width < 500 ? 150 : 180; - let columns = Math.min( - // Don't create more columns than we have items for - model.grid.length, - // The ideal number of columns is given by a packing of equally-sized - // squares into a grid. - // width / column = height / row. - // columns * rows = number of squares. - // ∴ columns = sqrt(width / height * number of squares). - // Except we actually want 16:9-ish slots rather than squares, so we - // divide the width-to-height ratio by the target aspect ratio. - Math.ceil( - Math.sqrt( - (width / minHeight / slotMaxAspectRatio) * model.grid.length, - ), + let columns = Math.min( + // Don't create more columns than we have items for + model.grid.length, + // The ideal number of columns is given by a packing of equally-sized + // squares into a grid. + // width / column = height / row. + // columns * rows = number of squares. + // ∴ columns = sqrt(width / height * number of squares). + // Except we actually want 16:9-ish slots rather than squares, so we + // divide the width-to-height ratio by the target aspect ratio. + Math.ceil( + Math.sqrt( + (width / minHeight / slotMaxAspectRatio) * model.grid.length, ), - ); - let rows = Math.ceil(model.grid.length / columns); + ), + ); + let rows = Math.ceil(model.grid.length / columns); - let slotWidth = (width - (columns - 1) * gap) / columns; - let slotHeight = (minHeight - (rows - 1) * gap) / rows; + let slotWidth = (width - (columns - 1) * gap) / columns; + let slotHeight = (minHeight - (rows - 1) * gap) / rows; - // Impose a minimum width and height on the slots - if (slotWidth < slotMinWidth) { - // In this case we want the slot width to determine the number of columns, - // not the other way around. If we take the above equation for the slot - // width (w = (W - (c - 1) * g) / c) and solve for c, we get - // c = (W + g) / (w + g). - columns = Math.floor((width + gap) / (slotMinWidth + gap)); - rows = Math.ceil(model.grid.length / columns); - slotWidth = (width - (columns - 1) * gap) / columns; - slotHeight = (minHeight - (rows - 1) * gap) / rows; + // Impose a minimum width and height on the slots + if (slotWidth < slotMinWidth) { + // In this case we want the slot width to determine the number of columns, + // not the other way around. If we take the above equation for the slot + // width (w = (W - (c - 1) * g) / c) and solve for c, we get + // c = (W + g) / (w + g). + columns = Math.floor((width + gap) / (slotMinWidth + gap)); + rows = Math.ceil(model.grid.length / columns); + slotWidth = (width - (columns - 1) * gap) / columns; + slotHeight = (minHeight - (rows - 1) * gap) / rows; + } + if (slotHeight < slotMinHeight) slotHeight = slotMinHeight; + // Impose a minimum and maximum aspect ratio on the slots + const slotAspectRatio = slotWidth / slotHeight; + if (slotAspectRatio > slotMaxAspectRatio) + slotWidth = slotHeight * slotMaxAspectRatio; + else if (slotAspectRatio < slotMinAspectRatio) + slotHeight = slotWidth / slotMinAspectRatio; + // TODO: We might now be hitting the minimum height or width limit again + + return [gap, slotWidth, slotHeight]; + }, [width, minHeight, model.grid.length]); + + const [generation] = useReactiveState( + (prev) => (prev === undefined ? 0 : prev + 1), + [model.grid, width, minHeight], + ); + + const tileModels: GridTileModel[] = useMemo( + () => model.grid.map((vm) => ({ type: "grid", vm })), + [model.grid], + ); + + return ( +
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( - (prev) => (prev === undefined ? 0 : prev + 1), - [model.grid, width, minHeight], - ); - - return ( -
- {model.grid.map((tile) => ( - - ))} -
- ); - }), - }, + > + {tileModels.map((m) => ( + + ))} +
+ ); + }), }); diff --git a/src/grid/SpotlightLayout.module.css b/src/grid/SpotlightLayout.module.css index bbce45cf..af43216c 100644 --- a/src/grid/SpotlightLayout.module.css +++ b/src/grid/SpotlightLayout.module.css @@ -14,18 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -.fixed, -.scrolling { +.layer { margin-inline: var(--inline-content-inset); display: grid; - --grid-slot-width: 180px; --grid-gap: 20px; + gap: 30px; +} + +.layer[data-orientation="landscape"] { + --grid-slot-width: 180px; grid-template-columns: 1fr calc( var(--grid-columns) * var(--grid-slot-width) + (var(--grid-columns) - 1) * var(--grid-gap) ); grid-template-rows: minmax(1fr, auto); - gap: 30px; } .scrolling { @@ -41,7 +43,7 @@ limitations under the License. /* CSS makes us put a condition here, even though all we want to do is unconditionally select the container so we can use cq units */ @container spotlight (width > 0) { - .spotlight > .slot { + .layer[data-orientation="landscape"] > .spotlight > .slot { inline-size: min(100cqi, 100cqb * (17 / 9)); block-size: min(100cqb, 100cqi / (4 / 3)); } @@ -52,38 +54,48 @@ unconditionally select the container so we can use cq units */ flex-wrap: wrap; gap: var(--grid-gap); justify-content: center; +} + +.layer[data-orientation="landscape"] > .grid { align-content: center; } -.grid > .slot { +.layer > .grid > .slot { inline-size: var(--grid-slot-width); +} + +.layer[data-orientation="landscape"] > .grid > .slot { block-size: 135px; } -@media (max-width: 600px) { - .fixed, - .scrolling { - margin-inline: 0; - display: block; - } - - .spotlight { - inline-size: 100%; - aspect-ratio: 16 / 9; - margin-block-end: var(--cpd-space-4x); - } - - .grid { - margin-inline: var(--inline-content-inset); - align-content: start; - } - - .grid > .slot { - --grid-columns: 2; - --grid-slot-width: calc( - (100% - (var(--grid-columns) - 1) * var(--grid-gap)) / var(--grid-columns) - ); - block-size: unset; - aspect-ratio: 4 / 3; - } +.layer[data-orientation="portrait"] { + margin-inline: 0; + display: block; +} + +.layer[data-orientation="portrait"] > .spotlight { + inline-size: 100%; + aspect-ratio: 16 / 9; + margin-block-end: var(--cpd-space-4x); +} + +.layer[data-orientation="portrait"] > .spotlight.withIndicators { + margin-block-end: calc(2 * var(--cpd-space-4x) + 2px); +} + +.layer[data-orientation="portrait"] > .spotlight > .slot { + inline-size: 100%; + block-size: 100%; +} + +.layer[data-orientation="portrait"] > .grid { + margin-inline: var(--inline-content-inset); + align-content: start; +} + +.layer[data-orientation="portrait"] > .grid > .slot { + --grid-slot-width: calc( + (100% - (var(--grid-columns) - 1) * var(--grid-gap)) / var(--grid-columns) + ); + aspect-ratio: 4 / 3; } diff --git a/src/grid/SpotlightLayout.tsx b/src/grid/SpotlightLayout.tsx index 38bc6e37..3e07a0b2 100644 --- a/src/grid/SpotlightLayout.tsx +++ b/src/grid/SpotlightLayout.tsx @@ -14,76 +14,113 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { CSSProperties, forwardRef } from "react"; +import { CSSProperties, forwardRef, useMemo } from "react"; import { useObservableEagerState } from "observable-hooks"; +import classNames from "classnames"; -import { CallLayout } from "./CallLayout"; +import { CallLayout, GridTileModel, TileModel } from "./CallLayout"; import { SpotlightLayout as SpotlightLayoutModel } from "../state/CallViewModel"; -import { useReactiveState } from "../useReactiveState"; import styles from "./SpotlightLayout.module.css"; -import { Slot } from "./Grid"; +import { useReactiveState } from "../useReactiveState"; interface GridCSSProperties extends CSSProperties { "--grid-columns": number; } -const getGridColumns = (gridLength: number): number => - gridLength > 20 ? 2 : 1; +interface Layout { + orientation: "portrait" | "landscape"; + gridColumns: number; +} + +function getLayout(gridLength: number, width: number): Layout { + const orientation = width < 800 ? "portrait" : "landscape"; + return { + orientation, + gridColumns: + orientation === "portrait" + ? Math.floor(width / 190) + : gridLength > 20 + ? 2 + : 1, + }; +} export const makeSpotlightLayout: CallLayout = ({ minBounds, }) => ({ - fixed: { - tiles: (model) => new Map([["spotlight", model.spotlight]]), - Layout: forwardRef(function SpotlightLayoutFixed({ model }, ref) { - const { width, height } = useObservableEagerState(minBounds); - const gridColumns = getGridColumns(model.grid.length); - const [generation] = useReactiveState( - (prev) => (prev === undefined ? 0 : prev + 1), - [model.grid.length, width, height], - ); + fixed: forwardRef(function SpotlightLayoutFixed({ model, Slot }, ref) { + const { width, height } = useObservableEagerState(minBounds); + const layout = getLayout(model.grid.length, width); + const tileModel: TileModel = useMemo( + () => ({ + type: "spotlight", + vms: model.spotlight, + maximised: layout.orientation === "portrait", + }), + [model.spotlight, layout.orientation], + ); + const [generation] = useReactiveState( + (prev) => (prev === undefined ? 0 : prev + 1), + [model.grid.length, width, height], + ); - return ( -
-
- -
-
+ return ( +
+
+
- ); - }), - }, +
+
+ ); + }), - scrolling: { - tiles: (model) => new Map(model.grid.map((tile) => [tile.id, tile])), - Layout: forwardRef(function SpotlightLayoutScrolling({ model }, ref) { - const { width, height } = useObservableEagerState(minBounds); - const gridColumns = getGridColumns(model.grid.length); - const [generation] = useReactiveState( - (prev) => (prev === undefined ? 0 : prev + 1), - [model.grid, width, height], - ); + scrolling: forwardRef(function SpotlightLayoutScrolling( + { model, Slot }, + ref, + ) { + const { width, height } = useObservableEagerState(minBounds); + const layout = getLayout(model.grid.length, width); + const tileModels: GridTileModel[] = useMemo( + () => model.grid.map((vm) => ({ type: "grid", vm })), + [model.grid], + ); + const [generation] = useReactiveState( + (prev) => (prev === undefined ? 0 : prev + 1), + [model.spotlight.length, model.grid, width, height], + ); - return ( + return ( +
-
-
- {model.grid.map((tile) => ( - - ))} -
+ className={classNames(styles.spotlight, { + [styles.withIndicators]: model.spotlight.length > 1, + })} + /> +
+ {tileModels.map((m) => ( + + ))}
- ); - }), - }, +
+ ); + }), }); diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index 76dc9bae..60c46aa6 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -142,3 +142,13 @@ limitations under the License. inline-size: 100%; align-self: center; } + +.tile { + position: absolute; + inset-block-start: 0; +} + +.tile.maximised { + position: relative; + flex-grow: 1; +} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index b2aeb9d2..3a16e1c6 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -25,6 +25,7 @@ import { ConnectionState, Room, Track } from "livekit-client"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { FC, + PropsWithoutRef, forwardRef, useCallback, useEffect, @@ -86,7 +87,7 @@ import { EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { E2eeType } from "../e2ee/e2eeType"; import { makeGridLayout } from "../grid/GridLayout"; import { makeSpotlightLayout } from "../grid/SpotlightLayout"; -import { CallLayout } from "../grid/CallLayout"; +import { CallLayout, GridTileModel, TileModel } from "../grid/CallLayout"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -329,7 +330,10 @@ export const InCallView: FC = ({ vm.layout.pipe( map((l) => { let makeLayout: CallLayout; - if (l.type === "grid" && l.grid.length !== 2) + if ( + l.type === "grid" && + !(l.grid.length === 2 && l.spotlight === undefined) + ) makeLayout = makeGridLayout as CallLayout; else if (l.type === "spotlight") makeLayout = makeSpotlightLayout as CallLayout; @@ -352,59 +356,79 @@ export const InCallView: FC = ({ [setLegacyLayout, vm], ); - const showSpeakingIndicators = + const showSpotlightIndicators = useObservable(layout.type === "spotlight"); + const showSpeakingIndicators = useObservable( layout.type === "spotlight" || - (layout.type === "grid" && layout.grid.length > 2); - - const SpotlightTileView = useMemo( - () => - forwardRef>( - function SpotlightTileView( - { className, style, targetWidth, targetHeight, model }, - ref, - ) { - return ( - - ); - }, - ), - [toggleSpotlightFullscreen], + (layout.type === "grid" && layout.grid.length > 2), ); - const GridTileView = useMemo( + + const Tile = useMemo( () => - forwardRef>( - function GridTileView( - { className, style, targetWidth, targetHeight, model }, - ref, - ) { - return ( - - ); - }, - ), - [toggleFullscreen, openProfile, showSpeakingIndicators], + forwardRef< + HTMLDivElement, + PropsWithoutRef> + >(function Tile( + { className, style, targetWidth, targetHeight, model }, + ref, + ) { + const showSpeakingIndicatorsValue = useObservableEagerState( + showSpeakingIndicators, + ); + const showSpotlightIndicatorsValue = useObservableEagerState( + showSpotlightIndicators, + ); + + return model.type === "grid" ? ( + + ) : ( + + ); + }), + [ + toggleFullscreen, + toggleSpotlightFullscreen, + openProfile, + showSpeakingIndicators, + showSpotlightIndicators, + ], + ); + + const LegacyTile = useMemo( + () => + forwardRef< + HTMLDivElement, + PropsWithoutRef> + >(function LegacyTile({ model: legacyModel, ...props }, ref) { + const model: GridTileModel = useMemo( + () => ({ type: "grid", vm: legacyModel }), + [legacyModel], + ); + return ; + }), + [Tile], ); const renderContent = (): JSX.Element => { @@ -421,17 +445,20 @@ export const InCallView: FC = ({ if (maximisedParticipant.id === "spotlight") { return ( ); } return ( = ({ items={items} layout={legacyLayout} disableAnimations={prefersReducedMotion} - Tile={GridTileView} + Tile={LegacyTile} /> ); } else { @@ -462,15 +489,15 @@ export const InCallView: FC = ({ ); diff --git a/src/tile/GridTile.module.css b/src/tile/GridTile.module.css index 923c7633..7ef66d8d 100644 --- a/src/tile/GridTile.module.css +++ b/src/tile/GridTile.module.css @@ -15,8 +15,6 @@ limitations under the License. */ .tile { - position: absolute; - top: 0; --media-view-border-radius: var(--cpd-space-4x); transition: outline-color ease 0.15s; outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0); @@ -62,8 +60,6 @@ borders don't support gradients */ } .tile[data-maximised="true"] { - position: relative; - flex-grow: 1; --media-view-border-radius: 0; --media-view-fg-inset: 10px; } diff --git a/src/tile/SpotlightTile.module.css b/src/tile/SpotlightTile.module.css index 9d772c1d..cc591fee 100644 --- a/src/tile/SpotlightTile.module.css +++ b/src/tile/SpotlightTile.module.css @@ -15,14 +15,10 @@ limitations under the License. */ .tile { - position: absolute; - top: 0; --border-width: var(--cpd-space-3x); } .tile.maximised { - position: relative; - flex-grow: 1; --border-width: 0px; } @@ -54,14 +50,14 @@ limitations under the License. border-radius: 0; } -.item { +.contents > .item { height: 100%; flex-basis: 100%; flex-shrink: 0; --media-view-fg-inset: 10px; } -.item.snap { +.contents > .item.snap { scroll-snap-align: start; } @@ -151,3 +147,38 @@ limitations under the License. .tile:has(:focus-visible) > button { opacity: 1; } + +.indicators { + display: flex; + gap: var(--cpd-space-2x); + position: absolute; + inset-inline-start: 0; + inset-block-end: calc(-1 * var(--cpd-space-6x)); + width: 100%; + justify-content: start; + transition: opacity ease 0.15s; + opacity: 0; +} + +.indicators.show { + opacity: 1; +} + +.maximised .indicators { + inset-block-end: calc(-1 * var(--cpd-space-4x) - 2px); + justify-content: center; +} + +.indicators > .item { + inline-size: 32px; + block-size: 2px; + transition: background-color ease 0.15s; +} + +.indicators > .item[data-visible="false"] { + background: var(--cpd-color-alpha-gray-600); +} + +.indicators > .item[data-visible="true"] { + background: var(--cpd-color-gray-1400); +} diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index fccc5235..f77ce4cf 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -178,6 +178,7 @@ interface Props { onToggleFullscreen: () => void; targetWidth: number; targetHeight: number; + showIndicators: boolean; className?: string; style?: ComponentProps["style"]; } @@ -191,6 +192,7 @@ export const SpotlightTile = forwardRef( onToggleFullscreen, targetWidth, targetHeight, + showIndicators, className, style, }, @@ -307,6 +309,15 @@ export const SpotlightTile = forwardRef( )} +
1, + })} + > + {vms.map((vm) => ( +
+ ))} +
); },