Files
element-call/src/tile/GridTile.tsx

308 lines
7.6 KiB
TypeScript
Raw Normal View History

2022-05-04 17:09:48 +01:00
/*
Copyright 2022-2024 New Vector Ltd.
2022-05-04 17:09:48 +01:00
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
2022-05-04 17:09:48 +01:00
*/
import {
ComponentProps,
ReactNode,
forwardRef,
useCallback,
useState,
} from "react";
import { animated } from "@react-spring/web";
2022-04-07 14:22:36 -07:00
import classNames from "classnames";
2022-10-10 09:19:10 -04:00
import { useTranslation } from "react-i18next";
import {
MicOnSolidIcon,
MicOffSolidIcon,
MicOffIcon,
OverflowHorizontalIcon,
VolumeOnIcon,
VolumeOffIcon,
VisibilityOnIcon,
UserProfileIcon,
ExpandIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
2023-12-01 17:43:09 -05:00
import {
ContextMenu,
MenuItem,
ToggleMenuItem,
Menu,
} from "@vector-im/compound-web";
import { useObservableEagerState } from "observable-hooks";
2022-08-12 19:27:34 +02:00
import styles from "./GridTile.module.css";
import {
UserMediaViewModel,
useDisplayName,
LocalUserMediaViewModel,
RemoteUserMediaViewModel,
} from "../state/MediaViewModel";
2023-12-01 17:43:09 -05:00
import { Slider } from "../Slider";
import { MediaView } from "./MediaView";
2024-06-20 10:37:42 -04:00
import { useLatest } from "../useLatest";
interface TileProps {
2023-12-01 17:43:09 -05:00
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number;
targetHeight: number;
displayName: string;
showVideo: boolean;
2024-06-07 17:29:48 -04:00
showSpeakingIndicators: boolean;
2023-12-01 17:43:09 -05:00
}
interface UserMediaTileProps extends TileProps {
vm: UserMediaViewModel;
2024-05-17 16:38:00 -04:00
mirror: boolean;
menuStart?: ReactNode;
menuEnd?: ReactNode;
}
const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
2023-12-01 17:43:09 -05:00
(
{
vm,
showVideo,
showSpeakingIndicators,
menuStart,
menuEnd,
2023-12-01 17:43:09 -05:00
className,
displayName,
...props
2023-12-01 17:43:09 -05:00
},
ref,
) => {
const { t } = useTranslation();
2024-06-07 17:29:48 -04:00
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(
2024-08-02 15:27:49 -04:00
(e: Event) => {
e.preventDefault();
vm.toggleFitContain();
},
[vm],
);
2023-12-01 17:43:09 -05:00
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
const [menuOpen, setMenuOpen] = useState(false);
const menu = (
2023-12-01 17:43:09 -05:00
<>
{menuStart}
<ToggleMenuItem
Icon={ExpandIcon}
label={t("video_tile.change_fit_contain")}
checked={cropVideo}
onSelect={onSelectFitContain}
/>
{menuEnd}
2023-12-01 17:43:09 -05:00
</>
2022-05-31 10:43:05 -04:00
);
2023-12-01 17:43:09 -05:00
const tile = (
2024-06-07 17:29:48 -04:00
<MediaView
ref={ref}
2024-06-07 17:29:48 -04:00
video={video}
member={vm.member}
unencryptedWarning={unencryptedWarning}
videoEnabled={videoEnabled && showVideo}
videoFit={cropVideo ? "cover" : "contain"}
2024-06-07 17:29:48 -04:00
className={classNames(className, styles.tile, {
[styles.speaking]: showSpeakingIndicators && speaking,
})}
2023-12-01 17:43:09 -05:00
nameTagLeadingIcon={
<MicIcon
width={20}
height={20}
aria-label={audioEnabled ? t("microphone_on") : t("microphone_off")}
data-muted={!audioEnabled}
className={styles.muteIcon}
/>
}
displayName={displayName}
2023-12-01 17:43:09 -05:00
primaryButton={
<Menu
open={menuOpen}
onOpenChange={setMenuOpen}
title={displayName}
2023-12-01 17:43:09 -05:00
trigger={
<button aria-label={t("common.options")}>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
side="left"
align="start"
>
{menu}
</Menu>
}
{...props}
2023-12-01 17:43:09 -05:00
/>
);
return (
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative>
2023-12-01 17:43:09 -05:00
{menu}
</ContextMenu>
);
},
);
UserMediaTile.displayName = "UserMediaTile";
interface LocalUserMediaTileProps extends TileProps {
vm: LocalUserMediaViewModel;
onOpenProfile: () => void;
}
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
2024-05-17 16:38:00 -04:00
({ vm, onOpenProfile, ...props }, ref) => {
const { t } = useTranslation();
const mirror = useObservableEagerState(vm.mirror);
2024-06-20 10:37:42 -04:00
const alwaysShow = useObservableEagerState(vm.alwaysShow);
const latestAlwaysShow = useLatest(alwaysShow);
const onSelectAlwaysShow = useCallback(
2024-08-02 15:27:49 -04:00
(e: Event) => {
e.preventDefault();
vm.setAlwaysShow(!latestAlwaysShow.current);
},
2024-06-20 10:37:42 -04:00
[vm, latestAlwaysShow],
);
return (
<UserMediaTile
ref={ref}
vm={vm}
2024-05-17 16:38:00 -04:00
mirror={mirror}
menuStart={
2024-06-20 10:37:42 -04:00
<ToggleMenuItem
Icon={VisibilityOnIcon}
label={t("video_tile.always_show")}
checked={alwaysShow}
onSelect={onSelectAlwaysShow}
/>
}
menuEnd={
<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);
2024-08-02 15:27:49 -04:00
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}
2024-05-17 16:38:00 -04:00
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 {
2024-06-07 17:29:48 -04:00
vm: UserMediaViewModel;
2023-12-01 17:43:09 -05:00
onOpenProfile: () => void;
targetWidth: number;
targetHeight: number;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
showVideo: boolean;
showSpeakingIndicators: boolean;
2023-12-01 17:43:09 -05:00
}
export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
({ vm, onOpenProfile, ...props }, ref) => {
const displayName = useDisplayName(vm);
if (vm instanceof LocalUserMediaViewModel) {
2023-12-01 17:43:09 -05:00
return (
<LocalUserMediaTile
2023-12-01 17:43:09 -05:00
ref={ref}
vm={vm}
onOpenProfile={onOpenProfile}
displayName={displayName}
{...props}
2023-12-01 17:43:09 -05:00
/>
);
} else {
return (
<RemoteUserMediaTile
ref={ref}
vm={vm}
displayName={displayName}
{...props}
/>
);
2023-12-01 17:43:09 -05:00
}
2023-10-11 10:42:04 -04:00
},
2022-05-31 10:43:05 -04:00
);
GridTile.displayName = "GridTile";