Delete the legacy grid system
This commit is contained in:
@@ -162,6 +162,5 @@
|
||||
"mute_for_me": "Mute for me",
|
||||
"sfu_participant_local": "You",
|
||||
"volume": "Volume"
|
||||
},
|
||||
"waiting_for_participants": "Waiting for other participants…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
import { ComponentType } from "react";
|
||||
|
||||
import { MediaViewModel } from "../state/MediaViewModel";
|
||||
import { MediaViewModel, UserMediaViewModel } from "../state/MediaViewModel";
|
||||
import { LayoutProps } from "./Grid";
|
||||
|
||||
export interface Bounds {
|
||||
@@ -53,7 +53,7 @@ export interface CallLayoutInputs {
|
||||
|
||||
export interface GridTileModel {
|
||||
type: "grid";
|
||||
vm: MediaViewModel;
|
||||
vm: UserMediaViewModel;
|
||||
}
|
||||
|
||||
export interface SpotlightTileModel {
|
||||
|
||||
@@ -41,7 +41,6 @@ import styles from "./Grid.module.css";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { TileWrapper } from "./TileWrapper";
|
||||
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
|
||||
import { TileSpringUpdate } from "./LegacyGrid";
|
||||
import { useInitial } from "../useInitial";
|
||||
|
||||
interface Rect {
|
||||
@@ -69,6 +68,13 @@ interface TileSpring {
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface TileSpringUpdate extends Partial<TileSpring> {
|
||||
from?: Partial<TileSpring>;
|
||||
reset?: boolean;
|
||||
immediate?: boolean | ((key: string) => boolean);
|
||||
delay?: (key: string) => number;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
tileId: string;
|
||||
tileX: number;
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.grid {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
touch-action: none;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,10 +18,9 @@ import {
|
||||
RoomAudioRenderer,
|
||||
RoomContext,
|
||||
useLocalParticipant,
|
||||
useTracks,
|
||||
} from "@livekit/components-react";
|
||||
import { usePreventScroll } from "@react-aria/overlays";
|
||||
import { ConnectionState, Room, Track } from "livekit-client";
|
||||
import { ConnectionState, Room } from "livekit-client";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import {
|
||||
FC,
|
||||
@@ -38,7 +37,6 @@ import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import classNames from "classnames";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import LogoMark from "../icons/LogoMark.svg?react";
|
||||
import LogoType from "../icons/LogoType.svg?react";
|
||||
@@ -51,10 +49,8 @@ import {
|
||||
SettingsButton,
|
||||
} from "../button";
|
||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||
import { LegacyGrid, useLegacyGridLayout } from "../grid/LegacyGrid";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
|
||||
import { ElementWidgetActions, widget } from "../widget";
|
||||
import styles from "./InCallView.module.css";
|
||||
import { GridTile } from "../tile/GridTile";
|
||||
@@ -72,14 +68,8 @@ import { InviteButton } from "../button/InviteButton";
|
||||
import { LayoutToggle } from "./LayoutToggle";
|
||||
import { ECConnectionState } from "../livekit/useECConnectionState";
|
||||
import { useOpenIDSFU } from "../livekit/openIDSFU";
|
||||
import {
|
||||
GridMode,
|
||||
Layout,
|
||||
TileDescriptor,
|
||||
useCallViewModel,
|
||||
} from "../state/CallViewModel";
|
||||
import { GridMode, Layout, useCallViewModel } from "../state/CallViewModel";
|
||||
import { Grid, TileProps } from "../grid/Grid";
|
||||
import { MediaViewModel } from "../state/MediaViewModel";
|
||||
import { useObservable } from "../state/useObservable";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { SpotlightTile } from "../tile/SpotlightTile";
|
||||
@@ -89,7 +79,6 @@ import { makeGridLayout } from "../grid/GridLayout";
|
||||
import { makeSpotlightLayout } from "../grid/SpotlightLayout";
|
||||
import {
|
||||
CallLayout,
|
||||
GridTileModel,
|
||||
TileModel,
|
||||
defaultPipAlignment,
|
||||
defaultSpotlightAlignment,
|
||||
@@ -98,10 +87,6 @@ import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
|
||||
const dummySpotlightItem = {
|
||||
id: "spotlight",
|
||||
} as TileDescriptor<MediaViewModel>;
|
||||
|
||||
export interface ActiveCallProps
|
||||
extends Omit<InCallViewProps, "livekitRoom" | "connState"> {
|
||||
e2eeSystem: EncryptionSystem;
|
||||
@@ -155,11 +140,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
participantCount,
|
||||
onLeave,
|
||||
hideHeader,
|
||||
otelGroupCallMembership,
|
||||
connState,
|
||||
onShareClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
usePreventScroll();
|
||||
useWakeLock();
|
||||
|
||||
@@ -177,15 +160,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
// Merge the refs so they can attach to the same element
|
||||
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
||||
|
||||
const screenSharingTracks = useTracks(
|
||||
[{ source: Track.Source.ScreenShare, withPlaceholder: false }],
|
||||
{
|
||||
room: livekitRoom,
|
||||
},
|
||||
);
|
||||
const { layout: legacyLayout, setLayout: setLegacyLayout } =
|
||||
useLegacyGridLayout(screenSharingTracks.length > 0);
|
||||
|
||||
const { hideScreensharing, showControls } = useUrlParams();
|
||||
|
||||
const { isScreenShareEnabled, localParticipant } = useLocalParticipant({
|
||||
@@ -210,42 +184,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
(muted) => muteStates.audio.setEnabled?.(!muted),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
widget?.api.transport.send(
|
||||
legacyLayout === "grid"
|
||||
? ElementWidgetActions.TileLayout
|
||||
: ElementWidgetActions.SpotlightLayout,
|
||||
{},
|
||||
);
|
||||
}, [legacyLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (widget) {
|
||||
const onTileLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
setLegacyLayout("grid");
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
const onSpotlightLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
setLegacyLayout("spotlight");
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
|
||||
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
|
||||
widget.lazyActions.on(
|
||||
ElementWidgetActions.SpotlightLayout,
|
||||
onSpotlightLayout,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout);
|
||||
widget!.lazyActions.off(
|
||||
ElementWidgetActions.SpotlightLayout,
|
||||
onSpotlightLayout,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [setLegacyLayout]);
|
||||
|
||||
const mobile = boundsValid && bounds.width <= 660;
|
||||
const reducedControls = boundsValid && bounds.width <= 340;
|
||||
const noControls = reducedControls && bounds.height <= 400;
|
||||
@@ -256,15 +194,12 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
|
||||
connState,
|
||||
);
|
||||
const items = useObservableEagerState(vm.tiles);
|
||||
const layout = useObservableEagerState(vm.layout);
|
||||
const gridMode = useObservableEagerState(vm.gridMode);
|
||||
const hasSpotlight = layout.spotlight !== undefined;
|
||||
// Hack: We insert a dummy "spotlight" tile into the tiles we pass to
|
||||
// useFullscreen so that we can control the fullscreen state of the
|
||||
// spotlight tile in the new layouts with this same hook.
|
||||
const fullscreenItems = useMemo(
|
||||
() => (hasSpotlight ? [...items, dummySpotlightItem] : items),
|
||||
[items, hasSpotlight],
|
||||
() => (hasSpotlight ? ["spotlight"] : []),
|
||||
[hasSpotlight],
|
||||
);
|
||||
const { fullscreenItem, toggleFullscreen, exitFullscreen } =
|
||||
useFullscreen(fullscreenItems);
|
||||
@@ -274,18 +209,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
);
|
||||
|
||||
// The maximised participant: either the participant that the user has
|
||||
// manually put in fullscreen, or the focused (active) participant if the
|
||||
// window is too small to show everyone
|
||||
const maximisedParticipant = useMemo(
|
||||
() =>
|
||||
fullscreenItem ??
|
||||
(noControls
|
||||
? items.find((item) => item.isSpeaker) ?? items.at(0) ?? null
|
||||
: null),
|
||||
[fullscreenItem, noControls, items],
|
||||
);
|
||||
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
// manually put in fullscreen, or (TODO) the spotlight if the window is too
|
||||
// small to show everyone
|
||||
const maximisedParticipant = fullscreenItem;
|
||||
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
||||
@@ -339,7 +265,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
makeLayout = makeSpotlightLayout as CallLayout<Layout>;
|
||||
else if (l.type === "one-on-one")
|
||||
makeLayout = makeOneOnOneLayout as CallLayout<Layout>;
|
||||
else return null; // Not yet implemented
|
||||
else throw new Error(`Unimplemented layout: ${l.type}`);
|
||||
|
||||
return makeLayout({
|
||||
minBounds: gridBoundsObservable,
|
||||
@@ -352,13 +278,46 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
);
|
||||
|
||||
const setGridMode = useCallback(
|
||||
(mode: GridMode) => {
|
||||
setLegacyLayout(mode);
|
||||
vm.setGridMode(mode);
|
||||
},
|
||||
[setLegacyLayout, vm],
|
||||
(mode: GridMode) => vm.setGridMode(mode),
|
||||
[vm],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
widget?.api.transport.send(
|
||||
gridMode === "grid"
|
||||
? ElementWidgetActions.TileLayout
|
||||
: ElementWidgetActions.SpotlightLayout,
|
||||
{},
|
||||
);
|
||||
}, [gridMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (widget) {
|
||||
const onTileLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
setGridMode("grid");
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
const onSpotlightLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
setGridMode("spotlight");
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
|
||||
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
|
||||
widget.lazyActions.on(
|
||||
ElementWidgetActions.SpotlightLayout,
|
||||
onSpotlightLayout,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout);
|
||||
widget!.lazyActions.off(
|
||||
ElementWidgetActions.SpotlightLayout,
|
||||
onSpotlightLayout,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [setGridMode]);
|
||||
|
||||
const showSpotlightIndicators = useObservable(layout.type === "spotlight");
|
||||
const showSpeakingIndicators = useObservable(
|
||||
layout.type === "spotlight" ||
|
||||
@@ -419,33 +378,10 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
],
|
||||
);
|
||||
|
||||
const LegacyTile = useMemo(
|
||||
() =>
|
||||
forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithoutRef<TileProps<MediaViewModel, HTMLDivElement>>
|
||||
>(function LegacyTile({ model: legacyModel, ...props }, ref) {
|
||||
const model: GridTileModel = useMemo(
|
||||
() => ({ type: "grid", vm: legacyModel }),
|
||||
[legacyModel],
|
||||
);
|
||||
return <Tile ref={ref} model={model} {...props} />;
|
||||
}),
|
||||
[Tile],
|
||||
);
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className={styles.centerMessage}>
|
||||
<p>{t("waiting_for_participants")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (maximisedParticipant !== null) {
|
||||
const fullscreen = maximisedParticipant === fullscreenItem;
|
||||
if (maximisedParticipant.id === "spotlight") {
|
||||
if (maximisedParticipant === "spotlight") {
|
||||
return (
|
||||
<SpotlightTile
|
||||
className={classNames(styles.tile, styles.maximised)}
|
||||
@@ -459,55 +395,28 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<GridTile
|
||||
className={classNames(styles.tile, styles.maximised)}
|
||||
vm={maximisedParticipant.data}
|
||||
maximised={true}
|
||||
fullscreen={fullscreen}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
targetHeight={gridBounds.height}
|
||||
targetWidth={gridBounds.width}
|
||||
key={maximisedParticipant.id}
|
||||
showSpeakingIndicators={false}
|
||||
onOpenProfile={openProfile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (layoutSystem === null) {
|
||||
// This new layout doesn't yet have an implemented layout system, so fall
|
||||
// back to the legacy grid system
|
||||
return (
|
||||
<LegacyGrid
|
||||
items={items}
|
||||
layout={legacyLayout}
|
||||
disableAnimations={prefersReducedMotion}
|
||||
Tile={LegacyTile}
|
||||
return (
|
||||
<>
|
||||
<Grid
|
||||
className={styles.scrollingGrid}
|
||||
model={layout}
|
||||
Layout={layoutSystem.scrolling}
|
||||
Tile={Tile}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<Grid
|
||||
className={styles.scrollingGrid}
|
||||
model={layout}
|
||||
Layout={layoutSystem.scrolling}
|
||||
Tile={Tile}
|
||||
/>
|
||||
<Grid
|
||||
className={styles.fixedGrid}
|
||||
style={{
|
||||
insetBlockStart: headerBounds.bottom,
|
||||
height: gridBounds.height,
|
||||
}}
|
||||
model={layout}
|
||||
Layout={layoutSystem.fixed}
|
||||
Tile={Tile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
<Grid
|
||||
className={styles.fixedGrid}
|
||||
style={{
|
||||
insetBlockStart: headerBounds.bottom,
|
||||
height: gridBounds.height,
|
||||
}}
|
||||
model={layout}
|
||||
Layout={layoutSystem.fixed}
|
||||
Tile={Tile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
||||
@@ -596,7 +505,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
{!mobile && !hideHeader && showControls && (
|
||||
<LayoutToggle
|
||||
className={styles.layout}
|
||||
layout={legacyLayout}
|
||||
layout={gridMode}
|
||||
setLayout={setGridMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -20,7 +20,6 @@ import { useCallback, useLayoutEffect, useRef } from "react";
|
||||
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { useEventTarget } from "../useEvents";
|
||||
import { TileDescriptor } from "../state/CallViewModel";
|
||||
|
||||
const isFullscreen = (): boolean =>
|
||||
Boolean(document.fullscreenElement) ||
|
||||
@@ -55,31 +54,30 @@ function useFullscreenChange(onFullscreenChange: () => void): void {
|
||||
* Provides callbacks for controlling the full-screen view, which can hold one
|
||||
* item at a time.
|
||||
*/
|
||||
export function useFullscreen<T>(items: TileDescriptor<T>[]): {
|
||||
fullscreenItem: TileDescriptor<T> | null;
|
||||
// TODO: Simplify this. Nowadays we only allow the spotlight to be fullscreen,
|
||||
// so we don't need to bother with multiple items.
|
||||
export function useFullscreen(items: string[]): {
|
||||
fullscreenItem: string | null;
|
||||
toggleFullscreen: (itemId: string) => void;
|
||||
exitFullscreen: () => void;
|
||||
} {
|
||||
const [fullscreenItem, setFullscreenItem] =
|
||||
useReactiveState<TileDescriptor<T> | null>(
|
||||
(prevItem) =>
|
||||
prevItem == null
|
||||
? null
|
||||
: items.find((i) => i.id === prevItem.id) ?? null,
|
||||
[items],
|
||||
);
|
||||
const [fullscreenItem, setFullscreenItem] = useReactiveState<string | null>(
|
||||
(prevItem) =>
|
||||
prevItem == null ? null : items.find((i) => i === prevItem) ?? null,
|
||||
[items],
|
||||
);
|
||||
|
||||
const latestItems = useRef<TileDescriptor<T>[]>(items);
|
||||
const latestItems = useRef<string[]>(items);
|
||||
latestItems.current = items;
|
||||
|
||||
const latestFullscreenItem = useRef<TileDescriptor<T> | null>(fullscreenItem);
|
||||
const latestFullscreenItem = useRef<string | null>(fullscreenItem);
|
||||
latestFullscreenItem.current = fullscreenItem;
|
||||
|
||||
const toggleFullscreen = useCallback(
|
||||
(itemId: string) => {
|
||||
setFullscreenItem(
|
||||
latestFullscreenItem.current === null
|
||||
? latestItems.current.find((i) => i.id === itemId) ?? null
|
||||
? latestItems.current.find((i) => i === itemId) ?? null
|
||||
: null,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -74,22 +74,6 @@ import { ObservableScope } from "./ObservableScope";
|
||||
// list again
|
||||
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
|
||||
|
||||
// Represents something that should get a tile on the layout,
|
||||
// ie. a user's video feed or a screen share feed.
|
||||
// TODO: This exposes too much information to the view layer, let's keep this
|
||||
// information internal to the view model and switch to using Tile<T> instead
|
||||
export interface TileDescriptor<T> {
|
||||
id: string;
|
||||
focused: boolean;
|
||||
isPresenter: boolean;
|
||||
isSpeaker: boolean;
|
||||
hasVideo: boolean;
|
||||
local: boolean;
|
||||
largeBaseSize: boolean;
|
||||
placeNear?: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface GridLayout {
|
||||
type: "grid";
|
||||
spotlight?: MediaViewModel[];
|
||||
@@ -548,108 +532,6 @@ export class CallViewModel extends ViewModel {
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
/**
|
||||
* The media tiles to be displayed in the call view.
|
||||
*/
|
||||
// TODO: Get rid of this field, replacing it with the 'layout' field above
|
||||
// which keeps more details of the layout order internal to the view model
|
||||
public readonly tiles: Observable<TileDescriptor<MediaViewModel>[]> =
|
||||
combineLatest([
|
||||
this.remoteParticipants,
|
||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||
]).pipe(
|
||||
scan((ts, [remoteParticipants, { participant: localParticipant }]) => {
|
||||
const ps = [localParticipant, ...remoteParticipants];
|
||||
const tilesById = new Map(ts.map((t) => [t.id, t]));
|
||||
const now = Date.now();
|
||||
let allGhosts = true;
|
||||
|
||||
const newTiles = ps.flatMap((p) => {
|
||||
const userMediaId = p.identity;
|
||||
const member = findMatrixMember(this.matrixRoom, userMediaId);
|
||||
allGhosts &&= member === undefined;
|
||||
const spokeRecently =
|
||||
p.lastSpokeAt !== undefined && now - +p.lastSpokeAt <= 10000;
|
||||
|
||||
// We always start with a local participant with the empty string as
|
||||
// their ID before we're connected, this is fine and we'll be in
|
||||
// "all ghosts" mode.
|
||||
if (userMediaId !== "" && member === undefined) {
|
||||
logger.warn(
|
||||
`Ruh, roh! No matrix member found for SFU participant '${userMediaId}': creating g-g-g-ghost!`,
|
||||
);
|
||||
}
|
||||
|
||||
const userMediaVm =
|
||||
tilesById.get(userMediaId)?.data ??
|
||||
(p instanceof LocalParticipant
|
||||
? new LocalUserMediaViewModel(
|
||||
userMediaId,
|
||||
member,
|
||||
p,
|
||||
this.encrypted,
|
||||
)
|
||||
: new RemoteUserMediaViewModel(
|
||||
userMediaId,
|
||||
member,
|
||||
p,
|
||||
this.encrypted,
|
||||
));
|
||||
tilesById.delete(userMediaId);
|
||||
|
||||
const userMediaTile: TileDescriptor<MediaViewModel> = {
|
||||
id: userMediaId,
|
||||
focused: false,
|
||||
isPresenter: p.isScreenShareEnabled,
|
||||
isSpeaker: (p.isSpeaking || spokeRecently) && !p.isLocal,
|
||||
hasVideo: p.isCameraEnabled,
|
||||
local: p.isLocal,
|
||||
largeBaseSize: false,
|
||||
data: userMediaVm,
|
||||
};
|
||||
|
||||
if (p.isScreenShareEnabled) {
|
||||
const screenShareId = `${userMediaId}:screen-share`;
|
||||
const screenShareVm =
|
||||
tilesById.get(screenShareId)?.data ??
|
||||
new ScreenShareViewModel(
|
||||
screenShareId,
|
||||
member,
|
||||
p,
|
||||
this.encrypted,
|
||||
);
|
||||
tilesById.delete(screenShareId);
|
||||
|
||||
const screenShareTile: TileDescriptor<MediaViewModel> = {
|
||||
id: screenShareId,
|
||||
focused: true,
|
||||
isPresenter: false,
|
||||
isSpeaker: false,
|
||||
hasVideo: true,
|
||||
local: p.isLocal,
|
||||
largeBaseSize: true,
|
||||
placeNear: userMediaId,
|
||||
data: screenShareVm,
|
||||
};
|
||||
return [userMediaTile, screenShareTile];
|
||||
} else {
|
||||
return [userMediaTile];
|
||||
}
|
||||
});
|
||||
|
||||
// Any tiles left in the map are unused and should be destroyed
|
||||
for (const t of tilesById.values()) t.data.destroy();
|
||||
|
||||
// If every item is a ghost, that probably means we're still connecting
|
||||
// and shouldn't bother showing anything yet
|
||||
return allGhosts ? [] : newTiles;
|
||||
}, [] as TileDescriptor<MediaViewModel>[]),
|
||||
finalizeValue((ts) => {
|
||||
for (const t of ts) t.data.destroy();
|
||||
}),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
// A call is permanently tied to a single Matrix room and LiveKit room
|
||||
private readonly matrixRoom: MatrixRoom,
|
||||
|
||||
@@ -33,7 +33,6 @@ import VolumeOffIcon from "@vector-im/compound-design-tokens/icons/volume-off.sv
|
||||
import VisibilityOnIcon from "@vector-im/compound-design-tokens/icons/visibility-on.svg?react";
|
||||
import UserProfileIcon from "@vector-im/compound-design-tokens/icons/user-profile.svg?react";
|
||||
import ExpandIcon from "@vector-im/compound-design-tokens/icons/expand.svg?react";
|
||||
import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?react";
|
||||
import {
|
||||
ContextMenu,
|
||||
MenuItem,
|
||||
@@ -44,8 +43,6 @@ import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import styles from "./GridTile.module.css";
|
||||
import {
|
||||
ScreenShareViewModel,
|
||||
MediaViewModel,
|
||||
UserMediaViewModel,
|
||||
useNameData,
|
||||
LocalUserMediaViewModel,
|
||||
@@ -63,45 +60,12 @@ interface TileProps {
|
||||
maximised: boolean;
|
||||
displayName: string;
|
||||
nameTag: string;
|
||||
showSpeakingIndicators: boolean;
|
||||
}
|
||||
|
||||
interface MediaTileProps
|
||||
extends TileProps,
|
||||
Omit<ComponentProps<typeof animated.div>, "className"> {
|
||||
vm: MediaViewModel;
|
||||
videoEnabled: boolean;
|
||||
videoFit: "contain" | "cover";
|
||||
mirror: boolean;
|
||||
nameTagLeadingIcon?: ReactNode;
|
||||
primaryButton: ReactNode;
|
||||
secondaryButton?: ReactNode;
|
||||
}
|
||||
|
||||
const MediaTile = forwardRef<HTMLDivElement, MediaTileProps>(
|
||||
({ vm, className, maximised, ...props }, ref) => {
|
||||
const video = useObservableEagerState(vm.video);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
||||
|
||||
return (
|
||||
<MediaView
|
||||
ref={ref}
|
||||
className={classNames(className, styles.tile)}
|
||||
data-maximised={maximised}
|
||||
video={video}
|
||||
member={vm.member}
|
||||
unencryptedWarning={unencryptedWarning}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MediaTile.displayName = "MediaTile";
|
||||
|
||||
interface UserMediaTileProps extends TileProps {
|
||||
vm: UserMediaViewModel;
|
||||
mirror: boolean;
|
||||
showSpeakingIndicators: boolean;
|
||||
menuStart?: ReactNode;
|
||||
menuEnd?: ReactNode;
|
||||
}
|
||||
@@ -115,11 +79,14 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
menuEnd,
|
||||
className,
|
||||
nameTag,
|
||||
maximised,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const video = useObservableEagerState(vm.video);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
||||
const audioEnabled = useObservableEagerState(vm.audioEnabled);
|
||||
const videoEnabled = useObservableEagerState(vm.videoEnabled);
|
||||
const speaking = useObservableEagerState(vm.speaking);
|
||||
@@ -148,12 +115,14 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
);
|
||||
|
||||
const tile = (
|
||||
<MediaTile
|
||||
<MediaView
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
video={video}
|
||||
member={vm.member}
|
||||
unencryptedWarning={unencryptedWarning}
|
||||
videoEnabled={videoEnabled}
|
||||
videoFit={cropVideo ? "cover" : "contain"}
|
||||
className={classNames(className, {
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.speaking]: showSpeakingIndicators && speaking,
|
||||
})}
|
||||
nameTagLeadingIcon={
|
||||
@@ -182,6 +151,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
{menu}
|
||||
</Menu>
|
||||
}
|
||||
data-maximised={maximised}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -199,7 +169,6 @@ UserMediaTile.displayName = "UserMediaTile";
|
||||
interface LocalUserMediaTileProps extends TileProps {
|
||||
vm: LocalUserMediaViewModel;
|
||||
onOpenProfile: () => void;
|
||||
showSpeakingIndicators: boolean;
|
||||
}
|
||||
|
||||
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
|
||||
@@ -248,7 +217,6 @@ LocalUserMediaTile.displayName = "LocalUserMediaTile";
|
||||
|
||||
interface RemoteUserMediaTileProps extends TileProps {
|
||||
vm: RemoteUserMediaViewModel;
|
||||
showSpeakingIndicators: boolean;
|
||||
}
|
||||
|
||||
const RemoteUserMediaTile = forwardRef<
|
||||
@@ -303,53 +271,8 @@ const RemoteUserMediaTile = forwardRef<
|
||||
|
||||
RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
|
||||
|
||||
interface ScreenShareTileProps extends TileProps {
|
||||
vm: ScreenShareViewModel;
|
||||
fullscreen: boolean;
|
||||
onToggleFullscreen: (itemId: string) => void;
|
||||
}
|
||||
|
||||
const ScreenShareTile = forwardRef<HTMLDivElement, ScreenShareTileProps>(
|
||||
({ vm, fullscreen, onToggleFullscreen, ...props }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const onClickFullScreen = useCallback(
|
||||
() => onToggleFullscreen(vm.id),
|
||||
[onToggleFullscreen, vm],
|
||||
);
|
||||
|
||||
const FullScreenIcon = fullscreen ? CollapseIcon : ExpandIcon;
|
||||
|
||||
return (
|
||||
<MediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
videoEnabled
|
||||
videoFit="contain"
|
||||
mirror={false}
|
||||
primaryButton={
|
||||
!vm.local && (
|
||||
<button
|
||||
aria-label={
|
||||
fullscreen
|
||||
? t("video_tile.full_screen")
|
||||
: t("video_tile.exit_full_screen")
|
||||
}
|
||||
onClick={onClickFullScreen}
|
||||
>
|
||||
<FullScreenIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ScreenShareTile.displayName = "ScreenShareTile";
|
||||
|
||||
interface GridTileProps {
|
||||
vm: MediaViewModel;
|
||||
vm: UserMediaViewModel;
|
||||
maximised: boolean;
|
||||
fullscreen: boolean;
|
||||
onToggleFullscreen: (itemId: string) => void;
|
||||
@@ -375,19 +298,8 @@ export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
|
||||
{...nameData}
|
||||
/>
|
||||
);
|
||||
} else if (vm instanceof RemoteUserMediaViewModel) {
|
||||
return <RemoteUserMediaTile ref={ref} vm={vm} {...props} {...nameData} />;
|
||||
} else {
|
||||
return (
|
||||
<ScreenShareTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
fullscreen={fullscreen}
|
||||
onToggleFullscreen={onToggleFullscreen}
|
||||
{...props}
|
||||
{...nameData}
|
||||
/>
|
||||
);
|
||||
return <RemoteUserMediaTile ref={ref} vm={vm} {...props} {...nameData} />;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/*
|
||||
Copyright 2023-2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { TileDescriptor } from "../../src/state/CallViewModel";
|
||||
import { Tile, reorderTiles } from "../../src/grid/LegacyGrid";
|
||||
|
||||
const alice: Tile<unknown> = {
|
||||
key: "alice",
|
||||
order: 0,
|
||||
item: { local: false } as unknown as TileDescriptor<unknown>,
|
||||
remove: false,
|
||||
focused: false,
|
||||
isPresenter: false,
|
||||
isSpeaker: false,
|
||||
hasVideo: true,
|
||||
};
|
||||
const bob: Tile<unknown> = {
|
||||
key: "bob",
|
||||
order: 1,
|
||||
item: { local: false } as unknown as TileDescriptor<unknown>,
|
||||
remove: false,
|
||||
focused: false,
|
||||
isPresenter: false,
|
||||
isSpeaker: false,
|
||||
hasVideo: false,
|
||||
};
|
||||
|
||||
test("reorderTiles does not promote a non-speaker", () => {
|
||||
const tiles = [{ ...alice }, { ...bob }];
|
||||
reorderTiles(tiles, "spotlight", 1);
|
||||
expect(tiles).toEqual([
|
||||
expect.objectContaining({ key: "alice", order: 0 }),
|
||||
expect.objectContaining({ key: "bob", order: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("reorderTiles promotes a speaker into the visible area", () => {
|
||||
const tiles = [{ ...alice }, { ...bob, isSpeaker: true }];
|
||||
reorderTiles(tiles, "spotlight", 1);
|
||||
expect(tiles).toEqual([
|
||||
expect.objectContaining({ key: "alice", order: 1 }),
|
||||
expect.objectContaining({ key: "bob", order: 0 }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("reorderTiles keeps a promoted speaker in the visible area", () => {
|
||||
const tiles = [
|
||||
{ ...alice, order: 1 },
|
||||
{ ...bob, isSpeaker: true, order: 0 },
|
||||
];
|
||||
reorderTiles(tiles, "spotlight", 1);
|
||||
expect(tiles).toEqual([
|
||||
expect.objectContaining({ key: "alice", order: 1 }),
|
||||
expect.objectContaining({ key: "bob", order: 0 }),
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user