Merge branch 'mgcm/feat/better-screenshare-ux' into valere/test/better_screenshare

This commit is contained in:
Valere
2026-07-29 10:21:17 +02:00
14 changed files with 814 additions and 58 deletions
+1
View File
@@ -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..."
+16 -12
View File
@@ -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) {
+8 -8
View File
@@ -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} />
+5 -2
View File
@@ -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 {
+2 -2
View File
@@ -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}>
+4
View File
@@ -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,
+9
View File
@@ -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,
+136
View File
@@ -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);
}
+203
View File
@@ -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";
+15 -17
View File
@@ -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;
}
+308 -4
View File
@@ -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
View File
@@ -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>
+3
View File
@@ -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) {