Merge branch 'livekit' into valere/auto_fit_based_on_video_ratio

This commit is contained in:
Valere
2026-03-09 14:30:54 +01:00
17 changed files with 424 additions and 105 deletions

View File

@@ -84,7 +84,6 @@ Please see LICENSE in the repository root for full details.
.expand {
appearance: none;
cursor: pointer;
opacity: 0;
padding: var(--cpd-space-2x);
border: none;
border-radius: var(--cpd-radius-pill-effect);
@@ -108,6 +107,35 @@ Please see LICENSE in the repository root for full details.
z-index: 1;
}
.volumeSlider {
width: 100%;
min-width: 172px;
}
/* Disable the hover effect for the screen share volume menu button */
.volumeMenuItem:hover {
background: transparent;
cursor: default;
}
.volumeMenuItem {
gap: var(--cpd-space-3x);
}
.menuMuteButton {
appearance: none;
background: none;
border: none;
padding: 0;
cursor: pointer;
display: flex;
}
/* Make icons change color with the theme */
.menuMuteButton > svg {
color: var(--cpd-color-icon-primary);
}
.expand > svg {
display: block;
color: var(--cpd-color-icon-primary);
@@ -119,17 +147,22 @@ Please see LICENSE in the repository root for full details.
}
}
.expand:active {
.expand:active,
.expand[data-state="open"] {
background: var(--cpd-color-gray-100);
}
@media (hover) {
.tile > div > button {
opacity: 0;
}
.tile:hover > div > button {
opacity: 1;
}
}
.tile:has(:focus-visible) > div > button {
.tile:has(:focus-visible) > div > button,
.tile > div:has([data-state="open"]) > button {
opacity: 1;
}

View File

@@ -9,6 +9,7 @@ import { test, expect, vi } from "vitest";
import { isInaccessible, render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import userEvent from "@testing-library/user-event";
import { TooltipProvider } from "@vector-im/compound-web";
import { SpotlightTile } from "./SpotlightTile";
import {
@@ -18,6 +19,7 @@ import {
mockLocalMedia,
mockRemoteMedia,
mockRemoteParticipant,
mockRemoteScreenShare,
} from "../utils/test";
import { SpotlightTileViewModel } from "../state/TileViewModel";
import { constant } from "../state/Behavior";
@@ -78,3 +80,63 @@ test("SpotlightTile is accessible", async () => {
await user.click(screen.getByRole("button", { name: "Expand" }));
expect(toggleExpanded).toHaveBeenCalled();
});
test("Screen share volume UI is shown when screen share has audio", async () => {
const vm = mockRemoteScreenShare(
mockRtcMembership("@alice:example.org", "AAAA"),
{},
mockRemoteParticipant({}),
);
vi.spyOn(vm, "audioEnabled$", "get").mockReturnValue(constant(true));
const toggleExpanded = vi.fn();
const { container } = render(
<TooltipProvider>
<SpotlightTile
vm={new SpotlightTileViewModel(constant([vm]), constant(false))}
targetWidth={300}
targetHeight={200}
expanded={false}
onToggleExpanded={toggleExpanded}
showIndicators
focusable
/>
</TooltipProvider>,
);
expect(await axe(container)).toHaveNoViolations();
// Volume menu button should exist
expect(screen.queryByRole("button", { name: /volume/i })).toBeInTheDocument();
});
test("Screen share volume UI is hidden when screen share has no audio", async () => {
const vm = mockRemoteScreenShare(
mockRtcMembership("@alice:example.org", "AAAA"),
{},
mockRemoteParticipant({}),
);
vi.spyOn(vm, "audioEnabled$", "get").mockReturnValue(constant(false));
const toggleExpanded = vi.fn();
const { container } = render(
<SpotlightTile
vm={new SpotlightTileViewModel(constant([vm]), constant(false))}
targetWidth={300}
targetHeight={200}
expanded={false}
onToggleExpanded={toggleExpanded}
showIndicators
focusable
/>,
);
expect(await axe(container)).toHaveNoViolations();
// Volume menu button should not exist
expect(
screen.queryByRole("button", { name: /volume/i }),
).not.toBeInTheDocument();
});

View File

@@ -20,6 +20,10 @@ import {
CollapseIcon,
ChevronLeftIcon,
ChevronRightIcon,
VolumeOffIcon,
VolumeOnIcon,
VolumeOffSolidIcon,
VolumeOnSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { animated } from "@react-spring/web";
import { type Observable, map } from "rxjs";
@@ -27,6 +31,7 @@ import { useObservableRef } from "observable-hooks";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { Menu, MenuItem } from "@vector-im/compound-web";
import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
@@ -45,6 +50,8 @@ import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
import { type ScreenShareViewModel } from "../state/media/ScreenShareViewModel";
import { type RemoteScreenShareViewModel } from "../state/media/RemoteScreenShareViewModel";
import { type MediaViewModel } from "../state/media/MediaViewModel";
import { Slider } from "../Slider";
import { platform } from "../Platform";
interface SpotlightItemBaseProps {
ref?: Ref<HTMLDivElement>;
@@ -240,6 +247,73 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
SpotlightItem.displayName = "SpotlightItem";
interface ScreenShareVolumeButtonProps {
vm: RemoteScreenShareViewModel;
}
const ScreenShareVolumeButton: FC<ScreenShareVolumeButtonProps> = ({ vm }) => {
const { t } = useTranslation();
const audioEnabled = useBehavior(vm.audioEnabled$);
const playbackMuted = useBehavior(vm.playbackMuted$);
const playbackVolume = useBehavior(vm.playbackVolume$);
const VolumeIcon = playbackMuted ? VolumeOffIcon : VolumeOnIcon;
const VolumeSolidIcon = playbackMuted
? VolumeOffSolidIcon
: VolumeOnSolidIcon;
const [volumeMenuOpen, setVolumeMenuOpen] = useState(false);
const onMuteButtonClick = useCallback(() => vm.togglePlaybackMuted(), [vm]);
const onVolumeChange = useCallback(
(v: number) => vm.adjustPlaybackVolume(v),
[vm],
);
const onVolumeCommit = useCallback(() => vm.commitPlaybackVolume(), [vm]);
return (
audioEnabled && (
<Menu
open={volumeMenuOpen}
onOpenChange={setVolumeMenuOpen}
title={t("video_tile.screen_share_volume")}
side="top"
align="end"
trigger={
<button
className={styles.expand}
aria-label={t("video_tile.screen_share_volume")}
>
<VolumeSolidIcon aria-hidden width={20} height={20} />
</button>
}
>
<MenuItem
as="div"
className={styles.volumeMenuItem}
onSelect={null}
label={null}
hideChevron={true}
>
<button className={styles.menuMuteButton} onClick={onMuteButtonClick}>
<VolumeIcon aria-hidden width={24} height={24} />
</button>
<Slider
className={styles.volumeSlider}
label={t("video_tile.volume")}
value={playbackVolume}
min={0}
max={1}
step={0.01}
onValueChange={onVolumeChange}
onValueCommit={onVolumeCommit}
/>
</MenuItem>
</Menu>
)
);
};
interface Props {
ref?: Ref<HTMLDivElement>;
vm: SpotlightTileViewModel;
@@ -274,6 +348,7 @@ export const SpotlightTile: FC<Props> = ({
const latestMedia = useLatest(media);
const latestVisibleId = useLatest(visibleId);
const visibleIndex = media.findIndex((vm) => vm.id === visibleId);
const visibleMedia = media.at(visibleIndex);
const canGoBack = visibleIndex > 0;
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
@@ -381,16 +456,21 @@ export const SpotlightTile: FC<Props> = ({
/>
))}
</div>
<div className={styles.bottomRightButtons}>
<button
className={classNames(styles.expand)}
aria-label={"maximise"}
onClick={onToggleFullscreen}
tabIndex={focusable ? undefined : -1}
>
<FullScreenIcon aria-hidden width={20} height={20} />
</button>
<div className={styles.bottomRightButtons}>
{visibleMedia?.type === "screen share" && !visibleMedia.local && (
<ScreenShareVolumeButton vm={visibleMedia} />
)}
{platform === "desktop" && (
<button
className={classNames(styles.expand)}
aria-label={"maximise"}
onClick={onToggleFullscreen}
tabIndex={focusable ? undefined : -1}
>
<FullScreenIcon aria-hidden width={20} height={20} />
</button>
)}
{onToggleExpanded && (
<button
className={classNames(styles.expand)}