diff --git a/src/button/Button.module.css b/src/button/Button.module.css index 1b53e0dd..dc7573d4 100644 --- a/src/button/Button.module.css +++ b/src/button/Button.module.css @@ -8,3 +8,17 @@ Please see LICENSE in the repository root for full details. .endCall > svg { color: var(--stopgap-color-on-solid-accent); } + +.rotate > svg { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 40360ce9..e639e76e 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -16,6 +16,7 @@ import { import { MicOnSolidIcon, MicOffSolidIcon, + SpinnerIcon, VideoCallSolidIcon, VideoCallOffSolidIcon, EndCallIcon, @@ -32,12 +33,13 @@ import { platform } from "../Platform"; interface MicButtonProps extends ComponentPropsWithoutRef<"button"> { enabled: boolean; + busy?: boolean; size?: "md" | "lg"; } -export const MicButton: FC = ({ enabled, ...props }) => { +export const MicButton: FC = ({ enabled, busy, ...props }) => { const { t } = useTranslation(); - const Icon = enabled ? MicOnSolidIcon : MicOffSolidIcon; + const Icon = busy ? SpinnerIcon : enabled ? MicOnSolidIcon : MicOffSolidIcon; const label = enabled ? t("mute_microphone_button_label") : t("unmute_microphone_button_label"); @@ -51,6 +53,11 @@ export const MicButton: FC = ({ enabled, ...props }) => { role="switch" aria-checked={enabled} {...props} + aria-busy={busy} + className={classNames(props.className, { + [styles.rotate]: !!busy, + })} + disabled={props.disabled || busy} /> ); @@ -58,12 +65,21 @@ export const MicButton: FC = ({ enabled, ...props }) => { interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> { enabled: boolean; + busy?: boolean; size?: "md" | "lg"; } -export const VideoButton: FC = ({ enabled, ...props }) => { +export const VideoButton: FC = ({ + enabled, + busy, + ...props +}) => { const { t } = useTranslation(); - const Icon = enabled ? VideoCallSolidIcon : VideoCallOffSolidIcon; + const Icon = busy + ? SpinnerIcon + : enabled + ? VideoCallSolidIcon + : VideoCallOffSolidIcon; const label = enabled ? t("stop_video_button_label") : t("start_video_button_label"); @@ -77,6 +93,11 @@ export const VideoButton: FC = ({ enabled, ...props }) => { role="switch" aria-checked={enabled} {...props} + aria-busy={busy} + className={classNames(props.className, { + [styles.rotate]: !!busy, + })} + disabled={props.disabled || busy} /> ); diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index 7006d809..2a25c20b 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -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(), diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index 7dd68d88..f952601d 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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" />, ); diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx index ec4d4800..bffef9b5 100644 --- a/src/components/CallFooterViewModel.tsx +++ b/src/components/CallFooterViewModel.tsx @@ -32,14 +32,21 @@ function buildMuteBehaviors( muteStates: MuteStates, ): Pick< ViewModel, - "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, diff --git a/src/components/MediaMuteAndSwitchButton.test.tsx b/src/components/MediaMuteAndSwitchButton.test.tsx index ac6540e0..2ec2c867 100644 --- a/src/components/MediaMuteAndSwitchButton.test.tsx +++ b/src/components/MediaMuteAndSwitchButton.test.tsx @@ -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( + , + ); + + 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(); diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index e2309e1a..57a2efb9 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -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 = ({ title, enabled, + busy, onMuteClick, iconsAndLabels, options, @@ -69,6 +72,7 @@ export const MediaMuteAndSwitchButton: FC = ({ }) => { const [plannedSelection, setPlannedSelection] = useState(null); const [menuOpen, setMenuOpen] = useState(false); + const isBusy = busy ?? false; const { t } = useTranslation(); const devices = useMediaDevices(); @@ -83,12 +87,14 @@ export const MediaMuteAndSwitchButton: FC = ({ button = ( { + 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 = ({ button = ( { + if (isBusy) return; onMuteClick?.(); e.preventDefault(); e.stopPropagation(); }} - disabled={onMuteClick === undefined} + disabled={isBusy || onMuteClick === undefined} data-testid="incall_mute" /> ); diff --git a/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap b/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap index ed8d2931..8fe77ef1 100644 --- a/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap +++ b/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap @@ -6,6 +6,7 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = ` class="container" >