mirror of
https://github.com/element-hq/element-call.git
synced 2026-08-14 07:20:09 +00:00
Merge branch 'mgcm/feat/better-screenshare-ux' into valere/test/better_screenshare
This commit is contained in:
@@ -260,6 +260,7 @@
|
||||
"expand": "Expand",
|
||||
"mute_for_me": "Mute for me",
|
||||
"muted_for_me": "Muted for me",
|
||||
"screen_share_name": "{{displayName}}'s screen share",
|
||||
"screen_share_volume": "Screen share volume",
|
||||
"volume": "Volume",
|
||||
"waiting_for_media": "Waiting for media..."
|
||||
|
||||
@@ -51,7 +51,7 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
|
||||
.contentFrame();
|
||||
|
||||
// Expect 3 video tiles
|
||||
await expect(frame.locator("video")).toHaveCount(3, {
|
||||
await expect(frame.getByTestId("video")).toHaveCount(3, {
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
@@ -72,12 +72,14 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
|
||||
.contentFrame();
|
||||
|
||||
// Expect 4 (3 + screen share) video tiles
|
||||
await expect(frame.locator("video")).toHaveCount(4, {
|
||||
await expect(frame.getByTestId("video")).toHaveCount(4, {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
frame.locator('video[data-lk-source="screen_share"]'),
|
||||
frame.locator(
|
||||
'video[data-testid="video"][data-lk-source="screen_share"]',
|
||||
),
|
||||
).toHaveCount(1);
|
||||
}
|
||||
|
||||
@@ -113,35 +115,37 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
|
||||
.contentFrame();
|
||||
|
||||
// Expect 5 (2 + screen share) video tiles
|
||||
await expect(frame.locator("video")).toHaveCount(5, {
|
||||
await expect(frame.getByTestId("video")).toHaveCount(5, {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
frame.locator('video[data-lk-source="screen_share"]'),
|
||||
frame.locator(
|
||||
'video[data-testid="video"][data-lk-source="screen_share"]',
|
||||
),
|
||||
).toHaveCount(2);
|
||||
|
||||
// Expect 2 indicators at the bottom
|
||||
await expect(frame.getByTestId("screenshare-indicator")).toHaveCount(2);
|
||||
await expect(frame.getByTestId("spotlight-indicator")).toHaveCount(2);
|
||||
await expect(frame.getByTestId("spotlight-indicator-preview")).toHaveCount(
|
||||
2,
|
||||
);
|
||||
|
||||
// Check the first indicator is visible
|
||||
await expect(
|
||||
frame.getByTestId("screenshare-indicator").first(),
|
||||
frame.getByTestId("spotlight-indicator").first(),
|
||||
).toHaveAttribute("data-visible", "true");
|
||||
|
||||
await carol.page.pause();
|
||||
|
||||
// now click on next
|
||||
await expect(frame.getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await frame.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Check the second indicator is visible
|
||||
await expect(
|
||||
frame.getByTestId("screenshare-indicator").nth(1),
|
||||
frame.getByTestId("spotlight-indicator").nth(1),
|
||||
).toHaveAttribute("data-visible", "true");
|
||||
// the first one should be grayed out
|
||||
await expect(
|
||||
frame.getByTestId("screenshare-indicator").first(),
|
||||
frame.getByTestId("spotlight-indicator").first(),
|
||||
).toHaveAttribute("data-visible", "false");
|
||||
|
||||
// There should be a prev button now
|
||||
|
||||
@@ -22,6 +22,14 @@ Please see LICENSE in the repository root for full details.
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
/* Keep the indicator row above the edge-to-edge footer. */
|
||||
.spotlight.withPreviewIndicator {
|
||||
padding-block-end: calc(
|
||||
var(--call-view-safe-area-inset-bottom, 0px) + var(--cpd-space-2x) +
|
||||
var(--spotlight-indicator-height)
|
||||
);
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
|
||||
@@ -31,10 +31,16 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
useObservableEagerState(minBounds$);
|
||||
const hasPreviewIndicator =
|
||||
useObservableEagerState(model.spotlight.media$).length > 1;
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<div className={styles.spotlight}>
|
||||
<div
|
||||
className={classNames(styles.spotlight, {
|
||||
[styles.withPreviewIndicator]: hasPreviewIndicator,
|
||||
})}
|
||||
>
|
||||
<Slot
|
||||
className={styles.slot}
|
||||
id="spotlight"
|
||||
@@ -54,16 +60,10 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
useUpdateLayout();
|
||||
useVisibleTiles(model.setVisibleTiles);
|
||||
useObservableEagerState(minBounds$);
|
||||
const withIndicators =
|
||||
useObservableEagerState(model.spotlight.media$).length > 1;
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<div
|
||||
className={classNames(styles.spotlight, {
|
||||
[styles.withIndicators]: withIndicators,
|
||||
})}
|
||||
/>
|
||||
<div className={styles.spotlight} />
|
||||
<div className={styles.grid}>
|
||||
{model.grid.map((m) => (
|
||||
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
|
||||
|
||||
@@ -23,8 +23,11 @@ Please see LICENSE in the repository root for full details.
|
||||
margin-block-end: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
.spotlight.withIndicators {
|
||||
margin-block-end: calc(2 * var(--cpd-space-4x) + 2px);
|
||||
.spotlight.withPreviewIndicator {
|
||||
margin-block-end: calc(
|
||||
var(--cpd-space-4x) + var(--cpd-space-2x) +
|
||||
var(--spotlight-indicator-height)
|
||||
);
|
||||
}
|
||||
|
||||
.spotlight > .slot {
|
||||
|
||||
@@ -66,7 +66,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
||||
width,
|
||||
model.grid.length,
|
||||
);
|
||||
const withIndicators = useBehavior(model.spotlight.media$).length > 1;
|
||||
const hasPreviewIndicator = useBehavior(model.spotlight.media$).length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -82,7 +82,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
||||
>
|
||||
<div
|
||||
className={classNames(styles.spotlight, {
|
||||
[styles.withIndicators]: withIndicators,
|
||||
[styles.withPreviewIndicator]: hasPreviewIndicator,
|
||||
})}
|
||||
/>
|
||||
<div className={styles.grid}>
|
||||
|
||||
@@ -52,6 +52,10 @@ layer(compound);
|
||||
|
||||
--call-view-overlay-layer: 1;
|
||||
--call-view-header-footer-layer: 2;
|
||||
|
||||
/* Shared with spotlight layouts that reserve room for the indicator row. */
|
||||
--spotlight-indicator-preview-height: clamp(40px, 12vh, 64px);
|
||||
--spotlight-indicator-height: var(--spotlight-indicator-preview-height);
|
||||
}
|
||||
|
||||
:root,
|
||||
|
||||
@@ -25,6 +25,15 @@ const defaultLiveKitPublishOptions: TrackPublishDefaults = {
|
||||
simulcast: true,
|
||||
videoSimulcastLayers: [VideoPresets.h180, VideoPresets.h360] as VideoPreset[],
|
||||
screenShareEncoding: ScreenSharePresets.h1080fps30.encoding,
|
||||
// Screen shares are published as three layers rather than LiveKit's default
|
||||
// two. The default low layer is only downscaled by 2 (960x540 at full
|
||||
// framerate), which is far more than a small preview needs; adding an
|
||||
// explicit 360p/3fps layer lets subscribers that only render a thumbnail
|
||||
// (such as the spotlight switcher previews) pull a very cheap stream.
|
||||
screenShareSimulcastLayers: [
|
||||
ScreenSharePresets.h360fps3,
|
||||
ScreenSharePresets.h720fps15,
|
||||
] as VideoPreset[],
|
||||
stopMicTrackOnMute: false,
|
||||
videoCodec: "vp8",
|
||||
videoEncoding: VideoPresets.h720.encoding,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.indicator {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-2x);
|
||||
max-inline-size: 200px;
|
||||
padding: var(--cpd-space-1x) var(--cpd-space-3x);
|
||||
border: var(--cpd-border-width-1) solid
|
||||
var(--cpd-color-border-interactive-secondary);
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
background: rgba(from var(--cpd-color-gray-100) r g b / 0.6);
|
||||
color: var(--cpd-color-text-primary);
|
||||
font: var(--cpd-font-body-sm-medium);
|
||||
box-shadow: var(--small-drop-shadow);
|
||||
transition:
|
||||
background-color ease 0.15s,
|
||||
border-color ease 0.15s,
|
||||
color ease 0.15s;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.indicator > svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--cpd-color-icon-primary);
|
||||
}
|
||||
|
||||
.indicator[data-visible="true"] {
|
||||
background: var(--cpd-color-gray-1400);
|
||||
border-color: var(--cpd-color-gray-1400);
|
||||
color: var(--cpd-color-text-on-solid-primary);
|
||||
}
|
||||
|
||||
.indicator[data-visible="true"] > svg {
|
||||
color: var(--cpd-color-icon-on-solid-primary);
|
||||
}
|
||||
|
||||
@media (hover) {
|
||||
.indicator[data-visible="false"]:hover {
|
||||
background: var(--cpd-color-gray-400);
|
||||
}
|
||||
}
|
||||
|
||||
.screenShare {
|
||||
position: relative;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
inline-size: auto;
|
||||
max-inline-size: none;
|
||||
block-size: var(--spotlight-indicator-preview-height);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--cpd-space-2x);
|
||||
}
|
||||
|
||||
.screenShare .name {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset-block-end: 0;
|
||||
inset-inline: 0;
|
||||
padding: var(--cpd-space-3x) var(--cpd-space-2x) var(--cpd-space-1x);
|
||||
text-align: start;
|
||||
font: var(--cpd-font-body-xs-medium);
|
||||
color: var(--cpd-color-text-primary);
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(from var(--cpd-color-bg-canvas-default) r g b / 0.9) 0%,
|
||||
rgba(from var(--cpd-color-bg-canvas-default) r g b / 0.7) 50%,
|
||||
rgba(from var(--cpd-color-bg-canvas-default) r g b / 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.preview {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
block-size: 100%;
|
||||
inline-size: auto;
|
||||
overflow: hidden;
|
||||
background: var(--video-tile-background);
|
||||
}
|
||||
|
||||
.preview::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(from var(--cpd-color-bg-canvas-default) r g b / 0.6);
|
||||
opacity: 0;
|
||||
transition: opacity ease 0.15s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.screenShare[data-visible="false"] .preview::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.screenShare[data-visible="false"]:focus-visible .preview::after {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
@media (hover) {
|
||||
.screenShare[data-visible="false"]:hover .preview::after {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
.preview::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.previewVideo {
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
object-fit: contain;
|
||||
/* Force Firefox to clip the video to the rounded container. */
|
||||
transform: translate(0);
|
||||
}
|
||||
|
||||
.previewFallback {
|
||||
color: var(--cpd-color-icon-primary);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
ComputerIcon,
|
||||
UserProfileSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { type TrackReference } from "@livekit/components-core";
|
||||
import { VideoTrack } from "@livekit/components-react";
|
||||
import classNames from "classnames";
|
||||
import { type FC, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { type MediaViewModel } from "../state/media/MediaViewModel";
|
||||
import { type ScreenShareViewModel } from "../state/media/ScreenShareViewModel";
|
||||
import { type LocalScreenShareViewModel } from "../state/media/LocalScreenShareViewModel";
|
||||
import { type RemoteScreenShareViewModel } from "../state/media/RemoteScreenShareViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import styles from "./SpotlightIndicator.module.css";
|
||||
|
||||
interface SpotlightIndicatorProps {
|
||||
vm: MediaViewModel;
|
||||
visible: boolean;
|
||||
focusable: boolean;
|
||||
/**
|
||||
* Whether to attach the screen share preview. The indicator row is kept
|
||||
* mounted even while hidden so that it can fade, but LiveKit's visibility
|
||||
* detection ignores opacity, so an attached preview would keep streaming
|
||||
* while invisible.
|
||||
*/
|
||||
showPreview: boolean;
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
|
||||
interface ScreenShareIndicatorPreviewProps {
|
||||
vm: ScreenShareViewModel;
|
||||
displayName: string;
|
||||
showPreview: boolean;
|
||||
}
|
||||
|
||||
interface ScreenShareIndicatorPreviewContentProps {
|
||||
video: TrackReference | undefined;
|
||||
videoEnabled: boolean;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
const screenShareAspectRatio = (video: TrackReference | undefined): number => {
|
||||
const { width, height } = video?.publication.dimensions ?? {};
|
||||
return width && height ? width / height : 16 / 9;
|
||||
};
|
||||
|
||||
const ScreenShareIndicatorPreviewContent: FC<
|
||||
ScreenShareIndicatorPreviewContentProps
|
||||
> = ({ video, videoEnabled, displayName }) => {
|
||||
const [aspectRatio, setAspectRatio] = useState(() =>
|
||||
screenShareAspectRatio(video),
|
||||
);
|
||||
|
||||
useEffect(() => setAspectRatio(screenShareAspectRatio(video)), [video]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={styles.preview} style={{ aspectRatio }}>
|
||||
{video !== undefined && videoEnabled ? (
|
||||
<VideoTrack
|
||||
trackRef={video}
|
||||
tabIndex={-1}
|
||||
disablePictureInPicture
|
||||
className={styles.previewVideo}
|
||||
data-testid="spotlight-indicator-preview"
|
||||
onLoadedMetadata={(event): void => {
|
||||
const { videoWidth, videoHeight } = event.currentTarget;
|
||||
if (videoWidth > 0 && videoHeight > 0)
|
||||
setAspectRatio(videoWidth / videoHeight);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ComputerIcon
|
||||
aria-hidden
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.previewFallback}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.name}>{displayName}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface LocalScreenShareIndicatorPreviewProps {
|
||||
vm: LocalScreenShareViewModel;
|
||||
displayName: string;
|
||||
showPreview: boolean;
|
||||
}
|
||||
|
||||
const LocalScreenShareIndicatorPreview: FC<
|
||||
LocalScreenShareIndicatorPreviewProps
|
||||
> = ({ vm, displayName, showPreview }) => {
|
||||
const video = useBehavior(vm.video$);
|
||||
|
||||
return (
|
||||
<ScreenShareIndicatorPreviewContent
|
||||
video={video}
|
||||
videoEnabled={showPreview}
|
||||
displayName={displayName}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface RemoteScreenShareIndicatorPreviewProps {
|
||||
vm: RemoteScreenShareViewModel;
|
||||
displayName: string;
|
||||
showPreview: boolean;
|
||||
}
|
||||
|
||||
const RemoteScreenShareIndicatorPreview: FC<
|
||||
RemoteScreenShareIndicatorPreviewProps
|
||||
> = ({ vm, displayName, showPreview }) => {
|
||||
const video = useBehavior(vm.video$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
|
||||
return (
|
||||
<ScreenShareIndicatorPreviewContent
|
||||
video={video}
|
||||
videoEnabled={videoEnabled && showPreview}
|
||||
displayName={displayName}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ScreenShareIndicatorPreview: FC<ScreenShareIndicatorPreviewProps> = ({
|
||||
vm,
|
||||
displayName,
|
||||
showPreview,
|
||||
}) =>
|
||||
vm.local ? (
|
||||
<LocalScreenShareIndicatorPreview
|
||||
vm={vm}
|
||||
displayName={displayName}
|
||||
showPreview={showPreview}
|
||||
/>
|
||||
) : (
|
||||
<RemoteScreenShareIndicatorPreview
|
||||
vm={vm}
|
||||
displayName={displayName}
|
||||
showPreview={showPreview}
|
||||
/>
|
||||
);
|
||||
|
||||
export const SpotlightIndicator: FC<SpotlightIndicatorProps> = ({
|
||||
vm,
|
||||
visible,
|
||||
focusable,
|
||||
showPreview,
|
||||
onClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const displayName = useBehavior(vm.displayName$);
|
||||
const screenShare = vm.type === "screen share";
|
||||
const label = screenShare
|
||||
? t("video_tile.screen_share_name", { displayName })
|
||||
: displayName;
|
||||
const onPreviewIndicatorClick = useCallback(
|
||||
() => onClick(vm.id),
|
||||
[onClick, vm.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
data-testid="spotlight-indicator"
|
||||
data-id={vm.id}
|
||||
data-type={screenShare ? "screen share" : "user"}
|
||||
className={classNames(styles.indicator, {
|
||||
[styles.screenShare]: screenShare,
|
||||
})}
|
||||
data-visible={visible}
|
||||
aria-current={visible}
|
||||
aria-label={label}
|
||||
onClick={onPreviewIndicatorClick}
|
||||
tabIndex={focusable ? undefined : -1}
|
||||
>
|
||||
{screenShare ? (
|
||||
<ScreenShareIndicatorPreview
|
||||
vm={vm}
|
||||
displayName={displayName}
|
||||
showPreview={showPreview}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<UserProfileSolidIcon aria-hidden width={16} height={16} />
|
||||
<span className={styles.name}>{label}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
SpotlightIndicator.displayName = "SpotlightIndicator";
|
||||
@@ -168,6 +168,9 @@ Please see LICENSE in the repository root for full details.
|
||||
.tile:hover button {
|
||||
opacity: 1;
|
||||
}
|
||||
.tile .indicators > button {
|
||||
opacity: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.tile:has(:focus-visible) > div > button,
|
||||
@@ -180,32 +183,27 @@ Please see LICENSE in the repository root for full details.
|
||||
gap: var(--cpd-space-2x);
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
inset-block-end: calc(-1 * var(--cpd-space-6x));
|
||||
inset-block-start: calc(100% + var(--cpd-space-2x));
|
||||
width: 100%;
|
||||
justify-content: start;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
overscroll-behavior-inline: contain;
|
||||
transition: opacity ease 0.15s;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.indicators.show {
|
||||
.indicators.show,
|
||||
.indicators:has(:focus-visible) {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tile[data-maximised="true"] .indicators {
|
||||
inset-block-end: calc(-1 * var(--cpd-space-4x) - 2px);
|
||||
justify-content: center;
|
||||
.tile[data-maximised="true"] .indicators > button:first-child {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.indicators > .item {
|
||||
flex-basis: 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);
|
||||
.tile[data-maximised="true"] .indicators > button:last-child {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { axe } from "vitest-axe";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type RemoteTrackPublication } from "livekit-client";
|
||||
|
||||
import { SpotlightTile } from "./SpotlightTile";
|
||||
import {
|
||||
@@ -77,23 +78,326 @@ test("SpotlightTile is accessible", async () => {
|
||||
);
|
||||
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
// Each name appears both in the item's name tag and in its indicator
|
||||
// button; the name tag comes first in the DOM
|
||||
const [aliceNameTag] = screen.getAllByText("Alice");
|
||||
const [bobNameTag] = screen.getAllByText("Bob");
|
||||
// Alice should be in the spotlight, with her name and avatar on the
|
||||
// first page
|
||||
screen.getByText("Alice");
|
||||
expect(isInaccessible(aliceNameTag)).toBe(false);
|
||||
const aliceAvatar = screen.getByRole("img");
|
||||
expect(screen.queryByRole("button", { name: "common.back" })).toBe(null);
|
||||
// Bob should be out of the spotlight, and therefore invisible
|
||||
expect(isInaccessible(screen.getByText("Bob"))).toBe(true);
|
||||
expect(isInaccessible(bobNameTag)).toBe(true);
|
||||
// Now navigate to Bob
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
screen.getByText("Bob");
|
||||
expect(isInaccessible(bobNameTag)).toBe(false);
|
||||
expect(screen.getByRole("img")).not.toBe(aliceAvatar);
|
||||
expect(isInaccessible(screen.getByText("Alice"))).toBe(true);
|
||||
expect(isInaccessible(aliceNameTag)).toBe(true);
|
||||
// Clicking Alice's indicator button brings her back into the spotlight
|
||||
await user.click(screen.getByRole("button", { name: "Alice" }));
|
||||
expect(isInaccessible(aliceNameTag)).toBe(false);
|
||||
expect(isInaccessible(bobNameTag)).toBe(true);
|
||||
// Can toggle whether the tile is expanded
|
||||
await user.click(screen.getByRole("button", { name: "Expand" }));
|
||||
expect(toggleExpanded).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("screen share indicator is labeled with the sharer's name", async () => {
|
||||
const userVm = mockRemoteMedia(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{
|
||||
rawDisplayName: "Alice",
|
||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||
},
|
||||
mockRemoteParticipant({}),
|
||||
);
|
||||
const screenShareVm = mockRemoteScreenShare(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{
|
||||
rawDisplayName: "Alice",
|
||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||
},
|
||||
mockRemoteParticipant({}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SpotlightTile
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([userVm, screenShareVm]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
)
|
||||
}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
onToggleExpanded={vi.fn()}
|
||||
showIndicators
|
||||
showNameTags
|
||||
showRingingStatus
|
||||
focusable
|
||||
/>,
|
||||
);
|
||||
|
||||
const [userNameTag, screenShareNameTag] = screen.getAllByText("Alice");
|
||||
expect(isInaccessible(screenShareNameTag)).toBe(true);
|
||||
const indicator = screen.getByRole("button", {
|
||||
name: "Alice's screen share",
|
||||
});
|
||||
const scrollIntoView = vi.spyOn(indicator, "scrollIntoView");
|
||||
await user.click(indicator);
|
||||
expect(isInaccessible(screenShareNameTag)).toBe(false);
|
||||
expect(isInaccessible(userNameTag)).toBe(true);
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
});
|
||||
|
||||
test("screen share indicators preview the shared screen", () => {
|
||||
const userVm = mockRemoteMedia(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{ rawDisplayName: "Alice" },
|
||||
mockRemoteParticipant({}),
|
||||
);
|
||||
const screenShareVm = mockRemoteScreenShare(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{ rawDisplayName: "Alice" },
|
||||
mockRemoteParticipant({}),
|
||||
);
|
||||
|
||||
render(
|
||||
<SpotlightTile
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([userVm, screenShareVm]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
)
|
||||
}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
onToggleExpanded={vi.fn()}
|
||||
showIndicators
|
||||
showNameTags
|
||||
showRingingStatus
|
||||
focusable
|
||||
/>,
|
||||
);
|
||||
|
||||
const [userIndicator, screenShareIndicator] = screen.getAllByTestId(
|
||||
"spotlight-indicator",
|
||||
);
|
||||
expect(userIndicator).toHaveAttribute("data-type", "user");
|
||||
expect(screenShareIndicator).toHaveAttribute("data-type", "screen share");
|
||||
expect(screen.getAllByTestId("spotlight-indicator-preview")).toHaveLength(1);
|
||||
expect(screenShareIndicator).toContainElement(
|
||||
screen.getByTestId("spotlight-indicator-preview"),
|
||||
);
|
||||
expect(screenShareIndicator.lastElementChild).toHaveTextContent("Alice");
|
||||
expect(screenShareIndicator.lastElementChild).not.toHaveTextContent(
|
||||
"screen share",
|
||||
);
|
||||
});
|
||||
|
||||
test("screen share preview uses the published aspect ratio", () => {
|
||||
const screenShareVm = mockRemoteScreenShare(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{ rawDisplayName: "Alice" },
|
||||
mockRemoteParticipant({
|
||||
getTrackPublication: () =>
|
||||
({
|
||||
dimensions: { width: 3440, height: 1440 },
|
||||
}) as RemoteTrackPublication,
|
||||
}),
|
||||
);
|
||||
const userVm = mockRemoteMedia(
|
||||
mockRtcMembership("@bob:example.org", "BBBB"),
|
||||
{ rawDisplayName: "Bob" },
|
||||
mockRemoteParticipant({}),
|
||||
);
|
||||
|
||||
render(
|
||||
<SpotlightTile
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([screenShareVm, userVm]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
)
|
||||
}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
onToggleExpanded={vi.fn()}
|
||||
showIndicators
|
||||
showNameTags
|
||||
showRingingStatus
|
||||
focusable
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("spotlight-indicator-preview").parentElement,
|
||||
).toHaveStyle({ aspectRatio: 3440 / 1440 });
|
||||
});
|
||||
|
||||
test("screen share indicator falls back to an icon without a video track", () => {
|
||||
const screenShareVm = mockRemoteScreenShare(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{ rawDisplayName: "Alice" },
|
||||
mockRemoteParticipant({ getTrackPublication: () => undefined }),
|
||||
);
|
||||
const userVm = mockRemoteMedia(
|
||||
mockRtcMembership("@bob:example.org", "BBBB"),
|
||||
{ rawDisplayName: "Bob" },
|
||||
mockRemoteParticipant({}),
|
||||
);
|
||||
|
||||
render(
|
||||
<SpotlightTile
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([screenShareVm, userVm]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
)
|
||||
}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
onToggleExpanded={vi.fn()}
|
||||
showIndicators
|
||||
showNameTags
|
||||
showRingingStatus
|
||||
focusable
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("spotlight-indicator-preview")).toBe(null);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Alice's screen share" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("screen share indicator hides the preview while disconnected", () => {
|
||||
const screenShareVm = mockRemoteScreenShare(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{ rawDisplayName: "Alice" },
|
||||
mockRemoteParticipant({}),
|
||||
);
|
||||
const userVm = mockRemoteMedia(
|
||||
mockRtcMembership("@bob:example.org", "BBBB"),
|
||||
{ rawDisplayName: "Bob" },
|
||||
mockRemoteParticipant({}),
|
||||
);
|
||||
vi.spyOn(screenShareVm, "videoEnabled$", "get").mockReturnValue(
|
||||
constant(false),
|
||||
);
|
||||
|
||||
render(
|
||||
<SpotlightTile
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([screenShareVm, userVm]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
)
|
||||
}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
onToggleExpanded={vi.fn()}
|
||||
showIndicators
|
||||
showNameTags
|
||||
showRingingStatus
|
||||
focusable
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("spotlight-indicator-preview")).toBe(null);
|
||||
});
|
||||
|
||||
test("screen share indicator does not attach a hidden preview", () => {
|
||||
const screenShareVm = mockRemoteScreenShare(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{ rawDisplayName: "Alice" },
|
||||
mockRemoteParticipant({}),
|
||||
);
|
||||
const userVm = mockRemoteMedia(
|
||||
mockRtcMembership("@bob:example.org", "BBBB"),
|
||||
{ rawDisplayName: "Bob" },
|
||||
mockRemoteParticipant({}),
|
||||
);
|
||||
|
||||
render(
|
||||
<SpotlightTile
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([screenShareVm, userVm]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
)
|
||||
}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
onToggleExpanded={vi.fn()}
|
||||
showIndicators={false}
|
||||
showNameTags
|
||||
showRingingStatus
|
||||
focusable
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId("spotlight-indicator")).toHaveLength(2);
|
||||
expect(screen.queryByTestId("spotlight-indicator-preview")).toBe(null);
|
||||
});
|
||||
|
||||
test("off-screen screen shares hide their full-size video", () => {
|
||||
const screenShareA = mockRemoteScreenShare(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{ rawDisplayName: "Alice" },
|
||||
mockRemoteParticipant({}),
|
||||
);
|
||||
const screenShareB = mockRemoteScreenShare(
|
||||
mockRtcMembership("@bob:example.org", "BBBB"),
|
||||
{ rawDisplayName: "Bob" },
|
||||
mockRemoteParticipant({}),
|
||||
);
|
||||
vi.spyOn(screenShareB, "id", "get").mockReturnValue("screenshare-b");
|
||||
|
||||
render(
|
||||
<SpotlightTile
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([screenShareA, screenShareB]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
)
|
||||
}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
onToggleExpanded={vi.fn()}
|
||||
showIndicators
|
||||
showNameTags
|
||||
showRingingStatus
|
||||
focusable
|
||||
/>,
|
||||
);
|
||||
|
||||
// Hiding the off-screen element gives it zero dimensions, so the thumbnail
|
||||
// drives LiveKit's adaptive stream quality.
|
||||
const [itemA, itemB] = screen.getAllByTestId("videoTile");
|
||||
expect(itemA).toHaveAttribute("data-video-enabled", "true");
|
||||
expect(itemB).toHaveAttribute("data-video-enabled", "false");
|
||||
});
|
||||
|
||||
test("Screen share volume UI is shown when screen share has audio", async () => {
|
||||
const vm = mockRemoteScreenShare(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
|
||||
+96
-13
@@ -54,6 +54,7 @@ import { Slider } from "../Slider";
|
||||
import { platform } from "../Platform";
|
||||
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
|
||||
import { RingingStatus } from "./RingingStatus";
|
||||
import { SpotlightIndicator } from "./SpotlightIndicator";
|
||||
|
||||
interface SpotlightItemBaseProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
@@ -164,23 +165,37 @@ const SpotlightScreenShareItem: FC<SpotlightScreenShareItemProps> = ({
|
||||
|
||||
interface SpotlightRemoteScreenShareItemProps extends SpotlightMemberMediaItemBaseProps {
|
||||
vm: RemoteScreenShareViewModel;
|
||||
visibleInSpotlight: boolean;
|
||||
}
|
||||
|
||||
const SpotlightRemoteScreenShareItem: FC<
|
||||
SpotlightRemoteScreenShareItemProps
|
||||
> = ({ vm, ...props }) => {
|
||||
> = ({ vm, visibleInSpotlight, ...props }) => {
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
return (
|
||||
<SpotlightScreenShareItem vm={vm} videoEnabled={videoEnabled} {...props} />
|
||||
<SpotlightScreenShareItem
|
||||
vm={vm}
|
||||
videoEnabled={videoEnabled && visibleInSpotlight}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface SpotlightMemberMediaItemProps extends SpotlightItemBaseProps {
|
||||
vm: MemberMediaViewModel;
|
||||
/**
|
||||
* Whether any part of this item is currently scrolled into view.
|
||||
*
|
||||
* LiveKit sizes adaptive streams from the largest attached video element,
|
||||
* even when it is off-screen. Hiding that element gives it zero dimensions,
|
||||
* allowing the thumbnail to request the low-quality layer.
|
||||
*/
|
||||
visibleInSpotlight: boolean;
|
||||
}
|
||||
|
||||
const SpotlightMemberMediaItem: FC<SpotlightMemberMediaItemProps> = ({
|
||||
vm,
|
||||
visibleInSpotlight,
|
||||
...props
|
||||
}) => {
|
||||
const video = useBehavior(vm.video$);
|
||||
@@ -198,9 +213,17 @@ const SpotlightMemberMediaItem: FC<SpotlightMemberMediaItemProps> = ({
|
||||
if (vm.type === "user")
|
||||
return <SpotlightUserMediaItem vm={vm} {...baseProps} />;
|
||||
return vm.local ? (
|
||||
<SpotlightScreenShareItem vm={vm} videoEnabled {...baseProps} />
|
||||
<SpotlightScreenShareItem
|
||||
vm={vm}
|
||||
videoEnabled={visibleInSpotlight}
|
||||
{...baseProps}
|
||||
/>
|
||||
) : (
|
||||
<SpotlightRemoteScreenShareItem vm={vm} {...baseProps} />
|
||||
<SpotlightRemoteScreenShareItem
|
||||
vm={vm}
|
||||
visibleInSpotlight={visibleInSpotlight}
|
||||
{...baseProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -250,6 +273,10 @@ interface SpotlightItemProps {
|
||||
background: "solid" | "transparent";
|
||||
focusable: boolean;
|
||||
intersectionObserver$: Observable<IntersectionObserver>;
|
||||
/**
|
||||
* Whether any part of this item is currently scrolled into view.
|
||||
*/
|
||||
visibleInSpotlight: boolean;
|
||||
/**
|
||||
* Whether this item should act as a scroll snapping point.
|
||||
*/
|
||||
@@ -268,6 +295,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
background,
|
||||
focusable,
|
||||
intersectionObserver$,
|
||||
visibleInSpotlight,
|
||||
snap,
|
||||
className,
|
||||
"aria-hidden": ariaHidden,
|
||||
@@ -315,7 +343,11 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
{...baseProps}
|
||||
/>
|
||||
) : (
|
||||
<SpotlightMemberMediaItem vm={vm} {...baseProps} />
|
||||
<SpotlightMemberMediaItem
|
||||
vm={vm}
|
||||
visibleInSpotlight={visibleInSpotlight}
|
||||
{...baseProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -424,11 +456,17 @@ export const SpotlightTile: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
|
||||
const indicatorsRef = useRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const maximised = useBehavior(vm.maximised$);
|
||||
const background = useBehavior(vm.background$);
|
||||
const media = useBehavior(vm.media$);
|
||||
const [visibleId, setVisibleId] = useState<string | undefined>(media[0]?.id);
|
||||
// Track partially visible items separately so their video is shown before
|
||||
// they become the active spotlight.
|
||||
const [visibleInSpotlightIds, setVisibleInSpotlightIds] = useState<
|
||||
ReadonlySet<string>
|
||||
>(() => new Set(media[0] === undefined ? [] : [media[0].id]));
|
||||
const latestMedia = useLatest(media);
|
||||
const latestVisibleId = useLatest(visibleId);
|
||||
const visibleIndex = media.findIndex((vm) => vm.id === visibleId);
|
||||
@@ -467,11 +505,24 @@ export const SpotlightTile: FC<Props> = ({
|
||||
(r) =>
|
||||
new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries.find((e) => e.isIntersecting);
|
||||
const visible = entries.find((e) => e.intersectionRatio >= 0.5);
|
||||
if (visible !== undefined)
|
||||
setVisibleId(visible.target.getAttribute("data-id")!);
|
||||
setVisibleInSpotlightIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const e of entries) {
|
||||
const id = e.target.getAttribute("data-id")!;
|
||||
if (e.isIntersecting) next.add(id);
|
||||
else next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
{ root: r, threshold: 0.5 },
|
||||
// The 0 threshold tells us which items are on screen at all,
|
||||
// while 0.5 tells us which one is spotlighted. 1 is a safety
|
||||
// net, since the ratio reported when crossing 0.5 can land
|
||||
// fractionally below it
|
||||
{ root: r, threshold: [0, 0.5, 1] },
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -502,6 +553,31 @@ export const SpotlightTile: FC<Props> = ({
|
||||
setScrollToId(media[visibleIndex + 1].id);
|
||||
}, [latestVisibleId, latestMedia, setScrollToId]);
|
||||
|
||||
const onPreviewIndicatorClick = useCallback(
|
||||
(id: string) => setScrollToId(id),
|
||||
[setScrollToId],
|
||||
);
|
||||
|
||||
// Chrome re-snaps to the remaining snap point on its own, but Safari
|
||||
// doesn't re-snap when the set of snap points changes, so we have to
|
||||
// scroll to the target media explicitly
|
||||
useEffect(() => {
|
||||
if (scrollToId !== null) {
|
||||
for (const item of ourRef.current?.querySelectorAll("[data-id]") ?? []) {
|
||||
if (item.getAttribute("data-id") === scrollToId) {
|
||||
item.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const indicator of indicatorsRef.current?.children ?? []) {
|
||||
if (indicator.getAttribute("data-id") === scrollToId) {
|
||||
indicator.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [scrollToId, ourRef, indicatorsRef]);
|
||||
|
||||
const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon;
|
||||
|
||||
return (
|
||||
@@ -533,6 +609,10 @@ export const SpotlightTile: FC<Props> = ({
|
||||
background={background}
|
||||
focusable={focusable}
|
||||
intersectionObserver$={intersectionObserver$}
|
||||
// Show the target video before it reaches the spotlight.
|
||||
visibleInSpotlight={
|
||||
visibleInSpotlightIds.has(vm.id) || scrollToId === vm.id
|
||||
}
|
||||
// This is how we get the container to scroll to the right media
|
||||
// when the previous/next buttons are clicked: we temporarily
|
||||
// remove all scroll snap points except for just the one media
|
||||
@@ -582,18 +662,21 @@ export const SpotlightTile: FC<Props> = ({
|
||||
<ChevronRightIcon aria-hidden width={24} height={24} />
|
||||
</button>
|
||||
)}
|
||||
{!expanded && (
|
||||
{!expanded && media.length > 1 && (
|
||||
<div
|
||||
ref={indicatorsRef}
|
||||
className={classNames(styles.indicators, {
|
||||
[styles.show]: showIndicators && media.length > 1,
|
||||
[styles.show]: showIndicators,
|
||||
})}
|
||||
>
|
||||
{media.map((vm) => (
|
||||
<div
|
||||
data-testid="screenshare-indicator"
|
||||
<SpotlightIndicator
|
||||
key={vm.id}
|
||||
className={styles.item}
|
||||
data-visible={vm.id === visibleId}
|
||||
vm={vm}
|
||||
visible={vm.id === visibleId}
|
||||
focusable={focusable}
|
||||
showPreview={showIndicators}
|
||||
onClick={onPreviewIndicatorClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,9 @@ window.matchMedia = global.matchMedia = (): MediaQueryList =>
|
||||
removeEventListener: () => {},
|
||||
}) as Partial<MediaQueryList> as MediaQueryList;
|
||||
|
||||
// Not implemented by jsdom
|
||||
window.HTMLElement.prototype.scrollIntoView = (): void => {};
|
||||
|
||||
const storage: Record<string, string> = {};
|
||||
const localStoragePolyfill = {
|
||||
getItem(key: string) {
|
||||
|
||||
Reference in New Issue
Block a user