diff --git a/frontend/public/background/bookshelf-room.jpg b/frontend/public/background/bookshelf-room.jpg new file mode 100644 index 00000000..b6d7fb56 Binary files /dev/null and b/frontend/public/background/bookshelf-room.jpg differ diff --git a/frontend/public/background/busy-room.jpg b/frontend/public/background/busy-room.jpg new file mode 100644 index 00000000..c8f06bd3 Binary files /dev/null and b/frontend/public/background/busy-room.jpg differ diff --git a/frontend/public/background/dune-view.jpg b/frontend/public/background/dune-view.jpg new file mode 100644 index 00000000..39c4a2e4 Binary files /dev/null and b/frontend/public/background/dune-view.jpg differ diff --git a/frontend/public/background/hogwarts.jpg b/frontend/public/background/hogwarts.jpg new file mode 100644 index 00000000..c7d71d64 Binary files /dev/null and b/frontend/public/background/hogwarts.jpg differ diff --git a/frontend/public/background/library.jpg b/frontend/public/background/library.jpg new file mode 100644 index 00000000..1b033ad7 Binary files /dev/null and b/frontend/public/background/library.jpg differ diff --git a/frontend/public/background/new-york.jpg b/frontend/public/background/new-york.jpg new file mode 100644 index 00000000..3cb2ec7f Binary files /dev/null and b/frontend/public/background/new-york.jpg differ diff --git a/frontend/public/background/plane.jpg b/frontend/public/background/plane.jpg new file mode 100644 index 00000000..ad375347 Binary files /dev/null and b/frontend/public/background/plane.jpg differ diff --git a/frontend/public/background/white-room.jpg b/frontend/public/background/white-room.jpg new file mode 100644 index 00000000..4e507c2d Binary files /dev/null and b/frontend/public/background/white-room.jpg differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3d48b626..d25df311 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { PublisherProvider } from './Context/PublisherProvider'; import RedirectToWaitingRoom from './components/RedirectToWaitingRoom'; import UnsupportedBrowserPage from './pages/UnsupportedBrowserPage'; import RoomContext from './Context/RoomContext'; +import { BackgroundPublisherProvider } from './Context/BackgroundPublisherProvider'; const App = () => { return ( @@ -20,9 +21,11 @@ const App = () => { - - + + + + + } /> { - + + + diff --git a/frontend/src/Context/BackgroundPublisherProvider/index.tsx b/frontend/src/Context/BackgroundPublisherProvider/index.tsx new file mode 100644 index 00000000..c38dfa51 --- /dev/null +++ b/frontend/src/Context/BackgroundPublisherProvider/index.tsx @@ -0,0 +1,31 @@ +import { ReactElement, ReactNode, createContext, useMemo } from 'react'; +import useBackgroundPublisher from './useBackgroundPublisher'; + +export type BackgroundPublisherContextType = ReturnType; +export const BackgroundPublisherContext = createContext({} as BackgroundPublisherContextType); + +export type BackgroundPublisherProviderProps = { + children: ReactNode; +}; + +/** + * BackgroundPublisherProvider - React Context Provider for BackgroundPublisherContext + * BackgroundPublisherContextType contains all state and methods for local video publisher + * See useBackgroundPublisher.tsx for methods and state + * @param {BackgroundPublisherProviderProps} props - Background publisher provider properties + * @property {ReactNode} children - The content to be rendered + * @returns {BackgroundPublisherContext} a context provider for a publisher Background + */ +export const BackgroundPublisherProvider = ({ + children, +}: { + children: ReactNode; +}): ReactElement => { + const backgroundPublisherContext = useBackgroundPublisher(); + const value = useMemo(() => backgroundPublisherContext, [backgroundPublisherContext]); + return ( + + {children} + + ); +}; diff --git a/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/index.tsx b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/index.tsx new file mode 100644 index 00000000..30ce0a82 --- /dev/null +++ b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/index.tsx @@ -0,0 +1,3 @@ +import useBackgroundPublisher from './useBackgroundPublisher'; + +export default useBackgroundPublisher; diff --git a/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx new file mode 100644 index 00000000..66a4b232 --- /dev/null +++ b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx @@ -0,0 +1,258 @@ +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterAll, afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { hasMediaProcessorSupport, initPublisher, Publisher } from '@vonage/client-sdk-video'; +import EventEmitter from 'events'; +import useBackgroundPublisher from './useBackgroundPublisher'; +import { UserContextType } from '../../user'; +import useUserContext from '../../../hooks/useUserContext'; +import usePermissions, { PermissionsHookType } from '../../../hooks/usePermissions'; +import useDevices from '../../../hooks/useDevices'; +import { AllMediaDevices } from '../../../types'; +import { + allMediaDevices, + defaultAudioDevice, + defaultVideoDevice, +} from '../../../utils/mockData/device'; +import { DEVICE_ACCESS_STATUS } from '../../../utils/constants'; + +vi.mock('@vonage/client-sdk-video'); +vi.mock('../../../hooks/useUserContext.tsx'); +vi.mock('../../../hooks/usePermissions.tsx'); +vi.mock('../../../hooks/useDevices.tsx'); +const mockUseUserContext = useUserContext as Mock<[], UserContextType>; +const mockUsePermissions = usePermissions as Mock<[], PermissionsHookType>; +const mockUseDevices = useDevices as Mock< + [], + { allMediaDevices: AllMediaDevices; getAllMediaDevices: () => void } +>; + +const defaultSettings = { + publishAudio: false, + publishVideo: false, + name: '', + noiseSuppression: true, + publishCaptions: false, +}; +const mockUserContextWithDefaultSettings = { + user: { + defaultSettings, + issues: { reconnections: 0, audioFallbacks: 0 }, + }, + setUser: vi.fn(), +} as UserContextType; + +describe('useBackgroundPublisher', () => { + const mockPublisher = Object.assign(new EventEmitter(), { + getAudioSource: () => defaultAudioDevice, + getVideoSource: () => defaultVideoDevice, + applyVideoFilter: vi.fn(), + clearVideoFilter: vi.fn(), + }) as unknown as Publisher; + const mockedInitPublisher = vi.fn(); + const mockedHasMediaProcessorSupport = vi.fn(); + const consoleErrorSpy = vi.spyOn(console, 'error'); + const mockSetAccessStatus = vi.fn(); + + beforeEach(() => { + vi.resetAllMocks(); + mockUseUserContext.mockImplementation(() => mockUserContextWithDefaultSettings); + (initPublisher as Mock).mockImplementation(mockedInitPublisher); + (hasMediaProcessorSupport as Mock).mockImplementation(mockedHasMediaProcessorSupport); + mockUseDevices.mockReturnValue({ + getAllMediaDevices: vi.fn(), + allMediaDevices, + }); + mockUsePermissions.mockReturnValue({ + accessStatus: DEVICE_ACCESS_STATUS.PENDING, + setAccessStatus: mockSetAccessStatus, + }); + }); + + afterEach(() => { + cleanup(); + }); + + describe('initBackgroundLocalPublisher', () => { + it('should call initBackgroundLocalPublisher', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = renderHook(() => useBackgroundPublisher()); + + await result.current.initBackgroundLocalPublisher(); + + expect(mockedInitPublisher).toHaveBeenCalled(); + }); + + it('should log access denied errors', async () => { + const error = new Error( + "It hit me pretty hard, how there's no kind of sad in this world that will stop it turning." + ); + error.name = 'OT_USER_MEDIA_ACCESS_DENIED'; + (initPublisher as Mock).mockImplementation((_, _args, callback) => { + callback(error); + }); + + const { result } = renderHook(() => useBackgroundPublisher()); + await result.current.initBackgroundLocalPublisher(); + expect(consoleErrorSpy).toHaveBeenCalledWith('initPublisher error: ', error); + }); + + it('should apply background high blur when initialized and changed background', async () => { + mockedHasMediaProcessorSupport.mockReturnValue(true); + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = renderHook(() => useBackgroundPublisher()); + await result.current.initBackgroundLocalPublisher(); + + await act(async () => { + await result.current.changeBackground('high-blur'); + }); + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundBlur', + blurStrength: 'high', + }); + }); + + it('should not replace background when initialized if the device does not support it', async () => { + mockedHasMediaProcessorSupport.mockReturnValue(false); + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = renderHook(() => useBackgroundPublisher()); + await result.current.initBackgroundLocalPublisher(); + expect(mockedInitPublisher).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + videoFilter: undefined, + }), + expect.any(Function) + ); + }); + }); + + describe('changeBackground', () => { + let result: ReturnType['result']; + beforeEach(async () => { + mockedHasMediaProcessorSupport.mockReturnValue(true); + mockedInitPublisher.mockReturnValue(mockPublisher); + result = renderHook(() => useBackgroundPublisher()).result; + await act(async () => { + await ( + result.current as ReturnType + ).initBackgroundLocalPublisher(); + }); + (mockPublisher.applyVideoFilter as Mock).mockClear(); + (mockPublisher.clearVideoFilter as Mock).mockClear(); + }); + + it('applies low blur filter', async () => { + await act(async () => { + await (result.current as ReturnType).changeBackground( + 'low-blur' + ); + }); + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundBlur', + blurStrength: 'low', + }); + }); + + it('applies background replacement with image', async () => { + await act(async () => { + await (result.current as ReturnType).changeBackground( + 'bg1.jpg' + ); + }); + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundReplacement', + backgroundImgUrl: expect.stringContaining('bg1.jpg'), + }); + }); + + it('clears video filter for unknown option', async () => { + await act(async () => { + await (result.current as ReturnType).changeBackground( + 'none' + ); + }); + }); + + it('logs an error if applyBackgroundFilter rejects', async () => { + mockPublisher.applyVideoFilter = vi.fn(() => { + throw new Error('Simulated internal failure'); + }); + + const { result: res } = renderHook(() => useBackgroundPublisher()); + await act(async () => { + await res.current.initBackgroundLocalPublisher(); + await res.current.changeBackground('low-blur'); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to apply background filter.'); + }); + }); + + describe('on accessDenied', () => { + const nativePermissions = global.navigator.permissions; + const mockQuery = vi.fn(); + let mockedPermissionStatus: { onchange: null | (() => void); status: string }; + const emitAccessDeniedError = () => { + // @ts-expect-error We simulate user denying microphone permissions in a browser. + mockPublisher.emit('accessDenied', { + message: 'Microphone permission denied during the call', + }); + }; + + beforeEach(() => { + mockedPermissionStatus = { + onchange: null, + status: 'prompt', + }; + mockQuery.mockResolvedValue(mockedPermissionStatus); + + Object.defineProperty(global.navigator, 'permissions', { + writable: true, + value: { + query: mockQuery, + }, + }); + }); + + afterAll(() => { + Object.defineProperty(global.navigator, 'permissions', { + writable: true, + value: nativePermissions, + }); + }); + + it('handles permission denial', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + + const { result } = renderHook(() => useBackgroundPublisher()); + + act(() => { + result.current.initBackgroundLocalPublisher(); + }); + expect(result.current.accessStatus).toBe(DEVICE_ACCESS_STATUS.PENDING); + + act(emitAccessDeniedError); + + expect(mockSetAccessStatus).toBeCalledWith(DEVICE_ACCESS_STATUS.REJECTED); + }); + + it('does not throw on older, unsupported browsers', async () => { + mockQuery.mockImplementation(() => { + throw new Error('Whoops'); + }); + mockedInitPublisher.mockReturnValue(mockPublisher); + + const { result } = renderHook(() => useBackgroundPublisher()); + + act(() => { + result.current.initBackgroundLocalPublisher(); + + expect(emitAccessDeniedError).not.toThrow(); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to query device permission for microphone: Error: Whoops' + ); + }); + }); +}); diff --git a/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.tsx b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.tsx new file mode 100644 index 00000000..fb87a544 --- /dev/null +++ b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.tsx @@ -0,0 +1,238 @@ +import { useState, useRef, useCallback, useEffect } from 'react'; +import { + Publisher, + Event, + initPublisher, + VideoFilter, + hasMediaProcessorSupport, + PublisherProperties, +} from '@vonage/client-sdk-video'; +import setMediaDevices from '../../../utils/mediaDeviceUtils'; +import useDevices from '../../../hooks/useDevices'; +import usePermissions from '../../../hooks/usePermissions'; +import useUserContext from '../../../hooks/useUserContext'; +import { DEVICE_ACCESS_STATUS } from '../../../utils/constants'; +import { AccessDeniedEvent } from '../../PublisherProvider/usePublisher/usePublisher'; +import DeviceStore from '../../../utils/DeviceStore'; +import applyBackgroundFilter from '../../../utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter'; + +export type BackgroundPublisherContextType = { + isPublishing: boolean; + isVideoEnabled: boolean; + publisher: Publisher | null; + publisherVideoElement: HTMLVideoElement | HTMLObjectElement | undefined; + destroyBackgroundPublisher: () => void; + toggleVideo: () => void; + changeBackground: (backgroundSelected: string) => void; + backgroundFilter: VideoFilter | undefined; + localVideoSource: string | undefined; + accessStatus: string | null; + changeVideoSource: (deviceId: string) => void; + initBackgroundLocalPublisher: () => Promise; +}; + +type PublisherVideoElementCreatedEvent = Event<'videoElementCreated', Publisher> & { + element: HTMLVideoElement | HTMLObjectElement; +}; + +/** + * Hook wrapper for creation, interaction with, and state for local video publisher with background effects. + * Access from app via BackgroundPublisherProvider, not directly. + * @property {boolean} isPublishing - React state boolean showing if we are publishing + * @property {boolean} isVideoEnabled - React state boolean showing if camera is on + * @property {Publisher | null} publisher - Publisher object + * @property {HTMLVideoElement | HTMLObjectElement} publisherVideoElement - video element for publisher + * @property {Function} destroyBackgroundPublisher - Method to destroy publisher + * @property {Function} toggleVideo - Method to toggle camera on/off. State updated internally, can be read via isVideoEnabled. + * @property {Function} changeBackground - Method to change background effect + * @property {VideoFilter | undefined} backgroundFilter - Current background filter applied to publisher + * @property {string | undefined} localVideoSource - Current video source device ID + * @property {string | null} accessStatus - Current device access status + * @property {Function} changeVideoSource - Method to change video source device ID + * @property {Function} initBackgroundLocalPublisher - Method to initialize the background publisher + * @returns {BackgroundPublisherContextType} Background context + */ +const useBackgroundPublisher = (): BackgroundPublisherContextType => { + const { user } = useUserContext(); + const { allMediaDevices, getAllMediaDevices } = useDevices(); + const [publisherVideoElement, setPublisherVideoElement] = useState< + HTMLVideoElement | HTMLObjectElement + >(); + const { setAccessStatus, accessStatus } = usePermissions(); + const backgroundPublisherRef = useRef(null); + const [isPublishing, setIsPublishing] = useState(false); + const initialBackgroundRef = useRef( + user.defaultSettings.backgroundFilter + ); + const [backgroundFilter, setBackgroundFilter] = useState( + user.defaultSettings.backgroundFilter + ); + const [isVideoEnabled, setIsVideoEnabled] = useState(true); + const [localVideoSource, setLocalVideoSource] = useState(undefined); + const deviceStoreRef = useRef(new DeviceStore()); + + /* This sets the default devices in use so that the user knows what devices they are using */ + useEffect(() => { + setMediaDevices(backgroundPublisherRef, allMediaDevices, () => {}, setLocalVideoSource); + }, [allMediaDevices]); + + const handleBackgroundDestroyed = () => { + backgroundPublisherRef.current = null; + }; + + /** + * Change background replacement or blur effect + * @param {string} backgroundSelected - The selected background option + * @returns {void} + */ + const changeBackground = useCallback( + (backgroundSelected: string) => { + applyBackgroundFilter({ + publisher: backgroundPublisherRef.current, + backgroundSelected, + setUser: undefined, + setBackgroundFilter, + storeItem: false, + }).catch(() => { + console.error('Failed to apply background filter.'); + }); + }, + [setBackgroundFilter] + ); + + /** + * Change video camera in use + * @returns {void} + */ + const changeVideoSource = useCallback((deviceId: string) => { + if (!deviceId || !backgroundPublisherRef.current) { + return; + } + backgroundPublisherRef.current.setVideoSource(deviceId); + setLocalVideoSource(deviceId); + }, []); + + /** + * Handle device permissions denial + * used to inform the user they need to give permissions to devices to access the call + * after a user grants permissions to the denied device, trigger a reload. + * @returns {void} + */ + const handleBackgroundAccessDenied = useCallback( + async (event: AccessDeniedEvent) => { + const deviceDeniedAccess = event.message?.startsWith('Microphone') ? 'microphone' : 'camera'; + + setAccessStatus(DEVICE_ACCESS_STATUS.REJECTED); + + try { + const permissionStatus = await window.navigator.permissions.query({ + name: deviceDeniedAccess, + }); + permissionStatus.onchange = () => { + if (permissionStatus.state === 'granted') { + setAccessStatus(DEVICE_ACCESS_STATUS.ACCESS_CHANGED); + } + }; + } catch (error) { + console.error(`Failed to query device permission for ${deviceDeniedAccess}: ${error}`); + } + }, + [setAccessStatus] + ); + + const handleVideoElementCreated = (event: PublisherVideoElementCreatedEvent) => { + setPublisherVideoElement(event.element); + setIsPublishing(true); + }; + + const addPublisherListeners = useCallback( + (publisher: Publisher | null) => { + if (!publisher) { + return; + } + publisher.on('destroyed', handleBackgroundDestroyed); + publisher.on('accessDenied', handleBackgroundAccessDenied); + publisher.on('videoElementCreated', handleVideoElementCreated); + publisher.on('accessAllowed', () => { + setAccessStatus(DEVICE_ACCESS_STATUS.ACCEPTED); + getAllMediaDevices(); + }); + }, + [getAllMediaDevices, handleBackgroundAccessDenied, setAccessStatus] + ); + + const initBackgroundLocalPublisher = useCallback(async () => { + if (backgroundPublisherRef.current) { + return; + } + + // Set videoFilter based on user's selected background + let videoFilter: VideoFilter | undefined; + if (initialBackgroundRef.current && hasMediaProcessorSupport()) { + videoFilter = initialBackgroundRef.current; + } + + await deviceStoreRef.current.init(); + const videoSource = deviceStoreRef.current.getConnectedDeviceId('videoinput'); + + const publisherOptions: PublisherProperties = { + insertDefaultUI: false, + videoFilter, + resolution: '1280x720', + videoSource, + }; + + backgroundPublisherRef.current = initPublisher(undefined, publisherOptions, (err: unknown) => { + if (err instanceof Error) { + backgroundPublisherRef.current = null; + if (err.name === 'OT_USER_MEDIA_ACCESS_DENIED') { + console.error('initPublisher error: ', err); + } + } + }); + addPublisherListeners(backgroundPublisherRef.current); + }, [addPublisherListeners]); + + /** + * Destroys the background publisher + * @returns {void} + */ + const destroyBackgroundPublisher = useCallback(() => { + if (backgroundPublisherRef.current) { + backgroundPublisherRef.current.destroy(); + backgroundPublisherRef.current = null; + } else { + console.warn('pub not destroyed'); + } + }, []); + + /** + * Turns the camera on and off + * A wrapper for Publisher.publishVideo() + * More details here: https://vonage.github.io/conversation-docs/video-js-reference/latest/Publisher.html#publishVideo + * @returns {void} + */ + const toggleVideo = () => { + if (!backgroundPublisherRef.current) { + return; + } + backgroundPublisherRef.current.publishVideo(!isVideoEnabled); + setIsVideoEnabled(!isVideoEnabled); + }; + + return { + initBackgroundLocalPublisher, + isPublishing, + isVideoEnabled, + destroyBackgroundPublisher, + publisher: backgroundPublisherRef.current, + publisherVideoElement, + toggleVideo, + changeBackground, + backgroundFilter, + changeVideoSource, + localVideoSource, + accessStatus, + }; +}; +export default useBackgroundPublisher; diff --git a/frontend/src/Context/PreviewPublisherProvider/index.tsx b/frontend/src/Context/PreviewPublisherProvider/index.tsx index d0a26258..949b5f4f 100644 --- a/frontend/src/Context/PreviewPublisherProvider/index.tsx +++ b/frontend/src/Context/PreviewPublisherProvider/index.tsx @@ -13,7 +13,7 @@ export type PreviewPublisherProviderProps = { * PublisherContext contains all state and methods for local video publisher * We use Context to make the publisher available in many components across the app without * prop drilling: https://react.dev/learn/passing-data-deeply-with-context#use-cases-for-context - * See usePublisher.tsx for methods and state + * See usePreviewPublisher.tsx for methods and state * @param {PreviewPublisherProviderProps} props - The provider properties * @property {ReactNode} children - The content to be rendered * @returns {PreviewPublisherContext} a context provider for a publisher preview diff --git a/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx index 8c40a1d9..e906201c 100644 --- a/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx +++ b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx @@ -30,19 +30,23 @@ const defaultSettings = { publishAudio: false, publishVideo: false, name: '', - blur: true, noiseSuppression: true, + publishCaptions: false, }; const mockUserContextWithDefaultSettings = { user: { defaultSettings, + issues: { reconnections: 0, audioFallbacks: 0 }, }, + setUser: vi.fn(), } as UserContextType; describe('usePreviewPublisher', () => { const mockPublisher = Object.assign(new EventEmitter(), { getAudioSource: () => defaultAudioDevice, getVideoSource: () => defaultVideoDevice, + applyVideoFilter: vi.fn(), + clearVideoFilter: vi.fn(), }) as unknown as Publisher; const mockedInitPublisher = vi.fn(); const mockedHasMediaProcessorSupport = vi.fn(); @@ -69,7 +73,7 @@ describe('usePreviewPublisher', () => { }); describe('initLocalPublisher', () => { - it('should call initPublisher', async () => { + it('should call initLocalPublisher', async () => { mockedInitPublisher.mockReturnValue(mockPublisher); const { result } = renderHook(() => usePreviewPublisher()); @@ -92,24 +96,22 @@ describe('usePreviewPublisher', () => { expect(consoleErrorSpy).toHaveBeenCalledWith('initPublisher error: ', error); }); - it('should apply background blur when initialized if set to true', async () => { + it('should apply background high blur when initialized and changed background', async () => { mockedHasMediaProcessorSupport.mockReturnValue(true); mockedInitPublisher.mockReturnValue(mockPublisher); const { result } = renderHook(() => usePreviewPublisher()); await result.current.initLocalPublisher(); - expect(mockedInitPublisher).toHaveBeenCalledWith( - undefined, - expect.objectContaining({ - videoFilter: expect.objectContaining({ - type: 'backgroundBlur', - blurStrength: 'high', - }), - }), - expect.any(Function) - ); + + await act(async () => { + await result.current.changeBackground('high-blur'); + }); + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundBlur', + blurStrength: 'high', + }); }); - it('should not apply background blur when initialized if the device does not support it', async () => { + it('should not replace background when initialized if the device does not support it', async () => { mockedHasMediaProcessorSupport.mockReturnValue(false); mockedInitPublisher.mockReturnValue(mockPublisher); const { result } = renderHook(() => usePreviewPublisher()); @@ -124,6 +126,64 @@ describe('usePreviewPublisher', () => { }); }); + describe('changeBackground', () => { + let result: ReturnType['result']; + beforeEach(async () => { + mockedHasMediaProcessorSupport.mockReturnValue(true); + mockedInitPublisher.mockReturnValue(mockPublisher); + result = renderHook(() => usePreviewPublisher()).result; + await act(async () => { + await (result.current as ReturnType).initLocalPublisher(); + }); + (mockPublisher.applyVideoFilter as Mock).mockClear(); + (mockPublisher.clearVideoFilter as Mock).mockClear(); + }); + + it('applies low blur filter', async () => { + await act(async () => { + await (result.current as ReturnType).changeBackground( + 'low-blur' + ); + }); + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundBlur', + blurStrength: 'low', + }); + }); + + it('applies background replacement with image', async () => { + await act(async () => { + await (result.current as ReturnType).changeBackground( + 'bg1.jpg' + ); + }); + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundReplacement', + backgroundImgUrl: expect.stringContaining('bg1.jpg'), + }); + }); + + it('clears video filter for unknown option', async () => { + await act(async () => { + await (result.current as ReturnType).changeBackground('none'); + }); + }); + + it('logs an error if applyBackgroundFilter rejects', async () => { + mockPublisher.applyVideoFilter = vi.fn(() => { + throw new Error('Simulated internal failure'); + }); + + const { result: res } = renderHook(() => usePreviewPublisher()); + await act(async () => { + await res.current.initLocalPublisher(); + await res.current.changeBackground('low-blur'); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to apply background filter.'); + }); + }); + describe('on accessDenied', () => { const nativePermissions = global.navigator.permissions; const mockQuery = vi.fn(); diff --git a/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx index f6b9462c..c00b9ebb 100644 --- a/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx +++ b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx @@ -16,6 +16,7 @@ import { UserType } from '../../user'; import { AccessDeniedEvent } from '../../PublisherProvider/usePublisher/usePublisher'; import DeviceStore from '../../../utils/DeviceStore'; import { setStorageItem, STORAGE_KEYS } from '../../../utils/storage'; +import applyBackgroundFilter from '../../../utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter'; type PublisherVideoElementCreatedEvent = Event<'videoElementCreated', Publisher> & { element: HTMLVideoElement | HTMLObjectElement; @@ -30,29 +31,37 @@ export type PreviewPublisherContextType = { destroyPublisher: () => void; toggleAudio: () => void; toggleVideo: () => void; - toggleBlur: () => void; + changeBackground: (backgroundSelected: string) => void; + backgroundFilter: VideoFilter | undefined; localAudioSource: string | undefined; localVideoSource: string | undefined; accessStatus: string | null; changeAudioSource: (deviceId: string) => void; changeVideoSource: (deviceId: string) => void; - hasBlur: boolean; initLocalPublisher: () => Promise; speechLevel: number; }; /** - * Hook wrapper for creation, interaction with, and state for local video publisher. - * Access from app via PublisherProvider, not directly. + * Hook wrapper for creation, interaction with, and state for local video preview publisher. + * Access from app via PreviewPublisherProvider, not directly. * @property {boolean} isAudioEnabled - React state boolean showing if audio is enabled * @property {boolean} isPublishing - React state boolean showing if we are publishing * @property {boolean} isVideoEnabled - React state boolean showing if camera is on - * @property {() => Promise} publish - Method to initialize publisher and publish to session * @property {Publisher | null} publisher - Publisher object * @property {HTMLVideoElement | HTMLObjectElement} publisherVideoElement - video element for publisher + * @property {Function} destroyPublisher - Method to destroy publisher * @property {() => void} toggleAudio - Method to toggle microphone on/off. State updated internally, can be read via isAudioEnabled. * @property {() => void} toggleVideo - Method to toggle camera on/off. State updated internally, can be read via isVideoEnabled. - * @property {() => void} unpublish - Method to unpublish from session and destroy publisher (for ending a call). + * @property {Function} changeBackground - Method to change background effect + * @property {VideoFilter | undefined} backgroundFilter - Current background filter applied to publisher + * @property {string | undefined} localVideoSource - Current video source device ID + * @property {string | undefined} localAudioSource - Current audio source device ID + * @property {string | null} accessStatus - Current device access status + * @property {Function} changeAudioSource - Method to change audio source device ID + * @property {Function} changeVideoSource - Method to change video source device ID + * @property {Function} initLocalPublisher - Method to initialize the preview publisher + * @property {number} speechLevel - Current speech level for audio visualization * @returns {PreviewPublisherContextType} preview context */ const usePreviewPublisher = (): PreviewPublisherContextType => { @@ -65,8 +74,12 @@ const usePreviewPublisher = (): PreviewPublisherContextType => { const { setAccessStatus, accessStatus } = usePermissions(); const publisherRef = useRef(null); const [isPublishing, setIsPublishing] = useState(false); - const initialLocalBlurRef = useRef(user.defaultSettings.blur); - const [localBlur, setLocalBlur] = useState(user.defaultSettings.blur); + const initialBackgroundRef = useRef( + user.defaultSettings.backgroundFilter + ); + const [backgroundFilter, setBackgroundFilter] = useState( + user.defaultSettings.backgroundFilter + ); const [isVideoEnabled, setIsVideoEnabled] = useState(true); const [isAudioEnabled, setIsAudioEnabled] = useState(true); const [localVideoSource, setLocalVideoSource] = useState(undefined); @@ -78,38 +91,28 @@ const usePreviewPublisher = (): PreviewPublisherContextType => { setMediaDevices(publisherRef, allMediaDevices, setLocalAudioSource, setLocalVideoSource); }, [allMediaDevices]); - const handleDestroyed = () => { + const handlePreviewDestroyed = () => { publisherRef.current = null; }; /** - * Change background blur status + * Change background replacement or blur effect + * @param {string} backgroundSelected - The selected background option * @returns {void} */ - const toggleBlur = useCallback(() => { - if (!publisherRef.current) { - return; - } - if (localBlur) { - publisherRef.current.clearVideoFilter(); - } else { - publisherRef.current.applyVideoFilter({ - type: 'backgroundBlur', - blurStrength: 'high', + const changeBackground = useCallback( + (backgroundSelected: string) => { + applyBackgroundFilter({ + publisher: publisherRef.current, + backgroundSelected, + setUser, + setBackgroundFilter, + }).catch(() => { + console.error('Failed to apply background filter.'); }); - } - setLocalBlur(!localBlur); - setStorageItem(STORAGE_KEYS.BACKGROUND_BLUR, JSON.stringify(!localBlur)); - if (setUser) { - setUser((prevUser: UserType) => ({ - ...prevUser, - defaultSettings: { - ...prevUser.defaultSettings, - blur: !localBlur, - }, - })); - } - }, [localBlur, setUser]); + }, + [setBackgroundFilter, setUser] + ); /** * Change microphone @@ -205,7 +208,7 @@ const usePreviewPublisher = (): PreviewPublisherContextType => { if (!publisher) { return; } - publisher.on('destroyed', handleDestroyed); + publisher.on('destroyed', handlePreviewDestroyed); publisher.on('accessDenied', handleAccessDenied); publisher.on('videoElementCreated', handleVideoElementCreated); publisher.on('audioLevelUpdated', ({ audioLevel }: { audioLevel: number }) => { @@ -224,13 +227,11 @@ const usePreviewPublisher = (): PreviewPublisherContextType => { return; } - const videoFilter: VideoFilter | undefined = - initialLocalBlurRef.current && hasMediaProcessorSupport() - ? { - type: 'backgroundBlur', - blurStrength: 'high', - } - : undefined; + // Set videoFilter based on user's selected background + let videoFilter: VideoFilter | undefined; + if (initialBackgroundRef.current && hasMediaProcessorSupport()) { + videoFilter = initialBackgroundRef.current; + } await deviceStoreRef.current.init(); const videoSource = deviceStoreRef.current.getConnectedDeviceId('videoinput'); @@ -255,6 +256,10 @@ const usePreviewPublisher = (): PreviewPublisherContextType => { addPublisherListeners(publisherRef.current); }, [addPublisherListeners]); + /** + * Destroys the preview publisher + * @returns {void} + */ const destroyPublisher = useCallback(() => { if (publisherRef.current) { publisherRef.current.destroy(); @@ -320,8 +325,8 @@ const usePreviewPublisher = (): PreviewPublisherContextType => { publisherVideoElement, toggleAudio, toggleVideo, - toggleBlur, - hasBlur: localBlur, + changeBackground, + backgroundFilter, changeAudioSource, changeVideoSource, localAudioSource, diff --git a/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx b/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx index a410c5e9..0bed30ab 100644 --- a/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx +++ b/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx @@ -1,6 +1,11 @@ import { beforeEach, describe, it, expect, vi, Mock, afterAll } from 'vitest'; import { act, renderHook, waitFor } from '@testing-library/react'; -import { initPublisher, Publisher, Stream } from '@vonage/client-sdk-video'; +import { + initPublisher, + Publisher, + Stream, + hasMediaProcessorSupport, +} from '@vonage/client-sdk-video'; import EventEmitter from 'events'; import usePublisher from './usePublisher'; import useUserContext from '../../../hooks/useUserContext'; @@ -20,13 +25,15 @@ const defaultSettings = { publishAudio: false, publishVideo: false, name: '', - blur: false, noiseSuppression: true, + publishCaptions: false, }; const mockUserContextWithDefaultSettings = { user: { defaultSettings, + issues: { reconnections: 0, audioFallbacks: 0 }, }, + setUser: vi.fn(), } as UserContextType; const mockStream = { streamId: 'stream-id', @@ -37,6 +44,8 @@ describe('usePublisher', () => { const destroySpy = vi.fn(); const mockPublisher = Object.assign(new EventEmitter(), { destroy: destroySpy, + applyVideoFilter: vi.fn(), + clearVideoFilter: vi.fn(), }) as unknown as Publisher; let mockSessionContext: SessionContextType; const mockedInitPublisher = vi.fn(); @@ -50,6 +59,7 @@ describe('usePublisher', () => { mockUseUserContext.mockImplementation(() => mockUserContextWithDefaultSettings); (initPublisher as Mock).mockImplementation(mockedInitPublisher); + (hasMediaProcessorSupport as Mock).mockImplementation(vi.fn().mockReturnValue(true)); mockSessionContext = { publish: mockedSessionPublish, @@ -114,6 +124,46 @@ describe('usePublisher', () => { }); }); + describe('changeBackground', () => { + let result: ReturnType['result']; + beforeEach(async () => { + (initPublisher as Mock).mockImplementation(() => mockPublisher); + result = renderHook(() => usePublisher()).result; + await act(async () => { + await (result.current as ReturnType).initializeLocalPublisher({}); + }); + (mockPublisher.applyVideoFilter as Mock).mockClear(); + (mockPublisher.clearVideoFilter as Mock).mockClear(); + }); + + it('applies low blur filter', async () => { + await act(async () => { + await (result.current as ReturnType).changeBackground('low-blur'); + }); + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundBlur', + blurStrength: 'low', + }); + }); + + it('applies background replacement with image', async () => { + await act(async () => { + await (result.current as ReturnType).changeBackground('bg1.jpg'); + }); + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundReplacement', + backgroundImgUrl: expect.stringContaining('bg1.jpg'), + }); + }); + + it('clears video filter for unknown option', async () => { + await act(async () => { + await (result.current as ReturnType).changeBackground('none'); + }); + expect(mockPublisher.clearVideoFilter).toHaveBeenCalled(); + }); + }); + describe('publish', () => { it('should publish to the session', async () => { (initPublisher as Mock).mockImplementation(() => mockPublisher); diff --git a/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.tsx b/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.tsx index d1234d9a..235bf7dd 100644 --- a/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.tsx +++ b/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.tsx @@ -14,6 +14,8 @@ import { PUBLISHING_BLOCKED_CAPTION } from '../../../utils/constants'; import getAccessDeniedError, { PublishingErrorType, } from '../../../utils/getAccessDeniedError/getAccessDeniedError'; +import useUserContext from '../../../hooks/useUserContext'; +import applyBackgroundFilter from '../../../utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter'; type PublisherStreamCreatedEvent = Event<'streamCreated', Publisher> & { stream: Stream; @@ -46,6 +48,7 @@ export type PublisherContextType = { stream: Stream | null | undefined; toggleAudio: () => void; toggleVideo: () => void; + changeBackground: (backgroundSelected: string) => void; unpublish: () => void; }; @@ -65,10 +68,12 @@ export type PublisherContextType = { * @property {Stream | null | undefined} stream - OT Stream object for publisher * @property {() => void} toggleAudio - Method to toggle microphone on/off. State updated internally, can be read via isAudioEnabled. * @property {() => void} toggleVideo - Method to toggle camera on/off. State updated internally, can be read via isVideoEnabled. + * @property {(backgroundSelected: string) => void} changeBackground - Method to change background replacement or blur effect. * @property {() => void} unpublish - Method to unpublish from session and destroy publisher (for ending a call). * @returns {PublisherContextType} the publisher context */ const usePublisher = (): PublisherContextType => { + const { setUser } = useUserContext(); const [publisherVideoElement, setPublisherVideoElement] = useState< HTMLVideoElement | HTMLObjectElement >(); @@ -118,6 +123,24 @@ const usePublisher = (): PublisherContextType => { publisherRef.current = null; }; + /** + * Change background replacement or blur effect + * @param {string} backgroundSelected - The selected background option + * @returns {void} + */ + const changeBackground = useCallback( + (backgroundSelected: string) => { + applyBackgroundFilter({ + publisher: publisherRef.current, + backgroundSelected, + setUser, + }).catch(() => { + console.error('Failed to apply background filter.'); + }); + }, + [setUser] + ); + const handleStreamCreated = (e: PublisherStreamCreatedEvent) => { setIsPublishing(true); setStream(e.stream); @@ -311,6 +334,7 @@ const usePublisher = (): PublisherContextType => { stream, toggleAudio, toggleVideo, + changeBackground, unpublish, }; }; diff --git a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx index 3c2162a8..32c402f0 100644 --- a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx +++ b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx @@ -16,7 +16,6 @@ const defaultSettings = { publishAudio: false, publishVideo: false, name: '', - blur: false, noiseSuppression: true, audioSource: undefined, videoSource: undefined, @@ -27,7 +26,10 @@ const customSettings = { publishAudio: true, publishVideo: true, name: 'Foo Bar', - blur: true, + backgroundFilter: { + type: 'backgroundBlur', + blurStrength: 'high', + }, noiseSuppression: false, audioSource: '68f1d1e6f11c629b1febe51a95f8f740f8ac5cd3d4c91419bd2b52bb1a9a01cd', videoSource: 'a68ec4e4a6bc10dc572bd806414b0da27d0aefb0ad822f7ba4cf9b226bb9b7c2', diff --git a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx index 3760d129..eb4b2d1a 100644 --- a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx +++ b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx @@ -29,8 +29,14 @@ const usePublisherOptions = (): PublisherProperties | null => { const videoSource = deviceStoreRef.current.getConnectedDeviceId('videoinput'); const audioSource = deviceStoreRef.current.getConnectedDeviceId('audioinput'); - const { name, noiseSuppression, blur, publishAudio, publishVideo, publishCaptions } = - user.defaultSettings; + const { + name, + noiseSuppression, + backgroundFilter, + publishAudio, + publishVideo, + publishCaptions, + } = user.defaultSettings; const initials = getInitials(name); const audioFilter: AudioFilter | undefined = @@ -39,9 +45,7 @@ const usePublisherOptions = (): PublisherProperties | null => { : undefined; const videoFilter: VideoFilter | undefined = - blur && hasMediaProcessorSupport() - ? { type: 'backgroundBlur', blurStrength: 'high' } - : undefined; + backgroundFilter && hasMediaProcessorSupport() ? backgroundFilter : undefined; setPublisherOptions({ audioFallback: { publisher: true }, diff --git a/frontend/src/Context/SessionProvider/session.tsx b/frontend/src/Context/SessionProvider/session.tsx index 606241a7..9d805ce4 100644 --- a/frontend/src/Context/SessionProvider/session.tsx +++ b/frontend/src/Context/SessionProvider/session.tsx @@ -55,6 +55,7 @@ export type SessionContextType = { archiveId: string | null; rightPanelActiveTab: RightPanelActiveTab; toggleParticipantList: () => void; + toggleBackgroundEffects: () => void; toggleChat: () => void; closeRightPanel: () => void; toggleReportIssue: () => void; @@ -88,6 +89,7 @@ export const SessionContext = createContext({ archiveId: null, rightPanelActiveTab: 'closed', toggleParticipantList: () => {}, + toggleBackgroundEffects: () => {}, toggleChat: () => {}, closeRightPanel: () => {}, toggleReportIssue: () => {}, @@ -149,6 +151,7 @@ const SessionProvider = ({ children }: SessionProviderProps): ReactElement => { const { closeRightPanel, toggleParticipantList, + toggleBackgroundEffects, toggleChat, rightPanelState: { unreadCount, activeTab: rightPanelActiveTab }, incrementUnreadCount, @@ -423,6 +426,7 @@ const SessionProvider = ({ children }: SessionProviderProps): ReactElement => { setLayoutMode, rightPanelActiveTab, toggleParticipantList, + toggleBackgroundEffects, toggleChat, closeRightPanel, toggleReportIssue, @@ -452,6 +456,7 @@ const SessionProvider = ({ children }: SessionProviderProps): ReactElement => { setLayoutMode, rightPanelActiveTab, toggleParticipantList, + toggleBackgroundEffects, toggleChat, closeRightPanel, toggleReportIssue, diff --git a/frontend/src/Context/tests/user.spec.tsx b/frontend/src/Context/tests/user.spec.tsx index 902a9edc..820a1d89 100644 --- a/frontend/src/Context/tests/user.spec.tsx +++ b/frontend/src/Context/tests/user.spec.tsx @@ -39,7 +39,7 @@ describe('UserContext', () => { publishAudio: true, publishVideo: true, name: '', - blur: false, + backgroundFilter: undefined, noiseSuppression: false, audioSource: undefined, videoSource: undefined, diff --git a/frontend/src/Context/user.tsx b/frontend/src/Context/user.tsx index 0eaa4c5f..4a8876fd 100644 --- a/frontend/src/Context/user.tsx +++ b/frontend/src/Context/user.tsx @@ -7,7 +7,9 @@ import { Dispatch, ReactElement, } from 'react'; +import { VideoFilter } from '@vonage/client-sdk-video'; import { getStorageItem, STORAGE_KEYS } from '../utils/storage'; +import { parseVideoFilter } from '../utils/util'; // Define the shape of the User context export type UserContextType = { @@ -21,9 +23,9 @@ export type UserType = { publishAudio: boolean; // Whether the user is publishing audio publishVideo: boolean; // Whether the user is publishing video name: string; // The user's name - blur: boolean; // Whether background blur is enabled noiseSuppression: boolean; // Whether noise suppression is enabled publishCaptions: boolean; // Whether captions are published + backgroundFilter?: VideoFilter; // The background replacement filter applied to the video audioSource?: string; // The selected audio input source (optional) videoSource?: string; // The selected video input source (optional) }; @@ -49,7 +51,7 @@ export type UserProviderProps = { const UserProvider = ({ children }: UserProviderProps): ReactElement => { // Load initial settings from local storage const noiseSuppression = getStorageItem(STORAGE_KEYS.NOISE_SUPPRESSION) === 'true'; - const blur = getStorageItem(STORAGE_KEYS.BACKGROUND_BLUR) === 'true'; + const backgroundFilter = parseVideoFilter(getStorageItem(STORAGE_KEYS.BACKGROUND_REPLACEMENT)); const name = getStorageItem(STORAGE_KEYS.USERNAME) ?? ''; const [user, setUser] = useState({ @@ -57,7 +59,7 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { publishAudio: true, publishVideo: true, name, - blur, + backgroundFilter, noiseSuppression, audioSource: undefined, videoSource: undefined, diff --git a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.spec.tsx b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.spec.tsx new file mode 100644 index 00000000..8dcfacac --- /dev/null +++ b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.spec.tsx @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import AddBackgroundEffect from './AddBackgroundEffect'; + +describe('AddBackgroundEffect', () => { + it('renders the add photo icon', () => { + render(); + const icon = screen.getByTestId('AddPhotoAlternateIcon'); + expect(icon).toBeInTheDocument(); + }); + + it('renders the tooltip with recommended text when enabled', async () => { + render(); + const option = screen.getByTestId('background-upload'); + expect(option).toBeInTheDocument(); + }); + + it('shows disabled tooltip when isDisabled is true', () => { + render(); + const option = screen.getByTestId('background-upload'); + expect(option).toHaveAttribute('aria-disabled', 'true'); + }); + + it('shows the tooltip when hovered', async () => { + render(); + const option = screen.getByTestId('background-upload'); + await userEvent.hover(option); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toBeInTheDocument(); + + expect(tooltip).toHaveTextContent(/recommended/i); + }); +}); diff --git a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.tsx b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.tsx new file mode 100644 index 00000000..d0cb6f85 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.tsx @@ -0,0 +1,48 @@ +import { ReactElement } from 'react'; +import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate'; +import { Tooltip } from '@mui/material'; +import SelectableOption from '../SelectableOption'; + +export type AddBackgroundEffectProps = { + isDisabled?: boolean; +}; + +/** + * Renders a button that allows user to upload background effects. + * + * This button is disabled if the user has reached the maximum limit of custom images. + * @param {AddBackgroundEffectProps} props - the props for the component. + * @property {boolean} isDisabled - Whether the button is disabled. + * @returns {ReactElement} A button for uploading background effects. + */ +const AddBackgroundEffect = ({ isDisabled = false }: AddBackgroundEffectProps): ReactElement => { + return ( + + Recommended: JPG/PNG img. at 1280x720 resolution. +
+ Note: Images are stored only locally in the browser. + + ) + } + arrow + > + {} + // TODO: Implement upload functionality + } + icon={} + /> +
+ ); +}; + +export default AddBackgroundEffect; diff --git a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/Index.tsx b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/Index.tsx new file mode 100644 index 00000000..ba0fb702 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/Index.tsx @@ -0,0 +1,3 @@ +import AddBackgroundEffect from './AddBackgroundEffect'; + +export default AddBackgroundEffect; diff --git a/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.spec.tsx b/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.spec.tsx new file mode 100644 index 00000000..799f10db --- /dev/null +++ b/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.spec.tsx @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import BackgroundGallery, { backgrounds } from './BackgroundGallery'; + +describe('BackgroundGallery', () => { + const backgroundsFiles = backgrounds.map((bg) => bg.file); + + it('renders all background images as selectable options', () => { + render( {}} />); + const imgs = screen.getAllByRole('img'); + backgroundsFiles.forEach((file) => { + expect(imgs.some((img) => (img as HTMLImageElement).src.includes(file))).toBe(true); + }); + const options = backgrounds.map((bg) => screen.getByTestId(`background-${bg.id}`)); + expect(options).toHaveLength(backgroundsFiles.length); + }); + + it('sets the selected background', async () => { + const setBackgroundSelected = vi.fn(); + render( + + ); + const duneViewOption = screen.getByTestId('background-bg3'); + await userEvent.click(duneViewOption); + expect(setBackgroundSelected).toHaveBeenCalledWith('dune-view.jpg'); + }); + + it('marks the background as selected', () => { + render( {}} />); + const planeOption = screen.getByTestId('background-bg7'); + expect(planeOption?.getAttribute('aria-pressed')).toBe('true'); + }); +}); diff --git a/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.tsx b/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.tsx new file mode 100644 index 00000000..50b6b17d --- /dev/null +++ b/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.tsx @@ -0,0 +1,60 @@ +import { ReactElement } from 'react'; +import { Box } from '@mui/material'; +import { BACKGROUNDS_PATH } from '../../../utils/constants'; +import SelectableOption from '../SelectableOption'; + +export const backgrounds = [ + { id: 'bg1', file: 'bookshelf-room.jpg' }, + { id: 'bg2', file: 'busy-room.jpg' }, + { id: 'bg3', file: 'dune-view.jpg' }, + { id: 'bg4', file: 'hogwarts.jpg' }, + { id: 'bg5', file: 'library.jpg' }, + { id: 'bg6', file: 'new-york.jpg' }, + { id: 'bg7', file: 'plane.jpg' }, + { id: 'bg8', file: 'white-room.jpg' }, +]; + +export type BackgroundGalleryProps = { + backgroundSelected: string; + setBackgroundSelected: (key: string) => void; +}; + +/** + * Renders a group of selectable images for background replacement in a meeting room. + * + * Each button represents a different background image option. + * @param {BackgroundGalleryProps} props - The props for the component. + * @property {string} backgroundSelected - The currently selected background image key. + * @property {Function} setBackgroundSelected - Callback to update the selected background image key. + * @returns {ReactElement} A horizontal stack of selectable option buttons. + */ +const BackgroundGallery = ({ + backgroundSelected, + setBackgroundSelected, +}: BackgroundGalleryProps): ReactElement => { + return ( + <> + {backgrounds.map((bg) => { + const path = `${BACKGROUNDS_PATH}/${bg.file}`; + return ( + + setBackgroundSelected(bg.file)} + image={path} + /> + + ); + })} + + ); +}; + +export default BackgroundGallery; diff --git a/frontend/src/components/BackgroundEffects/BackgroundGallery/Index.tsx b/frontend/src/components/BackgroundEffects/BackgroundGallery/Index.tsx new file mode 100644 index 00000000..e7a570f1 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/BackgroundGallery/Index.tsx @@ -0,0 +1,3 @@ +import BackgroundGallery from './BackgroundGallery'; + +export default BackgroundGallery; diff --git a/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.spec.tsx b/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.spec.tsx new file mode 100644 index 00000000..38d479c3 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.spec.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import BackgroundVideoContainer from './BackgroundVideoContainer'; + +vi.mock('../../../utils/waitUntilPlaying', () => ({ + __esModule: true, + default: vi.fn(() => Promise.resolve()), +})); + +describe('BackgroundVideoContainer', () => { + it('shows message when video is not enabled', () => { + render(); + expect(screen.getByText(/You have not enabled video/i)).toBeInTheDocument(); + }); + + it('renders video element when video is enabled', () => { + const videoEl = document.createElement('video'); + render(); + expect(videoEl.classList.contains('video__element')).toBe(true); + expect(videoEl.title).toBe('publisher-preview'); + }); + + it('shows loading spinner while video is loading', () => { + const videoEl = document.createElement('video'); + render(); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx b/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx new file mode 100644 index 00000000..46f4a4ca --- /dev/null +++ b/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx @@ -0,0 +1,91 @@ +import { useRef, useState, useEffect, ReactElement } from 'react'; +import { CircularProgress, useMediaQuery } from '@mui/material'; +import waitUntilPlaying from '../../../utils/waitUntilPlaying'; + +export type BackgroundVideoContainerProps = { + isFixedWidth?: boolean; + publisherVideoElement?: HTMLObjectElement | HTMLVideoElement | undefined; + isParentVideoEnabled?: boolean; +}; + +/** + * Component to render the video element for the background replacement preview publisher. + * @param {BackgroundVideoContainerProps} props - The properties for the component + * @property {boolean} isFixedWidth - Whether to apply a fixed width to the video element + * @property {HTMLObjectElement | HTMLVideoElement} publisherVideoElement - The video element to display + * @property {boolean} isParentVideoEnabled - Whether the parent video is enabled + * @returns {ReactElement} The rendered video container element + */ +const BackgroundVideoContainer = ({ + isFixedWidth = false, + publisherVideoElement, + isParentVideoEnabled = false, +}: BackgroundVideoContainerProps): ReactElement => { + const containerRef = useRef(null); + const [isVideoLoading, setIsVideoLoading] = useState(true); + const isSMViewport = useMediaQuery(`(max-width:500px)`); + const isMDViewport = useMediaQuery(`(max-width:768px)`); + const isTabletViewport = useMediaQuery(`(max-width:899px)`); + + useEffect(() => { + if (publisherVideoElement && containerRef.current) { + containerRef.current.appendChild(publisherVideoElement); + const myVideoElement = publisherVideoElement as HTMLElement; + myVideoElement.classList.add('video__element'); + myVideoElement.title = 'publisher-preview'; + myVideoElement.style.borderRadius = '12px'; + myVideoElement.style.maxHeight = isTabletViewport ? '80%' : '450px'; + + let width = '100%'; + if ((isFixedWidth && isTabletViewport) || (!isFixedWidth && isMDViewport)) { + width = '80%'; + } + myVideoElement.style.width = width; + + myVideoElement.style.marginLeft = 'auto'; + myVideoElement.style.marginRight = 'auto'; + myVideoElement.style.marginBottom = '1px'; + myVideoElement.style.transform = 'scaleX(-1)'; + myVideoElement.style.objectFit = 'contain'; + myVideoElement.style.aspectRatio = '16 / 9'; + myVideoElement.style.boxShadow = + '0 1px 2px 0 rgba(60, 64, 67, .3), 0 1px 3px 1px rgba(60, 64, 67, .15)'; + + waitUntilPlaying(publisherVideoElement).then(() => { + setIsVideoLoading(false); + }); + } + }, [ + isTabletViewport, + isMDViewport, + isSMViewport, + publisherVideoElement, + isFixedWidth, + isParentVideoEnabled, + ]); + + let containerWidth = '100%'; + if (isFixedWidth) { + containerWidth = isTabletViewport ? '80%' : '90%'; + } else if (isMDViewport) { + containerWidth = '80%'; + } + + return ( +
+ {!isParentVideoEnabled && ( +
+ You have not enabled video +
+ )} + {isParentVideoEnabled &&
} + {isVideoLoading && isParentVideoEnabled && ( +
+ +
+ )} +
+ ); +}; + +export default BackgroundVideoContainer; diff --git a/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/index.tsx b/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/index.tsx new file mode 100644 index 00000000..271cd2f1 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/index.tsx @@ -0,0 +1,3 @@ +import BackgroundVideoContainer from './BackgroundVideoContainer'; + +export default BackgroundVideoContainer; diff --git a/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.spec.tsx b/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.spec.tsx new file mode 100644 index 00000000..f8b95e4a --- /dev/null +++ b/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.spec.tsx @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import EffectOptionButtons from './EffectOptionButtons'; + +describe('EffectOptionButtons', () => { + it('renders all effect options', () => { + render( {}} />); + expect(screen.getByTestId('BlockIcon')).toBeInTheDocument(); + expect(screen.getAllByTestId('BlurOnIcon')).toHaveLength(2); + }); + + it('marks the selected option as selected', () => { + render( {}} />); + const selectedOption = screen.getByTestId('background-low-blur'); + expect(selectedOption).toBeInTheDocument(); + }); + + it('sets the selected background', async () => { + const setBackgroundSelected = vi.fn(); + render( + + ); + const lowBlur = screen.getByTestId('background-low-blur'); + await userEvent.click(lowBlur); + expect(setBackgroundSelected).toHaveBeenCalledWith('low-blur'); + }); + + it('sets the selected background with high blur', async () => { + const setBackgroundSelected = vi.fn(); + render( + + ); + const highBlur = screen.getByTestId('background-high-blur'); + await userEvent.click(highBlur); + expect(setBackgroundSelected).toHaveBeenCalledWith('high-blur'); + }); +}); diff --git a/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.tsx b/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.tsx new file mode 100644 index 00000000..7b747898 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.tsx @@ -0,0 +1,45 @@ +import { ReactElement } from 'react'; +import BlockIcon from '@mui/icons-material/Block'; +import BlurOnIcon from '@mui/icons-material/BlurOn'; +import SelectableOption from '../SelectableOption'; + +const options = [ + { key: 'none', icon: }, + { key: 'low-blur', icon: }, + { key: 'high-blur', icon: }, +]; + +export type EffectOptionButtonsProps = { + backgroundSelected: string; + setBackgroundSelected: (key: string) => void; +}; + +/** + * Renders a group of selectable buttons for background effects in a room. + * + * Each button represents a different background effect option. + * @param {EffectOptionButtonsProps} props - the props for the component. + * @property {boolean} backgroundSelected - The currently selected background effect key. + * @property {Function} setBackgroundSelected - Callback to update the selected background effect key. + * @returns {ReactElement} A horizontal stack of selectable option buttons. + */ +const EffectOptionButtons = ({ + backgroundSelected, + setBackgroundSelected, +}: EffectOptionButtonsProps): ReactElement => { + return ( + <> + {options.map(({ key, icon }) => ( + setBackgroundSelected(key)} + icon={icon} + /> + ))} + + ); +}; + +export default EffectOptionButtons; diff --git a/frontend/src/components/BackgroundEffects/EffectOptionButtons/Index.tsx b/frontend/src/components/BackgroundEffects/EffectOptionButtons/Index.tsx new file mode 100644 index 00000000..15616c2f --- /dev/null +++ b/frontend/src/components/BackgroundEffects/EffectOptionButtons/Index.tsx @@ -0,0 +1,3 @@ +import EffectOptionButtons from './EffectOptionButtons'; + +export default EffectOptionButtons; diff --git a/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.spec.tsx b/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.spec.tsx new file mode 100644 index 00000000..798ff130 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.spec.tsx @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import SelectableOption from './SelectableOption'; + +describe('SelectableOption', () => { + it('renders with icon when image is not provided', () => { + render( + {}} + id="icon-option" + icon={Icon} + /> + ); + expect(screen.getByTestId('background-icon-option')).toBeInTheDocument(); + expect(screen.getByTestId('test-icon')).toBeInTheDocument(); + }); + + it('renders with image when image is provided', () => { + render( + {}} id="img-option" image="/test.jpg" /> + ); + expect(screen.getByTestId('background-img-option')).toBeInTheDocument(); + expect(screen.getByAltText('background')).toHaveAttribute('src', '/test.jpg'); + }); + + it('calls onClick when clicked', async () => { + const handleClick = vi.fn(); + render( + Click} + /> + ); + await userEvent.click(screen.getByTestId('background-clickable')); + expect(handleClick).toHaveBeenCalled(); + }); + + it('shows selected aria-pressed when selected', () => { + render( + {}} id="selected" icon={Selected} /> + ); + const option = screen.getByTestId('background-selected'); + expect(option).toHaveAttribute('aria-pressed', 'true'); + }); + + it('is disabled when isDisabled is true', () => { + render( + {}} + id="disabled" + icon={Disabled} + isDisabled + /> + ); + const option = screen.getByTestId('background-disabled'); + expect(option).toHaveAttribute('aria-disabled', 'true'); + }); +}); diff --git a/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.tsx b/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.tsx new file mode 100644 index 00000000..231512ae --- /dev/null +++ b/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.tsx @@ -0,0 +1,75 @@ +import { ReactElement, ReactNode } from 'react'; +import { Paper } from '@mui/material'; +import { DEFAULT_SELECTABLE_OPTION_WIDTH } from '../../../utils/constants'; + +export type SelectableOptionProps = { + isSelected: boolean; + onClick: () => void; + id: string; + icon?: ReactNode; + image?: string; + size?: number; + isDisabled?: boolean; +}; + +/** + * Renders a selectable option with an icon or image. + * + * The option can be selected or disabled, and has a hover effect. + * @param {SelectableOptionProps} props - The properties for the component + * @property {boolean} isSelected - Whether the option is selected + * @property {Function} onClick - Function to call when the option is clicked + * @property {string} id - Unique identifier for the option + * @property {ReactNode} icon - Icon to display in the option + * @property {string} image - Image URL to display in the option + * @property {number} size - Size of the option (default is DEFAULT_SELECTABLE_OPTION_WIDTH) + * @property {boolean} isDisabled - Whether the option is disabled + * @returns {ReactElement} A selectable option element + */ +const SelectableOption = ({ + isSelected, + onClick, + id, + icon, + image, + size = DEFAULT_SELECTABLE_OPTION_WIDTH, + isDisabled = false, + ...otherProps // Used by MUI Tooltip +}: SelectableOptionProps): ReactElement => { + return ( + + {image ? ( + background + ) : ( + icon + )} + + ); +}; + +export default SelectableOption; diff --git a/frontend/src/components/BackgroundEffects/SelectableOption/index.tsx b/frontend/src/components/BackgroundEffects/SelectableOption/index.tsx new file mode 100644 index 00000000..62c1d319 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/SelectableOption/index.tsx @@ -0,0 +1,3 @@ +import SelectableOption from './SelectableOption'; + +export default SelectableOption; diff --git a/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx b/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx new file mode 100644 index 00000000..7a1a70d2 --- /dev/null +++ b/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx @@ -0,0 +1,74 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import BackgroundEffectsLayout from './BackgroundEffectsLayout'; + +const mockChangeBackground = vi.fn(); + +vi.mock('../../../hooks/usePublisherContext', () => ({ + __esModule: true, + default: () => ({ + publisher: { + getVideoFilter: vi.fn(() => undefined), + }, + changeBackground: mockChangeBackground, + isVideoEnabled: true, + }), +})); +vi.mock('../../../hooks/useBackgroundPublisherContext', () => ({ + __esModule: true, + default: () => ({ + publisherVideoElement: null, + changeBackground: vi.fn(), + }), +})); + +describe('BackgroundEffectsLayout', () => { + const handleClose = vi.fn(); + const renderLayout = (isOpen = true) => + render(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders when open', () => { + renderLayout(); + expect(screen.getByTestId('right-panel-title')).toHaveTextContent('Background Effects'); + expect(screen.getByTestId('background-video-container')).toBeInTheDocument(); + expect(screen.getByTestId('background-none')).toBeInTheDocument(); + expect(screen.getByTestId('background-upload')).toBeInTheDocument(); + expect(screen.getByTestId('background-bg1')).toBeInTheDocument(); + expect(screen.getAllByText(/Choose Background Effect/i)[0]).toBeInTheDocument(); + expect(screen.getByTestId('background-effect-cancel-button')).toBeInTheDocument(); + expect(screen.getByTestId('background-effect-apply-button')).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + const { container } = renderLayout(false); + expect(container).toBeEmptyDOMElement(); + }); + + it('calls handleClose when Cancel is clicked', async () => { + renderLayout(); + await userEvent.click(screen.getByTestId('background-effect-cancel-button')); + expect(handleClose).toHaveBeenCalled(); + }); + + it('calls handleClose and changeBackground when Apply is clicked', async () => { + renderLayout(); + await userEvent.click(screen.getByTestId('background-effect-apply-button')); + expect(mockChangeBackground).toHaveBeenCalled(); + expect(handleClose).toHaveBeenCalled(); + }); + + it('calls setBackgroundSelected when effect option none is clicked', async () => { + renderLayout(); + await userEvent.click(screen.getByTestId('background-none')); + }); + + it('calls setBackgroundSelected when a background gallery option is clicked', async () => { + renderLayout(); + await userEvent.click(screen.getByTestId('background-bg8')); + }); +}); diff --git a/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx b/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx new file mode 100644 index 00000000..2184778c --- /dev/null +++ b/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx @@ -0,0 +1,126 @@ +import { ReactElement, useCallback, useEffect, useState } from 'react'; +import { Box, Button, Typography } from '@mui/material'; +import usePublisherContext from '../../../hooks/usePublisherContext'; +import RightPanelTitle from '../RightPanel/RightPanelTitle'; +import EffectOptionButtons from '../../BackgroundEffects/EffectOptionButtons/EffectOptionButtons'; +import BackgroundGallery from '../../BackgroundEffects/BackgroundGallery/BackgroundGallery'; +import BackgroundVideoContainer from '../../BackgroundEffects/BackgroundVideoContainer'; +import useBackgroundPublisherContext from '../../../hooks/useBackgroundPublisherContext'; +import { DEFAULT_SELECTABLE_OPTION_WIDTH } from '../../../utils/constants'; +import AddBackgroundEffect from '../../BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect'; +import getInitialBackgroundFilter from '../../../utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter'; + +export type BackgroundEffectsLayoutProps = { + handleClose: () => void; + isOpen: boolean; +}; + +/** + * BackgroundEffectsLayout Component + * + * This component manages the UI for background effects (cancel background and blurs) in a room. + * @param {BackgroundEffectsLayoutProps} props - The props for the component. + * @property {boolean} isOpen - Whether the background effects panel is open. + * @property {Function} handleClose - Function to close the panel. + * @returns {ReactElement} The background effects panel component. + */ +const BackgroundEffectsLayout = ({ + handleClose, + isOpen, +}: BackgroundEffectsLayoutProps): ReactElement | false => { + const [backgroundSelected, setBackgroundSelected] = useState('none'); + const { publisher, changeBackground, isVideoEnabled } = usePublisherContext(); + const { publisherVideoElement, changeBackground: changeBackgroundPreview } = + useBackgroundPublisherContext(); + + const handleBackgroundSelect = (selectedBackgroundOption: string) => { + setBackgroundSelected(selectedBackgroundOption); + changeBackgroundPreview(selectedBackgroundOption); + }; + + const handleApplyBackgroundSelect = async () => { + changeBackground(backgroundSelected); + handleClose(); + }; + + const setInitialBackgroundReplacement = useCallback(() => { + const selectedBackgroundOption = getInitialBackgroundFilter(publisher); + setBackgroundSelected(selectedBackgroundOption); + return selectedBackgroundOption; + }, [publisher, setBackgroundSelected]); + + const publisherVideoFilter = publisher?.getVideoFilter(); + + // Reset background when closing the panel + useEffect(() => { + if (isOpen) { + const currentOption = setInitialBackgroundReplacement(); + changeBackgroundPreview(currentOption); + } + }, [publisherVideoFilter, isOpen, changeBackgroundPreview, setInitialBackgroundReplacement]); + + return ( + isOpen && ( + <> + + + + + + + + + Choose Background Effect + + + + + + {/* TODO: load custom images */} + + + + + + + + + + ) + ); +}; + +export default BackgroundEffectsLayout; diff --git a/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/Index.tsx b/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/Index.tsx new file mode 100644 index 00000000..cdeb11f9 --- /dev/null +++ b/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/Index.tsx @@ -0,0 +1,3 @@ +import BackgroundEffectsLayout from './BackgroundEffectsLayout'; + +export default BackgroundEffectsLayout; diff --git a/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.spec.tsx b/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.spec.tsx index 92c19466..f18ec30b 100644 --- a/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.spec.tsx +++ b/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.spec.tsx @@ -13,6 +13,7 @@ vi.mock('../../../hooks/useSpeakingDetector.tsx'); const mockUsePublisherContext = usePublisherContext as Mock<[], PublisherContextType>; const mockUseSpeakingDetector = useSpeakingDetector as Mock<[], boolean>; +const mockHandleToggleBackgroundEffects = vi.fn(); describe('DeviceControlButton', () => { const nativeMediaDevices = global.navigator.mediaDevices; @@ -64,13 +65,23 @@ describe('DeviceControlButton', () => { }); it('renders the video control button', () => { - render(); + render( + + ); expect(screen.getByLabelText('camera')).toBeInTheDocument(); expect(screen.getByTestId('ArrowDropUpIcon')).toBeInTheDocument(); }); it('renders the audio control button', () => { - render(); + render( + + ); expect(screen.getByLabelText('microphone')).toBeInTheDocument(); expect(screen.getByTestId('ArrowDropUpIcon')).toBeInTheDocument(); }); diff --git a/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx b/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx index 948f4db6..8e9f47ca 100644 --- a/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx +++ b/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx @@ -12,6 +12,7 @@ import DeviceSettingsMenu from '../DeviceSettingsMenu'; export type DeviceControlButtonProps = { deviceType: 'audio' | 'video'; + toggleBackgroundEffects: () => void; }; /** @@ -20,10 +21,14 @@ export type DeviceControlButtonProps = { * This component displays a current status of audio/video device (camera/microphone enabled/disabled) * and shows a dropdown that displays available audio/video devices. * @param {DeviceControlButtonProps} props - the props for the component. - * @property {boolean} deviceType - (optional) indicates the type of the device to control. + * @property {boolean} deviceType - indicates the type of the device to control. + * @property {Function} toggleBackgroundEffects - function to toggle background effects for video devices. * @returns {ReactElement} The DeviceControlButton component. */ -const DeviceControlButton = ({ deviceType }: DeviceControlButtonProps): ReactElement => { +const DeviceControlButton = ({ + deviceType, + toggleBackgroundEffects, +}: DeviceControlButtonProps): ReactElement => { const { isVideoEnabled, toggleAudio, toggleVideo, isAudioEnabled } = usePublisherContext(); const isAudio = deviceType === 'audio'; const [open, setOpen] = useState(false); @@ -98,6 +103,7 @@ const DeviceControlButton = ({ deviceType }: DeviceControlButtonProps): ReactEle { const nativeMediaDevices = global.navigator.mediaDevices; const mockHandleToggle = vi.fn(); + const mockHandleToggleBackgroundEffects = vi.fn(); const mockSetIsOpen = vi.fn(); const mockAnchorRef = { current: document.createElement('input'), @@ -100,6 +101,7 @@ describe('DeviceSettingsMenu Component', () => { { { { { { deviceType={deviceType} handleToggle={mockHandleToggle} handleClose={mockHandleClose} + toggleBackgroundEffects={mockHandleToggleBackgroundEffects} isOpen anchorRef={mockAnchorRef} setIsOpen={mockSetIsOpen} @@ -296,6 +303,7 @@ describe('DeviceSettingsMenu Component', () => { deviceType={deviceType} handleToggle={mockHandleToggle} handleClose={mockHandleClose} + toggleBackgroundEffects={mockHandleToggleBackgroundEffects} isOpen={false} anchorRef={mockAnchorRef} setIsOpen={mockSetIsOpen} @@ -304,13 +312,14 @@ describe('DeviceSettingsMenu Component', () => { expect(screen.queryByTestId('video-settings-devices-dropdown')).not.toBeInTheDocument(); }); - it('and renders the dropdown separator and background blur option when media processor is supported', async () => { + it('and renders the dropdown separator and background effects option when media processor is supported', async () => { mockedHasMediaProcessorSupport.mockReturnValue(true); render( { await waitFor(() => { expect(screen.queryByTestId('dropdown-separator')).toBeVisible(); - expect(screen.queryByText('Blur your background')).toBeVisible(); + expect(screen.queryByText('Background effects')).toBeVisible(); }); }); - it('and does not render the dropdown separator and background blur option when media processor is not supported', async () => { + it('and does not render the dropdown separator and background effects option when media processor is not supported', async () => { render( { await waitFor(() => { expect(screen.queryByTestId('dropdown-separator')).not.toBeInTheDocument(); - expect(screen.queryByText('Blur your background')).not.toBeInTheDocument(); + expect(screen.queryByText('Background effects')).not.toBeInTheDocument(); }); }); }); diff --git a/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx b/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx index f094b112..a3eac284 100644 --- a/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx +++ b/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx @@ -17,6 +17,7 @@ import VideoDevicesOptions from '../VideoDevicesOptions'; export type DeviceSettingsMenuProps = { deviceType: 'audio' | 'video'; handleToggle: () => void; + toggleBackgroundEffects: () => void; isOpen: boolean; anchorRef: RefObject; handleClose: (event: MouseEvent | TouchEvent) => void; @@ -33,15 +34,18 @@ export type DeviceSettingsMenuProps = { * - on supported devices, an option to blur the video background * @param {DeviceSettingsMenuProps} props - the props for this component. * @property {boolean} deviceType - indicates the type of the device to control. - * @property {() => void} handleToggle - the function that handles the toggle of video input device. + * @property {Function} handleToggle - the function that handles the toggle of video input device. + * @property {Function} toggleBackgroundEffects - the function that toggles background effects for video devices. * @property {boolean} isOpen - the prop that shows whether the pop up needs to be opened. * @property {RefObject} anchorRef - the anchor element to attach the pop up to. * @property {Function} handleClose - the function that handles the closing of the pop up. + * @property {Function} setIsOpen - the function to set the open state of the pop up. * @returns {ReactElement} - the DeviceSettingsMenu component. */ const DeviceSettingsMenu = ({ deviceType, handleToggle, + toggleBackgroundEffects, isOpen, anchorRef, handleClose, @@ -70,7 +74,7 @@ const DeviceSettingsMenu = ({ {hasMediaProcessorSupport() && ( <> - + )} diff --git a/frontend/src/components/MeetingRoom/RightPanel/RightPanel.tsx b/frontend/src/components/MeetingRoom/RightPanel/RightPanel.tsx index 45a8b8d5..6424554b 100644 --- a/frontend/src/components/MeetingRoom/RightPanel/RightPanel.tsx +++ b/frontend/src/components/MeetingRoom/RightPanel/RightPanel.tsx @@ -4,6 +4,7 @@ import Chat from '../Chat'; import ReportIssue from '../ReportIssue'; import type { RightPanelActiveTab } from '../../../hooks/useRightPanel'; import useIsSmallViewport from '../../../hooks/useIsSmallViewport'; +import BackgroundEffectsLayout from '../BackgroundEffectsLayout/BackgroundEffectsLayout'; export type RightPanelProps = { handleClose: () => void; @@ -13,10 +14,10 @@ export type RightPanelProps = { /** * RightPanel Component * Renders a tab panel that enters from off screen on the right of the window. - * The panel displays participant list or chat tab. + * The panel displays participant list, chat tab, report issue and background effects. * @param {RightPanelProps} props - props for the component * @property {RightPanelActiveTab} activeTab - string indicating which tab to display, or 'closed' if closed - * @property {() => void} handleClose - click handler to close the panel + * @property {Function} handleClose - click handler to close the panel * @returns {ReactElement} RightPanel Component */ const RightPanel = ({ activeTab, handleClose }: RightPanelProps): ReactElement => { @@ -31,6 +32,10 @@ const RightPanel = ({ activeTab, handleClose }: RightPanelProps): ReactElement = return (
+
diff --git a/frontend/src/components/MeetingRoom/RightPanel/RightPanelTitle.tsx b/frontend/src/components/MeetingRoom/RightPanel/RightPanelTitle.tsx index d3d9232e..eef97276 100644 --- a/frontend/src/components/MeetingRoom/RightPanel/RightPanelTitle.tsx +++ b/frontend/src/components/MeetingRoom/RightPanel/RightPanelTitle.tsx @@ -19,6 +19,7 @@ export type RightPanelTitleProps = { const RightPanelTitle = ({ handleClose, title }: RightPanelTitleProps): ReactElement => { return (
diff --git a/frontend/src/components/MeetingRoom/Toolbar/Toolbar.spec.tsx b/frontend/src/components/MeetingRoom/Toolbar/Toolbar.spec.tsx index cb43ed40..274f0281 100644 --- a/frontend/src/components/MeetingRoom/Toolbar/Toolbar.spec.tsx +++ b/frontend/src/components/MeetingRoom/Toolbar/Toolbar.spec.tsx @@ -60,6 +60,7 @@ describe('Toolbar', () => { toggleParticipantList: vi.fn(), toggleChat: vi.fn(), toggleReportIssue: vi.fn(), + toggleBackgroundEffects: vi.fn(), participantCount: 0, captionsState: { isUserCaptionsEnabled: false, diff --git a/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx b/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx index 407dd5a1..1afdd407 100644 --- a/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx +++ b/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx @@ -27,6 +27,7 @@ export type ToolbarProps = { isSharingScreen: boolean; rightPanelActiveTab: RightPanelActiveTab; toggleParticipantList: () => void; + toggleBackgroundEffects: () => void; toggleChat: () => void; toggleReportIssue: () => void; participantCount: number; @@ -61,6 +62,7 @@ const Toolbar = ({ toggleShareScreen, rightPanelActiveTab, toggleParticipantList, + toggleBackgroundEffects, toggleChat, toggleReportIssue, participantCount, @@ -162,8 +164,14 @@ const Toolbar = ({
- - + +
{toolbarButtons.map(displayCenterToolbarButtons)}
diff --git a/frontend/src/components/MeetingRoom/VideoDevicesOptions/VideoDevicesOptions.spec.tsx b/frontend/src/components/MeetingRoom/VideoDevicesOptions/VideoDevicesOptions.spec.tsx index 2a4ea646..18a36020 100644 --- a/frontend/src/components/MeetingRoom/VideoDevicesOptions/VideoDevicesOptions.spec.tsx +++ b/frontend/src/components/MeetingRoom/VideoDevicesOptions/VideoDevicesOptions.spec.tsx @@ -1,89 +1,27 @@ -import { describe, it, beforeEach, afterEach, vi, expect, Mock } from 'vitest'; -import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; -import { Publisher } from '@vonage/client-sdk-video'; -import { EventEmitter } from 'stream'; +import { describe, it, vi, expect, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; import VideoDevicesOptions from './VideoDevicesOptions'; -import usePublisherContext from '../../../hooks/usePublisherContext'; -import { PublisherContextType } from '../../../Context/PublisherProvider'; -import { defaultAudioDevice } from '../../../utils/mockData/device'; -// Mocks -vi.mock('../../../hooks/usePublisherContext'); -vi.mock('../../../utils/storage', () => ({ - setStorageItem: vi.fn(), - STORAGE_KEYS: { - VIDEO_SOURCE: 'videoSource', - }, -})); -const mockUsePublisherContext = usePublisherContext as Mock<[], PublisherContextType>; - -describe('VideoDevicesOptions Component', () => { - const mockGetVideoSource = vi.fn(() => ({ - deviceId: 'a68ec4e4a6bc10dc572bd806414b0da27d0aefb0ad822f7ba4cf9b226bb9b7c2', - label: 'FaceTime HD Camera (2C0E:82E3)', - })); - let mockPublisher: Publisher; - let publisherContext: PublisherContextType; - const applyVideoFilter = vi.fn(() => true); - const clearVideoFilter = vi.fn(); +describe('VideoDevicesOptions', () => { + const toggleBackgroundEffects = vi.fn(); beforeEach(() => { - mockPublisher = Object.assign(new EventEmitter(), { - getVideoFilter: vi.fn(() => ({ type: 'backgroundBlur' })), - setVideoSource: vi.fn(), - applyVideoFilter, - clearVideoFilter, - getAudioSource: () => defaultAudioDevice, - getVideoSource: () => mockGetVideoSource(), - videoWidth: () => 1280, - videoHeight: () => 720, - }) as unknown as Publisher; - publisherContext = { - publisher: mockPublisher, - isPublishing: true, - publish: vi.fn() as () => Promise, - initializeLocalPublisher: vi.fn(() => { - publisherContext.publisher = mockPublisher; - }) as unknown as () => void, - } as unknown as PublisherContextType; - mockUsePublisherContext.mockImplementation(() => publisherContext); - - mockGetVideoSource.mockReturnValue({ - deviceId: 'a68ec4e4a6bc10dc572bd806414b0da27d0aefb0ad822f7ba4cf9b226bb9b7c2', - label: 'FaceTime HD Camera (2C0E:82E3)', - }); + vi.clearAllMocks(); }); afterEach(() => { cleanup(); - vi.resetAllMocks(); }); - it('renders blur option and toggle icons', () => { - render(); - - expect(screen.getByTestId('blur-text')).toHaveTextContent('Blur your background'); - expect(screen.getByTestId('toggle-off-icon')).toBeInTheDocument(); - expect(screen.getByTestId('toggle-on-icon')).toBeInTheDocument(); + it('renders the background effects menu item', () => { + render(); + expect(screen.getByTestId('background-effects-text')).toHaveTextContent('Background effects'); + expect(screen.getByRole('menuitem')).toBeInTheDocument(); }); - it('calls applyVideoFilter and sets storage when toggled on', async () => { - render(); - - const toggleButton = screen.getByLabelText('Toggle background blur'); - fireEvent.click(toggleButton); - - await waitFor(() => { - expect(clearVideoFilter).toHaveBeenCalled(); - }); - - fireEvent.click(toggleButton); - - await waitFor(() => { - expect(applyVideoFilter).toHaveBeenCalledWith({ - type: 'backgroundBlur', - blurStrength: 'high', - }); - }); + it('calls toggleBackgroundEffects when menu item is clicked', () => { + render(); + fireEvent.click(screen.getByRole('menuitem')); + expect(toggleBackgroundEffects).toHaveBeenCalled(); }); }); diff --git a/frontend/src/components/MeetingRoom/VideoDevicesOptions/VideoDevicesOptions.tsx b/frontend/src/components/MeetingRoom/VideoDevicesOptions/VideoDevicesOptions.tsx index 711415d8..762ca2c2 100644 --- a/frontend/src/components/MeetingRoom/VideoDevicesOptions/VideoDevicesOptions.tsx +++ b/frontend/src/components/MeetingRoom/VideoDevicesOptions/VideoDevicesOptions.tsx @@ -1,47 +1,22 @@ -import { Typography, IconButton, MenuList, MenuItem } from '@mui/material'; -import BlurOnIcon from '@mui/icons-material/BlurOn'; -import { useState, useEffect, ReactElement } from 'react'; -import Grow from '@mui/material/Grow'; -import ToggleOffIcon from '@mui/icons-material/ToggleOff'; -import ToggleOnIcon from '@mui/icons-material/ToggleOn'; -import usePublisherContext from '../../../hooks/usePublisherContext'; -import { setStorageItem, STORAGE_KEYS } from '../../../utils/storage'; +import { Typography, MenuList, MenuItem } from '@mui/material'; +import { ReactElement } from 'react'; +import PortraitIcon from '@mui/icons-material/Portrait'; export type VideoDevicesOptionsProps = { - customLightBlueColor: string; + toggleBackgroundEffects: () => void; }; /** * VideoDevicesOptions Component * - * This component renders a drop-down menu for video device settings. + * This component renders a drop-down menu for video device settings (Background Effects). * @param {VideoDevicesOptionsProps} props - the props for the component. - * @property {string} customLightBlueColor - the custom color used for the toggled icon. + * @property {Function} toggleBackgroundEffects - Function to toggle background effects. * @returns {ReactElement} The video devices options component. */ -const VideoDevicesOptions = ({ customLightBlueColor }: VideoDevicesOptionsProps): ReactElement => { - const { publisher } = usePublisherContext(); - const [isToggled, setIsToggled] = useState(false); - - const handleToggle = async () => { - const newState = !isToggled; - setIsToggled(newState); - setStorageItem(STORAGE_KEYS.BACKGROUND_BLUR, JSON.stringify(newState)); - if (newState) { - await publisher?.applyVideoFilter({ - type: 'backgroundBlur', - blurStrength: 'high', - }); - } else { - await publisher?.clearVideoFilter(); - } - }; - - useEffect(() => { - const videoFilter = publisher?.getVideoFilter(); - setIsToggled(videoFilter !== null); - }, [publisher]); - +const VideoDevicesOptions = ({ + toggleBackgroundEffects, +}: VideoDevicesOptionsProps): ReactElement => { return ( - - - Blur your background + + + Background effects - - - - - - - - ); diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.spec.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.spec.tsx new file mode 100644 index 00000000..b09e40ac --- /dev/null +++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.spec.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import BackgroundEffectsButton from './BackgroundEffectsButton'; + +const { mockHasMediaProcessorSupport } = vi.hoisted(() => { + return { + mockHasMediaProcessorSupport: vi.fn().mockReturnValue(true), + }; +}); +vi.mock('@vonage/client-sdk-video', () => ({ + hasMediaProcessorSupport: mockHasMediaProcessorSupport, +})); + +describe('BackgroundEffectsButton', () => { + const mockOnClick = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the button if media processor is supported', () => { + mockHasMediaProcessorSupport.mockReturnValue(true); + render(); + expect(screen.getByLabelText(/background effects/i)).toBeInTheDocument(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('does not render the button if media processor is not supported', () => { + mockHasMediaProcessorSupport.mockReturnValue(false); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('calls onClick when the button is clicked', async () => { + mockHasMediaProcessorSupport.mockReturnValue(true); + render(); + await userEvent.click(screen.getByRole('button')); + expect(mockOnClick).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/WaitingRoom/BlurButton/BlurButton.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.tsx similarity index 50% rename from frontend/src/components/WaitingRoom/BlurButton/BlurButton.tsx rename to frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.tsx index a7273bc2..d2394080 100644 --- a/frontend/src/components/WaitingRoom/BlurButton/BlurButton.tsx +++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.tsx @@ -1,21 +1,24 @@ import { Box, Tooltip } from '@mui/material'; -import BlurOff from '@mui/icons-material/BlurOff'; import { hasMediaProcessorSupport } from '@vonage/client-sdk-video'; import { ReactElement } from 'react'; -import usePreviewPublisherContext from '../../../hooks/usePreviewPublisherContext'; -import BlurIcon from '../../Icons/Blur'; -import VideoContainerButton from '../VideoContainerButton'; +import PortraitIcon from '@mui/icons-material/Portrait'; +import VideoContainerButton from '../../VideoContainerButton'; + +export type BackgroundEffectsButtonProps = { + onClick: () => void; +}; /** - * BlurButton Component + * BackgroundEffectsButton Component * - * If the user's device supports the Vonage Media Processor, displays a button to toggle background blur on and off. - * @returns {ReactElement | false} - The BlurButton component. + * If the user's device supports the Vonage Media Processor, displays a button to modify background effects. + * @param {BackgroundEffectsButtonProps} props - The props for the component. + * @property {Function} onClick - Function to call when the button is clicked. + * @returns {ReactElement | false} - The BackgroundEffectsButton component. */ -const BlurButton = (): ReactElement | false => { - const { toggleBlur, hasBlur } = usePreviewPublisherContext(); - const title = `Turn background blur ${hasBlur ? 'off' : 'on'}`; - +const BackgroundEffectsButton = ({ + onClick, +}: BackgroundEffectsButtonProps): ReactElement | false => { return ( hasMediaProcessorSupport() && ( { transition: 'transform 0.2s ease-in-out', }} > - + - ) : ( - - ) + } /> @@ -54,4 +53,4 @@ const BlurButton = (): ReactElement | false => { ); }; -export default BlurButton; +export default BackgroundEffectsButton; diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/index.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/index.tsx new file mode 100644 index 00000000..d1cbe7ff --- /dev/null +++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/index.tsx @@ -0,0 +1,3 @@ +import BackgroundEffectsButton from './BackgroundEffectsButton'; + +export default BackgroundEffectsButton; diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.spec.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.spec.tsx new file mode 100644 index 00000000..29ab1baf --- /dev/null +++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.spec.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import BackgroundEffectsDialog from './BackgroundEffectsDialog'; + +vi.mock('../../../../hooks/useBackgroundPublisherContext', () => ({ + __esModule: true, + default: () => ({ + publisherVideoElement: null, + changeBackground: vi.fn(), + }), +})); + +describe('BackgroundEffectsDialog', () => { + it('renders dialog when open', () => { + render( + {}} /> + ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText(/background effects/i)).toBeInTheDocument(); + }); + + it('does not render dialog when closed', () => { + const { queryByRole } = render( + {}} + /> + ); + expect(queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('calls setBackgroundEffectsOpen(false) on close', async () => { + const setBackgroundEffectsOpen = vi.fn(); + render( + + ); + const backdrop = document.querySelector('.MuiBackdrop-root'); + expect(backdrop).toBeTruthy(); + await userEvent.click(backdrop!); + expect(setBackgroundEffectsOpen).toHaveBeenCalledWith(false); + }); +}); diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.tsx new file mode 100644 index 00000000..b19327c5 --- /dev/null +++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.tsx @@ -0,0 +1,40 @@ +import { Dialog, DialogContent } from '@mui/material'; +import { ReactElement } from 'react'; +import BackgroundEffectsLayout from '../BackgroundEffectsLayout/BackgroundEffectsLayout'; + +export type BackgroundEffectsDialogProps = { + isBackgroundEffectsOpen: boolean; + setIsBackgroundEffectsOpen: (open: boolean) => void; +}; + +/** + * BackgroundEffectsDialog Component + * + * This component renders a dialog for background effects in the waiting room. + * @param {BackgroundEffectsDialogProps} props - The props for the component. + * @property {boolean} isBackgroundEffectsOpen - Whether the dialog is open. + * @property {Function} setIsBackgroundEffectsOpen - Function to set the open state of the dialog. + * @returns {ReactElement} The background effects dialog component. + */ +const BackgroundEffectsDialog = ({ + isBackgroundEffectsOpen, + setIsBackgroundEffectsOpen, +}: BackgroundEffectsDialogProps): ReactElement | false => { + return ( + setIsBackgroundEffectsOpen(false)} + maxWidth="lg" + fullWidth + > + + setIsBackgroundEffectsOpen(false)} + /> + + + ); +}; + +export default BackgroundEffectsDialog; diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/index.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/index.tsx new file mode 100644 index 00000000..7d9a9efd --- /dev/null +++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/index.tsx @@ -0,0 +1,3 @@ +import BackgroundEffectsDialog from './BackgroundEffectsDialog'; + +export default BackgroundEffectsDialog; diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx new file mode 100644 index 00000000..0d87ed88 --- /dev/null +++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx @@ -0,0 +1,74 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import BackgroundEffectsLayout from './BackgroundEffectsLayout'; + +const mockChangeBackground = vi.fn(); + +vi.mock('../../../../hooks/usePreviewPublisherContext', () => ({ + __esModule: true, + default: () => ({ + publisher: { + getVideoFilter: vi.fn(() => undefined), + }, + changeBackground: mockChangeBackground, + isVideoEnabled: true, + }), +})); +vi.mock('../../../../hooks/useBackgroundPublisherContext', () => ({ + __esModule: true, + default: () => ({ + publisherVideoElement: null, + changeBackground: vi.fn(), + }), +})); + +describe('BackgroundEffects (Waiting Room)', () => { + const handleClose = vi.fn(); + const renderLayout = (isOpen = true) => + render(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders when open', () => { + renderLayout(); + expect(screen.getByText('Background Effects')).toBeInTheDocument(); + expect(screen.getByTestId('background-video-container')).toBeInTheDocument(); + expect(screen.getByTestId('background-none')).toBeInTheDocument(); + expect(screen.getByTestId('background-upload')).toBeInTheDocument(); + expect(screen.getByTestId('background-bg1')).toBeInTheDocument(); + expect(screen.getAllByText(/Choose Background Effect/i)[0]).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Apply/i })).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + const { container } = renderLayout(false); + expect(container).toBeEmptyDOMElement(); + }); + + it('calls handleClose when Cancel is clicked', async () => { + renderLayout(); + await userEvent.click(screen.getByRole('button', { name: /Cancel/i })); + expect(handleClose).toHaveBeenCalled(); + }); + + it('calls handleClose and changeBackground when Apply is clicked', async () => { + renderLayout(); + await userEvent.click(screen.getByRole('button', { name: /Apply/i })); + expect(mockChangeBackground).toHaveBeenCalled(); + expect(handleClose).toHaveBeenCalled(); + }); + + it('calls setBackgroundSelected when effect option none is clicked', async () => { + renderLayout(); + await userEvent.click(screen.getByTestId('background-none')); + }); + + it('calls setBackgroundSelected when a background gallery option is clicked', async () => { + renderLayout(); + await userEvent.click(screen.getByTestId('background-bg8')); + }); +}); diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx new file mode 100644 index 00000000..be05d055 --- /dev/null +++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx @@ -0,0 +1,134 @@ +import { ReactElement, useCallback, useEffect, useState } from 'react'; +import { Box, Button, Typography, useMediaQuery } from '@mui/material'; +import EffectOptionButtons from '../../../BackgroundEffects/EffectOptionButtons/EffectOptionButtons'; +import BackgroundGallery from '../../../BackgroundEffects/BackgroundGallery/BackgroundGallery'; +import BackgroundVideoContainer from '../../../BackgroundEffects/BackgroundVideoContainer'; +import usePreviewPublisherContext from '../../../../hooks/usePreviewPublisherContext'; +import useBackgroundPublisherContext from '../../../../hooks/useBackgroundPublisherContext'; +import { DEFAULT_SELECTABLE_OPTION_WIDTH } from '../../../../utils/constants'; +import AddBackgroundEffect from '../../../BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect'; +import getInitialBackgroundFilter from '../../../../utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter'; + +export type BackgroundEffectsProps = { + isOpen: boolean; + handleClose: () => void; +}; + +/** + * BackgroundEffectsLayout Component + * + * This component manages the UI for background effects in the waiting room. + * @param {BackgroundEffectsProps} props - The props for the component. + * @property {boolean} isOpen - Whether the background effects panel is open. + * @property {Function} handleClose - Function to close the panel. + * @returns {ReactElement} The background effects panel component. + */ +const BackgroundEffectsLayout = ({ + isOpen, + handleClose, +}: BackgroundEffectsProps): ReactElement | false => { + const [backgroundSelected, setBackgroundSelected] = useState('none'); + const { publisher, changeBackground, isVideoEnabled } = usePreviewPublisherContext(); + const { publisherVideoElement, changeBackground: changeBackgroundPreview } = + useBackgroundPublisherContext(); + const isTabletViewport = useMediaQuery(`(max-width:899px)`); + + const handleBackgroundSelect = (selectedBackgroundOption: string) => { + setBackgroundSelected(selectedBackgroundOption); + changeBackgroundPreview(selectedBackgroundOption); + }; + + const handleApplyBackgroundSelect = () => { + changeBackground(backgroundSelected); + handleClose(); + }; + + const setInitialBackgroundReplacement = useCallback(() => { + const selectedBackgroundOption = getInitialBackgroundFilter(publisher); + setBackgroundSelected(selectedBackgroundOption); + return selectedBackgroundOption; + }, [publisher, setBackgroundSelected]); + + // Reset background when closing the panel + useEffect(() => { + if (isOpen) { + const currentOption = setInitialBackgroundReplacement(); + changeBackgroundPreview(currentOption); + } + }, [publisher, isOpen, changeBackgroundPreview, setInitialBackgroundReplacement]); + + const buttonGroup = ( + + + + + ); + + return ( + isOpen && ( + <> + + Background Effects + + + + + + {!isTabletViewport && buttonGroup} + + + + + Choose Background Effect + + + + + {/* TODO: load custom images */} + + + + {isTabletViewport && buttonGroup} + + + ) + ); +}; + +export default BackgroundEffectsLayout; diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/Index.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/Index.tsx new file mode 100644 index 00000000..cdeb11f9 --- /dev/null +++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/Index.tsx @@ -0,0 +1,3 @@ +import BackgroundEffectsLayout from './BackgroundEffectsLayout'; + +export default BackgroundEffectsLayout; diff --git a/frontend/src/components/WaitingRoom/BlurButton/BlurButton.spec.tsx b/frontend/src/components/WaitingRoom/BlurButton/BlurButton.spec.tsx deleted file mode 100644 index a35c9298..00000000 --- a/frontend/src/components/WaitingRoom/BlurButton/BlurButton.spec.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { hasMediaProcessorSupport } from '@vonage/client-sdk-video'; -import { PreviewPublisherContextType } from '../../../Context/PreviewPublisherProvider'; -import usePreviewPublisherContext from '../../../hooks/usePreviewPublisherContext'; -import BlurButton from './BlurButton'; - -vi.mock('../../../hooks/usePreviewPublisherContext'); - -vi.mock('@vonage/client-sdk-video', () => ({ - hasMediaProcessorSupport: vi.fn(), -})); -const mockHasMediaProcessorSupport = hasMediaProcessorSupport as Mock; - -const mockUsePreviewPublisherContext = usePreviewPublisherContext as unknown as Mock< - [], - PreviewPublisherContextType ->; - -const mockToggleBlur = vi.fn(); - -describe('BlurButton component', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockUsePreviewPublisherContext.mockReturnValue({ - hasBlur: false, - toggleBlur: mockToggleBlur, - } as unknown as PreviewPublisherContextType); - }); - - it('does not render if media processor is not supported', () => { - mockHasMediaProcessorSupport.mockReturnValue(false); - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it('renders BlurIcon when blur is off', () => { - mockHasMediaProcessorSupport.mockReturnValue(true); - render(); - expect(screen.getByLabelText(/toggle background blur/i)).toBeInTheDocument(); - expect(screen.getByTestId('blurIcon')).toBeInTheDocument(); - }); - - it('renders BlurOff icon when blur is on', () => { - mockHasMediaProcessorSupport.mockReturnValue(true); - mockUsePreviewPublisherContext.mockReturnValue({ - hasBlur: true, - } as unknown as PreviewPublisherContextType); - render(); - expect(screen.getByLabelText(/toggle background blur/i)).toBeInTheDocument(); - expect(screen.getByTestId('BlurOffIcon')).toBeInTheDocument(); - }); - - it('calls toggleBlur when button is clicked', async () => { - mockHasMediaProcessorSupport.mockReturnValue(true); - render(); - const button = screen.getByTestId('video-container-button'); - expect(button).toBeInTheDocument(); - await button.click(); - expect(mockToggleBlur).toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/components/WaitingRoom/BlurButton/index.tsx b/frontend/src/components/WaitingRoom/BlurButton/index.tsx deleted file mode 100644 index 4ad8d253..00000000 --- a/frontend/src/components/WaitingRoom/BlurButton/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import BlurButton from './BlurButton'; - -export default BlurButton; diff --git a/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.tsx b/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.tsx index 9584fc8a..70744464 100644 --- a/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.tsx +++ b/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.tsx @@ -2,7 +2,6 @@ import { useRef, useState, useEffect, ReactElement } from 'react'; import { Stack } from '@mui/material'; import MicButton from '../MicButton'; import CameraButton from '../CameraButton'; -import BlurButton from '../BlurButton'; import VideoLoading from '../VideoLoading'; import waitUntilPlaying from '../../../utils/waitUntilPlaying'; import useUserContext from '../../../hooks/useUserContext'; @@ -12,6 +11,8 @@ import PreviewAvatar from '../PreviewAvatar'; import VoiceIndicatorIcon from '../../MeetingRoom/VoiceIndicator/VoiceIndicator'; import VignetteEffect from '../VignetteEffect'; import useIsSmallViewport from '../../../hooks/useIsSmallViewport'; +import BackgroundEffectsDialog from '../BackgroundEffects/BackgroundEffectsDialog'; +import BackgroundEffectsButton from '../BackgroundEffects/BackgroundEffectsButton'; export type VideoContainerProps = { username: string; @@ -21,14 +22,15 @@ export type VideoContainerProps = { * VideoContainer Component * * Loads and displays the preview publisher, a representation of what participants would see in the meeting room. - * Overlaid onto the preview publisher are the audio input toggle button, video input toggle button, and the background blur toggle button (if supported). + * Overlaid onto the preview publisher are the audio input toggle button, video input toggle button, and the background replacement button (if supported). * @param {VideoContainerProps} props - The props for the component. * @property {string} username - The user's username. * @returns {ReactElement} - The VideoContainer component. */ const VideoContainer = ({ username }: VideoContainerProps): ReactElement => { const containerRef = useRef(null); - const [videoLoading, setVideoLoading] = useState(true); + const [isVideoLoading, setIsVideoLoading] = useState(true); + const [isBackgroundEffectsOpen, setIsBackgroundEffectsOpen] = useState(false); const { user } = useUserContext(); const { publisherVideoElement, isVideoEnabled, isAudioEnabled, speechLevel } = usePreviewPublisherContext(); @@ -53,7 +55,7 @@ const VideoContainer = ({ username }: VideoContainerProps): ReactElement => { '0 1px 2px 0 rgba(60, 64, 67, .3), 0 1px 3px 1px rgba(60, 64, 67, .15)'; waitUntilPlaying(publisherVideoElement).then(() => { - setVideoLoading(false); + setIsVideoLoading(false); }); } }, [isSmallViewport, publisherVideoElement]); @@ -65,16 +67,20 @@ const VideoContainer = ({ username }: VideoContainerProps): ReactElement => { // see https://stackoverflow.com/questions/77748631/element-rounded-corners-leaking-out-to-front-when-using-overflow-hidden style={{ WebkitMask: 'linear-gradient(#000 0 0)' }} > -
+
- {videoLoading && } + {isVideoLoading && } - {!videoLoading && ( + {!isVideoLoading && (
{isAudioEnabled && (
@@ -86,7 +92,11 @@ const VideoContainer = ({ username }: VideoContainerProps): ReactElement => {
- + setIsBackgroundEffectsOpen(true)} /> +
)} diff --git a/frontend/src/css/App.css b/frontend/src/css/App.css index cf7fa6b7..4526b27b 100644 --- a/frontend/src/css/App.css +++ b/frontend/src/css/App.css @@ -69,3 +69,27 @@ #hidden-participants:hover .MuiAvatarGroup-root .MuiAvatar-root { border-color: rgb(76, 80, 82); } + +.choose-background-effect-box { + border: 1px solid #e0e0e0; + border-radius: 12px; + padding: 1rem; + padding-bottom: 0.5rem; + background-color: #f0f4f9; +} + +.background-video-container-disabled { + display: flex; + align-items: center; + justify-content: center; + max-height: 450px; + background: #f5f5f5; + border-radius: 12px; + box-shadow: + 0 1px 2px 0 rgba(60, 64, 67, 0.3), + 0 1px 3px 1px rgba(60, 64, 67, 0.15); + color: #888; + font-size: 1.25rem; + margin: 0 auto 16px auto; + aspect-ratio: 16 / 9; +} diff --git a/frontend/src/hooks/useBackgroundPublisherContext.tsx b/frontend/src/hooks/useBackgroundPublisherContext.tsx new file mode 100644 index 00000000..151cb737 --- /dev/null +++ b/frontend/src/hooks/useBackgroundPublisherContext.tsx @@ -0,0 +1,16 @@ +import { useContext } from 'react'; +import { + BackgroundPublisherContext, + BackgroundPublisherContextType, +} from '../Context/BackgroundPublisherProvider'; + +/** + * React hook to access the background replacement publisher context containing selected publisher options. + * @returns {BackgroundPublisherContextType} - The current context value for the Background replacement Publisher Context. + */ +const useBackgroundPublisherContext = (): BackgroundPublisherContextType => { + const context = useContext(BackgroundPublisherContext); + return context; +}; + +export default useBackgroundPublisherContext; diff --git a/frontend/src/hooks/useIsSmallViewport.tsx b/frontend/src/hooks/useIsSmallViewport.tsx index ddc4285c..52ce4a33 100644 --- a/frontend/src/hooks/useIsSmallViewport.tsx +++ b/frontend/src/hooks/useIsSmallViewport.tsx @@ -7,6 +7,8 @@ import { SMALL_VIEWPORT } from '../utils/constants'; * A custom hook that checks if the viewport width is less than or equal to a defined small viewport width. * @returns {boolean} True if the viewport is small, false otherwise. */ -export default function useIsSmallViewport(): boolean { +const useIsSmallViewport = (): boolean => { return useMediaQuery(`(max-width:${SMALL_VIEWPORT}px)`); -} +}; + +export default useIsSmallViewport; diff --git a/frontend/src/hooks/useRightPanel.tsx b/frontend/src/hooks/useRightPanel.tsx index 039923ef..9a6646af 100644 --- a/frontend/src/hooks/useRightPanel.tsx +++ b/frontend/src/hooks/useRightPanel.tsx @@ -1,6 +1,11 @@ import { useCallback, useState } from 'react'; -export type RightPanelActiveTab = 'chat' | 'participant-list' | 'closed' | 'issues'; +export type RightPanelActiveTab = + | 'chat' + | 'participant-list' + | 'background-effects' + | 'closed' + | 'issues'; export type RightPanelState = { activeTab: RightPanelActiveTab; @@ -13,6 +18,7 @@ export type UseRightPanel = { rightPanelState: RightPanelState; toggleChat: () => void; toggleParticipantList: () => void; + toggleBackgroundEffects: () => void; toggleReportIssue: () => void; }; @@ -27,7 +33,7 @@ const useRightPanel = (): UseRightPanel => { }); /** - * toggleChat - util to toggle participant list visibility, + * toggleParticipantList - util to toggle participant list visibility. */ const toggleParticipantList = useCallback(() => { setRightPanelState((prev) => { @@ -38,6 +44,18 @@ const useRightPanel = (): UseRightPanel => { }); }, []); + /** + * toggleBackgroundEffects - util to toggle background effects visibility. + */ + const toggleBackgroundEffects = useCallback(() => { + setRightPanelState((prev) => { + if (prev.activeTab === 'background-effects') { + return { ...prev, activeTab: 'closed' }; + } + return { ...prev, activeTab: 'background-effects' }; + }); + }, []); + /** * closeRightPanel - util to close right panel. */ @@ -50,7 +68,7 @@ const useRightPanel = (): UseRightPanel => { /** * toggleChat - util to toggle chat visibility, - * it also resets unread message counter, + * it also resets unread message counter. */ const toggleChat = useCallback(() => { setRightPanelState((prev) => { @@ -97,6 +115,7 @@ const useRightPanel = (): UseRightPanel => { rightPanelState, toggleChat, toggleParticipantList, + toggleBackgroundEffects, incrementUnreadCount, toggleReportIssue, }; diff --git a/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx b/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx index 09579a0c..674e3c6c 100644 --- a/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx +++ b/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx @@ -29,6 +29,15 @@ import usePublisherOptions from '../../Context/PublisherProvider/usePublisherOpt const mockedNavigate = vi.fn(); const mockedParams = { roomName: 'test-room-name' }; const mockedLocation = vi.fn(); +vi.mock('../../hooks/useBackgroundPublisherContext', () => ({ + __esModule: true, + default: () => ({ + initBackgroundLocalPublisher: vi.fn(), + destroyBackgroundLocalPublisher: vi.fn(), + backgroundPublisher: null, + accessStatus: undefined, + }), +})); vi.mock('react-router-dom', async () => { const mod = await vi.importActual('react-router-dom'); return { @@ -100,6 +109,7 @@ const createSubscriberWrapper = (id: string): SubscriberWrapper => { videoWidth: () => 1280, videoHeight: () => 720, subscribeToVideo: () => {}, + getVideoFilter: vi.fn(() => undefined), stream: { streamId: id, }, @@ -126,6 +136,7 @@ describe('MeetingRoom', () => { getAudioSource: () => defaultAudioDevice, videoWidth: () => 1280, videoHeight: () => 720, + getVideoFilter: vi.fn(() => undefined), }) as unknown as Publisher; publisherContext = { publisher: null, diff --git a/frontend/src/pages/MeetingRoom/MeetingRoom.tsx b/frontend/src/pages/MeetingRoom/MeetingRoom.tsx index 2b3f50c0..b23da15d 100644 --- a/frontend/src/pages/MeetingRoom/MeetingRoom.tsx +++ b/frontend/src/pages/MeetingRoom/MeetingRoom.tsx @@ -15,6 +15,8 @@ import usePublisherOptions from '../../Context/PublisherProvider/usePublisherOpt import CaptionsBox from '../../components/MeetingRoom/CaptionsButton/CaptionsBox'; import useIsSmallViewport from '../../hooks/useIsSmallViewport'; import CaptionsError from '../../components/MeetingRoom/CaptionsError'; +import useBackgroundPublisherContext from '../../hooks/useBackgroundPublisherContext'; +import { DEVICE_ACCESS_STATUS } from '../../utils/constants'; const height = '@apply h-[calc(100dvh_-_80px)]'; @@ -31,6 +33,14 @@ const MeetingRoom = (): ReactElement => { const roomName = useRoomName(); const { publisher, publish, quality, initializeLocalPublisher, publishingError, isVideoEnabled } = usePublisherContext(); + + const { + initBackgroundLocalPublisher, + publisher: backgroundPublisher, + accessStatus, + destroyBackgroundPublisher, + } = useBackgroundPublisherContext(); + const { joinRoom, subscriberWrappers, @@ -40,6 +50,7 @@ const MeetingRoom = (): ReactElement => { rightPanelActiveTab, toggleChat, toggleParticipantList, + toggleBackgroundEffects, closeRightPanel, toggleReportIssue, } = useSessionContext(); @@ -84,6 +95,26 @@ const MeetingRoom = (): ReactElement => { } }, [publisher, publish, connected]); + useEffect(() => { + if (!backgroundPublisher) { + initBackgroundLocalPublisher(); + } + + return () => { + // Ensure we destroy the backgroundPublisher and release any media devices. + if (backgroundPublisher) { + destroyBackgroundPublisher(); + } + }; + }, [initBackgroundLocalPublisher, backgroundPublisher, destroyBackgroundPublisher]); + + // After changing device permissions, reload the page to reflect the device's permission change. + useEffect(() => { + if (accessStatus === DEVICE_ACCESS_STATUS.ACCESS_CHANGED) { + window.location.reload(); + } + }, [accessStatus]); + // If the user is unable to publish, we redirect them to the goodbye page. // This prevents users from subscribing to other participants in the room, and being unable to communicate with them. useEffect(() => { @@ -123,6 +154,7 @@ const MeetingRoom = (): ReactElement => { toggleShareScreen={toggleShareScreen} rightPanelActiveTab={rightPanelActiveTab} toggleParticipantList={toggleParticipantList} + toggleBackgroundEffects={toggleBackgroundEffects} toggleChat={toggleChat} toggleReportIssue={toggleReportIssue} participantCount={ diff --git a/frontend/src/pages/WaitingRoom/WaitingRoom.spec.tsx b/frontend/src/pages/WaitingRoom/WaitingRoom.spec.tsx index fdfbaf0d..b7a67079 100644 --- a/frontend/src/pages/WaitingRoom/WaitingRoom.spec.tsx +++ b/frontend/src/pages/WaitingRoom/WaitingRoom.spec.tsx @@ -15,9 +15,11 @@ import useDevices from '../../hooks/useDevices'; import { AllMediaDevices } from '../../types'; import { allMediaDevices, defaultAudioDevice } from '../../utils/mockData/device'; import usePreviewPublisherContext from '../../hooks/usePreviewPublisherContext'; +import useBackgroundPublisherContext from '../../hooks/useBackgroundPublisherContext'; import usePermissions, { PermissionsHookType } from '../../hooks/usePermissions'; import { DEVICE_ACCESS_STATUS } from '../../utils/constants'; import waitUntilPlaying from '../../utils/waitUntilPlaying'; +import { BackgroundPublisherContextType } from '../../Context/BackgroundPublisherProvider'; const mockedNavigate = vi.fn(); const mockedParams = { roomName: 'test-room-name' }; @@ -44,6 +46,7 @@ const WaitingRoomWithProviders = () => ( vi.mock('../../hooks/useDevices.tsx'); vi.mock('../../hooks/useUserContext.tsx'); vi.mock('../../hooks/usePreviewPublisherContext.tsx'); +vi.mock('../../hooks/useBackgroundPublisherContext.tsx'); vi.mock('../../hooks/usePermissions.tsx'); vi.mock('../../utils/waitUntilPlaying/waitUntilPlaying.ts'); @@ -65,6 +68,10 @@ const mockUsePreviewPublisherContext = usePreviewPublisherContext as Mock< [], PreviewPublisherContextType >; +const mockUseBackgroundPublisherContext = useBackgroundPublisherContext as Mock< + [], + BackgroundPublisherContextType +>; const mockUsePermissions = usePermissions as Mock<[], PermissionsHookType>; const mockWaitUntilPlaying = vi.mocked(waitUntilPlaying); const reloadSpy = vi.fn(); @@ -72,6 +79,7 @@ const reloadSpy = vi.fn(); describe('WaitingRoom', () => { const nativeWindowLocation = window.location as string & Location; let previewPublisherContext: PreviewPublisherContextType; + let backgroundPublisherContext: BackgroundPublisherContextType; let mockPublisher: Publisher; let mockPublisherVideoElement: HTMLVideoElement; @@ -96,6 +104,12 @@ describe('WaitingRoom', () => { destroyPublisher: mockedDestroyPublisher, } as unknown as PreviewPublisherContextType; mockUsePreviewPublisherContext.mockImplementation(() => previewPublisherContext); + backgroundPublisherContext = { + publisher: null, + initBackgroundLocalPublisher: vi.fn(), + destroyBackgroundPublisher: mockedDestroyPublisher, + } as unknown as BackgroundPublisherContextType; + mockUseBackgroundPublisherContext.mockImplementation(() => backgroundPublisherContext); mockUsePermissions.mockReturnValue({ accessStatus: DEVICE_ACCESS_STATUS.ACCEPTED, setAccessStatus: vi.fn(), diff --git a/frontend/src/pages/WaitingRoom/WaitingRoom.tsx b/frontend/src/pages/WaitingRoom/WaitingRoom.tsx index c2ed0c4d..4e4b2a2c 100644 --- a/frontend/src/pages/WaitingRoom/WaitingRoom.tsx +++ b/frontend/src/pages/WaitingRoom/WaitingRoom.tsx @@ -8,6 +8,7 @@ import DeviceAccessAlert from '../../components/DeviceAccessAlert'; import Banner from '../../components/Banner'; import { getStorageItem, STORAGE_KEYS } from '../../utils/storage'; import useIsSmallViewport from '../../hooks/useIsSmallViewport'; +import useBackgroundPublisherContext from '../../hooks/useBackgroundPublisherContext'; /** * WaitingRoom Component @@ -17,7 +18,7 @@ import useIsSmallViewport from '../../hooks/useIsSmallViewport'; * - A video element showing the user how they'll appear upon joining a room containing controls to: * - Mute their audio input device. * - Disable their video input device. - * - Toggle on/off background blur (if supported). + * - Button to configure background replacement (if supported). * - Audio input, audio output, and video input device selectors. * - A username input field. * - The meeting room name and a button to join the room. @@ -26,6 +27,13 @@ import useIsSmallViewport from '../../hooks/useIsSmallViewport'; const WaitingRoom = (): ReactElement => { const { initLocalPublisher, publisher, accessStatus, destroyPublisher } = usePreviewPublisherContext(); + + const { + initBackgroundLocalPublisher, + publisher: backgroundPublisher, + destroyBackgroundPublisher, + } = useBackgroundPublisherContext(); + const [anchorEl, setAnchorEl] = useState(null); const [openAudioInput, setOpenAudioInput] = useState(false); const [openVideoInput, setOpenVideoInput] = useState(false); @@ -46,6 +54,19 @@ const WaitingRoom = (): ReactElement => { }; }, [initLocalPublisher, publisher, destroyPublisher]); + useEffect(() => { + if (!backgroundPublisher) { + initBackgroundLocalPublisher(); + } + + return () => { + // Ensure we destroy the backgroundPublisher and release any media devices. + if (backgroundPublisher) { + destroyBackgroundPublisher(); + } + }; + }, [initBackgroundLocalPublisher, backgroundPublisher, destroyBackgroundPublisher]); + // After changing device permissions, reload the page to reflect the device's permission change. useEffect(() => { if (accessStatus === DEVICE_ACCESS_STATUS.ACCESS_CHANGED) { diff --git a/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.spec.ts b/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.spec.ts new file mode 100644 index 00000000..c09ddc05 --- /dev/null +++ b/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.spec.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { EventEmitter } from 'stream'; +import { Publisher, hasMediaProcessorSupport } from '@vonage/client-sdk-video'; +import { BACKGROUNDS_PATH } from '../../constants'; +import { STORAGE_KEYS, setStorageItem } from '../../storage'; +import applyBackgroundFilter from './applyBackgroundFilter'; +import { defaultAudioDevice, defaultVideoDevice } from '../../mockData/device'; + +vi.mock('@vonage/client-sdk-video'); +vi.mock('../../storage', () => ({ + setStorageItem: vi.fn(), + STORAGE_KEYS: { BACKGROUND_REPLACEMENT: 'background_replacement' }, +})); + +describe('applyBackgroundFilter', () => { + const mockPublisher = Object.assign(new EventEmitter(), { + getAudioSource: () => defaultAudioDevice, + getVideoSource: () => defaultVideoDevice, + applyVideoFilter: vi.fn(), + clearVideoFilter: vi.fn(), + }) as unknown as Publisher; + beforeEach(() => { + vi.resetAllMocks(); + (hasMediaProcessorSupport as Mock).mockImplementation(vi.fn().mockReturnValue(true)); + }); + + it('does nothing if publisher is not provided', async () => { + await applyBackgroundFilter({ publisher: null, backgroundSelected: 'low-blur' }); + expect(setStorageItem).not.toHaveBeenCalled(); + }); + + it('applies low blur filter', async () => { + await applyBackgroundFilter({ publisher: mockPublisher, backgroundSelected: 'low-blur' }); + + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundBlur', + blurStrength: 'low', + }); + + expect(setStorageItem).toHaveBeenCalledWith( + STORAGE_KEYS.BACKGROUND_REPLACEMENT, + JSON.stringify({ + type: 'backgroundBlur', + blurStrength: 'low', + }) + ); + }); + + it('applies high blur filter', async () => { + await applyBackgroundFilter({ publisher: mockPublisher, backgroundSelected: 'high-blur' }); + + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundBlur', + blurStrength: 'high', + }); + + expect(setStorageItem).toHaveBeenCalledWith( + STORAGE_KEYS.BACKGROUND_REPLACEMENT, + JSON.stringify({ + type: 'backgroundBlur', + blurStrength: 'high', + }) + ); + }); + + it('applies background replacement filter with an image', async () => { + const filename = 'background.jpg'; + await applyBackgroundFilter({ publisher: mockPublisher, backgroundSelected: filename }); + + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundReplacement', + backgroundImgUrl: `${BACKGROUNDS_PATH}/${filename}`, + }); + + expect(setStorageItem).toHaveBeenCalledWith( + STORAGE_KEYS.BACKGROUND_REPLACEMENT, + JSON.stringify({ + type: 'backgroundReplacement', + backgroundImgUrl: `${BACKGROUNDS_PATH}/${filename}`, + }) + ); + }); + + it('clears the filter if backgroundSelected does not match any filter', async () => { + await applyBackgroundFilter({ publisher: mockPublisher, backgroundSelected: 'none' }); + + expect(mockPublisher.clearVideoFilter).toHaveBeenCalled(); + + expect(setStorageItem).toHaveBeenCalledWith( + STORAGE_KEYS.BACKGROUND_REPLACEMENT, + JSON.stringify('') + ); + }); + + it('calls setBackgroundFilter with the applied filter', async () => { + const setBackgroundFilter = vi.fn(); + + await applyBackgroundFilter({ + publisher: mockPublisher, + backgroundSelected: 'low-blur', + setUser: undefined, + setBackgroundFilter, + }); + + expect(setBackgroundFilter).toHaveBeenCalledWith({ + type: 'backgroundBlur', + blurStrength: 'low', + }); + }); + + it('calls setUser with the applied filter when storeItem is true', async () => { + const setUser = vi.fn(); + + await applyBackgroundFilter({ + publisher: mockPublisher, + backgroundSelected: 'low-blur', + setUser, + }); + + expect(setUser).toHaveBeenCalled(); + const updater = setUser.mock.calls[0][0]; + const prevUser = { defaultSettings: {} }; + const newUser = updater(prevUser); + expect(newUser.defaultSettings.backgroundFilter).toEqual({ + type: 'backgroundBlur', + blurStrength: 'low', + }); + }); +}); diff --git a/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.ts b/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.ts new file mode 100644 index 00000000..8437602d --- /dev/null +++ b/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.ts @@ -0,0 +1,75 @@ +import { hasMediaProcessorSupport, Publisher, VideoFilter } from '@vonage/client-sdk-video'; +import { UserType } from '../../../Context/user'; +import { BACKGROUNDS_PATH } from '../../constants'; +import { setStorageItem, STORAGE_KEYS } from '../../storage'; + +export type ApplyBackgroundFilterParams = { + publisher?: Publisher | null; + backgroundSelected: string; + setUser?: (fn: (prev: UserType) => UserType) => void; + setBackgroundFilter?: (filter: VideoFilter | undefined) => void; + storeItem?: boolean; +}; + +/** + * Applies a background filter to the publisher. + * @param {ApplyBackgroundFilterParams} props - The props for the component. + * @property {Publisher} publisher - The Vonage Publisher instance. + * @property {string} backgroundSelected - The selected background option. + * @property {Function} setUser - Optional function to update user state. + * @property {Function} setBackgroundFilter - Optional function to set background filter state. + * @property {boolean} storeItem - Optional flag to determine if the filter should be stored. + * @returns {Promise} - A promise that resolves when the filter is applied or cleared. + * @throws {Error} - Throws an error if the publisher is not provided or if the backgroundSelected is invalid. + */ +const applyBackgroundFilter = async ({ + publisher, + backgroundSelected, + setUser, + setBackgroundFilter, + storeItem = true, +}: ApplyBackgroundFilterParams): Promise => { + if (!publisher) { + return; + } + if (!hasMediaProcessorSupport()) { + console.error('Media Processor is not supported in this environment.'); + return; + } + + let videoFilter: VideoFilter | undefined; + if (backgroundSelected === 'low-blur' || backgroundSelected === 'high-blur') { + videoFilter = { + type: 'backgroundBlur', + blurStrength: backgroundSelected === 'low-blur' ? 'low' : 'high', + }; + await publisher.applyVideoFilter(videoFilter); + } else if (/\.(jpg|jpeg|png|gif|bmp)$/i.test(backgroundSelected)) { + videoFilter = { + type: 'backgroundReplacement', + backgroundImgUrl: `${BACKGROUNDS_PATH}/${backgroundSelected}`, + }; + await publisher.applyVideoFilter(videoFilter); + } else { + await publisher.clearVideoFilter(); + videoFilter = undefined; + } + + if (storeItem) { + setStorageItem(STORAGE_KEYS.BACKGROUND_REPLACEMENT, JSON.stringify(videoFilter ?? '')); + } + if (setBackgroundFilter) { + setBackgroundFilter(videoFilter); + } + if (setUser) { + setUser((prevUser: UserType) => ({ + ...prevUser, + defaultSettings: { + ...prevUser.defaultSettings, + backgroundFilter: videoFilter, + }, + })); + } +}; + +export default applyBackgroundFilter; diff --git a/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.spec.ts b/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.spec.ts new file mode 100644 index 00000000..9bff2d68 --- /dev/null +++ b/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.spec.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { Publisher } from '@vonage/client-sdk-video'; +import getInitialBackgroundFilter from './getInitialBackgroundFilter'; + +describe('getInitialBackgroundFilter', () => { + it('returns "low-blur" if filter is backgroundBlur with low strength', () => { + const mockPublisher = { + getVideoFilter: () => ({ + type: 'backgroundBlur', + blurStrength: 'low', + }), + } as unknown as Publisher; + expect(getInitialBackgroundFilter(mockPublisher)).toBe('low-blur'); + }); + + it('returns "high-blur" if filter is backgroundBlur with high strength', () => { + const mockPublisher = { + getVideoFilter: () => ({ + type: 'backgroundBlur', + blurStrength: 'high', + }), + } as unknown as Publisher; + expect(getInitialBackgroundFilter(mockPublisher)).toBe('high-blur'); + }); + + it('returns image filename if filter is backgroundReplacement', () => { + const mockPublisher = { + getVideoFilter: () => ({ + type: 'backgroundReplacement', + backgroundImgUrl: '/some/path/background1.jpg', + }), + } as unknown as Publisher; + expect(getInitialBackgroundFilter(mockPublisher)).toBe('background1.jpg'); + }); + + it('returns "none" if filter is backgroundReplacement but no filename', () => { + const mockPublisher = { + getVideoFilter: () => ({ + type: 'backgroundReplacement', + backgroundImgUrl: '', + }), + } as unknown as Publisher; + expect(getInitialBackgroundFilter(mockPublisher)).toBe('none'); + }); + + it('returns "none" if filter is not set', () => { + const mockPublisher = { + getVideoFilter: () => undefined, + } as unknown as Publisher; + expect(getInitialBackgroundFilter(mockPublisher)).toBe('none'); + }); + + it('returns "none" if publisher is not provided', () => { + expect(getInitialBackgroundFilter(undefined)).toBe('none'); + expect(getInitialBackgroundFilter(null)).toBe('none'); + }); + + it('returns "none" if filter type is unknown', () => { + const mockPublisher = { + getVideoFilter: () => ({ + type: 'otherType', + }), + } as unknown as Publisher; + expect(getInitialBackgroundFilter(mockPublisher)).toBe('none'); + }); +}); diff --git a/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.ts b/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.ts new file mode 100644 index 00000000..7f0ec81b --- /dev/null +++ b/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.ts @@ -0,0 +1,27 @@ +import { Publisher } from '@vonage/client-sdk-video'; + +/** + * Returns the initial background replacement setting based on the publisher's video filter. + * @param {Publisher} publisher - The Vonage Publisher instance. + * @returns {string} - The initial background replacement setting. + * Possible values are 'none', 'low-blur', 'high-blur', or the filename of a background image. + * If no valid background is set, it returns 'none'. + * @throws {Error} - Throws an error if the publisher is not provided. + */ +const getInitialBackgroundFilter = (publisher?: Publisher | null): string => { + const filter = publisher?.getVideoFilter?.(); + if (filter?.type === 'backgroundBlur') { + if (filter.blurStrength === 'low') { + return 'low-blur'; + } + if (filter.blurStrength === 'high') { + return 'high-blur'; + } + } + if (filter?.type === 'backgroundReplacement') { + return filter.backgroundImgUrl?.split('/').pop() || 'none'; + } + return 'none'; +}; + +export default getInitialBackgroundFilter; diff --git a/frontend/src/utils/constants.tsx b/frontend/src/utils/constants.tsx index f33f8c55..6fb310d5 100644 --- a/frontend/src/utils/constants.tsx +++ b/frontend/src/utils/constants.tsx @@ -131,3 +131,14 @@ export const CAPTION_ERROR_DISPLAY_DURATION_MS = 4000; * Typically used as the max-width breakpoint for responsive layouts. */ export const SMALL_VIEWPORT = 768; + +/** + * @constant {string} BACKGROUNDS_PATH - The path to the backgrounds assets directory. + */ +export const BACKGROUNDS_PATH = '/background'; + +/** + * @constant {number} DEFAULT_SELECTABLE_OPTION_WIDTH - The default size (in pixels) for selectable option elements. + * Used to define the width of selectable options in UI components. + */ +export const DEFAULT_SELECTABLE_OPTION_WIDTH = 68; diff --git a/frontend/src/utils/storage.ts b/frontend/src/utils/storage.ts index b4cd8f6d..c6b93305 100644 --- a/frontend/src/utils/storage.ts +++ b/frontend/src/utils/storage.ts @@ -2,7 +2,7 @@ export const STORAGE_KEYS = { AUDIO_SOURCE: 'audioSource', VIDEO_SOURCE: 'videoSource', NOISE_SUPPRESSION: 'noiseSuppression', - BACKGROUND_BLUR: 'backgroundBlur', + BACKGROUND_REPLACEMENT: 'backgroundReplacement', USERNAME: 'username', }; diff --git a/frontend/src/utils/util.spec.tsx b/frontend/src/utils/util.spec.tsx index 20cc006b..372a26df 100644 --- a/frontend/src/utils/util.spec.tsx +++ b/frontend/src/utils/util.spec.tsx @@ -153,3 +153,40 @@ describe('isMobile', () => { }); }); }); + +describe('parseVideoFilter', () => { + it('returns undefined for null input', () => { + expect(util.parseVideoFilter(null)).toBeUndefined(); + }); + + it('returns undefined for empty string', () => { + expect(util.parseVideoFilter('')).toBeUndefined(); + }); + + it('returns undefined for invalid JSON', () => { + expect(util.parseVideoFilter('not a json')).toBeUndefined(); + }); + + it('returns undefined for object with missing type', () => { + const raw = JSON.stringify({ blurStrength: 'low' }); + expect(util.parseVideoFilter(raw)).toBeUndefined(); + }); + + it('returns undefined for object with unknown type', () => { + const raw = JSON.stringify({ type: 'otherType', blurStrength: 'low' }); + expect(util.parseVideoFilter(raw)).toBeUndefined(); + }); + + it('returns VideoFilter for valid backgroundBlur', () => { + const raw = JSON.stringify({ type: 'backgroundBlur', blurStrength: 'low' }); + expect(util.parseVideoFilter(raw)).toEqual({ type: 'backgroundBlur', blurStrength: 'low' }); + }); + + it('returns VideoFilter for valid backgroundReplacement', () => { + const raw = JSON.stringify({ type: 'backgroundReplacement', backgroundImgUrl: '/img/bg.jpg' }); + expect(util.parseVideoFilter(raw)).toEqual({ + type: 'backgroundReplacement', + backgroundImgUrl: '/img/bg.jpg', + }); + }); +}); diff --git a/frontend/src/utils/util.ts b/frontend/src/utils/util.ts index 63f1464f..717bc74e 100644 --- a/frontend/src/utils/util.ts +++ b/frontend/src/utils/util.ts @@ -1,4 +1,4 @@ -import { Device } from '@vonage/client-sdk-video'; +import { VideoFilter, Device } from '@vonage/client-sdk-video'; import { UAParser } from 'ua-parser-js'; /** @@ -62,3 +62,30 @@ export const isMobile = (): boolean => { return device.type === 'mobile'; }; + +/** + * Parses a raw JSON string into a VideoFilter object if valid. + * @param {string | null} raw - The raw JSON string representing a video filter. + * @returns {VideoFilter | undefined} - The parsed VideoFilter object or undefined if invalid. + */ +export const parseVideoFilter = (raw: string | null): VideoFilter | undefined => { + if (!raw) { + return undefined; + } + + try { + const parsed = JSON.parse(raw); + if ( + typeof parsed === 'object' && + parsed !== null && + typeof parsed.type === 'string' && + (parsed.type === 'backgroundBlur' || parsed.type === 'backgroundReplacement') + ) { + return parsed as VideoFilter; + } + + return undefined; + } catch { + return undefined; + } +}; diff --git a/integration-tests/tests/waitingRoom.spec.ts b/integration-tests/tests/waitingRoom.spec.ts index b9adf92b..7bd3c708 100644 --- a/integration-tests/tests/waitingRoom.spec.ts +++ b/integration-tests/tests/waitingRoom.spec.ts @@ -17,7 +17,7 @@ test('The buttons in the meeting room should match those in the waiting room wit await expect(page.getByTestId('PersonIcon')).toHaveCount(0); if (browserName !== 'firefox') { - await expect(page.getByTestId('blurIcon')).toBeVisible(); + await expect(page.getByTestId('portraitIcon')).toBeVisible(); } await page.getByPlaceholder('Enter your name').fill('some-user'); await page.getByRole('button', { name: 'Join' }).click({ force: true }); @@ -29,15 +29,12 @@ test('The buttons in the meeting room should match those in the waiting room wit await expect(page.getByTestId('VideocamIcon')).toBeVisible(); await expect(page.locator('xpath=//div[contains(text(),"S")]')).toHaveCount(0); - // Skipping this step for FF as we don't support BG blur on FF - // Also, skipping this step for mobile viewport as, currently BG blur button is not displayed for mobile view ports. + // Skipping this step for FF as we don't support BG replacement on FF + // Also, skipping this step for mobile viewport as, currently BG replacement button is not displayed for mobile view ports. if (browserName !== 'firefox' && !isMobile) { await page.getByTestId('video-dropdown-button').click(); - await expect(page.getByTestId('blur-text')).toBeVisible(); - - await expect(page.getByTestId('toggle-off-icon')).toBeVisible(); - await expect(page.getByTestId('toggle-on-icon')).not.toBeVisible(); + await expect(page.getByTestId('background-effects-text')).toBeVisible(); } }); @@ -54,8 +51,7 @@ test('The buttons in the meeting room should match those in the waiting room wit await expect(page.getByTestId('PersonIcon')).toBeVisible(); if (browserName !== 'firefox') { - await page.getByTestId('blurIcon').click(); - await expect(page.getByTestId('BlurOffIcon')).toBeVisible(); + await expect(page.getByTestId('portraitIcon')).toBeVisible(); } await page.getByPlaceholder('Enter your name').fill('some user'); await page.getByRole('button', { name: 'Join' }).click({ force: true }); @@ -66,14 +62,11 @@ test('The buttons in the meeting room should match those in the waiting room wit await expect(page.getByTestId('VideocamOffIcon')).toBeVisible(); await expect(page.getByTestId('MicOffToolbar')).toBeVisible(); - // Skipping this step for FF as we don't support BG blur on FF - // Also, skipping this step for mobile viewport as, currently BG blur button is not displayed for mobile view ports. + // Skipping this step for FF as we don't support BG replacement on FF + // Also, skipping this step for mobile viewport as, currently BG replacement button is not displayed for mobile view ports. if (browserName !== 'firefox' && !isMobile) { await page.getByTestId('video-dropdown-button').click(); - await expect(page.getByTestId('blur-text')).toBeVisible(); - - await expect(page.getByTestId('toggle-off-icon')).not.toBeVisible(); - await expect(page.getByTestId('toggle-on-icon')).toBeVisible(); + await expect(page.getByTestId('background-effects-text')).toBeVisible(); } });