From 0fafe181cece109e651405d0b70a627304f26875 Mon Sep 17 00:00:00 2001 From: Milton Moura Date: Wed, 29 Jul 2026 00:46:54 +0000 Subject: [PATCH] Add multiple screenshare previews instead of line indicators Signed-off-by: Milton Moura --- locales/en/app.json | 1 + playwright/widget/screen-share.test.ts | 28 +- src/grid/SpotlightLandscapeLayout.module.css | 8 + src/grid/SpotlightLandscapeLayout.tsx | 16 +- src/grid/SpotlightPortraitLayout.module.css | 7 +- src/grid/SpotlightPortraitLayout.tsx | 4 +- src/index.css | 4 + src/livekit/options.ts | 9 + src/tile/SpotlightIndicator.module.css | 136 ++++++++ src/tile/SpotlightIndicator.tsx | 203 ++++++++++++ src/tile/SpotlightTile.module.css | 32 +- src/tile/SpotlightTile.test.tsx | 312 ++++++++++++++++++- src/tile/SpotlightTile.tsx | 109 ++++++- src/vitest.setup.ts | 3 + 14 files changed, 814 insertions(+), 58 deletions(-) create mode 100644 src/tile/SpotlightIndicator.module.css create mode 100644 src/tile/SpotlightIndicator.tsx diff --git a/locales/en/app.json b/locales/en/app.json index 3ff956a6a..2c5a47374 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -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..." diff --git a/playwright/widget/screen-share.test.ts b/playwright/widget/screen-share.test.ts index c6b03c3f9..50aa4cf23 100644 --- a/playwright/widget/screen-share.test.ts +++ b/playwright/widget/screen-share.test.ts @@ -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 diff --git a/src/grid/SpotlightLandscapeLayout.module.css b/src/grid/SpotlightLandscapeLayout.module.css index ddc4a3957..5b65689fc 100644 --- a/src/grid/SpotlightLandscapeLayout.module.css +++ b/src/grid/SpotlightLandscapeLayout.module.css @@ -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) { diff --git a/src/grid/SpotlightLandscapeLayout.tsx b/src/grid/SpotlightLandscapeLayout.tsx index d76890c54..3f5d51af1 100644 --- a/src/grid/SpotlightLandscapeLayout.tsx +++ b/src/grid/SpotlightLandscapeLayout.tsx @@ -31,10 +31,16 @@ export const makeSpotlightLandscapeLayout: CallLayout< }): ReactNode { useUpdateLayout(); useObservableEagerState(minBounds$); + const hasPreviewIndicator = + useObservableEagerState(model.spotlight.media$).length > 1; return (
-
+
1; return (
-
+
{model.grid.map((m) => ( diff --git a/src/grid/SpotlightPortraitLayout.module.css b/src/grid/SpotlightPortraitLayout.module.css index c7a6d14b2..1a9b4c19c 100644 --- a/src/grid/SpotlightPortraitLayout.module.css +++ b/src/grid/SpotlightPortraitLayout.module.css @@ -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 { diff --git a/src/grid/SpotlightPortraitLayout.tsx b/src/grid/SpotlightPortraitLayout.tsx index 6939e0826..0374d447f 100644 --- a/src/grid/SpotlightPortraitLayout.tsx +++ b/src/grid/SpotlightPortraitLayout.tsx @@ -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 (
diff --git a/src/index.css b/src/index.css index 660745692..7d8980d75 100644 --- a/src/index.css +++ b/src/index.css @@ -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, diff --git a/src/livekit/options.ts b/src/livekit/options.ts index 1d4cad774..7a5e4e269 100644 --- a/src/livekit/options.ts +++ b/src/livekit/options.ts @@ -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, diff --git a/src/tile/SpotlightIndicator.module.css b/src/tile/SpotlightIndicator.module.css new file mode 100644 index 000000000..02ff81b1e --- /dev/null +++ b/src/tile/SpotlightIndicator.module.css @@ -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); +} diff --git a/src/tile/SpotlightIndicator.tsx b/src/tile/SpotlightIndicator.tsx new file mode 100644 index 000000000..52ade07a1 --- /dev/null +++ b/src/tile/SpotlightIndicator.tsx @@ -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 ( + <> + + {video !== undefined && videoEnabled ? ( + { + const { videoWidth, videoHeight } = event.currentTarget; + if (videoWidth > 0 && videoHeight > 0) + setAspectRatio(videoWidth / videoHeight); + }} + /> + ) : ( + + )} + + {displayName} + + ); +}; + +interface LocalScreenShareIndicatorPreviewProps { + vm: LocalScreenShareViewModel; + displayName: string; + showPreview: boolean; +} + +const LocalScreenShareIndicatorPreview: FC< + LocalScreenShareIndicatorPreviewProps +> = ({ vm, displayName, showPreview }) => { + const video = useBehavior(vm.video$); + + return ( + + ); +}; + +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 ( + + ); +}; + +const ScreenShareIndicatorPreview: FC = ({ + vm, + displayName, + showPreview, +}) => + vm.local ? ( + + ) : ( + + ); + +export const SpotlightIndicator: FC = ({ + 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 ( + + ); +}; + +SpotlightIndicator.displayName = "SpotlightIndicator"; diff --git a/src/tile/SpotlightTile.module.css b/src/tile/SpotlightTile.module.css index 22f62a9b5..b06cb4428 100644 --- a/src/tile/SpotlightTile.module.css +++ b/src/tile/SpotlightTile.module.css @@ -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; } diff --git a/src/tile/SpotlightTile.test.tsx b/src/tile/SpotlightTile.test.tsx index 7924e557d..06dad445f 100644 --- a/src/tile/SpotlightTile.test.tsx +++ b/src/tile/SpotlightTile.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + // 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"), diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index d21a7f5f5..26f328638 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -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; @@ -164,23 +165,37 @@ const SpotlightScreenShareItem: FC = ({ interface SpotlightRemoteScreenShareItemProps extends SpotlightMemberMediaItemBaseProps { vm: RemoteScreenShareViewModel; + visibleInSpotlight: boolean; } const SpotlightRemoteScreenShareItem: FC< SpotlightRemoteScreenShareItemProps -> = ({ vm, ...props }) => { +> = ({ vm, visibleInSpotlight, ...props }) => { const videoEnabled = useBehavior(vm.videoEnabled$); return ( - + ); }; 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 = ({ vm, + visibleInSpotlight, ...props }) => { const video = useBehavior(vm.video$); @@ -198,9 +213,17 @@ const SpotlightMemberMediaItem: FC = ({ if (vm.type === "user") return ; return vm.local ? ( - + ) : ( - + ); }; @@ -250,6 +273,10 @@ interface SpotlightItemProps { background: "solid" | "transparent"; focusable: boolean; intersectionObserver$: Observable; + /** + * 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 = ({ background, focusable, intersectionObserver$, + visibleInSpotlight, snap, className, "aria-hidden": ariaHidden, @@ -315,7 +343,11 @@ const SpotlightItem: FC = ({ {...baseProps} /> ) : ( - + ); }; @@ -424,11 +456,17 @@ export const SpotlightTile: FC = ({ }) => { const { t } = useTranslation(); const [ourRef, root$] = useObservableRef(null); + const indicatorsRef = useRef(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(media[0]?.id); + // Track partially visible items separately so their video is shown before + // they become the active spotlight. + const [visibleInSpotlightIds, setVisibleInSpotlightIds] = useState< + ReadonlySet + >(() => 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 = ({ (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 = ({ 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 = ({ 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 = ({ )} - {!expanded && ( + {!expanded && media.length > 1 && (
1, + [styles.show]: showIndicators, })} > {media.map((vm) => ( -
))}
diff --git a/src/vitest.setup.ts b/src/vitest.setup.ts index be7179de4..aef6c2256 100644 --- a/src/vitest.setup.ts +++ b/src/vitest.setup.ts @@ -51,6 +51,9 @@ window.matchMedia = global.matchMedia = (): MediaQueryList => removeEventListener: () => {}, }) as Partial as MediaQueryList; +// Not implemented by jsdom +window.HTMLElement.prototype.scrollIntoView = (): void => {}; + const storage: Record = {}; const localStoragePolyfill = { getItem(key: string) {