mirror of
https://github.com/vector-im/element-call.git
synced 2026-02-20 04:57:03 +00:00
* Keep tiles in a stable order This introduces a new layer of abstraction on top of MediaViewModel: TileViewModel, which gives us a place to store data relating to tiles rather than their media, and also generally makes it easier to reason about tiles as they move about the call layout. I have created a class called TileStore to keep track of these tiles. This allows us to swap out the media shown on a tile as the spotlight speaker changes, and avoid moving tiles around unless they really need to jump between the visible/invisible regions of the layout. * Don't throttle spotlight updates Since we now assume that the spotlight and grid will be in sync (i.e. an active speaker in one will behave as an active speaker in the other), we don't want the spotlight to ever lag behind due to throttling. If this causes usability issues we should maybe look into making LiveKit's 'speaking' indicators less erratic first. * Make layout shifts due to a change in speaker less surprising Although we try now to avoid layout shifts due to the spotlight speaker changing wherever possible, a spotlight speaker coming from off screen can still trigger one. Let's shift the layout a bit more gracefully in this case. * Improve the tile ordering tests * Maximize the spotlight tile in portrait layout * Tell tiles whether they're actually visible in a more timely manner * Fix test * Fix speaking indicators logic * Improve readability of marbles * Fix test case --------- Co-authored-by: Hugh Nimmo-Smith <hughns@element.io>
321 lines
8.2 KiB
TypeScript
321 lines
8.2 KiB
TypeScript
/*
|
|
Copyright 2022-2024 New Vector Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
import {
|
|
ComponentProps,
|
|
ReactNode,
|
|
forwardRef,
|
|
useCallback,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { animated } from "@react-spring/web";
|
|
import classNames from "classnames";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
MicOnSolidIcon,
|
|
MicOffSolidIcon,
|
|
MicOffIcon,
|
|
OverflowHorizontalIcon,
|
|
VolumeOnIcon,
|
|
VolumeOffIcon,
|
|
VisibilityOnIcon,
|
|
UserProfileIcon,
|
|
ExpandIcon,
|
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
|
import {
|
|
ContextMenu,
|
|
MenuItem,
|
|
ToggleMenuItem,
|
|
Menu,
|
|
} from "@vector-im/compound-web";
|
|
import { useObservableEagerState } from "observable-hooks";
|
|
|
|
import styles from "./GridTile.module.css";
|
|
import {
|
|
UserMediaViewModel,
|
|
useDisplayName,
|
|
LocalUserMediaViewModel,
|
|
RemoteUserMediaViewModel,
|
|
} from "../state/MediaViewModel";
|
|
import { Slider } from "../Slider";
|
|
import { MediaView } from "./MediaView";
|
|
import { useLatest } from "../useLatest";
|
|
import { GridTileViewModel } from "../state/TileViewModel";
|
|
import { useMergedRefs } from "../useMergedRefs";
|
|
import { useReactions } from "../useReactions";
|
|
|
|
interface TileProps {
|
|
className?: string;
|
|
style?: ComponentProps<typeof animated.div>["style"];
|
|
targetWidth: number;
|
|
targetHeight: number;
|
|
displayName: string;
|
|
showSpeakingIndicators: boolean;
|
|
}
|
|
|
|
interface UserMediaTileProps extends TileProps {
|
|
vm: UserMediaViewModel;
|
|
mirror: boolean;
|
|
menuStart?: ReactNode;
|
|
menuEnd?: ReactNode;
|
|
}
|
|
|
|
const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
|
(
|
|
{
|
|
vm,
|
|
showSpeakingIndicators,
|
|
menuStart,
|
|
menuEnd,
|
|
className,
|
|
displayName,
|
|
...props
|
|
},
|
|
ref,
|
|
) => {
|
|
const { t } = useTranslation();
|
|
const video = useObservableEagerState(vm.video);
|
|
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
|
const audioEnabled = useObservableEagerState(vm.audioEnabled);
|
|
const videoEnabled = useObservableEagerState(vm.videoEnabled);
|
|
const speaking = useObservableEagerState(vm.speaking);
|
|
const cropVideo = useObservableEagerState(vm.cropVideo);
|
|
const onSelectFitContain = useCallback(
|
|
(e: Event) => {
|
|
e.preventDefault();
|
|
vm.toggleFitContain();
|
|
},
|
|
[vm],
|
|
);
|
|
const { raisedHands } = useReactions();
|
|
|
|
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
|
|
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const menu = (
|
|
<>
|
|
{menuStart}
|
|
<ToggleMenuItem
|
|
Icon={ExpandIcon}
|
|
label={t("video_tile.change_fit_contain")}
|
|
checked={cropVideo}
|
|
onSelect={onSelectFitContain}
|
|
/>
|
|
{menuEnd}
|
|
</>
|
|
);
|
|
|
|
const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""];
|
|
|
|
const showSpeaking = showSpeakingIndicators && speaking;
|
|
|
|
const tile = (
|
|
<MediaView
|
|
ref={ref}
|
|
video={video}
|
|
member={vm.member}
|
|
unencryptedWarning={unencryptedWarning}
|
|
videoEnabled={videoEnabled}
|
|
videoFit={cropVideo ? "cover" : "contain"}
|
|
className={classNames(className, styles.tile, {
|
|
[styles.speaking]: showSpeaking,
|
|
[styles.handRaised]: !showSpeaking && !!handRaised,
|
|
})}
|
|
nameTagLeadingIcon={
|
|
<MicIcon
|
|
width={20}
|
|
height={20}
|
|
aria-label={audioEnabled ? t("microphone_on") : t("microphone_off")}
|
|
data-muted={!audioEnabled}
|
|
className={styles.muteIcon}
|
|
/>
|
|
}
|
|
displayName={displayName}
|
|
primaryButton={
|
|
<Menu
|
|
open={menuOpen}
|
|
onOpenChange={setMenuOpen}
|
|
title={displayName}
|
|
trigger={
|
|
<button aria-label={t("common.options")}>
|
|
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
|
</button>
|
|
}
|
|
side="left"
|
|
align="start"
|
|
>
|
|
{menu}
|
|
</Menu>
|
|
}
|
|
raisedHandTime={handRaised}
|
|
{...props}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative>
|
|
{menu}
|
|
</ContextMenu>
|
|
);
|
|
},
|
|
);
|
|
|
|
UserMediaTile.displayName = "UserMediaTile";
|
|
|
|
interface LocalUserMediaTileProps extends TileProps {
|
|
vm: LocalUserMediaViewModel;
|
|
onOpenProfile: (() => void) | null;
|
|
}
|
|
|
|
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
|
|
({ vm, onOpenProfile, ...props }, ref) => {
|
|
const { t } = useTranslation();
|
|
const mirror = useObservableEagerState(vm.mirror);
|
|
const alwaysShow = useObservableEagerState(vm.alwaysShow);
|
|
const latestAlwaysShow = useLatest(alwaysShow);
|
|
const onSelectAlwaysShow = useCallback(
|
|
(e: Event) => {
|
|
e.preventDefault();
|
|
vm.setAlwaysShow(!latestAlwaysShow.current);
|
|
},
|
|
[vm, latestAlwaysShow],
|
|
);
|
|
|
|
return (
|
|
<UserMediaTile
|
|
ref={ref}
|
|
vm={vm}
|
|
mirror={mirror}
|
|
menuStart={
|
|
<ToggleMenuItem
|
|
Icon={VisibilityOnIcon}
|
|
label={t("video_tile.always_show")}
|
|
checked={alwaysShow}
|
|
onSelect={onSelectAlwaysShow}
|
|
/>
|
|
}
|
|
menuEnd={
|
|
onOpenProfile && (
|
|
<MenuItem
|
|
Icon={UserProfileIcon}
|
|
label={t("common.profile")}
|
|
onSelect={onOpenProfile}
|
|
/>
|
|
)
|
|
}
|
|
{...props}
|
|
/>
|
|
);
|
|
},
|
|
);
|
|
|
|
LocalUserMediaTile.displayName = "LocalUserMediaTile";
|
|
|
|
interface RemoteUserMediaTileProps extends TileProps {
|
|
vm: RemoteUserMediaViewModel;
|
|
}
|
|
|
|
const RemoteUserMediaTile = forwardRef<
|
|
HTMLDivElement,
|
|
RemoteUserMediaTileProps
|
|
>(({ vm, ...props }, ref) => {
|
|
const { t } = useTranslation();
|
|
const locallyMuted = useObservableEagerState(vm.locallyMuted);
|
|
const localVolume = useObservableEagerState(vm.localVolume);
|
|
const onSelectMute = useCallback(
|
|
(e: Event) => {
|
|
e.preventDefault();
|
|
vm.toggleLocallyMuted();
|
|
},
|
|
[vm],
|
|
);
|
|
const onChangeLocalVolume = useCallback(
|
|
(v: number) => vm.setLocalVolume(v),
|
|
[vm],
|
|
);
|
|
const onCommitLocalVolume = useCallback(() => vm.commitLocalVolume(), [vm]);
|
|
|
|
const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon;
|
|
|
|
return (
|
|
<UserMediaTile
|
|
ref={ref}
|
|
vm={vm}
|
|
mirror={false}
|
|
menuStart={
|
|
<>
|
|
<ToggleMenuItem
|
|
Icon={MicOffIcon}
|
|
label={t("video_tile.mute_for_me")}
|
|
checked={locallyMuted}
|
|
onSelect={onSelectMute}
|
|
/>
|
|
{/* TODO: Figure out how to make this slider keyboard accessible */}
|
|
<MenuItem as="div" Icon={VolumeIcon} label={null} onSelect={null}>
|
|
<Slider
|
|
className={styles.volumeSlider}
|
|
label={t("video_tile.volume")}
|
|
value={localVolume}
|
|
onValueChange={onChangeLocalVolume}
|
|
onValueCommit={onCommitLocalVolume}
|
|
min={0}
|
|
max={1}
|
|
step={0.01}
|
|
/>
|
|
</MenuItem>
|
|
</>
|
|
}
|
|
{...props}
|
|
/>
|
|
);
|
|
});
|
|
|
|
RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
|
|
|
|
interface GridTileProps {
|
|
vm: GridTileViewModel;
|
|
onOpenProfile: (() => void) | null;
|
|
targetWidth: number;
|
|
targetHeight: number;
|
|
className?: string;
|
|
style?: ComponentProps<typeof animated.div>["style"];
|
|
showSpeakingIndicators: boolean;
|
|
}
|
|
|
|
export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
|
|
({ vm, onOpenProfile, ...props }, theirRef) => {
|
|
const ourRef = useRef<HTMLDivElement | null>(null);
|
|
const ref = useMergedRefs(ourRef, theirRef);
|
|
const media = useObservableEagerState(vm.media);
|
|
const displayName = useDisplayName(media);
|
|
|
|
if (media instanceof LocalUserMediaViewModel) {
|
|
return (
|
|
<LocalUserMediaTile
|
|
ref={ref}
|
|
vm={media}
|
|
onOpenProfile={onOpenProfile}
|
|
displayName={displayName}
|
|
{...props}
|
|
/>
|
|
);
|
|
} else {
|
|
return (
|
|
<RemoteUserMediaTile
|
|
ref={ref}
|
|
vm={media}
|
|
displayName={displayName}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
GridTile.displayName = "GridTile";
|