Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

VIDCS-3273: Fix audio output selection crash in android Waiting Room #23

Merged
merged 14 commits into from
Jan 30, 2025
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
},
"devDependencies": {
"@types/lodash": "^4.17.1",
"@types/react": "^18.0.28",
"@types/react": "^18.3.3",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was me trying to get non-empty react types DefinitelyTyped/DefinitelyTyped#13251 (comment)

"@types/react-dom": "^18.2.1",
"@types/react-router-dom": "^5.3.3",
"@types/uuid": "^9.0.8",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,30 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import { AudioOutputDevice } from '@vonage/client-sdk-video';
import * as OT from '@vonage/client-sdk-video';
import useAudioOutput from './useAudioOutput';
import { nativeDevices } from '../../../utils/mockData/device';

vi.mock('@vonage/client-sdk-video');

describe('useAudioOutput', () => {
const nativeMediaDevices = global.navigator.mediaDevices;
let mockGetActiveAudioOutputDevice: MockInstance<[], Promise<AudioOutputDevice>>;
let mockSetAudioOutputDevice: MockInstance<[deviceId: string], Promise<void>>;

beforeEach(() => {
vi.resetAllMocks();

Object.defineProperty(global.navigator, 'mediaDevices', {
writable: true,
value: {
enumerateDevices: vi.fn(
() =>
new Promise<MediaDeviceInfo[]>((res) => {
res(nativeDevices as MediaDeviceInfo[]);
})
),
addEventListener: vi.fn(() => []),
removeEventListener: vi.fn(() => []),
},
});
mockGetActiveAudioOutputDevice = vi.spyOn(OT, 'getActiveAudioOutputDevice').mockImplementation(
() =>
new Promise<AudioOutputDevice>((res) => {
Expand All @@ -32,42 +46,46 @@ describe('useAudioOutput', () => {

afterAll(() => {
vi.restoreAllMocks();
Object.defineProperty(global.navigator, 'mediaDevices', {
writable: true,
value: nativeMediaDevices,
});
});

it('should provide initial state', () => {
const { result } = renderHook(() => useAudioOutput());

expect(result.current.audioOutput).toBeUndefined();
expect(result.current.setAudioOutput).toBeDefined();
expect(result.current.currentAudioOutputDevice).toBeUndefined();
expect(result.current.setAudioOutputDevice).toBeDefined();
});

it('should call getActiveAudioOutputDevice when initialized', async () => {
const { result } = renderHook(() => useAudioOutput());

await waitFor(() => expect(result.current.audioOutput).toBe('some-device-id'));
await waitFor(() => expect(result.current.currentAudioOutputDevice).toBe('some-device-id'));

expect(mockGetActiveAudioOutputDevice).toHaveBeenCalledOnce();
});

it('should update audioOutput when setAudioOutput is called', async () => {
it('should update currentAudioOutputDevice when setAudioOutputDevice is called', async () => {
const newAudioOutput = 'new-audio-output-device';
const { result, rerender } = renderHook(() => useAudioOutput());

await act(async () => {
await result.current.setAudioOutput(newAudioOutput);
await result.current.setAudioOutputDevice(newAudioOutput);
});

rerender();

expect(result.current.audioOutput).toBe(newAudioOutput);
expect(result.current.currentAudioOutputDevice).toBe(newAudioOutput);
});

it('should call setAudioOutputDevice when audioOutput is called', async () => {
it('should call setAudioOutputDevice when currentAudioOutputDevice is called', async () => {
const newAudioOutput = 'new-audio-output-device';
const { result } = renderHook(() => useAudioOutput());

await act(async () => {
await result.current.setAudioOutput(newAudioOutput);
await result.current.setAudioOutputDevice(newAudioOutput);
});

expect(mockSetAudioOutputDevice).toHaveBeenCalledOnce();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import {
getActiveAudioOutputDevice as getVonageActiveAudioOutputDevice,
setAudioOutputDevice as setVonageAudioOutputDevice,
} from '@vonage/client-sdk-video';
import { useCallback, useEffect, useState } from 'react';
import { getActiveAudioOutputDevice, setAudioOutputDevice } from '@vonage/client-sdk-video';

export type AudioDeviceId = string | null | undefined;

export type AudioOutputContextType = {
audioOutput: string | undefined | null;
setAudioOutput: (deviceId: AudioDeviceId) => Promise<void>;
currentAudioOutputDevice: string | undefined | null;
setAudioOutputDevice: (deviceId: AudioDeviceId) => Promise<void>;
Comment on lines +1 to +11
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some light renaming/refactoring here. It was super confusing to know what updateAudioOutput setAudioOutput , setAudioOutputDevice etc all did and how they differed

};

/**
Expand All @@ -15,26 +18,41 @@ export type AudioOutputContextType = {
* @returns {AudioOutputContextType} audioOutput context
*/
const useAudioOutput = (): AudioOutputContextType => {
const [audioOutput, setAudioOutput] = useState<AudioDeviceId>();
const [currentAudioOutputDevice, setCurrentAudioOutputDevice] = useState<AudioDeviceId>();
const { mediaDevices } = window.navigator;

useEffect(() => {
getActiveAudioOutputDevice().then((audioOutputDevice) => {
const updateCurrentAudioOutputDevice = useCallback(() => {
getVonageActiveAudioOutputDevice().then((audioOutputDevice) => {
if (audioOutputDevice.deviceId) {
setAudioOutput(audioOutputDevice.deviceId);
setCurrentAudioOutputDevice(audioOutputDevice.deviceId);
}
});
}, []);

const updateAudioOutput = useCallback(async (deviceId: AudioDeviceId) => {
useEffect(() => {
updateCurrentAudioOutputDevice();
}, [updateCurrentAudioOutputDevice]);

useEffect(() => {
// Add an event listener to update device list when the list changes (such as plugging or unplugging a device)
mediaDevices.addEventListener('devicechange', updateCurrentAudioOutputDevice);

return () => {
// Remove the event listener when component unmounts
mediaDevices.removeEventListener('devicechange', updateCurrentAudioOutputDevice);
};
}, [mediaDevices, updateCurrentAudioOutputDevice]);
Comment on lines +36 to +44
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this event listener to update the current device when the device list changes. This keeps the UI in sync if the current device was removed


const setAudioOutputDevice = useCallback(async (deviceId: AudioDeviceId) => {
if (deviceId) {
await setAudioOutputDevice(deviceId);
setAudioOutput(deviceId);
await setVonageAudioOutputDevice(deviceId);
setCurrentAudioOutputDevice(deviceId);
}
}, []);

return {
audioOutput,
setAudioOutput: updateAudioOutput,
currentAudioOutputDevice,
setAudioOutputDevice,
};
};

Expand Down
33 changes: 29 additions & 4 deletions frontend/src/Context/tests/RoomContext.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it, Mock, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import RoomContext from '../RoomContext';
import useUserContext from '../../hooks/useUserContext';
import { UserContextType } from '../user';
import useAudioOutputContext from '../../hooks/useAudioOutputContext';
import { AudioOutputContextType } from '../AudioOutputProvider';
import { nativeDevices } from '../../utils/mockData/device';

vi.mock('../../hooks/useUserContext');
vi.mock('../../hooks/useAudioOutputContext');
Expand All @@ -24,13 +25,37 @@ const mockUserContextWithDefaultSettings = {
},
} as UserContextType;
const mockUseAudioOutputContextValues = {
audioOutput: fakeAudioOutput,
currentAudioOutputDevice: fakeAudioOutput,
} as AudioOutputContextType;

mockUseUserContext.mockImplementation(() => mockUserContextWithDefaultSettings);
mockUseAudioOutputContext.mockImplementation(() => mockUseAudioOutputContextValues);

describe('RoomContext', () => {
const nativeMediaDevices = global.navigator.mediaDevices;
beforeEach(() => {
Object.defineProperty(global.navigator, 'mediaDevices', {
writable: true,
value: {
enumerateDevices: vi.fn(
() =>
new Promise<MediaDeviceInfo[]>((res) => {
res(nativeDevices as MediaDeviceInfo[]);
})
),
addEventListener: vi.fn(() => []),
removeEventListener: vi.fn(() => []),
},
});
});

afterEach(() => {
Object.defineProperty(global.navigator, 'mediaDevices', {
writable: true,
value: nativeMediaDevices,
});
});

it('renders content', () => {
const TestComponent = () => <div data-testid="test-component">Test Component</div>;

Expand All @@ -50,12 +75,12 @@ describe('RoomContext', () => {
it('provides context values to child components', () => {
const TestComponent = () => {
const { user } = useUserContext();
const { audioOutput } = useAudioOutputContext();
const { currentAudioOutputDevice } = useAudioOutputContext();

return (
<div>
<div data-testid="user-name">{user.defaultSettings.name}</div>
<div data-testid="audio-output">{audioOutput}</div>
<div data-testid="audio-output">{currentAudioOutputDevice}</div>
</div>
);
};
Expand Down
Loading
Loading