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

Conversation

maikthomas
Copy link
Contributor

What is this PR doing?

  • Fixes crash when selecting audio output device in waiting room on android
  • Change UI to allow audio output UI even when not supported by showing "System Default" and allowing Test Speaker to run

How should this be manually tested?

  • Need to test quite thoroughly on different browsers including android and iOS
  • Test audio output UI in waiting room
  • Test Speaker Test in waiting room
  • Test audio output UI in Meeting Room
  • Test Speaker Test in Meeting Room

On supported devices:

  • set non-default audio output device
  • unplug device
  • open audio settings
  • expect new device (default) to be selected

What are the relevant tickets?

Resolves VIDCS-3273

[ ] Resolves a Known Issue.
[ ] If yes, did you remove the item from the docs/KNOWN_ISSUES.md?
[ ] Resolves an item reported in Issues.
If yes, which issue? Issue Number?
[ ] If yes, did you close the item in Issues?

@maikthomas maikthomas added the WIP Work in progress label Jan 22, 2025
@dwivedisachin dwivedisachin changed the base branch from main to develop January 24, 2025 10:22
@dwivedisachin dwivedisachin changed the title VIDCS-3273: Fix audio output selection crash in android Waiting Room VIDCS-3273: Fix audio output selection crash in android Waiting Room Jan 24, 2025
@dwivedisachin dwivedisachin changed the title VIDCS-3273: Fix audio output selection crash in android Waiting Room VIDCS-3273: Fix audio output selection crash in android Waiting Room Jan 24, 2025
Copy link
Contributor Author

@maikthomas maikthomas left a comment

Choose a reason for hiding this comment

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

🧑‍🍳

"@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)

Comment on lines +1 to +11
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>;
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

Comment on lines 23 to 32
const InputDevices = ({ handleToggle, customLightBlueColor }: InputDevicesProps): ReactElement => {
const { publisher, isPublishing } = usePublisherContext();
const [devicesAvailable, setDevicesAvailable] = useState<Device[] | null>(null);
const { allMediaDevices } = useDevices();
const [options, setOptions] = useState<string[]>([]);
const { publisher } = usePublisherContext();
const {
allMediaDevices: { audioInputDevices },
} = useDevices();

useEffect(() => {
setDevicesAvailable(allMediaDevices.audioInputDevices);
}, [publisher, allMediaDevices, devicesAvailable, isPublishing]);
const changeAudioSource = (deviceId: string) => {
publisher?.setAudioSource(deviceId);
};

useEffect(() => {
if (devicesAvailable) {
const audioDevicesAvailable: string[] = devicesAvailable.map((availableDevice: Device) => {
return availableDevice.label;
});
setOptions(audioDevicesAvailable);
}
}, [devicesAvailable]);
const options = audioInputDevices.map((availableDevice: Device) => {
return availableDevice.label;
});

Copy link
Contributor Author

Choose a reason for hiding this comment

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

These affects and state were not needed and caused multiple renders and chained state effects.
The state comes directly from useDevices, and the state we need can be derived from that state directly, without setting state again. This is how the react docs tells us to do things.

Comment on lines -32 to +40
const { audioOutput, setAudioOutput } = useAudioOutputContext();
const [devicesAvailable, setDevicesAvailable] = useState<AudioOutputDevice[] | null>(null);
const { allMediaDevices } = useDevices();

useEffect(() => {
setDevicesAvailable(allMediaDevices.audioOutputDevices);
}, [allMediaDevices]);
const { currentAudioOutputDevice, setAudioOutputDevice } = useAudioOutputContext();
const {
allMediaDevices: { audioOutputDevices },
} = useDevices();

const changeAudioOutput = async (deviceId: string) => {
await setAudioOutputDevice(deviceId);
setAudioOutput(deviceId);
};
const isAudioOutputSupported = isGetActiveAudioOutputDeviceSupported();

useEffect(() => {
const getActiveAudioOutputDeviceLabel = async () => {
const activeOutputDevice = await getActiveAudioOutputDevice();
setAudioOutput(activeOutputDevice.deviceId);
};
getActiveAudioOutputDeviceLabel();
}, [setAudioOutput]);
const availableDevices = isAudioOutputSupported ? audioOutputDevices : defaultOutputDevices;

const handleChangeAudioOutput = (event: MouseEvent<HTMLLIElement>) => {
const handleChangeAudioOutput = async (event: MouseEvent<HTMLLIElement>) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same as the above file, a lot of this was not needed, made testing hard, and caused multiple render chains before the state was right, when the state could be derived from props/hooks directly.

Comment on lines +36 to +44
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]);
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

@maikthomas maikthomas removed the WIP Work in progress label Jan 27, 2025
Copy link
Contributor

@cpettet cpettet left a comment

Choose a reason for hiding this comment

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

Looks good so far! Just have a few comments/questions. Let me know what you think!

play: playMock,
pause: pauseMock,
currentTime: 9001,
setSinkId: vi.fn(),
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be undefined? The test name doesn't match the mock.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great catch! Luckily when I updated the value the test still passes 😅

const isSelected = device.deviceId === audioOutput;
<MenuList data-testid="output-devices">
{availableDevices?.map((device: AudioOutputDevice) => {
// If audio output device selection is not support we show the default device as selected
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit, typo

Copy link
Contributor

@behei-vonage behei-vonage left a comment

Choose a reason for hiding this comment

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

impressive job on this one! left a few questions, mostly just curious

mockGetAudioOutputDevices,
mockSetAudioOutputDevice,
mockGetActiveAudioOutputDevice,
} = vi.hoisted(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

sorry just curious what's the benefit of using hoisted() here? why don't just mock()?

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 want access to the mock functions (vi.fn()) that I use for the mocked object properties of the SDK, however if I define them inside vi.mock(() => { //here then they won't be accesible outside that scope.
If I just define them in the file then they won't have been declared yet when vi.mock( is called because mock is hoisted to the top of the file above the imports and all declarations.
That's why vitest exposes the vi.hoisted util to allow you to declare things before the vimock()s are called.
See the docs:

The call to vi.mock is hoisted, so it doesn't matter where you call it. It will always be executed before all imports. If you need to reference some variables outside of its scope, you can define them inside vi.hoisted and reference them inside vi.mock.

https://vitest.dev/api/vi#vi-mock

Copy link
Contributor

Choose a reason for hiding this comment

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

thanks for the explanation 🙏

@@ -61,12 +100,22 @@ describe('AudioInputOutputDevice Component', () => {
);

const outputDevicesElement = screen.getByTestId('output-devices');
expect(outputDevicesElement).toBeInTheDocument();
await waitFor(() => expect(outputDevicesElement.children).to.have.length(3));
const firstChild = outputDevicesElement.firstChild as HTMLOptionElement;
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: can firstChild be renamed to something more self-explanatory? something like firstDeviceElement?

Copy link
Contributor

@cpettet cpettet Jan 28, 2025

Choose a reason for hiding this comment

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

firstChild is an inherited property in the DOM. It's unlikely we'll be able to rename that one 😄

Copy link
Contributor

Choose a reason for hiding this comment

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

oh, thanks for the info! we can make consts be whatever we'd like them to be, though 🙂 it's just a nit though so if Mike prefers to keep it as a firstChild I'm okay with that

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've removed this variable and inlined the query.

It's a tricky one because firstDeviceElement or something like that might be more understandable, but this is a test and we are testing the firstChild. Whether that firstChild is a deviceElement depends on if the code is working 🤷


// Check initial list is correct
await waitFor(() => expect(outputDevicesElement.children).to.have.length(3));
const firstChild = outputDevicesElement.firstChild as HTMLOptionElement;
Copy link
Contributor

Choose a reason for hiding this comment

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

same nit here with the Child if you end up changing it 🙏

Copy link
Contributor Author

Choose a reason for hiding this comment

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

removed this variable

Copy link
Contributor Author

@maikthomas maikthomas left a comment

Choose a reason for hiding this comment

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

Responded to comments

@@ -61,12 +100,22 @@ describe('AudioInputOutputDevice Component', () => {
);

const outputDevicesElement = screen.getByTestId('output-devices');
expect(outputDevicesElement).toBeInTheDocument();
await waitFor(() => expect(outputDevicesElement.children).to.have.length(3));
const firstChild = outputDevicesElement.firstChild as HTMLOptionElement;
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've removed this variable and inlined the query.

It's a tricky one because firstDeviceElement or something like that might be more understandable, but this is a test and we are testing the firstChild. Whether that firstChild is a deviceElement depends on if the code is working 🤷


// Check initial list is correct
await waitFor(() => expect(outputDevicesElement.children).to.have.length(3));
const firstChild = outputDevicesElement.firstChild as HTMLOptionElement;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

removed this variable

mockGetAudioOutputDevices,
mockSetAudioOutputDevice,
mockGetActiveAudioOutputDevice,
} = vi.hoisted(() => {
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 want access to the mock functions (vi.fn()) that I use for the mocked object properties of the SDK, however if I define them inside vi.mock(() => { //here then they won't be accesible outside that scope.
If I just define them in the file then they won't have been declared yet when vi.mock( is called because mock is hoisted to the top of the file above the imports and all declarations.
That's why vitest exposes the vi.hoisted util to allow you to declare things before the vimock()s are called.
See the docs:

The call to vi.mock is hoisted, so it doesn't matter where you call it. It will always be executed before all imports. If you need to reference some variables outside of its scope, you can define them inside vi.hoisted and reference them inside vi.mock.

https://vitest.dev/api/vi#vi-mock

play: playMock,
pause: pauseMock,
currentTime: 9001,
setSinkId: vi.fn(),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great catch! Luckily when I updated the value the test still passes 😅

Copy link
Contributor

@cpettet cpettet left a comment

Choose a reason for hiding this comment

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

LGTM Great job! :shipit:

Copy link
Contributor

@behei-vonage behei-vonage left a comment

Choose a reason for hiding this comment

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

LGTM!

@dwivedisachin
Copy link
Collaborator

dwivedisachin commented Jan 30, 2025

Tested on Android device (Samsung S21 FE) LGTM!! 🚀

@v-kpheng v-kpheng merged commit acd7d37 into develop Jan 30, 2025
6 checks passed
@v-kpheng v-kpheng deleted the mthomas/VIDCS-3273-waiting-room-output branch January 30, 2025 18:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants