From 9c46f348ba2d3efd8b23bf42c63a5599cf6b25d2 Mon Sep 17 00:00:00 2001 From: zskhan Date: Wed, 3 Jun 2026 23:04:30 +0200 Subject: [PATCH 1/2] feat: show loader during background effect initialization --- .../calling/GroupVideoGridTile.styles.ts | 11 ++++++ .../components/calling/GroupVideoGridTile.tsx | 36 +++++++++++++++---- .../repositories/calling/CallingRepository.ts | 7 ++++ .../media/useBackgroundEffectsStore.ts | 8 +++++ 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/apps/webapp/src/script/components/calling/GroupVideoGridTile.styles.ts b/apps/webapp/src/script/components/calling/GroupVideoGridTile.styles.ts index 1d41b52f82c..7bd4ae7acfb 100644 --- a/apps/webapp/src/script/components/calling/GroupVideoGridTile.styles.ts +++ b/apps/webapp/src/script/components/calling/GroupVideoGridTile.styles.ts @@ -94,3 +94,14 @@ export const groupVideoElementVideo = (fitContain: boolean, mirrorSelf: boolean) export const groupVideoPauseOverlayLabel = (minimized: boolean): CSSObject => ({ fontSize: minimized ? '0.6875rem' : '0.875rem', }); + +export const groupVideoBackgroundInitializingOverlay: CSSObject = { + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 'inherit', + backgroundColor: 'var(--group-video-tile-bg)', + zIndex: 1, +}; diff --git a/apps/webapp/src/script/components/calling/GroupVideoGridTile.tsx b/apps/webapp/src/script/components/calling/GroupVideoGridTile.tsx index 4073dde53cb..21b8dc67f19 100644 --- a/apps/webapp/src/script/components/calling/GroupVideoGridTile.tsx +++ b/apps/webapp/src/script/components/calling/GroupVideoGridTile.tsx @@ -17,17 +17,19 @@ * */ -import {KeyboardEvent} from 'react'; +import {KeyboardEvent, useEffect, useState} from 'react'; +import is from '@sindresorhus/is'; import {QualifiedId} from '@wireapp/api-client/lib/user'; import {VIDEO_STATE} from '@wireapp/avs'; -import {TabIndex} from '@wireapp/react-ui-kit'; +import {Loading, TabIndex} from '@wireapp/react-ui-kit'; import {Avatar, AVATAR_SIZE} from 'Components/Avatar'; import { groupVideoActiveSpeaker, groupVideoActiveSpeakerTile, + groupVideoBackgroundInitializingOverlay, groupVideoElementVideo, groupVideoParticipantAudioStatus, groupVideoParticipantName, @@ -37,6 +39,7 @@ import { } from 'Components/calling/GroupVideoGridTile.styles'; import * as Icon from 'Components/icon'; import type {Participant} from 'Repositories/calling/Participant'; +import {useBackgroundEffectsStore} from 'Repositories/media/useBackgroundEffectsStore'; import {useKoSubscribableChildren} from 'Util/componentUtil'; import {isEnterKey} from 'Util/keyboardUtil'; import {t} from 'Util/localizerUtil'; @@ -82,11 +85,25 @@ const GroupVideoGridTile = ({ const {name} = useKoSubscribableChildren(participant?.user, ['name']); + const isBackgroundEffectInitializing = useBackgroundEffectsStore(state => state.isInitializing); + + const [isVideoReady, setIsVideoReady] = useState(false); + + useEffect(() => { + setIsVideoReady(false); + }, [processedVideoStream]); + + const isSelfParticipant = participant === selfParticipant; const sharesScreen = videoState === VIDEO_STATE.SCREENSHARE; const sharesCamera = [VIDEO_STATE.STARTED, VIDEO_STATE.PAUSED].includes(videoState); const hasPausedVideo = videoState === VIDEO_STATE.PAUSED; const doVideoReconnecting = videoState === VIDEO_STATE.RECONNECTING; - const hasActiveVideo = (sharesCamera || sharesScreen) && !!videoStream; + const isSelfInitializing = + isSelfParticipant && isBackgroundEffectInitializing && is.nullOrUndefined(processedVideoStream); + const hasActiveVideo = (sharesCamera || sharesScreen) && !!videoStream && !isSelfInitializing; + const showLoadingOverlay = + isSelfParticipant && + (isBackgroundEffectInitializing || (hasActiveVideo && !isVideoReady && !!processedVideoStream)); const handleTileClick = () => onTileDoubleClick(participant?.user.qualifiedId, participant?.clientId); @@ -127,9 +144,7 @@ const GroupVideoGridTile = ({ onKeyDown={handleEnterTileClick} role="button" // minimized is passed only from CallingCell where we don't want to focus individual the tile on the tab press - tabIndex={ - (!minimized || isMaximized) && participant !== selfParticipant ? TabIndex.FOCUSABLE : TabIndex.UNFOCUSABLE - } + tabIndex={(!minimized || isMaximized) && !isSelfParticipant ? TabIndex.FOCUSABLE : TabIndex.UNFOCUSABLE} aria-label={`Focus video ${participant?.user.id}`} > {hasActiveVideo ? ( @@ -144,7 +159,8 @@ const GroupVideoGridTile = ({ muted srcObject={processedVideoStream?.stream ?? videoStream} className="group-video-grid__element-video" - css={groupVideoElementVideo(isMaximized || sharesScreen, participant === selfParticipant && sharesCamera)} + css={groupVideoElementVideo(isMaximized || sharesScreen, isSelfParticipant && sharesCamera)} + onCanPlay={() => setIsVideoReady(true)} /> ) : ( @@ -157,6 +173,12 @@ const GroupVideoGridTile = ({ )} + {showLoadingOverlay && ( +
+ +
+ )} +
{!minimized && isMuted && ( diff --git a/apps/webapp/src/script/repositories/calling/CallingRepository.ts b/apps/webapp/src/script/repositories/calling/CallingRepository.ts index f973de9a497..74953aacdfc 100644 --- a/apps/webapp/src/script/repositories/calling/CallingRepository.ts +++ b/apps/webapp/src/script/repositories/calling/CallingRepository.ts @@ -76,6 +76,7 @@ import {BackgroundEffectsHandler} from 'Repositories/media/backgroundEffectsHand import type {MediaDevicesHandler} from 'Repositories/media/MediaDevicesHandler'; import type {MediaStreamHandler} from 'Repositories/media/MediaStreamHandler'; import {MediaType} from 'Repositories/media/MediaType'; +import {backgroundEffectsStore} from 'Repositories/media/useBackgroundEffectsStore'; import type {BackgroundEffectSelection, BackgroundSource} from 'Repositories/media/VideoBackgroundEffects'; import {TeamState} from 'Repositories/team/TeamState'; import {EventName} from 'Repositories/tracking/eventName'; @@ -728,6 +729,7 @@ export class CallingRepository { try { const selfParticipant = call.getSelfParticipant(); camera = this.teamState.isVideoCallingEnabled() ? camera : false; + backgroundEffectsStore.getState().setIsInitializing(true); const mediaStream = await this.getMediaStream({audio, camera}, call.isGroupOrConference); if (call.state() !== CALL_STATE.NONE) { selfParticipant.updateMediaStream(mediaStream, true); @@ -743,6 +745,8 @@ export class CallingRepository { return true; } catch (_error: unknown) { return false; + } finally { + backgroundEffectsStore.getState().setIsInitializing(false); } } @@ -2654,6 +2658,7 @@ export class CallingRepository { if (missingStreams.screen && selfParticipant.sharesScreen()) { return selfParticipant.getMediaStream(); } + backgroundEffectsStore.getState().setIsInitializing(true); const mediaStream = await this.getMediaStream(missingStreams, call.isGroupOrConference); this.mediaStreamQuery = undefined; selfParticipant.updateMediaStream(mediaStream, true); @@ -2668,6 +2673,8 @@ export class CallingRepository { this.logger.warn('Could not get mediaStream for call', error); this.handleMediaStreamError(call, missingStreams, error); return selfParticipant.getMediaStream(); + } finally { + backgroundEffectsStore.getState().setIsInitializing(false); } })(); diff --git a/apps/webapp/src/script/repositories/media/useBackgroundEffectsStore.ts b/apps/webapp/src/script/repositories/media/useBackgroundEffectsStore.ts index 89b94a26cc1..aa0e973bf75 100644 --- a/apps/webapp/src/script/repositories/media/useBackgroundEffectsStore.ts +++ b/apps/webapp/src/script/repositories/media/useBackgroundEffectsStore.ts @@ -44,6 +44,7 @@ export type BackgroundEffectsState = { model: string; lastVirtualBackgroundId: string; isHighQualityBlurEnabled: boolean; + isInitializing: boolean; setIsFeatureEnabled(value: boolean): void; setPreferredEffect(effect: BackgroundEffectSelection): void; @@ -51,6 +52,7 @@ export type BackgroundEffectsState = { setMetrics(metrics: RenderMetrics | undefined): void; setModel(model: string | undefined): void; setIsHighQualityBlurEnabled(value: boolean): void; + setIsInitializing(value: boolean): void; }; export const backgroundEffectsStore = createStore()( @@ -90,6 +92,12 @@ export const backgroundEffectsStore = createStore()( set(state => { state.isHighQualityBlurEnabled = value; }), + + isInitializing: false, + setIsInitializing: value => + set(state => { + state.isInitializing = value; + }), })), ); From 548d2deb11996fdda93330cde10f3b58d4696ec7 Mon Sep 17 00:00:00 2001 From: zskhan Date: Mon, 8 Jun 2026 12:58:18 +0200 Subject: [PATCH 2/2] feat: extract loading overlay logic into a hook, improve test coverage and accessibility --- .../calling/GroupVideoGridTile.styles.ts | 2 +- .../calling/GroupVideoGridTile.test.tsx | 112 ++++++++++++++++++ .../components/calling/GroupVideoGridTile.tsx | 38 +++--- .../calling/useShowLoadingOverlay.test.ts | 90 ++++++++++++++ .../calling/useShowLoadingOverlay.ts | 57 +++++++++ 5 files changed, 277 insertions(+), 22 deletions(-) create mode 100644 apps/webapp/src/script/components/calling/GroupVideoGridTile.test.tsx create mode 100644 apps/webapp/src/script/components/calling/useShowLoadingOverlay.test.ts create mode 100644 apps/webapp/src/script/components/calling/useShowLoadingOverlay.ts diff --git a/apps/webapp/src/script/components/calling/GroupVideoGridTile.styles.ts b/apps/webapp/src/script/components/calling/GroupVideoGridTile.styles.ts index 7bd4ae7acfb..cd356ef8e1d 100644 --- a/apps/webapp/src/script/components/calling/GroupVideoGridTile.styles.ts +++ b/apps/webapp/src/script/components/calling/GroupVideoGridTile.styles.ts @@ -86,7 +86,7 @@ export const groupVideoParticipantAudioStatus = ( }; }; -export const groupVideoElementVideo = (fitContain: boolean, mirrorSelf: boolean): CSSObject => ({ +export const getGroupVideoElementStyles = (fitContain: boolean, mirrorSelf: boolean): CSSObject => ({ objectFit: fitContain ? 'contain' : 'cover', transform: mirrorSelf ? 'rotateY(180deg)' : 'initial', }); diff --git a/apps/webapp/src/script/components/calling/GroupVideoGridTile.test.tsx b/apps/webapp/src/script/components/calling/GroupVideoGridTile.test.tsx new file mode 100644 index 00000000000..ea5cbeba980 --- /dev/null +++ b/apps/webapp/src/script/components/calling/GroupVideoGridTile.test.tsx @@ -0,0 +1,112 @@ +/* + * Wire + * Copyright (C) 2019 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {fireEvent, render} from '@testing-library/react'; + +import {VIDEO_STATE} from '@wireapp/avs'; + +import {Participant} from 'Repositories/calling/Participant'; +import {User} from 'Repositories/entity/User'; +import {backgroundEffectsStore} from 'Repositories/media/useBackgroundEffectsStore'; +import {createUuid} from 'Util/uuid'; + +import {GroupVideoGridTile} from './GroupVideoGridTile'; + +const loadingOverlaySelector = '[data-uie-name="background-effect-initializing"]'; + +const createParticipant = (name: string) => { + const user = new User(createUuid()); + user.name(name); + + return new Participant(user, `client-${name}`); +}; + +const createMediaStream = () => + ({ + getVideoTracks: jest.fn(() => []), + }) as unknown as MediaStream; + +const createProcessedVideoStream = (stream: MediaStream) => ({ + stream, + release: jest.fn(), +}); + +const renderComponent = ({ + participant = createParticipant('self'), + selfParticipant = participant, +}: { + participant?: Participant; + selfParticipant?: Participant; +} = {}) => + render( + , + ); + +describe('GroupVideoGridTile', () => { + beforeEach(() => { + backgroundEffectsStore.setState({ + isInitializing: false, + }); + }); + + it('should show loading overlay when background effect is initializing', () => { + backgroundEffectsStore.setState({ + isInitializing: true, + }); + + const {container} = renderComponent(); + + expect(container.querySelector(loadingOverlaySelector)).toBeInTheDocument(); + }); + + it('should show loading overlay while video is loading', () => { + const participant = createParticipant('self'); + const videoStream = createMediaStream(); + + participant.videoState(VIDEO_STATE.STARTED); + participant.videoStream(videoStream); + participant.processedVideoStream(createProcessedVideoStream(videoStream)); + + const {container} = renderComponent({participant}); + + expect(container.querySelector(loadingOverlaySelector)).toBeInTheDocument(); + }); + + it('should hide loading overlay when video is ready', () => { + const participant = createParticipant('self'); + const videoStream = createMediaStream(); + + participant.videoState(VIDEO_STATE.STARTED); + participant.videoStream(videoStream); + participant.processedVideoStream(createProcessedVideoStream(videoStream)); + + const {container} = renderComponent({participant}); + + fireEvent.canPlay(container.querySelector('video')!); + + expect(container.querySelector(loadingOverlaySelector)).not.toBeInTheDocument(); + }); +}); diff --git a/apps/webapp/src/script/components/calling/GroupVideoGridTile.tsx b/apps/webapp/src/script/components/calling/GroupVideoGridTile.tsx index 21b8dc67f19..354133ce381 100644 --- a/apps/webapp/src/script/components/calling/GroupVideoGridTile.tsx +++ b/apps/webapp/src/script/components/calling/GroupVideoGridTile.tsx @@ -17,9 +17,8 @@ * */ -import {KeyboardEvent, useEffect, useState} from 'react'; +import {KeyboardEvent} from 'react'; -import is from '@sindresorhus/is'; import {QualifiedId} from '@wireapp/api-client/lib/user'; import {VIDEO_STATE} from '@wireapp/avs'; @@ -30,7 +29,7 @@ import { groupVideoActiveSpeaker, groupVideoActiveSpeakerTile, groupVideoBackgroundInitializingOverlay, - groupVideoElementVideo, + getGroupVideoElementStyles, groupVideoParticipantAudioStatus, groupVideoParticipantName, groupVideoParticipantNameWrapper, @@ -39,11 +38,11 @@ import { } from 'Components/calling/GroupVideoGridTile.styles'; import * as Icon from 'Components/icon'; import type {Participant} from 'Repositories/calling/Participant'; -import {useBackgroundEffectsStore} from 'Repositories/media/useBackgroundEffectsStore'; import {useKoSubscribableChildren} from 'Util/componentUtil'; import {isEnterKey} from 'Util/keyboardUtil'; import {t} from 'Util/localizerUtil'; +import {useShowLoadingOverlay} from './useShowLoadingOverlay'; import {Video} from './Video'; interface GroupVideoGridTileProps { @@ -85,25 +84,18 @@ const GroupVideoGridTile = ({ const {name} = useKoSubscribableChildren(participant?.user, ['name']); - const isBackgroundEffectInitializing = useBackgroundEffectsStore(state => state.isInitializing); - - const [isVideoReady, setIsVideoReady] = useState(false); - - useEffect(() => { - setIsVideoReady(false); - }, [processedVideoStream]); - const isSelfParticipant = participant === selfParticipant; const sharesScreen = videoState === VIDEO_STATE.SCREENSHARE; const sharesCamera = [VIDEO_STATE.STARTED, VIDEO_STATE.PAUSED].includes(videoState); const hasPausedVideo = videoState === VIDEO_STATE.PAUSED; const doVideoReconnecting = videoState === VIDEO_STATE.RECONNECTING; - const isSelfInitializing = - isSelfParticipant && isBackgroundEffectInitializing && is.nullOrUndefined(processedVideoStream); - const hasActiveVideo = (sharesCamera || sharesScreen) && !!videoStream && !isSelfInitializing; - const showLoadingOverlay = - isSelfParticipant && - (isBackgroundEffectInitializing || (hasActiveVideo && !isVideoReady && !!processedVideoStream)); + const hasActiveVideo = (sharesCamera || sharesScreen) && !!videoStream; + + const {showLoadingOverlay, onVideoCanPlay} = useShowLoadingOverlay( + isSelfParticipant, + hasActiveVideo, + processedVideoStream, + ); const handleTileClick = () => onTileDoubleClick(participant?.user.qualifiedId, participant?.clientId); @@ -159,8 +151,8 @@ const GroupVideoGridTile = ({ muted srcObject={processedVideoStream?.stream ?? videoStream} className="group-video-grid__element-video" - css={groupVideoElementVideo(isMaximized || sharesScreen, isSelfParticipant && sharesCamera)} - onCanPlay={() => setIsVideoReady(true)} + css={getGroupVideoElementStyles(isMaximized || sharesScreen, isSelfParticipant && sharesCamera)} + onCanPlay={onVideoCanPlay} />
) : ( @@ -174,7 +166,11 @@ const GroupVideoGridTile = ({ )} {showLoadingOverlay && ( -
+
)} diff --git a/apps/webapp/src/script/components/calling/useShowLoadingOverlay.test.ts b/apps/webapp/src/script/components/calling/useShowLoadingOverlay.test.ts new file mode 100644 index 00000000000..f603c1e6620 --- /dev/null +++ b/apps/webapp/src/script/components/calling/useShowLoadingOverlay.test.ts @@ -0,0 +1,90 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {act, renderHook} from '@testing-library/react'; + +import {backgroundEffectsStore} from 'Repositories/media/useBackgroundEffectsStore'; + +import {useShowLoadingOverlay} from './useShowLoadingOverlay'; + +const createMediaStream = () => + ({ + getVideoTracks: jest.fn(() => []), + }) as unknown as MediaStream; + +const createProcessedVideoStream = (stream: MediaStream) => ({ + stream, + release: jest.fn(), +}); + +describe('useShowLoadingOverlay', () => { + beforeEach(() => { + backgroundEffectsStore.setState({ + isInitializing: false, + }); + }); + + it('should show loading overlay when background effect is initializing', () => { + backgroundEffectsStore.setState({ + isInitializing: true, + }); + + const {result} = renderHook(() => useShowLoadingOverlay(true, false, undefined)); + + expect(result.current.showLoadingOverlay).toBe(true); + }); + + it('should hide loading overlay when background effect is done initializing', () => { + const {result} = renderHook(() => useShowLoadingOverlay(true, false, undefined)); + + expect(result.current.showLoadingOverlay).toBe(false); + }); + + it('should show loading overlay while video is loading', () => { + const processedVideoStream = createProcessedVideoStream(createMediaStream()); + + const {result} = renderHook(() => useShowLoadingOverlay(true, true, processedVideoStream)); + + expect(result.current.showLoadingOverlay).toBe(true); + }); + + it('should hide loading overlay when video is ready', () => { + const processedVideoStream = createProcessedVideoStream(createMediaStream()); + + const {result} = renderHook(() => useShowLoadingOverlay(true, true, processedVideoStream)); + + act(() => { + result.current.onVideoCanPlay(); + }); + + expect(result.current.showLoadingOverlay).toBe(false); + }); + + it('should not show overlay for non-self participants', () => { + backgroundEffectsStore.setState({ + isInitializing: true, + }); + + const processedVideoStream = createProcessedVideoStream(createMediaStream()); + + const {result} = renderHook(() => useShowLoadingOverlay(false, true, processedVideoStream)); + + expect(result.current.showLoadingOverlay).toBe(false); + }); +}); diff --git a/apps/webapp/src/script/components/calling/useShowLoadingOverlay.ts b/apps/webapp/src/script/components/calling/useShowLoadingOverlay.ts new file mode 100644 index 00000000000..ec79aa9b61d --- /dev/null +++ b/apps/webapp/src/script/components/calling/useShowLoadingOverlay.ts @@ -0,0 +1,57 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {useCallback, useEffect, useState} from 'react'; + +import {useBackgroundEffectsStore} from 'Repositories/media/useBackgroundEffectsStore'; + +type ProcessedVideoStream = { + stream: MediaStream; + release: () => void; +}; + +const useShowLoadingOverlay = ( + isSelfParticipant: boolean, + hasActiveVideo: boolean, + processedVideoStream?: ProcessedVideoStream, +) => { + const isBackgroundEffectInitializing = useBackgroundEffectsStore(state => state.isInitializing); + const [isVideoReady, setIsVideoReady] = useState(false); + + useEffect(() => { + setIsVideoReady(false); + }, [processedVideoStream]); + + const onVideoCanPlay = useCallback(() => { + setIsVideoReady(true); + }, []); + + const hasProcessedVideoStream = processedVideoStream !== undefined; + + const showLoadingOverlay = + isSelfParticipant && + (isBackgroundEffectInitializing || (hasActiveVideo && !isVideoReady && hasProcessedVideoStream)); + + return { + onVideoCanPlay, + showLoadingOverlay, + }; +}; + +export {useShowLoadingOverlay};