Merge pull request #2485 from element-hq/new-call-layouts
New call layouts
This commit is contained in:
@@ -38,15 +38,6 @@ module.exports = {
|
|||||||
"jsx-a11y/media-has-caption": "off",
|
"jsx-a11y/media-has-caption": "off",
|
||||||
// We should use the js-sdk logger, never console directly.
|
// We should use the js-sdk logger, never console directly.
|
||||||
"no-console": ["error"],
|
"no-console": ["error"],
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
name: "@react-rxjs/core",
|
|
||||||
importNames: ["Subscribe", "RemoveSubscribe"],
|
|
||||||
message:
|
|
||||||
"These components are easy to misuse, please use the 'subscribe' component wrapper instead",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"react/display-name": "error",
|
"react/display-name": "error",
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
"@react-aria/tabs": "^3.1.0",
|
"@react-aria/tabs": "^3.1.0",
|
||||||
"@react-aria/tooltip": "^3.1.3",
|
"@react-aria/tooltip": "^3.1.3",
|
||||||
"@react-aria/utils": "^3.10.0",
|
"@react-aria/utils": "^3.10.0",
|
||||||
"@react-rxjs/core": "^0.10.7",
|
|
||||||
"@react-spring/web": "^9.4.4",
|
"@react-spring/web": "^9.4.4",
|
||||||
"@react-stately/collections": "^3.3.4",
|
"@react-stately/collections": "^3.3.4",
|
||||||
"@react-stately/select": "^3.1.3",
|
"@react-stately/select": "^3.1.3",
|
||||||
@@ -66,6 +65,7 @@
|
|||||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#238eea0ef5c82d0a11b8d5cc5c04104d6c94c4c1",
|
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#238eea0ef5c82d0a11b8d5cc5c04104d6c94c4c1",
|
||||||
"matrix-widget-api": "^1.3.1",
|
"matrix-widget-api": "^1.3.1",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
|
"observable-hooks": "^4.2.3",
|
||||||
"pako": "^2.0.4",
|
"pako": "^2.0.4",
|
||||||
"postcss-preset-env": "^9.0.0",
|
"postcss-preset-env": "^9.0.0",
|
||||||
"posthog-js": "^1.29.0",
|
"posthog-js": "^1.29.0",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
|
"back": "Back",
|
||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
"display_name": "Display name",
|
"display_name": "Display name",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"home": "Home",
|
"home": "Home",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"microphone": "Microphone",
|
"microphone": "Microphone",
|
||||||
|
"next": "Next",
|
||||||
"options": "Options",
|
"options": "Options",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
@@ -130,6 +132,7 @@
|
|||||||
"developer_settings_label": "Developer Settings",
|
"developer_settings_label": "Developer Settings",
|
||||||
"developer_settings_label_description": "Expose developer settings in the settings window.",
|
"developer_settings_label_description": "Expose developer settings in the settings window.",
|
||||||
"developer_tab_title": "Developer",
|
"developer_tab_title": "Developer",
|
||||||
|
"duplicate_tiles_label": "Number of additional tile copies per participant",
|
||||||
"feedback_tab_body": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.",
|
"feedback_tab_body": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.",
|
||||||
"feedback_tab_description_label": "Your feedback",
|
"feedback_tab_description_label": "Your feedback",
|
||||||
"feedback_tab_h4": "Submit feedback",
|
"feedback_tab_h4": "Submit feedback",
|
||||||
@@ -138,7 +141,6 @@
|
|||||||
"feedback_tab_title": "Feedback",
|
"feedback_tab_title": "Feedback",
|
||||||
"more_tab_title": "More",
|
"more_tab_title": "More",
|
||||||
"opt_in_description": "<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.",
|
"opt_in_description": "<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.",
|
||||||
"show_connection_stats_label": "Show connection stats",
|
|
||||||
"speaker_device_selection_label": "Speaker"
|
"speaker_device_selection_label": "Speaker"
|
||||||
},
|
},
|
||||||
"star_rating_input_label_one": "{{count}} stars",
|
"star_rating_input_label_one": "{{count}} stars",
|
||||||
@@ -154,12 +156,12 @@
|
|||||||
"unmute_microphone_button_label": "Unmute microphone",
|
"unmute_microphone_button_label": "Unmute microphone",
|
||||||
"version": "Version: {{version}}",
|
"version": "Version: {{version}}",
|
||||||
"video_tile": {
|
"video_tile": {
|
||||||
|
"always_show": "Always show",
|
||||||
"change_fit_contain": "Fit to frame",
|
"change_fit_contain": "Fit to frame",
|
||||||
"exit_full_screen": "Exit full screen",
|
"exit_full_screen": "Exit full screen",
|
||||||
"full_screen": "Full screen",
|
"full_screen": "Full screen",
|
||||||
"mute_for_me": "Mute for me",
|
"mute_for_me": "Mute for me",
|
||||||
"sfu_participant_local": "You",
|
"sfu_participant_local": "You",
|
||||||
"volume": "Volume"
|
"volume": "Volume"
|
||||||
},
|
}
|
||||||
"waiting_for_participants": "Waiting for other participants…"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2022 New Vector Ltd
|
Copyright 2022-2024 New Vector Ltd
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2022 New Vector Ltd
|
Copyright 2022-2024 New Vector Ltd
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -15,7 +15,7 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { FC, HTMLAttributes, ReactNode } from "react";
|
import { FC, HTMLAttributes, ReactNode, forwardRef } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Heading, Text } from "@vector-im/compound-web";
|
import { Heading, Text } from "@vector-im/compound-web";
|
||||||
@@ -32,13 +32,21 @@ interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Header: FC<HeaderProps> = ({ children, className, ...rest }) => {
|
export const Header = forwardRef<HTMLElement, HeaderProps>(
|
||||||
return (
|
({ children, className, ...rest }, ref) => {
|
||||||
<header className={classNames(styles.header, className)} {...rest}>
|
return (
|
||||||
{children}
|
<header
|
||||||
</header>
|
ref={ref}
|
||||||
);
|
className={classNames(styles.header, className)}
|
||||||
};
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Header.displayName = "Header";
|
||||||
|
|
||||||
interface LeftNavProps extends HTMLAttributes<HTMLElement> {
|
interface LeftNavProps extends HTMLAttributes<HTMLElement> {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|||||||
@@ -36,3 +36,8 @@ if (/android/i.test(navigator.userAgent)) {
|
|||||||
} else {
|
} else {
|
||||||
platform = "desktop";
|
platform = "desktop";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isFirefox = (): boolean => {
|
||||||
|
const { userAgent } = navigator;
|
||||||
|
return userAgent.includes("Firefox");
|
||||||
|
};
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { MatrixClient } from "matrix-js-sdk";
|
|||||||
import { Buffer } from "buffer";
|
import { Buffer } from "buffer";
|
||||||
|
|
||||||
import { widget } from "../widget";
|
import { widget } from "../widget";
|
||||||
import { getSetting, setSetting, getSettingKey } from "../settings/useSetting";
|
|
||||||
import {
|
import {
|
||||||
CallEndedTracker,
|
CallEndedTracker,
|
||||||
CallStartedTracker,
|
CallStartedTracker,
|
||||||
@@ -35,7 +34,7 @@ import {
|
|||||||
} from "./PosthogEvents";
|
} from "./PosthogEvents";
|
||||||
import { Config } from "../config/Config";
|
import { Config } from "../config/Config";
|
||||||
import { getUrlParams } from "../UrlParams";
|
import { getUrlParams } from "../UrlParams";
|
||||||
import { localStorageBus } from "../useLocalStorage";
|
import { optInAnalytics } from "../settings/settings";
|
||||||
|
|
||||||
/* Posthog analytics tracking.
|
/* Posthog analytics tracking.
|
||||||
*
|
*
|
||||||
@@ -131,7 +130,7 @@ export class PosthogAnalytics {
|
|||||||
const { analyticsID } = getUrlParams();
|
const { analyticsID } = getUrlParams();
|
||||||
// if the embedding platform (element web) already got approval to communicating with posthog
|
// if the embedding platform (element web) already got approval to communicating with posthog
|
||||||
// element call can also send events to posthog
|
// element call can also send events to posthog
|
||||||
setSetting("opt-in-analytics", Boolean(analyticsID));
|
optInAnalytics.setValue(Boolean(analyticsID));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.posthog.init(posthogConfig.project_api_key, {
|
this.posthog.init(posthogConfig.project_api_key, {
|
||||||
@@ -151,9 +150,7 @@ export class PosthogAnalytics {
|
|||||||
);
|
);
|
||||||
this.enabled = false;
|
this.enabled = false;
|
||||||
}
|
}
|
||||||
this.startListeningToSettingsChanges();
|
this.startListeningToSettingsChanges(); // Triggers maybeIdentifyUser
|
||||||
const optInAnalytics = getSetting("opt-in-analytics", false);
|
|
||||||
this.updateAnonymityAndIdentifyUser(optInAnalytics);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sanitizeProperties = (
|
private sanitizeProperties = (
|
||||||
@@ -336,8 +333,7 @@ export class PosthogAnalytics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onLoginStatusChanged(): void {
|
public onLoginStatusChanged(): void {
|
||||||
const optInAnalytics = getSetting("opt-in-analytics", false);
|
this.maybeIdentifyUser();
|
||||||
this.updateAnonymityAndIdentifyUser(optInAnalytics);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateSuperProperties(): void {
|
private updateSuperProperties(): void {
|
||||||
@@ -360,20 +356,12 @@ export class PosthogAnalytics {
|
|||||||
return this.eventSignup.getSignupEndTime() > new Date(0);
|
return this.eventSignup.getSignupEndTime() > new Date(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateAnonymityAndIdentifyUser(
|
private async maybeIdentifyUser(): Promise<void> {
|
||||||
pseudonymousOptIn: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
// Update this.anonymity based on the user's analytics opt-in settings
|
|
||||||
const anonymity = pseudonymousOptIn
|
|
||||||
? Anonymity.Pseudonymous
|
|
||||||
: Anonymity.Disabled;
|
|
||||||
this.setAnonymity(anonymity);
|
|
||||||
|
|
||||||
// We may not yet have a Matrix client at this point, if not, bail. This should get
|
// We may not yet have a Matrix client at this point, if not, bail. This should get
|
||||||
// triggered again by onLoginStatusChanged once we do have a client.
|
// triggered again by onLoginStatusChanged once we do have a client.
|
||||||
if (!window.matrixclient) return;
|
if (!window.matrixclient) return;
|
||||||
|
|
||||||
if (anonymity === Anonymity.Pseudonymous) {
|
if (this.anonymity === Anonymity.Pseudonymous) {
|
||||||
this.setRegistrationType(
|
this.setRegistrationType(
|
||||||
window.matrixclient.isGuest() || window.passwordlessUser
|
window.matrixclient.isGuest() || window.passwordlessUser
|
||||||
? RegistrationType.Guest
|
? RegistrationType.Guest
|
||||||
@@ -389,7 +377,7 @@ export class PosthogAnalytics {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (anonymity !== Anonymity.Disabled) {
|
if (this.anonymity !== Anonymity.Disabled) {
|
||||||
this.updateSuperProperties();
|
this.updateSuperProperties();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -419,8 +407,9 @@ export class PosthogAnalytics {
|
|||||||
// * When the user changes their preferences on this device
|
// * When the user changes their preferences on this device
|
||||||
// Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings
|
// Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings
|
||||||
// won't be called (i.e. this.anonymity will be left as the default, until the setting changes)
|
// won't be called (i.e. this.anonymity will be left as the default, until the setting changes)
|
||||||
localStorageBus.on(getSettingKey("opt-in-analytics"), (optInAnalytics) => {
|
optInAnalytics.value.subscribe((optIn) => {
|
||||||
this.updateAnonymityAndIdentifyUser(optInAnalytics);
|
this.setAnonymity(optIn ? Anonymity.Pseudonymous : Anonymity.Disabled);
|
||||||
|
this.maybeIdentifyUser();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
159
src/grid/CallLayout.ts
Normal file
159
src/grid/CallLayout.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BehaviorSubject, Observable } from "rxjs";
|
||||||
|
import { ComponentType } from "react";
|
||||||
|
|
||||||
|
import { MediaViewModel, UserMediaViewModel } from "../state/MediaViewModel";
|
||||||
|
import { LayoutProps } from "./Grid";
|
||||||
|
|
||||||
|
export interface Bounds {
|
||||||
|
width: 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 {
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
pipAlignment: BehaviorSubject<Alignment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridTileModel {
|
||||||
|
type: "grid";
|
||||||
|
vm: UserMediaViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotlightTileModel {
|
||||||
|
type: "spotlight";
|
||||||
|
vms: MediaViewModel[];
|
||||||
|
maximised: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TileModel = GridTileModel | SpotlightTileModel;
|
||||||
|
|
||||||
|
export interface CallLayoutOutputs<Model> {
|
||||||
|
/**
|
||||||
|
* Whether the scrolling layer of the layout should appear on top.
|
||||||
|
*/
|
||||||
|
scrollingOnTop: boolean;
|
||||||
|
/**
|
||||||
|
* The visually fixed (non-scrolling) layer of the layout.
|
||||||
|
*/
|
||||||
|
fixed: ComponentType<LayoutProps<Model, TileModel, HTMLDivElement>>;
|
||||||
|
/**
|
||||||
|
* The layer of the layout that can overflow and be scrolled.
|
||||||
|
*/
|
||||||
|
scrolling: ComponentType<LayoutProps<Model, TileModel, HTMLDivElement>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A layout system for media tiles.
|
||||||
|
*/
|
||||||
|
export type CallLayout<Model> = (
|
||||||
|
inputs: CallLayoutInputs,
|
||||||
|
) => CallLayoutOutputs<Model>;
|
||||||
|
|
||||||
|
export interface GridArrangement {
|
||||||
|
tileWidth: number;
|
||||||
|
tileHeight: number;
|
||||||
|
gap: number;
|
||||||
|
columns: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tileMinHeight = 130;
|
||||||
|
const tileMaxAspectRatio = 17 / 9;
|
||||||
|
const tileMinAspectRatio = 4 / 3;
|
||||||
|
const tileMobileMinAspectRatio = 2 / 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the ideal arrangement of tiles into a grid of a particular size.
|
||||||
|
*/
|
||||||
|
export function arrangeTiles(
|
||||||
|
width: number,
|
||||||
|
minHeight: number,
|
||||||
|
tileCount: number,
|
||||||
|
): GridArrangement {
|
||||||
|
// The goal here is to determine the grid size and padding that maximizes
|
||||||
|
// use of screen space for n tiles without making those tiles too small or
|
||||||
|
// too cropped (having an extreme aspect ratio)
|
||||||
|
const gap = width < 800 ? 16 : 20;
|
||||||
|
const tileMinWidth = width < 500 ? 150 : 180;
|
||||||
|
|
||||||
|
let columns = Math.min(
|
||||||
|
// Don't create more columns than we have items for
|
||||||
|
tileCount,
|
||||||
|
// The ideal number of columns is given by a packing of equally-sized
|
||||||
|
// squares into a grid.
|
||||||
|
// width / column = height / row.
|
||||||
|
// columns * rows = number of squares.
|
||||||
|
// ∴ columns = sqrt(width / height * number of squares).
|
||||||
|
// Except we actually want 16:9-ish tiles rather than squares, so we
|
||||||
|
// divide the width-to-height ratio by the target aspect ratio.
|
||||||
|
Math.ceil(Math.sqrt((width / minHeight / tileMaxAspectRatio) * tileCount)),
|
||||||
|
);
|
||||||
|
let rows = Math.ceil(tileCount / columns);
|
||||||
|
|
||||||
|
let tileWidth = (width - (columns + 1) * gap) / columns;
|
||||||
|
let tileHeight = (minHeight - (rows - 1) * gap) / rows;
|
||||||
|
|
||||||
|
// Impose a minimum width and height on the tiles
|
||||||
|
if (tileWidth < tileMinWidth) {
|
||||||
|
// In this case we want the tile width to determine the number of columns,
|
||||||
|
// not the other way around. If we take the above equation for the tile
|
||||||
|
// width (w = (W - (c - 1) * g) / c) and solve for c, we get
|
||||||
|
// c = (W + g) / (w + g).
|
||||||
|
columns = Math.floor((width + gap) / (tileMinWidth + gap));
|
||||||
|
rows = Math.ceil(tileCount / columns);
|
||||||
|
tileWidth = (width - (columns + 1) * gap) / columns;
|
||||||
|
tileHeight = (minHeight - (rows - 1) * gap) / rows;
|
||||||
|
}
|
||||||
|
if (tileHeight < tileMinHeight) tileHeight = tileMinHeight;
|
||||||
|
|
||||||
|
// Impose a minimum and maximum aspect ratio on the tiles
|
||||||
|
const tileAspectRatio = tileWidth / tileHeight;
|
||||||
|
// We enforce a different min aspect ratio in 1:1s on mobile
|
||||||
|
const minAspectRatio =
|
||||||
|
tileCount === 1 && width < 600
|
||||||
|
? tileMobileMinAspectRatio
|
||||||
|
: tileMinAspectRatio;
|
||||||
|
if (tileAspectRatio > tileMaxAspectRatio)
|
||||||
|
tileWidth = tileHeight * tileMaxAspectRatio;
|
||||||
|
else if (tileAspectRatio < minAspectRatio)
|
||||||
|
tileHeight = tileWidth / minAspectRatio;
|
||||||
|
// TODO: We might now be hitting the minimum height or width limit again
|
||||||
|
|
||||||
|
return { tileWidth, tileHeight, gap, columns };
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2022 New Vector Ltd
|
Copyright 2023-2024 New Vector Ltd
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
You may obtain a copy of the License at
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
Unless required by applicable law or agreed to in writing, software
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
@@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.videoGrid {
|
.grid {
|
||||||
position: relative;
|
contain: layout style;
|
||||||
overflow: hidden;
|
}
|
||||||
flex: 1;
|
|
||||||
touch-action: none;
|
.slot {
|
||||||
|
contain: strict;
|
||||||
}
|
}
|
||||||
481
src/grid/Grid.tsx
Normal file
481
src/grid/Grid.tsx
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
/*
|
||||||
|
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 {
|
||||||
|
SpringRef,
|
||||||
|
TransitionFn,
|
||||||
|
animated,
|
||||||
|
useTransition,
|
||||||
|
} from "@react-spring/web";
|
||||||
|
import { EventTypes, Handler, useScroll } from "@use-gesture/react";
|
||||||
|
import {
|
||||||
|
CSSProperties,
|
||||||
|
ComponentProps,
|
||||||
|
ComponentType,
|
||||||
|
FC,
|
||||||
|
LegacyRef,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import useMeasure from "react-use-measure";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import styles from "./Grid.module.css";
|
||||||
|
import { useMergedRefs } from "../useMergedRefs";
|
||||||
|
import { TileWrapper } from "./TileWrapper";
|
||||||
|
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
|
||||||
|
import { useInitial } from "../useInitial";
|
||||||
|
|
||||||
|
interface Rect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tile<Model> {
|
||||||
|
id: string;
|
||||||
|
model: Model;
|
||||||
|
onDrag: DragCallback | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlacedTile<Model> = Tile<Model> & Rect;
|
||||||
|
|
||||||
|
interface TileSpring {
|
||||||
|
opacity: number;
|
||||||
|
scale: number;
|
||||||
|
zIndex: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
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;
|
||||||
|
tileY: number;
|
||||||
|
cursorX: number;
|
||||||
|
cursorY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
|
||||||
|
id: string;
|
||||||
|
model: Model;
|
||||||
|
onDrag?: DragCallback;
|
||||||
|
style?: CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Offset {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the offset of one element relative to an ancestor.
|
||||||
|
*/
|
||||||
|
function offset(element: HTMLElement, relativeTo: Element): Offset {
|
||||||
|
if (
|
||||||
|
!(element.offsetParent instanceof HTMLElement) ||
|
||||||
|
element.offsetParent === relativeTo
|
||||||
|
) {
|
||||||
|
return { x: element.offsetLeft, y: element.offsetTop };
|
||||||
|
} else {
|
||||||
|
const o = offset(element.offsetParent, relativeTo);
|
||||||
|
o.x += element.offsetLeft;
|
||||||
|
o.y += element.offsetTop;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
|
||||||
|
ref: LegacyRef<R>;
|
||||||
|
model: LayoutModel;
|
||||||
|
/**
|
||||||
|
* Component creating an invisible "slot" for a tile to go in.
|
||||||
|
*/
|
||||||
|
Slot: ComponentType<SlotProps<TileModel>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TileProps<Model, R extends HTMLElement> {
|
||||||
|
ref: LegacyRef<R>;
|
||||||
|
className?: string;
|
||||||
|
style?: ComponentProps<typeof animated.div>["style"];
|
||||||
|
/**
|
||||||
|
* The width this tile will have once its animations have settled.
|
||||||
|
*/
|
||||||
|
targetWidth: number;
|
||||||
|
/**
|
||||||
|
* The height this tile will have once its animations have settled.
|
||||||
|
*/
|
||||||
|
targetHeight: number;
|
||||||
|
model: Model;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Drag {
|
||||||
|
/**
|
||||||
|
* The X coordinate of the dragged tile in grid space.
|
||||||
|
*/
|
||||||
|
x: number;
|
||||||
|
/**
|
||||||
|
* The Y coordinate of the dragged tile in grid space.
|
||||||
|
*/
|
||||||
|
y: number;
|
||||||
|
/**
|
||||||
|
* The X coordinate of the dragged tile, as a scalar of the grid width.
|
||||||
|
*/
|
||||||
|
xRatio: number;
|
||||||
|
/**
|
||||||
|
* The Y coordinate of the dragged tile, as a scalar of the grid height.
|
||||||
|
*/
|
||||||
|
yRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DragCallback = (drag: Drag) => void;
|
||||||
|
|
||||||
|
interface Props<
|
||||||
|
LayoutModel,
|
||||||
|
TileModel,
|
||||||
|
LayoutRef extends HTMLElement,
|
||||||
|
TileRef extends HTMLElement,
|
||||||
|
> {
|
||||||
|
/**
|
||||||
|
* Data with which to populate the layout.
|
||||||
|
*/
|
||||||
|
model: LayoutModel;
|
||||||
|
/**
|
||||||
|
* A component which creates an invisible layout grid of "slots" for tiles to
|
||||||
|
* go in. The root element must have a data-generation attribute which
|
||||||
|
* increments whenever the layout may have changed.
|
||||||
|
*/
|
||||||
|
Layout: ComponentType<LayoutProps<LayoutModel, TileModel, LayoutRef>>;
|
||||||
|
/**
|
||||||
|
* The component used to render each tile in the layout.
|
||||||
|
*/
|
||||||
|
Tile: ComponentType<TileProps<TileModel, TileRef>>;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A grid of animated tiles.
|
||||||
|
*/
|
||||||
|
export function Grid<
|
||||||
|
LayoutModel,
|
||||||
|
TileModel,
|
||||||
|
LayoutRef extends HTMLElement,
|
||||||
|
TileRef extends HTMLElement,
|
||||||
|
>({
|
||||||
|
model,
|
||||||
|
Layout,
|
||||||
|
Tile,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: Props<LayoutModel, TileModel, LayoutRef, TileRef>): ReactNode {
|
||||||
|
// Overview: This component places tiles by rendering an invisible layout grid
|
||||||
|
// of "slots" for tiles to go in. Once rendered, it uses the DOM API to get
|
||||||
|
// the dimensions of each slot, feeding these numbers back into react-spring
|
||||||
|
// to let the actual tiles move freely atop the layout.
|
||||||
|
|
||||||
|
// To tell us when the layout has changed, the layout system increments its
|
||||||
|
// data-generation attribute, which we watch with a MutationObserver.
|
||||||
|
|
||||||
|
const [gridRef1, gridBounds] = useMeasure();
|
||||||
|
const [gridRoot, gridRef2] = useState<HTMLElement | null>(null);
|
||||||
|
const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2);
|
||||||
|
|
||||||
|
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
|
||||||
|
const [generation, setGeneration] = useState<number | null>(null);
|
||||||
|
const tiles = useInitial(() => new Map<string, Tile<TileModel>>());
|
||||||
|
const prefersReducedMotion = usePrefersReducedMotion();
|
||||||
|
|
||||||
|
const Slot: FC<SlotProps<TileModel>> = useMemo(
|
||||||
|
() =>
|
||||||
|
function Slot({ id, model, onDrag, style, className, ...props }) {
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
tiles.set(id, { id, model, onDrag });
|
||||||
|
return (): void => void tiles.delete(id);
|
||||||
|
}, [id, model, onDrag]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={classNames(className, styles.slot)}
|
||||||
|
data-id={id}
|
||||||
|
style={style}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[tiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
const layoutRef = useCallback(
|
||||||
|
(e: HTMLElement | null) => {
|
||||||
|
setLayoutRoot(e);
|
||||||
|
if (e !== null)
|
||||||
|
setGeneration(parseInt(e.getAttribute("data-generation")!));
|
||||||
|
},
|
||||||
|
[setLayoutRoot, setGeneration],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (layoutRoot !== null) {
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
if (mutations.some((m) => m.type === "attributes")) {
|
||||||
|
setGeneration(parseInt(layoutRoot.getAttribute("data-generation")!));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(layoutRoot, { attributes: true });
|
||||||
|
return (): void => observer.disconnect();
|
||||||
|
}
|
||||||
|
}, [layoutRoot, setGeneration]);
|
||||||
|
|
||||||
|
// Combine the tile definitions and slots together to create placed tiles
|
||||||
|
const placedTiles = useMemo(() => {
|
||||||
|
const result: PlacedTile<TileModel>[] = [];
|
||||||
|
|
||||||
|
if (gridRoot !== null && layoutRoot !== null) {
|
||||||
|
const slots = layoutRoot.getElementsByClassName(
|
||||||
|
styles.slot,
|
||||||
|
) as HTMLCollectionOf<HTMLElement>;
|
||||||
|
for (const slot of slots) {
|
||||||
|
const id = slot.getAttribute("data-id")!;
|
||||||
|
if (slot.offsetWidth > 0 && slot.offsetHeight > 0)
|
||||||
|
result.push({
|
||||||
|
...tiles.get(id)!,
|
||||||
|
...offset(slot, gridRoot),
|
||||||
|
width: slot.offsetWidth,
|
||||||
|
height: slot.offsetHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
// The rects may change due to the grid updating to a new generation, but
|
||||||
|
// eslint can't statically verify this
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [gridRoot, layoutRoot, tiles, generation]);
|
||||||
|
|
||||||
|
// Drag state is stored in a ref rather than component state, because we use
|
||||||
|
// react-spring's imperative API during gestures to improve responsiveness
|
||||||
|
const dragState = useRef<DragState | null>(null);
|
||||||
|
|
||||||
|
const [tileTransitions, springRef] = useTransition(
|
||||||
|
placedTiles,
|
||||||
|
() => ({
|
||||||
|
key: ({ id }: Tile<TileModel>): string => id,
|
||||||
|
from: ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}: PlacedTile<TileModel>): TileSpringUpdate => ({
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
immediate: prefersReducedMotion,
|
||||||
|
}),
|
||||||
|
enter: { opacity: 1, scale: 1, immediate: prefersReducedMotion },
|
||||||
|
update: ({
|
||||||
|
id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}: PlacedTile<TileModel>): TileSpringUpdate | null =>
|
||||||
|
id === dragState.current?.tileId
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
immediate: prefersReducedMotion,
|
||||||
|
},
|
||||||
|
leave: { opacity: 0, scale: 0, immediate: prefersReducedMotion },
|
||||||
|
config: { mass: 0.7, tension: 252, friction: 25 },
|
||||||
|
}),
|
||||||
|
// react-spring's types are bugged and can't infer the spring type
|
||||||
|
) as unknown as [
|
||||||
|
TransitionFn<PlacedTile<TileModel>, TileSpring>,
|
||||||
|
SpringRef<TileSpring>,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Because we're using react-spring in imperative mode, we're responsible for
|
||||||
|
// firing animations manually whenever the tiles array updates
|
||||||
|
useEffect(() => {
|
||||||
|
springRef.start();
|
||||||
|
}, [placedTiles, springRef]);
|
||||||
|
|
||||||
|
const animateDraggedTile = (
|
||||||
|
endOfGesture: boolean,
|
||||||
|
callback: DragCallback,
|
||||||
|
): void => {
|
||||||
|
const { tileId, tileX, tileY } = dragState.current!;
|
||||||
|
const tile = placedTiles.find((t) => t.id === tileId)!;
|
||||||
|
|
||||||
|
springRef.current
|
||||||
|
.find((c) => (c.item as Tile<TileModel>).id === tileId)
|
||||||
|
?.start(
|
||||||
|
endOfGesture
|
||||||
|
? {
|
||||||
|
scale: 1,
|
||||||
|
zIndex: 1,
|
||||||
|
x: tile.x,
|
||||||
|
y: tile.y,
|
||||||
|
width: tile.width,
|
||||||
|
height: tile.height,
|
||||||
|
immediate:
|
||||||
|
prefersReducedMotion || ((key): boolean => key === "zIndex"),
|
||||||
|
// Allow the tile's position to settle before pushing its
|
||||||
|
// z-index back down
|
||||||
|
delay: (key): number => (key === "zIndex" ? 500 : 0),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
scale: 1.1,
|
||||||
|
zIndex: 2,
|
||||||
|
x: tileX,
|
||||||
|
y: tileY,
|
||||||
|
immediate:
|
||||||
|
prefersReducedMotion ||
|
||||||
|
((key): boolean =>
|
||||||
|
key === "zIndex" || key === "x" || key === "y"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (endOfGesture)
|
||||||
|
callback({
|
||||||
|
x: tileX,
|
||||||
|
y: tileY,
|
||||||
|
xRatio: tileX / (gridBounds.width - tile.width),
|
||||||
|
yRatio: tileY / (gridBounds.height - tile.height),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Callback for useDrag. We could call useDrag here, but the default
|
||||||
|
// pattern of spreading {...bind()} across the children to bind the gesture
|
||||||
|
// ends up breaking memoization and ruining this component's performance.
|
||||||
|
// Instead, we pass this callback to each tile via a ref, to let them bind the
|
||||||
|
// gesture using the much more sensible ref-based method.
|
||||||
|
const onTileDrag = (
|
||||||
|
tileId: string,
|
||||||
|
|
||||||
|
{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
tap,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
initial: [initialX, initialY],
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
delta: [dx, dy],
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
last,
|
||||||
|
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0],
|
||||||
|
): void => {
|
||||||
|
if (!tap) {
|
||||||
|
const tileController = springRef.current.find(
|
||||||
|
(c) => (c.item as Tile<TileModel>).id === tileId,
|
||||||
|
)!;
|
||||||
|
const callback = tiles.get(tileController.item.id)!.onDrag;
|
||||||
|
|
||||||
|
if (callback != null) {
|
||||||
|
if (dragState.current === null) {
|
||||||
|
const tileSpring = tileController.get();
|
||||||
|
dragState.current = {
|
||||||
|
tileId,
|
||||||
|
tileX: tileSpring.x,
|
||||||
|
tileY: tileSpring.y,
|
||||||
|
cursorX: initialX - gridBounds.x,
|
||||||
|
cursorY: initialY - gridBounds.y + scrollOffset.current,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
dragState.current.tileX += dx;
|
||||||
|
dragState.current.tileY += dy;
|
||||||
|
dragState.current.cursorX += dx;
|
||||||
|
dragState.current.cursorY += dy;
|
||||||
|
|
||||||
|
animateDraggedTile(last, callback);
|
||||||
|
|
||||||
|
if (last) dragState.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTileDragRef = useRef(onTileDrag);
|
||||||
|
onTileDragRef.current = onTileDrag;
|
||||||
|
|
||||||
|
const scrollOffset = useRef(0);
|
||||||
|
|
||||||
|
useScroll(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
({ xy: [, y], delta: [, dy] }) => {
|
||||||
|
scrollOffset.current = y;
|
||||||
|
|
||||||
|
if (dragState.current !== null) {
|
||||||
|
dragState.current.tileY += dy;
|
||||||
|
dragState.current.cursorY += dy;
|
||||||
|
animateDraggedTile(false, tiles.get(dragState.current.tileId)!.onDrag!);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ target: gridRoot ?? undefined },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={gridRef}
|
||||||
|
className={classNames(className, styles.grid)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<Layout ref={layoutRef} model={model} Slot={Slot} />
|
||||||
|
{tileTransitions((spring, { id, model, onDrag, width, height }) => (
|
||||||
|
<TileWrapper
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
onDrag={onDrag ? onTileDragRef : null}
|
||||||
|
targetWidth={width}
|
||||||
|
targetHeight={height}
|
||||||
|
model={model}
|
||||||
|
Tile={Tile}
|
||||||
|
{...spring}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/grid/GridLayout.module.css
Normal file
60
src/grid/GridLayout.module.css
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.fixed,
|
||||||
|
.scrolling {
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrolling {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrolling > .slot {
|
||||||
|
width: var(--width);
|
||||||
|
height: var(--height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed > .slot {
|
||||||
|
position: absolute;
|
||||||
|
inline-size: 404px;
|
||||||
|
block-size: 233px;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed > .slot[data-block-alignment="start"] {
|
||||||
|
inset-block-end: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed > .slot[data-block-alignment="end"] {
|
||||||
|
inset-block-start: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed > .slot[data-inline-alignment="start"] {
|
||||||
|
inset-inline-end: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed > .slot[data-inline-alignment="end"] {
|
||||||
|
inset-inline-start: unset;
|
||||||
|
}
|
||||||
139
src/grid/GridLayout.tsx
Normal file
139
src/grid/GridLayout.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CSSProperties, forwardRef, useCallback, useMemo } from "react";
|
||||||
|
import { distinctUntilChanged } from "rxjs";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
|
||||||
|
import { GridLayout as GridLayoutModel } from "../state/CallViewModel";
|
||||||
|
import styles from "./GridLayout.module.css";
|
||||||
|
import { useReactiveState } from "../useReactiveState";
|
||||||
|
import { useInitial } from "../useInitial";
|
||||||
|
import {
|
||||||
|
CallLayout,
|
||||||
|
GridTileModel,
|
||||||
|
TileModel,
|
||||||
|
arrangeTiles,
|
||||||
|
} from "./CallLayout";
|
||||||
|
import { DragCallback } from "./Grid";
|
||||||
|
|
||||||
|
interface GridCSSProperties extends CSSProperties {
|
||||||
|
"--gap": string;
|
||||||
|
"--width": string;
|
||||||
|
"--height": string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of the "grid" layout, in which all participants are shown
|
||||||
|
* together in a scrolling grid.
|
||||||
|
*/
|
||||||
|
export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||||
|
minBounds,
|
||||||
|
spotlightAlignment,
|
||||||
|
}) => ({
|
||||||
|
scrollingOnTop: false,
|
||||||
|
|
||||||
|
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
|
||||||
|
// lives
|
||||||
|
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) {
|
||||||
|
const { width, height } = useObservableEagerState(minBounds);
|
||||||
|
const alignment = useObservableEagerState(
|
||||||
|
useInitial(() =>
|
||||||
|
spotlightAlignment.pipe(
|
||||||
|
distinctUntilChanged(
|
||||||
|
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const tileModel: TileModel | undefined = useMemo(
|
||||||
|
() =>
|
||||||
|
model.spotlight && {
|
||||||
|
type: "spotlight",
|
||||||
|
vms: model.spotlight,
|
||||||
|
maximised: false,
|
||||||
|
},
|
||||||
|
[model.spotlight],
|
||||||
|
);
|
||||||
|
const [generation] = useReactiveState<number>(
|
||||||
|
(prev) => (prev === undefined ? 0 : prev + 1),
|
||||||
|
[model.spotlight === undefined, width, height, alignment],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDragSpotlight: DragCallback = useCallback(
|
||||||
|
({ xRatio, yRatio }) =>
|
||||||
|
spotlightAlignment.next({
|
||||||
|
block: yRatio < 0.5 ? "start" : "end",
|
||||||
|
inline: xRatio < 0.5 ? "start" : "end",
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={styles.fixed} data-generation={generation}>
|
||||||
|
{tileModel && (
|
||||||
|
<Slot
|
||||||
|
className={styles.slot}
|
||||||
|
id="spotlight"
|
||||||
|
model={tileModel}
|
||||||
|
onDrag={onDragSpotlight}
|
||||||
|
data-block-alignment={alignment.block}
|
||||||
|
data-inline-alignment={alignment.inline}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// The scrolling part of the layout is where all the grid tiles live
|
||||||
|
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
|
||||||
|
const { width, height: minHeight } = useObservableEagerState(minBounds);
|
||||||
|
const { gap, tileWidth, tileHeight } = useMemo(
|
||||||
|
() => arrangeTiles(width, minHeight, model.grid.length),
|
||||||
|
[width, minHeight, model.grid.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [generation] = useReactiveState<number>(
|
||||||
|
(prev) => (prev === undefined ? 0 : prev + 1),
|
||||||
|
[model.grid, width, minHeight],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tileModels: GridTileModel[] = useMemo(
|
||||||
|
() => model.grid.map((vm) => ({ type: "grid", vm })),
|
||||||
|
[model.grid],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-generation={generation}
|
||||||
|
className={styles.scrolling}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
width,
|
||||||
|
"--gap": `${gap}px`,
|
||||||
|
"--width": `${Math.floor(tileWidth)}px`,
|
||||||
|
"--height": `${Math.floor(tileHeight)}px`,
|
||||||
|
} as GridCSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tileModels.map((m) => (
|
||||||
|
<Slot key={m.vm.id} className={styles.slot} id={m.vm.id} model={m} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
61
src/grid/OneOnOneLayout.module.css
Normal file
61
src/grid/OneOnOneLayout.module.css
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.layer {
|
||||||
|
block-size: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.local {
|
||||||
|
position: absolute;
|
||||||
|
inline-size: 135px;
|
||||||
|
block-size: 160px;
|
||||||
|
inset: var(--cpd-space-4x);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.local {
|
||||||
|
inline-size: 170px;
|
||||||
|
block-size: 110px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight {
|
||||||
|
position: absolute;
|
||||||
|
inline-size: 404px;
|
||||||
|
block-size: 233px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot[data-block-alignment="start"] {
|
||||||
|
inset-block-end: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot[data-block-alignment="end"] {
|
||||||
|
inset-block-start: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot[data-inline-alignment="start"] {
|
||||||
|
inset-inline-end: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot[data-inline-alignment="end"] {
|
||||||
|
inset-inline-start: unset;
|
||||||
|
}
|
||||||
92
src/grid/OneOnOneLayout.tsx
Normal file
92
src/grid/OneOnOneLayout.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { forwardRef, useCallback, useMemo } from "react";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import { OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel";
|
||||||
|
import { CallLayout, GridTileModel, arrangeTiles } from "./CallLayout";
|
||||||
|
import { useReactiveState } from "../useReactiveState";
|
||||||
|
import styles from "./OneOnOneLayout.module.css";
|
||||||
|
import { DragCallback } from "./Grid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of the "one-on-one" layout, in which the remote participant
|
||||||
|
* is shown at maximum size, overlaid by a small view of the local participant.
|
||||||
|
*/
|
||||||
|
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
||||||
|
minBounds,
|
||||||
|
pipAlignment,
|
||||||
|
}) => ({
|
||||||
|
scrollingOnTop: false,
|
||||||
|
|
||||||
|
fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) {
|
||||||
|
return <div ref={ref} data-generation={0} />;
|
||||||
|
}),
|
||||||
|
|
||||||
|
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {
|
||||||
|
const { width, height } = useObservableEagerState(minBounds);
|
||||||
|
const pipAlignmentValue = useObservableEagerState(pipAlignment);
|
||||||
|
const { tileWidth, tileHeight } = useMemo(
|
||||||
|
() => arrangeTiles(width, height, 1),
|
||||||
|
[width, height],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [generation] = useReactiveState<number>(
|
||||||
|
(prev) => (prev === undefined ? 0 : prev + 1),
|
||||||
|
[width, height, pipAlignmentValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const remoteTileModel: GridTileModel = useMemo(
|
||||||
|
() => ({ type: "grid", vm: model.remote }),
|
||||||
|
[model.remote],
|
||||||
|
);
|
||||||
|
const localTileModel: GridTileModel = useMemo(
|
||||||
|
() => ({ type: "grid", vm: model.local }),
|
||||||
|
[model.local],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDragLocalTile: DragCallback = useCallback(
|
||||||
|
({ xRatio, yRatio }) =>
|
||||||
|
pipAlignment.next({
|
||||||
|
block: yRatio < 0.5 ? "start" : "end",
|
||||||
|
inline: xRatio < 0.5 ? "start" : "end",
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} data-generation={generation} className={styles.layer}>
|
||||||
|
<Slot
|
||||||
|
id={remoteTileModel.vm.id}
|
||||||
|
model={remoteTileModel}
|
||||||
|
className={styles.container}
|
||||||
|
style={{ width: tileWidth, height: tileHeight }}
|
||||||
|
>
|
||||||
|
<Slot
|
||||||
|
className={classNames(styles.slot, styles.local)}
|
||||||
|
id={localTileModel.vm.id}
|
||||||
|
model={localTileModel}
|
||||||
|
onDrag={onDragLocalTile}
|
||||||
|
data-block-alignment={pipAlignmentValue.block}
|
||||||
|
data-inline-alignment={pipAlignmentValue.inline}
|
||||||
|
/>
|
||||||
|
</Slot>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
47
src/grid/SpotlightExpandedLayout.module.css
Normal file
47
src/grid/SpotlightExpandedLayout.module.css
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.layer {
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight {
|
||||||
|
block-size: 100%;
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip {
|
||||||
|
position: absolute;
|
||||||
|
inline-size: 180px;
|
||||||
|
block-size: 135px;
|
||||||
|
inset: var(--cpd-space-4x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-block-alignment="start"] {
|
||||||
|
inset-block-end: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-block-alignment="end"] {
|
||||||
|
inset-block-start: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-inline-alignment="start"] {
|
||||||
|
inset-inline-end: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-inline-alignment="end"] {
|
||||||
|
inset-inline-start: unset;
|
||||||
|
}
|
||||||
103
src/grid/SpotlightExpandedLayout.tsx
Normal file
103
src/grid/SpotlightExpandedLayout.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { forwardRef, useCallback, useMemo } from "react";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
|
||||||
|
import { SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
|
||||||
|
import { CallLayout, GridTileModel, SpotlightTileModel } from "./CallLayout";
|
||||||
|
import { DragCallback } from "./Grid";
|
||||||
|
import styles from "./SpotlightExpandedLayout.module.css";
|
||||||
|
import { useReactiveState } from "../useReactiveState";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of the "expanded spotlight" layout, in which the spotlight
|
||||||
|
* tile stretches edge-to-edge and is overlaid by a picture-in-picture tile.
|
||||||
|
*/
|
||||||
|
export const makeSpotlightExpandedLayout: CallLayout<
|
||||||
|
SpotlightExpandedLayoutModel
|
||||||
|
> = ({ minBounds, pipAlignment }) => ({
|
||||||
|
scrollingOnTop: true,
|
||||||
|
|
||||||
|
fixed: forwardRef(function SpotlightExpandedLayoutFixed(
|
||||||
|
{ model, Slot },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const { width, height } = useObservableEagerState(minBounds);
|
||||||
|
|
||||||
|
const [generation] = useReactiveState<number>(
|
||||||
|
(prev) => (prev === undefined ? 0 : prev + 1),
|
||||||
|
[width, height],
|
||||||
|
);
|
||||||
|
|
||||||
|
const spotlightTileModel: SpotlightTileModel = useMemo(
|
||||||
|
() => ({ type: "spotlight", vms: model.spotlight, maximised: true }),
|
||||||
|
[model.spotlight],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} data-generation={generation} className={styles.layer}>
|
||||||
|
<Slot
|
||||||
|
className={styles.spotlight}
|
||||||
|
id="spotlight"
|
||||||
|
model={spotlightTileModel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
scrolling: forwardRef(function SpotlightExpandedLayoutScrolling(
|
||||||
|
{ model, Slot },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const { width, height } = useObservableEagerState(minBounds);
|
||||||
|
const pipAlignmentValue = useObservableEagerState(pipAlignment);
|
||||||
|
|
||||||
|
const [generation] = useReactiveState<number>(
|
||||||
|
(prev) => (prev === undefined ? 0 : prev + 1),
|
||||||
|
[width, height, model.pip === undefined, pipAlignmentValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pipTileModel: GridTileModel | undefined = useMemo(
|
||||||
|
() => model.pip && { type: "grid", vm: model.pip },
|
||||||
|
[model.pip],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDragPip: DragCallback = useCallback(
|
||||||
|
({ xRatio, yRatio }) =>
|
||||||
|
pipAlignment.next({
|
||||||
|
block: yRatio < 0.5 ? "start" : "end",
|
||||||
|
inline: xRatio < 0.5 ? "start" : "end",
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} data-generation={generation} className={styles.layer}>
|
||||||
|
{pipTileModel && (
|
||||||
|
<Slot
|
||||||
|
className={styles.pip}
|
||||||
|
id="pip"
|
||||||
|
model={pipTileModel}
|
||||||
|
onDrag={onDragPip}
|
||||||
|
data-block-alignment={pipAlignmentValue.block}
|
||||||
|
data-inline-alignment={pipAlignmentValue.inline}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
54
src/grid/SpotlightLandscapeLayout.module.css
Normal file
54
src/grid/SpotlightLandscapeLayout.module.css
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.layer {
|
||||||
|
block-size: 100%;
|
||||||
|
display: grid;
|
||||||
|
--gap: 20px;
|
||||||
|
gap: var(--gap);
|
||||||
|
--grid-slot-width: 180px;
|
||||||
|
grid-template-columns: 1fr var(--grid-slot-width);
|
||||||
|
grid-template-rows: minmax(1fr, auto);
|
||||||
|
padding-inline: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight {
|
||||||
|
container: spotlight / size;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS makes us put a condition here, even though all we want to do is
|
||||||
|
unconditionally select the container so we can use cq units */
|
||||||
|
@container spotlight (width > 0) {
|
||||||
|
.spotlight > .slot {
|
||||||
|
inline-size: min(100cqi, 100cqb * (17 / 9));
|
||||||
|
block-size: min(100cqb, 100cqi / (4 / 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--gap);
|
||||||
|
justify-content: center;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid > .slot {
|
||||||
|
inline-size: 180px;
|
||||||
|
block-size: 135px;
|
||||||
|
}
|
||||||
98
src/grid/SpotlightLandscapeLayout.tsx
Normal file
98
src/grid/SpotlightLandscapeLayout.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { forwardRef, useMemo } from "react";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import { CallLayout, GridTileModel, TileModel } from "./CallLayout";
|
||||||
|
import { SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel";
|
||||||
|
import styles from "./SpotlightLandscapeLayout.module.css";
|
||||||
|
import { useReactiveState } from "../useReactiveState";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of the "spotlight landscape" layout, in which the spotlight
|
||||||
|
* tile takes up most of the space on the left, and the grid of participants is
|
||||||
|
* shown as a scrolling rail on the right.
|
||||||
|
*/
|
||||||
|
export const makeSpotlightLandscapeLayout: CallLayout<
|
||||||
|
SpotlightLandscapeLayoutModel
|
||||||
|
> = ({ minBounds }) => ({
|
||||||
|
scrollingOnTop: false,
|
||||||
|
|
||||||
|
fixed: forwardRef(function SpotlightLandscapeLayoutFixed(
|
||||||
|
{ model, Slot },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const { width, height } = useObservableEagerState(minBounds);
|
||||||
|
const tileModel: TileModel = useMemo(
|
||||||
|
() => ({
|
||||||
|
type: "spotlight",
|
||||||
|
vms: model.spotlight,
|
||||||
|
maximised: false,
|
||||||
|
}),
|
||||||
|
[model.spotlight],
|
||||||
|
);
|
||||||
|
const [generation] = useReactiveState<number>(
|
||||||
|
(prev) => (prev === undefined ? 0 : prev + 1),
|
||||||
|
[model.grid.length, width, height],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} data-generation={generation} className={styles.layer}>
|
||||||
|
<div className={styles.spotlight}>
|
||||||
|
<Slot className={styles.slot} id="spotlight" model={tileModel} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.grid} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
scrolling: forwardRef(function SpotlightLandscapeLayoutScrolling(
|
||||||
|
{ model, Slot },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const { width, height } = useObservableEagerState(minBounds);
|
||||||
|
const tileModels: GridTileModel[] = useMemo(
|
||||||
|
() => model.grid.map((vm) => ({ type: "grid", vm })),
|
||||||
|
[model.grid],
|
||||||
|
);
|
||||||
|
const [generation] = useReactiveState<number>(
|
||||||
|
(prev) => (prev === undefined ? 0 : prev + 1),
|
||||||
|
[model.spotlight.length, model.grid, width, height],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} data-generation={generation} className={styles.layer}>
|
||||||
|
<div
|
||||||
|
className={classNames(styles.spotlight, {
|
||||||
|
[styles.withIndicators]: model.spotlight.length > 1,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{tileModels.map((m) => (
|
||||||
|
<Slot
|
||||||
|
key={m.vm.id}
|
||||||
|
className={styles.slot}
|
||||||
|
id={m.vm.id}
|
||||||
|
model={m}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
56
src/grid/SpotlightPortraitLayout.module.css
Normal file
56
src/grid/SpotlightPortraitLayout.module.css
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.layer {
|
||||||
|
block-size: 100%;
|
||||||
|
display: grid;
|
||||||
|
--gap: 20px;
|
||||||
|
gap: var(--gap);
|
||||||
|
margin-inline: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight {
|
||||||
|
container: spotlight / size;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
inline-size: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
margin-block-end: var(--cpd-space-4x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight.withIndicators {
|
||||||
|
margin-block-end: calc(2 * var(--cpd-space-4x) + 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight > .slot {
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--grid-gap);
|
||||||
|
justify-content: center;
|
||||||
|
align-content: start;
|
||||||
|
padding-inline: var(--grid-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid > .slot {
|
||||||
|
inline-size: var(--grid-tile-width);
|
||||||
|
block-size: var(--grid-tile-height);
|
||||||
|
}
|
||||||
124
src/grid/SpotlightPortraitLayout.tsx
Normal file
124
src/grid/SpotlightPortraitLayout.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CSSProperties, forwardRef, useMemo } from "react";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CallLayout,
|
||||||
|
GridTileModel,
|
||||||
|
TileModel,
|
||||||
|
arrangeTiles,
|
||||||
|
} from "./CallLayout";
|
||||||
|
import { SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
|
||||||
|
import styles from "./SpotlightPortraitLayout.module.css";
|
||||||
|
import { useReactiveState } from "../useReactiveState";
|
||||||
|
|
||||||
|
interface GridCSSProperties extends CSSProperties {
|
||||||
|
"--grid-gap": string;
|
||||||
|
"--grid-tile-width": string;
|
||||||
|
"--grid-tile-height": string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of the "spotlight portrait" layout, in which the spotlight
|
||||||
|
* tile is shown across the top of the screen, and the grid of participants
|
||||||
|
* scrolls behind it.
|
||||||
|
*/
|
||||||
|
export const makeSpotlightPortraitLayout: CallLayout<
|
||||||
|
SpotlightPortraitLayoutModel
|
||||||
|
> = ({ minBounds }) => ({
|
||||||
|
scrollingOnTop: false,
|
||||||
|
|
||||||
|
fixed: forwardRef(function SpotlightPortraitLayoutFixed(
|
||||||
|
{ model, Slot },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const { width, height } = useObservableEagerState(minBounds);
|
||||||
|
const tileModel: TileModel = useMemo(
|
||||||
|
() => ({
|
||||||
|
type: "spotlight",
|
||||||
|
vms: model.spotlight,
|
||||||
|
maximised: true,
|
||||||
|
}),
|
||||||
|
[model.spotlight],
|
||||||
|
);
|
||||||
|
const [generation] = useReactiveState<number>(
|
||||||
|
(prev) => (prev === undefined ? 0 : prev + 1),
|
||||||
|
[model.grid.length, width, height],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} data-generation={generation} className={styles.layer}>
|
||||||
|
<div className={styles.spotlight}>
|
||||||
|
<Slot className={styles.slot} id="spotlight" model={tileModel} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
scrolling: forwardRef(function SpotlightPortraitLayoutScrolling(
|
||||||
|
{ model, Slot },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const { width, height } = useObservableEagerState(minBounds);
|
||||||
|
const { gap, tileWidth, tileHeight } = arrangeTiles(
|
||||||
|
width,
|
||||||
|
0,
|
||||||
|
model.grid.length,
|
||||||
|
);
|
||||||
|
const tileModels: GridTileModel[] = useMemo(
|
||||||
|
() => model.grid.map((vm) => ({ type: "grid", vm })),
|
||||||
|
[model.grid],
|
||||||
|
);
|
||||||
|
const [generation] = useReactiveState<number>(
|
||||||
|
(prev) => (prev === undefined ? 0 : prev + 1),
|
||||||
|
[model.spotlight.length, model.grid, width, height],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-generation={generation}
|
||||||
|
className={styles.layer}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--grid-gap": `${gap}px`,
|
||||||
|
"--grid-tile-width": `${Math.floor(tileWidth)}px`,
|
||||||
|
"--grid-tile-height": `${Math.floor(tileHeight)}px`,
|
||||||
|
} as GridCSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classNames(styles.spotlight, {
|
||||||
|
[styles.withIndicators]: model.spotlight.length > 1,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{tileModels.map((m) => (
|
||||||
|
<Slot
|
||||||
|
key={m.vm.id}
|
||||||
|
className={styles.slot}
|
||||||
|
id={m.vm.id}
|
||||||
|
model={m}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2023 New Vector Ltd
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -14,15 +14,10 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.bigGrid {
|
.tile.draggable {
|
||||||
display: grid;
|
cursor: grab;
|
||||||
grid-auto-rows: 130px;
|
|
||||||
gap: var(--cpd-space-2x);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 800px) {
|
.tile.draggable:active {
|
||||||
.bigGrid {
|
cursor: grabbing;
|
||||||
grid-auto-rows: 135px;
|
|
||||||
gap: var(--cpd-space-5x);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2023 New Vector Ltd
|
Copyright 2023-2024 New Vector Ltd
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -14,83 +14,76 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { memo, ReactNode, RefObject, useRef } from "react";
|
import { ComponentType, memo, RefObject, useRef } from "react";
|
||||||
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
|
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
|
||||||
import { SpringValue, to } from "@react-spring/web";
|
import { SpringValue } from "@react-spring/web";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { ChildrenProperties } from "./VideoGrid";
|
import { TileProps } from "./Grid";
|
||||||
|
import styles from "./TileWrapper.module.css";
|
||||||
|
|
||||||
interface Props<T> {
|
interface Props<M, R extends HTMLElement> {
|
||||||
id: string;
|
id: string;
|
||||||
onDragRef: RefObject<
|
onDrag: RefObject<
|
||||||
(
|
(
|
||||||
tileId: string,
|
tileId: string,
|
||||||
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0],
|
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0],
|
||||||
) => void
|
) => void
|
||||||
>;
|
> | null;
|
||||||
targetWidth: number;
|
targetWidth: number;
|
||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
data: T;
|
model: M;
|
||||||
|
Tile: ComponentType<TileProps<M, R>>;
|
||||||
opacity: SpringValue<number>;
|
opacity: SpringValue<number>;
|
||||||
scale: SpringValue<number>;
|
scale: SpringValue<number>;
|
||||||
shadow: SpringValue<number>;
|
|
||||||
shadowSpread: SpringValue<number>;
|
|
||||||
zIndex: SpringValue<number>;
|
zIndex: SpringValue<number>;
|
||||||
x: SpringValue<number>;
|
x: SpringValue<number>;
|
||||||
y: SpringValue<number>;
|
y: SpringValue<number>;
|
||||||
width: SpringValue<number>;
|
width: SpringValue<number>;
|
||||||
height: SpringValue<number>;
|
height: SpringValue<number>;
|
||||||
children: (props: ChildrenProperties<T>) => ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TileWrapper_ = memo(
|
const TileWrapper_ = memo(
|
||||||
<T,>({
|
<M, R extends HTMLElement>({
|
||||||
id,
|
id,
|
||||||
onDragRef,
|
onDrag,
|
||||||
targetWidth,
|
targetWidth,
|
||||||
targetHeight,
|
targetHeight,
|
||||||
data,
|
model,
|
||||||
|
Tile,
|
||||||
opacity,
|
opacity,
|
||||||
scale,
|
scale,
|
||||||
shadow,
|
|
||||||
shadowSpread,
|
|
||||||
zIndex,
|
zIndex,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
children,
|
}: Props<M, R>) => {
|
||||||
}: Props<T>) => {
|
const ref = useRef<R | null>(null);
|
||||||
const ref = useRef<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
useDrag((state) => onDragRef?.current!(id, state), {
|
useDrag((state) => onDrag?.current!(id, state), {
|
||||||
target: ref,
|
target: ref,
|
||||||
filterTaps: true,
|
filterTaps: true,
|
||||||
preventScroll: true,
|
preventScroll: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Tile
|
||||||
{children({
|
ref={ref}
|
||||||
ref,
|
className={classNames(styles.tile, { [styles.draggable]: onDrag })}
|
||||||
style: {
|
style={{
|
||||||
opacity,
|
opacity,
|
||||||
scale,
|
scale,
|
||||||
zIndex,
|
zIndex,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
boxShadow: to(
|
}}
|
||||||
[shadow, shadowSpread],
|
targetWidth={targetWidth}
|
||||||
(s, ss) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px ${ss}px`,
|
targetHeight={targetHeight}
|
||||||
),
|
model={model}
|
||||||
},
|
/>
|
||||||
targetWidth,
|
|
||||||
targetHeight,
|
|
||||||
data,
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -104,4 +97,6 @@ TileWrapper_.displayName = "TileWrapper";
|
|||||||
// We pretend this component is a simple function rather than a
|
// We pretend this component is a simple function rather than a
|
||||||
// NamedExoticComponent, because that's the only way we can fit in a type
|
// NamedExoticComponent, because that's the only way we can fit in a type
|
||||||
// parameter
|
// parameter
|
||||||
export const TileWrapper = TileWrapper_ as <T>(props: Props<T>) => JSX.Element;
|
export const TileWrapper = TileWrapper_ as <M, R extends HTMLElement>(
|
||||||
|
props: Props<M, R>,
|
||||||
|
) => JSX.Element;
|
||||||
@@ -38,9 +38,12 @@ import { UserMenuContainer } from "../UserMenuContainer";
|
|||||||
import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
||||||
import { Caption } from "../typography/Typography";
|
import { Caption } from "../typography/Typography";
|
||||||
import { Form } from "../form/Form";
|
import { Form } from "../form/Form";
|
||||||
import { useOptInAnalytics } from "../settings/useSetting";
|
|
||||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
|
import {
|
||||||
|
useSetting,
|
||||||
|
optInAnalytics as optInAnalyticsSetting,
|
||||||
|
} from "../settings/settings";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
@@ -49,7 +52,7 @@ interface Props {
|
|||||||
export const RegisteredView: FC<Props> = ({ client }) => {
|
export const RegisteredView: FC<Props> = ({ client }) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<Error>();
|
const [error, setError] = useState<Error>();
|
||||||
const [optInAnalytics] = useOptInAnalytics();
|
const [optInAnalytics] = useSetting(optInAnalyticsSetting);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [joinExistingCallModalOpen, setJoinExistingCallModalOpen] =
|
const [joinExistingCallModalOpen, setJoinExistingCallModalOpen] =
|
||||||
|
|||||||
@@ -41,15 +41,18 @@ import styles from "./UnauthenticatedView.module.css";
|
|||||||
import commonStyles from "./common.module.css";
|
import commonStyles from "./common.module.css";
|
||||||
import { generateRandomName } from "../auth/generateRandomName";
|
import { generateRandomName } from "../auth/generateRandomName";
|
||||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||||
import { useOptInAnalytics } from "../settings/useSetting";
|
|
||||||
import { Config } from "../config/Config";
|
import { Config } from "../config/Config";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
|
import {
|
||||||
|
useSetting,
|
||||||
|
optInAnalytics as optInAnalyticsSetting,
|
||||||
|
} from "../settings/settings";
|
||||||
|
|
||||||
export const UnauthenticatedView: FC = () => {
|
export const UnauthenticatedView: FC = () => {
|
||||||
const { setClient } = useClient();
|
const { setClient } = useClient();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<Error>();
|
const [error, setError] = useState<Error>();
|
||||||
const [optInAnalytics] = useOptInAnalytics();
|
const [optInAnalytics] = useSetting(optInAnalyticsSetting);
|
||||||
const { recaptchaKey, register } = useInteractiveRegistration();
|
const { recaptchaKey, register } = useInteractiveRegistration();
|
||||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||||
|
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ import { Observable } from "rxjs";
|
|||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isFirefox,
|
useSetting,
|
||||||
useAudioInput,
|
audioInput as audioInputSetting,
|
||||||
useAudioOutput,
|
audioOutput as audioOutputSetting,
|
||||||
useVideoInput,
|
videoInput as videoInputSetting,
|
||||||
} from "../settings/useSetting";
|
} from "../settings/settings";
|
||||||
|
import { isFirefox } from "../Platform";
|
||||||
|
|
||||||
export interface MediaDevice {
|
export interface MediaDevice {
|
||||||
available: MediaDeviceInfo[];
|
available: MediaDeviceInfo[];
|
||||||
@@ -145,43 +146,36 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
|
|||||||
// for ouput devices because the selector wont be shown on FF.
|
// for ouput devices because the selector wont be shown on FF.
|
||||||
const useOutputNames = usingNames && !isFirefox();
|
const useOutputNames = usingNames && !isFirefox();
|
||||||
|
|
||||||
const [audioInputSetting, setAudioInputSetting] = useAudioInput();
|
const [storedAudioInput, setStoredAudioInput] = useSetting(audioInputSetting);
|
||||||
const [audioOutputSetting, setAudioOutputSetting] = useAudioOutput();
|
const [storedAudioOutput, setStoredAudioOutput] =
|
||||||
const [videoInputSetting, setVideoInputSetting] = useVideoInput();
|
useSetting(audioOutputSetting);
|
||||||
|
const [storedVideoInput, setStoredVideoInput] = useSetting(videoInputSetting);
|
||||||
|
|
||||||
const audioInput = useMediaDevice(
|
const audioInput = useMediaDevice("audioinput", storedAudioInput, usingNames);
|
||||||
"audioinput",
|
|
||||||
audioInputSetting,
|
|
||||||
usingNames,
|
|
||||||
);
|
|
||||||
const audioOutput = useMediaDevice(
|
const audioOutput = useMediaDevice(
|
||||||
"audiooutput",
|
"audiooutput",
|
||||||
audioOutputSetting,
|
storedAudioOutput,
|
||||||
useOutputNames,
|
useOutputNames,
|
||||||
alwaysUseDefaultAudio,
|
alwaysUseDefaultAudio,
|
||||||
);
|
);
|
||||||
const videoInput = useMediaDevice(
|
const videoInput = useMediaDevice("videoinput", storedVideoInput, usingNames);
|
||||||
"videoinput",
|
|
||||||
videoInputSetting,
|
|
||||||
usingNames,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioInput.selectedId !== undefined)
|
if (audioInput.selectedId !== undefined)
|
||||||
setAudioInputSetting(audioInput.selectedId);
|
setStoredAudioInput(audioInput.selectedId);
|
||||||
}, [setAudioInputSetting, audioInput.selectedId]);
|
}, [setStoredAudioInput, audioInput.selectedId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip setting state for ff output. Redundent since it is set to always return 'undefined'
|
// Skip setting state for ff output. Redundent since it is set to always return 'undefined'
|
||||||
// but makes it clear while debugging that this is not happening on FF. + perf ;)
|
// but makes it clear while debugging that this is not happening on FF. + perf ;)
|
||||||
if (audioOutput.selectedId !== undefined && !isFirefox())
|
if (audioOutput.selectedId !== undefined && !isFirefox())
|
||||||
setAudioOutputSetting(audioOutput.selectedId);
|
setStoredAudioOutput(audioOutput.selectedId);
|
||||||
}, [setAudioOutputSetting, audioOutput.selectedId]);
|
}, [setStoredAudioOutput, audioOutput.selectedId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (videoInput.selectedId !== undefined)
|
if (videoInput.selectedId !== undefined)
|
||||||
setVideoInputSetting(videoInput.selectedId);
|
setStoredVideoInput(videoInput.selectedId);
|
||||||
}, [setVideoInputSetting, videoInput.selectedId]);
|
}, [setStoredVideoInput, videoInput.selectedId]);
|
||||||
|
|
||||||
const startUsingDeviceNames = useCallback(
|
const startUsingDeviceNames = useCallback(
|
||||||
() => setNumCallersUsingNames((n) => n + 1),
|
() => setNumCallersUsingNames((n) => n + 1),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Observable, defer, finalize, tap } from "rxjs";
|
import { Observable, defer, finalize, scan, startWith, tap } from "rxjs";
|
||||||
|
|
||||||
const nothing = Symbol("nothing");
|
const nothing = Symbol("nothing");
|
||||||
|
|
||||||
@@ -35,3 +35,15 @@ export function finalizeValue<T>(callback: (finalValue: T) => void) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RxJS operator that accumulates a state from a source of events. This is like
|
||||||
|
* scan, except it emits an initial value immediately before any events arrive.
|
||||||
|
*/
|
||||||
|
export function accumulate<State, Event>(
|
||||||
|
initial: State,
|
||||||
|
update: (state: State, event: Event) => State,
|
||||||
|
) {
|
||||||
|
return (events: Observable<Event>): Observable<State> =>
|
||||||
|
events.pipe(scan(update, initial), startWith(initial));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2021 New Vector Ltd
|
Copyright 2021-2024 New Vector Ltd
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -19,6 +19,7 @@ limitations under the License.
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controlsOverlay {
|
.controlsOverlay {
|
||||||
@@ -46,9 +47,21 @@ limitations under the License.
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: sticky;
|
||||||
|
inset-block-start: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(0, 0, 0, 0) 0%,
|
||||||
|
var(--cpd-color-bg-canvas-default) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
inset-block-end: 0;
|
inset-block-end: 0;
|
||||||
|
z-index: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
grid-template-areas: "logo buttons layout";
|
grid-template-areas: "logo buttons layout";
|
||||||
@@ -109,3 +122,44 @@ limitations under the License.
|
|||||||
.footerHidden {
|
.footerHidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-end: 0;
|
||||||
|
inset-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixedGrid {
|
||||||
|
position: absolute;
|
||||||
|
inline-size: 100%;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollingGrid {
|
||||||
|
position: relative;
|
||||||
|
flex-grow: 1;
|
||||||
|
inline-size: 100%;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixedGrid,
|
||||||
|
.scrollingGrid {
|
||||||
|
/* Disable pointer events so the overlay doesn't block interaction with
|
||||||
|
elements behind it */
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixedGrid > :not(:first-child),
|
||||||
|
.scrollingGrid > :not(:first-child) {
|
||||||
|
pointer-events: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.maximised {
|
||||||
|
position: relative;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2022 - 2023 New Vector Ltd
|
Copyright 2022 - 2024 New Vector Ltd
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -14,31 +14,29 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ResizeObserver } from "@juggle/resize-observer";
|
|
||||||
import {
|
import {
|
||||||
RoomAudioRenderer,
|
RoomAudioRenderer,
|
||||||
RoomContext,
|
RoomContext,
|
||||||
useLocalParticipant,
|
useLocalParticipant,
|
||||||
useTracks,
|
|
||||||
} from "@livekit/components-react";
|
} from "@livekit/components-react";
|
||||||
import { usePreventScroll } from "@react-aria/overlays";
|
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 { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import {
|
import {
|
||||||
FC,
|
FC,
|
||||||
ReactNode,
|
PropsWithoutRef,
|
||||||
Ref,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import useMeasure from "react-use-measure";
|
import useMeasure from "react-use-measure";
|
||||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useStateObservable } from "@react-rxjs/core";
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
|
||||||
import LogoMark from "../icons/LogoMark.svg?react";
|
import LogoMark from "../icons/LogoMark.svg?react";
|
||||||
import LogoType from "../icons/LogoType.svg?react";
|
import LogoType from "../icons/LogoType.svg?react";
|
||||||
@@ -51,21 +49,16 @@ import {
|
|||||||
SettingsButton,
|
SettingsButton,
|
||||||
} from "../button";
|
} from "../button";
|
||||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||||
import { useVideoGridLayout, VideoGrid } from "../video-grid/VideoGrid";
|
|
||||||
import { useUrlParams } from "../UrlParams";
|
import { useUrlParams } from "../UrlParams";
|
||||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||||
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
|
|
||||||
import { ElementWidgetActions, widget } from "../widget";
|
import { ElementWidgetActions, widget } from "../widget";
|
||||||
import styles from "./InCallView.module.css";
|
import styles from "./InCallView.module.css";
|
||||||
import { VideoTile } from "../video-grid/VideoTile";
|
import { GridTile } from "../tile/GridTile";
|
||||||
import { NewVideoGrid } from "../video-grid/NewVideoGrid";
|
|
||||||
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
||||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||||
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
||||||
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
||||||
import { useLiveKit } from "../livekit/useLiveKit";
|
import { useLiveKit } from "../livekit/useLiveKit";
|
||||||
import { useFullscreen } from "./useFullscreen";
|
|
||||||
import { useLayoutStates } from "../video-grid/Layout";
|
|
||||||
import { useWakeLock } from "../useWakeLock";
|
import { useWakeLock } from "../useWakeLock";
|
||||||
import { useMergedRefs } from "../useMergedRefs";
|
import { useMergedRefs } from "../useMergedRefs";
|
||||||
import { MuteStates } from "./MuteStates";
|
import { MuteStates } from "./MuteStates";
|
||||||
@@ -74,13 +67,26 @@ import { InviteButton } from "../button/InviteButton";
|
|||||||
import { LayoutToggle } from "./LayoutToggle";
|
import { LayoutToggle } from "./LayoutToggle";
|
||||||
import { ECConnectionState } from "../livekit/useECConnectionState";
|
import { ECConnectionState } from "../livekit/useECConnectionState";
|
||||||
import { useOpenIDSFU } from "../livekit/openIDSFU";
|
import { useOpenIDSFU } from "../livekit/openIDSFU";
|
||||||
import { useCallViewModel } from "../state/CallViewModel";
|
import { GridMode, Layout, useCallViewModel } from "../state/CallViewModel";
|
||||||
import { subscribe } from "../state/subscribe";
|
import { Grid, TileProps } from "../grid/Grid";
|
||||||
|
import { useObservable } from "../state/useObservable";
|
||||||
|
import { useInitial } from "../useInitial";
|
||||||
|
import { SpotlightTile } from "../tile/SpotlightTile";
|
||||||
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
|
import { makeGridLayout } from "../grid/GridLayout";
|
||||||
|
import {
|
||||||
|
CallLayoutOutputs,
|
||||||
|
TileModel,
|
||||||
|
defaultPipAlignment,
|
||||||
|
defaultSpotlightAlignment,
|
||||||
|
} from "../grid/CallLayout";
|
||||||
|
import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
|
||||||
|
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
|
||||||
|
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
|
||||||
|
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
|
||||||
|
|
||||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
|
||||||
|
|
||||||
export interface ActiveCallProps
|
export interface ActiveCallProps
|
||||||
extends Omit<InCallViewProps, "livekitRoom" | "connState"> {
|
extends Omit<InCallViewProps, "livekitRoom" | "connState"> {
|
||||||
@@ -126,342 +132,403 @@ export interface InCallViewProps {
|
|||||||
onShareClick: (() => void) | null;
|
onShareClick: (() => void) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InCallView: FC<InCallViewProps> = subscribe(
|
export const InCallView: FC<InCallViewProps> = ({
|
||||||
({
|
client,
|
||||||
client,
|
matrixInfo,
|
||||||
matrixInfo,
|
rtcSession,
|
||||||
rtcSession,
|
livekitRoom,
|
||||||
|
muteStates,
|
||||||
|
participantCount,
|
||||||
|
onLeave,
|
||||||
|
hideHeader,
|
||||||
|
connState,
|
||||||
|
onShareClick,
|
||||||
|
}) => {
|
||||||
|
usePreventScroll();
|
||||||
|
useWakeLock();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (connState === ConnectionState.Disconnected) {
|
||||||
|
// annoyingly we don't get the disconnection reason this way,
|
||||||
|
// only by listening for the emitted event
|
||||||
|
onLeave(new Error("Disconnected from call server"));
|
||||||
|
}
|
||||||
|
}, [connState, onLeave]);
|
||||||
|
|
||||||
|
const containerRef1 = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [containerRef2, bounds] = useMeasure();
|
||||||
|
const boundsValid = bounds.height > 0;
|
||||||
|
// Merge the refs so they can attach to the same element
|
||||||
|
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
||||||
|
|
||||||
|
const { hideScreensharing, showControls } = useUrlParams();
|
||||||
|
|
||||||
|
const { isScreenShareEnabled, localParticipant } = useLocalParticipant({
|
||||||
|
room: livekitRoom,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMicrophone = useCallback(
|
||||||
|
() => muteStates.audio.setEnabled?.((e) => !e),
|
||||||
|
[muteStates],
|
||||||
|
);
|
||||||
|
const toggleCamera = useCallback(
|
||||||
|
() => muteStates.video.setEnabled?.((e) => !e),
|
||||||
|
[muteStates],
|
||||||
|
);
|
||||||
|
|
||||||
|
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
|
||||||
|
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
|
||||||
|
useCallViewKeyboardShortcuts(
|
||||||
|
containerRef1,
|
||||||
|
toggleMicrophone,
|
||||||
|
toggleCamera,
|
||||||
|
(muted) => muteStates.audio.setEnabled?.(!muted),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mobile = boundsValid && bounds.width <= 660;
|
||||||
|
const reducedControls = boundsValid && bounds.width <= 340;
|
||||||
|
const noControls = reducedControls && bounds.height <= 400;
|
||||||
|
|
||||||
|
const vm = useCallViewModel(
|
||||||
|
rtcSession.room,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
muteStates,
|
matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
|
||||||
participantCount,
|
|
||||||
onLeave,
|
|
||||||
hideHeader,
|
|
||||||
otelGroupCallMembership,
|
|
||||||
connState,
|
connState,
|
||||||
onShareClick,
|
);
|
||||||
}) => {
|
const windowMode = useObservableEagerState(vm.windowMode);
|
||||||
const { t } = useTranslation();
|
const layout = useObservableEagerState(vm.layout);
|
||||||
usePreventScroll();
|
const gridMode = useObservableEagerState(vm.gridMode);
|
||||||
useWakeLock();
|
|
||||||
|
|
||||||
useEffect(() => {
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||||
if (connState === ConnectionState.Disconnected) {
|
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
||||||
// annoyingly we don't get the disconnection reason this way,
|
|
||||||
// only by listening for the emitted event
|
|
||||||
onLeave(new Error("Disconnected from call server"));
|
|
||||||
}
|
|
||||||
}, [connState, onLeave]);
|
|
||||||
|
|
||||||
const containerRef1 = useRef<HTMLDivElement | null>(null);
|
const openSettings = useCallback(
|
||||||
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
|
() => setSettingsModalOpen(true),
|
||||||
const boundsValid = bounds.height > 0;
|
[setSettingsModalOpen],
|
||||||
// Merge the refs so they can attach to the same element
|
);
|
||||||
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
const closeSettings = useCallback(
|
||||||
|
() => setSettingsModalOpen(false),
|
||||||
|
[setSettingsModalOpen],
|
||||||
|
);
|
||||||
|
|
||||||
const screenSharingTracks = useTracks(
|
const openProfile = useCallback(() => {
|
||||||
[{ source: Track.Source.ScreenShare, withPlaceholder: false }],
|
setSettingsTab("profile");
|
||||||
{
|
setSettingsModalOpen(true);
|
||||||
room: livekitRoom,
|
}, [setSettingsTab, setSettingsModalOpen]);
|
||||||
},
|
|
||||||
);
|
const [headerRef, headerBounds] = useMeasure();
|
||||||
const { layout, setLayout } = useVideoGridLayout(
|
const [footerRef, footerBounds] = useMeasure();
|
||||||
screenSharingTracks.length > 0,
|
|
||||||
|
const gridBounds = useMemo(
|
||||||
|
() => ({
|
||||||
|
width: bounds.width,
|
||||||
|
height:
|
||||||
|
bounds.height -
|
||||||
|
headerBounds.height -
|
||||||
|
(windowMode === "flat" ? 0 : footerBounds.height),
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
bounds.width,
|
||||||
|
bounds.height,
|
||||||
|
headerBounds.height,
|
||||||
|
footerBounds.height,
|
||||||
|
windowMode,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const gridBoundsObservable = useObservable(gridBounds);
|
||||||
|
|
||||||
|
const spotlightAlignment = useInitial(
|
||||||
|
() => new BehaviorSubject(defaultSpotlightAlignment),
|
||||||
|
);
|
||||||
|
const pipAlignment = useInitial(
|
||||||
|
() => new BehaviorSubject(defaultPipAlignment),
|
||||||
|
);
|
||||||
|
|
||||||
|
const setGridMode = useCallback(
|
||||||
|
(mode: GridMode) => vm.setGridMode(mode),
|
||||||
|
[vm],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
widget?.api.transport.send(
|
||||||
|
gridMode === "grid"
|
||||||
|
? ElementWidgetActions.TileLayout
|
||||||
|
: ElementWidgetActions.SpotlightLayout,
|
||||||
|
{},
|
||||||
);
|
);
|
||||||
|
}, [gridMode]);
|
||||||
|
|
||||||
const { hideScreensharing, showControls } = useUrlParams();
|
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, {});
|
||||||
|
};
|
||||||
|
|
||||||
const { isScreenShareEnabled, localParticipant } = useLocalParticipant({
|
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
|
||||||
room: livekitRoom,
|
widget.lazyActions.on(
|
||||||
});
|
ElementWidgetActions.SpotlightLayout,
|
||||||
|
onSpotlightLayout,
|
||||||
const toggleMicrophone = useCallback(
|
|
||||||
() => muteStates.audio.setEnabled?.((e) => !e),
|
|
||||||
[muteStates],
|
|
||||||
);
|
|
||||||
const toggleCamera = useCallback(
|
|
||||||
() => muteStates.video.setEnabled?.((e) => !e),
|
|
||||||
[muteStates],
|
|
||||||
);
|
|
||||||
|
|
||||||
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
|
|
||||||
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
|
|
||||||
useCallViewKeyboardShortcuts(
|
|
||||||
containerRef1,
|
|
||||||
toggleMicrophone,
|
|
||||||
toggleCamera,
|
|
||||||
(muted) => muteStates.audio.setEnabled?.(!muted),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
widget?.api.transport.send(
|
|
||||||
layout === "grid"
|
|
||||||
? ElementWidgetActions.TileLayout
|
|
||||||
: ElementWidgetActions.SpotlightLayout,
|
|
||||||
{},
|
|
||||||
);
|
);
|
||||||
}, [layout]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
return (): void => {
|
||||||
if (widget) {
|
widget!.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout);
|
||||||
const onTileLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
widget!.lazyActions.off(
|
||||||
setLayout("grid");
|
|
||||||
widget!.api.transport.reply(ev.detail, {});
|
|
||||||
};
|
|
||||||
const onSpotlightLayout = (
|
|
||||||
ev: CustomEvent<IWidgetApiRequest>,
|
|
||||||
): void => {
|
|
||||||
setLayout("spotlight");
|
|
||||||
widget!.api.transport.reply(ev.detail, {});
|
|
||||||
};
|
|
||||||
|
|
||||||
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
|
|
||||||
widget.lazyActions.on(
|
|
||||||
ElementWidgetActions.SpotlightLayout,
|
ElementWidgetActions.SpotlightLayout,
|
||||||
onSpotlightLayout,
|
onSpotlightLayout,
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [setGridMode]);
|
||||||
|
|
||||||
return (): void => {
|
const toggleSpotlightExpanded = useCallback(
|
||||||
widget!.lazyActions.off(
|
() => vm.toggleSpotlightExpanded(),
|
||||||
ElementWidgetActions.TileLayout,
|
[vm],
|
||||||
onTileLayout,
|
);
|
||||||
);
|
|
||||||
widget!.lazyActions.off(
|
|
||||||
ElementWidgetActions.SpotlightLayout,
|
|
||||||
onSpotlightLayout,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [setLayout]);
|
|
||||||
|
|
||||||
const mobile = boundsValid && bounds.width <= 660;
|
const Tile = useMemo(
|
||||||
const reducedControls = boundsValid && bounds.width <= 340;
|
() =>
|
||||||
const noControls = reducedControls && bounds.height <= 400;
|
forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
const vm = useCallViewModel(
|
PropsWithoutRef<TileProps<TileModel, HTMLDivElement>>
|
||||||
rtcSession.room,
|
>(function Tile(
|
||||||
livekitRoom,
|
{ className, style, targetWidth, targetHeight, model },
|
||||||
matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
|
ref,
|
||||||
connState,
|
) {
|
||||||
);
|
const spotlightExpanded = useObservableEagerState(vm.spotlightExpanded);
|
||||||
const items = useStateObservable(vm.tiles);
|
const showSpeakingIndicatorsValue = useObservableEagerState(
|
||||||
const { fullscreenItem, toggleFullscreen, exitFullscreen } =
|
vm.showSpeakingIndicators,
|
||||||
useFullscreen(items);
|
|
||||||
|
|
||||||
// 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 Grid =
|
|
||||||
items.length > 12 && layout === "grid" ? NewVideoGrid : VideoGrid;
|
|
||||||
|
|
||||||
const prefersReducedMotion = usePrefersReducedMotion();
|
|
||||||
|
|
||||||
// This state is lifted out of NewVideoGrid so that layout states can be
|
|
||||||
// restored after a layout switch or upon exiting fullscreen
|
|
||||||
const layoutStates = useLayoutStates();
|
|
||||||
|
|
||||||
const renderContent = (): JSX.Element => {
|
|
||||||
if (items.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className={styles.centerMessage}>
|
|
||||||
<p>{t("waiting_for_participants")}</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
const showSpotlightIndicatorsValue = useObservableEagerState(
|
||||||
if (maximisedParticipant) {
|
vm.showSpotlightIndicators,
|
||||||
return (
|
);
|
||||||
<VideoTile
|
|
||||||
vm={maximisedParticipant.data}
|
return model.type === "grid" ? (
|
||||||
maximised={true}
|
<GridTile
|
||||||
fullscreen={maximisedParticipant === fullscreenItem}
|
ref={ref}
|
||||||
onToggleFullscreen={toggleFullscreen}
|
vm={model.vm}
|
||||||
targetHeight={bounds.height}
|
|
||||||
targetWidth={bounds.width}
|
|
||||||
key={maximisedParticipant.id}
|
|
||||||
showSpeakingIndicator={false}
|
|
||||||
onOpenProfile={openProfile}
|
onOpenProfile={openProfile}
|
||||||
|
targetWidth={targetWidth}
|
||||||
|
targetHeight={targetHeight}
|
||||||
|
className={classNames(className, styles.tile)}
|
||||||
|
style={style}
|
||||||
|
showSpeakingIndicators={showSpeakingIndicatorsValue}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SpotlightTile
|
||||||
|
ref={ref}
|
||||||
|
vms={model.vms}
|
||||||
|
maximised={model.maximised}
|
||||||
|
expanded={spotlightExpanded}
|
||||||
|
onToggleExpanded={toggleSpotlightExpanded}
|
||||||
|
targetWidth={targetWidth}
|
||||||
|
targetHeight={targetHeight}
|
||||||
|
showIndicators={showSpotlightIndicatorsValue}
|
||||||
|
className={classNames(className, styles.tile)}
|
||||||
|
style={style}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}),
|
||||||
|
[vm, toggleSpotlightExpanded, openProfile],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
const layouts = useMemo(() => {
|
||||||
<Grid
|
const inputs = {
|
||||||
items={items}
|
minBounds: gridBoundsObservable,
|
||||||
layout={layout}
|
spotlightAlignment,
|
||||||
disableAnimations={prefersReducedMotion || isSafari}
|
pipAlignment,
|
||||||
layoutStates={layoutStates}
|
|
||||||
>
|
|
||||||
{({ data: vm, ...props }): ReactNode => (
|
|
||||||
<VideoTile
|
|
||||||
vm={vm}
|
|
||||||
maximised={false}
|
|
||||||
fullscreen={false}
|
|
||||||
onToggleFullscreen={toggleFullscreen}
|
|
||||||
showSpeakingIndicator={items.length > 2}
|
|
||||||
onOpenProfile={openProfile}
|
|
||||||
{...props}
|
|
||||||
ref={props.ref as Ref<HTMLDivElement>}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
return {
|
||||||
|
grid: makeGridLayout(inputs),
|
||||||
|
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
|
||||||
|
"spotlight-portrait": makeSpotlightPortraitLayout(inputs),
|
||||||
|
"spotlight-expanded": makeSpotlightExpandedLayout(inputs),
|
||||||
|
"one-on-one": makeOneOnOneLayout(inputs),
|
||||||
|
};
|
||||||
|
}, [gridBoundsObservable, spotlightAlignment, pipAlignment]);
|
||||||
|
|
||||||
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
const renderContent = (): JSX.Element => {
|
||||||
rtcSession.room.roomId,
|
if (layout.type === "pip") {
|
||||||
);
|
return (
|
||||||
|
<SpotlightTile
|
||||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
className={classNames(styles.tile, styles.maximised)}
|
||||||
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
vms={layout.spotlight!}
|
||||||
|
maximised
|
||||||
const openSettings = useCallback(
|
expanded
|
||||||
() => setSettingsModalOpen(true),
|
onToggleExpanded={null}
|
||||||
[setSettingsModalOpen],
|
targetWidth={gridBounds.height}
|
||||||
);
|
targetHeight={gridBounds.width}
|
||||||
const closeSettings = useCallback(
|
showIndicators={false}
|
||||||
() => setSettingsModalOpen(false),
|
/>
|
||||||
[setSettingsModalOpen],
|
|
||||||
);
|
|
||||||
|
|
||||||
const openProfile = useCallback(() => {
|
|
||||||
setSettingsTab("profile");
|
|
||||||
setSettingsModalOpen(true);
|
|
||||||
}, [setSettingsTab, setSettingsModalOpen]);
|
|
||||||
|
|
||||||
const toggleScreensharing = useCallback(async () => {
|
|
||||||
exitFullscreen();
|
|
||||||
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, {
|
|
||||||
audio: true,
|
|
||||||
selfBrowserSurface: "include",
|
|
||||||
surfaceSwitching: "include",
|
|
||||||
systemAudio: "include",
|
|
||||||
});
|
|
||||||
}, [localParticipant, isScreenShareEnabled, exitFullscreen]);
|
|
||||||
|
|
||||||
let footer: JSX.Element | null;
|
|
||||||
|
|
||||||
if (noControls) {
|
|
||||||
footer = null;
|
|
||||||
} else {
|
|
||||||
const buttons: JSX.Element[] = [];
|
|
||||||
|
|
||||||
buttons.push(
|
|
||||||
<MicButton
|
|
||||||
key="1"
|
|
||||||
muted={!muteStates.audio.enabled}
|
|
||||||
onPress={toggleMicrophone}
|
|
||||||
disabled={muteStates.audio.setEnabled === null}
|
|
||||||
data-testid="incall_mute"
|
|
||||||
/>,
|
|
||||||
<VideoButton
|
|
||||||
key="2"
|
|
||||||
muted={!muteStates.video.enabled}
|
|
||||||
onPress={toggleCamera}
|
|
||||||
disabled={muteStates.video.setEnabled === null}
|
|
||||||
data-testid="incall_videomute"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!reducedControls) {
|
|
||||||
if (canScreenshare && !hideScreensharing) {
|
|
||||||
buttons.push(
|
|
||||||
<ScreenshareButton
|
|
||||||
key="3"
|
|
||||||
enabled={isScreenShareEnabled}
|
|
||||||
onPress={toggleScreensharing}
|
|
||||||
data-testid="incall_screenshare"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
buttons.push(<SettingsButton key="4" onPress={openSettings} />);
|
|
||||||
}
|
|
||||||
|
|
||||||
buttons.push(
|
|
||||||
<HangupButton
|
|
||||||
key="6"
|
|
||||||
onPress={function (): void {
|
|
||||||
onLeave();
|
|
||||||
}}
|
|
||||||
data-testid="incall_leave"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
footer = (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
showControls
|
|
||||||
? styles.footer
|
|
||||||
: hideHeader
|
|
||||||
? [styles.footer, styles.footerHidden]
|
|
||||||
: [styles.footer, styles.footerThin],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!mobile && !hideHeader && (
|
|
||||||
<div className={styles.logo}>
|
|
||||||
<LogoMark width={24} height={24} aria-hidden />
|
|
||||||
<LogoType
|
|
||||||
width={80}
|
|
||||||
height={11}
|
|
||||||
aria-label={import.meta.env.VITE_PRODUCT_NAME || "Element Call"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showControls && <div className={styles.buttons}>{buttons}</div>}
|
|
||||||
{!mobile && !hideHeader && showControls && (
|
|
||||||
<LayoutToggle
|
|
||||||
className={styles.layout}
|
|
||||||
layout={layout}
|
|
||||||
setLayout={setLayout}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const layers = layouts[layout.type] as CallLayoutOutputs<Layout>;
|
||||||
<div className={styles.inRoom} ref={containerRef}>
|
const fixedGrid = (
|
||||||
{!hideHeader && maximisedParticipant === null && (
|
<Grid
|
||||||
<Header>
|
key="fixed"
|
||||||
<LeftNav>
|
className={styles.fixedGrid}
|
||||||
<RoomHeaderInfo
|
style={{
|
||||||
id={matrixInfo.roomId}
|
insetBlockStart: headerBounds.bottom,
|
||||||
name={matrixInfo.roomName}
|
height: gridBounds.height,
|
||||||
avatarUrl={matrixInfo.roomAvatar}
|
}}
|
||||||
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
|
model={layout}
|
||||||
participantCount={participantCount}
|
Layout={layers.fixed}
|
||||||
/>
|
Tile={Tile}
|
||||||
</LeftNav>
|
/>
|
||||||
<RightNav>
|
);
|
||||||
{!reducedControls && showControls && onShareClick !== null && (
|
const scrollingGrid = (
|
||||||
<InviteButton onClick={onShareClick} />
|
<Grid
|
||||||
)}
|
key="scrolling"
|
||||||
</RightNav>
|
className={styles.scrollingGrid}
|
||||||
</Header>
|
model={layout}
|
||||||
|
Layout={layers.scrolling}
|
||||||
|
Tile={Tile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
// The grid tiles go *under* the spotlight in the portrait layout, but
|
||||||
|
// *over* the spotlight in the expanded layout
|
||||||
|
return layout.type === "spotlight-expanded" ? (
|
||||||
|
<>
|
||||||
|
{fixedGrid}
|
||||||
|
{scrollingGrid}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{scrollingGrid}
|
||||||
|
{fixedGrid}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
||||||
|
rtcSession.room.roomId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleScreensharing = useCallback(async () => {
|
||||||
|
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, {
|
||||||
|
audio: true,
|
||||||
|
selfBrowserSurface: "include",
|
||||||
|
surfaceSwitching: "include",
|
||||||
|
systemAudio: "include",
|
||||||
|
});
|
||||||
|
}, [localParticipant, isScreenShareEnabled]);
|
||||||
|
|
||||||
|
let footer: JSX.Element | null;
|
||||||
|
|
||||||
|
if (noControls) {
|
||||||
|
footer = null;
|
||||||
|
} else {
|
||||||
|
const buttons: JSX.Element[] = [];
|
||||||
|
|
||||||
|
buttons.push(
|
||||||
|
<MicButton
|
||||||
|
key="1"
|
||||||
|
muted={!muteStates.audio.enabled}
|
||||||
|
onPress={toggleMicrophone}
|
||||||
|
disabled={muteStates.audio.setEnabled === null}
|
||||||
|
data-testid="incall_mute"
|
||||||
|
/>,
|
||||||
|
<VideoButton
|
||||||
|
key="2"
|
||||||
|
muted={!muteStates.video.enabled}
|
||||||
|
onPress={toggleCamera}
|
||||||
|
disabled={muteStates.video.setEnabled === null}
|
||||||
|
data-testid="incall_videomute"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!reducedControls) {
|
||||||
|
if (canScreenshare && !hideScreensharing) {
|
||||||
|
buttons.push(
|
||||||
|
<ScreenshareButton
|
||||||
|
key="3"
|
||||||
|
enabled={isScreenShareEnabled}
|
||||||
|
onPress={toggleScreensharing}
|
||||||
|
data-testid="incall_screenshare"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
buttons.push(<SettingsButton key="4" onPress={openSettings} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons.push(
|
||||||
|
<HangupButton
|
||||||
|
key="6"
|
||||||
|
onPress={function (): void {
|
||||||
|
onLeave();
|
||||||
|
}}
|
||||||
|
data-testid="incall_leave"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
footer = (
|
||||||
|
<div
|
||||||
|
ref={footerRef}
|
||||||
|
className={classNames(
|
||||||
|
styles.footer,
|
||||||
|
!showControls &&
|
||||||
|
(hideHeader ? styles.footerHidden : styles.footerThin),
|
||||||
|
{ [styles.overlay]: windowMode === "flat" },
|
||||||
)}
|
)}
|
||||||
<div className={styles.controlsOverlay}>
|
>
|
||||||
<RoomAudioRenderer />
|
{!mobile && !hideHeader && (
|
||||||
{renderContent()}
|
<div className={styles.logo}>
|
||||||
{footer}
|
<LogoMark width={24} height={24} aria-hidden />
|
||||||
</div>
|
<LogoType
|
||||||
{!noControls && (
|
width={80}
|
||||||
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
height={11}
|
||||||
|
aria-label={import.meta.env.VITE_PRODUCT_NAME || "Element Call"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showControls && <div className={styles.buttons}>{buttons}</div>}
|
||||||
|
{!mobile && !hideHeader && showControls && (
|
||||||
|
<LayoutToggle
|
||||||
|
className={styles.layout}
|
||||||
|
layout={gridMode}
|
||||||
|
setLayout={setGridMode}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<SettingsModal
|
|
||||||
client={client}
|
|
||||||
roomId={rtcSession.room.roomId}
|
|
||||||
open={settingsModalOpen}
|
|
||||||
onDismiss={closeSettings}
|
|
||||||
tab={settingsTab}
|
|
||||||
onTabChange={setSettingsTab}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
return (
|
||||||
|
<div className={styles.inRoom} ref={containerRef}>
|
||||||
|
{!hideHeader && windowMode !== "pip" && windowMode !== "flat" && (
|
||||||
|
<Header className={styles.header} ref={headerRef}>
|
||||||
|
<LeftNav>
|
||||||
|
<RoomHeaderInfo
|
||||||
|
id={matrixInfo.roomId}
|
||||||
|
name={matrixInfo.roomName}
|
||||||
|
avatarUrl={matrixInfo.roomAvatar}
|
||||||
|
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
|
||||||
|
participantCount={participantCount}
|
||||||
|
/>
|
||||||
|
</LeftNav>
|
||||||
|
<RightNav>
|
||||||
|
{!reducedControls && showControls && onShareClick !== null && (
|
||||||
|
<InviteButton onClick={onShareClick} />
|
||||||
|
)}
|
||||||
|
</RightNav>
|
||||||
|
</Header>
|
||||||
|
)}
|
||||||
|
<RoomAudioRenderer />
|
||||||
|
{renderContent()}
|
||||||
|
{footer}
|
||||||
|
{!noControls && <RageshakeRequestModal {...rageshakeRequestModalProps} />}
|
||||||
|
<SettingsModal
|
||||||
|
client={client}
|
||||||
|
roomId={rtcSession.room.roomId}
|
||||||
|
open={settingsModalOpen}
|
||||||
|
onDismiss={closeSettings}
|
||||||
|
tab={settingsTab}
|
||||||
|
onTabChange={setSettingsTab}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import { GroupCallLoader } from "./GroupCallLoader";
|
|||||||
import { GroupCallView } from "./GroupCallView";
|
import { GroupCallView } from "./GroupCallView";
|
||||||
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
||||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||||
import { useOptInAnalytics } from "../settings/useSetting";
|
|
||||||
import { HomePage } from "../home/HomePage";
|
import { HomePage } from "../home/HomePage";
|
||||||
import { platform } from "../Platform";
|
import { platform } from "../Platform";
|
||||||
import { AppSelectionModal } from "./AppSelectionModal";
|
import { AppSelectionModal } from "./AppSelectionModal";
|
||||||
@@ -36,6 +35,10 @@ import { LobbyView } from "./LobbyView";
|
|||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
import { useProfile } from "../profile/useProfile";
|
import { useProfile } from "../profile/useProfile";
|
||||||
import { useMuteStates } from "./MuteStates";
|
import { useMuteStates } from "./MuteStates";
|
||||||
|
import {
|
||||||
|
useSetting,
|
||||||
|
optInAnalytics as optInAnalyticsSetting,
|
||||||
|
} from "../settings/settings";
|
||||||
|
|
||||||
export const RoomPage: FC = () => {
|
export const RoomPage: FC = () => {
|
||||||
const {
|
const {
|
||||||
@@ -80,7 +83,7 @@ export const RoomPage: FC = () => {
|
|||||||
registerPasswordlessUser,
|
registerPasswordlessUser,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
|
const [optInAnalytics, setOptInAnalytics] = useSetting(optInAnalyticsSetting);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// During the beta, opt into analytics by default
|
// During the beta, opt into analytics by default
|
||||||
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
|
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
|
||||||
|
|||||||
@@ -18,20 +18,12 @@ limitations under the License.
|
|||||||
margin-inline: var(--inline-content-inset);
|
margin-inline: var(--inline-content-inset);
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
block-size: 50vh;
|
block-size: 50vh;
|
||||||
}
|
border-radius: var(--cpd-space-4x);
|
||||||
|
|
||||||
.preview.content {
|
|
||||||
margin-inline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
block-size: 100%;
|
|
||||||
inline-size: 100%;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content video {
|
.preview > video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
@@ -69,12 +61,20 @@ limitations under the License.
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview.content .buttonBar {
|
|
||||||
padding-inline: var(--inline-content-inset);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-aspect-ratio: 1 / 1) {
|
@media (min-aspect-ratio: 1 / 1) {
|
||||||
.preview video {
|
.preview > video {
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 550px) {
|
||||||
|
.preview {
|
||||||
|
margin-inline: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonBar {
|
||||||
|
padding-inline: var(--inline-content-inset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2022 - 2023 New Vector Ltd
|
Copyright 2022 - 2024 New Vector Ltd
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -18,20 +18,15 @@ import { useEffect, useMemo, useRef, FC, ReactNode, useCallback } from "react";
|
|||||||
import useMeasure from "react-use-measure";
|
import useMeasure from "react-use-measure";
|
||||||
import { ResizeObserver } from "@juggle/resize-observer";
|
import { ResizeObserver } from "@juggle/resize-observer";
|
||||||
import { usePreviewTracks } from "@livekit/components-react";
|
import { usePreviewTracks } from "@livekit/components-react";
|
||||||
import {
|
import { LocalVideoTrack, Track } from "livekit-client";
|
||||||
CreateLocalTracksOptions,
|
|
||||||
LocalVideoTrack,
|
|
||||||
Track,
|
|
||||||
} from "livekit-client";
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { Glass } from "@vector-im/compound-web";
|
|
||||||
|
|
||||||
import { Avatar } from "../Avatar";
|
import { Avatar } from "../Avatar";
|
||||||
import styles from "./VideoPreview.module.css";
|
import styles from "./VideoPreview.module.css";
|
||||||
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||||
import { MuteStates } from "./MuteStates";
|
import { MuteStates } from "./MuteStates";
|
||||||
import { useMediaQuery } from "../useMediaQuery";
|
import { useInitial } from "../useInitial";
|
||||||
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
|
|
||||||
export type MatrixInfo = {
|
export type MatrixInfo = {
|
||||||
@@ -63,10 +58,10 @@ export const VideoPreview: FC<Props> = ({
|
|||||||
// Capture the audio options as they were when we first mounted, because
|
// Capture the audio options as they were when we first mounted, because
|
||||||
// we're not doing anything with the audio anyway so we don't need to
|
// we're not doing anything with the audio anyway so we don't need to
|
||||||
// re-open the devices when they change (see below).
|
// re-open the devices when they change (see below).
|
||||||
const initialAudioOptions = useRef<CreateLocalTracksOptions["audio"]>();
|
const initialAudioOptions = useInitial(
|
||||||
initialAudioOptions.current ??= muteStates.audio.enabled && {
|
() =>
|
||||||
deviceId: devices.audioInput.selectedId,
|
muteStates.audio.enabled && { deviceId: devices.audioInput.selectedId },
|
||||||
};
|
);
|
||||||
|
|
||||||
const localTrackOptions = useMemo(
|
const localTrackOptions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -76,12 +71,16 @@ export const VideoPreview: FC<Props> = ({
|
|||||||
// reference the initial values here.
|
// reference the initial values here.
|
||||||
// We also pass in a clone because livekit mutates the object passed in,
|
// We also pass in a clone because livekit mutates the object passed in,
|
||||||
// which would cause the devices to be re-opened on the next render.
|
// which would cause the devices to be re-opened on the next render.
|
||||||
audio: Object.assign({}, initialAudioOptions.current),
|
audio: Object.assign({}, initialAudioOptions),
|
||||||
video: muteStates.video.enabled && {
|
video: muteStates.video.enabled && {
|
||||||
deviceId: devices.videoInput.selectedId,
|
deviceId: devices.videoInput.selectedId,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[devices.videoInput.selectedId, muteStates.video.enabled],
|
[
|
||||||
|
initialAudioOptions,
|
||||||
|
devices.videoInput.selectedId,
|
||||||
|
muteStates.video.enabled,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onError = useCallback(
|
const onError = useCallback(
|
||||||
@@ -115,8 +114,8 @@ export const VideoPreview: FC<Props> = ({
|
|||||||
};
|
};
|
||||||
}, [videoTrack]);
|
}, [videoTrack]);
|
||||||
|
|
||||||
const content = (
|
return (
|
||||||
<>
|
<div className={classNames(styles.preview)} ref={previewRef}>
|
||||||
<video
|
<video
|
||||||
ref={videoEl}
|
ref={videoEl}
|
||||||
muted
|
muted
|
||||||
@@ -136,21 +135,6 @@ export const VideoPreview: FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.buttonBar}>{children}</div>
|
<div className={styles.buttonBar}>{children}</div>
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return useMediaQuery("(max-width: 550px)") ? (
|
|
||||||
<div
|
|
||||||
className={classNames(styles.preview, styles.content)}
|
|
||||||
ref={previewRef}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<Glass className={styles.preview}>
|
|
||||||
<div className={styles.content} ref={previewRef}>
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
</Glass>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { useCallback, useLayoutEffect, useRef } from "react";
|
|||||||
|
|
||||||
import { useReactiveState } from "../useReactiveState";
|
import { useReactiveState } from "../useReactiveState";
|
||||||
import { useEventTarget } from "../useEvents";
|
import { useEventTarget } from "../useEvents";
|
||||||
import { TileDescriptor } from "../state/CallViewModel";
|
|
||||||
|
|
||||||
const isFullscreen = (): boolean =>
|
const isFullscreen = (): boolean =>
|
||||||
Boolean(document.fullscreenElement) ||
|
Boolean(document.fullscreenElement) ||
|
||||||
@@ -55,31 +54,30 @@ function useFullscreenChange(onFullscreenChange: () => void): void {
|
|||||||
* Provides callbacks for controlling the full-screen view, which can hold one
|
* Provides callbacks for controlling the full-screen view, which can hold one
|
||||||
* item at a time.
|
* item at a time.
|
||||||
*/
|
*/
|
||||||
export function useFullscreen<T>(items: TileDescriptor<T>[]): {
|
// TODO: Simplify this. Nowadays we only allow the spotlight to be fullscreen,
|
||||||
fullscreenItem: TileDescriptor<T> | null;
|
// so we don't need to bother with multiple items.
|
||||||
|
export function useFullscreen(items: string[]): {
|
||||||
|
fullscreenItem: string | null;
|
||||||
toggleFullscreen: (itemId: string) => void;
|
toggleFullscreen: (itemId: string) => void;
|
||||||
exitFullscreen: () => void;
|
exitFullscreen: () => void;
|
||||||
} {
|
} {
|
||||||
const [fullscreenItem, setFullscreenItem] =
|
const [fullscreenItem, setFullscreenItem] = useReactiveState<string | null>(
|
||||||
useReactiveState<TileDescriptor<T> | null>(
|
(prevItem) =>
|
||||||
(prevItem) =>
|
prevItem == null ? null : (items.find((i) => i === prevItem) ?? null),
|
||||||
prevItem == null
|
[items],
|
||||||
? null
|
);
|
||||||
: (items.find((i) => i.id === prevItem.id) ?? null),
|
|
||||||
[items],
|
|
||||||
);
|
|
||||||
|
|
||||||
const latestItems = useRef<TileDescriptor<T>[]>(items);
|
const latestItems = useRef<string[]>(items);
|
||||||
latestItems.current = items;
|
latestItems.current = items;
|
||||||
|
|
||||||
const latestFullscreenItem = useRef<TileDescriptor<T> | null>(fullscreenItem);
|
const latestFullscreenItem = useRef<string | null>(fullscreenItem);
|
||||||
latestFullscreenItem.current = fullscreenItem;
|
latestFullscreenItem.current = fullscreenItem;
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(
|
const toggleFullscreen = useCallback(
|
||||||
(itemId: string) => {
|
(itemId: string) => {
|
||||||
setFullscreenItem(
|
setFullscreenItem(
|
||||||
latestFullscreenItem.current === null
|
latestFullscreenItem.current === null
|
||||||
? (latestItems.current.find((i) => i.id === itemId) ?? null)
|
? (latestItems.current.find((i) => i === itemId) ?? null)
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ChangeEvent, FC, Key, ReactNode } from "react";
|
import { ChangeEvent, FC, Key, ReactNode, useCallback } from "react";
|
||||||
import { Item } from "@react-stately/collections";
|
import { Item } from "@react-stately/collections";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { MatrixClient } from "matrix-js-sdk";
|
import { MatrixClient } from "matrix-js-sdk";
|
||||||
@@ -29,12 +29,6 @@ import OverflowIcon from "../icons/Overflow.svg?react";
|
|||||||
import UserIcon from "../icons/User.svg?react";
|
import UserIcon from "../icons/User.svg?react";
|
||||||
import FeedbackIcon from "../icons/Feedback.svg?react";
|
import FeedbackIcon from "../icons/Feedback.svg?react";
|
||||||
import { SelectInput } from "../input/SelectInput";
|
import { SelectInput } from "../input/SelectInput";
|
||||||
import {
|
|
||||||
useOptInAnalytics,
|
|
||||||
useDeveloperSettingsTab,
|
|
||||||
useShowConnectionStats,
|
|
||||||
isFirefox,
|
|
||||||
} from "./useSetting";
|
|
||||||
import { FieldRow, InputField } from "../input/Input";
|
import { FieldRow, InputField } from "../input/Input";
|
||||||
import { Body, Caption } from "../typography/Typography";
|
import { Body, Caption } from "../typography/Typography";
|
||||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||||
@@ -46,6 +40,13 @@ import {
|
|||||||
useMediaDeviceNames,
|
useMediaDeviceNames,
|
||||||
} from "../livekit/MediaDevicesContext";
|
} from "../livekit/MediaDevicesContext";
|
||||||
import { widget } from "../widget";
|
import { widget } from "../widget";
|
||||||
|
import {
|
||||||
|
useSetting,
|
||||||
|
optInAnalytics as optInAnalyticsSetting,
|
||||||
|
developerSettingsTab as developerSettingsTabSetting,
|
||||||
|
duplicateTiles as duplicateTilesSetting,
|
||||||
|
} from "./settings";
|
||||||
|
import { isFirefox } from "../Platform";
|
||||||
|
|
||||||
type SettingsTab =
|
type SettingsTab =
|
||||||
| "audio"
|
| "audio"
|
||||||
@@ -76,11 +77,11 @@ export const SettingsModal: FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
|
const [optInAnalytics, setOptInAnalytics] = useSetting(optInAnalyticsSetting);
|
||||||
const [developerSettingsTab, setDeveloperSettingsTab] =
|
const [developerSettingsTab, setDeveloperSettingsTab] = useSetting(
|
||||||
useDeveloperSettingsTab();
|
developerSettingsTabSetting,
|
||||||
const [showConnectionStats, setShowConnectionStats] =
|
);
|
||||||
useShowConnectionStats();
|
const [duplicateTiles, setDuplicateTiles] = useSetting(duplicateTilesSetting);
|
||||||
|
|
||||||
// Generate a `SelectInput` with a list of devices for a given device kind.
|
// Generate a `SelectInput` with a list of devices for a given device kind.
|
||||||
const generateDeviceSelection = (
|
const generateDeviceSelection = (
|
||||||
@@ -247,14 +248,16 @@ export const SettingsModal: FC<Props> = ({
|
|||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<InputField
|
<InputField
|
||||||
id="showConnectionStats"
|
id="duplicateTiles"
|
||||||
name="connection-stats"
|
type="number"
|
||||||
label={t("settings.show_connection_stats_label")}
|
label={t("settings.duplicate_tiles_label")}
|
||||||
type="checkbox"
|
value={duplicateTiles.toString()}
|
||||||
checked={showConnectionStats}
|
onChange={useCallback(
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
(event: ChangeEvent<HTMLInputElement>): void => {
|
||||||
setShowConnectionStats(e.target.checked)
|
setDuplicateTiles(event.target.valueAsNumber);
|
||||||
}
|
},
|
||||||
|
[setDuplicateTiles],
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|||||||
94
src/settings/settings.ts
Normal file
94
src/settings/settings.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { BehaviorSubject, Observable } from "rxjs";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
|
||||||
|
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||||
|
|
||||||
|
export class Setting<T> {
|
||||||
|
public constructor(key: string, defaultValue: T) {
|
||||||
|
this.key = `matrix-setting-${key}`;
|
||||||
|
|
||||||
|
const storedValue = localStorage.getItem(this.key);
|
||||||
|
let initialValue = defaultValue;
|
||||||
|
if (storedValue !== null) {
|
||||||
|
try {
|
||||||
|
initialValue = JSON.parse(storedValue);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`Invalid value stored for setting ${key}: ${storedValue}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._value = new BehaviorSubject(initialValue);
|
||||||
|
this.value = this._value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly key: string;
|
||||||
|
|
||||||
|
private readonly _value: BehaviorSubject<T>;
|
||||||
|
public readonly value: Observable<T>;
|
||||||
|
|
||||||
|
public readonly setValue = (value: T): void => {
|
||||||
|
this._value.next(value);
|
||||||
|
localStorage.setItem(this.key, JSON.stringify(value));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook that returns a settings's current value and a setter.
|
||||||
|
*/
|
||||||
|
export function useSetting<T>(setting: Setting<T>): [T, (value: T) => void] {
|
||||||
|
return [useObservableEagerState(setting.value), setting.setValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
// null = undecided
|
||||||
|
export const optInAnalytics = new Setting<boolean | null>(
|
||||||
|
"opt-in-analytics",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
// TODO: This setting can be disabled. Work out an approach to disableable
|
||||||
|
// settings thats works for Observables in addition to React.
|
||||||
|
export const useOptInAnalytics = (): [
|
||||||
|
boolean | null,
|
||||||
|
((value: boolean | null) => void) | null,
|
||||||
|
] => {
|
||||||
|
const setting = useSetting(optInAnalytics);
|
||||||
|
return PosthogAnalytics.instance.isEnabled() ? setting : [false, null];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const developerSettingsTab = new Setting(
|
||||||
|
"developer-settings-tab",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const duplicateTiles = new Setting("duplicate-tiles", 0);
|
||||||
|
|
||||||
|
export const audioInput = new Setting<string | undefined>(
|
||||||
|
"audio-input",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
export const audioOutput = new Setting<string | undefined>(
|
||||||
|
"audio-output",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
export const videoInput = new Setting<string | undefined>(
|
||||||
|
"video-input",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2022 - 2023 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 { useCallback, useMemo } from "react";
|
|
||||||
|
|
||||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
|
||||||
import {
|
|
||||||
getLocalStorageItem,
|
|
||||||
setLocalStorageItem,
|
|
||||||
useLocalStorage,
|
|
||||||
} from "../useLocalStorage";
|
|
||||||
|
|
||||||
type Setting<T> = [T, (value: T) => void];
|
|
||||||
type DisableableSetting<T> = [T, ((value: T) => void) | null];
|
|
||||||
|
|
||||||
export const getSettingKey = (name: string): string => {
|
|
||||||
return `matrix-setting-${name}`;
|
|
||||||
};
|
|
||||||
// Like useState, but reads from and persists the value to localStorage
|
|
||||||
export const useSetting = <T>(name: string, defaultValue: T): Setting<T> => {
|
|
||||||
const key = useMemo(() => getSettingKey(name), [name]);
|
|
||||||
|
|
||||||
const [item, setItem] = useLocalStorage(key);
|
|
||||||
|
|
||||||
const value = useMemo(
|
|
||||||
() => (item == null ? defaultValue : JSON.parse(item)),
|
|
||||||
[item, defaultValue],
|
|
||||||
);
|
|
||||||
const setValue = useCallback(
|
|
||||||
(value: T) => {
|
|
||||||
setItem(JSON.stringify(value));
|
|
||||||
},
|
|
||||||
[setItem],
|
|
||||||
);
|
|
||||||
|
|
||||||
return [value, setValue];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSetting = <T>(name: string, defaultValue: T): T => {
|
|
||||||
const item = getLocalStorageItem(getSettingKey(name));
|
|
||||||
return item === null ? defaultValue : JSON.parse(item);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setSetting = <T>(name: string, newValue: T): void =>
|
|
||||||
setLocalStorageItem(getSettingKey(name), JSON.stringify(newValue));
|
|
||||||
|
|
||||||
export const isFirefox = (): boolean => {
|
|
||||||
const { userAgent } = navigator;
|
|
||||||
return userAgent.includes("Firefox");
|
|
||||||
};
|
|
||||||
|
|
||||||
const canEnableSpatialAudio = (): boolean => {
|
|
||||||
// Spatial audio means routing audio through audio contexts. On Chrome,
|
|
||||||
// this bypasses the AEC processor and so breaks echo cancellation.
|
|
||||||
// We only allow spatial audio to be enabled on Firefox which we know
|
|
||||||
// passes audio context audio through the AEC algorithm.
|
|
||||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=687574 is the
|
|
||||||
// chrome bug for this: once this is fixed and the updated version is deployed
|
|
||||||
// widely enough, we can allow spatial audio everywhere. It's currently in a
|
|
||||||
// chrome flag, so we could enable this in Electron if we enabled the chrome flag
|
|
||||||
// in the Electron wrapper.
|
|
||||||
return isFirefox();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSpatialAudio = (): DisableableSetting<boolean> => {
|
|
||||||
const settingVal = useSetting("spatial-audio", false);
|
|
||||||
if (canEnableSpatialAudio()) return settingVal;
|
|
||||||
|
|
||||||
return [false, null];
|
|
||||||
};
|
|
||||||
|
|
||||||
// null = undecided
|
|
||||||
export const useOptInAnalytics = (): DisableableSetting<boolean | null> => {
|
|
||||||
const settingVal = useSetting<boolean | null>("opt-in-analytics", null);
|
|
||||||
if (PosthogAnalytics.instance.isEnabled()) return settingVal;
|
|
||||||
|
|
||||||
return [false, null];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDeveloperSettingsTab = (): Setting<boolean> =>
|
|
||||||
useSetting("developer-settings-tab", false);
|
|
||||||
|
|
||||||
export const useShowConnectionStats = (): Setting<boolean> =>
|
|
||||||
useSetting("show-connection-stats", false);
|
|
||||||
|
|
||||||
export const useAudioInput = (): Setting<string | undefined> =>
|
|
||||||
useSetting<string | undefined>("audio-input", undefined);
|
|
||||||
export const useAudioOutput = (): Setting<string | undefined> =>
|
|
||||||
useSetting<string | undefined>("audio-output", undefined);
|
|
||||||
export const useVideoInput = (): Setting<string | undefined> =>
|
|
||||||
useSetting<string | undefined>("video-input", undefined);
|
|
||||||
@@ -28,14 +28,15 @@ import {
|
|||||||
import { Room as MatrixRoom, RoomMember } from "matrix-js-sdk/src/matrix";
|
import { Room as MatrixRoom, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
|
||||||
EMPTY,
|
EMPTY,
|
||||||
Observable,
|
Observable,
|
||||||
|
Subject,
|
||||||
audit,
|
audit,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
concat,
|
concat,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
filter,
|
filter,
|
||||||
|
fromEvent,
|
||||||
map,
|
map,
|
||||||
merge,
|
merge,
|
||||||
mergeAll,
|
mergeAll,
|
||||||
@@ -43,14 +44,13 @@ import {
|
|||||||
sample,
|
sample,
|
||||||
scan,
|
scan,
|
||||||
shareReplay,
|
shareReplay,
|
||||||
|
skip,
|
||||||
startWith,
|
startWith,
|
||||||
switchAll,
|
|
||||||
switchMap,
|
switchMap,
|
||||||
throttleTime,
|
throttleTime,
|
||||||
timer,
|
timer,
|
||||||
zip,
|
zip,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { StateObservable, state } from "@react-rxjs/core";
|
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import { ViewModel } from "./ViewModel";
|
import { ViewModel } from "./ViewModel";
|
||||||
@@ -61,32 +61,26 @@ import {
|
|||||||
} from "../livekit/useECConnectionState";
|
} from "../livekit/useECConnectionState";
|
||||||
import { usePrevious } from "../usePrevious";
|
import { usePrevious } from "../usePrevious";
|
||||||
import {
|
import {
|
||||||
|
LocalUserMediaViewModel,
|
||||||
MediaViewModel,
|
MediaViewModel,
|
||||||
UserMediaViewModel,
|
RemoteUserMediaViewModel,
|
||||||
ScreenShareViewModel,
|
ScreenShareViewModel,
|
||||||
|
UserMediaViewModel,
|
||||||
} from "./MediaViewModel";
|
} from "./MediaViewModel";
|
||||||
import { finalizeValue } from "../observable-utils";
|
import { accumulate, finalizeValue } from "../observable-utils";
|
||||||
import { ObservableScope } from "./ObservableScope";
|
import { ObservableScope } from "./ObservableScope";
|
||||||
|
import { duplicateTiles } from "../settings/settings";
|
||||||
|
|
||||||
// How long we wait after a focus switch before showing the real participant
|
// How long we wait after a focus switch before showing the real participant
|
||||||
// list again
|
// list again
|
||||||
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
|
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
|
||||||
|
|
||||||
// Represents something that should get a tile on the layout,
|
// This is the number of participants that we think constitutes a "large" grid.
|
||||||
// ie. a user's video feed or a screen share feed.
|
// The hypothesis is that, after this many participants there's enough cognitive
|
||||||
// TODO: This exposes too much information to the view layer, let's keep this
|
// load that it makes sense to show the speaker in an easy-to-locate spotlight
|
||||||
// information internal to the view model and switch to using Tile<T> instead
|
// tile. We might change this to a scroll-based condition or do something else
|
||||||
export interface TileDescriptor<T> {
|
// entirely with the spotlight tile, if we workshop this further.
|
||||||
id: string;
|
const largeGridThreshold = 20;
|
||||||
focused: boolean;
|
|
||||||
isPresenter: boolean;
|
|
||||||
isSpeaker: boolean;
|
|
||||||
hasVideo: boolean;
|
|
||||||
local: boolean;
|
|
||||||
largeBaseSize: boolean;
|
|
||||||
placeNear?: string;
|
|
||||||
data: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GridLayout {
|
export interface GridLayout {
|
||||||
type: "grid";
|
type: "grid";
|
||||||
@@ -94,18 +88,30 @@ export interface GridLayout {
|
|||||||
grid: UserMediaViewModel[];
|
grid: UserMediaViewModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpotlightLayout {
|
export interface SpotlightLandscapeLayout {
|
||||||
type: "spotlight";
|
type: "spotlight-landscape";
|
||||||
spotlight: MediaViewModel[];
|
spotlight: MediaViewModel[];
|
||||||
grid: UserMediaViewModel[];
|
grid: UserMediaViewModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FullScreenLayout {
|
export interface SpotlightPortraitLayout {
|
||||||
type: "full screen";
|
type: "spotlight-portrait";
|
||||||
|
spotlight: MediaViewModel[];
|
||||||
|
grid: UserMediaViewModel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotlightExpandedLayout {
|
||||||
|
type: "spotlight-expanded";
|
||||||
spotlight: MediaViewModel[];
|
spotlight: MediaViewModel[];
|
||||||
pip?: UserMediaViewModel;
|
pip?: UserMediaViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OneOnOneLayout {
|
||||||
|
type: "one-on-one";
|
||||||
|
local: LocalUserMediaViewModel;
|
||||||
|
remote: RemoteUserMediaViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PipLayout {
|
export interface PipLayout {
|
||||||
type: "pip";
|
type: "pip";
|
||||||
spotlight: MediaViewModel[];
|
spotlight: MediaViewModel[];
|
||||||
@@ -117,26 +123,52 @@ export interface PipLayout {
|
|||||||
*/
|
*/
|
||||||
export type Layout =
|
export type Layout =
|
||||||
| GridLayout
|
| GridLayout
|
||||||
| SpotlightLayout
|
| SpotlightLandscapeLayout
|
||||||
| FullScreenLayout
|
| SpotlightPortraitLayout
|
||||||
|
| SpotlightExpandedLayout
|
||||||
|
| OneOnOneLayout
|
||||||
| PipLayout;
|
| PipLayout;
|
||||||
|
|
||||||
export type GridMode = "grid" | "spotlight";
|
export type GridMode = "grid" | "spotlight";
|
||||||
|
|
||||||
export type WindowMode = "normal" | "full screen" | "pip";
|
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorting bins defining the order in which media tiles appear in the layout.
|
* Sorting bins defining the order in which media tiles appear in the layout.
|
||||||
*/
|
*/
|
||||||
enum SortingBin {
|
enum SortingBin {
|
||||||
SelfStart,
|
/**
|
||||||
|
* Yourself, when the "always show self" option is on.
|
||||||
|
*/
|
||||||
|
SelfAlwaysShown,
|
||||||
|
/**
|
||||||
|
* Participants that are sharing their screen.
|
||||||
|
*/
|
||||||
Presenters,
|
Presenters,
|
||||||
|
/**
|
||||||
|
* Participants that have been speaking recently.
|
||||||
|
*/
|
||||||
Speakers,
|
Speakers,
|
||||||
|
/**
|
||||||
|
* Participants with both video and audio.
|
||||||
|
*/
|
||||||
VideoAndAudio,
|
VideoAndAudio,
|
||||||
|
/**
|
||||||
|
* Participants with video but no audio.
|
||||||
|
*/
|
||||||
Video,
|
Video,
|
||||||
|
/**
|
||||||
|
* Participants with audio but no video.
|
||||||
|
*/
|
||||||
Audio,
|
Audio,
|
||||||
|
/**
|
||||||
|
* Participants not sharing any media.
|
||||||
|
*/
|
||||||
NoMedia,
|
NoMedia,
|
||||||
SelfEnd,
|
/**
|
||||||
|
* Yourself, when the "always show self" option is off.
|
||||||
|
*/
|
||||||
|
SelfNotAlwaysShown,
|
||||||
}
|
}
|
||||||
|
|
||||||
class UserMedia {
|
class UserMedia {
|
||||||
@@ -151,14 +183,17 @@ class UserMedia {
|
|||||||
participant: LocalParticipant | RemoteParticipant,
|
participant: LocalParticipant | RemoteParticipant,
|
||||||
callEncrypted: boolean,
|
callEncrypted: boolean,
|
||||||
) {
|
) {
|
||||||
this.vm = new UserMediaViewModel(id, member, participant, callEncrypted);
|
this.vm =
|
||||||
|
participant instanceof LocalParticipant
|
||||||
|
? new LocalUserMediaViewModel(id, member, participant, callEncrypted)
|
||||||
|
: new RemoteUserMediaViewModel(id, member, participant, callEncrypted);
|
||||||
|
|
||||||
this.speaker = this.vm.speaking.pipeState(
|
this.speaker = this.vm.speaking.pipe(
|
||||||
// Require 1 s of continuous speaking to become a speaker, and 10 s of
|
// Require 1 s of continuous speaking to become a speaker, and 60 s of
|
||||||
// continuous silence to stop being considered a speaker
|
// continuous silence to stop being considered a speaker
|
||||||
audit((s) =>
|
audit((s) =>
|
||||||
merge(
|
merge(
|
||||||
timer(s ? 1000 : 10000),
|
timer(s ? 1000 : 60000),
|
||||||
// If the speaking flag resets to its original value during this time,
|
// If the speaking flag resets to its original value during this time,
|
||||||
// end the silencing window to stick with that original value
|
// end the silencing window to stick with that original value
|
||||||
this.vm.speaking.pipe(filter((s1) => s1 !== s)),
|
this.vm.speaking.pipe(filter((s1) => s1 !== s)),
|
||||||
@@ -210,7 +245,8 @@ function findMatrixMember(
|
|||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
id: string,
|
id: string,
|
||||||
): RoomMember | undefined {
|
): RoomMember | undefined {
|
||||||
if (!id) return undefined;
|
if (id === "local")
|
||||||
|
return room.getMember(room.client.getUserId()!) ?? undefined;
|
||||||
|
|
||||||
const parts = id.split(":");
|
const parts = id.split(":");
|
||||||
// must be at least 3 parts because we know the first part is a userId which must necessarily contain a colon
|
// must be at least 3 parts because we know the first part is a userId which must necessarily contain a colon
|
||||||
@@ -229,9 +265,9 @@ function findMatrixMember(
|
|||||||
|
|
||||||
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
||||||
export class CallViewModel extends ViewModel {
|
export class CallViewModel extends ViewModel {
|
||||||
private readonly rawRemoteParticipants = state(
|
private readonly rawRemoteParticipants = connectedParticipantsObserver(
|
||||||
connectedParticipantsObserver(this.livekitRoom),
|
this.livekitRoom,
|
||||||
);
|
).pipe(shareReplay(1));
|
||||||
|
|
||||||
// Lists of participants to "hold" on display, even if LiveKit claims that
|
// Lists of participants to "hold" on display, even if LiveKit claims that
|
||||||
// they've left
|
// they've left
|
||||||
@@ -271,16 +307,13 @@ export class CallViewModel extends ViewModel {
|
|||||||
},
|
},
|
||||||
).pipe(
|
).pipe(
|
||||||
mergeAll(),
|
mergeAll(),
|
||||||
// Aggregate the hold instructions into a single list showing which
|
// Accumulate the hold instructions into a single list showing which
|
||||||
// participants are being held
|
// participants are being held
|
||||||
scan(
|
accumulate([] as RemoteParticipant[][], (holds, instruction) =>
|
||||||
(holds, instruction) =>
|
"hold" in instruction
|
||||||
"hold" in instruction
|
? [instruction.hold, ...holds]
|
||||||
? [instruction.hold, ...holds]
|
: holds.filter((h) => h !== instruction.unhold),
|
||||||
: holds.filter((h) => h !== instruction.unhold),
|
|
||||||
[] as RemoteParticipant[][],
|
|
||||||
),
|
),
|
||||||
startWith([]),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly remoteParticipants: Observable<RemoteParticipant[]> =
|
private readonly remoteParticipants: Observable<RemoteParticipant[]> =
|
||||||
@@ -304,33 +337,30 @@ export class CallViewModel extends ViewModel {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly mediaItems: StateObservable<MediaItem[]> = state(
|
private readonly mediaItems: Observable<MediaItem[]> = combineLatest([
|
||||||
combineLatest([
|
this.remoteParticipants,
|
||||||
this.remoteParticipants,
|
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
duplicateTiles.value,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
scan(
|
scan(
|
||||||
(
|
(
|
||||||
prevItems,
|
prevItems,
|
||||||
[remoteParticipants, { participant: localParticipant }],
|
[remoteParticipants, { participant: localParticipant }, duplicateTiles],
|
||||||
) => {
|
) => {
|
||||||
let allGhosts = true;
|
const newItems = new Map(
|
||||||
|
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
||||||
|
for (const p of [localParticipant, ...remoteParticipants]) {
|
||||||
|
const userMediaId = p === localParticipant ? "local" : p.identity;
|
||||||
|
const member = findMatrixMember(this.matrixRoom, userMediaId);
|
||||||
|
if (member === undefined)
|
||||||
|
logger.warn(
|
||||||
|
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
|
||||||
|
);
|
||||||
|
|
||||||
const newItems = new Map(
|
// Create as many tiles for this participant as called for by
|
||||||
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
// the duplicateTiles option
|
||||||
for (const p of [localParticipant, ...remoteParticipants]) {
|
for (let i = 0; i < 1 + duplicateTiles; i++) {
|
||||||
const member = findMatrixMember(this.matrixRoom, p.identity);
|
const userMediaId = `${p.identity}:${i}`;
|
||||||
allGhosts &&= member === undefined;
|
|
||||||
// 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 (p.identity !== "" && member === undefined) {
|
|
||||||
logger.warn(
|
|
||||||
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userMediaId = p.identity;
|
|
||||||
yield [
|
yield [
|
||||||
userMediaId,
|
userMediaId,
|
||||||
prevItems.get(userMediaId) ??
|
prevItems.get(userMediaId) ??
|
||||||
@@ -346,69 +376,99 @@ export class CallViewModel extends ViewModel {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.bind(this)(),
|
}
|
||||||
);
|
}.bind(this)(),
|
||||||
|
);
|
||||||
|
|
||||||
for (const [id, t] of prevItems) if (!newItems.has(id)) t.destroy();
|
for (const [id, t] of prevItems) if (!newItems.has(id)) t.destroy();
|
||||||
|
return newItems;
|
||||||
// If every item is a ghost, that probably means we're still connecting
|
},
|
||||||
// and shouldn't bother showing anything yet
|
new Map<string, MediaItem>(),
|
||||||
return allGhosts ? new Map() : newItems;
|
|
||||||
},
|
|
||||||
new Map<string, MediaItem>(),
|
|
||||||
),
|
|
||||||
map((ms) => [...ms.values()]),
|
|
||||||
finalizeValue((ts) => {
|
|
||||||
for (const t of ts) t.destroy();
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
|
map((mediaItems) => [...mediaItems.values()]),
|
||||||
|
finalizeValue((ts) => {
|
||||||
|
for (const t of ts) t.destroy();
|
||||||
|
}),
|
||||||
|
shareReplay(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
|
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
|
||||||
map((ms) => ms.filter((m): m is UserMedia => m instanceof UserMedia)),
|
map((mediaItems) =>
|
||||||
|
mediaItems.filter((m): m is UserMedia => m instanceof UserMedia),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private readonly localUserMedia: Observable<LocalUserMediaViewModel> =
|
||||||
|
this.mediaItems.pipe(
|
||||||
|
map((ms) => ms.find((m) => m.vm.local)!.vm as LocalUserMediaViewModel),
|
||||||
|
);
|
||||||
|
|
||||||
private readonly screenShares: Observable<ScreenShare[]> =
|
private readonly screenShares: Observable<ScreenShare[]> =
|
||||||
this.mediaItems.pipe(
|
this.mediaItems.pipe(
|
||||||
map((ms) => ms.filter((m): m is ScreenShare => m instanceof ScreenShare)),
|
map((mediaItems) =>
|
||||||
|
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
|
||||||
|
),
|
||||||
|
shareReplay(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly spotlightSpeaker: Observable<UserMedia | null> =
|
private readonly hasRemoteScreenShares: Observable<boolean> =
|
||||||
|
this.screenShares.pipe(
|
||||||
|
map((ms) => ms.some((m) => !m.vm.local)),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly spotlightSpeaker: Observable<UserMediaViewModel> =
|
||||||
this.userMedia.pipe(
|
this.userMedia.pipe(
|
||||||
switchMap((ms) =>
|
switchMap((mediaItems) =>
|
||||||
ms.length === 0
|
mediaItems.length === 0
|
||||||
? of([])
|
? of([])
|
||||||
: combineLatest(
|
: combineLatest(
|
||||||
ms.map((m) => m.vm.speaking.pipe(map((s) => [m, s] as const))),
|
mediaItems.map((m) =>
|
||||||
|
m.vm.speaking.pipe(map((s) => [m, s] as const)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>(
|
scan<(readonly [UserMedia, boolean])[], UserMedia, null>(
|
||||||
(prev, ms) =>
|
(prev, mediaItems) =>
|
||||||
// Decide who to spotlight:
|
// Decide who to spotlight:
|
||||||
// If the previous speaker is still speaking, stick with them rather
|
// If the previous speaker (not the local user) is still speaking,
|
||||||
// than switching eagerly to someone else
|
// stick with them rather than switching eagerly to someone else
|
||||||
ms.find(([m, s]) => m === prev && s)?.[0] ??
|
(prev === null || prev.vm.local
|
||||||
// Otherwise, select anyone who is speaking
|
? null
|
||||||
ms.find(([, s]) => s)?.[0] ??
|
: mediaItems.find(([m, s]) => m === prev && s)?.[0]) ??
|
||||||
|
// Otherwise, select any remote user who is speaking
|
||||||
|
mediaItems.find(([m, s]) => !m.vm.local && s)?.[0] ??
|
||||||
// Otherwise, stick with the person who was last speaking
|
// Otherwise, stick with the person who was last speaking
|
||||||
prev ??
|
prev ??
|
||||||
// Otherwise, spotlight the local user
|
// Otherwise, spotlight the local user
|
||||||
ms.find(([m]) => m.vm.local)?.[0] ??
|
mediaItems.find(([m]) => m.vm.local)![0],
|
||||||
null,
|
|
||||||
null,
|
null,
|
||||||
),
|
),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
throttleTime(800, undefined, { leading: true, trailing: true }),
|
map((speaker) => speaker.vm),
|
||||||
|
shareReplay(1),
|
||||||
|
throttleTime(1600, undefined, { leading: true, trailing: true }),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe(
|
private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe(
|
||||||
switchMap((ms) => {
|
switchMap((mediaItems) => {
|
||||||
const bins = ms.map((m) =>
|
const bins = mediaItems.map((m) =>
|
||||||
combineLatest(
|
combineLatest(
|
||||||
[m.speaker, m.presenter, m.vm.audioEnabled, m.vm.videoEnabled],
|
[
|
||||||
(speaker, presenter, audio, video) => {
|
m.speaker,
|
||||||
|
m.presenter,
|
||||||
|
m.vm.audioEnabled,
|
||||||
|
m.vm.videoEnabled,
|
||||||
|
m.vm instanceof LocalUserMediaViewModel
|
||||||
|
? m.vm.alwaysShow
|
||||||
|
: of(false),
|
||||||
|
],
|
||||||
|
(speaker, presenter, audio, video, alwaysShow) => {
|
||||||
let bin: SortingBin;
|
let bin: SortingBin;
|
||||||
if (m.vm.local) bin = SortingBin.SelfStart;
|
if (m.vm.local)
|
||||||
|
bin = alwaysShow
|
||||||
|
? SortingBin.SelfAlwaysShown
|
||||||
|
: SortingBin.SelfNotAlwaysShown;
|
||||||
else if (presenter) bin = SortingBin.Presenters;
|
else if (presenter) bin = SortingBin.Presenters;
|
||||||
else if (speaker) bin = SortingBin.Speakers;
|
else if (speaker) bin = SortingBin.Speakers;
|
||||||
else if (video)
|
else if (video)
|
||||||
@@ -428,153 +488,197 @@ export class CallViewModel extends ViewModel {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly spotlight: Observable<MediaViewModel[]> = combineLatest(
|
private readonly spotlightAndPip: Observable<
|
||||||
[this.screenShares, this.spotlightSpeaker],
|
[Observable<MediaViewModel[]>, Observable<UserMediaViewModel | null>]
|
||||||
(screenShares, spotlightSpeaker): MediaViewModel[] =>
|
> = this.screenShares.pipe(
|
||||||
|
map((screenShares) =>
|
||||||
screenShares.length > 0
|
screenShares.length > 0
|
||||||
? screenShares.map((m) => m.vm)
|
? ([of(screenShares.map((m) => m.vm)), this.spotlightSpeaker] as const)
|
||||||
: spotlightSpeaker === null
|
: ([
|
||||||
? []
|
this.spotlightSpeaker.pipe(map((speaker) => [speaker!])),
|
||||||
: [spotlightSpeaker.vm],
|
this.localUserMedia.pipe(
|
||||||
|
switchMap((vm) =>
|
||||||
|
vm.alwaysShow.pipe(
|
||||||
|
map((alwaysShow) => (alwaysShow ? vm : null)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] as const),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Make this react to changes in window dimensions and screen
|
private readonly spotlight: Observable<MediaViewModel[]> =
|
||||||
// orientation
|
this.spotlightAndPip.pipe(
|
||||||
private readonly windowMode = of<WindowMode>("normal");
|
switchMap(([spotlight]) => spotlight),
|
||||||
|
shareReplay(1),
|
||||||
|
);
|
||||||
|
|
||||||
private readonly _gridMode = new BehaviorSubject<GridMode>("grid");
|
private readonly pip: Observable<UserMediaViewModel | null> =
|
||||||
|
this.spotlightAndPip.pipe(switchMap(([, pip]) => pip));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The general shape of the window.
|
||||||
|
*/
|
||||||
|
public readonly windowMode: Observable<WindowMode> = fromEvent(
|
||||||
|
window,
|
||||||
|
"resize",
|
||||||
|
).pipe(
|
||||||
|
startWith(null),
|
||||||
|
map(() => {
|
||||||
|
const height = window.innerHeight;
|
||||||
|
const width = window.innerWidth;
|
||||||
|
if (height <= 400 && width <= 340) return "pip";
|
||||||
|
if (width <= 660) return "narrow";
|
||||||
|
if (height <= 660) return "flat";
|
||||||
|
return "normal";
|
||||||
|
}),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
shareReplay(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly spotlightExpandedToggle = new Subject<void>();
|
||||||
|
public readonly spotlightExpanded: Observable<boolean> =
|
||||||
|
this.spotlightExpandedToggle.pipe(
|
||||||
|
accumulate(false, (expanded) => !expanded),
|
||||||
|
shareReplay(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
public toggleSpotlightExpanded(): void {
|
||||||
|
this.spotlightExpandedToggle.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly gridModeUserSelection = new Subject<GridMode>();
|
||||||
/**
|
/**
|
||||||
* The layout mode of the media tile grid.
|
* The layout mode of the media tile grid.
|
||||||
*/
|
*/
|
||||||
public readonly gridMode = state(this._gridMode);
|
public readonly gridMode: Observable<GridMode> =
|
||||||
|
// If the user hasn't selected spotlight and somebody starts screen sharing,
|
||||||
|
// automatically switch to spotlight mode and reset when screen sharing ends
|
||||||
|
this.gridModeUserSelection.pipe(
|
||||||
|
startWith(null),
|
||||||
|
switchMap((userSelection) =>
|
||||||
|
(userSelection === "spotlight"
|
||||||
|
? EMPTY
|
||||||
|
: combineLatest([this.hasRemoteScreenShares, this.windowMode]).pipe(
|
||||||
|
skip(userSelection === null ? 0 : 1),
|
||||||
|
map(
|
||||||
|
([hasScreenShares, windowMode]): GridMode =>
|
||||||
|
hasScreenShares || windowMode === "flat"
|
||||||
|
? "spotlight"
|
||||||
|
: "grid",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).pipe(startWith(userSelection ?? "grid")),
|
||||||
|
),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
shareReplay(1),
|
||||||
|
);
|
||||||
|
|
||||||
public setGridMode(value: GridMode): void {
|
public setGridMode(value: GridMode): void {
|
||||||
this._gridMode.next(value);
|
this.gridModeUserSelection.next(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly layout: StateObservable<Layout> = state(
|
public readonly layout: Observable<Layout> = this.windowMode.pipe(
|
||||||
combineLatest([this._gridMode, this.windowMode], (gridMode, windowMode) => {
|
switchMap((windowMode) => {
|
||||||
|
const spotlightLandscapeLayout = combineLatest(
|
||||||
|
[this.grid, this.spotlight],
|
||||||
|
(grid, spotlight): Layout => ({
|
||||||
|
type: "spotlight-landscape",
|
||||||
|
spotlight,
|
||||||
|
grid,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const spotlightExpandedLayout = combineLatest(
|
||||||
|
[this.spotlight, this.pip],
|
||||||
|
(spotlight, pip): Layout => ({
|
||||||
|
type: "spotlight-expanded",
|
||||||
|
spotlight,
|
||||||
|
pip: pip ?? undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
switch (windowMode) {
|
switch (windowMode) {
|
||||||
case "full screen":
|
case "normal":
|
||||||
throw new Error("unimplemented");
|
return this.gridMode.pipe(
|
||||||
|
switchMap((gridMode) => {
|
||||||
|
switch (gridMode) {
|
||||||
|
case "grid":
|
||||||
|
return combineLatest(
|
||||||
|
[this.grid, this.spotlight, this.screenShares],
|
||||||
|
(grid, spotlight, screenShares): Layout =>
|
||||||
|
grid.length == 2 && screenShares.length === 0
|
||||||
|
? {
|
||||||
|
type: "one-on-one",
|
||||||
|
local: grid.find(
|
||||||
|
(vm) => vm.local,
|
||||||
|
) as LocalUserMediaViewModel,
|
||||||
|
remote: grid.find(
|
||||||
|
(vm) => !vm.local,
|
||||||
|
) as RemoteUserMediaViewModel,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "grid",
|
||||||
|
spotlight:
|
||||||
|
screenShares.length > 0 ||
|
||||||
|
grid.length > largeGridThreshold
|
||||||
|
? spotlight
|
||||||
|
: undefined,
|
||||||
|
grid,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
case "spotlight":
|
||||||
|
return this.spotlightExpanded.pipe(
|
||||||
|
switchMap((expanded) =>
|
||||||
|
expanded
|
||||||
|
? spotlightExpandedLayout
|
||||||
|
: spotlightLandscapeLayout,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
case "narrow":
|
||||||
|
return combineLatest(
|
||||||
|
[this.grid, this.spotlight],
|
||||||
|
(grid, spotlight): Layout => ({
|
||||||
|
type: "spotlight-portrait",
|
||||||
|
spotlight,
|
||||||
|
grid,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
case "flat":
|
||||||
|
return this.gridMode.pipe(
|
||||||
|
switchMap((gridMode) => {
|
||||||
|
switch (gridMode) {
|
||||||
|
case "grid":
|
||||||
|
// Yes, grid mode actually gets you a "spotlight" layout in
|
||||||
|
// this window mode.
|
||||||
|
return spotlightLandscapeLayout;
|
||||||
|
case "spotlight":
|
||||||
|
return spotlightExpandedLayout;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
case "pip":
|
case "pip":
|
||||||
throw new Error("unimplemented");
|
return this.spotlight.pipe(
|
||||||
case "normal": {
|
map((spotlight): Layout => ({ type: "pip", spotlight })),
|
||||||
switch (gridMode) {
|
);
|
||||||
case "grid":
|
|
||||||
return combineLatest(
|
|
||||||
[this.grid, this.spotlight, this.screenShares],
|
|
||||||
(grid, spotlight, screenShares): Layout => ({
|
|
||||||
type: "grid",
|
|
||||||
spotlight: screenShares.length > 0 ? spotlight : undefined,
|
|
||||||
grid,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
case "spotlight":
|
|
||||||
return combineLatest(
|
|
||||||
[this.grid, this.spotlight],
|
|
||||||
(grid, spotlight): Layout => ({
|
|
||||||
type: "spotlight",
|
|
||||||
spotlight,
|
|
||||||
grid,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}).pipe(switchAll()),
|
}),
|
||||||
|
shareReplay(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
public showSpotlightIndicators: Observable<boolean> = this.layout.pipe(
|
||||||
* The media tiles to be displayed in the call view.
|
map((l) => l.type !== "grid"),
|
||||||
*/
|
distinctUntilChanged(),
|
||||||
// TODO: Get rid of this field, replacing it with the 'layout' field above
|
shareReplay(1),
|
||||||
// which keeps more details of the layout order internal to the view model
|
);
|
||||||
public readonly tiles: StateObservable<TileDescriptor<MediaViewModel>[]> =
|
|
||||||
state(
|
|
||||||
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) => {
|
public showSpeakingIndicators: Observable<boolean> = this.layout.pipe(
|
||||||
const userMediaId = p.identity;
|
map((l) => l.type !== "one-on-one" && l.type !== "spotlight-expanded"),
|
||||||
const member = findMatrixMember(this.matrixRoom, userMediaId);
|
distinctUntilChanged(),
|
||||||
allGhosts &&= member === undefined;
|
shareReplay(1),
|
||||||
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 ??
|
|
||||||
new UserMediaViewModel(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();
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
// A call is permanently tied to a single Matrix room and LiveKit room
|
// A call is permanently tied to a single Matrix room and LiveKit room
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
observeParticipantEvents,
|
observeParticipantEvents,
|
||||||
observeParticipantMedia,
|
observeParticipantMedia,
|
||||||
} from "@livekit/components-core";
|
} from "@livekit/components-core";
|
||||||
import { StateObservable, state } from "@react-rxjs/core";
|
|
||||||
import {
|
import {
|
||||||
LocalParticipant,
|
LocalParticipant,
|
||||||
LocalTrack,
|
LocalTrack,
|
||||||
@@ -32,34 +31,77 @@ import {
|
|||||||
TrackEvent,
|
TrackEvent,
|
||||||
facingModeFromLocalTrack,
|
facingModeFromLocalTrack,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { RoomMember } from "matrix-js-sdk/src/matrix";
|
import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
|
Observable,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
distinctUntilKeyChanged,
|
distinctUntilKeyChanged,
|
||||||
fromEvent,
|
fromEvent,
|
||||||
map,
|
map,
|
||||||
of,
|
of,
|
||||||
|
shareReplay,
|
||||||
startWith,
|
startWith,
|
||||||
switchMap,
|
switchMap,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { ViewModel } from "./ViewModel";
|
import { ViewModel } from "./ViewModel";
|
||||||
|
import { useReactiveState } from "../useReactiveState";
|
||||||
|
import { alwaysShowSelf } from "../settings/settings";
|
||||||
|
|
||||||
|
export interface NameData {
|
||||||
|
/**
|
||||||
|
* The display name of the participant.
|
||||||
|
*/
|
||||||
|
displayName: string;
|
||||||
|
/**
|
||||||
|
* The text to be shown on the participant's name tag.
|
||||||
|
*/
|
||||||
|
nameTag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Move this naming logic into the view model
|
||||||
|
export function useNameData(vm: MediaViewModel): NameData {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [displayName, setDisplayName] = useReactiveState(
|
||||||
|
() => vm.member?.rawDisplayName ?? "[👻]",
|
||||||
|
[vm.member],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (vm.member) {
|
||||||
|
const updateName = (): void => {
|
||||||
|
setDisplayName(vm.member!.rawDisplayName);
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.member!.on(RoomMemberEvent.Name, updateName);
|
||||||
|
return (): void => {
|
||||||
|
vm.member!.removeListener(RoomMemberEvent.Name, updateName);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [vm.member, setDisplayName]);
|
||||||
|
const nameTag = vm.local
|
||||||
|
? t("video_tile.sfu_participant_local")
|
||||||
|
: displayName;
|
||||||
|
|
||||||
|
return { displayName, nameTag };
|
||||||
|
}
|
||||||
|
|
||||||
function observeTrackReference(
|
function observeTrackReference(
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
source: Track.Source,
|
source: Track.Source,
|
||||||
): StateObservable<TrackReferenceOrPlaceholder> {
|
): Observable<TrackReferenceOrPlaceholder> {
|
||||||
return state(
|
return observeParticipantMedia(participant).pipe(
|
||||||
observeParticipantMedia(participant).pipe(
|
map(() => ({
|
||||||
map(() => ({
|
participant,
|
||||||
participant,
|
publication: participant.getTrackPublication(source),
|
||||||
publication: participant.getTrackPublication(source),
|
source,
|
||||||
source,
|
})),
|
||||||
})),
|
distinctUntilKeyChanged("publication"),
|
||||||
distinctUntilKeyChanged("publication"),
|
shareReplay(1),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,15 +113,16 @@ abstract class BaseMediaViewModel extends ViewModel {
|
|||||||
/**
|
/**
|
||||||
* The LiveKit video track for this media.
|
* The LiveKit video track for this media.
|
||||||
*/
|
*/
|
||||||
public readonly video: StateObservable<TrackReferenceOrPlaceholder>;
|
public readonly video: Observable<TrackReferenceOrPlaceholder>;
|
||||||
/**
|
/**
|
||||||
* Whether there should be a warning that this media is unencrypted.
|
* Whether there should be a warning that this media is unencrypted.
|
||||||
*/
|
*/
|
||||||
public readonly unencryptedWarning: StateObservable<boolean>;
|
public readonly unencryptedWarning: Observable<boolean>;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
// TODO: This is only needed for full screen toggling and can be removed as
|
/**
|
||||||
// soon as that code is moved into the view models
|
* An opaque identifier for this media.
|
||||||
|
*/
|
||||||
public readonly id: string,
|
public readonly id: string,
|
||||||
/**
|
/**
|
||||||
* The Matrix room member to which this media belongs.
|
* The Matrix room member to which this media belongs.
|
||||||
@@ -95,15 +138,13 @@ abstract class BaseMediaViewModel extends ViewModel {
|
|||||||
super();
|
super();
|
||||||
const audio = observeTrackReference(participant, audioSource);
|
const audio = observeTrackReference(participant, audioSource);
|
||||||
this.video = observeTrackReference(participant, videoSource);
|
this.video = observeTrackReference(participant, videoSource);
|
||||||
this.unencryptedWarning = state(
|
this.unencryptedWarning = combineLatest(
|
||||||
combineLatest(
|
[audio, this.video],
|
||||||
[audio, this.video],
|
(a, v) =>
|
||||||
(a, v) =>
|
callEncrypted &&
|
||||||
callEncrypted &&
|
(a.publication?.isEncrypted === false ||
|
||||||
(a.publication?.isEncrypted === false ||
|
v.publication?.isEncrypted === false),
|
||||||
v.publication?.isEncrypted === false),
|
).pipe(distinctUntilChanged(), shareReplay(1));
|
||||||
).pipe(distinctUntilChanged()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,66 +152,39 @@ abstract class BaseMediaViewModel extends ViewModel {
|
|||||||
* Some participant's media.
|
* Some participant's media.
|
||||||
*/
|
*/
|
||||||
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
|
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
|
||||||
|
export type UserMediaViewModel =
|
||||||
|
| LocalUserMediaViewModel
|
||||||
|
| RemoteUserMediaViewModel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Some participant's user media.
|
* Some participant's user media.
|
||||||
*/
|
*/
|
||||||
export class UserMediaViewModel extends BaseMediaViewModel {
|
abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||||
/**
|
|
||||||
* Whether the video should be mirrored.
|
|
||||||
*/
|
|
||||||
public readonly mirror = state(
|
|
||||||
this.video.pipe(
|
|
||||||
switchMap((v) => {
|
|
||||||
const track = v.publication?.track;
|
|
||||||
if (!(track instanceof LocalTrack)) return of(false);
|
|
||||||
// Watch for track restarts, because they indicate a camera switch
|
|
||||||
return fromEvent(track, TrackEvent.Restarted).pipe(
|
|
||||||
startWith(null),
|
|
||||||
// Mirror only front-facing cameras (those that face the user)
|
|
||||||
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the participant is speaking.
|
* Whether the participant is speaking.
|
||||||
*/
|
*/
|
||||||
public readonly speaking = state(
|
public readonly speaking = observeParticipantEvents(
|
||||||
observeParticipantEvents(
|
this.participant,
|
||||||
this.participant,
|
ParticipantEvent.IsSpeakingChanged,
|
||||||
ParticipantEvent.IsSpeakingChanged,
|
).pipe(
|
||||||
).pipe(map((p) => p.isSpeaking)),
|
map((p) => p.isSpeaking),
|
||||||
|
shareReplay(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly _locallyMuted = new BehaviorSubject(false);
|
|
||||||
/**
|
|
||||||
* Whether we've disabled this participant's audio.
|
|
||||||
*/
|
|
||||||
public readonly locallyMuted = state(this._locallyMuted);
|
|
||||||
|
|
||||||
private readonly _localVolume = new BehaviorSubject(1);
|
|
||||||
/**
|
|
||||||
* The volume to which we've set this participant's audio, as a scalar
|
|
||||||
* multiplier.
|
|
||||||
*/
|
|
||||||
public readonly localVolume = state(this._localVolume);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this participant is sending audio (i.e. is unmuted on their side).
|
* Whether this participant is sending audio (i.e. is unmuted on their side).
|
||||||
*/
|
*/
|
||||||
public readonly audioEnabled: StateObservable<boolean>;
|
public readonly audioEnabled: Observable<boolean>;
|
||||||
/**
|
/**
|
||||||
* Whether this participant is sending video.
|
* Whether this participant is sending video.
|
||||||
*/
|
*/
|
||||||
public readonly videoEnabled: StateObservable<boolean>;
|
public readonly videoEnabled: Observable<boolean>;
|
||||||
|
|
||||||
private readonly _cropVideo = new BehaviorSubject(true);
|
private readonly _cropVideo = new BehaviorSubject(true);
|
||||||
/**
|
/**
|
||||||
* Whether the tile video should be contained inside the tile or be cropped to fit.
|
* Whether the tile video should be contained inside the tile or be cropped to fit.
|
||||||
*/
|
*/
|
||||||
public readonly cropVideo = state(this._cropVideo);
|
public readonly cropVideo: Observable<boolean> = this._cropVideo;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -187,32 +201,96 @@ export class UserMediaViewModel extends BaseMediaViewModel {
|
|||||||
Track.Source.Camera,
|
Track.Source.Camera,
|
||||||
);
|
);
|
||||||
|
|
||||||
const media = observeParticipantMedia(participant);
|
const media = observeParticipantMedia(participant).pipe(shareReplay(1));
|
||||||
this.audioEnabled = state(
|
this.audioEnabled = media.pipe(
|
||||||
media.pipe(map((m) => m.microphoneTrack?.isMuted === false)),
|
map((m) => m.microphoneTrack?.isMuted === false),
|
||||||
);
|
);
|
||||||
this.videoEnabled = state(
|
this.videoEnabled = media.pipe(
|
||||||
media.pipe(map((m) => m.cameraTrack?.isMuted === false)),
|
map((m) => m.cameraTrack?.isMuted === false),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sync the local mute state and volume with LiveKit
|
|
||||||
if (!this.local)
|
|
||||||
combineLatest([this._locallyMuted, this._localVolume], (muted, volume) =>
|
|
||||||
muted ? 0 : volume,
|
|
||||||
)
|
|
||||||
.pipe(this.scope.bind())
|
|
||||||
.subscribe((volume) => {
|
|
||||||
(this.participant as RemoteParticipant).setVolume(volume);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public toggleLocallyMuted(): void {
|
|
||||||
this._locallyMuted.next(!this._locallyMuted.value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleFitContain(): void {
|
public toggleFitContain(): void {
|
||||||
this._cropVideo.next(!this._cropVideo.value);
|
this._cropVideo.next(!this._cropVideo.value);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local participant's user media.
|
||||||
|
*/
|
||||||
|
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||||
|
/**
|
||||||
|
* Whether the video should be mirrored.
|
||||||
|
*/
|
||||||
|
public readonly mirror = this.video.pipe(
|
||||||
|
switchMap((v) => {
|
||||||
|
const track = v.publication?.track;
|
||||||
|
if (!(track instanceof LocalTrack)) return of(false);
|
||||||
|
// Watch for track restarts, because they indicate a camera switch
|
||||||
|
return fromEvent(track, TrackEvent.Restarted).pipe(
|
||||||
|
startWith(null),
|
||||||
|
// Mirror only front-facing cameras (those that face the user)
|
||||||
|
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
shareReplay(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show this tile in a highly visible location near the start of
|
||||||
|
* the grid.
|
||||||
|
*/
|
||||||
|
public readonly alwaysShow = alwaysShowSelf.value;
|
||||||
|
public readonly setAlwaysShow = alwaysShowSelf.setValue;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
id: string,
|
||||||
|
member: RoomMember | undefined,
|
||||||
|
participant: LocalParticipant,
|
||||||
|
callEncrypted: boolean,
|
||||||
|
) {
|
||||||
|
super(id, member, participant, callEncrypted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A remote participant's user media.
|
||||||
|
*/
|
||||||
|
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||||
|
private readonly _locallyMuted = new BehaviorSubject(false);
|
||||||
|
/**
|
||||||
|
* Whether we've disabled this participant's audio.
|
||||||
|
*/
|
||||||
|
public readonly locallyMuted: Observable<boolean> = this._locallyMuted;
|
||||||
|
|
||||||
|
private readonly _localVolume = new BehaviorSubject(1);
|
||||||
|
/**
|
||||||
|
* The volume to which we've set this participant's audio, as a scalar
|
||||||
|
* multiplier.
|
||||||
|
*/
|
||||||
|
public readonly localVolume: Observable<number> = this._localVolume;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
id: string,
|
||||||
|
member: RoomMember | undefined,
|
||||||
|
participant: RemoteParticipant,
|
||||||
|
callEncrypted: boolean,
|
||||||
|
) {
|
||||||
|
super(id, member, participant, callEncrypted);
|
||||||
|
|
||||||
|
// Sync the local mute state and volume with LiveKit
|
||||||
|
combineLatest([this._locallyMuted, this._localVolume], (muted, volume) =>
|
||||||
|
muted ? 0 : volume,
|
||||||
|
)
|
||||||
|
.pipe(this.scope.bind())
|
||||||
|
.subscribe((volume) => {
|
||||||
|
(this.participant as RemoteParticipant).setVolume(volume);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleLocallyMuted(): void {
|
||||||
|
this._locallyMuted.next(!this._locallyMuted.value);
|
||||||
|
}
|
||||||
|
|
||||||
public setLocalVolume(value: number): void {
|
public setLocalVolume(value: number): void {
|
||||||
this._localVolume.next(value);
|
this._localVolume.next(value);
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2023 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 {
|
|
||||||
ForwardRefExoticComponent,
|
|
||||||
ForwardRefRenderFunction,
|
|
||||||
PropsWithoutRef,
|
|
||||||
RefAttributes,
|
|
||||||
forwardRef,
|
|
||||||
} from "react";
|
|
||||||
// eslint-disable-next-line no-restricted-imports
|
|
||||||
import { Subscribe, RemoveSubscribe } from "@react-rxjs/core";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps a React component that consumes Observables, resulting in a component
|
|
||||||
* that safely subscribes to its Observables before rendering. The component
|
|
||||||
* will return null until the subscriptions are created.
|
|
||||||
*/
|
|
||||||
export function subscribe<P, R>(
|
|
||||||
render: ForwardRefRenderFunction<R, P>,
|
|
||||||
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<R>> {
|
|
||||||
const Subscriber = forwardRef<R, { p: P }>(({ p }, ref) => (
|
|
||||||
<RemoveSubscribe>{render(p, ref)}</RemoveSubscribe>
|
|
||||||
));
|
|
||||||
Subscriber.displayName = "Subscriber";
|
|
||||||
|
|
||||||
// eslint-disable-next-line react/display-name
|
|
||||||
const OuterComponent = forwardRef<R, P>((p, ref) => (
|
|
||||||
<Subscribe>
|
|
||||||
<Subscriber ref={ref} p={p} />
|
|
||||||
</Subscribe>
|
|
||||||
));
|
|
||||||
// Copy over the component's display name, default props, etc.
|
|
||||||
Object.assign(OuterComponent, render);
|
|
||||||
return OuterComponent;
|
|
||||||
}
|
|
||||||
@@ -14,9 +14,11 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useRef } from "react";
|
import { Ref, useCallback, useRef } from "react";
|
||||||
import { BehaviorSubject, Observable } from "rxjs";
|
import { BehaviorSubject, Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { useInitial } from "../useInitial";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React hook that creates an Observable from a changing value. The Observable
|
* React hook that creates an Observable from a changing value. The Observable
|
||||||
* replays its current value upon subscription and emits whenever the value
|
* replays its current value upon subscription and emits whenever the value
|
||||||
@@ -28,3 +30,14 @@ export function useObservable<T>(value: T): Observable<T> {
|
|||||||
if (value !== subject.current.value) subject.current.next(value);
|
if (value !== subject.current.value) subject.current.next(value);
|
||||||
return subject.current;
|
return subject.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook that creates a ref and an Observable that emits any values
|
||||||
|
* stored in the ref. The Observable replays the value currently stored in the
|
||||||
|
* ref upon subscription.
|
||||||
|
*/
|
||||||
|
export function useObservableRef<T>(initialValue: T): [Observable<T>, Ref<T>] {
|
||||||
|
const subject = useInitial(() => new BehaviorSubject(initialValue));
|
||||||
|
const ref = useCallback((value: T) => subject.next(value), [subject]);
|
||||||
|
return [subject, ref];
|
||||||
|
}
|
||||||
|
|||||||
72
src/tile/GridTile.module.css
Normal file
72
src/tile/GridTile.module.css
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
--media-view-border-radius: var(--cpd-space-4x);
|
||||||
|
transition: outline-color ease 0.15s;
|
||||||
|
outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Use a pseudo-element to create the expressive speaking border, since CSS
|
||||||
|
borders don't support gradients */
|
||||||
|
.tile::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1; /* Put it below the outline */
|
||||||
|
opacity: 0; /* Hidden unless speaking */
|
||||||
|
transition: opacity ease 0.15s;
|
||||||
|
inset: calc(-1 * var(--cpd-border-width-4));
|
||||||
|
border-radius: var(--cpd-space-5x);
|
||||||
|
background: linear-gradient(
|
||||||
|
119deg,
|
||||||
|
rgba(13, 92, 189, 0.7) 0%,
|
||||||
|
rgba(13, 189, 168, 0.7) 100%
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(13, 92, 189, 0.9) 0%,
|
||||||
|
rgba(13, 189, 168, 0.9) 100%
|
||||||
|
);
|
||||||
|
background-blend-mode: overlay, normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.speaking {
|
||||||
|
/* !important because speaking border should take priority over hover */
|
||||||
|
outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.speaking::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.tile:hover {
|
||||||
|
outline: var(--cpd-border-width-2) solid
|
||||||
|
var(--cpd-color-border-interactive-hovered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.muteIcon[data-muted="true"] {
|
||||||
|
color: var(--cpd-color-icon-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muteIcon[data-muted="false"] {
|
||||||
|
color: var(--cpd-color-icon-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volumeSlider {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
301
src/tile/GridTile.tsx
Normal file
301
src/tile/GridTile.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ComponentProps,
|
||||||
|
ReactNode,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { animated } from "@react-spring/web";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import MicOnSolidIcon from "@vector-im/compound-design-tokens/icons/mic-on-solid.svg?react";
|
||||||
|
import MicOffSolidIcon from "@vector-im/compound-design-tokens/icons/mic-off-solid.svg?react";
|
||||||
|
import MicOffIcon from "@vector-im/compound-design-tokens/icons/mic-off.svg?react";
|
||||||
|
import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg?react";
|
||||||
|
import VolumeOnIcon from "@vector-im/compound-design-tokens/icons/volume-on.svg?react";
|
||||||
|
import VolumeOffIcon from "@vector-im/compound-design-tokens/icons/volume-off.svg?react";
|
||||||
|
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 {
|
||||||
|
ContextMenu,
|
||||||
|
MenuItem,
|
||||||
|
ToggleMenuItem,
|
||||||
|
Menu,
|
||||||
|
} from "@vector-im/compound-web";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
|
||||||
|
import styles from "./GridTile.module.css";
|
||||||
|
import {
|
||||||
|
UserMediaViewModel,
|
||||||
|
useNameData,
|
||||||
|
LocalUserMediaViewModel,
|
||||||
|
RemoteUserMediaViewModel,
|
||||||
|
} from "../state/MediaViewModel";
|
||||||
|
import { Slider } from "../Slider";
|
||||||
|
import { MediaView } from "./MediaView";
|
||||||
|
import { useLatest } from "../useLatest";
|
||||||
|
|
||||||
|
interface TileProps {
|
||||||
|
className?: string;
|
||||||
|
style?: ComponentProps<typeof animated.div>["style"];
|
||||||
|
targetWidth: number;
|
||||||
|
targetHeight: number;
|
||||||
|
displayName: string;
|
||||||
|
nameTag: string;
|
||||||
|
showSpeakingIndicators: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserMediaTileProps extends TileProps {
|
||||||
|
vm: UserMediaViewModel;
|
||||||
|
mirror: boolean;
|
||||||
|
menuStart?: ReactNode;
|
||||||
|
menuEnd?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
vm,
|
||||||
|
showSpeakingIndicators,
|
||||||
|
menuStart,
|
||||||
|
menuEnd,
|
||||||
|
className,
|
||||||
|
nameTag,
|
||||||
|
...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);
|
||||||
|
const cropVideo = useObservableEagerState(vm.cropVideo);
|
||||||
|
const onChangeFitContain = useCallback(() => vm.toggleFitContain(), [vm]);
|
||||||
|
const onSelectFitContain = useCallback(
|
||||||
|
(e: Event) => e.preventDefault(),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
|
||||||
|
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const menu = (
|
||||||
|
<>
|
||||||
|
{menuStart}
|
||||||
|
<ToggleMenuItem
|
||||||
|
Icon={ExpandIcon}
|
||||||
|
label={t("video_tile.change_fit_contain")}
|
||||||
|
checked={cropVideo}
|
||||||
|
onChange={onChangeFitContain}
|
||||||
|
onSelect={onSelectFitContain}
|
||||||
|
/>
|
||||||
|
{menuEnd}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const tile = (
|
||||||
|
<MediaView
|
||||||
|
ref={ref}
|
||||||
|
video={video}
|
||||||
|
member={vm.member}
|
||||||
|
unencryptedWarning={unencryptedWarning}
|
||||||
|
videoEnabled={videoEnabled}
|
||||||
|
videoFit={cropVideo ? "cover" : "contain"}
|
||||||
|
className={classNames(className, styles.tile, {
|
||||||
|
[styles.speaking]: showSpeakingIndicators && speaking,
|
||||||
|
})}
|
||||||
|
nameTagLeadingIcon={
|
||||||
|
<MicIcon
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
aria-label={audioEnabled ? t("microphone_on") : t("microphone_off")}
|
||||||
|
data-muted={!audioEnabled}
|
||||||
|
className={styles.muteIcon}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
nameTag={nameTag}
|
||||||
|
primaryButton={
|
||||||
|
<Menu
|
||||||
|
open={menuOpen}
|
||||||
|
onOpenChange={setMenuOpen}
|
||||||
|
title={nameTag}
|
||||||
|
trigger={
|
||||||
|
<button aria-label={t("common.options")}>
|
||||||
|
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
side="left"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
{menu}
|
||||||
|
</Menu>
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu title={nameTag} trigger={tile} hasAccessibleAlternative>
|
||||||
|
{menu}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
UserMediaTile.displayName = "UserMediaTile";
|
||||||
|
|
||||||
|
interface LocalUserMediaTileProps extends TileProps {
|
||||||
|
vm: LocalUserMediaViewModel;
|
||||||
|
onOpenProfile: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
|
||||||
|
({ vm, onOpenProfile, ...props }, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const mirror = useObservableEagerState(vm.mirror);
|
||||||
|
const alwaysShow = useObservableEagerState(vm.alwaysShow);
|
||||||
|
const latestAlwaysShow = useLatest(alwaysShow);
|
||||||
|
const onSelectAlwaysShow = useCallback(
|
||||||
|
(e: Event) => e.preventDefault(),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const onChangeAlwaysShow = useCallback(
|
||||||
|
() => vm.setAlwaysShow(!latestAlwaysShow.current),
|
||||||
|
[vm, latestAlwaysShow],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserMediaTile
|
||||||
|
ref={ref}
|
||||||
|
vm={vm}
|
||||||
|
mirror={mirror}
|
||||||
|
menuStart={
|
||||||
|
<ToggleMenuItem
|
||||||
|
Icon={VisibilityOnIcon}
|
||||||
|
label={t("video_tile.always_show")}
|
||||||
|
checked={alwaysShow}
|
||||||
|
onChange={onChangeAlwaysShow}
|
||||||
|
onSelect={onSelectAlwaysShow}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
menuEnd={
|
||||||
|
<MenuItem
|
||||||
|
Icon={UserProfileIcon}
|
||||||
|
label={t("common.profile")}
|
||||||
|
onSelect={onOpenProfile}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
LocalUserMediaTile.displayName = "LocalUserMediaTile";
|
||||||
|
|
||||||
|
interface RemoteUserMediaTileProps extends TileProps {
|
||||||
|
vm: RemoteUserMediaViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RemoteUserMediaTile = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
RemoteUserMediaTileProps
|
||||||
|
>(({ vm, ...props }, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const locallyMuted = useObservableEagerState(vm.locallyMuted);
|
||||||
|
const localVolume = useObservableEagerState(vm.localVolume);
|
||||||
|
const onChangeMute = useCallback(() => vm.toggleLocallyMuted(), [vm]);
|
||||||
|
const onSelectMute = useCallback((e: Event) => e.preventDefault(), []);
|
||||||
|
const onChangeLocalVolume = useCallback(
|
||||||
|
(v: number) => vm.setLocalVolume(v),
|
||||||
|
[vm],
|
||||||
|
);
|
||||||
|
|
||||||
|
const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserMediaTile
|
||||||
|
ref={ref}
|
||||||
|
vm={vm}
|
||||||
|
mirror={false}
|
||||||
|
menuStart={
|
||||||
|
<>
|
||||||
|
<ToggleMenuItem
|
||||||
|
Icon={MicOffIcon}
|
||||||
|
label={t("video_tile.mute_for_me")}
|
||||||
|
checked={locallyMuted}
|
||||||
|
onChange={onChangeMute}
|
||||||
|
onSelect={onSelectMute}
|
||||||
|
/>
|
||||||
|
{/* TODO: Figure out how to make this slider keyboard accessible */}
|
||||||
|
<MenuItem as="div" Icon={VolumeIcon} label={null} onSelect={null}>
|
||||||
|
<Slider
|
||||||
|
className={styles.volumeSlider}
|
||||||
|
label={t("video_tile.volume")}
|
||||||
|
value={localVolume}
|
||||||
|
onValueChange={onChangeLocalVolume}
|
||||||
|
min={0.1}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
disabled={locallyMuted}
|
||||||
|
/>
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
|
||||||
|
|
||||||
|
interface GridTileProps {
|
||||||
|
vm: UserMediaViewModel;
|
||||||
|
onOpenProfile: () => void;
|
||||||
|
targetWidth: number;
|
||||||
|
targetHeight: number;
|
||||||
|
className?: string;
|
||||||
|
style?: ComponentProps<typeof animated.div>["style"];
|
||||||
|
showSpeakingIndicators: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
|
||||||
|
({ vm, onOpenProfile, ...props }, ref) => {
|
||||||
|
const nameData = useNameData(vm);
|
||||||
|
|
||||||
|
if (vm instanceof LocalUserMediaViewModel) {
|
||||||
|
return (
|
||||||
|
<LocalUserMediaTile
|
||||||
|
ref={ref}
|
||||||
|
vm={vm}
|
||||||
|
onOpenProfile={onOpenProfile}
|
||||||
|
{...props}
|
||||||
|
{...nameData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <RemoteUserMediaTile ref={ref} vm={vm} {...props} {...nameData} />;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
GridTile.displayName = "GridTile";
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2022-2023 New Vector Ltd
|
Copyright 2022-2024 New Vector Ltd
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -14,63 +14,13 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.videoTile {
|
.media {
|
||||||
position: absolute;
|
container-name: mediaView;
|
||||||
top: 0;
|
|
||||||
container-name: videoTile;
|
|
||||||
container-type: size;
|
container-type: size;
|
||||||
border-radius: var(--cpd-space-4x);
|
border-radius: var(--media-view-border-radius);
|
||||||
transition: outline-color ease 0.15s;
|
|
||||||
outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Use a pseudo-element to create the expressive speaking border, since CSS
|
.media video {
|
||||||
borders don't support gradients */
|
|
||||||
.videoTile::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
z-index: -1; /* Put it below the outline */
|
|
||||||
opacity: 0; /* Hidden unless speaking */
|
|
||||||
transition: opacity ease 0.15s;
|
|
||||||
inset: calc(-1 * var(--cpd-border-width-4));
|
|
||||||
border-radius: var(--cpd-space-5x);
|
|
||||||
background: linear-gradient(
|
|
||||||
119deg,
|
|
||||||
rgba(13, 92, 189, 0.7) 0%,
|
|
||||||
rgba(13, 189, 168, 0.7) 100%
|
|
||||||
),
|
|
||||||
linear-gradient(
|
|
||||||
180deg,
|
|
||||||
rgba(13, 92, 189, 0.9) 0%,
|
|
||||||
rgba(13, 189, 168, 0.9) 100%
|
|
||||||
);
|
|
||||||
background-blend-mode: overlay, normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.videoTile.speaking {
|
|
||||||
/* !important because speaking border should take priority over hover */
|
|
||||||
outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.videoTile.speaking::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
.videoTile:hover {
|
|
||||||
outline: var(--cpd-border-width-2) solid
|
|
||||||
var(--cpd-color-border-interactive-hovered);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.videoTile.maximised {
|
|
||||||
position: relative;
|
|
||||||
border-radius: 0;
|
|
||||||
inline-size: 100%;
|
|
||||||
block-size: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.videoTile video {
|
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
@@ -81,19 +31,19 @@ borders don't support gradients */
|
|||||||
transform: translate(0);
|
transform: translate(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.videoTile.mirror video {
|
.media.mirror video {
|
||||||
transform: scaleX(-1);
|
transform: scaleX(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.videoTile.screenshare video {
|
.media[data-video-fit="cover"] video {
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.videoTile.cropVideo video {
|
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.videoTile.videoMuted video {
|
.media[data-video-fit="contain"] video {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media.videoMuted video {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,13 +64,13 @@ borders don't support gradients */
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.videoTile.videoMuted .avatar {
|
.media.videoMuted .avatar {
|
||||||
display: initial;
|
display: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CSS makes us put a condition here, even though all we want to do is
|
/* CSS makes us put a condition here, even though all we want to do is
|
||||||
unconditionally select the container so we can use cqmin units */
|
unconditionally select the container so we can use cqmin units */
|
||||||
@container videoTile (width > 0) {
|
@container mediaView (width > 0) {
|
||||||
.avatar {
|
.avatar {
|
||||||
/* Half of the smallest dimension of the tile */
|
/* Half of the smallest dimension of the tile */
|
||||||
inline-size: 50cqmin;
|
inline-size: 50cqmin;
|
||||||
@@ -137,11 +87,14 @@ unconditionally select the container so we can use cqmin units */
|
|||||||
|
|
||||||
.fg {
|
.fg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: var(--cpd-space-1x);
|
inset: var(
|
||||||
|
--media-view-fg-inset,
|
||||||
|
calc(var(--media-view-border-radius) - var(--cpd-space-3x))
|
||||||
|
);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: 1fr auto;
|
||||||
grid-template-areas: ". button2" "nameTag button1";
|
grid-template-areas: ". ." "nameTag button";
|
||||||
gap: var(--cpd-space-1x);
|
gap: var(--cpd-space-1x);
|
||||||
place-items: start;
|
place-items: start;
|
||||||
}
|
}
|
||||||
@@ -167,14 +120,6 @@ unconditionally select the container so we can use cqmin units */
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.muteIcon[data-muted="true"] {
|
|
||||||
color: var(--cpd-color-icon-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.muteIcon[data-muted="false"] {
|
|
||||||
color: var(--cpd-color-icon-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nameTag > .name {
|
.nameTag > .name {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -200,8 +145,7 @@ unconditionally select the container so we can use cqmin units */
|
|||||||
transition: opacity ease 0.15s;
|
transition: opacity ease 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fg > button:focus-visible,
|
.fg:has(:focus-visible) > button,
|
||||||
.fg > :focus-visible ~ button,
|
|
||||||
.fg > button[data-enabled="true"],
|
.fg > button[data-enabled="true"],
|
||||||
.fg > button[data-state="open"] {
|
.fg > button[data-state="open"] {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -231,13 +175,5 @@ unconditionally select the container so we can use cqmin units */
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fg > button:first-of-type {
|
.fg > button:first-of-type {
|
||||||
grid-area: button1;
|
grid-area: button;
|
||||||
}
|
|
||||||
|
|
||||||
.fg > button:nth-of-type(2) {
|
|
||||||
grid-area: button2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.volumeSlider {
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
127
src/tile/MediaView.tsx
Normal file
127
src/tile/MediaView.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||||
|
import { animated } from "@react-spring/web";
|
||||||
|
import { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { ComponentProps, ReactNode, forwardRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { VideoTrack } from "@livekit/components-react";
|
||||||
|
import { Text, Tooltip } from "@vector-im/compound-web";
|
||||||
|
import ErrorIcon from "@vector-im/compound-design-tokens/icons/error.svg?react";
|
||||||
|
|
||||||
|
import styles from "./MediaView.module.css";
|
||||||
|
import { Avatar } from "../Avatar";
|
||||||
|
|
||||||
|
interface Props extends ComponentProps<typeof animated.div> {
|
||||||
|
className?: string;
|
||||||
|
style?: ComponentProps<typeof animated.div>["style"];
|
||||||
|
targetWidth: number;
|
||||||
|
targetHeight: number;
|
||||||
|
video: TrackReferenceOrPlaceholder;
|
||||||
|
videoFit: "cover" | "contain";
|
||||||
|
mirror: boolean;
|
||||||
|
member: RoomMember | undefined;
|
||||||
|
videoEnabled: boolean;
|
||||||
|
unencryptedWarning: boolean;
|
||||||
|
nameTagLeadingIcon?: ReactNode;
|
||||||
|
nameTag: string;
|
||||||
|
displayName: string;
|
||||||
|
primaryButton?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
targetWidth,
|
||||||
|
targetHeight,
|
||||||
|
video,
|
||||||
|
videoFit,
|
||||||
|
mirror,
|
||||||
|
member,
|
||||||
|
videoEnabled,
|
||||||
|
unencryptedWarning,
|
||||||
|
nameTagLeadingIcon,
|
||||||
|
nameTag,
|
||||||
|
displayName,
|
||||||
|
primaryButton,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<animated.div
|
||||||
|
className={classNames(styles.media, className, {
|
||||||
|
[styles.mirror]: mirror,
|
||||||
|
[styles.videoMuted]: !videoEnabled,
|
||||||
|
})}
|
||||||
|
style={style}
|
||||||
|
ref={ref}
|
||||||
|
data-testid="videoTile"
|
||||||
|
data-video-fit={videoFit}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={styles.bg}>
|
||||||
|
<Avatar
|
||||||
|
id={member?.userId ?? displayName}
|
||||||
|
name={displayName}
|
||||||
|
size={Math.round(Math.min(targetWidth, targetHeight) / 2)}
|
||||||
|
src={member?.getMxcAvatarUrl()}
|
||||||
|
className={styles.avatar}
|
||||||
|
/>
|
||||||
|
{video.publication !== undefined && (
|
||||||
|
<VideoTrack
|
||||||
|
trackRef={video}
|
||||||
|
// There's no reason for this to be focusable
|
||||||
|
tabIndex={-1}
|
||||||
|
disablePictureInPicture
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.fg}>
|
||||||
|
<div className={styles.nameTag}>
|
||||||
|
{nameTagLeadingIcon}
|
||||||
|
<Text as="span" size="sm" weight="medium" className={styles.name}>
|
||||||
|
{nameTag}
|
||||||
|
</Text>
|
||||||
|
{unencryptedWarning && (
|
||||||
|
<Tooltip
|
||||||
|
label={t("common.unencrypted")}
|
||||||
|
side="bottom"
|
||||||
|
isTriggerInteractive={false}
|
||||||
|
>
|
||||||
|
<ErrorIcon
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
aria-label={t("common.unencrypted")}
|
||||||
|
className={styles.errorIcon}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{primaryButton}
|
||||||
|
</div>
|
||||||
|
</animated.div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
MediaView.displayName = "MediaView";
|
||||||
167
src/tile/SpotlightTile.module.css
Normal file
167
src/tile/SpotlightTile.module.css
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
display: flex;
|
||||||
|
border-radius: var(--cpd-space-6x);
|
||||||
|
contain: strict;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
scroll-snap-type: inline mandatory;
|
||||||
|
scroll-snap-stop: always;
|
||||||
|
/* It would be nice to use smooth scrolling here, but Firefox has a bug where
|
||||||
|
it will not re-snap if the snapping point changes while it's smoothly
|
||||||
|
animating to another snapping point.
|
||||||
|
scroll-behavior: smooth; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.maximised {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
height: 100%;
|
||||||
|
flex-basis: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
--media-view-fg-inset: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.snap {
|
||||||
|
scroll-snap-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advance {
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
padding: calc(var(--cpd-space-3x) - var(--cpd-border-width-1));
|
||||||
|
border: var(--cpd-border-width-1) solid
|
||||||
|
var(--cpd-color-border-interactive-secondary);
|
||||||
|
border-radius: var(--cpd-radius-pill-effect);
|
||||||
|
background: var(--cpd-color-alpha-gray-1400);
|
||||||
|
box-shadow: var(--small-drop-shadow);
|
||||||
|
transition-duration: 0.1s;
|
||||||
|
transition-property: opacity, background-color, border-color;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
/* Center the button vertically on the tile */
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.advance > svg {
|
||||||
|
display: block;
|
||||||
|
color: var(--cpd-color-icon-on-solid-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover) {
|
||||||
|
.advance:hover {
|
||||||
|
border-color: var(--cpd-color-bg-action-primary-hovered);
|
||||||
|
background: var(--cpd-color-bg-action-primary-hovered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.advance:active {
|
||||||
|
border-color: var(--cpd-color-bg-action-primary-pressed);
|
||||||
|
background: var(--cpd-color-bg-action-primary-pressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
inset-inline-start: var(--cpd-space-1x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next {
|
||||||
|
inset-inline-end: var(--cpd-space-1x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand {
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
padding: var(--cpd-space-2x);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--cpd-radius-pill-effect);
|
||||||
|
background: var(--cpd-color-alpha-gray-1400);
|
||||||
|
box-shadow: var(--small-drop-shadow);
|
||||||
|
transition-duration: 0.1s;
|
||||||
|
transition-property: opacity, background-color;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
--inset: 6px;
|
||||||
|
inset-block-end: var(--inset);
|
||||||
|
inset-inline-end: var(--inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand > svg {
|
||||||
|
display: block;
|
||||||
|
color: var(--cpd-color-icon-on-solid-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover) {
|
||||||
|
.expand:hover {
|
||||||
|
background: var(--cpd-color-bg-action-primary-hovered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand:active {
|
||||||
|
background: var(--cpd-color-bg-action-primary-pressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover) {
|
||||||
|
.tile:hover > button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile:has(:focus-visible) > button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicators {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--cpd-space-2x);
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
inset-block-end: calc(-1 * var(--cpd-space-6x));
|
||||||
|
width: 100%;
|
||||||
|
justify-content: start;
|
||||||
|
transition: opacity ease 0.15s;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicators.show {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maximised .indicators {
|
||||||
|
inset-block-end: calc(-1 * var(--cpd-space-4x) - 2px);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicators > .item {
|
||||||
|
inline-size: 32px;
|
||||||
|
block-size: 2px;
|
||||||
|
transition: background-color ease 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicators > .item[data-visible="false"] {
|
||||||
|
background: var(--cpd-color-alpha-gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicators > .item[data-visible="true"] {
|
||||||
|
background: var(--cpd-color-gray-1400);
|
||||||
|
}
|
||||||
323
src/tile/SpotlightTile.tsx
Normal file
323
src/tile/SpotlightTile.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ComponentProps,
|
||||||
|
RefAttributes,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "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 ChevronLeftIcon from "@vector-im/compound-design-tokens/icons/chevron-left.svg?react";
|
||||||
|
import ChevronRightIcon from "@vector-im/compound-design-tokens/icons/chevron-right.svg?react";
|
||||||
|
import { animated } from "@react-spring/web";
|
||||||
|
import { Observable, map } from "rxjs";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||||
|
import { RoomMember } from "matrix-js-sdk";
|
||||||
|
|
||||||
|
import { MediaView } from "./MediaView";
|
||||||
|
import styles from "./SpotlightTile.module.css";
|
||||||
|
import {
|
||||||
|
LocalUserMediaViewModel,
|
||||||
|
MediaViewModel,
|
||||||
|
ScreenShareViewModel,
|
||||||
|
UserMediaViewModel,
|
||||||
|
useNameData,
|
||||||
|
} from "../state/MediaViewModel";
|
||||||
|
import { useInitial } from "../useInitial";
|
||||||
|
import { useMergedRefs } from "../useMergedRefs";
|
||||||
|
import { useObservableRef } from "../state/useObservable";
|
||||||
|
import { useReactiveState } from "../useReactiveState";
|
||||||
|
import { useLatest } from "../useLatest";
|
||||||
|
|
||||||
|
interface SpotlightItemBaseProps {
|
||||||
|
className?: string;
|
||||||
|
"data-id": string;
|
||||||
|
targetWidth: number;
|
||||||
|
targetHeight: number;
|
||||||
|
video: TrackReferenceOrPlaceholder;
|
||||||
|
member: RoomMember | undefined;
|
||||||
|
unencryptedWarning: boolean;
|
||||||
|
nameTag: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||||
|
videoEnabled: boolean;
|
||||||
|
videoFit: "contain" | "cover";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpotlightLocalUserMediaItemProps
|
||||||
|
extends SpotlightUserMediaItemBaseProps {
|
||||||
|
vm: LocalUserMediaViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpotlightLocalUserMediaItem = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
SpotlightLocalUserMediaItemProps
|
||||||
|
>(({ vm, ...props }, ref) => {
|
||||||
|
const mirror = useObservableEagerState(vm.mirror);
|
||||||
|
return <MediaView ref={ref} mirror={mirror} {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
|
||||||
|
|
||||||
|
interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps {
|
||||||
|
vm: UserMediaViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpotlightUserMediaItem = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
SpotlightUserMediaItemProps
|
||||||
|
>(({ vm, ...props }, ref) => {
|
||||||
|
const videoEnabled = useObservableEagerState(vm.videoEnabled);
|
||||||
|
const cropVideo = useObservableEagerState(vm.cropVideo);
|
||||||
|
|
||||||
|
const baseProps: SpotlightUserMediaItemBaseProps = {
|
||||||
|
videoEnabled,
|
||||||
|
videoFit: cropVideo ? "cover" : "contain",
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
|
||||||
|
return vm instanceof LocalUserMediaViewModel ? (
|
||||||
|
<SpotlightLocalUserMediaItem ref={ref} vm={vm} {...baseProps} />
|
||||||
|
) : (
|
||||||
|
<MediaView mirror={false} {...baseProps} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
SpotlightUserMediaItem.displayName = "SpotlightUserMediaItem";
|
||||||
|
|
||||||
|
interface SpotlightItemProps {
|
||||||
|
vm: MediaViewModel;
|
||||||
|
targetWidth: number;
|
||||||
|
targetHeight: number;
|
||||||
|
intersectionObserver: Observable<IntersectionObserver>;
|
||||||
|
/**
|
||||||
|
* Whether this item should act as a scroll snapping point.
|
||||||
|
*/
|
||||||
|
snap: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
||||||
|
({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => {
|
||||||
|
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const ref = useMergedRefs(ourRef, theirRef);
|
||||||
|
const { displayName, nameTag } = useNameData(vm);
|
||||||
|
const video = useObservableEagerState(vm.video);
|
||||||
|
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
||||||
|
|
||||||
|
// Hook this item up to the intersection observer
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ourRef.current!;
|
||||||
|
let prevIo: IntersectionObserver | null = null;
|
||||||
|
const subscription = intersectionObserver.subscribe((io) => {
|
||||||
|
prevIo?.unobserve(element);
|
||||||
|
io.observe(element);
|
||||||
|
prevIo = io;
|
||||||
|
});
|
||||||
|
return (): void => {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
prevIo?.unobserve(element);
|
||||||
|
};
|
||||||
|
}, [intersectionObserver]);
|
||||||
|
|
||||||
|
const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
|
||||||
|
ref,
|
||||||
|
"data-id": vm.id,
|
||||||
|
className: classNames(styles.item, { [styles.snap]: snap }),
|
||||||
|
targetWidth,
|
||||||
|
targetHeight,
|
||||||
|
video,
|
||||||
|
member: vm.member,
|
||||||
|
unencryptedWarning,
|
||||||
|
nameTag,
|
||||||
|
displayName,
|
||||||
|
};
|
||||||
|
|
||||||
|
return vm instanceof ScreenShareViewModel ? (
|
||||||
|
<MediaView
|
||||||
|
videoEnabled
|
||||||
|
videoFit="contain"
|
||||||
|
mirror={false}
|
||||||
|
{...baseProps}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SpotlightUserMediaItem vm={vm} {...baseProps} />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
SpotlightItem.displayName = "SpotlightItem";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
vms: MediaViewModel[];
|
||||||
|
maximised: boolean;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggleExpanded: (() => void) | null;
|
||||||
|
targetWidth: number;
|
||||||
|
targetHeight: number;
|
||||||
|
showIndicators: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: ComponentProps<typeof animated.div>["style"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
vms,
|
||||||
|
maximised,
|
||||||
|
expanded,
|
||||||
|
onToggleExpanded,
|
||||||
|
targetWidth,
|
||||||
|
targetHeight,
|
||||||
|
showIndicators,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
},
|
||||||
|
theirRef,
|
||||||
|
) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [root, ourRef] = useObservableRef<HTMLDivElement | null>(null);
|
||||||
|
const ref = useMergedRefs(ourRef, theirRef);
|
||||||
|
const [visibleId, setVisibleId] = useState(vms[0].id);
|
||||||
|
const latestVms = useLatest(vms);
|
||||||
|
const latestVisibleId = useLatest(visibleId);
|
||||||
|
const visibleIndex = vms.findIndex((vm) => vm.id === visibleId);
|
||||||
|
const canGoBack = visibleIndex > 0;
|
||||||
|
const canGoToNext = visibleIndex !== -1 && visibleIndex < vms.length - 1;
|
||||||
|
|
||||||
|
// To keep track of which item is visible, we need an intersection observer
|
||||||
|
// hooked up to the root element and the items. Because the items will run
|
||||||
|
// their effects before their parent does, we need to do this dance with an
|
||||||
|
// Observable to actually give them the intersection observer.
|
||||||
|
const intersectionObserver = useInitial<Observable<IntersectionObserver>>(
|
||||||
|
() =>
|
||||||
|
root.pipe(
|
||||||
|
map(
|
||||||
|
(r) =>
|
||||||
|
new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries.find((e) => e.isIntersecting);
|
||||||
|
if (visible !== undefined)
|
||||||
|
setVisibleId(visible.target.getAttribute("data-id")!);
|
||||||
|
},
|
||||||
|
{ root: r, threshold: 0.5 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [scrollToId, setScrollToId] = useReactiveState<string | null>(
|
||||||
|
(prev) =>
|
||||||
|
prev == null || prev === visibleId || vms.every((vm) => vm.id !== prev)
|
||||||
|
? null
|
||||||
|
: prev,
|
||||||
|
[visibleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onBackClick = useCallback(() => {
|
||||||
|
const vms = latestVms.current;
|
||||||
|
const visibleIndex = vms.findIndex(
|
||||||
|
(vm) => vm.id === latestVisibleId.current,
|
||||||
|
);
|
||||||
|
if (visibleIndex > 0) setScrollToId(vms[visibleIndex - 1].id);
|
||||||
|
}, [latestVisibleId, latestVms, setScrollToId]);
|
||||||
|
|
||||||
|
const onNextClick = useCallback(() => {
|
||||||
|
const vms = latestVms.current;
|
||||||
|
const visibleIndex = vms.findIndex(
|
||||||
|
(vm) => vm.id === latestVisibleId.current,
|
||||||
|
);
|
||||||
|
if (visibleIndex !== -1 && visibleIndex !== vms.length - 1)
|
||||||
|
setScrollToId(vms[visibleIndex + 1].id);
|
||||||
|
}, [latestVisibleId, latestVms, setScrollToId]);
|
||||||
|
|
||||||
|
const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<animated.div
|
||||||
|
ref={ref}
|
||||||
|
className={classNames(className, styles.tile, {
|
||||||
|
[styles.maximised]: maximised,
|
||||||
|
})}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{canGoBack && (
|
||||||
|
<button
|
||||||
|
className={classNames(styles.advance, styles.back)}
|
||||||
|
aria-label={t("common.back")}
|
||||||
|
onClick={onBackClick}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon aria-hidden width={24} height={24} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{vms.map((vm) => (
|
||||||
|
<SpotlightItem
|
||||||
|
key={vm.id}
|
||||||
|
vm={vm}
|
||||||
|
targetWidth={targetWidth}
|
||||||
|
targetHeight={targetHeight}
|
||||||
|
intersectionObserver={intersectionObserver}
|
||||||
|
snap={scrollToId === null || scrollToId === vm.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{onToggleExpanded && (
|
||||||
|
<button
|
||||||
|
className={classNames(styles.expand)}
|
||||||
|
aria-label={
|
||||||
|
expanded
|
||||||
|
? t("video_tile.full_screen")
|
||||||
|
: t("video_tile.exit_full_screen")
|
||||||
|
}
|
||||||
|
onClick={onToggleExpanded}
|
||||||
|
>
|
||||||
|
<ToggleExpandIcon aria-hidden width={20} height={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canGoToNext && (
|
||||||
|
<button
|
||||||
|
className={classNames(styles.advance, styles.next)}
|
||||||
|
aria-label={t("common.next")}
|
||||||
|
onClick={onNextClick}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon aria-hidden width={24} height={24} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!expanded && (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.indicators, {
|
||||||
|
[styles.show]: showIndicators && vms.length > 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{vms.map((vm) => (
|
||||||
|
<div className={styles.item} data-visible={vm.id === visibleId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</animated.div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
SpotlightTile.displayName = "SpotlightTile";
|
||||||
26
src/useInitial.ts
Normal file
26
src/useInitial.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook that returns the value given on the initial render.
|
||||||
|
*/
|
||||||
|
export function useInitial<T>(getValue: () => T): T {
|
||||||
|
const ref = useRef<{ value: T }>();
|
||||||
|
ref.current ??= { value: getValue() };
|
||||||
|
return ref.current.value;
|
||||||
|
}
|
||||||
31
src/useLatest.ts
Normal file
31
src/useLatest.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { RefObject, useRef } from "react";
|
||||||
|
|
||||||
|
export interface LatestRef<T> extends RefObject<T> {
|
||||||
|
current: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook that returns a ref containing the value given on the latest
|
||||||
|
* render.
|
||||||
|
*/
|
||||||
|
export function useLatest<T>(value: T): LatestRef<T> {
|
||||||
|
const ref = useRef<T>(value);
|
||||||
|
ref.current = value;
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2023 New Vector Ltd
|
Copyright 2023-2024 New Vector Ltd
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -44,7 +44,8 @@ export const useReactiveState = <T>(
|
|||||||
if (
|
if (
|
||||||
prevDeps.current === undefined ||
|
prevDeps.current === undefined ||
|
||||||
deps.length !== prevDeps.current.length ||
|
deps.length !== prevDeps.current.length ||
|
||||||
deps.some((d, i) => d !== prevDeps.current![i])
|
// Deps might be NaN, so we compare with Object.is rather than ===
|
||||||
|
deps.some((d, i) => !Object.is(d, prevDeps.current![i]))
|
||||||
) {
|
) {
|
||||||
state.current = updateFn(state.current);
|
state.current = updateFn(state.current);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,195 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2023 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 { ComponentType, ReactNode, useCallback, useMemo, useRef } from "react";
|
|
||||||
|
|
||||||
import type { RectReadOnly } from "react-use-measure";
|
|
||||||
import { useReactiveState } from "../useReactiveState";
|
|
||||||
import { TileDescriptor } from "../state/CallViewModel";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A video grid layout system with concrete states of type State.
|
|
||||||
*/
|
|
||||||
// Ideally State would be parameterized by the tile data type, but then that
|
|
||||||
// makes Layout a higher-kinded type, which isn't achievable in TypeScript
|
|
||||||
// (unless you invoke some dark type-level computation magic… 😏)
|
|
||||||
// So we're stuck with these types being a little too strong.
|
|
||||||
export interface Layout<State> {
|
|
||||||
/**
|
|
||||||
* The layout state for zero tiles.
|
|
||||||
*/
|
|
||||||
readonly emptyState: State;
|
|
||||||
/**
|
|
||||||
* Updates/adds/removes tiles in a way that looks natural in the context of
|
|
||||||
* the given initial state.
|
|
||||||
*/
|
|
||||||
readonly updateTiles: <T>(s: State, tiles: TileDescriptor<T>[]) => State;
|
|
||||||
/**
|
|
||||||
* Adapts the layout to a new container size.
|
|
||||||
*/
|
|
||||||
readonly updateBounds: (s: State, bounds: RectReadOnly) => State;
|
|
||||||
/**
|
|
||||||
* Gets tiles in the order created by the layout.
|
|
||||||
*/
|
|
||||||
readonly getTiles: <T>(s: State) => TileDescriptor<T>[];
|
|
||||||
/**
|
|
||||||
* Determines whether a tile is draggable.
|
|
||||||
*/
|
|
||||||
readonly canDragTile: <T>(s: State, tile: TileDescriptor<T>) => boolean;
|
|
||||||
/**
|
|
||||||
* Drags the tile 'from' to the location of the tile 'to' (if possible).
|
|
||||||
* The position parameters are numbers in the range [0, 1) describing the
|
|
||||||
* specific positions on 'from' and 'to' that the drag gesture is targeting.
|
|
||||||
*/
|
|
||||||
readonly dragTile: <T>(
|
|
||||||
s: State,
|
|
||||||
from: TileDescriptor<T>,
|
|
||||||
to: TileDescriptor<T>,
|
|
||||||
xPositionOnFrom: number,
|
|
||||||
yPositionOnFrom: number,
|
|
||||||
xPositionOnTo: number,
|
|
||||||
yPositionOnTo: number,
|
|
||||||
) => State;
|
|
||||||
/**
|
|
||||||
* Toggles the focus of the given tile (if this layout has the concept of
|
|
||||||
* focus).
|
|
||||||
*/
|
|
||||||
readonly toggleFocus?: <T>(s: State, tile: TileDescriptor<T>) => State;
|
|
||||||
/**
|
|
||||||
* A React component generating the slot elements for a given layout state.
|
|
||||||
*/
|
|
||||||
readonly Slots: ComponentType<{ s: State }>;
|
|
||||||
/**
|
|
||||||
* Whether the state of this layout should be remembered even while a
|
|
||||||
* different layout is active.
|
|
||||||
*/
|
|
||||||
readonly rememberState: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A version of Map with stronger types that allow us to save layout states in a
|
|
||||||
* type-safe way.
|
|
||||||
*/
|
|
||||||
export interface LayoutStatesMap {
|
|
||||||
get<State>(layout: Layout<State>): State | undefined;
|
|
||||||
set<State>(layout: Layout<State>, state: State): LayoutStatesMap;
|
|
||||||
delete<State>(layout: Layout<State>): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook creating a Map to store layout states in.
|
|
||||||
*/
|
|
||||||
export const useLayoutStates = (): LayoutStatesMap => {
|
|
||||||
const layoutStates = useRef<Map<unknown, unknown>>();
|
|
||||||
if (layoutStates.current === undefined) layoutStates.current = new Map();
|
|
||||||
return layoutStates.current as LayoutStatesMap;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface UseLayout<State, T> {
|
|
||||||
state: State;
|
|
||||||
orderedItems: TileDescriptor<T>[];
|
|
||||||
generation: number;
|
|
||||||
canDragTile: (tile: TileDescriptor<T>) => boolean;
|
|
||||||
dragTile: (
|
|
||||||
from: TileDescriptor<T>,
|
|
||||||
to: TileDescriptor<T>,
|
|
||||||
xPositionOnFrom: number,
|
|
||||||
yPositionOnFrom: number,
|
|
||||||
xPositionOnTo: number,
|
|
||||||
yPositionOnTo: number,
|
|
||||||
) => void;
|
|
||||||
toggleFocus: ((tile: TileDescriptor<T>) => void) | undefined;
|
|
||||||
slots: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook which uses the provided layout system to arrange a set of items into a
|
|
||||||
* concrete layout state, and provides callbacks for user interaction.
|
|
||||||
*/
|
|
||||||
export function useLayout<State, T>(
|
|
||||||
layout: Layout<State>,
|
|
||||||
items: TileDescriptor<T>[],
|
|
||||||
bounds: RectReadOnly,
|
|
||||||
layoutStates: LayoutStatesMap,
|
|
||||||
): UseLayout<State, T> {
|
|
||||||
const prevLayout = useRef<Layout<unknown>>();
|
|
||||||
const prevState = layoutStates.get(layout);
|
|
||||||
|
|
||||||
const [state, setState] = useReactiveState<State>(() => {
|
|
||||||
// If the bounds aren't known yet, don't add anything to the layout
|
|
||||||
if (bounds.width === 0) {
|
|
||||||
return layout.emptyState;
|
|
||||||
} else {
|
|
||||||
if (
|
|
||||||
prevLayout.current !== undefined &&
|
|
||||||
layout !== prevLayout.current &&
|
|
||||||
!prevLayout.current.rememberState
|
|
||||||
)
|
|
||||||
layoutStates.delete(prevLayout.current);
|
|
||||||
|
|
||||||
const baseState = layoutStates.get(layout) ?? layout.emptyState;
|
|
||||||
return layout.updateTiles(layout.updateBounds(baseState, bounds), items);
|
|
||||||
}
|
|
||||||
}, [layout, items, bounds]);
|
|
||||||
|
|
||||||
const generation = useRef<number>(0);
|
|
||||||
if (state !== prevState) generation.current++;
|
|
||||||
|
|
||||||
prevLayout.current = layout as Layout<unknown>;
|
|
||||||
// No point in remembering an empty state, plus it would end up clobbering the
|
|
||||||
// real saved state while restoring a layout
|
|
||||||
if (state !== layout.emptyState) layoutStates.set(layout, state);
|
|
||||||
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
orderedItems: useMemo(() => layout.getTiles<T>(state), [layout, state]),
|
|
||||||
generation: generation.current,
|
|
||||||
canDragTile: useCallback(
|
|
||||||
(tile: TileDescriptor<T>) => layout.canDragTile(state, tile),
|
|
||||||
[layout, state],
|
|
||||||
),
|
|
||||||
dragTile: useCallback(
|
|
||||||
(
|
|
||||||
from: TileDescriptor<T>,
|
|
||||||
to: TileDescriptor<T>,
|
|
||||||
xPositionOnFrom: number,
|
|
||||||
yPositionOnFrom: number,
|
|
||||||
xPositionOnTo: number,
|
|
||||||
yPositionOnTo: number,
|
|
||||||
) =>
|
|
||||||
setState((s) =>
|
|
||||||
layout.dragTile(
|
|
||||||
s,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
xPositionOnFrom,
|
|
||||||
yPositionOnFrom,
|
|
||||||
xPositionOnTo,
|
|
||||||
yPositionOnTo,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
[layout, setState],
|
|
||||||
),
|
|
||||||
toggleFocus: useMemo(
|
|
||||||
() =>
|
|
||||||
layout.toggleFocus &&
|
|
||||||
((tile: TileDescriptor<T>): void =>
|
|
||||||
setState((s) => layout.toggleFocus!(s, tile))),
|
|
||||||
[layout, setState],
|
|
||||||
),
|
|
||||||
slots: <layout.Slots s={state} />,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2023 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 { SpringRef, TransitionFn, useTransition } from "@react-spring/web";
|
|
||||||
import { EventTypes, Handler, useScroll } from "@use-gesture/react";
|
|
||||||
import {
|
|
||||||
CSSProperties,
|
|
||||||
FC,
|
|
||||||
ReactNode,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import useMeasure from "react-use-measure";
|
|
||||||
import { zip } from "lodash";
|
|
||||||
|
|
||||||
import styles from "./NewVideoGrid.module.css";
|
|
||||||
import {
|
|
||||||
VideoGridProps as Props,
|
|
||||||
TileSpring,
|
|
||||||
ChildrenProperties,
|
|
||||||
TileSpringUpdate,
|
|
||||||
} from "./VideoGrid";
|
|
||||||
import { useReactiveState } from "../useReactiveState";
|
|
||||||
import { useMergedRefs } from "../useMergedRefs";
|
|
||||||
import { TileWrapper } from "./TileWrapper";
|
|
||||||
import { BigGrid } from "./BigGrid";
|
|
||||||
import { useLayout } from "./Layout";
|
|
||||||
import { TileDescriptor } from "../state/CallViewModel";
|
|
||||||
|
|
||||||
interface Rect {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Tile<T> extends Rect {
|
|
||||||
item: TileDescriptor<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DragState {
|
|
||||||
tileId: string;
|
|
||||||
tileX: number;
|
|
||||||
tileY: number;
|
|
||||||
cursorX: number;
|
|
||||||
cursorY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TapData {
|
|
||||||
tileId: string;
|
|
||||||
ts: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SlotProps {
|
|
||||||
style?: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Slot: FC<SlotProps> = ({ style }) => (
|
|
||||||
<div className={styles.slot} style={style} />
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An interactive, animated grid of video tiles.
|
|
||||||
*/
|
|
||||||
export function NewVideoGrid<T>({
|
|
||||||
items,
|
|
||||||
disableAnimations,
|
|
||||||
layoutStates,
|
|
||||||
children,
|
|
||||||
}: Props<T>): ReactNode {
|
|
||||||
// Overview: This component lays out tiles by rendering an invisible template
|
|
||||||
// grid of "slots" for tiles to go in. Once rendered, it uses the DOM API to
|
|
||||||
// get the dimensions of each slot, feeding these numbers back into
|
|
||||||
// react-spring to let the actual tiles move freely atop the template.
|
|
||||||
|
|
||||||
// To know when the rendered grid becomes consistent with the layout we've
|
|
||||||
// requested, we give it a data-generation attribute which holds the ID of the
|
|
||||||
// most recently rendered generation of the grid, and watch it with a
|
|
||||||
// MutationObserver.
|
|
||||||
|
|
||||||
const [slotsRoot, setSlotsRoot] = useState<HTMLDivElement | null>(null);
|
|
||||||
const [renderedGeneration, setRenderedGeneration] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (slotsRoot !== null) {
|
|
||||||
setRenderedGeneration(
|
|
||||||
parseInt(slotsRoot.getAttribute("data-generation")!),
|
|
||||||
);
|
|
||||||
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
if (mutations.some((m) => m.type === "attributes")) {
|
|
||||||
setRenderedGeneration(
|
|
||||||
parseInt(slotsRoot.getAttribute("data-generation")!),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(slotsRoot, { attributes: true });
|
|
||||||
return (): void => observer.disconnect();
|
|
||||||
}
|
|
||||||
}, [slotsRoot, setRenderedGeneration]);
|
|
||||||
|
|
||||||
const [gridRef1, gridBounds] = useMeasure();
|
|
||||||
const gridRef2 = useRef<HTMLDivElement | null>(null);
|
|
||||||
const gridRef = useMergedRefs(gridRef1, gridRef2);
|
|
||||||
|
|
||||||
const slotRects = useMemo(() => {
|
|
||||||
if (slotsRoot === null) return [];
|
|
||||||
|
|
||||||
const slots = slotsRoot.getElementsByClassName(styles.slot);
|
|
||||||
const rects = new Array<Rect>(slots.length);
|
|
||||||
for (let i = 0; i < slots.length; i++) {
|
|
||||||
const slot = slots[i] as HTMLElement;
|
|
||||||
rects[i] = {
|
|
||||||
x: slot.offsetLeft,
|
|
||||||
y: slot.offsetTop,
|
|
||||||
width: slot.offsetWidth,
|
|
||||||
height: slot.offsetHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return rects;
|
|
||||||
// The rects may change due to the grid being resized or rerendered, but
|
|
||||||
// eslint can't statically verify this
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [slotsRoot, renderedGeneration, gridBounds]);
|
|
||||||
|
|
||||||
// TODO: Implement more layouts and select the right one here
|
|
||||||
const layout = BigGrid;
|
|
||||||
const {
|
|
||||||
state: grid,
|
|
||||||
orderedItems,
|
|
||||||
generation,
|
|
||||||
canDragTile,
|
|
||||||
dragTile,
|
|
||||||
toggleFocus,
|
|
||||||
slots,
|
|
||||||
} = useLayout(layout, items, gridBounds, layoutStates);
|
|
||||||
|
|
||||||
const [tiles] = useReactiveState<Tile<T>[]>(
|
|
||||||
(prevTiles) => {
|
|
||||||
// If React hasn't yet rendered the current generation of the grid, skip
|
|
||||||
// the update, because grid and slotRects will be out of sync
|
|
||||||
if (renderedGeneration !== generation) return prevTiles ?? [];
|
|
||||||
|
|
||||||
const tileRects = new Map(
|
|
||||||
zip(orderedItems, slotRects) as [TileDescriptor<T>, Rect][],
|
|
||||||
);
|
|
||||||
// In order to not break drag gestures, it's critical that we render tiles
|
|
||||||
// in a stable order (that of 'items')
|
|
||||||
return items.map((item) => ({ ...tileRects.get(item)!, item }));
|
|
||||||
},
|
|
||||||
[slotRects, grid, renderedGeneration],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Drag state is stored in a ref rather than component state, because we use
|
|
||||||
// react-spring's imperative API during gestures to improve responsiveness
|
|
||||||
const dragState = useRef<DragState | null>(null);
|
|
||||||
|
|
||||||
const [tileTransitions, springRef] = useTransition(
|
|
||||||
tiles,
|
|
||||||
() => ({
|
|
||||||
key: ({ item }: Tile<T>): string => item.id,
|
|
||||||
from: ({ x, y, width, height }: Tile<T>): TileSpringUpdate => ({
|
|
||||||
opacity: 0,
|
|
||||||
scale: 0,
|
|
||||||
shadow: 0,
|
|
||||||
shadowSpread: 0,
|
|
||||||
zIndex: 1,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
immediate: disableAnimations,
|
|
||||||
}),
|
|
||||||
enter: { opacity: 1, scale: 1, immediate: disableAnimations },
|
|
||||||
update: ({
|
|
||||||
item,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}: Tile<T>): TileSpringUpdate | null =>
|
|
||||||
item.id === dragState.current?.tileId
|
|
||||||
? null
|
|
||||||
: {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
immediate: disableAnimations,
|
|
||||||
},
|
|
||||||
leave: { opacity: 0, scale: 0, immediate: disableAnimations },
|
|
||||||
config: { mass: 0.7, tension: 252, friction: 25 },
|
|
||||||
}),
|
|
||||||
// react-spring's types are bugged and can't infer the spring type
|
|
||||||
) as unknown as [TransitionFn<Tile<T>, TileSpring>, SpringRef<TileSpring>];
|
|
||||||
|
|
||||||
// Because we're using react-spring in imperative mode, we're responsible for
|
|
||||||
// firing animations manually whenever the tiles array updates
|
|
||||||
useEffect(() => {
|
|
||||||
springRef.start();
|
|
||||||
}, [tiles, springRef]);
|
|
||||||
|
|
||||||
const animateDraggedTile = (endOfGesture: boolean): void => {
|
|
||||||
const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!;
|
|
||||||
const tile = tiles.find((t) => t.item.id === tileId)!;
|
|
||||||
|
|
||||||
springRef.current
|
|
||||||
.find((c) => (c.item as Tile<T>).item.id === tileId)
|
|
||||||
?.start(
|
|
||||||
endOfGesture
|
|
||||||
? {
|
|
||||||
scale: 1,
|
|
||||||
zIndex: 1,
|
|
||||||
shadow: 0,
|
|
||||||
x: tile.x,
|
|
||||||
y: tile.y,
|
|
||||||
width: tile.width,
|
|
||||||
height: tile.height,
|
|
||||||
immediate:
|
|
||||||
disableAnimations || ((key): boolean => key === "zIndex"),
|
|
||||||
// Allow the tile's position to settle before pushing its
|
|
||||||
// z-index back down
|
|
||||||
delay: (key): number => (key === "zIndex" ? 500 : 0),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
scale: 1.1,
|
|
||||||
zIndex: 2,
|
|
||||||
shadow: 15,
|
|
||||||
x: tileX,
|
|
||||||
y: tileY,
|
|
||||||
immediate:
|
|
||||||
disableAnimations ||
|
|
||||||
((key): boolean =>
|
|
||||||
key === "zIndex" || key === "x" || key === "y"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const overTile = tiles.find(
|
|
||||||
(t) =>
|
|
||||||
cursorX >= t.x &&
|
|
||||||
cursorX < t.x + t.width &&
|
|
||||||
cursorY >= t.y &&
|
|
||||||
cursorY < t.y + t.height,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (overTile !== undefined)
|
|
||||||
dragTile(
|
|
||||||
tile.item,
|
|
||||||
overTile.item,
|
|
||||||
(cursorX - tileX) / tile.width,
|
|
||||||
(cursorY - tileY) / tile.height,
|
|
||||||
(cursorX - overTile.x) / overTile.width,
|
|
||||||
(cursorY - overTile.y) / overTile.height,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const lastTap = useRef<TapData | null>(null);
|
|
||||||
|
|
||||||
// Callback for useDrag. We could call useDrag here, but the default
|
|
||||||
// pattern of spreading {...bind()} across the children to bind the gesture
|
|
||||||
// ends up breaking memoization and ruining this component's performance.
|
|
||||||
// Instead, we pass this callback to each tile via a ref, to let them bind the
|
|
||||||
// gesture using the much more sensible ref-based method.
|
|
||||||
const onTileDrag = (
|
|
||||||
tileId: string,
|
|
||||||
|
|
||||||
{
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
tap,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
initial: [initialX, initialY],
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
delta: [dx, dy],
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
last,
|
|
||||||
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0],
|
|
||||||
): void => {
|
|
||||||
if (tap) {
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (
|
|
||||||
tileId === lastTap.current?.tileId &&
|
|
||||||
now - lastTap.current.ts < 500
|
|
||||||
) {
|
|
||||||
toggleFocus?.(items.find((i) => i.id === tileId)!);
|
|
||||||
lastTap.current = null;
|
|
||||||
} else {
|
|
||||||
lastTap.current = { tileId, ts: now };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const tileController = springRef.current.find(
|
|
||||||
(c) => (c.item as Tile<T>).item.id === tileId,
|
|
||||||
)!;
|
|
||||||
|
|
||||||
if (canDragTile((tileController.item as Tile<T>).item)) {
|
|
||||||
if (dragState.current === null) {
|
|
||||||
const tileSpring = tileController.get();
|
|
||||||
dragState.current = {
|
|
||||||
tileId,
|
|
||||||
tileX: tileSpring.x,
|
|
||||||
tileY: tileSpring.y,
|
|
||||||
cursorX: initialX - gridBounds.x,
|
|
||||||
cursorY: initialY - gridBounds.y + scrollOffset.current,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
dragState.current.tileX += dx;
|
|
||||||
dragState.current.tileY += dy;
|
|
||||||
dragState.current.cursorX += dx;
|
|
||||||
dragState.current.cursorY += dy;
|
|
||||||
|
|
||||||
animateDraggedTile(last);
|
|
||||||
|
|
||||||
if (last) dragState.current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onTileDragRef = useRef(onTileDrag);
|
|
||||||
onTileDragRef.current = onTileDrag;
|
|
||||||
|
|
||||||
const scrollOffset = useRef(0);
|
|
||||||
|
|
||||||
useScroll(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
({ xy: [, y], delta: [, dy] }) => {
|
|
||||||
scrollOffset.current = y;
|
|
||||||
|
|
||||||
if (dragState.current !== null) {
|
|
||||||
dragState.current.tileY += dy;
|
|
||||||
dragState.current.cursorY += dy;
|
|
||||||
animateDraggedTile(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ target: gridRef2 },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render nothing if the grid has yet to be generated
|
|
||||||
if (grid === null) {
|
|
||||||
return <div ref={gridRef} className={styles.grid} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={gridRef} className={styles.grid}>
|
|
||||||
<div
|
|
||||||
ref={setSlotsRoot}
|
|
||||||
className={styles.slots}
|
|
||||||
data-generation={generation}
|
|
||||||
>
|
|
||||||
{slots}
|
|
||||||
</div>
|
|
||||||
{tileTransitions((spring, tile) => (
|
|
||||||
<TileWrapper
|
|
||||||
key={tile.item.id}
|
|
||||||
id={tile.item.id}
|
|
||||||
onDragRef={onTileDragRef}
|
|
||||||
targetWidth={tile.width}
|
|
||||||
targetHeight={tile.height}
|
|
||||||
data={tile.item.data}
|
|
||||||
{...spring}
|
|
||||||
>
|
|
||||||
{children as (props: ChildrenProperties<T>) => ReactNode}
|
|
||||||
</TileWrapper>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,494 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2022-2023 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 {
|
|
||||||
ComponentProps,
|
|
||||||
ForwardedRef,
|
|
||||||
ReactNode,
|
|
||||||
forwardRef,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { animated } from "@react-spring/web";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
TrackReferenceOrPlaceholder,
|
|
||||||
VideoTrack,
|
|
||||||
} from "@livekit/components-react";
|
|
||||||
import {
|
|
||||||
RoomMember,
|
|
||||||
RoomMemberEvent,
|
|
||||||
} from "matrix-js-sdk/src/models/room-member";
|
|
||||||
import MicOnSolidIcon from "@vector-im/compound-design-tokens/icons/mic-on-solid.svg?react";
|
|
||||||
import MicOffSolidIcon from "@vector-im/compound-design-tokens/icons/mic-off-solid.svg?react";
|
|
||||||
import ErrorIcon from "@vector-im/compound-design-tokens/icons/error.svg?react";
|
|
||||||
import MicOffIcon from "@vector-im/compound-design-tokens/icons/mic-off.svg?react";
|
|
||||||
import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg?react";
|
|
||||||
import VolumeOnIcon from "@vector-im/compound-design-tokens/icons/volume-on.svg?react";
|
|
||||||
import VolumeOffIcon from "@vector-im/compound-design-tokens/icons/volume-off.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 {
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
ContextMenu,
|
|
||||||
MenuItem,
|
|
||||||
ToggleMenuItem,
|
|
||||||
Menu,
|
|
||||||
} from "@vector-im/compound-web";
|
|
||||||
import { useStateObservable } from "@react-rxjs/core";
|
|
||||||
|
|
||||||
import { Avatar } from "../Avatar";
|
|
||||||
import styles from "./VideoTile.module.css";
|
|
||||||
import { useReactiveState } from "../useReactiveState";
|
|
||||||
import {
|
|
||||||
ScreenShareViewModel,
|
|
||||||
MediaViewModel,
|
|
||||||
UserMediaViewModel,
|
|
||||||
} from "../state/MediaViewModel";
|
|
||||||
import { subscribe } from "../state/subscribe";
|
|
||||||
import { useMergedRefs } from "../useMergedRefs";
|
|
||||||
import { Slider } from "../Slider";
|
|
||||||
|
|
||||||
interface TileProps {
|
|
||||||
tileRef?: ForwardedRef<HTMLDivElement>;
|
|
||||||
className?: string;
|
|
||||||
style?: ComponentProps<typeof animated.div>["style"];
|
|
||||||
targetWidth: number;
|
|
||||||
targetHeight: number;
|
|
||||||
video: TrackReferenceOrPlaceholder;
|
|
||||||
member: RoomMember | undefined;
|
|
||||||
videoEnabled: boolean;
|
|
||||||
maximised: boolean;
|
|
||||||
unencryptedWarning: boolean;
|
|
||||||
nameTagLeadingIcon?: ReactNode;
|
|
||||||
nameTag: string;
|
|
||||||
displayName: string;
|
|
||||||
primaryButton: ReactNode;
|
|
||||||
secondaryButton?: ReactNode;
|
|
||||||
[k: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Tile = forwardRef<HTMLDivElement, TileProps>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
tileRef = null,
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
targetWidth,
|
|
||||||
targetHeight,
|
|
||||||
video,
|
|
||||||
member,
|
|
||||||
videoEnabled,
|
|
||||||
maximised,
|
|
||||||
unencryptedWarning,
|
|
||||||
nameTagLeadingIcon,
|
|
||||||
nameTag,
|
|
||||||
displayName,
|
|
||||||
primaryButton,
|
|
||||||
secondaryButton,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const mergedRef = useMergedRefs(tileRef, ref);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<animated.div
|
|
||||||
className={classNames(styles.videoTile, className, {
|
|
||||||
[styles.maximised]: maximised,
|
|
||||||
[styles.videoMuted]: !videoEnabled,
|
|
||||||
})}
|
|
||||||
style={style}
|
|
||||||
ref={mergedRef}
|
|
||||||
data-testid="videoTile"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className={styles.bg}>
|
|
||||||
<Avatar
|
|
||||||
id={member?.userId ?? displayName}
|
|
||||||
name={displayName}
|
|
||||||
size={Math.round(Math.min(targetWidth, targetHeight) / 2)}
|
|
||||||
src={member?.getMxcAvatarUrl()}
|
|
||||||
className={styles.avatar}
|
|
||||||
/>
|
|
||||||
{video.publication !== undefined && (
|
|
||||||
<VideoTrack
|
|
||||||
trackRef={video}
|
|
||||||
// There's no reason for this to be focusable
|
|
||||||
tabIndex={-1}
|
|
||||||
disablePictureInPicture
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.fg}>
|
|
||||||
<div className={styles.nameTag}>
|
|
||||||
{nameTagLeadingIcon}
|
|
||||||
<Text as="span" size="sm" weight="medium" className={styles.name}>
|
|
||||||
{nameTag}
|
|
||||||
</Text>
|
|
||||||
{unencryptedWarning && (
|
|
||||||
<Tooltip
|
|
||||||
label={t("common.unencrypted")}
|
|
||||||
side="bottom"
|
|
||||||
isTriggerInteractive={false}
|
|
||||||
>
|
|
||||||
<ErrorIcon
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
aria-label={t("common.unencrypted")}
|
|
||||||
className={styles.errorIcon}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{primaryButton}
|
|
||||||
{secondaryButton}
|
|
||||||
</div>
|
|
||||||
</animated.div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Tile.displayName = "Tile";
|
|
||||||
|
|
||||||
interface UserMediaTileProps {
|
|
||||||
vm: UserMediaViewModel;
|
|
||||||
className?: string;
|
|
||||||
style?: ComponentProps<typeof animated.div>["style"];
|
|
||||||
targetWidth: number;
|
|
||||||
targetHeight: number;
|
|
||||||
nameTag: string;
|
|
||||||
displayName: string;
|
|
||||||
maximised: boolean;
|
|
||||||
onOpenProfile: () => void;
|
|
||||||
showSpeakingIndicator: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UserMediaTile = subscribe<UserMediaTileProps, HTMLDivElement>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
vm,
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
targetWidth,
|
|
||||||
targetHeight,
|
|
||||||
nameTag,
|
|
||||||
displayName,
|
|
||||||
maximised,
|
|
||||||
onOpenProfile,
|
|
||||||
showSpeakingIndicator,
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const video = useStateObservable(vm.video);
|
|
||||||
const audioEnabled = useStateObservable(vm.audioEnabled);
|
|
||||||
const videoEnabled = useStateObservable(vm.videoEnabled);
|
|
||||||
const unencryptedWarning = useStateObservable(vm.unencryptedWarning);
|
|
||||||
const mirror = useStateObservable(vm.mirror);
|
|
||||||
const speaking = useStateObservable(vm.speaking);
|
|
||||||
const locallyMuted = useStateObservable(vm.locallyMuted);
|
|
||||||
const cropVideo = useStateObservable(vm.cropVideo);
|
|
||||||
const localVolume = useStateObservable(vm.localVolume);
|
|
||||||
const onChangeMute = useCallback(() => vm.toggleLocallyMuted(), [vm]);
|
|
||||||
const onChangeFitContain = useCallback(() => vm.toggleFitContain(), [vm]);
|
|
||||||
const onSelectMute = useCallback((e: Event) => e.preventDefault(), []);
|
|
||||||
const onSelectFitContain = useCallback(
|
|
||||||
(e: Event) => e.preventDefault(),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onChangeLocalVolume = useCallback(
|
|
||||||
(v: number) => vm.setLocalVolume(v),
|
|
||||||
[vm],
|
|
||||||
);
|
|
||||||
|
|
||||||
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
|
|
||||||
const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon;
|
|
||||||
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
const menu = vm.local ? (
|
|
||||||
<>
|
|
||||||
<MenuItem
|
|
||||||
Icon={UserProfileIcon}
|
|
||||||
label={t("common.profile")}
|
|
||||||
onSelect={onOpenProfile}
|
|
||||||
/>
|
|
||||||
<ToggleMenuItem
|
|
||||||
Icon={ExpandIcon}
|
|
||||||
label={t("video_tile.change_fit_contain")}
|
|
||||||
checked={cropVideo}
|
|
||||||
onChange={onChangeFitContain}
|
|
||||||
onSelect={onSelectFitContain}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ToggleMenuItem
|
|
||||||
Icon={MicOffIcon}
|
|
||||||
label={t("video_tile.mute_for_me")}
|
|
||||||
checked={locallyMuted}
|
|
||||||
onChange={onChangeMute}
|
|
||||||
onSelect={onSelectMute}
|
|
||||||
/>
|
|
||||||
<ToggleMenuItem
|
|
||||||
Icon={ExpandIcon}
|
|
||||||
label={t("video_tile.change_fit_contain")}
|
|
||||||
checked={cropVideo}
|
|
||||||
onChange={onChangeFitContain}
|
|
||||||
onSelect={onSelectFitContain}
|
|
||||||
/>
|
|
||||||
{/* TODO: Figure out how to make this slider keyboard accessible */}
|
|
||||||
<MenuItem as="div" Icon={VolumeIcon} label={null} onSelect={null}>
|
|
||||||
<Slider
|
|
||||||
className={styles.volumeSlider}
|
|
||||||
label={t("video_tile.volume")}
|
|
||||||
value={localVolume}
|
|
||||||
onValueChange={onChangeLocalVolume}
|
|
||||||
min={0.1}
|
|
||||||
max={1}
|
|
||||||
step={0.01}
|
|
||||||
disabled={locallyMuted}
|
|
||||||
/>
|
|
||||||
</MenuItem>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const tile = (
|
|
||||||
<Tile
|
|
||||||
tileRef={ref}
|
|
||||||
className={classNames(className, {
|
|
||||||
[styles.mirror]: mirror,
|
|
||||||
[styles.speaking]: showSpeakingIndicator && speaking,
|
|
||||||
[styles.cropVideo]: cropVideo,
|
|
||||||
})}
|
|
||||||
style={style}
|
|
||||||
targetWidth={targetWidth}
|
|
||||||
targetHeight={targetHeight}
|
|
||||||
video={video}
|
|
||||||
member={vm.member}
|
|
||||||
videoEnabled={videoEnabled}
|
|
||||||
maximised={maximised}
|
|
||||||
unencryptedWarning={unencryptedWarning}
|
|
||||||
nameTagLeadingIcon={
|
|
||||||
<MicIcon
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
aria-label={audioEnabled ? t("microphone_on") : t("microphone_off")}
|
|
||||||
data-muted={!audioEnabled}
|
|
||||||
className={styles.muteIcon}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
nameTag={nameTag}
|
|
||||||
displayName={displayName}
|
|
||||||
primaryButton={
|
|
||||||
<Menu
|
|
||||||
open={menuOpen}
|
|
||||||
onOpenChange={setMenuOpen}
|
|
||||||
title={nameTag}
|
|
||||||
trigger={
|
|
||||||
<button aria-label={t("common.options")}>
|
|
||||||
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
side="left"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
{menu}
|
|
||||||
</Menu>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu title={nameTag} trigger={tile} hasAccessibleAlternative>
|
|
||||||
{menu}
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
UserMediaTile.displayName = "UserMediaTile";
|
|
||||||
|
|
||||||
interface ScreenShareTileProps {
|
|
||||||
vm: ScreenShareViewModel;
|
|
||||||
className?: string;
|
|
||||||
style?: ComponentProps<typeof animated.div>["style"];
|
|
||||||
targetWidth: number;
|
|
||||||
targetHeight: number;
|
|
||||||
nameTag: string;
|
|
||||||
displayName: string;
|
|
||||||
maximised: boolean;
|
|
||||||
fullscreen: boolean;
|
|
||||||
onToggleFullscreen: (itemId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ScreenShareTile = subscribe<ScreenShareTileProps, HTMLDivElement>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
vm,
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
targetWidth,
|
|
||||||
targetHeight,
|
|
||||||
nameTag,
|
|
||||||
displayName,
|
|
||||||
maximised,
|
|
||||||
fullscreen,
|
|
||||||
onToggleFullscreen,
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const video = useStateObservable(vm.video);
|
|
||||||
const unencryptedWarning = useStateObservable(vm.unencryptedWarning);
|
|
||||||
const onClickFullScreen = useCallback(
|
|
||||||
() => onToggleFullscreen(vm.id),
|
|
||||||
[onToggleFullscreen, vm],
|
|
||||||
);
|
|
||||||
|
|
||||||
const FullScreenIcon = fullscreen ? CollapseIcon : ExpandIcon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tile
|
|
||||||
ref={ref}
|
|
||||||
className={classNames(className, styles.screenshare)}
|
|
||||||
style={style}
|
|
||||||
targetWidth={targetWidth}
|
|
||||||
targetHeight={targetHeight}
|
|
||||||
video={video}
|
|
||||||
member={vm.member}
|
|
||||||
videoEnabled={true}
|
|
||||||
maximised={maximised}
|
|
||||||
unencryptedWarning={unencryptedWarning}
|
|
||||||
nameTag={nameTag}
|
|
||||||
displayName={displayName}
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ScreenShareTile.displayName = "ScreenShareTile";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
vm: MediaViewModel;
|
|
||||||
maximised: boolean;
|
|
||||||
fullscreen: boolean;
|
|
||||||
onToggleFullscreen: (itemId: string) => void;
|
|
||||||
onOpenProfile: () => void;
|
|
||||||
targetWidth: number;
|
|
||||||
targetHeight: number;
|
|
||||||
className?: string;
|
|
||||||
style?: ComponentProps<typeof animated.div>["style"];
|
|
||||||
showSpeakingIndicator: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VideoTile = forwardRef<HTMLDivElement, Props>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
vm,
|
|
||||||
maximised,
|
|
||||||
fullscreen,
|
|
||||||
onToggleFullscreen,
|
|
||||||
onOpenProfile,
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
targetWidth,
|
|
||||||
targetHeight,
|
|
||||||
showSpeakingIndicator,
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// Handle display name changes.
|
|
||||||
// TODO: Move this into the view model
|
|
||||||
const [displayName, setDisplayName] = useReactiveState(
|
|
||||||
() => vm.member?.rawDisplayName ?? "[👻]",
|
|
||||||
[vm.member],
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
if (vm.member) {
|
|
||||||
const updateName = (): void => {
|
|
||||||
setDisplayName(vm.member!.rawDisplayName);
|
|
||||||
};
|
|
||||||
|
|
||||||
vm.member!.on(RoomMemberEvent.Name, updateName);
|
|
||||||
return (): void => {
|
|
||||||
vm.member!.removeListener(RoomMemberEvent.Name, updateName);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [vm.member, setDisplayName]);
|
|
||||||
const nameTag = vm.local
|
|
||||||
? t("video_tile.sfu_participant_local")
|
|
||||||
: displayName;
|
|
||||||
|
|
||||||
if (vm instanceof UserMediaViewModel) {
|
|
||||||
return (
|
|
||||||
<UserMediaTile
|
|
||||||
ref={ref}
|
|
||||||
className={className}
|
|
||||||
style={style}
|
|
||||||
vm={vm}
|
|
||||||
targetWidth={targetWidth}
|
|
||||||
targetHeight={targetHeight}
|
|
||||||
nameTag={nameTag}
|
|
||||||
displayName={displayName}
|
|
||||||
maximised={maximised}
|
|
||||||
onOpenProfile={onOpenProfile}
|
|
||||||
showSpeakingIndicator={showSpeakingIndicator}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<ScreenShareTile
|
|
||||||
ref={ref}
|
|
||||||
className={className}
|
|
||||||
style={style}
|
|
||||||
vm={vm}
|
|
||||||
targetWidth={targetWidth}
|
|
||||||
targetHeight={targetHeight}
|
|
||||||
nameTag={nameTag}
|
|
||||||
displayName={displayName}
|
|
||||||
maximised={maximised}
|
|
||||||
fullscreen={fullscreen}
|
|
||||||
onToggleFullscreen={onToggleFullscreen}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
VideoTile.displayName = "VideoTile";
|
|
||||||
@@ -1,493 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2023 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 {
|
|
||||||
addItems,
|
|
||||||
column,
|
|
||||||
cycleTileSize,
|
|
||||||
fillGaps,
|
|
||||||
forEachCellInArea,
|
|
||||||
Grid,
|
|
||||||
SparseGrid,
|
|
||||||
resize,
|
|
||||||
row,
|
|
||||||
moveTile,
|
|
||||||
} from "../../src/video-grid/BigGrid";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a grid from a string specifying the contents of each cell as a letter.
|
|
||||||
*/
|
|
||||||
function mkGrid(spec: string): Grid {
|
|
||||||
const secondNewline = spec.indexOf("\n", 1);
|
|
||||||
const columns = secondNewline === -1 ? spec.length : secondNewline - 1;
|
|
||||||
const cells = spec.match(/[a-z ]/g) ?? ([] as string[]);
|
|
||||||
const areas = new Set(cells);
|
|
||||||
areas.delete(" "); // Space represents an empty cell, not an area
|
|
||||||
const grid: Grid = { columns, cells: new Array(cells.length) };
|
|
||||||
|
|
||||||
for (const area of areas) {
|
|
||||||
const start = cells.indexOf(area);
|
|
||||||
const end = cells.lastIndexOf(area);
|
|
||||||
const rows = row(end, grid) - row(start, grid) + 1;
|
|
||||||
const columns = column(end, grid) - column(start, grid) + 1;
|
|
||||||
|
|
||||||
forEachCellInArea(start, end, grid, (_c, i) => {
|
|
||||||
grid.cells[i] = {
|
|
||||||
item: { id: area } as unknown as TileDescriptor<unknown>,
|
|
||||||
origin: i === start,
|
|
||||||
rows,
|
|
||||||
columns,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turns a grid into a string showing the contents of each cell as a letter.
|
|
||||||
*/
|
|
||||||
function showGrid(g: Grid): string {
|
|
||||||
let result = "\n";
|
|
||||||
for (let i = 0; i < g.cells.length; i++) {
|
|
||||||
if (i > 0 && i % g.columns == 0) result += "\n";
|
|
||||||
result += g.cells[i]?.item.id ?? " ";
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function testFillGaps(title: string, input: string, output: string): void {
|
|
||||||
test(`fillGaps ${title}`, () => {
|
|
||||||
expect(showGrid(fillGaps(mkGrid(input)))).toBe(output);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
testFillGaps(
|
|
||||||
"does nothing on an empty grid",
|
|
||||||
`
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testFillGaps(
|
|
||||||
"does nothing if there are no gaps",
|
|
||||||
`
|
|
||||||
ab
|
|
||||||
cd
|
|
||||||
ef`,
|
|
||||||
`
|
|
||||||
ab
|
|
||||||
cd
|
|
||||||
ef`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testFillGaps(
|
|
||||||
"fills a gap",
|
|
||||||
`
|
|
||||||
a b
|
|
||||||
cde
|
|
||||||
f`,
|
|
||||||
`
|
|
||||||
cab
|
|
||||||
fde`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testFillGaps(
|
|
||||||
"fills multiple gaps",
|
|
||||||
`
|
|
||||||
a bc
|
|
||||||
defgh
|
|
||||||
ijkl
|
|
||||||
mno`,
|
|
||||||
`
|
|
||||||
aebch
|
|
||||||
difgl
|
|
||||||
mjnok`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testFillGaps(
|
|
||||||
"fills a big gap with 1×1 tiles",
|
|
||||||
`
|
|
||||||
abcd
|
|
||||||
e f
|
|
||||||
g h
|
|
||||||
ijkl`,
|
|
||||||
`
|
|
||||||
abcd
|
|
||||||
ehkf
|
|
||||||
glji`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testFillGaps(
|
|
||||||
"fills a big gap with a large tile",
|
|
||||||
`
|
|
||||||
|
|
||||||
aa
|
|
||||||
bc`,
|
|
||||||
`
|
|
||||||
aa
|
|
||||||
cb`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testFillGaps(
|
|
||||||
"prefers moving around large tiles",
|
|
||||||
`
|
|
||||||
a bc
|
|
||||||
ddde
|
|
||||||
dddf
|
|
||||||
ghij
|
|
||||||
k`,
|
|
||||||
`
|
|
||||||
abce
|
|
||||||
dddf
|
|
||||||
dddj
|
|
||||||
kghi`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testFillGaps(
|
|
||||||
"moves through large tiles if necessary",
|
|
||||||
`
|
|
||||||
a bc
|
|
||||||
dddd
|
|
||||||
efgh
|
|
||||||
i`,
|
|
||||||
`
|
|
||||||
afbc
|
|
||||||
dddd
|
|
||||||
iegh`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testFillGaps(
|
|
||||||
"keeps a large tile from hanging off the bottom",
|
|
||||||
`
|
|
||||||
abcd
|
|
||||||
efgh
|
|
||||||
|
|
||||||
ii
|
|
||||||
ii`,
|
|
||||||
`
|
|
||||||
abcd
|
|
||||||
iigh
|
|
||||||
iief`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testFillGaps(
|
|
||||||
"collapses large tiles trapped at the bottom",
|
|
||||||
`
|
|
||||||
abcd
|
|
||||||
e fg
|
|
||||||
hh
|
|
||||||
hh
|
|
||||||
ii
|
|
||||||
ii`,
|
|
||||||
`
|
|
||||||
abcd
|
|
||||||
hhfg
|
|
||||||
hhie`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testFillGaps(
|
|
||||||
"gives up on pushing large tiles upwards when not possible",
|
|
||||||
`
|
|
||||||
aa
|
|
||||||
aa
|
|
||||||
bccd
|
|
||||||
eccf
|
|
||||||
ghij`,
|
|
||||||
`
|
|
||||||
aadf
|
|
||||||
aaji
|
|
||||||
bcch
|
|
||||||
eccg`,
|
|
||||||
);
|
|
||||||
|
|
||||||
function testCycleTileSize(
|
|
||||||
title: string,
|
|
||||||
tileId: string,
|
|
||||||
input: string,
|
|
||||||
output: string,
|
|
||||||
): void {
|
|
||||||
test(`cycleTileSize ${title}`, () => {
|
|
||||||
const grid = mkGrid(input);
|
|
||||||
const tile = grid.cells.find((c) => c?.item.id === tileId)!.item;
|
|
||||||
expect(showGrid(cycleTileSize(grid, tile))).toBe(output);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
testCycleTileSize(
|
|
||||||
"expands a tile to 2×2 in a 3 column layout",
|
|
||||||
"c",
|
|
||||||
`
|
|
||||||
abc
|
|
||||||
def
|
|
||||||
ghi`,
|
|
||||||
`
|
|
||||||
acc
|
|
||||||
dcc
|
|
||||||
gbe
|
|
||||||
ifh`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testCycleTileSize(
|
|
||||||
"expands a tile to 3×3 in a 4 column layout",
|
|
||||||
"g",
|
|
||||||
`
|
|
||||||
abcd
|
|
||||||
efgh`,
|
|
||||||
`
|
|
||||||
acdh
|
|
||||||
bggg
|
|
||||||
fggg
|
|
||||||
e`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testCycleTileSize(
|
|
||||||
"restores a tile to 1×1",
|
|
||||||
"b",
|
|
||||||
`
|
|
||||||
abbc
|
|
||||||
dbbe
|
|
||||||
fghi
|
|
||||||
jk`,
|
|
||||||
`
|
|
||||||
abhc
|
|
||||||
djge
|
|
||||||
fik`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testCycleTileSize(
|
|
||||||
"expands a tile even in a crowded grid",
|
|
||||||
"c",
|
|
||||||
`
|
|
||||||
abb
|
|
||||||
cbb
|
|
||||||
dde
|
|
||||||
ddf
|
|
||||||
ghi
|
|
||||||
klm`,
|
|
||||||
`
|
|
||||||
abb
|
|
||||||
gbb
|
|
||||||
dde
|
|
||||||
ddf
|
|
||||||
ccm
|
|
||||||
cch
|
|
||||||
lik`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testCycleTileSize(
|
|
||||||
"does nothing if the tile has no room to expand",
|
|
||||||
"c",
|
|
||||||
`
|
|
||||||
abb
|
|
||||||
cbb
|
|
||||||
dde
|
|
||||||
ddf`,
|
|
||||||
`
|
|
||||||
abb
|
|
||||||
cbb
|
|
||||||
dde
|
|
||||||
ddf`,
|
|
||||||
);
|
|
||||||
|
|
||||||
test("cycleTileSize is its own inverse", () => {
|
|
||||||
const input = `
|
|
||||||
abc
|
|
||||||
def
|
|
||||||
ghi
|
|
||||||
jk`;
|
|
||||||
|
|
||||||
const grid = mkGrid(input);
|
|
||||||
let gridAfter = grid;
|
|
||||||
|
|
||||||
const toggle = (tileId: string): void => {
|
|
||||||
const tile = grid.cells.find((c) => c?.item.id === tileId)!.item;
|
|
||||||
gridAfter = cycleTileSize(gridAfter, tile);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle a series of tiles
|
|
||||||
toggle("j");
|
|
||||||
toggle("h");
|
|
||||||
toggle("a");
|
|
||||||
// Now do the same thing in reverse
|
|
||||||
toggle("a");
|
|
||||||
toggle("h");
|
|
||||||
toggle("j");
|
|
||||||
|
|
||||||
// The grid should be back to its original state
|
|
||||||
expect(showGrid(gridAfter)).toBe(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
function testAddItems(
|
|
||||||
title: string,
|
|
||||||
items: TileDescriptor<unknown>[],
|
|
||||||
input: string,
|
|
||||||
output: string,
|
|
||||||
): void {
|
|
||||||
test(`addItems ${title}`, () => {
|
|
||||||
expect(showGrid(addItems(items, mkGrid(input) as SparseGrid) as Grid)).toBe(
|
|
||||||
output,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
testAddItems(
|
|
||||||
"appends 1×1 tiles",
|
|
||||||
["e", "f"].map((i) => ({ id: i }) as unknown as TileDescriptor<unknown>),
|
|
||||||
`
|
|
||||||
aab
|
|
||||||
aac
|
|
||||||
d`,
|
|
||||||
`
|
|
||||||
aab
|
|
||||||
aac
|
|
||||||
def`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testAddItems(
|
|
||||||
"places one tile near another on request",
|
|
||||||
[{ id: "g", placeNear: "b" } as unknown as TileDescriptor<unknown>],
|
|
||||||
`
|
|
||||||
abc
|
|
||||||
def`,
|
|
||||||
`
|
|
||||||
abc
|
|
||||||
g
|
|
||||||
def`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testAddItems(
|
|
||||||
"places items with a large base size",
|
|
||||||
[{ id: "g", largeBaseSize: true } as unknown as TileDescriptor<unknown>],
|
|
||||||
`
|
|
||||||
abc
|
|
||||||
def`,
|
|
||||||
`
|
|
||||||
abc
|
|
||||||
ggf
|
|
||||||
gge
|
|
||||||
d`,
|
|
||||||
);
|
|
||||||
|
|
||||||
function testMoveTile(
|
|
||||||
title: string,
|
|
||||||
from: number,
|
|
||||||
to: number,
|
|
||||||
input: string,
|
|
||||||
output: string,
|
|
||||||
): void {
|
|
||||||
test(`moveTile ${title}`, () => {
|
|
||||||
expect(showGrid(moveTile(mkGrid(input), from, to))).toBe(output);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
testMoveTile(
|
|
||||||
"refuses to move a tile too far to the left",
|
|
||||||
1,
|
|
||||||
-1,
|
|
||||||
`
|
|
||||||
abc`,
|
|
||||||
`
|
|
||||||
abc`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testMoveTile(
|
|
||||||
"refuses to move a tile too far to the right",
|
|
||||||
1,
|
|
||||||
3,
|
|
||||||
`
|
|
||||||
abc`,
|
|
||||||
`
|
|
||||||
abc`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testMoveTile(
|
|
||||||
"moves a large tile to an unoccupied space",
|
|
||||||
3,
|
|
||||||
1,
|
|
||||||
`
|
|
||||||
a b
|
|
||||||
ccd
|
|
||||||
cce`,
|
|
||||||
`
|
|
||||||
acc
|
|
||||||
bcc
|
|
||||||
d e`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testMoveTile(
|
|
||||||
"refuses to move a large tile to an occupied space",
|
|
||||||
3,
|
|
||||||
1,
|
|
||||||
`
|
|
||||||
abb
|
|
||||||
ccd
|
|
||||||
cce`,
|
|
||||||
`
|
|
||||||
abb
|
|
||||||
ccd
|
|
||||||
cce`,
|
|
||||||
);
|
|
||||||
|
|
||||||
function testResize(
|
|
||||||
title: string,
|
|
||||||
columns: number,
|
|
||||||
input: string,
|
|
||||||
output: string,
|
|
||||||
): void {
|
|
||||||
test(`resize ${title}`, () => {
|
|
||||||
expect(showGrid(resize(mkGrid(input), columns))).toBe(output);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
testResize(
|
|
||||||
"contracts the grid",
|
|
||||||
2,
|
|
||||||
`
|
|
||||||
abbb
|
|
||||||
cbbb
|
|
||||||
ddde
|
|
||||||
dddf
|
|
||||||
gh`,
|
|
||||||
`
|
|
||||||
af
|
|
||||||
bb
|
|
||||||
bb
|
|
||||||
dd
|
|
||||||
dd
|
|
||||||
ch
|
|
||||||
eg`,
|
|
||||||
);
|
|
||||||
|
|
||||||
testResize(
|
|
||||||
"expands the grid",
|
|
||||||
4,
|
|
||||||
`
|
|
||||||
af
|
|
||||||
bb
|
|
||||||
bb
|
|
||||||
ch
|
|
||||||
dd
|
|
||||||
dd
|
|
||||||
eg`,
|
|
||||||
`
|
|
||||||
afcd
|
|
||||||
bbbg
|
|
||||||
bbbe
|
|
||||||
h`,
|
|
||||||
);
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2023 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/video-grid/VideoGrid";
|
|
||||||
|
|
||||||
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 }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
23
yarn.lock
23
yarn.lock
@@ -2880,14 +2880,6 @@
|
|||||||
"@react-aria/utils" "^3.13.1"
|
"@react-aria/utils" "^3.13.1"
|
||||||
clsx "^1.1.1"
|
clsx "^1.1.1"
|
||||||
|
|
||||||
"@react-rxjs/core@^0.10.7":
|
|
||||||
version "0.10.7"
|
|
||||||
resolved "https://registry.yarnpkg.com/@react-rxjs/core/-/core-0.10.7.tgz#09951f43a6c80892526ac13d51859098b0e74993"
|
|
||||||
integrity sha512-dornp8pUs9OcdqFKKRh9+I2FVe21gWufNun6RYU1ddts7kUy9i4Thvl0iqcPFbGY61cJQMAJF7dxixWMSD/A/A==
|
|
||||||
dependencies:
|
|
||||||
"@rx-state/core" "0.1.4"
|
|
||||||
use-sync-external-store "^1.0.0"
|
|
||||||
|
|
||||||
"@react-spring/animated@~9.7.3":
|
"@react-spring/animated@~9.7.3":
|
||||||
version "9.7.3"
|
version "9.7.3"
|
||||||
resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.7.3.tgz#4211b1a6d48da0ff474a125e93c0f460ff816e0f"
|
resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.7.3.tgz#4211b1a6d48da0ff474a125e93c0f460ff816e0f"
|
||||||
@@ -3227,11 +3219,6 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.1.tgz#0cb240c147c0dfd0e3eaff4cc060a772d39e155c"
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.1.tgz#0cb240c147c0dfd0e3eaff4cc060a772d39e155c"
|
||||||
integrity sha512-yjk2MAkQmoaPYCSu35RLJ62+dz358nE83VfTePJRp8CG7aMg25mEJYpXFiD+NcevhX8LxD5OP5tktPXnXN7GDw==
|
integrity sha512-yjk2MAkQmoaPYCSu35RLJ62+dz358nE83VfTePJRp8CG7aMg25mEJYpXFiD+NcevhX8LxD5OP5tktPXnXN7GDw==
|
||||||
|
|
||||||
"@rx-state/core@0.1.4":
|
|
||||||
version "0.1.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rx-state/core/-/core-0.1.4.tgz#586dde80be9dbdac31844006a0dcaa2bc7f35a5c"
|
|
||||||
integrity sha512-Z+3hjU2xh1HisLxt+W5hlYX/eGSDaXXP+ns82gq/PLZpkXLu0uwcNUh9RLY3Clq4zT+hSsA3vcpIGt6+UAb8rQ==
|
|
||||||
|
|
||||||
"@sentry-internal/browser-utils@8.18.0":
|
"@sentry-internal/browser-utils@8.18.0":
|
||||||
version "8.18.0"
|
version "8.18.0"
|
||||||
resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.18.0.tgz#b3d06a77bf80e8d00e4cd8fc11a242cb4e9fa534"
|
resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.18.0.tgz#b3d06a77bf80e8d00e4cd8fc11a242cb4e9fa534"
|
||||||
@@ -6988,6 +6975,11 @@ object.values@^1.1.7:
|
|||||||
define-properties "^1.2.0"
|
define-properties "^1.2.0"
|
||||||
es-abstract "^1.22.1"
|
es-abstract "^1.22.1"
|
||||||
|
|
||||||
|
observable-hooks@^4.2.3:
|
||||||
|
version "4.2.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/observable-hooks/-/observable-hooks-4.2.3.tgz#69e3353caafd7887ad9030bd440b053304e8d2d1"
|
||||||
|
integrity sha512-d6fYTIU+9sg1V+CT0GhgoE/ntjIqcy9DGaYGE6ELGVP4ojaWIEsaLvL/05hLOM+AL7aySN4DCTLvj6dDF9T8XA==
|
||||||
|
|
||||||
oidc-client-ts@^3.0.1:
|
oidc-client-ts@^3.0.1:
|
||||||
version "3.0.1"
|
version "3.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/oidc-client-ts/-/oidc-client-ts-3.0.1.tgz#be264fb87c89f74f73863646431c32cd06f5ceb7"
|
resolved "https://registry.yarnpkg.com/oidc-client-ts/-/oidc-client-ts-3.0.1.tgz#be264fb87c89f74f73863646431c32cd06f5ceb7"
|
||||||
@@ -8770,11 +8762,6 @@ use-sidecar@^1.1.2:
|
|||||||
detect-node-es "^1.1.0"
|
detect-node-es "^1.1.0"
|
||||||
tslib "^2.0.0"
|
tslib "^2.0.0"
|
||||||
|
|
||||||
use-sync-external-store@^1.0.0:
|
|
||||||
version "1.2.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
|
|
||||||
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
|
|
||||||
|
|
||||||
usehooks-ts@3.1.0:
|
usehooks-ts@3.1.0:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-3.1.0.tgz#156119f36efc85f1b1952616c02580f140950eca"
|
resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-3.1.0.tgz#156119f36efc85f1b1952616c02580f140950eca"
|
||||||
|
|||||||
Reference in New Issue
Block a user