Implement most of the remaining layout changes

Includes the mobile UX optimizations and the tweaks we've made to cut down on wasted space, but does not yet include the change to embed the spotlight tile within the grid.
This commit is contained in:
Robin
2024-07-03 15:08:30 -04:00
parent a16f235277
commit 2440037639
25 changed files with 761 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.
*/
@@ -121,7 +125,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
@@ -132,7 +136,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

@@ -40,6 +40,8 @@ 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;
@@ -36,7 +35,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,19 @@ 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";
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,99 @@
/*
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";
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,93 @@
/*
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";
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,39 @@ 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;
}
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 +58,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 +89,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";
// How long we wait after a focus switch before showing the real participant
@@ -80,25 +80,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[];
@@ -110,14 +115,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.
@@ -269,16 +275,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[]> =
@@ -352,6 +355,11 @@ export class CallViewModel extends ViewModel {
map((ms) => ms.filter((m): m is UserMedia => m instanceof UserMedia)),
);
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((ms) => ms.filter((m): m is ScreenShare => m instanceof ScreenShare)),
@@ -364,7 +372,7 @@ export class CallViewModel extends ViewModel {
distinctUntilChanged(),
);
private readonly spotlightSpeaker: Observable<UserMedia | null> =
private readonly spotlightSpeaker: Observable<UserMediaViewModel> =
this.userMedia.pipe(
switchMap((ms) =>
ms.length === 0
@@ -373,7 +381,7 @@ export class CallViewModel extends ViewModel {
ms.map((m) => m.vm.speaking.pipe(map((s) => [m, s] as const))),
),
),
scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>(
scan<(readonly [UserMedia, boolean])[], UserMedia, null>(
(prev, ms) =>
// Decide who to spotlight:
// If the previous speaker (not the local user) is still speaking,
@@ -386,11 +394,11 @@ export class CallViewModel extends ViewModel {
// Otherwise, stick with the person who was last speaking
prev ??
// Otherwise, spotlight the local user
ms.find(([m]) => m.vm.local)?.[0] ??
null,
ms.find(([m]) => m.vm.local)![0],
null,
),
distinctUntilChanged(),
map((speaker) => speaker.vm),
shareReplay(1),
throttleTime(1600, undefined, { leading: true, trailing: true }),
);
@@ -433,38 +441,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);
@@ -472,11 +533,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) => {
@@ -485,11 +559,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,
@@ -507,22 +579,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>
);
},