Merge pull request #3916 from element-hq/one-on-one-portrait

Create a proper one-on-one call layout for portrait screens
This commit is contained in:
Robin
2026-05-18 13:28:59 +02:00
committed by GitHub
29 changed files with 933 additions and 399 deletions

View File

@@ -1,5 +1,21 @@
.bar { .bar {
flex-shrink: 0; flex-shrink: 0;
position: relative;
}
/* Pseudo-element for the gradient background */
.bar::before {
content: "";
position: absolute;
inset-inline: 0;
/* Extend the gradient beyond the bottom of the header for readability */
inset-block: -24px;
z-index: var(--call-view-header-footer-layer);
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0) 0%,
var(--cpd-color-bg-canvas-default) 100%
);
} }
.bar > header { .bar > header {

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type BehaviorSubject, type Observable } from "rxjs"; import { type Observable } from "rxjs";
import { type ComponentType } from "react"; import { type ComponentType } from "react";
import { type LayoutProps } from "./Grid"; import { type LayoutProps } from "./Grid";
@@ -16,37 +16,18 @@ export interface Bounds {
height: number; height: number;
} }
export interface Alignment {
inline: "start" | "end";
block: "start" | "end";
}
export const defaultSpotlightAlignment: Alignment = {
inline: "end",
block: "end",
};
export const defaultPipAlignment: Alignment = { inline: "end", block: "start" };
export interface CallLayoutInputs { export interface CallLayoutInputs {
/** /**
* The minimum bounds of the layout area. * The minimum bounds of the layout area.
*/ */
minBounds$: Observable<Bounds>; minBounds$: Observable<Bounds>;
/**
* The alignment of the floating spotlight tile, if present.
*/
spotlightAlignment$: BehaviorSubject<Alignment>;
/**
* The alignment of the small picture-in-picture tile, if present.
*/
pipAlignment$: BehaviorSubject<Alignment>;
} }
export interface CallLayoutOutputs<Model> { export interface CallLayoutOutputs<Model> {
/** /**
* Whether the scrolling layer of the layout should appear on top. * Which layer should appear in the foreground.
*/ */
scrollingOnTop: boolean; foreground: "fixed" | "scrolling";
/** /**
* The visually fixed (non-scrolling) layer of the layout. * The visually fixed (non-scrolling) layer of the layout.
*/ */

View File

@@ -32,9 +32,8 @@ interface GridCSSProperties extends CSSProperties {
*/ */
export const makeGridLayout: CallLayout<GridLayoutModel> = ({ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
minBounds$, minBounds$,
spotlightAlignment$,
}) => ({ }) => ({
scrollingOnTop: false, foreground: "fixed",
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile // The "fixed" (non-scrolling) part of the layout is where the spotlight tile
// lives // lives
@@ -42,7 +41,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
useUpdateLayout(); useUpdateLayout();
const alignment = useObservableEagerState( const alignment = useObservableEagerState(
useInitial(() => useInitial(() =>
spotlightAlignment$.pipe( model.spotlightAlignment$.pipe(
distinctUntilChanged( distinctUntilChanged(
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline, (a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
), ),
@@ -52,11 +51,11 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
const onDragSpotlight: DragCallback = useCallback( const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) => ({ xRatio, yRatio }) =>
spotlightAlignment$.next({ model.spotlightAlignment$.next({
block: yRatio < 0.5 ? "start" : "end", block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end",
}), }),
[], [model.spotlightAlignment$],
); );
return ( return (

View File

@@ -22,12 +22,6 @@ Please see LICENSE in the repository root for full details.
inset: 0; inset: 0;
} }
.spotlight {
position: absolute;
inline-size: 404px;
block-size: 233px;
}
.slot[data-block-alignment="start"] { .slot[data-block-alignment="start"] {
inset-block-end: unset; inset-block-end: unset;
} }

View File

@@ -1,5 +1,6 @@
/* /*
Copyright 2024 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
@@ -9,31 +10,35 @@ import { type ReactNode, useCallback, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames"; import classNames from "classnames";
import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/layout-types.ts"; import { type OneOnOneLandscapeLayout as OneOnOneLandscapeLayoutModel } from "../state/layout-types.ts";
import { type CallLayout, arrangeTiles } from "./CallLayout"; import { type CallLayout, arrangeTiles } from "./CallLayout";
import styles from "./OneOnOneLayout.module.css"; import styles from "./OneOnOneLandscapeLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid"; import { type DragCallback, useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
/** /**
* An implementation of the "one-on-one" layout, in which the remote participant * An implementation of the "one-on-one" layout for landscape screens, in which
* is shown at maximum size, overlaid by a small view of the local participant. * the remote participant is shown at maximum size, overlaid by a small view of
* the local participant.
*/ */
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({ export const makeOneOnOneLandscapeLayout: CallLayout<
minBounds$, OneOnOneLandscapeLayoutModel
pipAlignment$, > = ({ minBounds$ }) => ({
}) => ({ foreground: "fixed",
scrollingOnTop: false,
fixed: function OneOnOneLayoutFixed({ ref }): ReactNode { fixed: function OneOnOneLandscapeLayoutFixed({ ref }): ReactNode {
useUpdateLayout(); useUpdateLayout();
return <div ref={ref} />; return <div ref={ref} />;
}, },
scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode { scrolling: function OneOnOneLandscapeLayoutScrolling({
ref,
model,
Slot,
}): ReactNode {
useUpdateLayout(); useUpdateLayout();
const { width, height } = useObservableEagerState(minBounds$); const { width, height } = useObservableEagerState(minBounds$);
const pipAlignmentValue = useBehavior(pipAlignment$); const pipAlignment = useBehavior(model.pipAlignment$);
const { tileWidth, tileHeight } = useMemo( const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1), () => arrangeTiles(width, height, 1),
[width, height], [width, height],
@@ -41,11 +46,11 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
const onDragLocalTile: DragCallback = useCallback( const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) => ({ xRatio, yRatio }) =>
pipAlignment$.next({ model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end", block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end",
}), }),
[], [model.pipAlignment$],
); );
return ( return (
@@ -61,8 +66,8 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
id={model.pip.id} id={model.pip.id}
model={model.pip} model={model.pip}
onDrag={onDragLocalTile} onDrag={onDragLocalTile}
data-block-alignment={pipAlignmentValue.block} data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignmentValue.inline} data-inline-alignment={pipAlignment.inline}
/> />
</Slot> </Slot>
</div> </div>

View File

@@ -0,0 +1,46 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
.layer {
block-size: 100%;
}
.spotlight {
block-size: 100%;
inline-size: 100%;
}
.pip {
position: absolute;
inset: var(--cpd-space-4x);
}
.pip[data-size="sm"] {
inline-size: 88px;
block-size: 132px;
}
.pip[data-size="lg"] {
inline-size: 140px;
block-size: 210px;
}
.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,74 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback } from "react";
import classNames from "classnames";
import { type OneOnOnePortraitLayout as OneOnOnePortraitLayoutModel } from "../state/layout-types.ts";
import { type CallLayout } from "./CallLayout";
import styles from "./OneOnOnePortraitLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
/**
* An implementation of the "one-on-one" layout for portrait screens, in which
* the remote participant is shown at maximum size, overlaid by a small view of
* the local participant.
*/
export const makeOneOnOnePortraitLayout: CallLayout<
OneOnOnePortraitLayoutModel
> = () => ({
foreground: "scrolling",
fixed: function OneOnOnePortraitLayoutFixed({ ref, model, Slot }): ReactNode {
useUpdateLayout();
return (
<div ref={ref} className={styles.layer}>
<Slot
className={styles.spotlight}
id="spotlight"
model={model.spotlight}
/>
</div>
);
},
scrolling: function OneOnOnePortraitLayoutScrolling({
ref,
model,
Slot,
}): ReactNode {
useUpdateLayout();
const pipSize = useBehavior(model.pipSize$);
const pipAlignment = useBehavior(model.pipAlignment$);
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return (
<div ref={ref} className={styles.layer}>
{model.pip && (
<Slot
className={classNames(styles.pip)}
id={model.pip.id}
model={model.pip}
onDrag={onDragLocalTile}
data-size={pipSize}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
)}
</div>
);
},
});

View File

@@ -19,8 +19,8 @@ import { useBehavior } from "../useBehavior";
*/ */
export const makeSpotlightExpandedLayout: CallLayout< export const makeSpotlightExpandedLayout: CallLayout<
SpotlightExpandedLayoutModel SpotlightExpandedLayoutModel
> = ({ pipAlignment$ }) => ({ > = () => ({
scrollingOnTop: true, foreground: "scrolling",
fixed: function SpotlightExpandedLayoutFixed({ fixed: function SpotlightExpandedLayoutFixed({
ref, ref,
@@ -46,15 +46,15 @@ export const makeSpotlightExpandedLayout: CallLayout<
Slot, Slot,
}): ReactNode { }): ReactNode {
useUpdateLayout(); useUpdateLayout();
const pipAlignmentValue = useBehavior(pipAlignment$); const pipAlignment = useBehavior(model.pipAlignment$);
const onDragPip: DragCallback = useCallback( const onDragPip: DragCallback = useCallback(
({ xRatio, yRatio }) => ({ xRatio, yRatio }) =>
pipAlignment$.next({ model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end", block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end",
}), }),
[], [model.pipAlignment$],
); );
return ( return (
@@ -65,8 +65,8 @@ export const makeSpotlightExpandedLayout: CallLayout<
id={model.pip.id} id={model.pip.id}
model={model.pip} model={model.pip}
onDrag={onDragPip} onDrag={onDragPip}
data-block-alignment={pipAlignmentValue.block} data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignmentValue.inline} data-inline-alignment={pipAlignment.inline}
/> />
)} )}
</div> </div>

View File

@@ -22,7 +22,7 @@ import { useUpdateLayout, useVisibleTiles } from "./Grid";
export const makeSpotlightLandscapeLayout: CallLayout< export const makeSpotlightLandscapeLayout: CallLayout<
SpotlightLandscapeLayoutModel SpotlightLandscapeLayoutModel
> = ({ minBounds$ }) => ({ > = ({ minBounds$ }) => ({
scrollingOnTop: false, foreground: "scrolling",
fixed: function SpotlightLandscapeLayoutFixed({ fixed: function SpotlightLandscapeLayoutFixed({
ref, ref,

View File

@@ -29,7 +29,7 @@ interface GridCSSProperties extends CSSProperties {
export const makeSpotlightPortraitLayout: CallLayout< export const makeSpotlightPortraitLayout: CallLayout<
SpotlightPortraitLayoutModel SpotlightPortraitLayoutModel
> = ({ minBounds$ }) => ({ > = ({ minBounds$ }) => ({
scrollingOnTop: false, foreground: "fixed",
fixed: function SpotlightPortraitLayoutFixed({ fixed: function SpotlightPortraitLayoutFixed({
ref, ref,

View File

@@ -26,6 +26,33 @@ Please see LICENSE in the repository root for full details.
); );
} }
.header.hidden {
display: none;
}
.header.overlay {
/* Note that the header is still position: sticky in this case so that certain
tiles can move down out of the way of the header when visible. */
opacity: 1;
transition: opacity 0.15s;
}
.header.overlay.hidden {
display: flex;
opacity: 0;
pointer-events: none;
/* Switch to position: absolute so the header takes up no space in the layout
when hidden. */
position: absolute;
inset-block-start: 0;
inset-inline: 0;
}
.header.overlay:has(:focus-visible) {
opacity: 1;
pointer-events: initial;
}
.header.filler { .header.filler {
block-size: var(--cpd-space-6x); block-size: var(--cpd-space-6x);
background: none; background: none;

View File

@@ -21,7 +21,7 @@ import {
import useMeasure from "react-use-measure"; import useMeasure from "react-use-measure";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import classNames from "classnames"; import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs"; import { map } from "rxjs";
import { useObservable } from "observable-hooks"; import { useObservable } from "observable-hooks";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -51,12 +51,9 @@ import { SpotlightTile } from "../tile/SpotlightTile";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { makeGridLayout } from "../grid/GridLayout"; import { makeGridLayout } from "../grid/GridLayout";
import { import { type CallLayoutOutputs } from "../grid/CallLayout";
type CallLayoutOutputs, import { makeOneOnOneLandscapeLayout } from "../grid/OneOnOneLandscapeLayout";
defaultPipAlignment, import { makeOneOnOnePortraitLayout } from "../grid/OneOnOnePortraitLayout";
defaultSpotlightAlignment,
} from "../grid/CallLayout";
import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout"; import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout"; import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout"; import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
@@ -93,6 +90,13 @@ import { useLatest } from "../useLatest.ts";
import { CallFooter } from "../components/CallFooter.tsx"; import { CallFooter } from "../components/CallFooter.tsx";
import { SettingsIconButton } from "../button/Button.tsx"; import { SettingsIconButton } from "../button/Button.tsx";
declare module "react" {
interface CSSProperties {
"--call-view-safe-area-inset-top"?: string;
"--call-view-safe-area-inset-bottom"?: string;
}
}
const logger = rootLogger.getChild("[InCallView]"); const logger = rootLogger.getChild("[InCallView]");
export interface ActiveCallProps extends Omit< export interface ActiveCallProps extends Omit<
@@ -239,10 +243,11 @@ export const InCallView: FC<InCallViewProps> = ({
const audioParticipants = useBehavior(vm.livekitRoomItems$); const audioParticipants = useBehavior(vm.livekitRoomItems$);
const participantCount = useBehavior(vm.participantCount$); const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$); const reconnecting = useBehavior(vm.reconnecting$);
const windowMode = useBehavior(vm.windowMode$);
const layout = useBehavior(vm.layout$); const layout = useBehavior(vm.layout$);
const edgeToEdge = useBehavior(vm.edgeToEdge$);
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$); const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
const [debugTileLayout] = useSetting(debugTileLayoutSetting); const [debugTileLayout] = useSetting(debugTileLayoutSetting);
const showNameTags = useBehavior(vm.showNameTags$);
const gridMode = useBehavior(vm.gridMode$); const gridMode = useBehavior(vm.gridMode$);
const showHeader = useBehavior(vm.showHeader$); const showHeader = useBehavior(vm.showHeader$);
const showFooter = useBehavior(vm.showFooter$); const showFooter = useBehavior(vm.showFooter$);
@@ -325,15 +330,14 @@ export const InCallView: FC<InCallViewProps> = ({
width: bounds.width, width: bounds.width,
height: height:
bounds.height - bounds.height -
headerBounds.height - (edgeToEdge ? 0 : headerBounds.height + footerBounds.height),
(windowMode === "flat" ? 0 : footerBounds.height),
}), }),
[ [
bounds.width, bounds.width,
bounds.height, bounds.height,
headerBounds.height, headerBounds.height,
footerBounds.height, footerBounds.height,
windowMode, edgeToEdge,
], ],
); );
const gridBoundsObservable$ = useObservable( const gridBoundsObservable$ = useObservable(
@@ -341,13 +345,6 @@ export const InCallView: FC<InCallViewProps> = ({
[gridBounds], [gridBounds],
); );
const spotlightAlignment$ = useInitial(
() => new BehaviorSubject(defaultSpotlightAlignment),
);
const pipAlignment$ = useInitial(
() => new BehaviorSubject(defaultPipAlignment),
);
const setGridMode = useCallback( const setGridMode = useCallback(
(mode: GridMode) => vm.setGridMode(mode), (mode: GridMode) => vm.setGridMode(mode),
[vm], [vm],
@@ -356,49 +353,47 @@ export const InCallView: FC<InCallViewProps> = ({
useAppBarHidden(!showHeader); useAppBarHidden(!showHeader);
let header: ReactNode = null; let header: ReactNode = null;
if (showHeader) { switch (headerStyle) {
switch (headerStyle) { case HeaderStyle.AppBar: {
case HeaderStyle.AppBar: { // dont build a header here. The AppBar will take care of it.
// dont build a header here. The AppBar will take care of it. break;
break;
}
case HeaderStyle.None:
// Cosmetic header to fill out space while still affecting the bounds of
// the grid
header = (
<div
className={classNames(styles.header, styles.filler)}
ref={headerRef}
/>
);
break;
case HeaderStyle.Standard:
header = (
<Header
className={styles.header}
ref={headerRef}
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
>
<LeftNav>
<RoomHeaderInfo
id={matrixInfo.roomId}
name={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatar}
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
participantCount={participantCount}
/>
</LeftNav>
<RightNav>
{showControls && onShareClick !== null && (
<InviteButton
className={styles.invite}
onClick={onShareClick}
/>
)}
</RightNav>
</Header>
);
} }
case HeaderStyle.None:
// Cosmetic header to fill out space while still affecting the bounds of
// the grid
header = showHeader && (
<div
className={classNames(styles.header, styles.filler)}
ref={headerRef}
/>
);
break;
case HeaderStyle.Standard:
header = (
<Header
className={classNames(styles.header, {
[styles.overlay]: edgeToEdge,
[styles.hidden]: !showHeader,
})}
ref={headerRef}
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
>
<LeftNav>
<RoomHeaderInfo
id={matrixInfo.roomId}
name={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatar}
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
participantCount={participantCount}
/>
</LeftNav>
<RightNav>
{showControls && onShareClick !== null && (
<InviteButton className={styles.invite} onClick={onShareClick} />
)}
</RightNav>
</Header>
);
} }
// The reconnecting toast cannot be dismissed // The reconnecting toast cannot be dismissed
@@ -445,12 +440,11 @@ export const InCallView: FC<InCallViewProps> = ({
}: TileProps<TileViewModel, HTMLDivElement>): ReactNode { }: TileProps<TileViewModel, HTMLDivElement>): ReactNode {
const spotlightExpanded = useBehavior(vm.spotlightExpanded$); const spotlightExpanded = useBehavior(vm.spotlightExpanded$);
const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$); const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$);
const showSpeakingIndicatorsValue = useBehavior( const showSpotlightIndicators = useBehavior(
vm.showSpeakingIndicators$,
);
const showSpotlightIndicatorsValue = useBehavior(
vm.showSpotlightIndicators$, vm.showSpotlightIndicators$,
); );
const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$);
const showNameTags = useBehavior(vm.showNameTags$);
return model instanceof GridTileViewModel ? ( return model instanceof GridTileViewModel ? (
<GridTile <GridTile
@@ -461,7 +455,8 @@ export const InCallView: FC<InCallViewProps> = ({
targetHeight={targetHeight} targetHeight={targetHeight}
className={classNames(className, styles.tile)} className={classNames(className, styles.tile)}
style={style} style={style}
showSpeakingIndicators={showSpeakingIndicatorsValue} showSpeakingIndicators={showSpeakingIndicators}
showNameTags={showNameTags}
focusable={!contentObscured} focusable={!contentObscured}
/> />
) : ( ) : (
@@ -472,7 +467,8 @@ export const InCallView: FC<InCallViewProps> = ({
onToggleExpanded={onToggleExpanded} onToggleExpanded={onToggleExpanded}
targetWidth={targetWidth} targetWidth={targetWidth}
targetHeight={targetHeight} targetHeight={targetHeight}
showIndicators={showSpotlightIndicatorsValue} showIndicators={showSpotlightIndicators}
showNameTags={showNameTags}
focusable={!contentObscured} focusable={!contentObscured}
className={classNames(className, styles.tile)} className={classNames(className, styles.tile)}
style={style} style={style}
@@ -483,19 +479,16 @@ export const InCallView: FC<InCallViewProps> = ({
); );
const layouts = useMemo(() => { const layouts = useMemo(() => {
const inputs = { const inputs = { minBounds$: gridBoundsObservable$ };
minBounds$: gridBoundsObservable$,
spotlightAlignment$,
pipAlignment$,
};
return { return {
grid: makeGridLayout(inputs), grid: makeGridLayout(inputs),
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs), "spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
"spotlight-portrait": makeSpotlightPortraitLayout(inputs), "spotlight-portrait": makeSpotlightPortraitLayout(inputs),
"spotlight-expanded": makeSpotlightExpandedLayout(inputs), "spotlight-expanded": makeSpotlightExpandedLayout(inputs),
"one-on-one": makeOneOnOneLayout(inputs), "one-on-one-landscape": makeOneOnOneLandscapeLayout(inputs),
"one-on-one-portrait": makeOneOnOnePortraitLayout(inputs),
}; };
}, [gridBoundsObservable$, spotlightAlignment$, pipAlignment$]); }, [gridBoundsObservable$]);
const renderContent = (): JSX.Element => { const renderContent = (): JSX.Element => {
if (layout.type === "pip") { if (layout.type === "pip") {
@@ -508,6 +501,7 @@ export const InCallView: FC<InCallViewProps> = ({
targetWidth={gridBounds.width} targetWidth={gridBounds.width}
targetHeight={gridBounds.height} targetHeight={gridBounds.height}
showIndicators={false} showIndicators={false}
showNameTags={showNameTags}
focusable={!contentObscured} focusable={!contentObscured}
aria-hidden={contentObscured} aria-hidden={contentObscured}
/> />
@@ -521,8 +515,18 @@ export const InCallView: FC<InCallViewProps> = ({
className={styles.fixedGrid} className={styles.fixedGrid}
style={{ style={{
insetBlockStart: insetBlockStart:
headerBounds.height > 0 ? headerBounds.bottom : bounds.top, edgeToEdge || headerBounds.height === 0 ? 0 : headerBounds.bottom,
height: gridBounds.height, height: edgeToEdge ? "100%" : gridBounds.height,
// If edge-to-edge, compute new safe area insets that account for the
// header and footer.
"--call-view-safe-area-inset-top":
edgeToEdge && header && showHeader
? `calc(env(safe-area-inset-top) + ${headerBounds.height}px)`
: undefined,
"--call-view-safe-area-inset-bottom":
edgeToEdge && showFooter
? `calc(env(safe-area-inset-bottom) + ${footerBounds.height}px)`
: undefined,
}} }}
model={layout} model={layout}
Layout={layers.fixed} Layout={layers.fixed}
@@ -540,19 +544,24 @@ export const InCallView: FC<InCallViewProps> = ({
aria-hidden={contentObscured} aria-hidden={contentObscured}
/> />
); );
// The grid tiles go *under* the spotlight in the portrait layout, but
// *over* the spotlight in the expanded layout // Put the right layer in the foreground for the requested layout
return layout.type === "spotlight-expanded" ? ( switch (layers.foreground) {
<> case "fixed":
{fixedGrid} return (
{scrollingGrid} <>
</> {scrollingGrid}
) : ( {fixedGrid}
<> </>
{scrollingGrid} );
{fixedGrid} case "scrolling":
</> return (
); <>
{fixedGrid}
{scrollingGrid}
</>
);
}
}; };
const rageshakeRequestModalProps = useRageshakeRequestModal( const rageshakeRequestModalProps = useRageshakeRequestModal(
@@ -575,7 +584,7 @@ export const InCallView: FC<InCallViewProps> = ({
ref={footerRef} ref={footerRef}
hidden={!showFooter} hidden={!showFooter}
hideControls={!showControls} hideControls={!showControls}
asOverlay={windowMode === "flat"} asOverlay={edgeToEdge}
asPip={layout.type === "pip"} asPip={layout.type === "pip"}
// Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None. // Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None.
hideLogo={headerStyle !== HeaderStyle.Standard} hideLogo={headerStyle !== HeaderStyle.Standard}

View File

@@ -50,6 +50,7 @@ import {
aliceParticipant, aliceParticipant,
aliceRtcMember, aliceRtcMember,
aliceUserId, aliceUserId,
bob,
bobId, bobId,
bobRtcMember, bobRtcMember,
local, local,
@@ -133,12 +134,19 @@ export interface SpotlightExpandedLayoutSummary {
pip?: string; pip?: string;
} }
export interface OneOnOneLayoutSummary { export interface OneOnOneLandscapeLayoutSummary {
type: "one-on-one"; type: "one-on-one-landscape";
spotlight: string; spotlight: string;
pip: string; pip: string;
} }
export interface OneOnOnePortraitLayoutSummary {
type: "one-on-one-portrait";
spotlight: string[];
pip?: string;
pipSize: "sm" | "lg";
}
export interface PipLayoutSummary { export interface PipLayoutSummary {
type: "pip"; type: "pip";
spotlight: string[]; spotlight: string[];
@@ -149,7 +157,8 @@ export type LayoutSummary =
| SpotlightLandscapeLayoutSummary | SpotlightLandscapeLayoutSummary
| SpotlightPortraitLayoutSummary | SpotlightPortraitLayoutSummary
| SpotlightExpandedLayoutSummary | SpotlightExpandedLayoutSummary
| OneOnOneLayoutSummary | OneOnOneLandscapeLayoutSummary
| OneOnOnePortraitLayoutSummary
| PipLayoutSummary; | PipLayoutSummary;
function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> { function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
@@ -187,7 +196,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
pip: pip?.id, pip: pip?.id,
}), }),
); );
case "one-on-one": case "one-on-one-landscape":
return combineLatest( return combineLatest(
[l.spotlight.media$, l.pip.media$], [l.spotlight.media$, l.pip.media$],
(spotlight, pip) => ({ (spotlight, pip) => ({
@@ -196,6 +205,20 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
pip: pip.id, pip: pip.id,
}), }),
); );
case "one-on-one-portrait":
return combineLatest(
[
l.spotlight.media$,
l.pip?.media$ ?? constant(undefined),
l.pipSize$,
],
(spotlight, pip, pipSize) => ({
type: l.type,
spotlight: spotlight.map((vm) => vm.id),
pip: pip?.id,
pipSize,
}),
);
case "pip": case "pip":
return l.spotlight.media$.pipe( return l.spotlight.media$.pipe(
map((spotlight) => ({ map((spotlight) => ({
@@ -405,7 +428,7 @@ describe.each([
expectedLayoutMarbles, expectedLayoutMarbles,
{ {
a: { a: {
type: "one-on-one", type: "one-on-one-landscape",
pip: `${localId}:0`, pip: `${localId}:0`,
spotlight: `${aliceId}:0`, spotlight: `${aliceId}:0`,
}, },
@@ -421,6 +444,85 @@ describe.each([
}); });
}); });
test("one-on-one portrait layout shows local tile when video is enabled", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Local participant enables their video, then disables it
const videoInputMarbles = " ny--n";
// While tile is shown, tap the screen twice
const tapScreenInputMarbles = "--aa-";
// Layout should show local tile, make it small, enlarge it again, then hide it
const expectedLayoutMarbles = "abcba";
withCallViewModel(
{
remoteParticipants$: constant([aliceParticipant]),
roomMembers: [local, alice],
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
videoEnabled: new Map([
[localParticipant, behavior(videoInputMarbles, yesNo)],
]),
windowSize$: constant({ width: 380, height: 700 }), // Mobile phone in portrait
},
(vm) => {
schedule(tapScreenInputMarbles, { a: () => vm.tapScreen() });
expectObservable(vm.edgeToEdge$).toBe("y", yesNo); // Edge-to-edge-layout
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
type: "one-on-one-portrait",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
},
b: {
type: "one-on-one-portrait",
spotlight: [`${aliceId}:0`],
pip: `${localId}:0`,
pipSize: "lg",
},
c: {
type: "one-on-one-portrait",
spotlight: [`${aliceId}:0`],
pip: `${localId}:0`,
pipSize: "sm",
},
},
);
},
);
});
});
test("one-on-one portrait layout shows name tags in room with 3 members", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
withCallViewModel(
{
remoteParticipants$: constant([aliceParticipant]),
// Both Alice and Bob are with us in the room
roomMembers: [local, alice, bob],
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
windowSize$: constant({ width: 380, height: 700 }), // Mobile phone in portrait
},
(vm) => {
// Uses one-on-one portrait layout
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
a: {
type: "one-on-one-portrait",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
},
});
// It wouldn't be clear whether Alice or Bob is the remote video tile,
// so the interface must put a name tag on it
expectObservable(vm.showNameTags$).toBe("y", yesNo);
},
);
});
});
test("participants stay in the same order unless to appear/disappear", () => { test("participants stay in the same order unless to appear/disappear", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => { withTestScheduler(({ behavior, schedule, expectObservable }) => {
const visibilityInputMarbles = "a"; const visibilityInputMarbles = "a";
@@ -576,7 +678,7 @@ describe.each([
}); });
test("layout reacts to window size", () => { test("layout reacts to window size", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const windowSizeInputMarbles = "abc"; const windowSizeInputMarbles = "abc";
const expectedLayoutMarbles = " abc"; const expectedLayoutMarbles = " abc";
withCallViewModel( withCallViewModel(
@@ -584,7 +686,7 @@ describe.each([
remoteParticipants$: constant([aliceParticipant]), remoteParticipants$: constant([aliceParticipant]),
rtcMembers$: constant([localRtcMember, aliceRtcMember]), rtcMembers$: constant([localRtcMember, aliceRtcMember]),
windowSize$: behavior(windowSizeInputMarbles, { windowSize$: behavior(windowSizeInputMarbles, {
a: { width: 300, height: 600 }, // Start very narrow, like a phone a: { width: 380, height: 700 }, // Start very narrow, like a phone
b: { width: 1000, height: 800 }, // Go to normal desktop window size b: { width: 1000, height: 800 }, // Go to normal desktop window size
c: { width: 200, height: 180 }, // Go to PiP size c: { width: 200, height: 180 }, // Go to PiP size
}), }),
@@ -595,13 +697,14 @@ describe.each([
{ {
a: { a: {
// This is the expected one-on-one layout for a narrow window // This is the expected one-on-one layout for a narrow window
type: "spotlight-expanded", type: "one-on-one-portrait",
spotlight: [`${aliceId}:0`], spotlight: [`${aliceId}:0`],
pip: `${localId}:0`, pip: undefined,
pipSize: "lg",
}, },
b: { b: {
// In a larger window, expect the normal one-on-one layout // In a larger window, expect the normal one-on-one layout
type: "one-on-one", type: "one-on-one-landscape",
pip: `${localId}:0`, pip: `${localId}:0`,
spotlight: `${aliceId}:0`, spotlight: `${aliceId}:0`,
}, },
@@ -956,7 +1059,7 @@ describe.each([
grid: [`${localId}:0`], grid: [`${localId}:0`],
}, },
b: { b: {
type: "one-on-one", type: "one-on-one-landscape",
pip: `${localId}:0`, pip: `${localId}:0`,
spotlight: `${aliceId}:0`, spotlight: `${aliceId}:0`,
}, },
@@ -999,7 +1102,7 @@ describe.each([
grid: [`${localId}:0`], grid: [`${localId}:0`],
}, },
b: { b: {
type: "one-on-one", type: "one-on-one-landscape",
pip: `${localId}:0`, pip: `${localId}:0`,
spotlight: `${aliceId}:0`, spotlight: `${aliceId}:0`,
}, },
@@ -1009,7 +1112,7 @@ describe.each([
grid: [`${localId}:0`, `${aliceId}:0`, `${daveId}:0`], grid: [`${localId}:0`, `${aliceId}:0`, `${daveId}:0`],
}, },
d: { d: {
type: "one-on-one", type: "one-on-one-landscape",
pip: `${localId}:0`, pip: `${localId}:0`,
spotlight: `${daveId}:0`, spotlight: `${daveId}:0`,
}, },
@@ -1227,7 +1330,7 @@ describe.each([
// ringing the entire time (even once timed out) // ringing the entire time (even once timed out)
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", { expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
a: { a: {
type: "one-on-one", type: "one-on-one-landscape",
spotlight: `${localId}:0`, spotlight: `${localId}:0`,
pip: `ringing:${aliceUserId}`, pip: `ringing:${aliceUserId}`,
}, },
@@ -1266,12 +1369,12 @@ describe.each([
// ringing the entire time // ringing the entire time
expectObservable(summarizeLayout$(vm.layout$)).toBe("a 20ms b", { expectObservable(summarizeLayout$(vm.layout$)).toBe("a 20ms b", {
a: { a: {
type: "one-on-one", type: "one-on-one-landscape",
spotlight: `${localId}:0`, spotlight: `${localId}:0`,
pip: `ringing:${aliceUserId}`, pip: `ringing:${aliceUserId}`,
}, },
b: { b: {
type: "one-on-one", type: "one-on-one-landscape",
spotlight: `${aliceId}:0`, spotlight: `${aliceId}:0`,
pip: `${localId}:0`, pip: `${localId}:0`,
}, },

View File

@@ -38,6 +38,7 @@ import {
tap, tap,
throttleTime, throttleTime,
timer, timer,
BehaviorSubject,
} from "rxjs"; } from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { import {
@@ -68,7 +69,8 @@ import { setPipEnabled$ } from "../../controls";
import { TileStore } from "../TileStore"; import { TileStore } from "../TileStore";
import { gridLikeLayout } from "../GridLikeLayout"; import { gridLikeLayout } from "../GridLikeLayout";
import { spotlightExpandedLayout } from "../SpotlightExpandedLayout"; import { spotlightExpandedLayout } from "../SpotlightExpandedLayout";
import { oneOnOneLayout } from "../OneOnOneLayout"; import { oneOnOneLandscapeLayout } from "../OneOnOneLandscapeLayout";
import { oneOnOnePortraitLayout } from "../OneOnOnePortraitLayout";
import { pipLayout } from "../PipLayout"; import { pipLayout } from "../PipLayout";
import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement"; import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement";
import { import {
@@ -86,10 +88,12 @@ import { getUrlParams, HeaderStyle } from "../../UrlParams";
import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { ElementWidgetActions, widget } from "../../widget"; import { ElementWidgetActions, widget } from "../../widget";
import { import {
type Alignment,
type GridLayoutMedia, type GridLayoutMedia,
type Layout, type Layout,
type LayoutMedia, type LayoutMedia,
type OneOnOneLayoutMedia, type OneOnOneLandscapeLayoutMedia,
type OneOnOnePortraitLayoutMedia,
type SpotlightExpandedLayoutMedia, type SpotlightExpandedLayoutMedia,
type SpotlightLandscapeLayoutMedia, type SpotlightLandscapeLayoutMedia,
type SpotlightPortraitLayoutMedia, type SpotlightPortraitLayoutMedia,
@@ -327,16 +331,6 @@ export interface CallViewModel {
{ sender: string; emoji: string; startX: number }[] { sender: string; emoji: string; startX: number }[]
>; >;
// window/layout
/**
* The general shape of the window.
*/
windowMode$: Behavior<WindowMode>;
spotlightExpanded$: Behavior<boolean>;
toggleSpotlightExpanded$: Behavior<(() => void) | null>;
gridMode$: Behavior<GridMode>;
setGridMode: (value: GridMode) => void;
/** /**
* The layout of tiles in the call interface. * The layout of tiles in the call interface.
*/ */
@@ -347,10 +341,20 @@ export interface CallViewModel {
tileStoreGeneration$: Behavior<number>; tileStoreGeneration$: Behavior<number>;
showSpotlightIndicators$: Behavior<boolean>; showSpotlightIndicators$: Behavior<boolean>;
showSpeakingIndicators$: Behavior<boolean>; showSpeakingIndicators$: Behavior<boolean>;
showNameTags$: Behavior<boolean>;
spotlightExpanded$: Behavior<boolean>;
toggleSpotlightExpanded$: Behavior<(() => void) | null>;
gridMode$: Behavior<GridMode>;
setGridMode: (value: GridMode) => void;
// header/footer visibility // header/footer visibility
showHeader$: Behavior<boolean>; showHeader$: Behavior<boolean>;
showFooter$: Behavior<boolean>; showFooter$: Behavior<boolean>;
/**
* Whether the call layout should be displayed edge-to-edge, with the footer
* and header as overlays.
*/
edgeToEdge$: Behavior<boolean>;
// audio routing // audio routing
/** /**
@@ -777,6 +781,7 @@ export function createCallViewModel$(
callPickupState === "timeout" || callPickupState === "timeout" ||
callPickupState === "decline" callPickupState === "decline"
) { ) {
// TODO: Respect io.element.functional_members
for (const member of roomMembers.values()) { for (const member of roomMembers.values()) {
if (!userMedia.some((vm) => vm.userId === member.userId)) if (!userMedia.some((vm) => vm.userId === member.userId))
yield { yield {
@@ -1057,6 +1062,7 @@ export function createCallViewModel$(
[grid$, spotlight$], [grid$, spotlight$],
(grid, spotlight) => ({ (grid, spotlight) => ({
type: "grid", type: "grid",
edgeToEdge: false,
spotlight: spotlight.some((vm) => vm.type === "screen share") spotlight: spotlight.some((vm) => vm.type === "screen share")
? spotlight ? spotlight
: undefined, : undefined,
@@ -1067,6 +1073,7 @@ export function createCallViewModel$(
const spotlightLandscapeLayoutMedia$: Observable<SpotlightLandscapeLayoutMedia> = const spotlightLandscapeLayoutMedia$: Observable<SpotlightLandscapeLayoutMedia> =
combineLatest([grid$, spotlight$], (grid, spotlight) => ({ combineLatest([grid$, spotlight$], (grid, spotlight) => ({
type: "spotlight-landscape", type: "spotlight-landscape",
edgeToEdge: false,
spotlight, spotlight,
grid, grid,
})); }));
@@ -1074,16 +1081,20 @@ export function createCallViewModel$(
const spotlightPortraitLayoutMedia$: Observable<SpotlightPortraitLayoutMedia> = const spotlightPortraitLayoutMedia$: Observable<SpotlightPortraitLayoutMedia> =
combineLatest([grid$, spotlight$], (grid, spotlight) => ({ combineLatest([grid$, spotlight$], (grid, spotlight) => ({
type: "spotlight-portrait", type: "spotlight-portrait",
edgeToEdge: false,
spotlight, spotlight,
grid, grid,
})); }));
const spotlightExpandedLayoutMedia$: Observable<SpotlightExpandedLayoutMedia> = const spotlightExpandedLayoutMedia$ = (
edgeToEdge: boolean,
): Observable<SpotlightExpandedLayoutMedia> =>
spotlightAndPip$.pipe( spotlightAndPip$.pipe(
switchMap(({ spotlight, pip$ }) => switchMap(({ spotlight, pip$ }) =>
pip$.pipe( pip$.pipe(
map((pip) => ({ map((pip) => ({
type: "spotlight-expanded" as const, type: "spotlight-expanded" as const,
edgeToEdge,
spotlight, spotlight,
pip: pip ?? undefined, pip: pip ?? undefined,
})), })),
@@ -1091,55 +1102,88 @@ export function createCallViewModel$(
), ),
); );
const oneOnOneLayoutMedia$: Observable<OneOnOneLayoutMedia | null> = const oneOnOneLayoutMedia$: Observable<{
combineLatest([userMedia$, screenShares$]).pipe( local: LocalUserMediaViewModel;
switchMap(([userMedia, screenShares]) => { remote: UserMediaViewModel | RingingMediaViewModel;
// One-on-one layout only supports 2 user media, no screen shares } | null> = combineLatest([userMedia$, screenShares$]).pipe(
if (userMedia.length <= 2 && screenShares.length === 0) { switchMap(([userMedia, screenShares]) => {
const local = userMedia.find( // One-on-one layout only supports 2 user media, no screen shares
(vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel => if (userMedia.length <= 2 && screenShares.length === 0) {
vm.type === "user" && vm.local, const local = userMedia.find(
(vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel =>
vm.type === "user" && vm.local,
);
if (local !== undefined) {
const remote = userMedia.find(
(vm): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel =>
vm.type === "user" && !vm.local,
); );
if (local !== undefined) { if (remote !== undefined) return of({ local, remote });
const remote = userMedia.find(
( // If there's no other user media in the call (could still happen in
vm, // this branch due to the duplicate tiles option), we could possibly
): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel => // show ringing media instead
vm.type === "user" && !vm.local, if (userMedia.length === 1)
return ringingMedia$.pipe(
map((ringingMedia) => {
return ringingMedia.length === 1
? {
local,
remote: ringingMedia[0],
}
: null;
}),
); );
if (remote !== undefined)
return of({
type: "one-on-one" as const,
spotlight: remote,
pip: local,
});
// If there's no other user media in the call (could still happen in
// this branch due to the duplicate tiles option), we could possibly
// show ringing media instead
if (userMedia.length === 1)
return ringingMedia$.pipe(
map((ringingMedia) => {
return ringingMedia.length === 1
? {
type: "one-on-one" as const,
spotlight: local,
pip: ringingMedia[0],
}
: null;
}),
);
}
} }
}
return of(null); return of(null);
}),
);
const oneOnOneLandscapeLayoutMedia$: Observable<OneOnOneLandscapeLayoutMedia | null> =
oneOnOneLayoutMedia$.pipe(
map((media) => {
if (media === null) return null;
return media.remote.type === "ringing"
? {
type: "one-on-one-landscape" as const,
edgeToEdge: false,
spotlight: media.local,
pip: media.remote,
}
: {
type: "one-on-one-landscape" as const,
edgeToEdge: false,
spotlight: media.remote,
pip: media.local,
};
}),
);
const oneOnOnePortraitLayoutMedia$: Observable<OneOnOnePortraitLayoutMedia | null> =
oneOnOneLayoutMedia$.pipe(
switchMap((media) => {
if (media === null) return of(null);
return media.local.videoEnabled$.pipe(
map((videoEnabled) => ({
type: "one-on-one-portrait" as const,
edgeToEdge: true as const,
spotlight: media.remote,
pip: videoEnabled ? media.local : undefined,
})),
);
}), }),
); );
const pipLayoutMedia$: Observable<LayoutMedia> = spotlight$.pipe( const pipLayoutMedia$: Observable<LayoutMedia> = spotlight$.pipe(
map((spotlight) => ({ type: "pip", spotlight })), map((spotlight) => ({
type: "pip",
edgeToEdge: platform !== "desktop",
spotlight,
})),
); );
/** /**
@@ -1154,7 +1198,7 @@ export function createCallViewModel$(
switchMap((gridMode) => { switchMap((gridMode) => {
switch (gridMode) { switch (gridMode) {
case "grid": case "grid":
return oneOnOneLayoutMedia$.pipe( return oneOnOneLandscapeLayoutMedia$.pipe(
switchMap((oneOnOne) => switchMap((oneOnOne) =>
oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne), oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne),
), ),
@@ -1163,7 +1207,7 @@ export function createCallViewModel$(
return spotlightExpanded$.pipe( return spotlightExpanded$.pipe(
switchMap((expanded) => switchMap((expanded) =>
expanded expanded
? spotlightExpandedLayoutMedia$ ? spotlightExpandedLayoutMedia$(false)
: spotlightLandscapeLayoutMedia$, : spotlightLandscapeLayoutMedia$,
), ),
); );
@@ -1171,7 +1215,7 @@ export function createCallViewModel$(
}), }),
); );
case "narrow": case "narrow":
return oneOnOneLayoutMedia$.pipe( return oneOnOnePortraitLayoutMedia$.pipe(
switchMap((oneOnOne) => switchMap((oneOnOne) =>
oneOnOne === null oneOnOne === null
? combineLatest([grid$, spotlight$], (grid, spotlight) => ? combineLatest([grid$, spotlight$], (grid, spotlight) =>
@@ -1180,9 +1224,7 @@ export function createCallViewModel$(
? spotlightPortraitLayoutMedia$ ? spotlightPortraitLayoutMedia$
: gridLayoutMedia$, : gridLayoutMedia$,
).pipe(switchAll()) ).pipe(switchAll())
: // The expanded spotlight layout makes for a better one-on-one : of(oneOnOne),
// experience in narrow windows
spotlightExpandedLayoutMedia$,
), ),
); );
case "flat": case "flat":
@@ -1194,7 +1236,7 @@ export function createCallViewModel$(
// this window mode. // this window mode.
return spotlightLandscapeLayoutMedia$; return spotlightLandscapeLayoutMedia$;
case "spotlight": case "spotlight":
return spotlightExpandedLayoutMedia$; return spotlightExpandedLayoutMedia$(true);
} }
}), }),
); );
@@ -1205,6 +1247,193 @@ export function createCallViewModel$(
), ),
); );
const showSpotlightIndicators$ = scope.behavior<boolean>(
layoutMedia$.pipe(map((l) => l.type !== "grid")),
);
const showSpeakingIndicators$ = scope.behavior<boolean>(
layoutMedia$.pipe(
map((l) => {
switch (l.type) {
case "spotlight-landscape":
case "spotlight-portrait":
// If the spotlight is showing the active speaker, we can do without
// speaking indicators as they're a redundant visual cue. But if
// screen sharing feeds are in the spotlight we still need them.
return l.spotlight.some((m) => m.type === "screen share");
// In expanded spotlight layout, the active speaker is always shown in
// the picture-in-picture tile so there is no need for speaking
// indicators. And in one-on-one layout there's no question as to who is
// speaking.
case "spotlight-expanded":
case "one-on-one-landscape":
case "one-on-one-portrait":
return false;
default:
return true;
}
}),
),
);
const showNameTags$ = scope.behavior<boolean>(
layoutMedia$.pipe(
switchMap((l) =>
l.type === "pip" || l.type === "one-on-one-portrait"
? matrixRoomMembers$.pipe(
map(
(members) =>
// Hide name tags by default in these layouts. For safety we
// still need to show them in case it wouldn't be clear who
// the spotlight media belongs to.
// TODO: Respect io.element.functional_members (while still
// being careful to never show a functional member's media
// without a name tag!)
// TODO: Only hide name tags in DMs, not group chats that just
// happen to have only 2 users
members.size > 2,
),
)
: of(true),
),
),
);
const toggleSpotlightExpanded$ = scope.behavior<(() => void) | null>(
windowMode$.pipe(
switchMap((mode) =>
mode === "normal"
? layoutMedia$.pipe(
map(
(l) =>
l.type === "spotlight-landscape" ||
l.type === "spotlight-expanded",
),
)
: of(false),
),
distinctUntilChanged(),
map((enabled) =>
enabled ? (): void => spotlightExpandedToggle$.next() : null,
),
),
);
const edgeToEdge$ = scope.behavior<boolean>(
layoutMedia$.pipe(map(({ edgeToEdge }) => edgeToEdge)),
);
const screenTap$ = new Subject<void>();
const controlsTap$ = new Subject<void>();
const screenHover$ = new Subject<void>();
const screenUnhover$ = new Subject<void>();
const naturallyShowFooter$ = scope.behavior<boolean>(
edgeToEdge$.pipe(
switchMap((edgeToEdge) => {
if (!edgeToEdge) return of(true);
// Sadly Firefox has some layering glitches that prevent the footer
// from appearing properly. They happen less often if we never hide
// the footer.
if (isFirefox()) return of(true);
// Layout is edge-to-edge; show/hide the footer in response to interactions
return windowMode$.pipe(
switchMap((mode) => {
const showInitially = mode !== "flat";
const timeout$ = mode === "flat" ? timer(showFooterMs) : NEVER;
return merge(
screenTap$.pipe(map(() => "tap screen" as const)),
controlsTap$.pipe(map(() => "tap controls" as const)),
screenHover$.pipe(map(() => "hover" as const)),
).pipe(
switchScan((state, interaction) => {
switch (interaction) {
case "tap screen":
return state
? // Toggle visibility on tap
of(false)
: // Hide after a timeout
timeout$.pipe(
map(() => false),
startWith(true),
);
case "tap controls":
// The user is interacting with things, so reset the timeout
return timeout$.pipe(
map(() => false),
startWith(true),
);
case "hover":
// Show on hover and hide after a timeout
return race(timeout$, screenUnhover$.pipe(take(1))).pipe(
map(() => false),
startWith(true),
);
}
}, showInitially),
startWith(showInitially),
);
}),
);
}),
),
);
const urlParams = getUrlParams();
const showFooterUrlParams = !(
urlParams.header === HeaderStyle.None && urlParams.showControls === false
);
const showFooter$ = scope.behavior(
naturallyShowFooter$.pipe(
map((naturallyShowFooter) => naturallyShowFooter && showFooterUrlParams),
),
);
const showHeader$ = scope.behavior<boolean>(
windowMode$.pipe(
switchMap((mode) => {
// In small windows the header would be too obstructive
if (mode === "pip" || mode === "flat") return of(false);
// In edge-to-edge layouts, couple the visibility of the header
// to that of the footer
return edgeToEdge$.pipe(
switchMap((edgeToEdge) => (edgeToEdge ? showFooter$ : of(true))),
);
}),
),
);
/**
* The alignment of the floating spotlight tile, if present.
*/
const spotlightAlignment$ = new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
});
/**
* The size of the small picture-in-picture tile, if present, when in portrait.
*/
const portraitPipSize$ = scope.behavior(
showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))),
);
/**
* The alignment of the small picture-in-picture tile, if present, when in portrait.
*/
const portraitPipAlignment$ = new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
});
/**
* The alignment of the small picture-in-picture tile, if present, when in landscape.
*/
const landscapePipAlignment$ = new BehaviorSubject<Alignment>({
inline: "end",
block: "start",
});
// There is a cyclical dependency here: the layout algorithms want to know // There is a cyclical dependency here: the layout algorithms want to know
// which tiles are on screen, but to know which tiles are on screen we have to // which tiles are on screen, but to know which tiles are on screen we have to
// first render a layout. To deal with this we assume initially that no tiles // first render a layout. To deal with this we assume initially that no tiles
@@ -1231,16 +1460,33 @@ export function createCallViewModel$(
case "spotlight-portrait": case "spotlight-portrait":
[layout, newTiles] = gridLikeLayout( [layout, newTiles] = gridLikeLayout(
media, media,
spotlightAlignment$,
visibleTiles, visibleTiles,
setVisibleTiles, setVisibleTiles,
prevTiles, prevTiles,
); );
break; break;
case "spotlight-expanded": case "spotlight-expanded":
[layout, newTiles] = spotlightExpandedLayout(media, prevTiles); [layout, newTiles] = spotlightExpandedLayout(
media,
landscapePipAlignment$,
prevTiles,
);
break; break;
case "one-on-one": case "one-on-one-landscape":
[layout, newTiles] = oneOnOneLayout(media, prevTiles); [layout, newTiles] = oneOnOneLandscapeLayout(
media,
landscapePipAlignment$,
prevTiles,
);
break;
case "one-on-one-portrait":
[layout, newTiles] = oneOnOnePortraitLayout(
media,
portraitPipSize$,
portraitPipAlignment$,
prevTiles,
);
break; break;
case "pip": case "pip":
[layout, newTiles] = pipLayout(media, prevTiles); [layout, newTiles] = pipLayout(media, prevTiles);
@@ -1268,130 +1514,6 @@ export function createCallViewModel$(
layoutInternals$.pipe(map(({ tiles }) => tiles.generation)), layoutInternals$.pipe(map(({ tiles }) => tiles.generation)),
); );
const showSpotlightIndicators$ = scope.behavior<boolean>(
layout$.pipe(map((l) => l.type !== "grid")),
);
const showSpeakingIndicators$ = scope.behavior<boolean>(
layout$.pipe(
switchMap((l) => {
switch (l.type) {
case "spotlight-landscape":
case "spotlight-portrait":
// If the spotlight is showing the active speaker, we can do without
// speaking indicators as they're a redundant visual cue. But if
// screen sharing feeds are in the spotlight we still need them.
return l.spotlight.media$.pipe(
map((models: MediaViewModel[]) =>
models.some((m) => m.type === "screen share"),
),
);
// In expanded spotlight layout, the active speaker is always shown in
// the picture-in-picture tile so there is no need for speaking
// indicators. And in one-on-one layout there's no question as to who is
// speaking.
case "spotlight-expanded":
case "one-on-one":
return of(false);
default:
return of(true);
}
}),
),
);
const toggleSpotlightExpanded$ = scope.behavior<(() => void) | null>(
windowMode$.pipe(
switchMap((mode) =>
mode === "normal"
? layout$.pipe(
map(
(l) =>
l.type === "spotlight-landscape" ||
l.type === "spotlight-expanded",
),
)
: of(false),
),
distinctUntilChanged(),
map((enabled) =>
enabled ? (): void => spotlightExpandedToggle$.next() : null,
),
),
);
const screenTap$ = new Subject<void>();
const controlsTap$ = new Subject<void>();
const screenHover$ = new Subject<void>();
const screenUnhover$ = new Subject<void>();
const showHeader$ = scope.behavior<boolean>(
windowMode$.pipe(map((mode) => mode !== "pip" && mode !== "flat")),
);
const urlParams = getUrlParams();
const showFooterUrlParams = !(
urlParams.header === HeaderStyle.None && urlParams.showControls === false
);
const showFooterLayout$ = scope.behavior<boolean>(
windowMode$.pipe(
switchMap((mode) => {
switch (mode) {
case "pip":
return of(platform === "desktop" ? true : false);
case "normal":
case "narrow":
return of(true);
case "flat":
// Sadly Firefox has some layering glitches that prevent the footer
// from appearing properly. They happen less often if we never hide
// the footer.
if (isFirefox()) return of(true);
// Show/hide the footer in response to interactions
return merge(
screenTap$.pipe(map(() => "tap screen" as const)),
controlsTap$.pipe(map(() => "tap controls" as const)),
screenHover$.pipe(map(() => "hover" as const)),
).pipe(
switchScan((state, interaction) => {
switch (interaction) {
case "tap screen":
return state
? // Toggle visibility on tap
of(false)
: // Hide after a timeout
timer(showFooterMs).pipe(
map(() => false),
startWith(true),
);
case "tap controls":
// The user is interacting with things, so reset the timeout
return timer(showFooterMs).pipe(
map(() => false),
startWith(true),
);
case "hover":
// Show on hover and hide after a timeout
return race(
timer(showFooterMs),
screenUnhover$.pipe(take(1)),
).pipe(
map(() => false),
startWith(true),
);
}
}, false),
startWith(false),
);
}
}),
),
);
const showFooter$ = scope.behavior(
showFooterLayout$.pipe(
map((showFooter) => showFooter && showFooterUrlParams),
),
);
/** /**
* Whether audio is currently being output through the earpiece. * Whether audio is currently being output through the earpiece.
*/ */
@@ -1595,7 +1717,6 @@ export function createCallViewModel$(
audibleReactions$: audibleReactions$, audibleReactions$: audibleReactions$,
visibleReactions$: visibleReactions$, visibleReactions$: visibleReactions$,
windowMode$: windowMode$,
spotlightExpanded$: spotlightExpanded$, spotlightExpanded$: spotlightExpanded$,
toggleSpotlightExpanded$: toggleSpotlightExpanded$, toggleSpotlightExpanded$: toggleSpotlightExpanded$,
gridMode$: gridMode$, gridMode$: gridMode$,
@@ -1621,8 +1742,10 @@ export function createCallViewModel$(
tileStoreGeneration$: tileStoreGeneration$, tileStoreGeneration$: tileStoreGeneration$,
showSpotlightIndicators$: showSpotlightIndicators$, showSpotlightIndicators$: showSpotlightIndicators$,
showSpeakingIndicators$: showSpeakingIndicators$, showSpeakingIndicators$: showSpeakingIndicators$,
showNameTags$,
showHeader$: showHeader$, showHeader$: showHeader$,
showFooter$: showFooter$, showFooter$: showFooter$,
edgeToEdge$,
earpieceMode$: earpieceMode$, earpieceMode$: earpieceMode$,
audioOutputSwitcher$: audioOutputSwitcher$, audioOutputSwitcher$: audioOutputSwitcher$,
reconnecting$: localMembership.reconnecting$, reconnecting$: localMembership.reconnecting$,

View File

@@ -8,11 +8,11 @@ Please see LICENSE in the repository root for full details.
import { import {
ConnectionState, ConnectionState,
type LocalParticipant,
type Participant, type Participant,
ParticipantEvent, ParticipantEvent,
type RemoteParticipant, type RemoteParticipant,
type Room as LivekitRoom, type Room as LivekitRoom,
type TrackPublication,
} from "livekit-client"; } from "livekit-client";
import { SyncState } from "matrix-js-sdk/lib/sync"; import { SyncState } from "matrix-js-sdk/lib/sync";
import { BehaviorSubject, combineLatest, map, of } from "rxjs"; import { BehaviorSubject, combineLatest, map, of } from "rxjs";
@@ -72,6 +72,7 @@ export interface CallViewModelInputs {
roomMembers: RoomMember[]; roomMembers: RoomMember[];
livekitConnectionState$: Behavior<ConnectionState>; livekitConnectionState$: Behavior<ConnectionState>;
speaking: Map<Participant, Behavior<boolean>>; speaking: Map<Participant, Behavior<boolean>>;
videoEnabled: Map<Participant, Behavior<boolean>>;
sharingScreen: Map<Participant, Behavior<boolean>>; sharingScreen: Map<Participant, Behavior<boolean>>;
mediaDevices: MediaDevices; mediaDevices: MediaDevices;
initialSyncState: SyncState; initialSyncState: SyncState;
@@ -98,6 +99,7 @@ export function withCallViewModel(mode: MatrixRTCMode) {
ConnectionState.Connected, ConnectionState.Connected,
), ),
speaking = new Map(), speaking = new Map(),
videoEnabled = new Map(),
sharingScreen = new Map(), sharingScreen = new Map(),
mediaDevices = mockMediaDevices({}), mediaDevices = mockMediaDevices({}),
initialSyncState = SyncState.Syncing, initialSyncState = SyncState.Syncing,
@@ -151,11 +153,19 @@ export function withCallViewModel(mode: MatrixRTCMode) {
.mockReturnValue(remoteParticipants$); .mockReturnValue(remoteParticipants$);
const mediaSpy = vi const mediaSpy = vi
.spyOn(ComponentsCore, "observeParticipantMedia") .spyOn(ComponentsCore, "observeParticipantMedia")
.mockImplementation((p) => .mockImplementation((p) => {
of({ participant: p } as Partial< return (videoEnabled.get(p) ?? constant(false)).pipe(
ComponentsCore.ParticipantMedia<LocalParticipant> map((videoEnabled) => ({
> as ComponentsCore.ParticipantMedia<LocalParticipant>), participant: p,
); isMicrophoneEnabled: false,
isCameraEnabled: videoEnabled,
isScreenShareEnabled: false,
cameraTrack: {
isMuted: !videoEnabled,
} as unknown as TrackPublication,
})),
);
});
const eventsSpy = vi const eventsSpy = vi
.spyOn(ComponentsCore, "observeParticipantEvents") .spyOn(ComponentsCore, "observeParticipantEvents")
.mockImplementation((p, ...eventTypes) => { .mockImplementation((p, ...eventTypes) => {

View File

@@ -5,7 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type Layout, type LayoutMedia } from "./layout-types.ts"; import { type BehaviorSubject } from "rxjs";
import {
type Alignment,
type Layout,
type LayoutMedia,
} from "./layout-types.ts";
import { type TileStore } from "./TileStore"; import { type TileStore } from "./TileStore";
export type GridLikeLayoutType = export type GridLikeLayoutType =
@@ -19,6 +25,7 @@ export type GridLikeLayoutType =
*/ */
export function gridLikeLayout( export function gridLikeLayout(
media: LayoutMedia & { type: GridLikeLayoutType }, media: LayoutMedia & { type: GridLikeLayoutType },
spotlightAlignment$: BehaviorSubject<Alignment>,
visibleTiles: number, visibleTiles: number,
setVisibleTiles: (value: number) => void, setVisibleTiles: (value: number) => void,
prevTiles: TileStore, prevTiles: TileStore,
@@ -37,6 +44,7 @@ export function gridLikeLayout(
type: media.type, type: media.type,
spotlight: tiles.spotlightTile, spotlight: tiles.spotlightTile,
grid: tiles.gridTiles, grid: tiles.gridTiles,
spotlightAlignment$,
setVisibleTiles, setVisibleTiles,
} as Layout & { type: GridLikeLayoutType }, } as Layout & { type: GridLikeLayoutType },
tiles, tiles,

View File

@@ -1,29 +1,39 @@
/* /*
Copyright 2024 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type OneOnOneLayout, type OneOnOneLayoutMedia } from "./layout-types"; import { type BehaviorSubject } from "rxjs";
import {
type Alignment,
type OneOnOneLandscapeLayout,
type OneOnOneLandscapeLayoutMedia,
} from "./layout-types";
import { type TileStore } from "./TileStore"; import { type TileStore } from "./TileStore";
/** /**
* Produces a one-on-one layout with the given media. * Produces a one-on-one landscape layout with the given media.
*/ */
export function oneOnOneLayout( export function oneOnOneLandscapeLayout(
media: OneOnOneLayoutMedia, media: OneOnOneLandscapeLayoutMedia,
pipAlignment$: BehaviorSubject<Alignment>,
prevTiles: TileStore, prevTiles: TileStore,
): [OneOnOneLayout, TileStore] { ): [OneOnOneLandscapeLayout, TileStore] {
const update = prevTiles.from(2); const update = prevTiles.from(2);
update.registerGridTile(media.pip); update.registerGridTile(media.pip);
update.registerGridTile(media.spotlight); update.registerGridTile(media.spotlight);
const tiles = update.build(); const tiles = update.build();
return [ return [
{ {
type: media.type, type: media.type,
spotlight: tiles.gridTilesByMedia.get(media.spotlight)!, spotlight: tiles.gridTilesByMedia.get(media.spotlight)!,
pip: tiles.gridTilesByMedia.get(media.pip)!, pip: tiles.gridTilesByMedia.get(media.pip)!,
pipAlignment$,
}, },
tiles, tiles,
]; ];

View File

@@ -0,0 +1,43 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject } from "rxjs";
import {
type Alignment,
type OneOnOnePortraitLayout,
type OneOnOnePortraitLayoutMedia,
} from "./layout-types";
import { type TileStore } from "./TileStore";
import { type Behavior } from "./Behavior";
/**
* Produces a one-on-one portrait layout with the given media.
*/
export function oneOnOnePortraitLayout(
media: OneOnOnePortraitLayoutMedia,
pipSize$: Behavior<"sm" | "lg">,
pipAlignment$: BehaviorSubject<Alignment>,
prevTiles: TileStore,
): [OneOnOnePortraitLayout, TileStore] {
const update = prevTiles.from(media.pip === undefined ? 0 : 1);
update.registerSpotlight([media.spotlight], true);
if (media.pip !== undefined) update.registerGridTile(media.pip);
const tiles = update.build();
return [
{
type: media.type,
spotlight: tiles.spotlightTile!,
pip: media.pip && tiles.gridTilesByMedia.get(media.pip),
pipSize$,
pipAlignment$,
},
tiles,
];
}

View File

@@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type BehaviorSubject } from "rxjs";
import { import {
type Alignment,
type SpotlightExpandedLayout, type SpotlightExpandedLayout,
type SpotlightExpandedLayoutMedia, type SpotlightExpandedLayoutMedia,
} from "./layout-types"; } from "./layout-types";
@@ -16,6 +19,7 @@ import { type TileStore } from "./TileStore";
*/ */
export function spotlightExpandedLayout( export function spotlightExpandedLayout(
media: SpotlightExpandedLayoutMedia, media: SpotlightExpandedLayoutMedia,
pipAlignment$: BehaviorSubject<Alignment>,
prevTiles: TileStore, prevTiles: TileStore,
): [SpotlightExpandedLayout, TileStore] { ): [SpotlightExpandedLayout, TileStore] {
const update = prevTiles.from(1); const update = prevTiles.from(1);
@@ -27,7 +31,8 @@ export function spotlightExpandedLayout(
{ {
type: media.type, type: media.type,
spotlight: tiles.spotlightTile!, spotlight: tiles.spotlightTile!,
pip: tiles.gridTiles[0], pip: tiles.gridTiles.at(0),
pipAlignment$,
}, },
tiles, tiles,
]; ];

View File

@@ -5,6 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type BehaviorSubject } from "rxjs";
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts"; import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
import { type MediaViewModel } from "./media/MediaViewModel.ts"; import { type MediaViewModel } from "./media/MediaViewModel.ts";
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts"; import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts";
@@ -13,39 +15,53 @@ import {
type GridTileViewModel, type GridTileViewModel,
type SpotlightTileViewModel, type SpotlightTileViewModel,
} from "./TileViewModel.ts"; } from "./TileViewModel.ts";
import { type Behavior } from "./Behavior.ts";
export interface GridLayoutMedia { export interface GridLayoutMedia {
type: "grid"; type: "grid";
edgeToEdge: false;
spotlight?: MediaViewModel[]; spotlight?: MediaViewModel[];
grid: UserMediaViewModel[]; grid: UserMediaViewModel[];
} }
export interface SpotlightLandscapeLayoutMedia { export interface SpotlightLandscapeLayoutMedia {
type: "spotlight-landscape"; type: "spotlight-landscape";
edgeToEdge: false;
spotlight: MediaViewModel[]; spotlight: MediaViewModel[];
grid: UserMediaViewModel[]; grid: UserMediaViewModel[];
} }
export interface SpotlightPortraitLayoutMedia { export interface SpotlightPortraitLayoutMedia {
type: "spotlight-portrait"; type: "spotlight-portrait";
edgeToEdge: false;
spotlight: MediaViewModel[]; spotlight: MediaViewModel[];
grid: UserMediaViewModel[]; grid: UserMediaViewModel[];
} }
export interface SpotlightExpandedLayoutMedia { export interface SpotlightExpandedLayoutMedia {
type: "spotlight-expanded"; type: "spotlight-expanded";
edgeToEdge: boolean;
spotlight: MediaViewModel[]; spotlight: MediaViewModel[];
pip?: UserMediaViewModel; pip?: UserMediaViewModel;
} }
export interface OneOnOneLayoutMedia { export interface OneOnOneLandscapeLayoutMedia {
type: "one-on-one"; type: "one-on-one-landscape";
edgeToEdge: false;
spotlight: UserMediaViewModel; spotlight: UserMediaViewModel;
pip: LocalUserMediaViewModel | RingingMediaViewModel; pip: LocalUserMediaViewModel | RingingMediaViewModel;
} }
export interface OneOnOnePortraitLayoutMedia {
type: "one-on-one-portrait";
edgeToEdge: true;
spotlight: UserMediaViewModel | RingingMediaViewModel;
pip?: LocalUserMediaViewModel;
}
export interface PipLayoutMedia { export interface PipLayoutMedia {
type: "pip"; type: "pip";
edgeToEdge: boolean;
spotlight: MediaViewModel[]; spotlight: MediaViewModel[];
} }
@@ -54,13 +70,20 @@ export type LayoutMedia =
| SpotlightLandscapeLayoutMedia | SpotlightLandscapeLayoutMedia
| SpotlightPortraitLayoutMedia | SpotlightPortraitLayoutMedia
| SpotlightExpandedLayoutMedia | SpotlightExpandedLayoutMedia
| OneOnOneLayoutMedia | OneOnOneLandscapeLayoutMedia
| OneOnOnePortraitLayoutMedia
| PipLayoutMedia; | PipLayoutMedia;
export interface Alignment {
inline: "start" | "end";
block: "start" | "end";
}
export interface GridLayout { export interface GridLayout {
type: "grid"; type: "grid";
spotlight?: SpotlightTileViewModel; spotlight?: SpotlightTileViewModel;
grid: GridTileViewModel[]; grid: GridTileViewModel[];
spotlightAlignment$: BehaviorSubject<Alignment>;
setVisibleTiles: (value: number) => void; setVisibleTiles: (value: number) => void;
} }
@@ -82,12 +105,22 @@ export interface SpotlightExpandedLayout {
type: "spotlight-expanded"; type: "spotlight-expanded";
spotlight: SpotlightTileViewModel; spotlight: SpotlightTileViewModel;
pip?: GridTileViewModel; pip?: GridTileViewModel;
pipAlignment$: BehaviorSubject<Alignment>;
} }
export interface OneOnOneLayout { export interface OneOnOneLandscapeLayout {
type: "one-on-one"; type: "one-on-one-landscape";
spotlight: GridTileViewModel; spotlight: GridTileViewModel;
pip: GridTileViewModel; pip: GridTileViewModel;
pipAlignment$: BehaviorSubject<Alignment>;
}
export interface OneOnOnePortraitLayout {
type: "one-on-one-portrait";
spotlight: SpotlightTileViewModel;
pip?: GridTileViewModel;
pipSize$: Behavior<"sm" | "lg">;
pipAlignment$: BehaviorSubject<Alignment>;
} }
export interface PipLayout { export interface PipLayout {
@@ -104,5 +137,6 @@ export type Layout =
| SpotlightLandscapeLayout | SpotlightLandscapeLayout
| SpotlightPortraitLayout | SpotlightPortraitLayout
| SpotlightExpandedLayout | SpotlightExpandedLayout
| OneOnOneLayout | OneOnOneLandscapeLayout
| OneOnOnePortraitLayout
| PipLayout; | PipLayout;

View File

@@ -72,6 +72,10 @@ borders don't support gradients */
} }
} }
.tile.edgeToEdge {
--media-view-border-radius: 0;
}
.muteIcon[data-muted="true"] { .muteIcon[data-muted="true"] {
color: var(--cpd-color-icon-secondary); color: var(--cpd-color-icon-secondary);
} }

View File

@@ -77,6 +77,7 @@ test("GridTile is accessible", async () => {
targetWidth={300} targetWidth={300}
targetHeight={200} targetHeight={200}
showSpeakingIndicators showSpeakingIndicators
showNameTags
focusable focusable
/> />
</ReactionsSenderProvider>, </ReactionsSenderProvider>,
@@ -109,6 +110,7 @@ test("GridTile displays ringing media", async () => {
targetWidth={300} targetWidth={300}
targetHeight={200} targetHeight={200}
showSpeakingIndicators showSpeakingIndicators
showNameTags
focusable focusable
/> />
</ReactionsSenderProvider>, </ReactionsSenderProvider>,

View File

@@ -62,6 +62,7 @@ interface TileProps {
targetHeight: number; targetHeight: number;
displayName: string; displayName: string;
mxcAvatarUrl: string | undefined; mxcAvatarUrl: string | undefined;
showNameTags: boolean;
focusable: boolean; focusable: boolean;
} }
@@ -398,6 +399,7 @@ interface GridTileProps {
className?: string; className?: string;
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
showSpeakingIndicators: boolean; showSpeakingIndicators: boolean;
showNameTags: boolean;
focusable: boolean; focusable: boolean;
} }
@@ -419,9 +421,9 @@ export const GridTile: FC<GridTileProps> = ({
<RingingMediaTile <RingingMediaTile
ref={ref} ref={ref}
vm={media} vm={media}
{...props}
displayName={displayName} displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl} mxcAvatarUrl={mxcAvatarUrl}
{...props}
/> />
); );
} else if (media.local) { } else if (media.local) {

View File

@@ -85,6 +85,7 @@ unconditionally select the container so we can use cqmin units */
calc(var(--media-view-border-radius) - var(--cpd-space-3x)) calc(var(--media-view-border-radius) - var(--cpd-space-3x))
); );
padding: var(--fg-inset); padding: var(--fg-inset);
transition: padding 0.3s;
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
grid-template-rows: 1fr auto; grid-template-rows: 1fr auto;
@@ -94,6 +95,12 @@ unconditionally select the container so we can use cqmin units */
contain: strict; contain: strict;
} }
@media (prefers-reduced-motion) {
.fg {
transition: none;
}
}
.nameTag { .nameTag {
grid-area: nameTag; grid-area: nameTag;
place-self: end start; place-self: end start;

View File

@@ -42,6 +42,7 @@ describe("MediaView", () => {
targetHeight: 200, targetHeight: 200,
mirror: false, mirror: false,
unencryptedWarning: false, unencryptedWarning: false,
showNameTags: true,
video: trackReference, video: trackReference,
userId: "@alice:example.com", userId: "@alice:example.com",
mxcAvatarUrl: undefined, mxcAvatarUrl: undefined,
@@ -107,6 +108,16 @@ describe("MediaView", () => {
expect(screen.getByRole("img", { name: "Not encrypted" })).toBeTruthy(); expect(screen.getByRole("img", { name: "Not encrypted" })).toBeTruthy();
}); });
test("is shown and accessible even with name tag hidden", async () => {
const { container } = render(
<TooltipProvider>
<MediaView {...baseProps} unencryptedWarning showNameTags={false} />
</TooltipProvider>,
);
expect(await axe(container)).toHaveNoViolations();
screen.getByRole("img", { name: "Not encrypted" });
});
test("is not shown", () => { test("is not shown", () => {
render( render(
<TooltipProvider> <TooltipProvider>

View File

@@ -44,6 +44,7 @@ interface Props extends ComponentProps<typeof animated.div> {
videoEnabled: boolean; videoEnabled: boolean;
unencryptedWarning: boolean; unencryptedWarning: boolean;
status?: { text: string; Icon: ComponentType<SVGAttributes<SVGElement>> }; status?: { text: string; Icon: ComponentType<SVGAttributes<SVGElement>> };
showNameTags: boolean;
nameTagLeadingIcon?: ReactNode; nameTagLeadingIcon?: ReactNode;
displayName: string; displayName: string;
mxcAvatarUrl: string | undefined; mxcAvatarUrl: string | undefined;
@@ -72,6 +73,7 @@ export const MediaView: FC<Props> = ({
userId, userId,
videoEnabled, videoEnabled,
unencryptedWarning, unencryptedWarning,
showNameTags,
nameTagLeadingIcon, nameTagLeadingIcon,
displayName, displayName,
mxcAvatarUrl, mxcAvatarUrl,
@@ -94,6 +96,23 @@ export const MediaView: FC<Props> = ({
const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2); const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2);
const warnings = unencryptedWarning && (
<Tooltip
label={t("common.unencrypted")}
placement="bottom"
isTriggerInteractive={false}
nonInteractiveTriggerTabIndex={focusable ? undefined : -1}
>
<ErrorSolidIcon
width={20}
height={20}
className={styles.errorIcon}
role="img"
aria-label={t("common.unencrypted")}
/>
</Tooltip>
);
return ( return (
<animated.div <animated.div
className={classNames(styles.media, className, { className={classNames(styles.media, className, {
@@ -184,34 +203,23 @@ export const MediaView: FC<Props> = ({
</Text> </Text>
</div> </div>
)*/} )*/}
<div className={styles.nameTag}> {showNameTags && targetWidth >= 100 ? (
{nameTagLeadingIcon} <div className={styles.nameTag}>
<Text {nameTagLeadingIcon}
as="span" <Text
size="sm" as="span"
weight="medium" size="sm"
className={styles.name} weight="medium"
data-testid="name_tag" className={styles.name}
> data-testid="name_tag"
{displayName}
</Text>
{unencryptedWarning && (
<Tooltip
label={t("common.unencrypted")}
placement="bottom"
isTriggerInteractive={false}
nonInteractiveTriggerTabIndex={focusable ? undefined : -1}
> >
<ErrorSolidIcon {displayName}
width={20} </Text>
height={20} {warnings}
className={styles.errorIcon} </div>
role="img" ) : (
aria-label={t("common.unencrypted")} warnings
/> )}
</Tooltip>
)}
</div>
{primaryButton} {primaryButton}
</div> </div>
</animated.div> </animated.div>

View File

@@ -35,7 +35,9 @@ Please see LICENSE in the repository root for full details.
.maximised .item { .maximised .item {
/* Ensure that foreground elements lie within the safe area */ /* Ensure that foreground elements lie within the safe area */
--media-view-fg-inset: 10px calc(env(safe-area-inset-right) + 10px) 10px --media-view-fg-inset: calc(var(--call-view-safe-area-inset-top, 0px) + 10px)
calc(env(safe-area-inset-right) + 10px)
calc(var(--call-view-safe-area-inset-bottom, 0px) + 10px)
calc(env(safe-area-inset-left) + 10px); calc(env(safe-area-inset-left) + 10px);
} }

View File

@@ -65,6 +65,7 @@ test("SpotlightTile is accessible", async () => {
expanded={false} expanded={false}
onToggleExpanded={toggleExpanded} onToggleExpanded={toggleExpanded}
showIndicators showIndicators
showNameTags
focusable={true} focusable={true}
/>, />,
); );
@@ -106,6 +107,7 @@ test("Screen share volume UI is shown when screen share has audio", async () =>
expanded={false} expanded={false}
onToggleExpanded={toggleExpanded} onToggleExpanded={toggleExpanded}
showIndicators showIndicators
showNameTags
focusable focusable
/> />
</TooltipProvider>, </TooltipProvider>,
@@ -135,6 +137,7 @@ test("Screen share volume UI is hidden when screen share has no audio", async ()
expanded={false} expanded={false}
onToggleExpanded={toggleExpanded} onToggleExpanded={toggleExpanded}
showIndicators showIndicators
showNameTags
focusable focusable
/>, />,
); );
@@ -171,6 +174,7 @@ test("SpotlightTile displays ringing media", async () => {
expanded={false} expanded={false}
onToggleExpanded={toggleExpanded} onToggleExpanded={toggleExpanded}
showIndicators showIndicators
showNameTags
focusable={true} focusable={true}
/>, />,
); );

View File

@@ -66,6 +66,7 @@ interface SpotlightItemBaseProps {
userId: string; userId: string;
displayName: string; displayName: string;
mxcAvatarUrl: string | undefined; mxcAvatarUrl: string | undefined;
showNameTags: boolean;
focusable: boolean; focusable: boolean;
"aria-hidden"?: boolean; "aria-hidden"?: boolean;
} }
@@ -244,6 +245,7 @@ interface SpotlightItemProps {
* The height this tile will have once its animations have settled. * The height this tile will have once its animations have settled.
*/ */
targetHeight: number; targetHeight: number;
showNameTags: boolean;
focusable: boolean; focusable: boolean;
intersectionObserver$: Observable<IntersectionObserver>; intersectionObserver$: Observable<IntersectionObserver>;
/** /**
@@ -258,6 +260,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
vm, vm,
targetWidth, targetWidth,
targetHeight, targetHeight,
showNameTags,
focusable, focusable,
intersectionObserver$, intersectionObserver$,
snap, snap,
@@ -293,6 +296,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
userId: vm.userId, userId: vm.userId,
displayName, displayName,
mxcAvatarUrl, mxcAvatarUrl,
showNameTags,
focusable, focusable,
"aria-hidden": ariaHidden, "aria-hidden": ariaHidden,
}; };
@@ -381,6 +385,7 @@ interface Props {
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
showIndicators: boolean; showIndicators: boolean;
showNameTags: boolean;
focusable: boolean; focusable: boolean;
className?: string; className?: string;
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
@@ -394,6 +399,7 @@ export const SpotlightTile: FC<Props> = ({
targetWidth, targetWidth,
targetHeight, targetHeight,
showIndicators, showIndicators,
showNameTags,
focusable = true, focusable = true,
className, className,
style, style,
@@ -504,6 +510,7 @@ export const SpotlightTile: FC<Props> = ({
vm={vm} vm={vm}
targetWidth={targetWidth} targetWidth={targetWidth}
targetHeight={targetHeight} targetHeight={targetHeight}
showNameTags={showNameTags}
focusable={focusable} focusable={focusable}
intersectionObserver$={intersectionObserver$} intersectionObserver$={intersectionObserver$}
// This is how we get the container to scroll to the right media // This is how we get the container to scroll to the right media