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) {