Merge branch 'livekit' into robin/switch-camera-tile

This commit is contained in:
Robin
2025-08-14 16:39:08 +02:00
80 changed files with 2782 additions and 1783 deletions

View File

@@ -9,7 +9,6 @@ import { type RemoteTrackPublication } from "livekit-client";
import { test, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { of } from "rxjs";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { GridTile } from "./GridTile";
@@ -17,6 +16,7 @@ import { mockRtcMembership, withRemoteMedia } from "../utils/test";
import { GridTileViewModel } from "../state/TileViewModel";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import type { CallViewModel } from "../state/CallViewModel";
import { constant } from "../state/Behavior";
global.IntersectionObserver = class MockIntersectionObserver {
public observe(): void {}
@@ -53,13 +53,13 @@ test("GridTile is accessible", async () => {
memberships: [],
} as unknown as MatrixRTCSession;
const cVm = {
reactions$: of({}),
handsRaised$: of({}),
reactions$: constant({}),
handsRaised$: constant({}),
} as Partial<CallViewModel> as CallViewModel;
const { container } = render(
<ReactionsSenderProvider vm={cVm} rtcSession={fakeRtcSession}>
<GridTile
vm={new GridTileViewModel(of(vm))}
vm={new GridTileViewModel(constant(vm))}
onOpenProfile={() => {}}
targetWidth={300}
targetHeight={200}

View File

@@ -36,7 +36,7 @@ import {
ToggleMenuItem,
Menu,
} from "@vector-im/compound-web";
import { useObservableEagerState, useObservableState } from "observable-hooks";
import { useObservableEagerState } from "observable-hooks";
import styles from "./GridTile.module.css";
import {
@@ -50,6 +50,7 @@ import { useLatest } from "../useLatest";
import { type GridTileViewModel } from "../state/TileViewModel";
import { useMergedRefs } from "../useMergedRefs";
import { useReactionsSender } from "../reactions/useReactionsSender";
import { useBehavior } from "../useBehavior";
interface TileProps {
ref?: Ref<HTMLDivElement>;
@@ -84,19 +85,19 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
}) => {
const { toggleRaisedHand } = useReactionsSender();
const { t } = useTranslation();
const video = useObservableEagerState(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
const video = useBehavior(vm.video$);
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
const encryptionStatus = useBehavior(vm.encryptionStatus$);
const audioStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.audioStreamStats$);
const videoStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.videoStreamStats$);
const audioEnabled = useObservableEagerState(vm.audioEnabled$);
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
const speaking = useObservableEagerState(vm.speaking$);
const cropVideo = useObservableEagerState(vm.cropVideo$);
const audioEnabled = useBehavior(vm.audioEnabled$);
const videoEnabled = useBehavior(vm.videoEnabled$);
const speaking = useBehavior(vm.speaking$);
const cropVideo = useBehavior(vm.cropVideo$);
const onSelectFitContain = useCallback(
(e: Event) => {
e.preventDefault();
@@ -104,8 +105,8 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
},
[vm],
);
const handRaised = useObservableState(vm.handRaised$);
const reaction = useObservableState(vm.reaction$);
const handRaised = useBehavior(vm.handRaised$);
const reaction = useBehavior(vm.reaction$);
const AudioIcon = locallyMuted
? VolumeOffSolidIcon
@@ -210,9 +211,9 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
...props
}) => {
const { t } = useTranslation();
const mirror = useObservableEagerState(vm.mirror$);
const alwaysShow = useObservableEagerState(vm.alwaysShow$);
const switchCamera = useObservableEagerState(vm.switchCamera$);
const mirror = useBehavior(vm.mirror$);
const alwaysShow = useBehavior(vm.alwaysShow$);
const switchCamera = useBehavior(vm.switchCamera$);
const latestAlwaysShow = useLatest(alwaysShow);
const onSelectAlwaysShow = useCallback(
@@ -274,8 +275,8 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
...props
}) => {
const { t } = useTranslation();
const locallyMuted = useObservableEagerState(vm.locallyMuted$);
const localVolume = useObservableEagerState(vm.localVolume$);
const locallyMuted = useBehavior(vm.locallyMuted$);
const localVolume = useBehavior(vm.localVolume$);
const onSelectMute = useCallback(
(e: Event) => {
e.preventDefault();
@@ -346,8 +347,8 @@ export const GridTile: FC<GridTileProps> = ({
}) => {
const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const media = useObservableEagerState(vm.media$);
const displayName = useObservableEagerState(media.displayname$);
const media = useBehavior(vm.media$);
const displayName = useBehavior(media.displayName$);
if (media instanceof LocalUserMediaViewModel) {
return (

View File

@@ -88,40 +88,48 @@ Please see LICENSE in the repository root for full details.
padding: var(--cpd-space-2x);
border: none;
border-radius: var(--cpd-radius-pill-effect);
background: var(--cpd-color-alpha-gray-1400);
background: rgba(from var(--cpd-color-gray-100) r g b / 0.6);
box-shadow: var(--small-drop-shadow);
transition:
opacity 0.15s,
background-color 0.1s;
position: absolute;
z-index: 1;
--inset: 6px;
inset-block-end: var(--inset);
inset-inline-end: var(--inset);
}
.bottomRightButtons {
display: flex;
gap: var(--cpd-space-2x);
position: absolute;
inset-block-end: var(--cpd-space-1x);
inset-inline-end: var(--cpd-space-1x);
z-index: 1;
}
.expand > svg {
display: block;
color: var(--cpd-color-icon-on-solid-primary);
color: var(--cpd-color-icon-primary);
}
@media (hover) {
.expand:hover {
background: var(--cpd-color-bg-action-primary-hovered);
background: var(--cpd-color-gray-400);
}
}
.expand:active {
background: var(--cpd-color-bg-action-primary-pressed);
background: var(--cpd-color-gray-100);
}
@media (hover) {
.tile:hover > button {
.tile:hover > div > button {
opacity: 1;
}
}
.tile:has(:focus-visible) > button {
.tile:has(:focus-visible) > div > button {
opacity: 1;
}

View File

@@ -9,7 +9,6 @@ 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 { of } from "rxjs";
import { SpotlightTile } from "./SpotlightTile";
import {
@@ -20,6 +19,7 @@ import {
withRemoteMedia,
} from "../utils/test";
import { SpotlightTileViewModel } from "../state/TileViewModel";
import { constant } from "../state/Behavior";
global.IntersectionObserver = class MockIntersectionObserver {
public observe(): void {}
@@ -48,7 +48,12 @@ test("SpotlightTile is accessible", async () => {
const toggleExpanded = vi.fn();
const { container } = render(
<SpotlightTile
vm={new SpotlightTileViewModel(of([vm1, vm2]), of(false))}
vm={
new SpotlightTileViewModel(
constant([vm1, vm2]),
constant(false),
)
}
targetWidth={300}
targetHeight={200}
expanded={false}

View File

@@ -23,12 +23,14 @@ import {
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { animated } from "@react-spring/web";
import { type Observable, map } from "rxjs";
import { useObservableEagerState, useObservableRef } from "observable-hooks";
import { useObservableRef } from "observable-hooks";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { type RoomMember } from "matrix-js-sdk";
import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
import { MediaView } from "./MediaView";
import styles from "./SpotlightTile.module.css";
import {
@@ -43,6 +45,7 @@ import { useMergedRefs } from "../useMergedRefs";
import { useReactiveState } from "../useReactiveState";
import { useLatest } from "../useLatest";
import { type SpotlightTileViewModel } from "../state/TileViewModel";
import { useBehavior } from "../useBehavior";
interface SpotlightItemBaseProps {
ref?: Ref<HTMLDivElement>;
@@ -73,7 +76,7 @@ const SpotlightLocalUserMediaItem: FC<SpotlightLocalUserMediaItemProps> = ({
vm,
...props
}) => {
const mirror = useObservableEagerState(vm.mirror$);
const mirror = useBehavior(vm.mirror$);
return <MediaView mirror={mirror} {...props} />;
};
@@ -87,8 +90,8 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
vm,
...props
}) => {
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
const cropVideo = useObservableEagerState(vm.cropVideo$);
const videoEnabled = useBehavior(vm.videoEnabled$);
const cropVideo = useBehavior(vm.cropVideo$);
const baseProps: SpotlightUserMediaItemBaseProps &
RefAttributes<HTMLDivElement> = {
@@ -130,10 +133,10 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
}) => {
const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const displayName = useObservableEagerState(vm.displayname$);
const video = useObservableEagerState(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
const displayName = useBehavior(vm.displayName$);
const video = useBehavior(vm.video$);
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
const encryptionStatus = useBehavior(vm.encryptionStatus$);
// Hook this item up to the intersection observer
useEffect(() => {
@@ -200,8 +203,8 @@ export const SpotlightTile: FC<Props> = ({
const { t } = useTranslation();
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const maximised = useObservableEagerState(vm.maximised$);
const media = useObservableEagerState(vm.media$);
const maximised = useBehavior(vm.maximised$);
const media = useBehavior(vm.media$);
const [visibleId, setVisibleId] = useState<string | undefined>(media[0]?.id);
const latestMedia = useLatest(media);
const latestVisibleId = useLatest(visibleId);
@@ -209,6 +212,26 @@ export const SpotlightTile: FC<Props> = ({
const canGoBack = visibleIndex > 0;
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
const isFullscreen = useCallback((): boolean => {
const rootElement = document.body;
if (rootElement && document.fullscreenElement) return true;
return false;
}, []);
const FullScreenIcon = isFullscreen()
? FullScreenMinimiseIcon
: FullScreenMaximiseIcon;
const onToggleFullscreen = useCallback(() => {
const rootElement = document.body;
if (!rootElement) return;
if (isFullscreen()) {
void document?.exitFullscreen();
} else {
void rootElement.requestFullscreen();
}
}, [isFullscreen]);
// To keep track of which item is visible, we need an intersection observer
// hooked up to the root element and the items. Because the items will run
// their effects before their parent does, we need to do this dance with an
@@ -291,17 +314,28 @@ export const SpotlightTile: FC<Props> = ({
/>
))}
</div>
{onToggleExpanded && (
<div className={styles.bottomRightButtons}>
<button
className={classNames(styles.expand)}
aria-label={
expanded ? t("video_tile.collapse") : t("video_tile.expand")
}
onClick={onToggleExpanded}
aria-label={"maximise"}
onClick={onToggleFullscreen}
>
<ToggleExpandIcon aria-hidden width={20} height={20} />
<FullScreenIcon aria-hidden width={20} height={20} />
</button>
)}
{onToggleExpanded && (
<button
className={classNames(styles.expand)}
aria-label={
expanded ? t("video_tile.collapse") : t("video_tile.expand")
}
onClick={onToggleExpanded}
>
<ToggleExpandIcon aria-hidden width={20} height={20} />
</button>
)}
</div>
{canGoToNext && (
<button
className={classNames(styles.advance, styles.next)}