mirror of
https://github.com/vector-im/element-call.git
synced 2026-05-07 10:14:36 +00:00
WIP
This commit is contained in:
@@ -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.
|
||||
*/
|
||||
|
||||
import { type BehaviorSubject, type Observable } from "rxjs";
|
||||
import { type Observable } from "rxjs";
|
||||
import { type ComponentType } from "react";
|
||||
|
||||
import { type LayoutProps } from "./Grid";
|
||||
@@ -16,41 +16,11 @@ export interface Bounds {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Alignment {
|
||||
inline: "start" | "end";
|
||||
block: "start" | "end";
|
||||
}
|
||||
|
||||
export const defaultSpotlightAlignment: Alignment = {
|
||||
inline: "end",
|
||||
block: "end",
|
||||
};
|
||||
export const defaultPortraitPipAlignment: Alignment = {
|
||||
inline: "end",
|
||||
block: "end",
|
||||
};
|
||||
export const defaultLandscapePipAlignment: Alignment = {
|
||||
inline: "end",
|
||||
block: "start",
|
||||
};
|
||||
|
||||
export interface CallLayoutInputs {
|
||||
/**
|
||||
* The minimum bounds of the layout area.
|
||||
*/
|
||||
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, when in portrait.
|
||||
*/
|
||||
portraitPipAlignment$: BehaviorSubject<Alignment>;
|
||||
/**
|
||||
* The alignment of the small picture-in-picture tile, if present, when in landscape.
|
||||
*/
|
||||
landscapePipAlignment$: BehaviorSubject<Alignment>;
|
||||
}
|
||||
|
||||
export interface CallLayoutOutputs<Model> {
|
||||
|
||||
@@ -32,7 +32,6 @@ interface GridCSSProperties extends CSSProperties {
|
||||
*/
|
||||
export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||
minBounds$,
|
||||
spotlightAlignment$,
|
||||
}) => ({
|
||||
scrollingOnTop: false,
|
||||
|
||||
@@ -42,7 +41,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||
useUpdateLayout();
|
||||
const alignment = useObservableEagerState(
|
||||
useInitial(() =>
|
||||
spotlightAlignment$.pipe(
|
||||
model.spotlightAlignment$.pipe(
|
||||
distinctUntilChanged(
|
||||
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
|
||||
),
|
||||
@@ -52,11 +51,11 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||
|
||||
const onDragSpotlight: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
spotlightAlignment$.next({
|
||||
model.spotlightAlignment$.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[],
|
||||
[model.spotlightAlignment$],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,12 +22,6 @@ Please see LICENSE in the repository root for full details.
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.spotlight {
|
||||
position: absolute;
|
||||
inline-size: 404px;
|
||||
block-size: 233px;
|
||||
}
|
||||
|
||||
.slot[data-block-alignment="start"] {
|
||||
inset-block-end: unset;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useBehavior } from "../useBehavior";
|
||||
*/
|
||||
export const makeOneOnOneLandscapeLayout: CallLayout<
|
||||
OneOnOneLandscapeLayoutModel
|
||||
> = ({ minBounds$, landscapePipAlignment$ }) => ({
|
||||
> = ({ minBounds$ }) => ({
|
||||
scrollingOnTop: false,
|
||||
|
||||
fixed: function OneOnOneLandscapeLayoutFixed({ ref }): ReactNode {
|
||||
@@ -38,7 +38,7 @@ export const makeOneOnOneLandscapeLayout: CallLayout<
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const { width, height } = useObservableEagerState(minBounds$);
|
||||
const pipAlignmentValue = useBehavior(landscapePipAlignment$);
|
||||
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||
const { tileWidth, tileHeight } = useMemo(
|
||||
() => arrangeTiles(width, height, 1),
|
||||
[width, height],
|
||||
@@ -46,11 +46,11 @@ export const makeOneOnOneLandscapeLayout: CallLayout<
|
||||
|
||||
const onDragLocalTile: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
landscapePipAlignment$.next({
|
||||
model.pipAlignment$.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[],
|
||||
[model.pipAlignment$],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -66,8 +66,8 @@ export const makeOneOnOneLandscapeLayout: CallLayout<
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
onDrag={onDragLocalTile}
|
||||
data-block-alignment={pipAlignmentValue.block}
|
||||
data-inline-alignment={pipAlignmentValue.inline}
|
||||
data-block-alignment={pipAlignment.block}
|
||||
data-inline-alignment={pipAlignment.inline}
|
||||
/>
|
||||
</Slot>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
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.
|
||||
@@ -7,39 +7,40 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
.layer {
|
||||
block-size: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.local {
|
||||
position: absolute;
|
||||
inline-size: 180px;
|
||||
block-size: 135px;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.spotlight {
|
||||
position: absolute;
|
||||
inline-size: 404px;
|
||||
block-size: 233px;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.slot[data-block-alignment="start"] {
|
||||
.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;
|
||||
}
|
||||
|
||||
.slot[data-block-alignment="end"] {
|
||||
.pip[data-block-alignment="end"] {
|
||||
inset-block-start: unset;
|
||||
}
|
||||
|
||||
.slot[data-inline-alignment="start"] {
|
||||
.pip[data-inline-alignment="start"] {
|
||||
inset-inline-end: unset;
|
||||
}
|
||||
|
||||
.slot[data-inline-alignment="end"] {
|
||||
.pip[data-inline-alignment="end"] {
|
||||
inset-inline-start: unset;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,11 @@ 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, useMemo } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type OneOnOnePortraitLayout as OneOnOnePortraitLayoutModel } from "../state/layout-types.ts";
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import styles from "./OneOnOnePortraitLayout.module.css";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
@@ -23,12 +22,20 @@ import { useBehavior } from "../useBehavior";
|
||||
*/
|
||||
export const makeOneOnOnePortraitLayout: CallLayout<
|
||||
OneOnOnePortraitLayoutModel
|
||||
> = ({ minBounds$, portraitPipAlignment$ }) => ({
|
||||
> = () => ({
|
||||
scrollingOnTop: false,
|
||||
|
||||
fixed: function OneOnOnePortraitLayoutFixed({ ref }): ReactNode {
|
||||
fixed: function OneOnOnePortraitLayoutFixed({ ref, model, Slot }): ReactNode {
|
||||
useUpdateLayout();
|
||||
return <div ref={ref} />;
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<Slot
|
||||
className={styles.spotlight}
|
||||
id={model.spotlight.id}
|
||||
model={model.spotlight}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
scrolling: function OneOnOnePortraitLayoutScrolling({
|
||||
@@ -37,41 +44,30 @@ export const makeOneOnOnePortraitLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const { width, height } = useObservableEagerState(minBounds$);
|
||||
const pipAlignmentValue = useBehavior(portraitPipAlignment$);
|
||||
const { tileWidth, tileHeight } = useMemo(
|
||||
() => arrangeTiles(width, height, 1),
|
||||
[width, height],
|
||||
);
|
||||
|
||||
const pipSize = useBehavior(model.pipSize$);
|
||||
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||
const onDragLocalTile: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
portraitPipAlignment$.next({
|
||||
model.pipAlignment$.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[],
|
||||
[model.pipAlignment$],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<Slot
|
||||
id={model.spotlight.id}
|
||||
model={model.spotlight}
|
||||
className={styles.container}
|
||||
style={{ width: tileWidth, height: tileHeight }}
|
||||
>
|
||||
{model.pip && (
|
||||
<Slot
|
||||
className={classNames(styles.slot, styles.local)}
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
onDrag={onDragLocalTile}
|
||||
data-block-alignment={pipAlignmentValue.block}
|
||||
data-inline-alignment={pipAlignmentValue.inline}
|
||||
/>
|
||||
)}
|
||||
</Slot>
|
||||
{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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useBehavior } from "../useBehavior";
|
||||
*/
|
||||
export const makeSpotlightExpandedLayout: CallLayout<
|
||||
SpotlightExpandedLayoutModel
|
||||
> = ({ landscapePipAlignment$ }) => ({
|
||||
> = () => ({
|
||||
scrollingOnTop: true,
|
||||
|
||||
fixed: function SpotlightExpandedLayoutFixed({
|
||||
@@ -46,15 +46,15 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const pipAlignmentValue = useBehavior(landscapePipAlignment$);
|
||||
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||
|
||||
const onDragPip: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
landscapePipAlignment$.next({
|
||||
model.pipAlignment$.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[],
|
||||
[model.pipAlignment$],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -65,8 +65,8 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
onDrag={onDragPip}
|
||||
data-block-alignment={pipAlignmentValue.block}
|
||||
data-inline-alignment={pipAlignmentValue.inline}
|
||||
data-block-alignment={pipAlignment.block}
|
||||
data-inline-alignment={pipAlignment.inline}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
block-size: var(--cpd-space-6x);
|
||||
background: none;
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import useMeasure from "react-use-measure";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import classNames from "classnames";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
import { map } from "rxjs";
|
||||
import { useObservable } from "observable-hooks";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -51,12 +51,7 @@ import { SpotlightTile } from "../tile/SpotlightTile";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { makeGridLayout } from "../grid/GridLayout";
|
||||
import {
|
||||
type CallLayoutOutputs,
|
||||
defaultSpotlightAlignment,
|
||||
defaultPortraitPipAlignment,
|
||||
defaultLandscapePipAlignment,
|
||||
} from "../grid/CallLayout";
|
||||
import { type CallLayoutOutputs } from "../grid/CallLayout";
|
||||
import { makeOneOnOneLandscapeLayout } from "../grid/OneOnOneLandscapeLayout";
|
||||
import { makeOneOnOnePortraitLayout } from "../grid/OneOnOnePortraitLayout";
|
||||
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
|
||||
@@ -241,8 +236,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const audioParticipants = useBehavior(vm.livekitRoomItems$);
|
||||
const participantCount = useBehavior(vm.participantCount$);
|
||||
const reconnecting = useBehavior(vm.reconnecting$);
|
||||
const windowMode = useBehavior(vm.windowMode$);
|
||||
const layout = useBehavior(vm.layout$);
|
||||
const edgeToEdge = useBehavior(vm.edgeToEdge$);
|
||||
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
||||
const [debugTileLayout] = useSetting(debugTileLayoutSetting);
|
||||
const gridMode = useBehavior(vm.gridMode$);
|
||||
@@ -327,15 +322,14 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
width: bounds.width,
|
||||
height:
|
||||
bounds.height -
|
||||
headerBounds.height -
|
||||
(windowMode === "flat" ? 0 : footerBounds.height),
|
||||
(edgeToEdge ? 0 : headerBounds.height + footerBounds.height),
|
||||
}),
|
||||
[
|
||||
bounds.width,
|
||||
bounds.height,
|
||||
headerBounds.height,
|
||||
footerBounds.height,
|
||||
windowMode,
|
||||
edgeToEdge,
|
||||
],
|
||||
);
|
||||
const gridBoundsObservable$ = useObservable(
|
||||
@@ -343,16 +337,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
[gridBounds],
|
||||
);
|
||||
|
||||
const spotlightAlignment$ = useInitial(
|
||||
() => new BehaviorSubject(defaultSpotlightAlignment),
|
||||
);
|
||||
const portraitPipAlignment$ = useInitial(
|
||||
() => new BehaviorSubject(defaultPortraitPipAlignment),
|
||||
);
|
||||
const landscapePipAlignment$ = useInitial(
|
||||
() => new BehaviorSubject(defaultLandscapePipAlignment),
|
||||
);
|
||||
|
||||
const setGridMode = useCallback(
|
||||
(mode: GridMode) => vm.setGridMode(mode),
|
||||
[vm],
|
||||
@@ -361,49 +345,47 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
useAppBarHidden(!showHeader);
|
||||
|
||||
let header: ReactNode = null;
|
||||
if (showHeader) {
|
||||
switch (headerStyle) {
|
||||
case HeaderStyle.AppBar: {
|
||||
// dont build a header here. The AppBar will take care of it.
|
||||
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>
|
||||
);
|
||||
switch (headerStyle) {
|
||||
case HeaderStyle.AppBar: {
|
||||
// dont build a header here. The AppBar will take care of it.
|
||||
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={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
|
||||
@@ -488,12 +470,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
);
|
||||
|
||||
const layouts = useMemo(() => {
|
||||
const inputs = {
|
||||
minBounds$: gridBoundsObservable$,
|
||||
spotlightAlignment$,
|
||||
portraitPipAlignment$,
|
||||
landscapePipAlignment$,
|
||||
};
|
||||
const inputs = { minBounds$: gridBoundsObservable$ };
|
||||
return {
|
||||
grid: makeGridLayout(inputs),
|
||||
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
|
||||
@@ -502,12 +479,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
"one-on-one-landscape": makeOneOnOneLandscapeLayout(inputs),
|
||||
"one-on-one-portrait": makeOneOnOnePortraitLayout(inputs),
|
||||
};
|
||||
}, [
|
||||
gridBoundsObservable$,
|
||||
spotlightAlignment$,
|
||||
portraitPipAlignment$,
|
||||
landscapePipAlignment$,
|
||||
]);
|
||||
}, [gridBoundsObservable$]);
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
if (layout.type === "pip") {
|
||||
@@ -533,7 +505,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
className={styles.fixedGrid}
|
||||
style={{
|
||||
insetBlockStart:
|
||||
headerBounds.height > 0 ? headerBounds.bottom : bounds.top,
|
||||
edgeToEdge || headerBounds.height === 0
|
||||
? bounds.top
|
||||
: headerBounds.bottom,
|
||||
height: gridBounds.height,
|
||||
}}
|
||||
model={layout}
|
||||
@@ -552,19 +526,24 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
aria-hidden={contentObscured}
|
||||
/>
|
||||
);
|
||||
// The grid tiles go *under* the spotlight in the portrait layout, but
|
||||
// *over* the spotlight in the expanded layout
|
||||
return layout.type === "spotlight-expanded" ? (
|
||||
<>
|
||||
{fixedGrid}
|
||||
{scrollingGrid}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{scrollingGrid}
|
||||
{fixedGrid}
|
||||
</>
|
||||
);
|
||||
|
||||
// Put the right layer in the foreground for the requested layout
|
||||
switch (layout.foreground) {
|
||||
case "fixed":
|
||||
return (
|
||||
<>
|
||||
{fixedGrid}
|
||||
{scrollingGrid}
|
||||
</>
|
||||
);
|
||||
case "scrolling":
|
||||
return (
|
||||
<>
|
||||
{scrollingGrid}
|
||||
{fixedGrid}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
||||
@@ -587,7 +566,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
ref={footerRef}
|
||||
hidden={!showFooter}
|
||||
hideControls={!showControls}
|
||||
asOverlay={windowMode === "flat"}
|
||||
asOverlay={edgeToEdge}
|
||||
asPip={layout.type === "pip"}
|
||||
// Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None.
|
||||
hideLogo={headerStyle !== HeaderStyle.Standard}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
tap,
|
||||
throttleTime,
|
||||
timer,
|
||||
BehaviorSubject,
|
||||
} from "rxjs";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
@@ -87,6 +88,7 @@ import { getUrlParams, HeaderStyle } from "../../UrlParams";
|
||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||
import { ElementWidgetActions, widget } from "../../widget";
|
||||
import {
|
||||
type Alignment,
|
||||
type GridLayoutMedia,
|
||||
type Layout,
|
||||
type LayoutMedia,
|
||||
@@ -329,16 +331,6 @@ export interface CallViewModel {
|
||||
{ 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.
|
||||
*/
|
||||
@@ -349,10 +341,19 @@ export interface CallViewModel {
|
||||
tileStoreGeneration$: Behavior<number>;
|
||||
showSpotlightIndicators$: Behavior<boolean>;
|
||||
showSpeakingIndicators$: Behavior<boolean>;
|
||||
spotlightExpanded$: Behavior<boolean>;
|
||||
toggleSpotlightExpanded$: Behavior<(() => void) | null>;
|
||||
gridMode$: Behavior<GridMode>;
|
||||
setGridMode: (value: GridMode) => void;
|
||||
|
||||
// header/footer visibility
|
||||
showHeader$: 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
|
||||
/**
|
||||
@@ -1058,6 +1059,7 @@ export function createCallViewModel$(
|
||||
[grid$, spotlight$],
|
||||
(grid, spotlight) => ({
|
||||
type: "grid",
|
||||
edgeToEdge: false,
|
||||
spotlight: spotlight.some((vm) => vm.type === "screen share")
|
||||
? spotlight
|
||||
: undefined,
|
||||
@@ -1068,6 +1070,7 @@ export function createCallViewModel$(
|
||||
const spotlightLandscapeLayoutMedia$: Observable<SpotlightLandscapeLayoutMedia> =
|
||||
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
|
||||
type: "spotlight-landscape",
|
||||
edgeToEdge: false,
|
||||
spotlight,
|
||||
grid,
|
||||
}));
|
||||
@@ -1075,16 +1078,20 @@ export function createCallViewModel$(
|
||||
const spotlightPortraitLayoutMedia$: Observable<SpotlightPortraitLayoutMedia> =
|
||||
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
|
||||
type: "spotlight-portrait",
|
||||
edgeToEdge: false,
|
||||
spotlight,
|
||||
grid,
|
||||
}));
|
||||
|
||||
const spotlightExpandedLayoutMedia$: Observable<SpotlightExpandedLayoutMedia> =
|
||||
const spotlightExpandedLayoutMedia$ = (
|
||||
edgeToEdge: boolean,
|
||||
): Observable<SpotlightExpandedLayoutMedia> =>
|
||||
spotlightAndPip$.pipe(
|
||||
switchMap(({ spotlight, pip$ }) =>
|
||||
pip$.pipe(
|
||||
map((pip) => ({
|
||||
type: "spotlight-expanded" as const,
|
||||
edgeToEdge,
|
||||
spotlight,
|
||||
pip: pip ?? undefined,
|
||||
})),
|
||||
@@ -1140,11 +1147,13 @@ export function createCallViewModel$(
|
||||
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,
|
||||
};
|
||||
@@ -1158,6 +1167,7 @@ export function createCallViewModel$(
|
||||
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,
|
||||
})),
|
||||
@@ -1166,7 +1176,11 @@ export function createCallViewModel$(
|
||||
);
|
||||
|
||||
const pipLayoutMedia$: Observable<LayoutMedia> = spotlight$.pipe(
|
||||
map((spotlight) => ({ type: "pip", spotlight })),
|
||||
map((spotlight) => ({
|
||||
type: "pip",
|
||||
edgeToEdge: platform !== "desktop",
|
||||
spotlight,
|
||||
})),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -1190,7 +1204,7 @@ export function createCallViewModel$(
|
||||
return spotlightExpanded$.pipe(
|
||||
switchMap((expanded) =>
|
||||
expanded
|
||||
? spotlightExpandedLayoutMedia$
|
||||
? spotlightExpandedLayoutMedia$(false)
|
||||
: spotlightLandscapeLayoutMedia$,
|
||||
),
|
||||
);
|
||||
@@ -1219,7 +1233,7 @@ export function createCallViewModel$(
|
||||
// this window mode.
|
||||
return spotlightLandscapeLayoutMedia$;
|
||||
case "spotlight":
|
||||
return spotlightExpandedLayoutMedia$;
|
||||
return spotlightExpandedLayoutMedia$(true);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1230,6 +1244,170 @@ 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 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
|
||||
// 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
|
||||
@@ -1256,19 +1434,33 @@ export function createCallViewModel$(
|
||||
case "spotlight-portrait":
|
||||
[layout, newTiles] = gridLikeLayout(
|
||||
media,
|
||||
spotlightAlignment$,
|
||||
visibleTiles,
|
||||
setVisibleTiles,
|
||||
prevTiles,
|
||||
);
|
||||
break;
|
||||
case "spotlight-expanded":
|
||||
[layout, newTiles] = spotlightExpandedLayout(media, prevTiles);
|
||||
[layout, newTiles] = spotlightExpandedLayout(
|
||||
media,
|
||||
landscapePipAlignment$,
|
||||
prevTiles,
|
||||
);
|
||||
break;
|
||||
case "one-on-one-landscape":
|
||||
[layout, newTiles] = oneOnOneLandscapeLayout(media, prevTiles);
|
||||
[layout, newTiles] = oneOnOneLandscapeLayout(
|
||||
media,
|
||||
landscapePipAlignment$,
|
||||
prevTiles,
|
||||
);
|
||||
break;
|
||||
case "one-on-one-portrait":
|
||||
[layout, newTiles] = oneOnOnePortraitLayout(media, prevTiles);
|
||||
[layout, newTiles] = oneOnOnePortraitLayout(
|
||||
media,
|
||||
portraitPipSize$,
|
||||
portraitPipAlignment$,
|
||||
prevTiles,
|
||||
);
|
||||
break;
|
||||
case "pip":
|
||||
[layout, newTiles] = pipLayout(media, prevTiles);
|
||||
@@ -1296,131 +1488,6 @@ export function createCallViewModel$(
|
||||
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-landscape":
|
||||
case "one-on-one-portrait":
|
||||
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.
|
||||
*/
|
||||
@@ -1624,7 +1691,6 @@ export function createCallViewModel$(
|
||||
audibleReactions$: audibleReactions$,
|
||||
visibleReactions$: visibleReactions$,
|
||||
|
||||
windowMode$: windowMode$,
|
||||
spotlightExpanded$: spotlightExpanded$,
|
||||
toggleSpotlightExpanded$: toggleSpotlightExpanded$,
|
||||
gridMode$: gridMode$,
|
||||
@@ -1652,6 +1718,7 @@ export function createCallViewModel$(
|
||||
showSpeakingIndicators$: showSpeakingIndicators$,
|
||||
showHeader$: showHeader$,
|
||||
showFooter$: showFooter$,
|
||||
edgeToEdge$,
|
||||
earpieceMode$: earpieceMode$,
|
||||
audioOutputSwitcher$: audioOutputSwitcher$,
|
||||
reconnecting$: localMembership.reconnecting$,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
export type GridLikeLayoutType =
|
||||
@@ -19,6 +25,7 @@ export type GridLikeLayoutType =
|
||||
*/
|
||||
export function gridLikeLayout(
|
||||
media: LayoutMedia & { type: GridLikeLayoutType },
|
||||
spotlightAlignment$: BehaviorSubject<Alignment>,
|
||||
visibleTiles: number,
|
||||
setVisibleTiles: (value: number) => void,
|
||||
prevTiles: TileStore,
|
||||
@@ -35,8 +42,10 @@ export function gridLikeLayout(
|
||||
return [
|
||||
{
|
||||
type: media.type,
|
||||
foreground: "scrolling",
|
||||
spotlight: tiles.spotlightTile,
|
||||
grid: tiles.gridTiles,
|
||||
spotlightAlignment$,
|
||||
setVisibleTiles,
|
||||
} as Layout & { type: GridLikeLayoutType },
|
||||
tiles,
|
||||
|
||||
@@ -6,7 +6,10 @@ 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 OneOnOneLandscapeLayout,
|
||||
type OneOnOneLandscapeLayoutMedia,
|
||||
} from "./layout-types";
|
||||
@@ -17,17 +20,21 @@ import { type TileStore } from "./TileStore";
|
||||
*/
|
||||
export function oneOnOneLandscapeLayout(
|
||||
media: OneOnOneLandscapeLayoutMedia,
|
||||
pipAlignment$: BehaviorSubject<Alignment>,
|
||||
prevTiles: TileStore,
|
||||
): [OneOnOneLandscapeLayout, TileStore] {
|
||||
const update = prevTiles.from(2);
|
||||
update.registerGridTile(media.pip);
|
||||
update.registerGridTile(media.spotlight);
|
||||
const tiles = update.build();
|
||||
|
||||
return [
|
||||
{
|
||||
type: media.type,
|
||||
foreground: "scrolling",
|
||||
spotlight: tiles.gridTilesByMedia.get(media.spotlight)!,
|
||||
pip: tiles.gridTilesByMedia.get(media.pip)!,
|
||||
pipAlignment$,
|
||||
},
|
||||
tiles,
|
||||
];
|
||||
|
||||
@@ -6,28 +6,38 @@ 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 ? 1 : 2);
|
||||
if (media.pip !== undefined) update.registerGridTile(media.pip);
|
||||
update.registerGridTile(media.spotlight);
|
||||
if (media.pip !== undefined) update.registerGridTile(media.pip, false);
|
||||
update.registerGridTile(media.spotlight, false, true);
|
||||
const tiles = update.build();
|
||||
|
||||
return [
|
||||
{
|
||||
type: media.type,
|
||||
foreground: "fixed",
|
||||
spotlight: tiles.gridTilesByMedia.get(media.spotlight)!,
|
||||
pip: media.pip && tiles.gridTilesByMedia.get(media.pip)!,
|
||||
pip: media.pip && tiles.gridTilesByMedia.get(media.pip),
|
||||
pipSize$,
|
||||
pipAlignment$,
|
||||
},
|
||||
tiles,
|
||||
];
|
||||
|
||||
@@ -26,6 +26,7 @@ export function pipLayout(
|
||||
return [
|
||||
{
|
||||
type: media.type,
|
||||
foreground: "fixed",
|
||||
spotlight: tiles.spotlightTile!,
|
||||
},
|
||||
tiles,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
type Alignment,
|
||||
type SpotlightExpandedLayout,
|
||||
type SpotlightExpandedLayoutMedia,
|
||||
} from "./layout-types";
|
||||
@@ -16,6 +19,7 @@ import { type TileStore } from "./TileStore";
|
||||
*/
|
||||
export function spotlightExpandedLayout(
|
||||
media: SpotlightExpandedLayoutMedia,
|
||||
pipAlignment$: BehaviorSubject<Alignment>,
|
||||
prevTiles: TileStore,
|
||||
): [SpotlightExpandedLayout, TileStore] {
|
||||
const update = prevTiles.from(1);
|
||||
@@ -26,8 +30,10 @@ export function spotlightExpandedLayout(
|
||||
return [
|
||||
{
|
||||
type: media.type,
|
||||
foreground: "fixed",
|
||||
spotlight: tiles.spotlightTile!,
|
||||
pip: tiles.gridTiles[0],
|
||||
pip: tiles.gridTiles.at(0),
|
||||
pipAlignment$,
|
||||
},
|
||||
tiles,
|
||||
];
|
||||
|
||||
@@ -59,11 +59,37 @@ class GridTileData {
|
||||
this.media$.next(value);
|
||||
}
|
||||
|
||||
private readonly showNameTag$: BehaviorSubject<boolean>;
|
||||
public get showNameTag(): boolean {
|
||||
return this.showNameTag$.value;
|
||||
}
|
||||
public set showNameTag(value: boolean) {
|
||||
this.showNameTag$.next(value);
|
||||
}
|
||||
|
||||
private readonly edgeToEdge$: BehaviorSubject<boolean>;
|
||||
public get edgeToEdge(): boolean {
|
||||
return this.edgeToEdge$.value;
|
||||
}
|
||||
public set edgeToEdge(value: boolean) {
|
||||
this.edgeToEdge$.next(value);
|
||||
}
|
||||
|
||||
public readonly vm: GridTileViewModel;
|
||||
|
||||
public constructor(media: UserMediaViewModel | RingingMediaViewModel) {
|
||||
public constructor(
|
||||
media: UserMediaViewModel | RingingMediaViewModel,
|
||||
showNameTag: boolean,
|
||||
edgeToEdge: boolean,
|
||||
) {
|
||||
this.media$ = new BehaviorSubject(media);
|
||||
this.vm = new GridTileViewModel(this.media$);
|
||||
this.showNameTag$ = new BehaviorSubject(showNameTag);
|
||||
this.edgeToEdge$ = new BehaviorSubject(edgeToEdge);
|
||||
this.vm = new GridTileViewModel(
|
||||
this.media$,
|
||||
this.showNameTag$,
|
||||
this.edgeToEdge$,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +209,8 @@ export class TileStoreBuilder {
|
||||
*/
|
||||
public registerGridTile(
|
||||
media: UserMediaViewModel | RingingMediaViewModel,
|
||||
showNameTag = true,
|
||||
edgeToEdge = false,
|
||||
): void {
|
||||
if (DEBUG_ENABLED)
|
||||
logger.debug(
|
||||
@@ -220,6 +248,8 @@ export class TileStoreBuilder {
|
||||
this.stationaryGridEntries[prevIndex] = entry;
|
||||
// Do the media swap
|
||||
entry.media = media;
|
||||
entry.showNameTag = showNameTag;
|
||||
entry.edgeToEdge = edgeToEdge;
|
||||
this.prevGridByMedia.delete(this.spotlight.media[0]);
|
||||
this.prevGridByMedia.set(media, prev);
|
||||
} else {
|
||||
@@ -228,7 +258,7 @@ export class TileStoreBuilder {
|
||||
(nowVisible
|
||||
? this.visibleGridEntries
|
||||
: this.invisibleGridEntries
|
||||
).push(new GridTileData(media));
|
||||
).push(new GridTileData(media, showNameTag, edgeToEdge));
|
||||
}
|
||||
|
||||
this.numGridEntries++;
|
||||
@@ -244,10 +274,12 @@ export class TileStoreBuilder {
|
||||
(this.numGridEntries < this.visibleTiles
|
||||
? this.visibleGridEntries
|
||||
: this.invisibleGridEntries
|
||||
).push(new GridTileData(media));
|
||||
).push(new GridTileData(media, showNameTag, edgeToEdge));
|
||||
} else {
|
||||
// Reuse the existing tile
|
||||
const [entry, prevIndex] = prev;
|
||||
entry.showNameTag = showNameTag;
|
||||
entry.edgeToEdge = edgeToEdge;
|
||||
const previouslyVisible = prevIndex < this.visibleTiles;
|
||||
const nowVisible = this.numGridEntries < this.visibleTiles;
|
||||
// If it doesn't need to move between the visible/invisible sections of
|
||||
@@ -270,7 +302,7 @@ export class TileStoreBuilder {
|
||||
* method will more eagerly try to reuse an existing tile, replacing its
|
||||
* media, than registerGridTile would.
|
||||
*/
|
||||
public registerPipTile(media: UserMediaViewModel): void {
|
||||
public registerPipTile(media: UserMediaViewModel, showNameTag = true): void {
|
||||
if (DEBUG_ENABLED)
|
||||
logger.debug(
|
||||
`[TileStore, ${this.generation}] register PiP tile: ${media.displayName$.value}`,
|
||||
@@ -282,10 +314,12 @@ export class TileStoreBuilder {
|
||||
this.stationaryGridEntries[0] = entry;
|
||||
// Do the media swap
|
||||
entry.media = media;
|
||||
entry.showNameTag = showNameTag;
|
||||
entry.edgeToEdge = false;
|
||||
this.prevGridByMedia.delete(entry.media);
|
||||
this.prevGridByMedia.set(media, [entry, 0]);
|
||||
} else {
|
||||
this.visibleGridEntries.push(new GridTileData(media));
|
||||
this.visibleGridEntries.push(new GridTileData(media, showNameTag, false));
|
||||
}
|
||||
|
||||
this.numGridEntries++;
|
||||
|
||||
@@ -23,6 +23,7 @@ export class GridTileViewModel {
|
||||
UserMediaViewModel | RingingMediaViewModel
|
||||
>,
|
||||
public readonly showNameTag$: Behavior<boolean>,
|
||||
public readonly edgeToEdge$: Behavior<boolean>,
|
||||
) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
|
||||
import { type MediaViewModel } from "./media/MediaViewModel.ts";
|
||||
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts";
|
||||
@@ -13,45 +15,53 @@ import {
|
||||
type GridTileViewModel,
|
||||
type SpotlightTileViewModel,
|
||||
} from "./TileViewModel.ts";
|
||||
import { type Behavior } from "./Behavior.ts";
|
||||
|
||||
export interface GridLayoutMedia {
|
||||
type: "grid";
|
||||
edgeToEdge: false;
|
||||
spotlight?: MediaViewModel[];
|
||||
grid: UserMediaViewModel[];
|
||||
}
|
||||
|
||||
export interface SpotlightLandscapeLayoutMedia {
|
||||
type: "spotlight-landscape";
|
||||
edgeToEdge: false;
|
||||
spotlight: MediaViewModel[];
|
||||
grid: UserMediaViewModel[];
|
||||
}
|
||||
|
||||
export interface SpotlightPortraitLayoutMedia {
|
||||
type: "spotlight-portrait";
|
||||
edgeToEdge: false;
|
||||
spotlight: MediaViewModel[];
|
||||
grid: UserMediaViewModel[];
|
||||
}
|
||||
|
||||
export interface SpotlightExpandedLayoutMedia {
|
||||
type: "spotlight-expanded";
|
||||
edgeToEdge: boolean;
|
||||
spotlight: MediaViewModel[];
|
||||
pip?: UserMediaViewModel;
|
||||
}
|
||||
|
||||
export interface OneOnOneLandscapeLayoutMedia {
|
||||
type: "one-on-one-landscape";
|
||||
edgeToEdge: false;
|
||||
spotlight: UserMediaViewModel;
|
||||
pip: LocalUserMediaViewModel | RingingMediaViewModel;
|
||||
}
|
||||
|
||||
export interface OneOnOnePortraitLayoutMedia {
|
||||
type: "one-on-one-portrait";
|
||||
edgeToEdge: true;
|
||||
spotlight: UserMediaViewModel | RingingMediaViewModel;
|
||||
pip?: LocalUserMediaViewModel;
|
||||
}
|
||||
|
||||
export interface PipLayoutMedia {
|
||||
type: "pip";
|
||||
edgeToEdge: boolean;
|
||||
spotlight: MediaViewModel[];
|
||||
}
|
||||
|
||||
@@ -64,15 +74,23 @@ export type LayoutMedia =
|
||||
| OneOnOnePortraitLayoutMedia
|
||||
| PipLayoutMedia;
|
||||
|
||||
export interface Alignment {
|
||||
inline: "start" | "end";
|
||||
block: "start" | "end";
|
||||
}
|
||||
|
||||
export interface GridLayout {
|
||||
type: "grid";
|
||||
foreground: "scrolling";
|
||||
spotlight?: SpotlightTileViewModel;
|
||||
grid: GridTileViewModel[];
|
||||
spotlightAlignment$: BehaviorSubject<Alignment>;
|
||||
setVisibleTiles: (value: number) => void;
|
||||
}
|
||||
|
||||
export interface SpotlightLandscapeLayout {
|
||||
type: "spotlight-landscape";
|
||||
foreground: "scrolling";
|
||||
spotlight: SpotlightTileViewModel;
|
||||
grid: GridTileViewModel[];
|
||||
setVisibleTiles: (value: number) => void;
|
||||
@@ -80,6 +98,7 @@ export interface SpotlightLandscapeLayout {
|
||||
|
||||
export interface SpotlightPortraitLayout {
|
||||
type: "spotlight-portrait";
|
||||
foreground: "scrolling";
|
||||
spotlight: SpotlightTileViewModel;
|
||||
grid: GridTileViewModel[];
|
||||
setVisibleTiles: (value: number) => void;
|
||||
@@ -87,24 +106,32 @@ export interface SpotlightPortraitLayout {
|
||||
|
||||
export interface SpotlightExpandedLayout {
|
||||
type: "spotlight-expanded";
|
||||
foreground: "fixed";
|
||||
spotlight: SpotlightTileViewModel;
|
||||
pip?: GridTileViewModel;
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
}
|
||||
|
||||
export interface OneOnOneLandscapeLayout {
|
||||
type: "one-on-one-landscape";
|
||||
foreground: "scrolling";
|
||||
spotlight: GridTileViewModel;
|
||||
pip: GridTileViewModel;
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
}
|
||||
|
||||
export interface OneOnOnePortraitLayout {
|
||||
type: "one-on-one-portrait";
|
||||
foreground: "fixed";
|
||||
spotlight: GridTileViewModel;
|
||||
pip?: GridTileViewModel;
|
||||
pipSize$: Behavior<"sm" | "lg">;
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
}
|
||||
|
||||
export interface PipLayout {
|
||||
type: "pip";
|
||||
foreground: "fixed";
|
||||
spotlight: SpotlightTileViewModel;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,10 @@ borders don't support gradients */
|
||||
}
|
||||
}
|
||||
|
||||
.tile.edgeToEdge {
|
||||
--media-view-border-radius: 0;
|
||||
}
|
||||
|
||||
.muteIcon[data-muted="true"] {
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ interface TileProps {
|
||||
targetHeight: number;
|
||||
displayName: string;
|
||||
mxcAvatarUrl: string | undefined;
|
||||
showNameTag: boolean;
|
||||
focusable: boolean;
|
||||
}
|
||||
|
||||
@@ -406,6 +407,7 @@ export const GridTile: FC<GridTileProps> = ({
|
||||
vm,
|
||||
showSpeakingIndicators,
|
||||
onOpenProfile,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -413,26 +415,34 @@ export const GridTile: FC<GridTileProps> = ({
|
||||
const media = useBehavior(vm.media$);
|
||||
const displayName = useBehavior(media.displayName$);
|
||||
const mxcAvatarUrl = useBehavior(media.mxcAvatarUrl$);
|
||||
const showNameTag = useBehavior(vm.showNameTag$);
|
||||
const edgeToEdge = useBehavior(vm.edgeToEdge$);
|
||||
|
||||
const classes = classNames(className, { [styles.edgeToEdge]: edgeToEdge });
|
||||
|
||||
if (media.type === "ringing") {
|
||||
return (
|
||||
<RingingMediaTile
|
||||
ref={ref}
|
||||
className={classes}
|
||||
vm={media}
|
||||
{...props}
|
||||
displayName={displayName}
|
||||
mxcAvatarUrl={mxcAvatarUrl}
|
||||
showNameTag={showNameTag}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
} else if (media.local) {
|
||||
return (
|
||||
<LocalUserMediaTile
|
||||
ref={ref}
|
||||
className={classes}
|
||||
vm={media}
|
||||
showSpeakingIndicators={showSpeakingIndicators}
|
||||
onOpenProfile={onOpenProfile}
|
||||
displayName={displayName}
|
||||
mxcAvatarUrl={mxcAvatarUrl}
|
||||
showNameTag={showNameTag}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -440,10 +450,12 @@ export const GridTile: FC<GridTileProps> = ({
|
||||
return (
|
||||
<RemoteUserMediaTile
|
||||
ref={ref}
|
||||
className={classes}
|
||||
vm={media}
|
||||
showSpeakingIndicators={showSpeakingIndicators}
|
||||
displayName={displayName}
|
||||
mxcAvatarUrl={mxcAvatarUrl}
|
||||
showNameTag={showNameTag}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -44,6 +44,11 @@ interface Props extends ComponentProps<typeof animated.div> {
|
||||
videoEnabled: boolean;
|
||||
unencryptedWarning: boolean;
|
||||
status?: { text: string; Icon: ComponentType<SVGAttributes<SVGElement>> };
|
||||
/**
|
||||
* Whether to show the participant's name tag.
|
||||
* @default true
|
||||
*/
|
||||
showNameTag?: boolean;
|
||||
nameTagLeadingIcon?: ReactNode;
|
||||
displayName: string;
|
||||
mxcAvatarUrl: string | undefined;
|
||||
@@ -72,6 +77,7 @@ export const MediaView: FC<Props> = ({
|
||||
userId,
|
||||
videoEnabled,
|
||||
unencryptedWarning,
|
||||
showNameTag = true,
|
||||
nameTagLeadingIcon,
|
||||
displayName,
|
||||
mxcAvatarUrl,
|
||||
@@ -94,6 +100,23 @@ export const MediaView: FC<Props> = ({
|
||||
|
||||
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 (
|
||||
<animated.div
|
||||
className={classNames(styles.media, className, {
|
||||
@@ -184,34 +207,23 @@ export const MediaView: FC<Props> = ({
|
||||
</Text>
|
||||
</div>
|
||||
)*/}
|
||||
<div className={styles.nameTag}>
|
||||
{nameTagLeadingIcon}
|
||||
<Text
|
||||
as="span"
|
||||
size="sm"
|
||||
weight="medium"
|
||||
className={styles.name}
|
||||
data-testid="name_tag"
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
{unencryptedWarning && (
|
||||
<Tooltip
|
||||
label={t("common.unencrypted")}
|
||||
placement="bottom"
|
||||
isTriggerInteractive={false}
|
||||
nonInteractiveTriggerTabIndex={focusable ? undefined : -1}
|
||||
{showNameTag ? (
|
||||
<div className={styles.nameTag}>
|
||||
{nameTagLeadingIcon}
|
||||
<Text
|
||||
as="span"
|
||||
size="sm"
|
||||
weight="medium"
|
||||
className={styles.name}
|
||||
data-testid="name_tag"
|
||||
>
|
||||
<ErrorSolidIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className={styles.errorIcon}
|
||||
role="img"
|
||||
aria-label={t("common.unencrypted")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{displayName}
|
||||
</Text>
|
||||
{warnings}
|
||||
</div>
|
||||
) : (
|
||||
warnings
|
||||
)}
|
||||
{primaryButton}
|
||||
</div>
|
||||
</animated.div>
|
||||
|
||||
Reference in New Issue
Block a user