feat(mute): add syncing state and disable toggle during async mute

This commit is contained in:
Valere
2026-06-04 18:55:31 +02:00
parent fc3c4bf566
commit 4606373e5b
10 changed files with 191 additions and 19 deletions

View File

@@ -80,7 +80,9 @@ export const Default: Story = {
showLogo: false,
layoutMode: "grid",
audioEnabled: true,
audioBusy: false,
videoEnabled: true,
videoBusy: false,
setLayoutMode: fn(),
openSettings: fn(),
toggleAudio: fn(),

View File

@@ -72,7 +72,9 @@ export interface FooterActions {
// we do not use any ? optional properties so that the vm type is including all fields.
export interface FooterState {
audioEnabled: boolean;
audioBusy: boolean;
videoEnabled: boolean;
videoBusy: boolean;
videoBlurEnabled: boolean;
showFooter: boolean;
@@ -122,7 +124,9 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
const setLayoutMode = useBehavior(vm.setLayoutMode$);
const openSettings = useBehavior(vm.openSettings$);
const audioEnabled = useBehavior(vm.audioEnabled$);
const audioBusy = useBehavior(vm.audioBusy$);
const videoEnabled = useBehavior(vm.videoEnabled$);
const videoBusy = useBehavior(vm.videoBusy$);
const toggleAudio = useBehavior(vm.toggleAudio$);
const toggleVideo = useBehavior(vm.toggleVideo$);
const sharingScreen = useBehavior(vm.sharingScreen$);
@@ -167,6 +171,7 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
key="audio"
iconsAndLabels="audio"
enabled={audioEnabled ?? false}
busy={audioBusy ?? false}
onMuteClick={toggleAudio}
data-testid="incall_mute"
options={audioOptions}
@@ -180,8 +185,9 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
size={buttonSize}
key="audio"
enabled={audioEnabled ?? false}
busy={audioBusy ?? false}
onClick={toggleAudio}
disabled={toggleAudio === undefined}
disabled={(audioBusy ?? false) || toggleAudio === undefined}
data-testid="incall_mute"
/>,
);
@@ -194,6 +200,7 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
key="video"
iconsAndLabels="video"
enabled={videoEnabled ?? false}
busy={videoBusy ?? false}
onMuteClick={toggleVideo}
options={videoOptions}
selectedOption={selectedVideo}
@@ -208,8 +215,9 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
size={buttonSize}
key="video"
enabled={videoEnabled ?? false}
busy={videoBusy ?? false}
onClick={toggleVideo}
disabled={toggleVideo === undefined}
disabled={(videoBusy ?? false) || toggleVideo === undefined}
data-testid="incall_videomute"
/>,
);

View File

@@ -32,14 +32,21 @@ function buildMuteBehaviors(
muteStates: MuteStates,
): Pick<
ViewModel<FooterSnapshot>,
"audioEnabled$" | "toggleAudio$" | "videoEnabled$" | "toggleVideo$"
| "audioEnabled$"
| "audioBusy$"
| "toggleAudio$"
| "videoEnabled$"
| "videoBusy$"
| "toggleVideo$"
> {
return {
audioEnabled$: muteStates.audio.enabled$,
audioBusy$: muteStates.audio.syncing$,
toggleAudio$: scope.behavior(
muteStates.audio.toggle$.pipe(map((t) => t ?? undefined)),
),
videoEnabled$: muteStates.video.enabled$,
videoBusy$: muteStates.video.syncing$,
toggleVideo$: scope.behavior(
muteStates.video.toggle$.pipe(map((t) => t ?? undefined)),
),
@@ -252,7 +259,9 @@ export function createLobbyFooterViewModel(
setLayoutMode: undefined,
toggleScreenSharing: undefined,
audioEnabled: undefined,
audioBusy: false,
videoEnabled: undefined,
videoBusy: false,
layoutMode: undefined,
sharingScreen: false,
audioOutputSwitcher: undefined,

View File

@@ -93,6 +93,27 @@ describe("MediaMuteAndSwitchButton", () => {
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("requests device names when opened", async () => {
const user = userEvent.setup();
const requestDeviceNames = vi.fn();

View File

@@ -40,6 +40,8 @@ export interface MediaMuteAndSwitchButtonProps {
enabled?: boolean;
/** Callback if the mute button is clicked */
onMuteClick?: () => void;
/** True while mute/unmute operation is syncing. */
busy?: boolean;
iconsAndLabels: "video" | "audio";
/** The options available for the media device selector modal */
options?: MenuOptions[];
@@ -59,6 +61,7 @@ const BLUR_ID = "blur";
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
title,
enabled,
busy,
onMuteClick,
iconsAndLabels,
options,
@@ -69,6 +72,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
}) => {
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const isBusy = busy ?? false;
const { t } = useTranslation();
const devices = useMediaDevices();
@@ -83,12 +87,14 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
button = (
<VideoButton
enabled={enabled ?? false}
busy={isBusy}
onClick={(e) => {
if (isBusy) return;
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
disabled={onMuteClick === undefined}
disabled={isBusy || onMuteClick === undefined}
data-testid="incall_videomute"
/>
);
@@ -106,12 +112,14 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
button = (
<MicButton
enabled={enabled ?? false}
busy={isBusy}
onClick={(e) => {
if (isBusy) return;
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
disabled={onMuteClick === undefined}
disabled={isBusy || onMuteClick === undefined}
data-testid="incall_mute"
/>
);

View File

@@ -6,6 +6,7 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = `
class="container"
>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_0_"