Merge pull request #2463 from robintown/rest-of-the-layouts

Implement most of the remaining layout changes
This commit is contained in:
Robin
2024-07-18 11:34:47 -04:00
committed by GitHub
25 changed files with 783 additions and 497 deletions

View File

@@ -22,7 +22,6 @@ limitations under the License.
user-select: none;
flex-shrink: 0;
padding-inline: var(--inline-content-inset);
padding-block-end: var(--cpd-space-4x);
}
.nav {

View File

@@ -65,6 +65,10 @@ export interface SpotlightTileModel {
export type TileModel = GridTileModel | SpotlightTileModel;
export interface CallLayoutOutputs<Model> {
/**
* Whether the scrolling layer of the layout should appear on top.
*/
scrollingOnTop: boolean;
/**
* The visually fixed (non-scrolling) layer of the layout.
*/
@@ -122,7 +126,7 @@ export function arrangeTiles(
);
let rows = Math.ceil(tileCount / columns);
let tileWidth = (width - (columns - 1) * gap) / columns;
let tileWidth = (width - (columns + 1) * gap) / columns;
let tileHeight = (minHeight - (rows - 1) * gap) / rows;
// Impose a minimum width and height on the tiles
@@ -133,7 +137,7 @@ export function arrangeTiles(
// c = (W + g) / (w + g).
columns = Math.floor((width + gap) / (tileMinWidth + gap));
rows = Math.ceil(tileCount / columns);
tileWidth = (width - (columns - 1) * gap) / columns;
tileWidth = (width - (columns + 1) * gap) / columns;
tileHeight = (minHeight - (rows - 1) * gap) / rows;
}
if (tileHeight < tileMinHeight) tileHeight = tileMinHeight;

View File

@@ -16,7 +16,6 @@ limitations under the License.
.fixed,
.scrolling {
margin-inline: var(--inline-content-inset);
block-size: 100%;
}
@@ -41,7 +40,7 @@ limitations under the License.
position: absolute;
inline-size: 404px;
block-size: 233px;
inset: -12px;
inset: 0;
}
.fixed > .slot[data-block-alignment="start"] {

View File

@@ -36,10 +36,16 @@ interface GridCSSProperties extends CSSProperties {
"--height": string;
}
/**
* An implementation of the "grid" layout, in which all participants are shown
* together in a scrolling grid.
*/
export const makeGridLayout: CallLayout<GridLayoutModel> = ({
minBounds,
spotlightAlignment,
}) => ({
scrollingOnTop: false,
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
// lives
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) {

View File

@@ -15,7 +15,6 @@ limitations under the License.
*/
.layer {
margin-inline: var(--inline-content-inset);
block-size: 100%;
display: grid;
place-items: center;
@@ -43,7 +42,6 @@ limitations under the License.
position: absolute;
inline-size: 404px;
block-size: 233px;
inset: -12px;
}
.slot[data-block-alignment="start"] {

View File

@@ -19,63 +19,23 @@ import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
import { OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel";
import {
CallLayout,
GridTileModel,
SpotlightTileModel,
arrangeTiles,
} from "./CallLayout";
import { CallLayout, GridTileModel, arrangeTiles } from "./CallLayout";
import { useReactiveState } from "../useReactiveState";
import styles from "./OneOnOneLayout.module.css";
import { DragCallback } from "./Grid";
/**
* An implementation of the "one-on-one" layout, in which the remote participant
* is shown at maximum size, overlaid by a small view of the local participant.
*/
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
minBounds,
spotlightAlignment,
pipAlignment,
}) => ({
fixed: forwardRef(function OneOnOneLayoutFixed({ model, Slot }, ref) {
const { width, height } = useObservableEagerState(minBounds);
const spotlightAlignmentValue = useObservableEagerState(spotlightAlignment);
scrollingOnTop: false,
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[width, height, model.spotlight === undefined, spotlightAlignmentValue],
);
const spotlightTileModel: SpotlightTileModel | undefined = useMemo(
() =>
model.spotlight && {
type: "spotlight",
vms: model.spotlight,
maximised: false,
},
[model.spotlight],
);
const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) =>
spotlightAlignment.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[],
);
return (
<div ref={ref} data-generation={generation} className={styles.layer}>
{spotlightTileModel && (
<Slot
className={classNames(styles.slot, styles.spotlight)}
id="spotlight"
model={spotlightTileModel}
onDrag={onDragSpotlight}
data-block-alignment={spotlightAlignmentValue.block}
data-inline-alignment={spotlightAlignmentValue.inline}
/>
)}
</div>
);
fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) {
return <div ref={ref} data-generation={0} />;
}),
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {

View File

@@ -0,0 +1,47 @@
/*
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 {
block-size: 100%;
}
.spotlight {
block-size: 100%;
inline-size: 100%;
}
.pip {
position: absolute;
inline-size: 180px;
block-size: 135px;
inset: var(--cpd-space-4x);
}
.pip[data-block-alignment="start"] {
inset-block-end: unset;
}
.pip[data-block-alignment="end"] {
inset-block-start: unset;
}
.pip[data-inline-alignment="start"] {
inset-inline-end: unset;
}
.pip[data-inline-alignment="end"] {
inset-inline-start: unset;
}

View File

@@ -0,0 +1,103 @@
/*
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 { forwardRef, useCallback, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import { SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
import { CallLayout, GridTileModel, SpotlightTileModel } from "./CallLayout";
import { DragCallback } from "./Grid";
import styles from "./SpotlightExpandedLayout.module.css";
import { useReactiveState } from "../useReactiveState";
/**
* An implementation of the "expanded spotlight" layout, in which the spotlight
* tile stretches edge-to-edge and is overlaid by a picture-in-picture tile.
*/
export const makeSpotlightExpandedLayout: CallLayout<
SpotlightExpandedLayoutModel
> = ({ minBounds, pipAlignment }) => ({
scrollingOnTop: true,
fixed: forwardRef(function SpotlightExpandedLayoutFixed(
{ model, Slot },
ref,
) {
const { width, height } = useObservableEagerState(minBounds);
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[width, height],
);
const spotlightTileModel: SpotlightTileModel = useMemo(
() => ({ type: "spotlight", vms: model.spotlight, maximised: true }),
[model.spotlight],
);
return (
<div ref={ref} data-generation={generation} className={styles.layer}>
<Slot
className={styles.spotlight}
id="spotlight"
model={spotlightTileModel}
/>
</div>
);
}),
scrolling: forwardRef(function SpotlightExpandedLayoutScrolling(
{ model, Slot },
ref,
) {
const { width, height } = useObservableEagerState(minBounds);
const pipAlignmentValue = useObservableEagerState(pipAlignment);
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[width, height, model.pip === undefined, pipAlignmentValue],
);
const pipTileModel: GridTileModel | undefined = useMemo(
() => model.pip && { type: "grid", vm: model.pip },
[model.pip],
);
const onDragPip: DragCallback = useCallback(
({ xRatio, yRatio }) =>
pipAlignment.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[],
);
return (
<div ref={ref} data-generation={generation} className={styles.layer}>
{pipTileModel && (
<Slot
className={styles.pip}
id="pip"
model={pipTileModel}
onDrag={onDragPip}
data-block-alignment={pipAlignmentValue.block}
data-inline-alignment={pipAlignmentValue.inline}
/>
)}
</div>
);
}),
});

View File

@@ -0,0 +1,54 @@
/*
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 {
block-size: 100%;
display: grid;
--gap: 20px;
gap: var(--gap);
--grid-slot-width: 180px;
grid-template-columns: 1fr var(--grid-slot-width);
grid-template-rows: minmax(1fr, auto);
padding-inline: var(--gap);
}
.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) {
.spotlight > .slot {
inline-size: min(100cqi, 100cqb * (17 / 9));
block-size: min(100cqb, 100cqi / (4 / 3));
}
}
.grid {
display: flex;
flex-wrap: wrap;
gap: var(--gap);
justify-content: center;
align-content: center;
}
.grid > .slot {
inline-size: 180px;
block-size: 135px;
}

View File

@@ -0,0 +1,98 @@
/*
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 { forwardRef, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
import { CallLayout, GridTileModel, TileModel } from "./CallLayout";
import { SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel";
import styles from "./SpotlightLandscapeLayout.module.css";
import { useReactiveState } from "../useReactiveState";
/**
* An implementation of the "spotlight landscape" layout, in which the spotlight
* tile takes up most of the space on the left, and the grid of participants is
* shown as a scrolling rail on the right.
*/
export const makeSpotlightLandscapeLayout: CallLayout<
SpotlightLandscapeLayoutModel
> = ({ minBounds }) => ({
scrollingOnTop: false,
fixed: forwardRef(function SpotlightLandscapeLayoutFixed(
{ model, Slot },
ref,
) {
const { width, height } = useObservableEagerState(minBounds);
const tileModel: TileModel = useMemo(
() => ({
type: "spotlight",
vms: model.spotlight,
maximised: false,
}),
[model.spotlight],
);
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[model.grid.length, width, height],
);
return (
<div ref={ref} data-generation={generation} className={styles.layer}>
<div className={styles.spotlight}>
<Slot className={styles.slot} id="spotlight" model={tileModel} />
</div>
<div className={styles.grid} />
</div>
);
}),
scrolling: forwardRef(function SpotlightLandscapeLayoutScrolling(
{ model, Slot },
ref,
) {
const { width, height } = useObservableEagerState(minBounds);
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} className={styles.layer}>
<div
className={classNames(styles.spotlight, {
[styles.withIndicators]: model.spotlight.length > 1,
})}
/>
<div className={styles.grid}>
{tileModels.map((m) => (
<Slot
key={m.vm.id}
className={styles.slot}
id={m.vm.id}
model={m}
/>
))}
</div>
</div>
);
}),
});

View File

@@ -1,98 +0,0 @@
/*
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);
block-size: 100%;
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);
}
.spotlight {
container: spotlight / size;
display: grid;
place-items: center;
}
/* CSS makes us put a condition here, even though all we want to do is
unconditionally select the container so we can use cq units */
@container spotlight (width > 0) {
.layer[data-orientation="landscape"] > .spotlight > .slot {
inline-size: min(100cqi, 100cqb * (17 / 9));
block-size: min(100cqb, 100cqi / (4 / 3));
}
}
.grid {
display: flex;
flex-wrap: wrap;
gap: var(--grid-gap);
justify-content: center;
}
.layer[data-orientation="landscape"] > .grid {
align-content: center;
}
.layer > .grid > .slot {
inline-size: var(--grid-slot-width);
}
.layer[data-orientation="landscape"] > .grid > .slot {
block-size: 135px;
}
.layer[data-orientation="portrait"] {
margin-inline: 0;
display: block;
}
.layer[data-orientation="portrait"] > .spotlight {
inline-size: 100%;
aspect-ratio: 16 / 9;
margin-block-end: var(--cpd-space-4x);
}
.layer[data-orientation="portrait"] > .spotlight.withIndicators {
margin-block-end: calc(2 * var(--cpd-space-4x) + 2px);
}
.layer[data-orientation="portrait"] > .spotlight > .slot {
inline-size: 100%;
block-size: 100%;
}
.layer[data-orientation="portrait"] > .grid {
margin-inline: var(--inline-content-inset);
align-content: start;
}
.layer[data-orientation="portrait"] > .grid > .slot {
--grid-slot-width: calc(
(100% - (var(--grid-columns) - 1) * var(--grid-gap)) / var(--grid-columns)
);
aspect-ratio: 4 / 3;
}

View File

@@ -0,0 +1,56 @@
/*
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 {
block-size: 100%;
display: grid;
--gap: 20px;
gap: var(--gap);
margin-inline: 0;
display: block;
}
.spotlight {
container: spotlight / size;
display: grid;
place-items: center;
inline-size: 100%;
aspect-ratio: 16 / 9;
margin-block-end: var(--cpd-space-4x);
}
.spotlight.withIndicators {
margin-block-end: calc(2 * var(--cpd-space-4x) + 2px);
}
.spotlight > .slot {
inline-size: 100%;
block-size: 100%;
}
.grid {
display: flex;
flex-wrap: wrap;
gap: var(--grid-gap);
justify-content: center;
align-content: start;
padding-inline: var(--grid-gap);
}
.grid > .slot {
inline-size: var(--grid-tile-width);
block-size: var(--grid-tile-height);
}

View File

@@ -18,46 +18,44 @@ 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 {
CallLayout,
GridTileModel,
TileModel,
arrangeTiles,
} from "./CallLayout";
import { SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
import styles from "./SpotlightPortraitLayout.module.css";
import { useReactiveState } from "../useReactiveState";
interface GridCSSProperties extends CSSProperties {
"--grid-columns": number;
"--grid-gap": string;
"--grid-tile-width": string;
"--grid-tile-height": string;
}
interface Layout {
orientation: "portrait" | "landscape";
gridColumns: number;
}
/**
* An implementation of the "spotlight portrait" layout, in which the spotlight
* tile is shown across the top of the screen, and the grid of participants
* scrolls behind it.
*/
export const makeSpotlightPortraitLayout: CallLayout<
SpotlightPortraitLayoutModel
> = ({ minBounds }) => ({
scrollingOnTop: false,
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) {
fixed: forwardRef(function SpotlightPortraitLayoutFixed(
{ 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",
maximised: true,
}),
[model.spotlight, layout.orientation],
[model.spotlight],
);
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
@@ -65,27 +63,24 @@ export const makeSpotlightLayout: CallLayout<SpotlightLayoutModel> = ({
);
return (
<div
ref={ref}
data-generation={generation}
data-orientation={layout.orientation}
className={styles.layer}
style={{ "--grid-columns": layout.gridColumns } as GridCSSProperties}
>
<div ref={ref} data-generation={generation} className={styles.layer}>
<div className={styles.spotlight}>
<Slot className={styles.slot} id="spotlight" model={tileModel} />
</div>
<div className={styles.grid} />
</div>
);
}),
scrolling: forwardRef(function SpotlightLayoutScrolling(
scrolling: forwardRef(function SpotlightPortraitLayoutScrolling(
{ model, Slot },
ref,
) {
const { width, height } = useObservableEagerState(minBounds);
const layout = getLayout(model.grid.length, width);
const { gap, tileWidth, tileHeight } = arrangeTiles(
width,
0,
model.grid.length,
);
const tileModels: GridTileModel[] = useMemo(
() => model.grid.map((vm) => ({ type: "grid", vm })),
[model.grid],
@@ -99,9 +94,14 @@ export const makeSpotlightLayout: CallLayout<SpotlightLayoutModel> = ({
<div
ref={ref}
data-generation={generation}
data-orientation={layout.orientation}
className={styles.layer}
style={{ "--grid-columns": layout.gridColumns } as GridCSSProperties}
style={
{
"--grid-gap": `${gap}px`,
"--grid-tile-width": `${Math.floor(tileWidth)}px`,
"--grid-tile-height": `${Math.floor(tileHeight)}px`,
} as GridCSSProperties
}
>
<div
className={classNames(styles.spotlight, {

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Observable, defer, finalize, tap } from "rxjs";
import { Observable, defer, finalize, scan, startWith, tap } from "rxjs";
const nothing = Symbol("nothing");
@@ -35,3 +35,15 @@ export function finalizeValue<T>(callback: (finalValue: T) => void) {
);
});
}
/**
* RxJS operator that accumulates a state from a source of events. This is like
* scan, except it emits an initial value immediately before any events arrive.
*/
export function accumulate<State, Event>(
initial: State,
update: (state: State, event: Event) => State,
) {
return (events: Observable<Event>): Observable<State> =>
events.pipe(scan(update, initial), startWith(initial));
}

View File

@@ -68,7 +68,7 @@ limitations under the License.
align-items: center;
gap: var(--cpd-space-3x);
padding-block: var(--cpd-space-4x);
margin-inline: var(--inline-content-inset);
padding-inline: var(--inline-content-inset);
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
@@ -123,17 +123,16 @@ limitations under the License.
display: none;
}
.footer.overlay {
position: absolute;
inset-block-end: 0;
inset-inline: 0;
}
.fixedGrid {
position: absolute;
inline-size: 100%;
align-self: center;
/* Disable pointer events so the overlay doesn't block interaction with
elements behind it */
pointer-events: none;
}
.fixedGrid > :not(:first-child) {
pointer-events: initial;
}
.scrollingGrid {
@@ -143,6 +142,18 @@ limitations under the License.
align-self: center;
}
.fixedGrid,
.scrollingGrid {
/* Disable pointer events so the overlay doesn't block interaction with
elements behind it */
pointer-events: none;
}
.fixedGrid > :not(:first-child),
.scrollingGrid > :not(:first-child) {
pointer-events: initial;
}
.tile {
position: absolute;
inset-block-start: 0;

View File

@@ -35,7 +35,7 @@ import {
import useMeasure from "react-use-measure";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs";
import { BehaviorSubject } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import LogoMark from "../icons/LogoMark.svg?react";
@@ -59,7 +59,6 @@ import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { useLiveKit } from "../livekit/useLiveKit";
import { useFullscreen } from "./useFullscreen";
import { useWakeLock } from "../useWakeLock";
import { useMergedRefs } from "../useMergedRefs";
import { MuteStates } from "./MuteStates";
@@ -76,14 +75,16 @@ import { SpotlightTile } from "../tile/SpotlightTile";
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType";
import { makeGridLayout } from "../grid/GridLayout";
import { makeSpotlightLayout } from "../grid/SpotlightLayout";
import {
CallLayout,
CallLayoutOutputs,
TileModel,
defaultPipAlignment,
defaultSpotlightAlignment,
} from "../grid/CallLayout";
import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -194,24 +195,9 @@ export const InCallView: FC<InCallViewProps> = ({
matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
connState,
);
const windowMode = useObservableEagerState(vm.windowMode);
const layout = useObservableEagerState(vm.layout);
const gridMode = useObservableEagerState(vm.gridMode);
const hasSpotlight = layout.spotlight !== undefined;
const fullscreenItems = useMemo(
() => (hasSpotlight ? ["spotlight"] : []),
[hasSpotlight],
);
const { fullscreenItem, toggleFullscreen, exitFullscreen } =
useFullscreen(fullscreenItems);
const toggleSpotlightFullscreen = useCallback(
() => toggleFullscreen("spotlight"),
[toggleFullscreen],
);
// The maximised participant: either the participant that the user has
// manually put in fullscreen, or (TODO) the spotlight if the window is too
// small to show everyone
const maximisedParticipant = fullscreenItem;
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
@@ -235,14 +221,18 @@ export const InCallView: FC<InCallViewProps> = ({
const gridBounds = useMemo(
() => ({
width: footerBounds.width,
height: bounds.height - headerBounds.height - footerBounds.height,
width: bounds.width,
height:
bounds.height -
headerBounds.height -
(windowMode === "flat" ? 0 : footerBounds.height),
}),
[
footerBounds.width,
bounds.width,
bounds.height,
headerBounds.height,
footerBounds.height,
windowMode,
],
);
const gridBoundsObservable = useObservable(gridBounds);
@@ -254,29 +244,6 @@ export const InCallView: FC<InCallViewProps> = ({
() => new BehaviorSubject(defaultPipAlignment),
);
const layoutSystem = useObservableEagerState(
useInitial(() =>
vm.layout.pipe(
map((l) => {
let makeLayout: CallLayout<Layout>;
if (l.type === "grid")
makeLayout = makeGridLayout as CallLayout<Layout>;
else if (l.type === "spotlight")
makeLayout = makeSpotlightLayout as CallLayout<Layout>;
else if (l.type === "one-on-one")
makeLayout = makeOneOnOneLayout as CallLayout<Layout>;
else throw new Error(`Unimplemented layout: ${l.type}`);
return makeLayout({
minBounds: gridBoundsObservable,
spotlightAlignment,
pipAlignment,
});
}),
),
),
);
const setGridMode = useCallback(
(mode: GridMode) => vm.setGridMode(mode),
[vm],
@@ -318,10 +285,9 @@ export const InCallView: FC<InCallViewProps> = ({
}
}, [setGridMode]);
const showSpotlightIndicators = useObservable(layout.type === "spotlight");
const showSpeakingIndicators = useObservable(
layout.type === "spotlight" ||
(layout.type === "grid" && layout.grid.length > 2),
const toggleSpotlightExpanded = useCallback(
() => vm.toggleSpotlightExpanded(),
[vm],
);
const Tile = useMemo(
@@ -333,20 +299,18 @@ export const InCallView: FC<InCallViewProps> = ({
{ className, style, targetWidth, targetHeight, model },
ref,
) {
const spotlightExpanded = useObservableEagerState(vm.spotlightExpanded);
const showSpeakingIndicatorsValue = useObservableEagerState(
showSpeakingIndicators,
vm.showSpeakingIndicators,
);
const showSpotlightIndicatorsValue = useObservableEagerState(
showSpotlightIndicators,
vm.showSpotlightIndicators,
);
return model.type === "grid" ? (
<GridTile
ref={ref}
vm={model.vm}
maximised={false}
fullscreen={false}
onToggleFullscreen={toggleFullscreen}
onOpenProfile={openProfile}
targetWidth={targetWidth}
targetHeight={targetHeight}
@@ -359,8 +323,8 @@ export const InCallView: FC<InCallViewProps> = ({
ref={ref}
vms={model.vms}
maximised={model.maximised}
fullscreen={false}
onToggleFullscreen={toggleSpotlightFullscreen}
expanded={spotlightExpanded}
onToggleExpanded={toggleSpotlightExpanded}
targetWidth={targetWidth}
targetHeight={targetHeight}
showIndicators={showSpotlightIndicatorsValue}
@@ -369,52 +333,74 @@ export const InCallView: FC<InCallViewProps> = ({
/>
);
}),
[
toggleFullscreen,
toggleSpotlightFullscreen,
openProfile,
showSpeakingIndicators,
showSpotlightIndicators,
],
[vm, toggleSpotlightExpanded, openProfile],
);
const layouts = useMemo(() => {
const inputs = {
minBounds: gridBoundsObservable,
spotlightAlignment,
pipAlignment,
};
return {
grid: makeGridLayout(inputs),
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
"spotlight-portrait": makeSpotlightPortraitLayout(inputs),
"spotlight-expanded": makeSpotlightExpandedLayout(inputs),
"one-on-one": makeOneOnOneLayout(inputs),
};
}, [gridBoundsObservable, spotlightAlignment, pipAlignment]);
const renderContent = (): JSX.Element => {
if (maximisedParticipant !== null) {
const fullscreen = maximisedParticipant === fullscreenItem;
if (maximisedParticipant === "spotlight") {
return (
<SpotlightTile
className={classNames(styles.tile, styles.maximised)}
vms={layout.spotlight!}
maximised
fullscreen={fullscreen}
onToggleFullscreen={toggleSpotlightFullscreen}
targetWidth={gridBounds.height}
targetHeight={gridBounds.width}
showIndicators={false}
/>
);
}
if (layout.type === "pip") {
return (
<SpotlightTile
className={classNames(styles.tile, styles.maximised)}
vms={layout.spotlight!}
maximised
expanded
onToggleExpanded={null}
targetWidth={gridBounds.height}
targetHeight={gridBounds.width}
showIndicators={false}
/>
);
}
return (
const layers = layouts[layout.type] as CallLayoutOutputs<Layout>;
const fixedGrid = (
<Grid
key="fixed"
className={styles.fixedGrid}
style={{
insetBlockStart: headerBounds.bottom,
height: gridBounds.height,
}}
model={layout}
Layout={layers.fixed}
Tile={Tile}
/>
);
const scrollingGrid = (
<Grid
key="scrolling"
className={styles.scrollingGrid}
model={layout}
Layout={layers.scrolling}
Tile={Tile}
/>
);
// The grid tiles go *under* the spotlight in the portrait layout, but
// *over* the spotlight in the expanded layout
return layout.type === "spotlight-expanded" ? (
<>
<Grid
className={styles.scrollingGrid}
model={layout}
Layout={layoutSystem.scrolling}
Tile={Tile}
/>
<Grid
className={styles.fixedGrid}
style={{
insetBlockStart: headerBounds.bottom,
height: gridBounds.height,
}}
model={layout}
Layout={layoutSystem.fixed}
Tile={Tile}
/>
{fixedGrid}
{scrollingGrid}
</>
) : (
<>
{scrollingGrid}
{fixedGrid}
</>
);
};
@@ -424,14 +410,13 @@ export const InCallView: FC<InCallViewProps> = ({
);
const toggleScreensharing = useCallback(async () => {
exitFullscreen();
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, {
audio: true,
selfBrowserSurface: "include",
surfaceSwitching: "include",
systemAudio: "include",
});
}, [localParticipant, isScreenShareEnabled, exitFullscreen]);
}, [localParticipant, isScreenShareEnabled]);
let footer: JSX.Element | null;
@@ -484,11 +469,10 @@ export const InCallView: FC<InCallViewProps> = ({
<div
ref={footerRef}
className={classNames(
showControls
? styles.footer
: hideHeader
? [styles.footer, styles.footerHidden]
: [styles.footer, styles.footerThin],
styles.footer,
!showControls &&
(hideHeader ? styles.footerHidden : styles.footerThin),
{ [styles.overlay]: windowMode === "flat" },
)}
>
{!mobile && !hideHeader && (
@@ -515,7 +499,7 @@ export const InCallView: FC<InCallViewProps> = ({
return (
<div className={styles.inRoom} ref={containerRef}>
{!hideHeader && maximisedParticipant === null && (
{!hideHeader && windowMode !== "pip" && windowMode !== "flat" && (
<Header className={styles.header} ref={headerRef}>
<LeftNav>
<RoomHeaderInfo

View File

@@ -18,20 +18,12 @@ limitations under the License.
margin-inline: var(--inline-content-inset);
min-block-size: 0;
block-size: 50vh;
}
.preview.content {
margin-inline: 0;
}
.content {
border-radius: var(--cpd-space-4x);
position: relative;
block-size: 100%;
inline-size: 100%;
overflow: hidden;
}
.content video {
.preview > video {
width: 100%;
height: 100%;
object-fit: cover;
@@ -69,12 +61,20 @@ limitations under the License.
);
}
.preview.content .buttonBar {
padding-inline: var(--inline-content-inset);
}
@media (min-aspect-ratio: 1 / 1) {
.preview video {
.preview > video {
aspect-ratio: 16 / 9;
}
}
@media (max-width: 550px) {
.preview {
margin-inline: 0;
border-radius: 0;
block-size: 100%;
}
.buttonBar {
padding-inline: var(--inline-content-inset);
}
}

View File

@@ -21,13 +21,11 @@ import { usePreviewTracks } from "@livekit/components-react";
import { LocalVideoTrack, Track } from "livekit-client";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { Glass } from "@vector-im/compound-web";
import { Avatar } from "../Avatar";
import styles from "./VideoPreview.module.css";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { MuteStates } from "./MuteStates";
import { useMediaQuery } from "../useMediaQuery";
import { useInitial } from "../useInitial";
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
@@ -116,8 +114,8 @@ export const VideoPreview: FC<Props> = ({
};
}, [videoTrack]);
const content = (
<>
return (
<div className={classNames(styles.preview)} ref={previewRef}>
<video
ref={videoEl}
muted
@@ -137,21 +135,6 @@ export const VideoPreview: FC<Props> = ({
</div>
)}
<div className={styles.buttonBar}>{children}</div>
</>
);
return useMediaQuery("(max-width: 550px)") ? (
<div
className={classNames(styles.preview, styles.content)}
ref={previewRef}
>
{content}
</div>
) : (
<Glass className={styles.preview}>
<div className={styles.content} ref={previewRef}>
{content}
</div>
</Glass>
);
};

View File

@@ -34,9 +34,9 @@ import {
audit,
combineLatest,
concat,
concatMap,
distinctUntilChanged,
filter,
fromEvent,
map,
merge,
mergeAll,
@@ -44,11 +44,11 @@ import {
sample,
scan,
shareReplay,
skip,
startWith,
switchMap,
throttleTime,
timer,
withLatestFrom,
zip,
} from "rxjs";
import { logger } from "matrix-js-sdk/src/logger";
@@ -67,7 +67,7 @@ import {
ScreenShareViewModel,
UserMediaViewModel,
} from "./MediaViewModel";
import { finalizeValue } from "../observable-utils";
import { accumulate, finalizeValue } from "../observable-utils";
import { ObservableScope } from "./ObservableScope";
import { duplicateTiles } from "../settings/settings";
@@ -88,25 +88,30 @@ export interface GridLayout {
grid: UserMediaViewModel[];
}
export interface SpotlightLayout {
type: "spotlight";
export interface SpotlightLandscapeLayout {
type: "spotlight-landscape";
spotlight: MediaViewModel[];
grid: UserMediaViewModel[];
}
export interface OneOnOneLayout {
type: "one-on-one";
spotlight?: ScreenShareViewModel[];
local: LocalUserMediaViewModel;
remote: RemoteUserMediaViewModel;
export interface SpotlightPortraitLayout {
type: "spotlight-portrait";
spotlight: MediaViewModel[];
grid: UserMediaViewModel[];
}
export interface FullScreenLayout {
type: "full screen";
export interface SpotlightExpandedLayout {
type: "spotlight-expanded";
spotlight: MediaViewModel[];
pip?: UserMediaViewModel;
}
export interface OneOnOneLayout {
type: "one-on-one";
local: LocalUserMediaViewModel;
remote: RemoteUserMediaViewModel;
}
export interface PipLayout {
type: "pip";
spotlight: MediaViewModel[];
@@ -118,14 +123,15 @@ export interface PipLayout {
*/
export type Layout =
| GridLayout
| SpotlightLayout
| SpotlightLandscapeLayout
| SpotlightPortraitLayout
| SpotlightExpandedLayout
| OneOnOneLayout
| FullScreenLayout
| PipLayout;
export type GridMode = "grid" | "spotlight";
export type WindowMode = "normal" | "full screen" | "pip";
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
/**
* Sorting bins defining the order in which media tiles appear in the layout.
@@ -301,16 +307,13 @@ export class CallViewModel extends ViewModel {
},
).pipe(
mergeAll(),
// Aggregate the hold instructions into a single list showing which
// Accumulate the hold instructions into a single list showing which
// participants are being held
scan(
(holds, instruction) =>
"hold" in instruction
? [instruction.hold, ...holds]
: holds.filter((h) => h !== instruction.unhold),
[] as RemoteParticipant[][],
accumulate([] as RemoteParticipant[][], (holds, instruction) =>
"hold" in instruction
? [instruction.hold, ...holds]
: holds.filter((h) => h !== instruction.unhold),
),
startWith([]),
);
private readonly remoteParticipants: Observable<RemoteParticipant[]> =
@@ -395,6 +398,11 @@ export class CallViewModel extends ViewModel {
),
);
private readonly localUserMedia: Observable<LocalUserMediaViewModel> =
this.mediaItems.pipe(
map((ms) => ms.find((m) => m.vm.local)!.vm as LocalUserMediaViewModel),
);
private readonly screenShares: Observable<ScreenShare[]> =
this.mediaItems.pipe(
map((mediaItems) =>
@@ -409,7 +417,7 @@ export class CallViewModel extends ViewModel {
distinctUntilChanged(),
);
private readonly spotlightSpeaker: Observable<UserMedia | null> =
private readonly spotlightSpeaker: Observable<UserMediaViewModel> =
this.userMedia.pipe(
switchMap((mediaItems) =>
mediaItems.length === 0
@@ -420,7 +428,7 @@ export class CallViewModel extends ViewModel {
),
),
),
scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>(
scan<(readonly [UserMedia, boolean])[], UserMedia, null>(
(prev, mediaItems) =>
// Decide who to spotlight:
// If the previous speaker (not the local user) is still speaking,
@@ -433,11 +441,11 @@ export class CallViewModel extends ViewModel {
// Otherwise, stick with the person who was last speaking
prev ??
// Otherwise, spotlight the local user
mediaItems.find(([m]) => m.vm.local)?.[0] ??
null,
mediaItems.find(([m]) => m.vm.local)![0],
null,
),
distinctUntilChanged(),
map((speaker) => speaker.vm),
shareReplay(1),
throttleTime(1600, undefined, { leading: true, trailing: true }),
);
@@ -480,38 +488,91 @@ export class CallViewModel extends ViewModel {
}),
);
private readonly spotlight: Observable<MediaViewModel[]> = combineLatest(
[this.screenShares, this.spotlightSpeaker],
(screenShares, spotlightSpeaker): MediaViewModel[] =>
private readonly spotlightAndPip: Observable<
[Observable<MediaViewModel[]>, Observable<UserMediaViewModel | null>]
> = this.screenShares.pipe(
map((screenShares) =>
screenShares.length > 0
? screenShares.map((m) => m.vm)
: spotlightSpeaker === null
? []
: [spotlightSpeaker.vm],
? ([of(screenShares.map((m) => m.vm)), this.spotlightSpeaker] as const)
: ([
this.spotlightSpeaker.pipe(map((speaker) => [speaker!])),
this.localUserMedia.pipe(
switchMap((vm) =>
vm.alwaysShow.pipe(
map((alwaysShow) => (alwaysShow ? vm : null)),
),
),
),
] as const),
),
);
// TODO: Make this react to changes in window dimensions and screen
// orientation
private readonly windowMode = of<WindowMode>("normal");
private readonly spotlight: Observable<MediaViewModel[]> =
this.spotlightAndPip.pipe(
switchMap(([spotlight]) => spotlight),
shareReplay(1),
);
private readonly pip: Observable<UserMediaViewModel | null> =
this.spotlightAndPip.pipe(switchMap(([, pip]) => pip));
/**
* The general shape of the window.
*/
public readonly windowMode: Observable<WindowMode> = fromEvent(
window,
"resize",
).pipe(
startWith(null),
map(() => {
const height = window.innerHeight;
const width = window.innerWidth;
if (height <= 400 && width <= 340) return "pip";
if (width <= 660) return "narrow";
if (height <= 660) return "flat";
return "normal";
}),
distinctUntilChanged(),
shareReplay(1),
);
private readonly spotlightExpandedToggle = new Subject<void>();
public readonly spotlightExpanded: Observable<boolean> =
this.spotlightExpandedToggle.pipe(
accumulate(false, (expanded) => !expanded),
shareReplay(1),
);
public toggleSpotlightExpanded(): void {
this.spotlightExpandedToggle.next();
}
private readonly gridModeUserSelection = new Subject<GridMode>();
/**
* The layout mode of the media tile grid.
*/
public readonly gridMode: Observable<GridMode> = merge(
// Always honor a manual user selection
this.gridModeUserSelection,
public readonly gridMode: Observable<GridMode> =
// 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"
this.gridModeUserSelection.pipe(
startWith(null),
switchMap((userSelection) =>
(userSelection === "spotlight"
? EMPTY
: of<GridMode>(hasScreenShares ? "spotlight" : "grid"),
: combineLatest([this.hasRemoteScreenShares, this.windowMode]).pipe(
skip(userSelection === null ? 0 : 1),
map(
([hasScreenShares, windowMode]): GridMode =>
hasScreenShares || windowMode === "flat"
? "spotlight"
: "grid",
),
)
).pipe(startWith(userSelection ?? "grid")),
),
),
).pipe(distinctUntilChanged(), shareReplay(1));
distinctUntilChanged(),
shareReplay(1),
);
public setGridMode(value: GridMode): void {
this.gridModeUserSelection.next(value);
@@ -519,11 +580,24 @@ export class CallViewModel extends ViewModel {
public readonly layout: Observable<Layout> = this.windowMode.pipe(
switchMap((windowMode) => {
const spotlightLandscapeLayout = combineLatest(
[this.grid, this.spotlight],
(grid, spotlight): Layout => ({
type: "spotlight-landscape",
spotlight,
grid,
}),
);
const spotlightExpandedLayout = combineLatest(
[this.spotlight, this.pip],
(spotlight, pip): Layout => ({
type: "spotlight-expanded",
spotlight,
pip: pip ?? undefined,
}),
);
switch (windowMode) {
case "full screen":
throw new Error("unimplemented");
case "pip":
throw new Error("unimplemented");
case "normal":
return this.gridMode.pipe(
switchMap((gridMode) => {
@@ -532,11 +606,9 @@ export class CallViewModel extends ViewModel {
return combineLatest(
[this.grid, this.spotlight, this.screenShares],
(grid, spotlight, screenShares): Layout =>
grid.length == 2
grid.length == 2 && screenShares.length === 0
? {
type: "one-on-one",
spotlight:
screenShares.length > 0 ? spotlight : undefined,
local: grid.find(
(vm) => vm.local,
) as LocalUserMediaViewModel,
@@ -555,22 +627,59 @@ export class CallViewModel extends ViewModel {
},
);
case "spotlight":
return combineLatest(
[this.grid, this.spotlight],
(grid, spotlight): Layout => ({
type: "spotlight",
spotlight,
grid,
}),
return this.spotlightExpanded.pipe(
switchMap((expanded) =>
expanded
? spotlightExpandedLayout
: spotlightLandscapeLayout,
),
);
}
}),
);
case "narrow":
return combineLatest(
[this.grid, this.spotlight],
(grid, spotlight): Layout => ({
type: "spotlight-portrait",
spotlight,
grid,
}),
);
case "flat":
return this.gridMode.pipe(
switchMap((gridMode) => {
switch (gridMode) {
case "grid":
// Yes, grid mode actually gets you a "spotlight" layout in
// this window mode.
return spotlightLandscapeLayout;
case "spotlight":
return spotlightExpandedLayout;
}
}),
);
case "pip":
return this.spotlight.pipe(
map((spotlight): Layout => ({ type: "pip", spotlight })),
);
}
}),
shareReplay(1),
);
public showSpotlightIndicators: Observable<boolean> = this.layout.pipe(
map((l) => l.type !== "grid"),
distinctUntilChanged(),
shareReplay(1),
);
public showSpeakingIndicators: Observable<boolean> = this.layout.pipe(
map((l) => l.type !== "one-on-one" && l.type !== "spotlight-expanded"),
distinctUntilChanged(),
shareReplay(1),
);
public constructor(
// A call is permanently tied to a single Matrix room and LiveKit room
private readonly matrixRoom: MatrixRoom,

View File

@@ -22,7 +22,7 @@ limitations under the License.
/* Use a pseudo-element to create the expressive speaking border, since CSS
borders don't support gradients */
.tile[data-maximised="false"]::before {
.tile::before {
content: "";
position: absolute;
z-index: -1; /* Put it below the outline */
@@ -43,27 +43,22 @@ borders don't support gradients */
background-blend-mode: overlay, normal;
}
.tile[data-maximised="false"].speaking {
.tile.speaking {
/* !important because speaking border should take priority over hover */
outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important;
}
.tile[data-maximised="false"].speaking::before {
.tile.speaking::before {
opacity: 1;
}
@media (hover: hover) {
.tile[data-maximised="false"]:hover {
.tile:hover {
outline: var(--cpd-border-width-2) solid
var(--cpd-color-border-interactive-hovered);
}
}
.tile[data-maximised="true"] {
--media-view-border-radius: 0;
--media-view-fg-inset: 10px;
}
.muteIcon[data-muted="true"] {
color: var(--cpd-color-icon-secondary);
}

View File

@@ -57,7 +57,6 @@ interface TileProps {
style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number;
targetHeight: number;
maximised: boolean;
displayName: string;
nameTag: string;
showSpeakingIndicators: boolean;
@@ -79,7 +78,6 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
menuEnd,
className,
nameTag,
maximised,
...props
},
ref,
@@ -151,7 +149,6 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
{menu}
</Menu>
}
data-maximised={maximised}
{...props}
/>
);
@@ -273,9 +270,6 @@ RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
interface GridTileProps {
vm: UserMediaViewModel;
maximised: boolean;
fullscreen: boolean;
onToggleFullscreen: (itemId: string) => void;
onOpenProfile: () => void;
targetWidth: number;
targetHeight: number;
@@ -285,7 +279,7 @@ interface GridTileProps {
}
export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
({ vm, fullscreen, onToggleFullscreen, onOpenProfile, ...props }, ref) => {
({ vm, onOpenProfile, ...props }, ref) => {
const nameData = useNameData(vm);
if (vm instanceof LocalUserMediaViewModel) {

View File

@@ -94,7 +94,7 @@ unconditionally select the container so we can use cqmin units */
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: 1fr auto;
grid-template-areas: ". button2" "nameTag button1";
grid-template-areas: ". ." "nameTag button";
gap: var(--cpd-space-1x);
place-items: start;
}
@@ -175,9 +175,5 @@ unconditionally select the container so we can use cqmin units */
}
.fg > button:first-of-type {
grid-area: button1;
}
.fg > button:nth-of-type(2) {
grid-area: button2;
grid-area: button;
}

View File

@@ -42,7 +42,6 @@ interface Props extends ComponentProps<typeof animated.div> {
nameTag: string;
displayName: string;
primaryButton?: ReactNode;
secondaryButton?: ReactNode;
}
export const MediaView = forwardRef<HTMLDivElement, Props>(
@@ -62,7 +61,6 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
nameTag,
displayName,
primaryButton,
secondaryButton,
...props
},
ref,
@@ -120,7 +118,6 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
)}
</div>
{primaryButton}
{secondaryButton}
</div>
</animated.div>
);

View File

@@ -15,28 +15,11 @@ limitations under the License.
*/
.tile {
--border-width: var(--cpd-space-3x);
}
.tile.maximised {
--border-width: 0px;
}
.border {
box-sizing: border-box;
block-size: 100%;
inline-size: 100%;
}
.tile.maximised .border {
display: contents;
}
.contents {
display: flex;
border-radius: var(--cpd-space-6x);
contain: strict;
overflow: auto;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
scroll-snap-type: inline mandatory;
scroll-snap-stop: always;
@@ -46,18 +29,18 @@ limitations under the License.
scroll-behavior: smooth; */
}
.tile.maximised .contents {
.tile.maximised {
border-radius: 0;
}
.contents > .item {
.item {
height: 100%;
flex-basis: 100%;
flex-shrink: 0;
--media-view-fg-inset: 10px;
}
.contents > .item.snap {
.item.snap {
scroll-snap-align: start;
}
@@ -105,7 +88,7 @@ limitations under the License.
inset-inline-end: var(--cpd-space-1x);
}
.fullScreen {
.expand {
appearance: none;
cursor: pointer;
opacity: 0;
@@ -118,23 +101,23 @@ limitations under the License.
transition-property: opacity, background-color;
position: absolute;
z-index: 1;
--inset: calc(var(--border-width) + 6px);
--inset: 6px;
inset-block-end: var(--inset);
inset-inline-end: var(--inset);
}
.fullScreen > svg {
.expand > svg {
display: block;
color: var(--cpd-color-icon-on-solid-primary);
}
@media (hover) {
.fullScreen:hover {
.expand:hover {
background: var(--cpd-color-bg-action-primary-hovered);
}
}
.fullScreen:active {
.expand:active {
background: var(--cpd-color-bg-action-primary-pressed);
}

View File

@@ -23,7 +23,6 @@ import {
useRef,
useState,
} from "react";
import { Glass } from "@vector-im/compound-web";
import ExpandIcon from "@vector-im/compound-design-tokens/icons/expand.svg?react";
import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?react";
import ChevronLeftIcon from "@vector-im/compound-design-tokens/icons/chevron-left.svg?react";
@@ -174,8 +173,8 @@ SpotlightItem.displayName = "SpotlightItem";
interface Props {
vms: MediaViewModel[];
maximised: boolean;
fullscreen: boolean;
onToggleFullscreen: () => void;
expanded: boolean;
onToggleExpanded: (() => void) | null;
targetWidth: number;
targetHeight: number;
showIndicators: boolean;
@@ -188,8 +187,8 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
{
vms,
maximised,
fullscreen,
onToggleFullscreen,
expanded,
onToggleExpanded,
targetWidth,
targetHeight,
showIndicators,
@@ -254,9 +253,8 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
setScrollToId(vms[visibleIndex + 1].id);
}, [latestVisibleId, latestVms, setScrollToId]);
const FullScreenIcon = fullscreen ? CollapseIcon : ExpandIcon;
const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon;
// We need a wrapper element because Glass doesn't provide an animated.div
return (
<animated.div
ref={ref}
@@ -274,33 +272,29 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
<ChevronLeftIcon aria-hidden width={24} height={24} />
</button>
)}
<Glass className={styles.border}>
{/* Similarly we need a wrapper element here because Glass expects a
single child */}
<div className={styles.contents}>
{vms.map((vm) => (
<SpotlightItem
key={vm.id}
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
intersectionObserver={intersectionObserver}
snap={scrollToId === null || scrollToId === vm.id}
/>
))}
</div>
</Glass>
<button
className={classNames(styles.fullScreen)}
aria-label={
fullscreen
? t("video_tile.full_screen")
: t("video_tile.exit_full_screen")
}
onClick={onToggleFullscreen}
>
<FullScreenIcon aria-hidden width={20} height={20} />
</button>
{vms.map((vm) => (
<SpotlightItem
key={vm.id}
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
intersectionObserver={intersectionObserver}
snap={scrollToId === null || scrollToId === vm.id}
/>
))}
{onToggleExpanded && (
<button
className={classNames(styles.expand)}
aria-label={
expanded
? t("video_tile.full_screen")
: t("video_tile.exit_full_screen")
}
onClick={onToggleExpanded}
>
<ToggleExpandIcon aria-hidden width={20} height={20} />
</button>
)}
{canGoToNext && (
<button
className={classNames(styles.advance, styles.next)}
@@ -310,15 +304,17 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
<ChevronRightIcon aria-hidden width={24} height={24} />
</button>
)}
<div
className={classNames(styles.indicators, {
[styles.show]: showIndicators && vms.length > 1,
})}
>
{vms.map((vm) => (
<div className={styles.item} data-visible={vm.id === visibleId} />
))}
</div>
{!expanded && (
<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>
);
},