init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
/**
* The type of Redux action which signals that an error occurred while obtaining
* a camera.
*
* {
* type: NOTIFY_CAMERA_ERROR,
* error: Object
* }
*/
export const NOTIFY_CAMERA_ERROR = 'NOTIFY_CAMERA_ERROR';
/**
* The type of Redux action which signals that an error occurred while obtaining
* a microphone.
*
* {
* type: NOTIFY_MIC_ERROR,
* error: Object
* }
*/
export const NOTIFY_MIC_ERROR = 'NOTIFY_MIC_ERROR';
/**
* The type of Redux action which signals that the currently used audio
* input device should be changed.
*
* {
* type: SET_AUDIO_INPUT_DEVICE,
* deviceId: string,
* }
*/
export const SET_AUDIO_INPUT_DEVICE = 'SET_AUDIO_INPUT_DEVICE';
/**
* The type of Redux action which signals that the currently used video
* input device should be changed.
*
* {
* type: SET_VIDEO_INPUT_DEVICE,
* deviceId: string,
* }
*/
export const SET_VIDEO_INPUT_DEVICE = 'SET_VIDEO_INPUT_DEVICE';
/**
* The type of Redux action which signals that the list of known available
* audio and video sources has changed.
*
* {
* type: UPDATE_DEVICE_LIST,
* devices: Array<MediaDeviceInfo>,
* }
*/
export const UPDATE_DEVICE_LIST = 'UPDATE_DEVICE_LIST';
/**
* The type of Redux action which will add a pending device requests that will
* be executed later when it is possible (when the conference is joined).
*
* {
* type: ADD_PENDING_DEVICE_REQUEST,
* request: Object
* }
*/
export const ADD_PENDING_DEVICE_REQUEST = 'ADD_PENDING_DEVICE_REQUEST';
/**
* The type of Redux action which will remove all pending device requests.
*
* {
* type: REMOVE_PENDING_DEVICE_REQUESTS
* }
*/
export const REMOVE_PENDING_DEVICE_REQUESTS = 'REMOVE_PENDING_DEVICE_REQUESTS';
/**
* The type of Redux action which will check passed old and passed new devices
* and if needed will show notifications asking the user whether to use those.
*
* {
* type: CHECK_AND_NOTIFY_FOR_NEW_DEVICE
* newDevices: Array<MediaDeviceInfo>
* oldDevices: Array<MediaDeviceInfo>
* }
*/
export const CHECK_AND_NOTIFY_FOR_NEW_DEVICE = 'CHECK_AND_NOTIFY_FOR_NEW_DEVICE';
/**
* The type of Redux action which signals that the device permissions have changed.
*
* {
* type: CHECK_AND_NOTIFY_FOR_NEW_DEVICE
* permissions: Object
* }
*/
export const DEVICE_PERMISSIONS_CHANGED = 'DEVICE_PERMISSIONS_CHANGED';

View File

@@ -0,0 +1,356 @@
import { IStore } from '../../app/types';
import JitsiMeetJS from '../lib-jitsi-meet';
import { updateSettings } from '../settings/actions';
import { getUserSelectedOutputDeviceId } from '../settings/functions.web';
import {
ADD_PENDING_DEVICE_REQUEST,
CHECK_AND_NOTIFY_FOR_NEW_DEVICE,
DEVICE_PERMISSIONS_CHANGED,
NOTIFY_CAMERA_ERROR,
NOTIFY_MIC_ERROR,
REMOVE_PENDING_DEVICE_REQUESTS,
SET_AUDIO_INPUT_DEVICE,
SET_VIDEO_INPUT_DEVICE,
UPDATE_DEVICE_LIST
} from './actionTypes';
import {
areDeviceLabelsInitialized,
areDevicesDifferent,
filterIgnoredDevices,
flattenAvailableDevices,
getDeviceIdByLabel,
getDeviceLabelById,
getDevicesFromURL,
logDevices,
setAudioOutputDeviceId
} from './functions';
import logger from './logger';
/**
* Maps the WebRTC string for device type to the keys used to store configure,
* within redux, which devices should be used by default.
*/
const DEVICE_TYPE_TO_SETTINGS_KEYS = {
audioInput: {
currentDeviceId: 'micDeviceId',
userSelectedDeviceId: 'userSelectedMicDeviceId',
userSelectedDeviceLabel: 'userSelectedMicDeviceLabel'
},
audioOutput: {
currentDeviceId: 'audioOutputDeviceId',
userSelectedDeviceId: 'userSelectedAudioOutputDeviceId',
userSelectedDeviceLabel: 'userSelectedAudioOutputDeviceLabel'
},
videoInput: {
currentDeviceId: 'cameraDeviceId',
userSelectedDeviceId: 'userSelectedCameraDeviceId',
userSelectedDeviceLabel: 'userSelectedCameraDeviceLabel'
}
};
/**
* Adds a pending device request.
*
* @param {Object} request - The request to be added.
* @returns {{
* type: ADD_PENDING_DEVICE_REQUEST,
* request: Object
* }}
*/
export function addPendingDeviceRequest(request: Object) {
return {
type: ADD_PENDING_DEVICE_REQUEST,
request
};
}
/**
* Configures the initial A/V devices before the conference has started.
*
* @returns {Function}
*/
export function configureInitialDevices() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const deviceLabels = getDevicesFromURL(getState());
let updateSettingsPromise;
logger.debug(`(TIME) configureInitialDevices: deviceLabels=${
Boolean(deviceLabels)}, performance.now=${window.performance.now()}`);
if (deviceLabels) {
updateSettingsPromise = dispatch(getAvailableDevices()).then(() => {
const state = getState();
if (!areDeviceLabelsInitialized(state)) {
// The labels are not available if the A/V permissions are
// not yet granted.
Object.keys(deviceLabels).forEach(key => {
dispatch(addPendingDeviceRequest({
type: 'devices',
name: 'setDevice',
device: {
kind: key.toLowerCase(),
label: deviceLabels[key as keyof typeof deviceLabels]
},
// eslint-disable-next-line @typescript-eslint/no-empty-function
responseCallback() {}
}));
});
return;
}
const newSettings: any = {};
Object.keys(deviceLabels).forEach(key => {
const label = deviceLabels[key as keyof typeof deviceLabels];
// @ts-ignore
const deviceId = getDeviceIdByLabel(state, label, key);
if (deviceId) {
const settingsTranslationMap = DEVICE_TYPE_TO_SETTINGS_KEYS[
key as keyof typeof DEVICE_TYPE_TO_SETTINGS_KEYS];
newSettings[settingsTranslationMap.currentDeviceId] = deviceId;
newSettings[settingsTranslationMap.userSelectedDeviceId] = deviceId;
newSettings[settingsTranslationMap.userSelectedDeviceLabel] = label;
}
});
dispatch(updateSettings(newSettings));
});
} else {
updateSettingsPromise = Promise.resolve();
}
return updateSettingsPromise
.then(() => {
const userSelectedAudioOutputDeviceId = getUserSelectedOutputDeviceId(getState());
logger.debug(`(TIME) configureInitialDevices -> setAudioOutputDeviceId: performance.now=${
window.performance.now()}`);
return setAudioOutputDeviceId(userSelectedAudioOutputDeviceId, dispatch)
.catch(ex => logger.warn(`Failed to set audio output device.
Default audio output device will be used instead ${ex}`));
});
};
}
/**
* Queries for connected A/V input and output devices and updates the redux
* state of known devices.
*
* @returns {Function}
*/
export function getAvailableDevices() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => new Promise(resolve => {
const { mediaDevices } = JitsiMeetJS;
if (mediaDevices.isDeviceChangeAvailable()) {
mediaDevices.enumerateDevices((devices: MediaDeviceInfo[]) => {
const { filteredDevices, ignoredDevices } = filterIgnoredDevices(devices);
const oldDevices = flattenAvailableDevices(getState()['features/base/devices'].availableDevices);
if (areDevicesDifferent(oldDevices, filteredDevices)) {
logDevices(ignoredDevices, 'Ignored devices on device list changed:');
dispatch(updateDeviceList(filteredDevices));
}
resolve(filteredDevices);
});
} else {
resolve([]);
}
});
}
/**
* Signals that an error occurred while trying to obtain a track from a camera.
*
* @param {Object} error - The device error, as provided by lib-jitsi-meet.
* @param {string} error.name - The constant for the type of the error.
* @param {string} error.message - Optional additional information about the
* error.
* @returns {{
* type: NOTIFY_CAMERA_ERROR,
* error: Object
* }}
*/
export function notifyCameraError(error: Error) {
return {
type: NOTIFY_CAMERA_ERROR,
error
};
}
/**
* Signals that an error occurred while trying to obtain a track from a mic.
*
* @param {Object} error - The device error, as provided by lib-jitsi-meet.
* @param {Object} error.name - The constant for the type of the error.
* @param {string} error.message - Optional additional information about the
* error.
* @returns {{
* type: NOTIFY_MIC_ERROR,
* error: Object
* }}
*/
export function notifyMicError(error: Error) {
return {
type: NOTIFY_MIC_ERROR,
error
};
}
/**
* Remove all pending device requests.
*
* @returns {{
* type: REMOVE_PENDING_DEVICE_REQUESTS
* }}
*/
export function removePendingDeviceRequests() {
return {
type: REMOVE_PENDING_DEVICE_REQUESTS
};
}
/**
* Signals to update the currently used audio input device.
*
* @param {string} deviceId - The id of the new audio input device.
* @returns {{
* type: SET_AUDIO_INPUT_DEVICE,
* deviceId: string
* }}
*/
export function setAudioInputDevice(deviceId: string) {
return {
type: SET_AUDIO_INPUT_DEVICE,
deviceId
};
}
/**
* Sets the audio input device id and updates the settings
* so they are persisted across sessions.
*
* @param {string} deviceId - The id of the new audio input device.
* @returns {Function}
*/
export function setAudioInputDeviceAndUpdateSettings(deviceId: string) {
return function(dispatch: IStore['dispatch'], getState: IStore['getState']) {
const deviceLabel = getDeviceLabelById(getState(), deviceId, 'audioInput');
dispatch(setAudioInputDevice(deviceId));
dispatch(updateSettings({
userSelectedMicDeviceId: deviceId,
userSelectedMicDeviceLabel: deviceLabel
}));
};
}
/**
* Updates the output device id.
*
* @param {string} deviceId - The id of the new output device.
* @returns {Function}
*/
export function setAudioOutputDevice(deviceId: string) {
return function(dispatch: IStore['dispatch'], getState: IStore['getState']) {
const deviceLabel = getDeviceLabelById(getState(), deviceId, 'audioOutput');
return setAudioOutputDeviceId(deviceId, dispatch, true, deviceLabel);
};
}
/**
* Signals to update the currently used video input device.
*
* @param {string} deviceId - The id of the new video input device.
* @returns {{
* type: SET_VIDEO_INPUT_DEVICE,
* deviceId: string
* }}
*/
export function setVideoInputDevice(deviceId: string) {
return {
type: SET_VIDEO_INPUT_DEVICE,
deviceId
};
}
/**
* Sets the video input device id and updates the settings
* so they are persisted across sessions.
*
* @param {string} deviceId - The id of the new video input device.
* @returns {Function}
*/
export function setVideoInputDeviceAndUpdateSettings(deviceId: string) {
return function(dispatch: IStore['dispatch'], getState: IStore['getState']) {
const deviceLabel = getDeviceLabelById(getState(), deviceId, 'videoInput');
dispatch(setVideoInputDevice(deviceId));
dispatch(updateSettings({
userSelectedCameraDeviceId: deviceId,
userSelectedCameraDeviceLabel: deviceLabel
}));
};
}
/**
* Signals to update the list of known audio and video devices.
*
* @param {Array<MediaDeviceInfo>} devices - All known available audio input,
* audio output, and video input devices.
* @returns {{
* type: UPDATE_DEVICE_LIST,
* devices: Array<MediaDeviceInfo>
* }}
*/
export function updateDeviceList(devices: MediaDeviceInfo[]) {
return {
type: UPDATE_DEVICE_LIST,
devices
};
}
/**
* Signals to check new and old devices for newly added devices and notify.
*
* @param {Array<MediaDeviceInfo>} newDevices - Array of the new devices.
* @param {Array<MediaDeviceInfo>} oldDevices - Array of the old devices.
* @returns {{
* type: CHECK_AND_NOTIFY_FOR_NEW_DEVICE,
* newDevices: Array<MediaDeviceInfo>,
* oldDevices: Array<MediaDeviceInfo>
* }}
*/
export function checkAndNotifyForNewDevice(newDevices: MediaDeviceInfo[], oldDevices: MediaDeviceInfo[]) {
return {
type: CHECK_AND_NOTIFY_FOR_NEW_DEVICE,
newDevices,
oldDevices
};
}
/**
* Signals that the device permissions have changed.
*
* @param {Object} permissions - Object with the permissions.
* @returns {{
* type: DEVICE_PERMISSIONS_CHANGED,
* permissions: Object
* }}
*/
export function devicePermissionsChanged(permissions: Object) {
return {
type: DEVICE_PERMISSIONS_CHANGED,
permissions
};
}

View File

@@ -0,0 +1,11 @@
/**
* Prefixes of devices that will be filtered from the device list.
*
* NOTE: It seems that the filtered devices can't be set
* as default device on the OS level and this use case is not handled in the code. If we add more device prefixes that
* can be default devices we should make sure to handle the default device use case.
*/
export const DEVICE_LABEL_PREFIXES_TO_IGNORE = [
'Microsoft Teams Audio Device',
'ZoomAudioDevice'
];

View File

@@ -0,0 +1,19 @@
import { IReduxState } from '../../app/types';
/**
* Returns true if there are devices of a specific type or on native platform.
*
* @param {Object} state - The state of the application.
* @param {string} type - The type of device: VideoOutput | audioOutput | audioInput.
*
* @returns {boolean}
*/
export function hasAvailableDevices(state: IReduxState, type: string) {
if (state['features/base/devices'] === undefined) {
return true;
}
const availableDevices = state['features/base/devices'].availableDevices;
return Number(availableDevices[type as keyof typeof availableDevices]?.length) > 0;
}

View File

@@ -0,0 +1 @@
export * from './functions.any';

View File

@@ -0,0 +1,383 @@
/* eslint-disable require-jsdoc */
import { IReduxState, IStore } from '../../app/types';
import JitsiMeetJS from '../lib-jitsi-meet';
import { updateSettings } from '../settings/actions';
import { ISettingsState } from '../settings/reducer';
import { setNewAudioOutputDevice } from '../sounds/functions.web';
import { parseURLParams } from '../util/parseURLParams';
import { DEVICE_LABEL_PREFIXES_TO_IGNORE } from './constants';
import logger from './logger';
import { IDevicesState } from './types';
export * from './functions.any';
const webrtcKindToJitsiKindTranslator = {
audioinput: 'audioInput',
audiooutput: 'audioOutput',
videoinput: 'videoInput'
};
/**
* Detects the use case when the labels are not available if the A/V permissions
* are not yet granted.
*
* @param {Object} state - The redux state.
* @returns {boolean} - True if the labels are already initialized and false
* otherwise.
*/
export function areDeviceLabelsInitialized(state: IReduxState) {
// TODO: Replace with something that doesn't use APP when the conference.js logic is reactified.
if (APP.conference._localTracksInitialized) {
return true;
}
for (const type of [ 'audioInput', 'audioOutput', 'videoInput' ]) {
const availableDevices = state['features/base/devices'].availableDevices;
if ((availableDevices[type as keyof typeof availableDevices] || []).find(d => Boolean(d.label))) {
return true;
}
}
return false;
}
/**
* Get device id of the audio output device which is currently in use.
* Empty string stands for default device.
*
* @returns {string}
*/
export function getAudioOutputDeviceId() {
return JitsiMeetJS.mediaDevices.getAudioOutputDevice();
}
/**
* Finds the real device id of the default device of the given type.
*
* @param {Object} state - The redux state.
* @param {*} kind - The type of the device. One of "audioInput",
* "audioOutput", and "videoInput". Also supported is all lowercase versions
* of the preceding types.
* @returns {string|undefined}
*/
export function getDefaultDeviceId(state: IReduxState, kind: string) {
const kindToSearch = webrtcKindToJitsiKindTranslator[kind as keyof typeof webrtcKindToJitsiKindTranslator] || kind;
const availableDevices = state['features/base/devices'].availableDevices;
const defaultDevice = (availableDevices[kindToSearch as keyof typeof availableDevices] || [])
.find(d => d.deviceId === 'default');
// Find the device with a matching group id.
const matchingDevice = (availableDevices[kindToSearch as keyof typeof availableDevices] || [])
.find(d => d.deviceId !== 'default' && d.groupId === defaultDevice?.groupId);
if (matchingDevice) {
return matchingDevice.deviceId;
}
}
/**
* Finds a device with a label that matches the passed label and returns its id.
*
* @param {Object} state - The redux state.
* @param {string} label - The label.
* @param {string} kind - The type of the device. One of "audioInput",
* "audioOutput", and "videoInput". Also supported is all lowercase versions
* of the preceding types.
* @returns {string|undefined}
*/
export function getDeviceIdByLabel(state: IReduxState, label: string, kind: string) {
const kindToSearch = webrtcKindToJitsiKindTranslator[kind as keyof typeof webrtcKindToJitsiKindTranslator] || kind;
const availableDevices = state['features/base/devices'].availableDevices;
const device
= (availableDevices[kindToSearch as keyof typeof availableDevices] || [])
.find(d => d.label === label);
if (device) {
return device.deviceId;
}
}
/**
* Finds a device with a label that matches the passed id and returns its label.
*
* @param {Object} state - The redux state.
* @param {string} id - The device id.
* @param {string} kind - The type of the device. One of "audioInput",
* "audioOutput", and "videoInput". Also supported is all lowercase versions
* of the preceding types.
* @returns {string|undefined}
*/
export function getDeviceLabelById(state: IReduxState, id: string, kind: string) {
const kindToSearch = webrtcKindToJitsiKindTranslator[kind as keyof typeof webrtcKindToJitsiKindTranslator] || kind;
const availableDevices = state['features/base/devices'].availableDevices;
const device
= (availableDevices[kindToSearch as keyof typeof availableDevices] || [])
.find(d => d.deviceId === id);
if (device) {
return device.label;
}
}
/**
* Returns the devices set in the URL.
*
* @param {Object} state - The redux state.
* @returns {Object|undefined}
*/
export function getDevicesFromURL(state: IReduxState) {
const urlParams
= parseURLParams(state['features/base/connection'].locationURL ?? '');
const audioOutput = urlParams['devices.audioOutput'];
const videoInput = urlParams['devices.videoInput'];
const audioInput = urlParams['devices.audioInput'];
if (!audioOutput && !videoInput && !audioInput) {
return undefined;
}
const devices: IDevicesState['availableDevices'] = {};
audioOutput && (devices.audioOutput = audioOutput);
videoInput && (devices.videoInput = videoInput);
audioInput && (devices.audioInput = audioInput);
return devices;
}
/**
* Converts an array of media devices into an object organized by device kind.
*
* @param {Array<MediaDeviceInfo>} devices - Available media devices.
* @private
* @returns {Object} An object with the media devices split by type. The keys
* are device type and the values are arrays with devices matching the device
* type.
*/
// @ts-ignore
export function groupDevicesByKind(devices: MediaDeviceInfo[]): IDevicesState['availableDevices'] {
return {
audioInput: devices.filter(device => device.kind === 'audioinput'),
audioOutput: devices.filter(device => device.kind === 'audiooutput'),
videoInput: devices.filter(device => device.kind === 'videoinput')
};
}
/**
* Filters the devices that start with one of the prefixes from DEVICE_LABEL_PREFIXES_TO_IGNORE.
*
* @param {MediaDeviceInfo[]} devices - The devices to be filtered.
* @returns {MediaDeviceInfo[]} - The filtered devices.
*/
// @ts-ignore
export function filterIgnoredDevices(devices: MediaDeviceInfo[] = []) {
// @ts-ignore
const ignoredDevices: MediaDeviceInfo[] = [];
const filteredDevices = devices.filter(device => {
if (!device.label) {
return true;
}
if (DEVICE_LABEL_PREFIXES_TO_IGNORE.find(prefix => device.label?.startsWith(prefix))) {
ignoredDevices.push(device);
return false;
}
return true;
});
return {
filteredDevices,
ignoredDevices
};
}
/**
* Check if the passed device arrays are different.
*
* @param {MediaDeviceInfo[]} devices1 - Array with devices to be compared.
* @param {MediaDeviceInfo[]} devices2 - Array with devices to be compared.
* @returns {boolean} - True if the device arrays are different and false otherwise.
*/
// @ts-ignore
export function areDevicesDifferent(devices1: MediaDeviceInfo[] = [], devices2: MediaDeviceInfo[] = []) {
if (devices1.length !== devices2.length) {
return true;
}
for (let i = 0; i < devices1.length; i++) {
const device1 = devices1[i];
const found = devices2.find(({ deviceId, groupId, kind, label }) =>
device1.deviceId === deviceId
&& device1.groupId === groupId
&& device1.kind === kind
&& device1.label === label
);
if (!found) {
return true;
}
}
return false;
}
/**
* Flattens the availableDevices from redux.
*
* @param {IDevicesState.availableDevices} devices - The available devices from redux.
* @returns {MediaDeviceInfo[]} - The flattened array of devices.
*/
export function flattenAvailableDevices(
{ audioInput = [], audioOutput = [], videoInput = [] }: IDevicesState['availableDevices']) {
return audioInput.concat(audioOutput).concat(videoInput);
}
/**
* We want to strip any device details that are not very user friendly, like usb ids put in brackets at the end.
*
* @param {string} label - Device label to format.
*
* @returns {string} - Formatted string.
*/
export function formatDeviceLabel(label: string) {
let formattedLabel = label;
// Remove braked description at the end as it contains non user friendly strings i.e.
// Microsoft® LifeCam HD-3000 (045e:0779:31dg:d1231)
const ix = formattedLabel.lastIndexOf('(');
if (ix !== -1) {
formattedLabel = formattedLabel.substr(0, ix);
}
return formattedLabel;
}
/**
* Returns a list of objects containing all the microphone device ids and labels.
*
* @param {Object} state - The state of the application.
* @returns {Object[]}
*/
export function getAudioInputDeviceData(state: IReduxState) {
return state['features/base/devices'].availableDevices.audioInput?.map(
({ deviceId, label }) => {
return {
deviceId,
label
};
});
}
/**
* Returns a list of objectes containing all the output device ids and labels.
*
* @param {Object} state - The state of the application.
* @returns {Object[]}
*/
export function getAudioOutputDeviceData(state: IReduxState) {
return state['features/base/devices'].availableDevices.audioOutput?.map(
({ deviceId, label }) => {
return {
deviceId,
label
};
});
}
/**
* Returns a list of all the camera device ids.
*
* @param {Object} state - The state of the application.
* @returns {string[]}
*/
export function getVideoDeviceIds(state: IReduxState) {
return state['features/base/devices'].availableDevices.videoInput?.map(({ deviceId }) => deviceId);
}
/**
* Converts an array of device info objects into string.
*
* @param {MediaDeviceInfo[]} devices - The devices.
* @returns {string}
*/
// @ts-ignore
function devicesToStr(devices?: MediaDeviceInfo[]) {
return devices?.map(device => `\t\t${device.label}[${device.deviceId}]`).join('\n');
}
/**
* Logs an array of devices.
*
* @param {MediaDeviceInfo[]} devices - The array of devices.
* @param {string} title - The title that will be printed in the log.
* @returns {void}
*/
// @ts-ignore
export function logDevices(devices: MediaDeviceInfo[], title = '') {
const deviceList = groupDevicesByKind(devices);
const audioInputs = devicesToStr(deviceList.audioInput);
const audioOutputs = devicesToStr(deviceList.audioOutput);
const videoInputs = devicesToStr(deviceList.videoInput);
logger.debug(`${title}:\n`
+ `audioInput:\n${audioInputs}\n`
+ `audioOutput:\n${audioOutputs}\n`
+ `videoInput:\n${videoInputs}`);
}
/**
* Set device id of the audio output device which is currently in use.
* Empty string stands for default device.
*
* @param {string} newId - New audio output device id.
* @param {Function} dispatch - The Redux dispatch function.
* @param {boolean} userSelection - Whether this is a user selection update.
* @param {?string} newLabel - New audio output device label to store.
* @returns {Promise}
*/
export function setAudioOutputDeviceId(
newId = 'default',
dispatch: IStore['dispatch'],
userSelection = false,
newLabel?: string): Promise<any> {
logger.debug(`setAudioOutputDevice: ${String(newLabel)}[${newId}]`);
if (!JitsiMeetJS.mediaDevices.isDeviceChangeAvailable('output')) {
logger.warn('Adjusting audio output is not supported');
return Promise.resolve();
}
return JitsiMeetJS.mediaDevices.setAudioOutputDevice(newId)
.then(() => {
dispatch(setNewAudioOutputDevice(newId));
const newSettings: Partial<ISettingsState> = {
audioOutputDeviceId: newId,
userSelectedAudioOutputDeviceId: undefined,
userSelectedAudioOutputDeviceLabel: undefined
};
if (userSelection) {
newSettings.userSelectedAudioOutputDeviceId = newId;
newSettings.userSelectedAudioOutputDeviceLabel = newLabel;
} else {
// a flow workaround, I needed to add 'userSelectedAudioOutputDeviceId: undefined'
delete newSettings.userSelectedAudioOutputDeviceId;
delete newSettings.userSelectedAudioOutputDeviceLabel;
}
return dispatch(updateSettings(newSettings));
});
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../logging/functions';
export default getLogger('features/base/devices');

View File

@@ -0,0 +1,349 @@
import { AnyAction } from 'redux';
import { IStore } from '../../app/types';
import { processExternalDeviceRequest } from '../../device-selection/functions';
import { showNotification, showWarningNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { replaceAudioTrackById, replaceVideoTrackById, setDeviceStatusWarning } from '../../prejoin/actions';
import { isPrejoinPageVisible } from '../../prejoin/functions';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app/actionTypes';
import { isMobileBrowser } from '../environment/utils';
import JitsiMeetJS, { JitsiMediaDevicesEvents, JitsiTrackErrors } from '../lib-jitsi-meet';
import { MEDIA_TYPE } from '../media/constants';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import { updateSettings } from '../settings/actions';
import { getLocalTrack } from '../tracks/functions';
import {
CHECK_AND_NOTIFY_FOR_NEW_DEVICE,
NOTIFY_CAMERA_ERROR,
NOTIFY_MIC_ERROR,
SET_AUDIO_INPUT_DEVICE,
SET_VIDEO_INPUT_DEVICE,
UPDATE_DEVICE_LIST
} from './actionTypes';
import {
devicePermissionsChanged,
removePendingDeviceRequests,
setAudioInputDevice,
setVideoInputDevice
} from './actions';
import {
areDeviceLabelsInitialized,
formatDeviceLabel,
logDevices,
setAudioOutputDeviceId
} from './functions';
import logger from './logger';
const JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP = {
microphone: {
[JitsiTrackErrors.CONSTRAINT_FAILED]: 'dialog.micConstraintFailedError',
[JitsiTrackErrors.GENERAL]: 'dialog.micUnknownError',
[JitsiTrackErrors.NOT_FOUND]: 'dialog.micNotFoundError',
[JitsiTrackErrors.PERMISSION_DENIED]: 'dialog.micPermissionDeniedError',
[JitsiTrackErrors.TIMEOUT]: 'dialog.micTimeoutError'
},
camera: {
[JitsiTrackErrors.CONSTRAINT_FAILED]: 'dialog.cameraConstraintFailedError',
[JitsiTrackErrors.GENERAL]: 'dialog.cameraUnknownError',
[JitsiTrackErrors.NOT_FOUND]: 'dialog.cameraNotFoundError',
[JitsiTrackErrors.PERMISSION_DENIED]: 'dialog.cameraPermissionDeniedError',
[JitsiTrackErrors.UNSUPPORTED_RESOLUTION]: 'dialog.cameraUnsupportedResolutionError',
[JitsiTrackErrors.TIMEOUT]: 'dialog.cameraTimeoutError'
}
};
/**
* A listener for device permissions changed reported from lib-jitsi-meet.
*/
let permissionsListener: Function | undefined;
/**
* Implements the middleware of the feature base/devices.
*
* @param {Store} store - Redux store.
* @returns {Function}
*/
// eslint-disable-next-line no-unused-vars
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case APP_WILL_MOUNT: {
const _permissionsListener = (permissions: Object) => {
store.dispatch(devicePermissionsChanged(permissions));
};
const { mediaDevices } = JitsiMeetJS;
permissionsListener = _permissionsListener;
mediaDevices.addEventListener(JitsiMediaDevicesEvents.PERMISSIONS_CHANGED, permissionsListener);
Promise.all([
mediaDevices.isDevicePermissionGranted('audio'),
mediaDevices.isDevicePermissionGranted('video')
])
.then(results => {
_permissionsListener({
audio: results[0],
video: results[1]
});
})
.catch(() => {
// Ignore errors.
});
break;
}
case APP_WILL_UNMOUNT:
if (typeof permissionsListener === 'function') {
JitsiMeetJS.mediaDevices.removeEventListener(
JitsiMediaDevicesEvents.PERMISSIONS_CHANGED, permissionsListener);
permissionsListener = undefined;
}
break;
case NOTIFY_CAMERA_ERROR: {
if (!action.error) {
break;
}
const { message, name } = action.error;
const cameraJitsiTrackErrorMsg
= JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP.camera[name];
const cameraErrorMsg = cameraJitsiTrackErrorMsg
|| JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP
.camera[JitsiTrackErrors.GENERAL];
const additionalCameraErrorMsg = cameraJitsiTrackErrorMsg ? null : message;
const titleKey = name === JitsiTrackErrors.PERMISSION_DENIED
? 'deviceError.cameraPermission' : 'deviceError.cameraError';
store.dispatch(showWarningNotification({
description: additionalCameraErrorMsg,
descriptionKey: cameraErrorMsg,
titleKey
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
if (isPrejoinPageVisible(store.getState())) {
store.dispatch(setDeviceStatusWarning(titleKey));
}
break;
}
case NOTIFY_MIC_ERROR: {
if (!action.error) {
break;
}
const { message, name } = action.error;
const micJitsiTrackErrorMsg
= JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP.microphone[name];
const micErrorMsg = micJitsiTrackErrorMsg
|| JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP
.microphone[JitsiTrackErrors.GENERAL];
const additionalMicErrorMsg = micJitsiTrackErrorMsg ? null : message;
const titleKey = name === JitsiTrackErrors.PERMISSION_DENIED
? 'deviceError.microphonePermission'
: 'deviceError.microphoneError';
store.dispatch(showWarningNotification({
description: additionalMicErrorMsg,
descriptionKey: micErrorMsg,
titleKey
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
if (isPrejoinPageVisible(store.getState())) {
store.dispatch(setDeviceStatusWarning(titleKey));
}
break;
}
case SET_AUDIO_INPUT_DEVICE:
if (isPrejoinPageVisible(store.getState())) {
store.dispatch(replaceAudioTrackById(action.deviceId));
} else {
APP.conference.onAudioDeviceChanged(action.deviceId);
}
break;
case SET_VIDEO_INPUT_DEVICE: {
const localTrack = getLocalTrack(store.getState()['features/base/tracks'], MEDIA_TYPE.VIDEO);
// on mobile devices the video stream has to be stopped before replacing it
if (isMobileBrowser() && localTrack && !localTrack.muted) {
localTrack.jitsiTrack.stopStream();
}
if (isPrejoinPageVisible(store.getState())) {
store.dispatch(replaceVideoTrackById(action.deviceId));
} else {
APP.conference.onVideoDeviceChanged(action.deviceId);
}
break;
}
case UPDATE_DEVICE_LIST:
logDevices(action.devices, 'Device list updated');
if (areDeviceLabelsInitialized(store.getState())) {
return _processPendingRequests(store, next, action);
}
break;
case CHECK_AND_NOTIFY_FOR_NEW_DEVICE:
_checkAndNotifyForNewDevice(store, action.newDevices, action.oldDevices);
break;
}
return next(action);
});
/**
* Does extra sync up on properties that may need to be updated after the
* conference was joined.
*
* @param {Store} store - The redux store in which the specified {@code action}
* is being dispatched.
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
* specified {@code action} to the specified {@code store}.
* @param {Action} action - The redux action {@code CONFERENCE_JOINED} which is
* being dispatched in the specified {@code store}.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _processPendingRequests({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
const result = next(action);
const state = getState();
const { pendingRequests } = state['features/base/devices'];
if (!pendingRequests || pendingRequests.length === 0) {
return result;
}
pendingRequests.forEach((request: any) => {
processExternalDeviceRequest(
dispatch,
getState,
request,
request.responseCallback);
});
dispatch(removePendingDeviceRequests());
return result;
}
/**
* Finds a new device by comparing new and old array of devices and dispatches
* notification with the new device. For new devices with same groupId only one
* notification will be shown, this is so to avoid showing multiple notifications
* for audio input and audio output devices.
*
* @param {Store} store - The redux store in which the specified {@code action}
* is being dispatched.
* @param {MediaDeviceInfo[]} newDevices - The array of new devices we received.
* @param {MediaDeviceInfo[]} oldDevices - The array of the old devices we have.
* @private
* @returns {void}
*/
function _checkAndNotifyForNewDevice(store: IStore, newDevices: MediaDeviceInfo[], oldDevices: MediaDeviceInfo[]) {
const { dispatch } = store;
// let's intersect both newDevices and oldDevices and handle thew newly
// added devices
const onlyNewDevices = newDevices.filter(
nDevice => !oldDevices.find(
device => device.deviceId === nDevice.deviceId));
// we group devices by groupID which normally is the grouping by physical device
// plugging in headset we provide normally two device, one input and one output
// and we want to show only one notification for this physical audio device
const devicesGroupBy: {
[key: string]: MediaDeviceInfo[];
} = onlyNewDevices.reduce((accumulated: any, value) => {
accumulated[value.groupId] = accumulated[value.groupId] || [];
accumulated[value.groupId].push(value);
return accumulated;
}, {});
Object.values(devicesGroupBy).forEach(devicesArray => {
if (devicesArray.length < 1) {
return;
}
// let's get the first device as a reference, we will use it for
// label and type
const newDevice = devicesArray[0];
// we want to strip any device details that are not very
// user friendly, like usb ids put in brackets at the end
const description = formatDeviceLabel(newDevice.label);
let titleKey;
switch (newDevice.kind) {
case 'videoinput': {
titleKey = 'notify.newDeviceCameraTitle';
break;
}
case 'audioinput' :
case 'audiooutput': {
titleKey = 'notify.newDeviceAudioTitle';
break;
}
}
if (!isPrejoinPageVisible(store.getState())) {
dispatch(showNotification({
description,
titleKey,
customActionNameKey: [ 'notify.newDeviceAction' ],
customActionHandler: [ _useDevice.bind(undefined, store, devicesArray) ]
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
}
});
}
/**
* Set a device to be currently used, selected by the user.
*
* @param {Store} store - The redux store in which the specified {@code action}
* is being dispatched.
* @param {Array<MediaDeviceInfo|InputDeviceInfo>} devices - The devices to save.
* @returns {boolean} - Returns true in order notifications to be dismissed.
* @private
*/
function _useDevice({ dispatch }: IStore, devices: MediaDeviceInfo[]) {
devices.forEach(device => {
switch (device.kind) {
case 'videoinput': {
dispatch(updateSettings({
userSelectedCameraDeviceId: device.deviceId,
userSelectedCameraDeviceLabel: device.label
}));
dispatch(setVideoInputDevice(device.deviceId));
break;
}
case 'audioinput': {
dispatch(updateSettings({
userSelectedMicDeviceId: device.deviceId,
userSelectedMicDeviceLabel: device.label
}));
dispatch(setAudioInputDevice(device.deviceId));
break;
}
case 'audiooutput': {
setAudioOutputDeviceId(
device.deviceId,
dispatch,
true,
device.label)
.then(() => logger.log('changed audio output device'))
.catch(err => {
logger.warn(
'Failed to change audio output device.',
'Default or previously set audio output device will',
' be used instead.',
err);
});
break;
}
}
});
return true;
}

View File

@@ -0,0 +1,88 @@
import ReducerRegistry from '../redux/ReducerRegistry';
import {
ADD_PENDING_DEVICE_REQUEST,
DEVICE_PERMISSIONS_CHANGED,
REMOVE_PENDING_DEVICE_REQUESTS,
SET_AUDIO_INPUT_DEVICE,
SET_VIDEO_INPUT_DEVICE,
UPDATE_DEVICE_LIST
} from './actionTypes';
import { groupDevicesByKind } from './functions.web';
import logger from './logger';
import { IDevicesState } from './types';
const DEFAULT_STATE: IDevicesState = {
availableDevices: {
audioInput: [],
audioOutput: [],
videoInput: []
},
pendingRequests: [],
permissions: {
audio: false,
video: false
}
};
/**
* Listen for actions which changes the state of known and used devices.
*
* @param {IDevicesState} state - The Redux state of the feature features/base/devices.
* @param {Object} action - Action object.
* @param {string} action.type - Type of action.
* @param {Array<MediaDeviceInfo>} action.devices - All available audio and
* video devices.
* @returns {Object}
*/
ReducerRegistry.register<IDevicesState>(
'features/base/devices',
(state = DEFAULT_STATE, action): IDevicesState => {
switch (action.type) {
case UPDATE_DEVICE_LIST: {
const deviceList = groupDevicesByKind(action.devices);
return {
...state,
availableDevices: deviceList
};
}
case ADD_PENDING_DEVICE_REQUEST:
return {
...state,
pendingRequests: [
...state.pendingRequests,
action.request
]
};
case REMOVE_PENDING_DEVICE_REQUESTS:
return {
...state,
pendingRequests: [ ]
};
// TODO: Changing of current audio and video device id is currently handled outside of react/redux.
case SET_AUDIO_INPUT_DEVICE: {
logger.debug(`set audio input device: ${action.deviceId}`);
return state;
}
case SET_VIDEO_INPUT_DEVICE: {
logger.debug(`set video input device: ${action.deviceId}`);
return state;
}
case DEVICE_PERMISSIONS_CHANGED: {
return {
...state,
permissions: action.permissions
};
}
default:
return state;
}
});

View File

@@ -0,0 +1,17 @@
/* eslint-disable lines-around-comment */
export interface IDevicesState {
availableDevices: {
// @ts-ignore
audioInput?: MediaDeviceInfo[];
// @ts-ignore
audioOutput?: MediaDeviceInfo[];
// @ts-ignore
videoInput?: MediaDeviceInfo[];
};
pendingRequests: any[];
permissions: {
audio: boolean;
video: boolean;
};
}