mirror of
https://github.com/vector-im/element-call.git
synced 2026-06-27 17:52:56 +00:00
Merge pull request #4013 from element-hq/valere/fix_repeated_click_to_unmute
feat(mute): add syncing state and disable toggle during async mute
This commit is contained in:
@@ -8,3 +8,17 @@ Please see LICENSE in the repository root for full details.
|
|||||||
.endCall > svg {
|
.endCall > svg {
|
||||||
color: var(--stopgap-color-on-solid-accent);
|
color: var(--stopgap-color-on-solid-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rotate > svg {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
113
src/button/Button.test.tsx
Normal file
113
src/button/Button.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
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 { describe, expect, test, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
|
|
||||||
|
import { MicButton, VideoButton } from "./Button";
|
||||||
|
|
||||||
|
describe("MicButton", () => {
|
||||||
|
test("calls onClick when not busy", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<MicButton enabled={true} onClick={onClick} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole("switch");
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
expect(onClick).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not call onClick when busy", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<MicButton enabled={true} busy={true} onClick={onClick} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole("switch");
|
||||||
|
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||||
|
expect(button).toHaveAttribute("aria-busy", "true");
|
||||||
|
|
||||||
|
await user.click(button);
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not call onClick when disabled", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<MicButton enabled={true} disabled={true} onClick={onClick} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole("switch");
|
||||||
|
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||||
|
|
||||||
|
await user.click(button);
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VideoButton", () => {
|
||||||
|
test("calls onClick when not busy", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<VideoButton enabled={true} onClick={onClick} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole("switch");
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
expect(onClick).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not call onClick when busy", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<VideoButton enabled={true} busy={true} onClick={onClick} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole("switch");
|
||||||
|
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||||
|
expect(button).toHaveAttribute("aria-busy", "true");
|
||||||
|
|
||||||
|
await user.click(button);
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not call onClick when disabled", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<VideoButton enabled={true} disabled={true} onClick={onClick} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole("switch");
|
||||||
|
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||||
|
|
||||||
|
await user.click(button);
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
MicOnSolidIcon,
|
MicOnSolidIcon,
|
||||||
MicOffSolidIcon,
|
MicOffSolidIcon,
|
||||||
|
SpinnerIcon,
|
||||||
VideoCallSolidIcon,
|
VideoCallSolidIcon,
|
||||||
VideoCallOffSolidIcon,
|
VideoCallOffSolidIcon,
|
||||||
EndCallIcon,
|
EndCallIcon,
|
||||||
@@ -32,12 +33,13 @@ import { platform } from "../Platform";
|
|||||||
|
|
||||||
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
|
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
busy?: boolean;
|
||||||
size?: "md" | "lg";
|
size?: "md" | "lg";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
|
export const MicButton: FC<MicButtonProps> = ({ enabled, busy, ...props }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const Icon = enabled ? MicOnSolidIcon : MicOffSolidIcon;
|
const Icon = busy ? SpinnerIcon : enabled ? MicOnSolidIcon : MicOffSolidIcon;
|
||||||
const label = enabled
|
const label = enabled
|
||||||
? t("mute_microphone_button_label")
|
? t("mute_microphone_button_label")
|
||||||
: t("unmute_microphone_button_label");
|
: t("unmute_microphone_button_label");
|
||||||
@@ -51,6 +53,11 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
|
|||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={enabled}
|
aria-checked={enabled}
|
||||||
{...props}
|
{...props}
|
||||||
|
aria-busy={busy}
|
||||||
|
className={classNames(props.className, {
|
||||||
|
[styles.rotate]: !!busy,
|
||||||
|
})}
|
||||||
|
disabled={props.disabled || busy}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -58,12 +65,21 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
|
|||||||
|
|
||||||
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
|
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
busy?: boolean;
|
||||||
size?: "md" | "lg";
|
size?: "md" | "lg";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
|
export const VideoButton: FC<VideoButtonProps> = ({
|
||||||
|
enabled,
|
||||||
|
busy,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const Icon = enabled ? VideoCallSolidIcon : VideoCallOffSolidIcon;
|
const Icon = busy
|
||||||
|
? SpinnerIcon
|
||||||
|
: enabled
|
||||||
|
? VideoCallSolidIcon
|
||||||
|
: VideoCallOffSolidIcon;
|
||||||
const label = enabled
|
const label = enabled
|
||||||
? t("stop_video_button_label")
|
? t("stop_video_button_label")
|
||||||
: t("start_video_button_label");
|
: t("start_video_button_label");
|
||||||
@@ -77,6 +93,11 @@ export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
|
|||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={enabled}
|
aria-checked={enabled}
|
||||||
{...props}
|
{...props}
|
||||||
|
aria-busy={busy}
|
||||||
|
className={classNames(props.className, {
|
||||||
|
[styles.rotate]: !!busy,
|
||||||
|
})}
|
||||||
|
disabled={props.disabled || busy}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ export const Default: Story = {
|
|||||||
showLogo: false,
|
showLogo: false,
|
||||||
layoutMode: "grid",
|
layoutMode: "grid",
|
||||||
audioEnabled: true,
|
audioEnabled: true,
|
||||||
|
audioBusy: false,
|
||||||
videoEnabled: true,
|
videoEnabled: true,
|
||||||
|
videoBusy: false,
|
||||||
setLayoutMode: fn(),
|
setLayoutMode: fn(),
|
||||||
openSettings: fn(),
|
openSettings: fn(),
|
||||||
toggleAudio: fn(),
|
toggleAudio: fn(),
|
||||||
@@ -152,6 +154,26 @@ export const WithAudioAndVideoOptions: Story = {
|
|||||||
selectedVideo: "1",
|
selectedVideo: "1",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const AudioBusy: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
audioEnabled: true,
|
||||||
|
audioBusy: true,
|
||||||
|
videoEnabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VideoBusy: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
audioEnabled: true,
|
||||||
|
videoEnabled: true,
|
||||||
|
videoBusy: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
export const WithLogo: Story = {
|
export const WithLogo: Story = {
|
||||||
...Default,
|
...Default,
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ export interface FooterActions {
|
|||||||
// we do not use any ? optional properties so that the vm type is including all fields.
|
// we do not use any ? optional properties so that the vm type is including all fields.
|
||||||
export interface FooterState {
|
export interface FooterState {
|
||||||
audioEnabled: boolean;
|
audioEnabled: boolean;
|
||||||
|
audioBusy: boolean;
|
||||||
videoEnabled: boolean;
|
videoEnabled: boolean;
|
||||||
|
videoBusy: boolean;
|
||||||
videoBlurEnabled: boolean;
|
videoBlurEnabled: boolean;
|
||||||
showFooter: boolean;
|
showFooter: boolean;
|
||||||
|
|
||||||
@@ -122,7 +124,9 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
|
|||||||
const setLayoutMode = useBehavior(vm.setLayoutMode$);
|
const setLayoutMode = useBehavior(vm.setLayoutMode$);
|
||||||
const openSettings = useBehavior(vm.openSettings$);
|
const openSettings = useBehavior(vm.openSettings$);
|
||||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||||
|
const audioBusy = useBehavior(vm.audioBusy$);
|
||||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||||
|
const videoBusy = useBehavior(vm.videoBusy$);
|
||||||
const toggleAudio = useBehavior(vm.toggleAudio$);
|
const toggleAudio = useBehavior(vm.toggleAudio$);
|
||||||
const toggleVideo = useBehavior(vm.toggleVideo$);
|
const toggleVideo = useBehavior(vm.toggleVideo$);
|
||||||
const sharingScreen = useBehavior(vm.sharingScreen$);
|
const sharingScreen = useBehavior(vm.sharingScreen$);
|
||||||
@@ -167,6 +171,7 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
|
|||||||
key="audio"
|
key="audio"
|
||||||
iconsAndLabels="audio"
|
iconsAndLabels="audio"
|
||||||
enabled={audioEnabled ?? false}
|
enabled={audioEnabled ?? false}
|
||||||
|
busy={audioBusy ?? false}
|
||||||
onMuteClick={toggleAudio}
|
onMuteClick={toggleAudio}
|
||||||
data-testid="incall_mute"
|
data-testid="incall_mute"
|
||||||
options={audioOptions}
|
options={audioOptions}
|
||||||
@@ -180,8 +185,9 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
|
|||||||
size={buttonSize}
|
size={buttonSize}
|
||||||
key="audio"
|
key="audio"
|
||||||
enabled={audioEnabled ?? false}
|
enabled={audioEnabled ?? false}
|
||||||
|
busy={audioBusy ?? false}
|
||||||
onClick={toggleAudio}
|
onClick={toggleAudio}
|
||||||
disabled={toggleAudio === undefined}
|
disabled={(audioBusy ?? false) || toggleAudio === undefined}
|
||||||
data-testid="incall_mute"
|
data-testid="incall_mute"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -194,6 +200,7 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
|
|||||||
key="video"
|
key="video"
|
||||||
iconsAndLabels="video"
|
iconsAndLabels="video"
|
||||||
enabled={videoEnabled ?? false}
|
enabled={videoEnabled ?? false}
|
||||||
|
busy={videoBusy ?? false}
|
||||||
onMuteClick={toggleVideo}
|
onMuteClick={toggleVideo}
|
||||||
options={videoOptions}
|
options={videoOptions}
|
||||||
selectedOption={selectedVideo}
|
selectedOption={selectedVideo}
|
||||||
@@ -208,8 +215,9 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
|
|||||||
size={buttonSize}
|
size={buttonSize}
|
||||||
key="video"
|
key="video"
|
||||||
enabled={videoEnabled ?? false}
|
enabled={videoEnabled ?? false}
|
||||||
|
busy={videoBusy ?? false}
|
||||||
onClick={toggleVideo}
|
onClick={toggleVideo}
|
||||||
disabled={toggleVideo === undefined}
|
disabled={(videoBusy ?? false) || toggleVideo === undefined}
|
||||||
data-testid="incall_videomute"
|
data-testid="incall_videomute"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,14 +32,21 @@ function buildMuteBehaviors(
|
|||||||
muteStates: MuteStates,
|
muteStates: MuteStates,
|
||||||
): Pick<
|
): Pick<
|
||||||
ViewModel<FooterSnapshot>,
|
ViewModel<FooterSnapshot>,
|
||||||
"audioEnabled$" | "toggleAudio$" | "videoEnabled$" | "toggleVideo$"
|
| "audioEnabled$"
|
||||||
|
| "audioBusy$"
|
||||||
|
| "toggleAudio$"
|
||||||
|
| "videoEnabled$"
|
||||||
|
| "videoBusy$"
|
||||||
|
| "toggleVideo$"
|
||||||
> {
|
> {
|
||||||
return {
|
return {
|
||||||
audioEnabled$: muteStates.audio.enabled$,
|
audioEnabled$: muteStates.audio.enabled$,
|
||||||
|
audioBusy$: muteStates.audio.syncing$,
|
||||||
toggleAudio$: scope.behavior(
|
toggleAudio$: scope.behavior(
|
||||||
muteStates.audio.toggle$.pipe(map((t) => t ?? undefined)),
|
muteStates.audio.toggle$.pipe(map((t) => t ?? undefined)),
|
||||||
),
|
),
|
||||||
videoEnabled$: muteStates.video.enabled$,
|
videoEnabled$: muteStates.video.enabled$,
|
||||||
|
videoBusy$: muteStates.video.syncing$,
|
||||||
toggleVideo$: scope.behavior(
|
toggleVideo$: scope.behavior(
|
||||||
muteStates.video.toggle$.pipe(map((t) => t ?? undefined)),
|
muteStates.video.toggle$.pipe(map((t) => t ?? undefined)),
|
||||||
),
|
),
|
||||||
@@ -252,7 +259,9 @@ export function createLobbyFooterViewModel(
|
|||||||
setLayoutMode: undefined,
|
setLayoutMode: undefined,
|
||||||
toggleScreenSharing: undefined,
|
toggleScreenSharing: undefined,
|
||||||
audioEnabled: undefined,
|
audioEnabled: undefined,
|
||||||
|
audioBusy: false,
|
||||||
videoEnabled: undefined,
|
videoEnabled: undefined,
|
||||||
|
videoBusy: false,
|
||||||
layoutMode: undefined,
|
layoutMode: undefined,
|
||||||
sharingScreen: false,
|
sharingScreen: false,
|
||||||
audioOutputSwitcher: undefined,
|
audioOutputSwitcher: undefined,
|
||||||
|
|||||||
@@ -93,6 +93,48 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
expect(onMute).toHaveBeenCalled();
|
expect(onMute).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("disables mute button while busy", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onMute = vi.fn();
|
||||||
|
const { getByRole } = renderComponent(
|
||||||
|
<MediaMuteAndSwitchButton
|
||||||
|
title={"Switcher"}
|
||||||
|
onMuteClick={onMute}
|
||||||
|
iconsAndLabels="audio"
|
||||||
|
enabled={true}
|
||||||
|
busy={true}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const muteButton = getByRole("switch", { name: "Mute microphone" });
|
||||||
|
expect(muteButton).toHaveAttribute("aria-disabled", "true");
|
||||||
|
expect(muteButton).toHaveAttribute("aria-busy", "true");
|
||||||
|
|
||||||
|
await user.click(muteButton);
|
||||||
|
expect(onMute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disables video button while busy", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onMute = vi.fn();
|
||||||
|
const { getByRole } = renderComponent(
|
||||||
|
<MediaMuteAndSwitchButton
|
||||||
|
title={"Switcher"}
|
||||||
|
onMuteClick={onMute}
|
||||||
|
iconsAndLabels="video"
|
||||||
|
enabled={true}
|
||||||
|
busy={true}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const videoButton = getByRole("switch", { name: "Stop video" });
|
||||||
|
expect(videoButton).toHaveAttribute("aria-disabled", "true");
|
||||||
|
expect(videoButton).toHaveAttribute("aria-busy", "true");
|
||||||
|
|
||||||
|
await user.click(videoButton);
|
||||||
|
expect(onMute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
test("requests device names when opened", async () => {
|
test("requests device names when opened", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const requestDeviceNames = vi.fn();
|
const requestDeviceNames = vi.fn();
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export interface MediaMuteAndSwitchButtonProps {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Callback if the mute button is clicked */
|
/** Callback if the mute button is clicked */
|
||||||
onMuteClick?: () => void;
|
onMuteClick?: () => void;
|
||||||
|
/** True while mute/unmute operation is syncing. */
|
||||||
|
busy?: boolean;
|
||||||
iconsAndLabels: "video" | "audio";
|
iconsAndLabels: "video" | "audio";
|
||||||
/** The options available for the media device selector modal */
|
/** The options available for the media device selector modal */
|
||||||
options?: MenuOptions[];
|
options?: MenuOptions[];
|
||||||
@@ -59,6 +61,7 @@ const BLUR_ID = "blur";
|
|||||||
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||||
title,
|
title,
|
||||||
enabled,
|
enabled,
|
||||||
|
busy,
|
||||||
onMuteClick,
|
onMuteClick,
|
||||||
iconsAndLabels,
|
iconsAndLabels,
|
||||||
options,
|
options,
|
||||||
@@ -69,6 +72,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
|
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const isBusy = busy ?? false;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const devices = useMediaDevices();
|
const devices = useMediaDevices();
|
||||||
|
|
||||||
@@ -83,12 +87,13 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
button = (
|
button = (
|
||||||
<VideoButton
|
<VideoButton
|
||||||
enabled={enabled ?? false}
|
enabled={enabled ?? false}
|
||||||
|
busy={isBusy}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
onMuteClick?.();
|
onMuteClick?.();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
disabled={onMuteClick === undefined}
|
disabled={isBusy || onMuteClick === undefined}
|
||||||
data-testid="incall_videomute"
|
data-testid="incall_videomute"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -106,12 +111,13 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
button = (
|
button = (
|
||||||
<MicButton
|
<MicButton
|
||||||
enabled={enabled ?? false}
|
enabled={enabled ?? false}
|
||||||
|
busy={isBusy}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
onMuteClick?.();
|
onMuteClick?.();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
disabled={onMuteClick === undefined}
|
disabled={isBusy || onMuteClick === undefined}
|
||||||
data-testid="incall_mute"
|
data-testid="incall_mute"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = `
|
|||||||
class="container"
|
class="container"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
aria-busy="false"
|
||||||
aria-checked="false"
|
aria-checked="false"
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
aria-labelledby="_r_0_"
|
aria-labelledby="_r_0_"
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-busy="false"
|
||||||
aria-checked="false"
|
aria-checked="false"
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
aria-labelledby="_r_i_"
|
aria-labelledby="_r_i_"
|
||||||
@@ -352,6 +353,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-busy="false"
|
||||||
aria-checked="false"
|
aria-checked="false"
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
aria-labelledby="_r_n_"
|
aria-labelledby="_r_n_"
|
||||||
|
|||||||
@@ -304,6 +304,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-busy="false"
|
||||||
aria-checked="false"
|
aria-checked="false"
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
aria-labelledby="_r_g_"
|
aria-labelledby="_r_g_"
|
||||||
@@ -328,6 +329,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-busy="false"
|
||||||
aria-checked="false"
|
aria-checked="false"
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
aria-labelledby="_r_l_"
|
aria-labelledby="_r_l_"
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ function createMockLocalTrack(source: Track.Source): LocalTrack {
|
|||||||
|
|
||||||
function createMockMuteState(enabled$: BehaviorSubject<boolean>): {
|
function createMockMuteState(enabled$: BehaviorSubject<boolean>): {
|
||||||
enabled$: BehaviorSubject<boolean>;
|
enabled$: BehaviorSubject<boolean>;
|
||||||
|
syncing$: BehaviorSubject<boolean>;
|
||||||
setHandler: (h: (enabled: boolean) => void) => void;
|
setHandler: (h: (enabled: boolean) => void) => void;
|
||||||
unsetHandler: () => void;
|
unsetHandler: () => void;
|
||||||
} {
|
} {
|
||||||
@@ -70,6 +71,7 @@ function createMockMuteState(enabled$: BehaviorSubject<boolean>): {
|
|||||||
|
|
||||||
const ms = {
|
const ms = {
|
||||||
enabled$,
|
enabled$,
|
||||||
|
syncing$: new BehaviorSubject(false),
|
||||||
setHandler: vi.fn().mockImplementation((h: (enabled: boolean) => void) => {
|
setHandler: vi.fn().mockImplementation((h: (enabled: boolean) => void) => {
|
||||||
currentHandler = h;
|
currentHandler = h;
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -112,27 +112,49 @@ export class Publisher {
|
|||||||
this.logger.info("Local track published", localTrackPublication);
|
this.logger.info("Local track published", localTrackPublication);
|
||||||
const lkRoom = this.connection.livekitRoom;
|
const lkRoom = this.connection.livekitRoom;
|
||||||
if (!this.shouldPublish) {
|
if (!this.shouldPublish) {
|
||||||
|
this.logger.debug("Not publishing, pausing upstream");
|
||||||
this.pauseUpstreams(lkRoom, [localTrackPublication.source]).catch((e) => {
|
this.pauseUpstreams(lkRoom, [localTrackPublication.source]).catch((e) => {
|
||||||
this.logger.error(`Failed to pause upstreams`, e);
|
this.logger.error(`Failed to pause upstreams`, e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// also check the mute state and apply it
|
|
||||||
if (localTrackPublication.source === Track.Source.Microphone) {
|
if (localTrackPublication.source === Track.Source.Microphone) {
|
||||||
const enabled = this.muteStates.audio.enabled$.value;
|
const muteState = this.muteStates.audio;
|
||||||
lkRoom.localParticipant.setMicrophoneEnabled(enabled).catch((e) => {
|
// skip this if a sync is in progress: enabled$ still reflects the old
|
||||||
this.logger.error(
|
// state while the handler is mid-flight, so the handler itself will apply
|
||||||
`Failed to enable microphone track, enabled:${enabled}`,
|
// the correct mute state once it completes.
|
||||||
e,
|
if (!muteState.syncing$.value) {
|
||||||
);
|
const enabled = muteState.enabled$.value;
|
||||||
});
|
if (!enabled) {
|
||||||
|
this.logger.info(
|
||||||
|
"Local audio track just published but muted meanwhile, setting enabled to false",
|
||||||
|
);
|
||||||
|
lkRoom.localParticipant.setMicrophoneEnabled(false).catch((e) => {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to enable microphone track, enabled:${enabled}`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (localTrackPublication.source === Track.Source.Camera) {
|
} else if (localTrackPublication.source === Track.Source.Camera) {
|
||||||
const enabled = this.muteStates.video.enabled$.value;
|
const muteState = this.muteStates.video;
|
||||||
lkRoom.localParticipant.setCameraEnabled(enabled).catch((e) => {
|
// skip this if a sync is in progress: enabled$ still reflects the old
|
||||||
this.logger.error(
|
// state while the handler is mid-flight, so the handler itself will apply
|
||||||
`Failed to enable camera track, enabled:${enabled}`,
|
// the correct mute state once it completes.
|
||||||
e,
|
if (!muteState.syncing$.value) {
|
||||||
);
|
const enabled = muteState.enabled$.value;
|
||||||
});
|
if (!enabled) {
|
||||||
|
this.logger.info(
|
||||||
|
"Local video track just published but muted meanwhile, setting enabled to false",
|
||||||
|
);
|
||||||
|
lkRoom.localParticipant.setCameraEnabled(false).catch((e) => {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to enable camera track, enabled:${enabled}`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -92,6 +92,75 @@ describe("MuteState", () => {
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(lastEnabled).toBe(true);
|
expect(lastEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should ignore toggle while syncing but still process set requests", async () => {
|
||||||
|
const deviceStub = {
|
||||||
|
available$: constant(
|
||||||
|
new Map<string, DeviceLabel>([
|
||||||
|
["mic", { type: "name", name: "Microphone" }],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
selected$: constant({ id: "mic" }),
|
||||||
|
select(): void {},
|
||||||
|
} as unknown as MediaDevice<DeviceLabel, SelectedDevice>;
|
||||||
|
|
||||||
|
const muteState = new MuteState(
|
||||||
|
testScope,
|
||||||
|
deviceStub,
|
||||||
|
false,
|
||||||
|
constant(false),
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = Promise.withResolvers<boolean>();
|
||||||
|
const second = Promise.withResolvers<boolean>();
|
||||||
|
const handler = vi
|
||||||
|
.fn<(desired: boolean) => Promise<boolean>>()
|
||||||
|
.mockImplementationOnce(async () => first.promise)
|
||||||
|
.mockImplementationOnce(async () => second.promise);
|
||||||
|
muteState.setHandler(handler);
|
||||||
|
|
||||||
|
const syncingValues: boolean[] = [];
|
||||||
|
muteState.syncing$.subscribe((syncing) => {
|
||||||
|
syncingValues.push(syncing);
|
||||||
|
});
|
||||||
|
|
||||||
|
let setEnabled: ((enabled: boolean) => void) | null = null;
|
||||||
|
muteState.setEnabled$.subscribe((setter) => {
|
||||||
|
setEnabled = setter;
|
||||||
|
});
|
||||||
|
let toggle: (() => void) | null = null;
|
||||||
|
muteState.toggle$.subscribe((toggleFn) => {
|
||||||
|
toggle = toggleFn;
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
// Start syncing by requesting unmute.
|
||||||
|
toggle!();
|
||||||
|
await flushPromises();
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handler).toHaveBeenNthCalledWith(1, true);
|
||||||
|
|
||||||
|
// Toggle requests are ignored while syncing.
|
||||||
|
toggle!();
|
||||||
|
await flushPromises();
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// setEnabled still updates latest desired state while syncing (push-to-talk).
|
||||||
|
setEnabled!(false);
|
||||||
|
await flushPromises();
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
first.resolve(true);
|
||||||
|
await flushPromises();
|
||||||
|
expect(handler).toHaveBeenCalledTimes(2);
|
||||||
|
expect(handler).toHaveBeenNthCalledWith(2, false);
|
||||||
|
|
||||||
|
second.resolve(false);
|
||||||
|
await flushPromises();
|
||||||
|
expect(syncingValues).toContain(true);
|
||||||
|
expect(syncingValues.at(-1)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("MuteStates", () => {
|
describe("MuteStates", () => {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { type Behavior, constant } from "./Behavior";
|
|||||||
|
|
||||||
interface MuteStateData {
|
interface MuteStateData {
|
||||||
enabled$: Observable<boolean>;
|
enabled$: Observable<boolean>;
|
||||||
|
syncing$: Observable<boolean>;
|
||||||
set: ((enabled: boolean) => void) | null;
|
set: ((enabled: boolean) => void) | null;
|
||||||
toggle: (() => void) | null;
|
toggle: (() => void) | null;
|
||||||
}
|
}
|
||||||
@@ -79,33 +80,40 @@ export class MuteState<Label, Selected> {
|
|||||||
this.handler$.value(false).catch((err) => {
|
this.handler$.value(false).catch((err) => {
|
||||||
logger.error("MuteState-disable: handler error", err);
|
logger.error("MuteState-disable: handler error", err);
|
||||||
});
|
});
|
||||||
return { enabled$: of(false), set: null, toggle: null };
|
return {
|
||||||
|
enabled$: of(false),
|
||||||
|
syncing$: of(false),
|
||||||
|
set: null,
|
||||||
|
toggle: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assume the default value only once devices are actually connected
|
// Assume the default value only once devices are actually connected
|
||||||
let enabled = this.enabledByDefault;
|
let enabled = this.enabledByDefault;
|
||||||
const set$ = new Subject<boolean>();
|
const set$ = new Subject<boolean>();
|
||||||
const toggle$ = new Subject<void>();
|
const toggle$ = new Subject<void>();
|
||||||
|
const syncing$ = new BehaviorSubject(false);
|
||||||
const desired$ = merge(set$, toggle$.pipe(map(() => !enabled)));
|
const desired$ = merge(set$, toggle$.pipe(map(() => !enabled)));
|
||||||
const enabled$ = new Observable<boolean>((subscriber) => {
|
const enabled$ = new Observable<boolean>((subscriber) => {
|
||||||
subscriber.next(enabled);
|
subscriber.next(enabled);
|
||||||
let latestDesired = this.enabledByDefault;
|
let latestDesired = this.enabledByDefault;
|
||||||
let syncing = false;
|
|
||||||
|
|
||||||
const sync = async (): Promise<void> => {
|
const sync = async (): Promise<void> => {
|
||||||
if (enabled === latestDesired) syncing = false;
|
if (enabled === latestDesired) {
|
||||||
else {
|
syncing$.next(false);
|
||||||
|
} else {
|
||||||
const previouslyEnabled = enabled;
|
const previouslyEnabled = enabled;
|
||||||
|
syncing$.next(true);
|
||||||
enabled = await firstValueFrom(
|
enabled = await firstValueFrom(
|
||||||
this.handler$.pipe(
|
this.handler$.pipe(
|
||||||
switchMap(async (handler) => handler(latestDesired)),
|
switchMap(async (handler) => handler(latestDesired)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (enabled === previouslyEnabled) {
|
if (enabled === previouslyEnabled) {
|
||||||
syncing = false;
|
syncing$.next(false);
|
||||||
} else {
|
} else {
|
||||||
subscriber.next(enabled);
|
subscriber.next(enabled);
|
||||||
syncing = true;
|
syncing$.next(true);
|
||||||
sync().catch((err) => {
|
sync().catch((err) => {
|
||||||
// TODO: better error handling
|
// TODO: better error handling
|
||||||
logger.error("MuteState: handler error", err);
|
logger.error("MuteState: handler error", err);
|
||||||
@@ -116,21 +124,28 @@ export class MuteState<Label, Selected> {
|
|||||||
|
|
||||||
const s = desired$.subscribe((desired) => {
|
const s = desired$.subscribe((desired) => {
|
||||||
latestDesired = desired;
|
latestDesired = desired;
|
||||||
if (syncing === false) {
|
if (syncing$.value === false) {
|
||||||
syncing = true;
|
syncing$.next(true);
|
||||||
sync().catch((err) => {
|
sync().catch((err) => {
|
||||||
// TODO: better error handling
|
// TODO: better error handling
|
||||||
logger.error("MuteState: handler error", err);
|
logger.error("MuteState: handler error", err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return (): void => s.unsubscribe();
|
return (): void => {
|
||||||
|
s.unsubscribe();
|
||||||
|
syncing$.complete();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
set: (enabled: boolean): void => set$.next(enabled),
|
set: (enabled: boolean): void => set$.next(enabled),
|
||||||
toggle: (): void => toggle$.next(),
|
toggle: (): void => {
|
||||||
|
if (syncing$.value) return;
|
||||||
|
toggle$.next();
|
||||||
|
},
|
||||||
enabled$,
|
enabled$,
|
||||||
|
syncing$,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -147,6 +162,10 @@ export class MuteState<Label, Selected> {
|
|||||||
this.data$.pipe(map(({ toggle }) => toggle)),
|
this.data$.pipe(map(({ toggle }) => toggle)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public readonly syncing$: Behavior<boolean> = this.scope.behavior(
|
||||||
|
this.data$.pipe(switchMap(({ syncing$ }) => syncing$)),
|
||||||
|
);
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly scope: ObservableScope,
|
private readonly scope: ObservableScope,
|
||||||
private readonly device: MediaDevice<Label, Selected>,
|
private readonly device: MediaDevice<Label, Selected>,
|
||||||
|
|||||||
Reference in New Issue
Block a user