fix(audio): ensure correct audio device labels in Firefox

Firefox incorretly displays placeholder audio device labels in the audio
settings/echo test modal when audio is disconnected. This issue arises
due to two quirks:
- Firefox does not support the 'microphone' query from the Permissions
  API, causing a fallback gUM permission check.
- Firefox omits device labels from `enumerateDevices` if no streams
  are active, even if gUM permission is granted. This behavior differs
  from other browsers and causes our `enumerateDevices` handling to
  assume that granted permission implies labels are present. This
  failed since we clear streams before resolving the fallback gUM.

We now run an additional `enumerateDevices` call in `AudioSettings` when
a selected input device is defined. This ensures `enumerateDevices` is
re-run when a new stream is active, adding the correct device labels in
Firefox and improving device listings in all browsers. We've also
enhanced error handling in the enumeration process and fixed a false
positive in `hasMicrophonePermission`.
This commit is contained in:
prlanzarin 2024-08-20 00:30:34 +00:00
parent ddfba135a6
commit b9f66c2a10
2 changed files with 70 additions and 29 deletions

View File

@ -279,6 +279,9 @@ class AudioSettings extends React.Component {
// Only generate input streams if they're going to be used with something
// In this case, the volume meter or local echo test.
if (produceStreams) {
this.setState({
producingStreams: true,
});
this.generateInputStream(deviceId).then((stream) => {
// Extract the deviceId again from the stream to guarantee consistency
// between stream DID vs chosen DID. That's necessary in scenarios where,
@ -301,8 +304,13 @@ class AudioSettings extends React.Component {
this.setState({
inputDeviceId: extractedDeviceId,
stream,
producingStreams: false,
});
// Update the device list after the stream has been generated.
// This is necessary to guarantee the device list is up-to-date, mainly
// in Firefox as it omit labels if no active stream is present (even if
// gUM permission is flagged as granted).
this.updateDeviceList();
}).catch((error) => {
logger.warn({
logCode: 'audiosettings_gum_failed',
@ -313,6 +321,13 @@ class AudioSettings extends React.Component {
},
}, `Audio settings gUM failed: ${error.name}`);
handleGUMFailure(error);
}).finally(() => {
// Component unmounted after gUM resolution -> skip echo rendering
if (!this._isMounted) return;
this.setState({
producingStreams: false,
});
});
} else {
this.setState({
@ -377,6 +392,15 @@ class AudioSettings extends React.Component {
audioInputDevices,
audioOutputDevices,
});
})
.catch((error) => {
logger.warn({
logCode: 'audiosettings_enumerate_devices_error',
extraInfo: {
errorName: error.name,
errorMessage: error.message,
},
}, `Audio settings: error enumerating devices - {${error.name}: ${error.message}}`);
});
}

View File

@ -73,10 +73,45 @@ const useIsUsingAudio = () => {
return Boolean(isConnected || isConnecting || isHangingUp || isEchoTest);
};
/**
* Check if the user has granted permission to use the microphone.
*
* @param {Object} options - Options object.
* @param {string} options.permissionStatus - The current permission status.
* @param {boolean} options.gumOnPrompt - Whether to check microphone permission by attempting to
* get a media stream.
* @returns {Promise<boolean|null>} - A promise that resolves to a boolean indicating whether the
* user has granted permission to use the microphone. If the permission status is unknown, the
* promise resolves to null.
*/
const hasMicrophonePermission = async ({
permissionStatus,
permissionStatus = null,
gumOnPrompt = false,
}) => {
const checkWithGUM = () => {
if (!gumOnPrompt) return Promise.resolve(null);
return doGUM({ audio: getAudioConstraints() })
.then((stream) => {
// Close the stream and remove all tracks - this is just a permission check
stream.getTracks().forEach((track) => {
track.stop();
stream.removeTrack(track);
});
return true;
})
.catch((error) => {
if (error.name === 'NotAllowedError') return false;
// Give it the benefit of the doubt. It might be a device mismatch
// or something else that's not a permissions issue, so let's try
// to proceed. Rollbacks that happen downstream might fix the issue,
// otherwise we'll land on the Help screen anyways
return null;
});
};
try {
let status = permissionStatus;
@ -91,36 +126,19 @@ const hasMicrophonePermission = async ({
switch (status) {
case 'denied':
return false;
case 'prompt':
// Prompt without any subsequent action is considered unknown
if (!gumOnPrompt) {
return null;
}
return doGUM({ audio: getAudioConstraints() }).then((stream) => {
stream.getTracks().forEach((track) => {
track.stop();
stream.removeTrack(track);
});
return true;
}).catch((error) => {
if (error.name === 'NotAllowedError') {
return false;
}
// Give it the benefit of the doubt. It might be a device mismatch
// or something else that's not a permissions issue, so let's try
// to proceed. Rollbacks that happen downstream might fix the issue,
// otherwise we'll land on the Help screen anyways
return null;
});
case 'granted':
default:
return true;
case null:
case 'prompt':
return checkWithGUM();
default:
return null;
}
} catch (error) {
logger.error({
logger.warn({
logCode: 'audio_check_microphone_permission_error',
extraInfo: {
errorName: error.name,
@ -128,8 +146,7 @@ const hasMicrophonePermission = async ({
},
}, `Error checking microphone permission: ${error.message}`);
// Null = could not determine permission status
return null;
return checkWithGUM();
}
};