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

View File

@@ -260,6 +260,7 @@
"expand": "Expand", "expand": "Expand",
"mute_for_me": "Mute for me", "mute_for_me": "Mute for me",
"muted_for_me": "Muted for me", "muted_for_me": "Muted for me",
"screen_share_name": "{{displayName}}'s screen share",
"screen_share_volume": "Screen share volume", "screen_share_volume": "Screen share volume",
"volume": "Volume", "volume": "Volume",
"waiting_for_media": "Waiting for media..." "waiting_for_media": "Waiting for media..."

View File

@@ -51,7 +51,7 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
.contentFrame(); .contentFrame();
// Expect 3 video tiles // Expect 3 video tiles
await expect(frame.locator("video")).toHaveCount(3, { await expect(frame.getByTestId("video")).toHaveCount(3, {
timeout: 10000, timeout: 10000,
}); });
} }
@@ -72,12 +72,14 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
.contentFrame(); .contentFrame();
// Expect 4 (3 + screen share) video tiles // Expect 4 (3 + screen share) video tiles
await expect(frame.locator("video")).toHaveCount(4, { await expect(frame.getByTestId("video")).toHaveCount(4, {
timeout: 5000, timeout: 5000,
}); });
await expect( await expect(
frame.locator('video[data-lk-source="screen_share"]'), frame.locator(
'video[data-testid="video"][data-lk-source="screen_share"]',
),
).toHaveCount(1); ).toHaveCount(1);
} }
@@ -113,35 +115,37 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
.contentFrame(); .contentFrame();
// Expect 5 (2 + screen share) video tiles // Expect 5 (2 + screen share) video tiles
await expect(frame.locator("video")).toHaveCount(5, { await expect(frame.getByTestId("video")).toHaveCount(5, {
timeout: 5000, timeout: 5000,
}); });
await expect( await expect(
frame.locator('video[data-lk-source="screen_share"]'), frame.locator(
'video[data-testid="video"][data-lk-source="screen_share"]',
),
).toHaveCount(2); ).toHaveCount(2);
// Expect 2 indicators at the bottom await expect(frame.getByTestId("spotlight-indicator")).toHaveCount(2);
await expect(frame.getByTestId("screenshare-indicator")).toHaveCount(2); await expect(frame.getByTestId("spotlight-indicator-preview")).toHaveCount(
2,
);
// Check the first indicator is visible // Check the first indicator is visible
await expect( await expect(
frame.getByTestId("screenshare-indicator").first(), frame.getByTestId("spotlight-indicator").first(),
).toHaveAttribute("data-visible", "true"); ).toHaveAttribute("data-visible", "true");
await carol.page.pause();
// now click on next // now click on next
await expect(frame.getByRole("button", { name: "Next" })).toBeVisible(); await expect(frame.getByRole("button", { name: "Next" })).toBeVisible();
await frame.getByRole("button", { name: "Next" }).click(); await frame.getByRole("button", { name: "Next" }).click();
// Check the second indicator is visible // Check the second indicator is visible
await expect( await expect(
frame.getByTestId("screenshare-indicator").nth(1), frame.getByTestId("spotlight-indicator").nth(1),
).toHaveAttribute("data-visible", "true"); ).toHaveAttribute("data-visible", "true");
// the first one should be grayed out // the first one should be grayed out
await expect( await expect(
frame.getByTestId("screenshare-indicator").first(), frame.getByTestId("spotlight-indicator").first(),
).toHaveAttribute("data-visible", "false"); ).toHaveAttribute("data-visible", "false");
// There should be a prev button now // There should be a prev button now

View File

@@ -22,6 +22,14 @@ Please see LICENSE in the repository root for full details.
place-items: center; 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 /* 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 */ unconditionally select the container so we can use cq units */
@container spotlight (width > 0) { @container spotlight (width > 0) {

View File

@@ -31,10 +31,16 @@ export const makeSpotlightLandscapeLayout: CallLayout<
}): ReactNode { }): ReactNode {
useUpdateLayout(); useUpdateLayout();
useObservableEagerState(minBounds$); useObservableEagerState(minBounds$);
const hasPreviewIndicator =
useObservableEagerState(model.spotlight.media$).length > 1;
return ( return (
<div ref={ref} className={styles.layer}> <div ref={ref} className={styles.layer}>
<div className={styles.spotlight}> <div
className={classNames(styles.spotlight, {
[styles.withPreviewIndicator]: hasPreviewIndicator,
})}
>
<Slot <Slot
className={styles.slot} className={styles.slot}
id="spotlight" id="spotlight"
@@ -54,16 +60,10 @@ export const makeSpotlightLandscapeLayout: CallLayout<
useUpdateLayout(); useUpdateLayout();
useVisibleTiles(model.setVisibleTiles); useVisibleTiles(model.setVisibleTiles);
useObservableEagerState(minBounds$); useObservableEagerState(minBounds$);
const withIndicators =
useObservableEagerState(model.spotlight.media$).length > 1;
return ( return (
<div ref={ref} className={styles.layer}> <div ref={ref} className={styles.layer}>
<div <div className={styles.spotlight} />
className={classNames(styles.spotlight, {
[styles.withIndicators]: withIndicators,
})}
/>
<div className={styles.grid}> <div className={styles.grid}>
{model.grid.map((m) => ( {model.grid.map((m) => (
<Slot key={m.id} className={styles.slot} id={m.id} model={m} /> <Slot key={m.id} className={styles.slot} id={m.id} model={m} />

View File

@@ -23,8 +23,11 @@ Please see LICENSE in the repository root for full details.
margin-block-end: var(--cpd-space-4x); margin-block-end: var(--cpd-space-4x);
} }
.spotlight.withIndicators { .spotlight.withPreviewIndicator {
margin-block-end: calc(2 * var(--cpd-space-4x) + 2px); margin-block-end: calc(
var(--cpd-space-4x) + var(--cpd-space-2x) +
var(--spotlight-indicator-height)
);
} }
.spotlight > .slot { .spotlight > .slot {

View File

@@ -66,7 +66,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
width, width,
model.grid.length, model.grid.length,
); );
const withIndicators = useBehavior(model.spotlight.media$).length > 1; const hasPreviewIndicator = useBehavior(model.spotlight.media$).length > 1;
return ( return (
<div <div
@@ -82,7 +82,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
> >
<div <div
className={classNames(styles.spotlight, { className={classNames(styles.spotlight, {
[styles.withIndicators]: withIndicators, [styles.withPreviewIndicator]: hasPreviewIndicator,
})} })}
/> />
<div className={styles.grid}> <div className={styles.grid}>

View File

@@ -52,6 +52,10 @@ layer(compound);
--call-view-overlay-layer: 1; --call-view-overlay-layer: 1;
--call-view-header-footer-layer: 2; --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, :root,

View File

@@ -25,6 +25,15 @@ const defaultLiveKitPublishOptions: TrackPublishDefaults = {
simulcast: true, simulcast: true,
videoSimulcastLayers: [VideoPresets.h180, VideoPresets.h360] as VideoPreset[], videoSimulcastLayers: [VideoPresets.h180, VideoPresets.h360] as VideoPreset[],
screenShareEncoding: ScreenSharePresets.h1080fps30.encoding, 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, stopMicTrackOnMute: false,
videoCodec: "vp8", videoCodec: "vp8",
videoEncoding: VideoPresets.h720.encoding, videoEncoding: VideoPresets.h720.encoding,

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);
}

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";

View File

@@ -168,6 +168,9 @@ Please see LICENSE in the repository root for full details.
.tile:hover button { .tile:hover button {
opacity: 1; opacity: 1;
} }
.tile .indicators > button {
opacity: unset;
}
} }
.tile:has(:focus-visible) > div > button, .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); gap: var(--cpd-space-2x);
position: absolute; position: absolute;
inset-inline-start: 0; inset-inline-start: 0;
inset-block-end: calc(-1 * var(--cpd-space-6x)); inset-block-start: calc(100% + var(--cpd-space-2x));
width: 100%; width: 100%;
justify-content: start; justify-content: start;
overflow-x: auto;
scrollbar-width: none;
overscroll-behavior-inline: contain;
transition: opacity ease 0.15s; transition: opacity ease 0.15s;
opacity: 0; opacity: 0;
pointer-events: none;
} }
.indicators.show { .indicators.show,
.indicators:has(:focus-visible) {
opacity: 1; opacity: 1;
pointer-events: auto;
} }
.tile[data-maximised="true"] .indicators { .tile[data-maximised="true"] .indicators > button:first-child {
inset-block-end: calc(-1 * var(--cpd-space-4x) - 2px); margin-inline-start: auto;
justify-content: center;
} }
.indicators > .item { .tile[data-maximised="true"] .indicators > button:last-child {
flex-basis: 32px; margin-inline-end: auto;
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);
} }

View File

@@ -11,6 +11,7 @@ import { axe } from "vitest-axe";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { type RemoteTrackPublication } from "livekit-client";
import { SpotlightTile } from "./SpotlightTile"; import { SpotlightTile } from "./SpotlightTile";
import { import {
@@ -77,23 +78,326 @@ test("SpotlightTile is accessible", async () => {
); );
expect(await axe(container)).toHaveNoViolations(); 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 // Alice should be in the spotlight, with her name and avatar on the
// first page // first page
screen.getByText("Alice"); expect(isInaccessible(aliceNameTag)).toBe(false);
const aliceAvatar = screen.getByRole("img"); const aliceAvatar = screen.getByRole("img");
expect(screen.queryByRole("button", { name: "common.back" })).toBe(null); expect(screen.queryByRole("button", { name: "common.back" })).toBe(null);
// Bob should be out of the spotlight, and therefore invisible // 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 // Now navigate to Bob
await user.click(screen.getByRole("button", { name: "Next" })); await user.click(screen.getByRole("button", { name: "Next" }));
screen.getByText("Bob"); expect(isInaccessible(bobNameTag)).toBe(false);
expect(screen.getByRole("img")).not.toBe(aliceAvatar); 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 // Can toggle whether the tile is expanded
await user.click(screen.getByRole("button", { name: "Expand" })); await user.click(screen.getByRole("button", { name: "Expand" }));
expect(toggleExpanded).toHaveBeenCalled(); 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 () => { test("Screen share volume UI is shown when screen share has audio", async () => {
const vm = mockRemoteScreenShare( const vm = mockRemoteScreenShare(
mockRtcMembership("@alice:example.org", "AAAA"), mockRtcMembership("@alice:example.org", "AAAA"),

View File

@@ -54,6 +54,7 @@ import { Slider } from "../Slider";
import { platform } from "../Platform"; import { platform } from "../Platform";
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
import { RingingStatus } from "./RingingStatus"; import { RingingStatus } from "./RingingStatus";
import { SpotlightIndicator } from "./SpotlightIndicator";
interface SpotlightItemBaseProps { interface SpotlightItemBaseProps {
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
@@ -164,23 +165,37 @@ const SpotlightScreenShareItem: FC<SpotlightScreenShareItemProps> = ({
interface SpotlightRemoteScreenShareItemProps extends SpotlightMemberMediaItemBaseProps { interface SpotlightRemoteScreenShareItemProps extends SpotlightMemberMediaItemBaseProps {
vm: RemoteScreenShareViewModel; vm: RemoteScreenShareViewModel;
visibleInSpotlight: boolean;
} }
const SpotlightRemoteScreenShareItem: FC< const SpotlightRemoteScreenShareItem: FC<
SpotlightRemoteScreenShareItemProps SpotlightRemoteScreenShareItemProps
> = ({ vm, ...props }) => { > = ({ vm, visibleInSpotlight, ...props }) => {
const videoEnabled = useBehavior(vm.videoEnabled$); const videoEnabled = useBehavior(vm.videoEnabled$);
return ( return (
<SpotlightScreenShareItem vm={vm} videoEnabled={videoEnabled} {...props} /> <SpotlightScreenShareItem
vm={vm}
videoEnabled={videoEnabled && visibleInSpotlight}
{...props}
/>
); );
}; };
interface SpotlightMemberMediaItemProps extends SpotlightItemBaseProps { interface SpotlightMemberMediaItemProps extends SpotlightItemBaseProps {
vm: MemberMediaViewModel; 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> = ({ const SpotlightMemberMediaItem: FC<SpotlightMemberMediaItemProps> = ({
vm, vm,
visibleInSpotlight,
...props ...props
}) => { }) => {
const video = useBehavior(vm.video$); const video = useBehavior(vm.video$);
@@ -198,9 +213,17 @@ const SpotlightMemberMediaItem: FC<SpotlightMemberMediaItemProps> = ({
if (vm.type === "user") if (vm.type === "user")
return <SpotlightUserMediaItem vm={vm} {...baseProps} />; return <SpotlightUserMediaItem vm={vm} {...baseProps} />;
return vm.local ? ( 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"; background: "solid" | "transparent";
focusable: boolean; focusable: boolean;
intersectionObserver$: Observable<IntersectionObserver>; 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. * Whether this item should act as a scroll snapping point.
*/ */
@@ -268,6 +295,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
background, background,
focusable, focusable,
intersectionObserver$, intersectionObserver$,
visibleInSpotlight,
snap, snap,
className, className,
"aria-hidden": ariaHidden, "aria-hidden": ariaHidden,
@@ -315,7 +343,11 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
{...baseProps} {...baseProps}
/> />
) : ( ) : (
<SpotlightMemberMediaItem vm={vm} {...baseProps} /> <SpotlightMemberMediaItem
vm={vm}
visibleInSpotlight={visibleInSpotlight}
{...baseProps}
/>
); );
}; };
@@ -424,11 +456,17 @@ export const SpotlightTile: FC<Props> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null); const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
const indicatorsRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const maximised = useBehavior(vm.maximised$); const maximised = useBehavior(vm.maximised$);
const background = useBehavior(vm.background$); const background = useBehavior(vm.background$);
const media = useBehavior(vm.media$); const media = useBehavior(vm.media$);
const [visibleId, setVisibleId] = useState<string | undefined>(media[0]?.id); 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 latestMedia = useLatest(media);
const latestVisibleId = useLatest(visibleId); const latestVisibleId = useLatest(visibleId);
const visibleIndex = media.findIndex((vm) => vm.id === visibleId); const visibleIndex = media.findIndex((vm) => vm.id === visibleId);
@@ -467,11 +505,24 @@ export const SpotlightTile: FC<Props> = ({
(r) => (r) =>
new IntersectionObserver( new IntersectionObserver(
(entries) => { (entries) => {
const visible = entries.find((e) => e.isIntersecting); const visible = entries.find((e) => e.intersectionRatio >= 0.5);
if (visible !== undefined) if (visible !== undefined)
setVisibleId(visible.target.getAttribute("data-id")!); 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); setScrollToId(media[visibleIndex + 1].id);
}, [latestVisibleId, latestMedia, setScrollToId]); }, [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; const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon;
return ( return (
@@ -533,6 +609,10 @@ export const SpotlightTile: FC<Props> = ({
background={background} background={background}
focusable={focusable} focusable={focusable}
intersectionObserver$={intersectionObserver$} 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 // This is how we get the container to scroll to the right media
// when the previous/next buttons are clicked: we temporarily // when the previous/next buttons are clicked: we temporarily
// remove all scroll snap points except for just the one media // 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} /> <ChevronRightIcon aria-hidden width={24} height={24} />
</button> </button>
)} )}
{!expanded && ( {!expanded && media.length > 1 && (
<div <div
ref={indicatorsRef}
className={classNames(styles.indicators, { className={classNames(styles.indicators, {
[styles.show]: showIndicators && media.length > 1, [styles.show]: showIndicators,
})} })}
> >
{media.map((vm) => ( {media.map((vm) => (
<div <SpotlightIndicator
data-testid="screenshare-indicator"
key={vm.id} key={vm.id}
className={styles.item} vm={vm}
data-visible={vm.id === visibleId} visible={vm.id === visibleId}
focusable={focusable}
showPreview={showIndicators}
onClick={onPreviewIndicatorClick}
/> />
))} ))}
</div> </div>

View File

@@ -51,6 +51,9 @@ window.matchMedia = global.matchMedia = (): MediaQueryList =>
removeEventListener: () => {}, removeEventListener: () => {},
}) as Partial<MediaQueryList> as MediaQueryList; }) as Partial<MediaQueryList> as MediaQueryList;
// Not implemented by jsdom
window.HTMLElement.prototype.scrollIntoView = (): void => {};
const storage: Record<string, string> = {}; const storage: Record<string, string> = {};
const localStoragePolyfill = { const localStoragePolyfill = {
getItem(key: string) { getItem(key: string) {