bigbluebutton-Github/bigbluebutton-html5/imports/ui/components/audio/service.js
prlanzarin b9f66c2a10 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`.
2024-08-20 00:43:24 +00:00

207 lines
6.8 KiB
JavaScript
Executable File

import Auth from '/imports/ui/services/auth';
import AudioManager from '/imports/ui/services/audio-manager';
import logger from '/imports/startup/client/logger';
import Storage from '../../services/storage/session';
import { useReactiveVar } from '@apollo/client';
import {
getAudioConstraints,
doGUM,
} from '/imports/api/audio/client/bridge/service';
import {
toggleMuteMicrophone,
toggleMuteMicrophoneSystem,
} from '/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service';
const MUTED_KEY = 'muted';
const recoverMicState = (toggleVoice) => {
const muted = Storage.getItem(MUTED_KEY);
if ((muted === undefined) || (muted === null) || AudioManager.inputDeviceId === 'listen-only') {
return;
}
logger.debug({
logCode: 'audio_recover_mic_state',
}, `Audio recover previous mic state: muted = ${muted}`);
toggleVoice(Auth.userID, muted);
};
const audioEventHandler = (toggleVoice) => (event) => {
if (!event) {
return;
}
switch (event.name) {
case 'started':
if (!event.isListenOnly) recoverMicState(toggleVoice);
break;
default:
break;
}
};
const init = (messages, intl, toggleVoice, speechLocale, voiceConf, username) => {
AudioManager.setAudioMessages(messages, intl);
if (AudioManager.initialized) return Promise.resolve(false);
const meetingId = Auth.meetingID;
const userId = Auth.userID;
const { sessionToken } = Auth;
const voiceBridge = voiceConf;
// FIX ME
const microphoneLockEnforced = false;
const userData = {
meetingId,
userId,
sessionToken,
username,
voiceBridge,
microphoneLockEnforced,
speechLocale,
};
return AudioManager.init(userData, audioEventHandler(toggleVoice));
};
const useIsUsingAudio = () => {
const isConnected = useReactiveVar(AudioManager._isConnected.value);
const isConnecting = useReactiveVar(AudioManager._isConnecting.value);
const isHangingUp = useReactiveVar(AudioManager._isHangingUp.value);
const isEchoTest = useReactiveVar(AudioManager._isEchoTest.value);
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 = 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;
// If the browser doesn't support the Permissions API, we can't check
// microphone permissions - return null (unknown)
if (navigator?.permissions?.query == null) return null;
if (!status) {
({ state: status } = await navigator.permissions.query({ name: 'microphone' }));
}
switch (status) {
case 'denied':
return false;
case 'granted':
return true;
case null:
case 'prompt':
return checkWithGUM();
default:
return null;
}
} catch (error) {
logger.warn({
logCode: 'audio_check_microphone_permission_error',
extraInfo: {
errorName: error.name,
errorMessage: error.message,
},
}, `Error checking microphone permission: ${error.message}`);
return checkWithGUM();
}
};
export default {
init,
exitAudio: () => AudioManager.exitAudio(),
forceExitAudio: () => AudioManager.forceExitAudio(),
transferCall: () => AudioManager.transferCall(),
joinListenOnly: () => AudioManager.joinListenOnly(),
joinMicrophone: (options) => AudioManager.joinMicrophone(options),
joinEchoTest: () => AudioManager.joinEchoTest(),
changeInputDevice: (inputDeviceId) => AudioManager.changeInputDevice(inputDeviceId),
changeInputStream: (newInputStream) => { AudioManager.inputStream = newInputStream; },
liveChangeInputDevice: (inputDeviceId) => AudioManager.liveChangeInputDevice(inputDeviceId),
changeOutputDevice: (
outputDeviceId,
isLive,
) => AudioManager.changeOutputDevice(outputDeviceId, isLive),
toggleMuteMicrophone,
toggleMuteMicrophoneSystem,
isConnectedToBreakout: () => {
const transferStatus = AudioManager.getBreakoutAudioTransferStatus();
if (transferStatus.status
=== AudioManager.BREAKOUT_AUDIO_TRANSFER_STATES.CONNECTED) return true;
return false;
},
isConnected: () => {
const transferStatus = AudioManager.getBreakoutAudioTransferStatus();
if (!!transferStatus.breakoutMeetingId
&& transferStatus.breakoutMeetingId !== Auth.meetingID) return false;
return AudioManager.isConnected;
},
isUsingAudio: () => AudioManager.isUsingAudio(),
isConnecting: () => AudioManager.isConnecting,
isListenOnly: () => AudioManager.isListenOnly,
inputDeviceId: () => AudioManager.inputDeviceId,
outputDeviceId: () => AudioManager.outputDeviceId,
isEchoTest: () => AudioManager.isEchoTest,
isMuted: () => AudioManager.isMuted,
autoplayBlocked: () => AudioManager.autoplayBlocked,
handleAllowAutoplay: () => AudioManager.handleAllowAutoplay(),
playAlertSound: (url) => AudioManager.playAlertSound(url),
updateAudioConstraints: (constraints) => AudioManager.updateAudioConstraints(constraints),
recoverMicState,
isReconnecting: () => AudioManager.isReconnecting,
setBreakoutAudioTransferStatus: (status) => AudioManager
.setBreakoutAudioTransferStatus(status),
getBreakoutAudioTransferStatus: () => AudioManager
.getBreakoutAudioTransferStatus(),
getStats: () => AudioManager.getStats(),
getAudioConstraints,
doGUM,
supportsTransparentListenOnly: () => AudioManager.supportsTransparentListenOnly(),
hasMicrophonePermission,
notify: (message, error, icon) => { AudioManager.notify(message, error, icon); },
useIsUsingAudio,
};