2026-05-11 17:50:33 +08:00
|
|
|
/*
|
|
|
|
|
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 { type ComponentType, useState, type FC } from "react";
|
|
|
|
|
import {
|
|
|
|
|
Button,
|
|
|
|
|
Menu,
|
|
|
|
|
MenuItem,
|
|
|
|
|
ToggleMenuItem,
|
|
|
|
|
} from "@vector-im/compound-web";
|
|
|
|
|
import { t } from "i18next";
|
|
|
|
|
import {
|
|
|
|
|
CheckIcon,
|
|
|
|
|
ChevronUpIcon,
|
|
|
|
|
ChevronDownIcon,
|
|
|
|
|
MicOnIcon,
|
|
|
|
|
SpinnerIcon,
|
|
|
|
|
VideoCallIcon,
|
|
|
|
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
|
|
|
|
import classNames from "classnames";
|
|
|
|
|
|
|
|
|
|
import styles from "./MediaMuteAndSwitchButton.module.css";
|
2026-05-13 13:16:24 +02:00
|
|
|
import { MicButton, VideoButton } from "../button";
|
2026-05-11 17:50:33 +08:00
|
|
|
|
|
|
|
|
export interface MenuOptions {
|
|
|
|
|
label: string;
|
|
|
|
|
id: string;
|
|
|
|
|
}
|
|
|
|
|
export interface ToggleOption {
|
|
|
|
|
label: string;
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
id: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface MediaMuteAndSwitchButtonProps {
|
|
|
|
|
/** The title used in the Switcher modal. */
|
|
|
|
|
title: string;
|
|
|
|
|
/** If the Mute button is enabled */
|
|
|
|
|
enabled?: boolean;
|
|
|
|
|
/** Callback if the mute button is clicked */
|
|
|
|
|
onMuteClick?: () => void;
|
2026-05-18 14:31:03 +02:00
|
|
|
iconsAndLabels: "video" | "audio";
|
2026-05-11 17:50:33 +08:00
|
|
|
/** The options available for the media device selector modal */
|
|
|
|
|
options?: MenuOptions[];
|
|
|
|
|
/** The option that will currently be rendered as the selected option */
|
|
|
|
|
selectedOption?: string;
|
|
|
|
|
/**
|
|
|
|
|
* The available toggles (including there current state)
|
|
|
|
|
* The toggle state is not stored by this component.
|
|
|
|
|
* It is handled externally and needs to be set by listening to the `onSelect` callback and setting the right toggle item to `enabled`
|
|
|
|
|
*/
|
|
|
|
|
toggles?: ToggleOption[];
|
|
|
|
|
/**
|
|
|
|
|
* For any toggle and option this method will be called.
|
|
|
|
|
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
|
|
|
|
|
*/
|
|
|
|
|
onSelect?: (id: string) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|
|
|
|
title,
|
|
|
|
|
enabled,
|
|
|
|
|
onMuteClick,
|
2026-05-13 13:16:24 +02:00
|
|
|
iconsAndLabels,
|
2026-05-11 17:50:33 +08:00
|
|
|
options,
|
|
|
|
|
selectedOption,
|
|
|
|
|
toggles,
|
|
|
|
|
onSelect,
|
|
|
|
|
}) => {
|
|
|
|
|
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
|
|
|
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
2026-05-13 13:16:24 +02:00
|
|
|
|
|
|
|
|
let button;
|
|
|
|
|
switch (iconsAndLabels) {
|
2026-05-11 17:50:33 +08:00
|
|
|
case "video":
|
2026-05-13 13:16:24 +02:00
|
|
|
button = (
|
|
|
|
|
<VideoButton
|
|
|
|
|
enabled={enabled ?? false}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
onMuteClick?.();
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
}}
|
|
|
|
|
disabled={onMuteClick === undefined}
|
|
|
|
|
data-testid="incall_videomute"
|
|
|
|
|
/>
|
|
|
|
|
);
|
2026-05-11 17:50:33 +08:00
|
|
|
break;
|
|
|
|
|
case "audio":
|
2026-05-13 13:16:24 +02:00
|
|
|
button = (
|
|
|
|
|
<MicButton
|
|
|
|
|
enabled={enabled ?? false}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
onMuteClick?.();
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
}}
|
|
|
|
|
disabled={onMuteClick === undefined}
|
|
|
|
|
data-testid="incall_mute"
|
|
|
|
|
/>
|
|
|
|
|
);
|
2026-05-18 14:31:03 +02:00
|
|
|
|
2026-05-11 17:50:33 +08:00
|
|
|
break;
|
|
|
|
|
}
|
2026-05-13 13:16:24 +02:00
|
|
|
|
|
|
|
|
let IconOptions: ComponentType<React.SVGAttributes<SVGElement>> | undefined;
|
|
|
|
|
let optionsButtonLabel: string;
|
|
|
|
|
switch (iconsAndLabels) {
|
|
|
|
|
case "video":
|
|
|
|
|
IconOptions = VideoCallIcon;
|
|
|
|
|
optionsButtonLabel = t("settings.devices.camera");
|
|
|
|
|
break;
|
|
|
|
|
case "audio":
|
|
|
|
|
IconOptions = MicOnIcon;
|
|
|
|
|
optionsButtonLabel = t("settings.devices.microphone");
|
|
|
|
|
break;
|
2026-05-11 17:50:33 +08:00
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className={classNames({
|
|
|
|
|
[styles.container]: true,
|
|
|
|
|
[styles.containerOpen]: menuOpen,
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
{/* The mute button lives inside */}
|
2026-05-13 13:16:24 +02:00
|
|
|
{button}
|
2026-05-11 17:50:33 +08:00
|
|
|
<Menu
|
|
|
|
|
title={title}
|
|
|
|
|
showTitle={true}
|
|
|
|
|
open={menuOpen}
|
|
|
|
|
onOpenChange={setMenuOpen}
|
|
|
|
|
side="top"
|
|
|
|
|
trigger={
|
|
|
|
|
<Button
|
|
|
|
|
iconOnly
|
|
|
|
|
className={classNames({
|
|
|
|
|
[styles.menuButton]: true,
|
|
|
|
|
[styles.chevronIconOpen]: menuOpen,
|
|
|
|
|
})}
|
|
|
|
|
Icon={menuOpen ? ChevronUpIcon : ChevronDownIcon}
|
|
|
|
|
kind={"tertiary"}
|
|
|
|
|
size="lg"
|
|
|
|
|
aria-label={optionsButtonLabel}
|
|
|
|
|
/>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{options?.map((option) => (
|
|
|
|
|
<MenuItem
|
|
|
|
|
hideChevron
|
|
|
|
|
label={option.label}
|
|
|
|
|
Icon={
|
|
|
|
|
IconOptions && (
|
|
|
|
|
<IconOptions
|
|
|
|
|
width={24}
|
|
|
|
|
height={24}
|
|
|
|
|
className={styles.itemIcon}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
onSelect={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (option.id === selectedOption) return;
|
|
|
|
|
setPlannedSelection(option.id);
|
|
|
|
|
onSelect?.(option.id);
|
|
|
|
|
}}
|
|
|
|
|
key={option.id}
|
|
|
|
|
>
|
|
|
|
|
{selectedOption === option.id && (
|
|
|
|
|
<CheckIcon width={24} height={24} />
|
|
|
|
|
)}
|
|
|
|
|
{selectedOption !== option.id && plannedSelection === option.id && (
|
|
|
|
|
<SpinnerIcon width={24} height={24} className={styles.rotate} />
|
|
|
|
|
)}
|
|
|
|
|
</MenuItem>
|
|
|
|
|
))}
|
|
|
|
|
{(toggles?.length ?? 0) > 0 && <hr />}
|
|
|
|
|
{toggles?.map((toggle) => (
|
|
|
|
|
<ToggleMenuItem
|
|
|
|
|
label={toggle.label}
|
|
|
|
|
onSelect={(e) => {
|
|
|
|
|
onSelect?.(toggle.id);
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
}}
|
|
|
|
|
checked={toggle.enabled}
|
|
|
|
|
key={toggle.id}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</Menu>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|