diff --git a/src/components/bone-mute-state-indicator.js b/src/components/bone-mute-state-indicator.js index 8896ebe61c..c1d8604f43 100644 --- a/src/components/bone-mute-state-indicator.js +++ b/src/components/bone-mute-state-indicator.js @@ -37,7 +37,7 @@ AFRAME.registerComponent("bone-mute-state-indicator", { updateMuteState() { if (!this.modelLoaded) return; - const muted = !APP.mediaDevicesManager.isMicEnabled; + const muted = !APP.dialog.isMicEnabled; this.mutedBone.position.y = muted ? this.data.onPos : this.data.offPos; this.unmutedBone.position.y = !muted ? this.data.onPos : this.data.offPos; this.mutedBone.matrixNeedsUpdate = true; diff --git a/src/components/in-world-hud.js b/src/components/in-world-hud.js index 738bd0e82d..23a24c897f 100644 --- a/src/components/in-world-hud.js +++ b/src/components/in-world-hud.js @@ -35,7 +35,7 @@ AFRAME.registerComponent("in-world-hud", { }; this.onMicClick = () => { - APP.mediaDevicesManager.toggleMic(); + APP.dialog.toggleMicrophone(); }; this.onSpawnClick = () => { diff --git a/src/components/mute-mic.js b/src/components/mute-mic.js index cc69a53da8..a31db5bce4 100644 --- a/src/components/mute-mic.js +++ b/src/components/mute-mic.js @@ -52,16 +52,17 @@ AFRAME.registerComponent("mute-mic", { }, onToggle: function () { - APP.mediaDevicesManager.toggleMic(); + APP.dialog.toggleMicrophone(); + if (!this.el.sceneEl.is("entered")) return; this.el.sceneEl.systems["hubs-systems"].soundEffectsSystem.playSoundOneShot(SOUND_TOGGLE_MIC); }, onMute: function () { - APP.mediaDevicesManager.micEnabled = false; + APP.dialog.enableMicrophone(false); }, onUnmute: function () { - APP.mediaDevicesManager.micEnabled = true; + APP.dialog.enableMicrophone(true); } }); diff --git a/src/hub.js b/src/hub.js index 974cc98aac..26ce4bbe5f 100644 --- a/src/hub.js +++ b/src/hub.js @@ -764,6 +764,11 @@ document.addEventListener("DOMContentLoaded", async () => { const authChannel = new AuthChannel(store); const hubChannel = new HubChannel(store, hubId); window.APP.hubChannel = hubChannel; + hubChannel.addEventListener("permissions_updated", () => { + if (!hubChannel.can("voice_chat")) { + APP.dialog.enableMicrophone(false); + } + }); const entryManager = new SceneEntryManager(hubChannel, authChannel, history); window.APP.entryManager = entryManager; @@ -1397,7 +1402,7 @@ document.addEventListener("DOMContentLoaded", async () => { hubPhxChannel.on("mute", ({ session_id }) => { if (session_id === NAF.clientId) { - APP.mediaDevicesManager.micEnabled = false; + APP.dialog.enableMicrophone(false); } }); diff --git a/src/react-components/preferences-screen.js b/src/react-components/preferences-screen.js index 524d1c3269..33805b4010 100644 --- a/src/react-components/preferences-screen.js +++ b/src/react-components/preferences-screen.js @@ -10,7 +10,12 @@ import styles from "../assets/stylesheets/preferences-screen.scss"; import { AVAILABLE_LOCALES } from "../assets/locales/locale_config"; import { themes } from "../utils/theme"; import MediaDevicesManager from "../utils/media-devices-manager"; -import { MediaDevices, MediaDevicesEvents, PermissionStatus } from "../utils/media-devices-utils"; +import { + DEFAULT_MEDIA_DEVICE_OPTION, + MediaDevices, + MediaDevicesEvents, + PermissionStatus +} from "../utils/media-devices-utils"; import { Slider } from "./input/Slider"; import { addOrientationChangeListener, @@ -877,25 +882,30 @@ class PreferencesScreen extends Component { this.mediaDevicesManager = APP.mediaDevicesManager; + const defaultMediaDeviceOption = { + value: DEFAULT_MEDIA_DEVICE_OPTION.value, + text: DEFAULT_MEDIA_DEVICE_OPTION.label + }; + this.state = { category: CATEGORY_AUDIO, toastHeight: "150px", preferredMic: { key: "preferredMic", prefType: PREFERENCE_LIST_ITEM_TYPE.SELECT, - options: [{ value: "none", text: "None" }], + options: [defaultMediaDeviceOption], disabled: !canVoiceChat }, preferredCamera: { key: "preferredCamera", prefType: PREFERENCE_LIST_ITEM_TYPE.SELECT, - options: [{ value: "none", text: "None" }] + options: [defaultMediaDeviceOption] }, ...(MediaDevicesManager.isAudioOutputSelectEnabled && { preferredSpeakers: { key: "preferredSpeakers", prefType: PREFERENCE_LIST_ITEM_TYPE.SELECT, - options: [{ value: "none", text: "None" }] + options: [defaultMediaDeviceOption] } }), canVoiceChat diff --git a/src/react-components/room/useMicrophoneStatus.js b/src/react-components/room/useMicrophoneStatus.js index da0d3dc665..ca48521b53 100644 --- a/src/react-components/room/useMicrophoneStatus.js +++ b/src/react-components/room/useMicrophoneStatus.js @@ -3,7 +3,7 @@ import { MediaDevices, MediaDevicesEvents } from "../../utils/media-devices-util export function useMicrophoneStatus(scene) { const mediaDevicesManager = APP.mediaDevicesManager; - const [isMicMuted, setIsMicMuted] = useState(!mediaDevicesManager.isMicEnabled); + const [isMicMuted, setIsMicMuted] = useState(!APP.dialog.isMicEnabled); const [isMicEnabled, setIsMicEnabled] = useState(APP.mediaDevicesManager.isMicShared); const [permissionStatus, setPermissionsStatus] = useState( mediaDevicesManager.getPermissionsStatus(MediaDevices.MICROPHONE) @@ -41,7 +41,7 @@ export function useMicrophoneStatus(scene) { const toggleMute = useCallback(() => { if (mediaDevicesManager.isMicShared) { - mediaDevicesManager.toggleMic(); + APP.dialog.toggleMicrophone(); } else { mediaDevicesManager.startMicShare({ unmute: true }); } diff --git a/src/scene-entry-manager.js b/src/scene-entry-manager.js index 2e5062b7fb..e7ab6e4439 100644 --- a/src/scene-entry-manager.js +++ b/src/scene-entry-manager.js @@ -131,7 +131,7 @@ export default class SceneEntryManager { this.scene.addState("entered"); - APP.mediaDevicesManager.micEnabled = !muteOnEntry; + APP.dialog.enableMicrophone(!muteOnEntry); }; whenSceneLoaded = callback => { diff --git a/src/storage/store.js b/src/storage/store.js index e7aa9573f9..c1784b6470 100644 --- a/src/storage/store.js +++ b/src/storage/store.js @@ -11,7 +11,7 @@ const OAUTH_FLOW_CREDENTIALS_KEY = "ret-oauth-flow-account-credentials"; const validator = new Validator(); import { EventTarget } from "event-target-shim"; import { fetchRandomDefaultAvatarId, generateRandomName } from "../utils/identity.js"; -import { NO_DEVICE_ID } from "../utils/media-devices-utils.js"; +import { NO_DEVICE_ID } from "../utils/media-devices-utils"; import { AAModes } from "../constants"; const defaultMaterialQuality = (function () { diff --git a/src/systems/audio-system.js b/src/systems/audio-system.js index fbfd46dd7a..c568ad67c5 100644 --- a/src/systems/audio-system.js +++ b/src/systems/audio-system.js @@ -19,6 +19,7 @@ function performDelayedReconnect(gainNode) { import * as sdpTransform from "sdp-transform"; import MediaDevicesManager from "../utils/media-devices-manager"; +import { NO_DEVICE_ID } from "../utils/media-devices-utils"; function isThreeAudio(node) { return node instanceof THREE.Audio || node instanceof THREE.PositionalAudio; @@ -218,7 +219,7 @@ export class AudioSystem { if (MediaDevicesManager.isAudioOutputSelectEnabled && APP.mediaDevicesManager) { const sinkId = APP.mediaDevicesManager.selectedSpeakersDeviceId; - const isDefault = sinkId === APP.mediaDevicesManager.defaultOutputDeviceId; + const isDefault = sinkId === NO_DEVICE_ID; if ((!this.outputMediaAudio && isDefault) || sinkId === this.outputMediaAudio?.sinkId) return; const sink = isDefault ? this._sceneEl.audioListener.getInput() : this.audioDestination; this.mixer[SourceType.AVATAR_AUDIO_SOURCE].disconnect(); diff --git a/src/utils/media-devices-manager.js b/src/utils/media-devices-manager.js index bcdb6fa13b..e6563d64b0 100644 --- a/src/utils/media-devices-manager.js +++ b/src/utils/media-devices-manager.js @@ -1,5 +1,13 @@ import { EventEmitter } from "eventemitter3"; -import { MediaDevicesEvents, PermissionStatus, MediaDevices, NO_DEVICE_ID } from "./media-devices-utils"; +import { + MediaDevicesEvents, + PermissionStatus, + MediaDevices, + NO_DEVICE_ID, + optionFor, + getValidMediaDevices, + DEFAULT_MEDIA_DEVICE_OPTION +} from "./media-devices-utils"; import { detectOS, detect } from "detect-browser"; import { isIOS as detectIOS } from "./is-mobile"; @@ -33,8 +41,7 @@ export default class MediaDevicesManager extends EventEmitter { this._micDevices = []; this._videoDevices = []; this._outputDevices = []; - this._deviceId = null; - this._audioTrack = null; + this.audioTrack = null; this.audioSystem = audioSystem; this._mediaStream = audioSystem.outboundStream; this._permissionsStatus = { @@ -46,8 +53,6 @@ export default class MediaDevicesManager extends EventEmitter { this.onDeviceChange = this.onDeviceChange.bind(this); navigator.mediaDevices.addEventListener("devicechange", this.onDeviceChange); - this.onPermissionsUpdated = this.onPermissionsUpdated.bind(this); - APP.hubChannel.addEventListener("permissions_updated", this.onPermissionsUpdated); } static get isAudioOutputSelectEnabled() { @@ -58,44 +63,16 @@ export default class MediaDevicesManager extends EventEmitter { return audioInputSelectEnabled; } - get deviceId() { - return this._deviceId; - } - - set deviceId(deviceId) { - this._deviceId = deviceId; - } - - get audioTrack() { - return this._audioTrack; - } - - set audioTrack(audioTrack) { - this._audioTrack = audioTrack; - } - - get defaultInputDeviceId() { - return this._micDevices.length > 0 ? this._micDevices[0].value : NO_DEVICE_ID; - } - - get defaultOutputDeviceId() { - return this._outputDevices.length > 0 ? this._outputDevices[0].value : NO_DEVICE_ID; - } - - get defaultVideoDeviceId() { - return this._videoDevices.length > 0 ? this._videoDevices[0].value : NO_DEVICE_ID; - } - get micDevicesOptions() { - return this._micDevices.length > 0 ? this._micDevices : [{ value: NO_DEVICE_ID, label: "None" }]; + return [DEFAULT_MEDIA_DEVICE_OPTION, ...this._micDevices]; } get videoDevicesOptions() { - return this._videoDevices.length > 0 ? this._videoDevices : [{ value: NO_DEVICE_ID, label: "None" }]; + return [DEFAULT_MEDIA_DEVICE_OPTION, ...this._videoDevices]; } get outputDevicesOptions() { - return this._outputDevices.length > 0 ? this._outputDevices : [{ value: NO_DEVICE_ID, label: "None" }]; + return [DEFAULT_MEDIA_DEVICE_OPTION, ...this._outputDevices]; } get mediaStream() { @@ -122,7 +99,7 @@ export default class MediaDevicesManager extends EventEmitter { const exists = this._outputDevices.some(device => { return device.value === preferredSpeakers; }); - return exists ? preferredSpeakers : this.defaultOutputDeviceId; + return exists ? preferredSpeakers : NO_DEVICE_ID; } get isMicShared() { @@ -145,28 +122,10 @@ export default class MediaDevicesManager extends EventEmitter { }); } - set micEnabled(enabled) { - APP.dialog.enableMicrophone(enabled); - } - - get isMicEnabled() { - return APP.dialog.isMicEnabled; - } - - toggleMic() { - APP.dialog.toggleMicrophone(); - } - getPermissionsStatus(type) { return this._permissionsStatus[type]; } - onPermissionsUpdated = () => { - if (!APP.hubChannel.can("voice_chat")) { - APP.dialog.enableMicrophone(false); - } - }; - onDeviceChange = () => { this.fetchMediaDevices().then(() => { this.changeAudioOutput(this.selectedSpeakersDeviceId); @@ -174,46 +133,26 @@ export default class MediaDevicesManager extends EventEmitter { }); }; - updatePermissions() { - const micStatus = this._micDevices.length === 0 ? PermissionStatus.PROMPT : PermissionStatus.GRANTED; - this._permissionsStatus[MediaDevices.MICROPHONE] = micStatus; - this.emit(MediaDevicesEvents.PERMISSIONS_STATUS_CHANGED, { - mediaDevice: MediaDevices.MICROPHONE, - status: micStatus - }); - const videoStatus = this._videoDevices.length === 0 ? PermissionStatus.PROMPT : PermissionStatus.GRANTED; - this._permissionsStatus[MediaDevices.CAMERA] = videoStatus; - this.emit(MediaDevicesEvents.PERMISSIONS_STATUS_CHANGED, { - mediaDevice: MediaDevices.CAMERA, - status: videoStatus - }); - const speakersStatus = this._micDevices.length === 0 ? PermissionStatus.PROMPT : PermissionStatus.GRANTED; - this._permissionsStatus[MediaDevices.SPEAKERS] = speakersStatus; + updatePermissionStatus(mediaDevice, shouldPrompt) { + const status = shouldPrompt ? PermissionStatus.PROMPT : PermissionStatus.GRANTED; + this._permissionsStatus[mediaDevice] = status; this.emit(MediaDevicesEvents.PERMISSIONS_STATUS_CHANGED, { - mediaDevice: MediaDevices.SPEAKERS, - status: speakersStatus + mediaDevice, + status }); } async fetchMediaDevices() { console.log("Fetching media devices"); - return new Promise(resolve => { - navigator.mediaDevices.enumerateDevices().then(mediaDevices => { - mediaDevices = mediaDevices.filter(d => d.label !== ""); - this._micDevices = mediaDevices - .filter(d => d.deviceId !== "default" && d.kind === "audioinput") - .map(d => ({ value: d.deviceId, label: d.label || `Mic Device (${d.deviceId.substring(0, 9)})` })); - this._videoDevices = mediaDevices - .filter(d => d.deviceId !== "default" && d.kind === "videoinput") - .map(d => ({ value: d.deviceId, label: d.label || `Camera Device (${d.deviceId.substring(0, 9)})` })); - if (MediaDevicesManager.isAudioOutputSelectEnabled) { - this._outputDevices = mediaDevices - .filter(d => d.deviceId !== "default" && d.kind === "audiooutput") - .map(d => ({ value: d.deviceId, label: d.label || `Audio Output (${d.deviceId.substring(0, 9)})` })); - } - this.updatePermissions(); - resolve(); - }); + return getValidMediaDevices().then(mediaDevices => { + this._micDevices = mediaDevices.filter(d => d.kind === "audioinput").map(optionFor); + this._videoDevices = mediaDevices.filter(d => d.kind === "videoinput").map(optionFor); + if (MediaDevicesManager.isAudioOutputSelectEnabled) { + this._outputDevices = mediaDevices.filter(d => d.kind === "audiooutput").map(optionFor); + this.updatePermissionStatus(MediaDevices.SPEAKERS, this._outputDevices.length === 0); + } + this.updatePermissionStatus(MediaDevices.MICROPHONE, this._micDevices.length === 0); + this.updatePermissionStatus(MediaDevices.CAMERA, this._videoDevices.length === 0); }); } @@ -297,7 +236,7 @@ export default class MediaDevicesManager extends EventEmitter { this.audioTrack = newStream.getAudioTracks()[0]; this.audioTrack.addEventListener("ended", async () => { this._scene.emit(MediaDevicesEvents.MIC_SHARE_ENDED); - this.startMicShare({ unmute: this.isMicEnabled }); + this.startMicShare({ unmute: APP.dialog.isMicEnabled }); }); if (/Oculus/.test(navigator.userAgent)) { @@ -437,30 +376,20 @@ export default class MediaDevicesManager extends EventEmitter { } deviceIdForMicDeviceLabel(label) { - return this._micDevices.filter(d => d.label === label).map(d => d.value)[0] || this.defaultInputDeviceId; + const match = this.micDevicesOptions.find(d => d.label === label); + return (match && match.value) || NO_DEVICE_ID; } deviceIdForSpeakersDeviceLabel(label) { - return this._outputDevices.filter(d => d.label === label).map(d => d.value)[0] || this.defaultOutputDeviceId; + const match = this.outputDevicesOptions.find(d => d.label === label); + return (match && match.value) || NO_DEVICE_ID; } micLabelForDeviceId(deviceId) { - return this._micDevices.filter(d => d.value === deviceId).map(d => d.label)[0]; - } - - speakersLabelForDeviceId(deviceId) { - return this._outputDevices.filter(d => d.value === deviceId).map(d => d.label)[0]; + return this.micDevicesOptions.find(d => d.value === deviceId).label; } hasHmdMicrophone() { return !!this.state._micDevices.find(d => HMD_MIC_REGEXES.find(r => d.label.match(r))); } - - videoDeviceIdForMicLabel(label) { - return this._videoDevices.filter(d => d.label === label).map(d => d.value)[0]; - } - - videoLabelForDeviceId(deviceId) { - return this._videoDevices.filter(d => d.value === deviceId).map(d => d.label)[0]; - } } diff --git a/src/utils/media-devices-utils.js b/src/utils/media-devices-utils.js deleted file mode 100644 index ffe5d65b12..0000000000 --- a/src/utils/media-devices-utils.js +++ /dev/null @@ -1,23 +0,0 @@ -export const MediaDevicesEvents = Object.freeze({ - PERMISSIONS_STATUS_CHANGED: "permissions_status_changed", - MIC_SHARE_STARTED: "mic_share_started", - MIC_SHARE_ENDED: "mic_share_ended", - VIDEO_SHARE_STARTED: "video_share_started", - VIDEO_SHARE_ENDED: "video_share_ended", - DEVICE_CHANGE: "devicechange" -}); - -export const PermissionStatus = Object.freeze({ - GRANTED: "granted", - DENIED: "denied", - PROMPT: "prompt" -}); - -export const MediaDevices = Object.freeze({ - MICROPHONE: "microphone", - SPEAKERS: "speakers", - CAMERA: "camera", - SCREEN: "screen" -}); - -export const NO_DEVICE_ID = "none"; diff --git a/src/utils/media-devices-utils.ts b/src/utils/media-devices-utils.ts new file mode 100644 index 0000000000..7edb4d367e --- /dev/null +++ b/src/utils/media-devices-utils.ts @@ -0,0 +1,62 @@ +export const MediaDevicesEvents = Object.freeze({ + PERMISSIONS_STATUS_CHANGED: "permissions_status_changed", + MIC_SHARE_STARTED: "mic_share_started", + MIC_SHARE_ENDED: "mic_share_ended", + VIDEO_SHARE_STARTED: "video_share_started", + VIDEO_SHARE_ENDED: "video_share_ended", + DEVICE_CHANGE: "devicechange" +}); + +export const PermissionStatus = Object.freeze({ + GRANTED: "granted", + DENIED: "denied", + PROMPT: "prompt" +}); + +export const MediaDevices = Object.freeze({ + MICROPHONE: "microphone", + SPEAKERS: "speakers", + CAMERA: "camera", + SCREEN: "screen" +}); + +export const NO_DEVICE_ID = "none"; + +type MediaDeviceOption = { + value: string; + label: string; +}; + +export const DEFAULT_MEDIA_DEVICE_OPTION: MediaDeviceOption = { value: NO_DEVICE_ID, label: "System Default" }; + +const labelPrefix: Required<{ [K in MediaDeviceKind]: string }> = { + audioinput: "Mic Device", + videoinput: "Camera Device", + audiooutput: "Audio Output" +}; + +function labelFor(device: MediaDeviceInfo) { + return device.label || `${labelPrefix[device.kind]} ${device.deviceId.substring(0, 9)}`; +} + +export function optionFor(device: MediaDeviceInfo): MediaDeviceOption { + return { + value: device.deviceId, + label: labelFor(device) + }; +} + +export async function getValidMediaDevices() { + const mediaDevices = await navigator.mediaDevices.enumerateDevices(); + // Some mediaDevices seem to be invalid. For example, + // { + // deviceId : "" + // groupId : "" + // kind : "videoinput" + // label : "" + // } + // was returned when testing Chrome Version 111.0.5563.19 (Official Build) beta (64-bit) + // Ignore these entries that lack a deviceId. + // Also ignore default devices. Default devices are handled separately. + return mediaDevices.filter(d => d.deviceId && d.deviceId !== "default"); +}