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,64 @@
/**
* The type of a feature/internal/protected (redux) action to add an audio
* element to the sounds collection state.
*
* {
* type: _ADD_AUDIO_ELEMENT,
* ref: AudioElement,
* soundId: string
* }
*/
export const _ADD_AUDIO_ELEMENT = '_ADD_AUDIO_ELEMENT';
/**
* The type of feature/internal/protected (redux) action to remove an audio
* element for given sound identifier from the sounds collection state.
*
* {
* type: _REMOVE_AUDIO_ELEMENT,
* soundId: string
* }
*/
export const _REMOVE_AUDIO_ELEMENT = '_REMOVE_AUDIO_ELEMENT';
/**
* The type of (redux) action to play a sound from the sounds collection.
*
* {
* type: PLAY_SOUND,
* soundId: string
* }
*/
export const PLAY_SOUND = 'PLAY_SOUND';
/**
* The type of (redux) action to register a new sound with the sounds
* collection.
*
* {
* type: REGISTER_SOUND,
* soundId: string
* }
*/
export const REGISTER_SOUND = 'REGISTER_SOUND';
/**
* The type of (redux) action to stop a sound from the sounds collection.
*
* {
* type: STOP_SOUND,
* soundId: string
* }
*/
export const STOP_SOUND = 'STOP_SOUND';
/**
* The type of (redux) action to unregister an existing sound from the sounds
* collection.
*
* {
* type: UNREGISTER_SOUND,
* soundId: string
* }
*/
export const UNREGISTER_SOUND = 'UNREGISTER_SOUND';

View File

@@ -0,0 +1,155 @@
import { IStore } from '../../app/types';
import { getConferenceState } from '../conference/functions';
import { Sounds } from '../config/configType';
import { AudioElement } from '../media/components/AbstractAudio';
import {
PLAY_SOUND,
REGISTER_SOUND,
STOP_SOUND,
UNREGISTER_SOUND,
_ADD_AUDIO_ELEMENT,
_REMOVE_AUDIO_ELEMENT
} from './actionTypes';
import { getSoundsPath } from './functions';
import { getDisabledSounds } from './functions.any';
/**
* Adds {@link AudioElement} instance to the base/sounds feature state for the
* {@link Sound} instance identified by the given id. After this action the
* sound can be played by dispatching the {@link PLAY_SOUND} action.
*
* @param {string} soundId - The sound identifier for which the audio element
* will be stored.
* @param {AudioElement} audioElement - The audio element which implements the
* audio playback functionality and which is backed by the sound resource
* corresponding to the {@link Sound} with the given id.
* @protected
* @returns {{
* type: PLAY_SOUND,
* audioElement: AudioElement,
* soundId: string
* }}
*/
export function _addAudioElement(soundId: string, audioElement: AudioElement) {
return {
type: _ADD_AUDIO_ELEMENT,
audioElement,
soundId
};
}
/**
* The opposite of {@link _addAudioElement} which removes {@link AudioElement}
* for given sound from base/sounds state. It means that the audio resource has
* been disposed and the sound can no longer be played.
*
* @param {string} soundId - The {@link Sound} instance identifier for which the
* audio element is being removed.
* @protected
* @returns {{
* type: _REMOVE_AUDIO_ELEMENT,
* soundId: string
* }}
*/
export function _removeAudioElement(soundId: string) {
return {
type: _REMOVE_AUDIO_ELEMENT,
soundId
};
}
/**
* Starts playback of the sound identified by the given sound id. The action
* will have effect only if the audio resource has been loaded already.
*
* @param {string} soundId - The id of the sound to be played (the same one
* which was used in {@link registerSound} to register the sound).
* @returns {Function}
*/
export function playSound(soundId: string) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const disabledSounds = getDisabledSounds(state);
const { leaving } = getConferenceState(state);
// Skip playing sounds when leaving, to avoid hearing that recording has stopped and so on.
if (leaving) {
return;
}
if (!disabledSounds.includes(soundId as Sounds) && !disabledSounds.find(id => soundId.startsWith(id))) {
dispatch({
type: PLAY_SOUND,
soundId
});
}
};
}
/**
* Registers a new sound for given id and a source object which can be either a
* path or a raw object depending on the platform (native vs web). It will make
* the {@link SoundCollection} render extra HTMLAudioElement which will make it
* available for playback through the {@link playSound} action.
*
* @param {string} soundId - The global identifier which identify the sound
* created for given source object.
* @param {string} soundName - The name of bundled audio file that will be
* associated with the given {@code soundId}.
* @param {Object} options - Optional parameters.
* @param {boolean} options.loop - True in order to loop the sound.
* @returns {{
* type: REGISTER_SOUND,
* soundId: string,
* src: string,
* options: {
* loop: boolean
* }
* }}
*/
export function registerSound(
soundId: string, soundName: string, options: Object = {}) {
return {
type: REGISTER_SOUND,
soundId,
src: `${getSoundsPath()}/${soundName}`,
options
};
}
/**
* Stops playback of the sound identified by the given sound id.
*
* @param {string} soundId - The id of the sound to be stopped (the same one
* which was used in {@link registerSound} to register the sound).
* @returns {{
* type: STOP_SOUND,
* soundId: string
* }}
*/
export function stopSound(soundId: string) {
return {
type: STOP_SOUND,
soundId
};
}
/**
* Unregister the sound identified by the given id. It will make the
* {@link SoundCollection} component stop rendering the corresponding
* {@code HTMLAudioElement} which then should result in the audio resource
* disposal.
*
* @param {string} soundId - The identifier of the {@link Sound} to be removed.
* @returns {{
* type: UNREGISTER_SOUND,
* soundId: string
* }}
*/
export function unregisterSound(soundId: string) {
return {
type: UNREGISTER_SOUND,
soundId
};
}

View File

@@ -0,0 +1,154 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { AudioElement } from '../../media/components/AbstractAudio';
import { Audio } from '../../media/components/index';
import { _addAudioElement, _removeAudioElement } from '../actions';
import { Sound } from '../reducer';
/**
* {@link SoundCollection}'s properties.
*/
interface IProps {
/**
* Dispatches {@link _ADD_AUDIO_ELEMENT} Redux action which will store the
* {@link AudioElement} for a sound in the Redux store.
*/
_addAudioElement: Function;
/**
* Dispatches {@link _REMOVE_AUDIO_ELEMENT} Redux action which will remove
* the sound's {@link AudioElement} from the Redux store.
*/
_removeAudioElement: Function;
/**
* It's the 'base/sounds' reducer's state mapped to a property. It's used to
* render audio elements for every registered sound.
*/
_sounds: Map<string, Sound>;
}
/**
* Collections of all global sounds used by the app for playing audio
* notifications in response to various events. It renders <code>Audio</code>
* element for each sound registered in the base/sounds feature. When the audio
* resource is loaded it will emit add/remove audio element actions which will
* attach the element to the corresponding {@link Sound} instance in the Redux
* state. When that happens the sound can be played using the {@link playSound}
* action.
*/
class SoundCollection extends Component<IProps> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
let key = 0;
const sounds = [];
for (const [ soundId, sound ] of this.props._sounds.entries()) {
const { options, src } = sound;
sounds.push(
React.createElement(
Audio, {
key,
setRef: this._setRef.bind(this, soundId),
src,
loop: options?.loop
}));
key += 1;
}
return sounds;
}
/**
* Set the (reference to the) {@link AudioElement} object which implements
* the audio playback functionality.
*
* @param {string} soundId - The sound Id for the audio element for which
* the callback is being executed.
* @param {AudioElement} element - The {@link AudioElement} instance
* which implements the audio playback functionality.
* @protected
* @returns {void}
*/
_setRef(soundId: string, element?: AudioElement) {
if (element) {
this.props._addAudioElement(soundId, element);
} else {
this.props._removeAudioElement(soundId);
}
}
}
/**
* Maps (parts of) the Redux state to {@code SoundCollection}'s props.
*
* @param {Object} state - The redux state.
* @private
* @returns {{
* _sounds: Map<string, Sound>
* }}
*/
function _mapStateToProps(state: IReduxState) {
return {
_sounds: state['features/base/sounds']
};
}
/**
* Maps dispatching of some actions to React component props.
*
* @param {Function} dispatch - Redux action dispatcher.
* @private
* @returns {{
* _addAudioElement: void,
* _removeAudioElement: void
* }}
*/
export function _mapDispatchToProps(dispatch: IStore['dispatch']) {
return {
/**
* Dispatches action to store the {@link AudioElement} for
* a {@link Sound} identified by given <tt>soundId</tt> in the Redux
* store, so that the playback can be controlled through the Redux
* actions.
*
* @param {string} soundId - A global identifier which will be used to
* identify the {@link Sound} instance for which an audio element will
* be added.
* @param {AudioElement} audioElement - The {@link AudioElement}
* instance that will be stored in the Redux state of the base/sounds
* feature, as part of the {@link Sound} object. At that point the sound
* will be ready for playback.
* @private
* @returns {void}
*/
_addAudioElement(soundId: string, audioElement: AudioElement) {
dispatch(_addAudioElement(soundId, audioElement));
},
/**
* Dispatches action to remove {@link AudioElement} from the Redux
* store for specific {@link Sound}, because it is no longer part of
* the DOM tree and the audio resource will be released.
*
* @param {string} soundId - The id of the {@link Sound} instance for
* which an {@link AudioElement} will be removed from the Redux store.
* @private
* @returns {void}
*/
_removeAudioElement(soundId: string) {
dispatch(_removeAudioElement(soundId));
}
};
}
export default connect(_mapStateToProps, _mapDispatchToProps)(SoundCollection);

View File

@@ -0,0 +1,9 @@
/**
* Returns the location of the sounds. On Android sounds files are copied to
* the 'assets/sounds/' folder of the SDK bundle on build time.
*
* @returns {string}
*/
export function getSoundsPath() {
return 'asset:/sounds';
}

View File

@@ -0,0 +1,11 @@
import { IReduxState } from '../../app/types';
/**
* Selector for retrieving the disabled sounds array.
*
* @param {Object} state - The Redux state.
* @returns {Array<string>} - The disabled sound id's array.
*/
export function getDisabledSounds(state: IReduxState) {
return state['features/base/config'].disabledSounds || [];
}

View File

@@ -0,0 +1,12 @@
import { getSdkBundlePath } from '../../app/functions.native';
/**
* Returns the location of the sounds. On iOS it's the location of the SDK
* bundle on the phone. Each sound file must be added to the SDK's XCode project
* in order to be bundled correctly.
*
* @returns {string}
*/
export function getSoundsPath() {
return getSdkBundlePath();
}

View File

@@ -0,0 +1,27 @@
import { IStore } from '../../app/types';
/**
* Returns the location of the sounds. On Web it's the relative path to
* the sounds folder placed in the source root.
*
* @returns {string}
*/
export function getSoundsPath() {
return 'sounds';
}
/**
* Set new audio output device on the global sound elements.
*
* @param {string } deviceId - The new output deviceId.
* @returns {Function}
*/
export function setNewAudioOutputDevice(deviceId: string) {
return function(_dispatch: IStore['dispatch'], getState: IStore['getState']) {
const sounds = getState()['features/base/sounds'];
for (const [ , sound ] of sounds) {
sound.audioElement?.setSinkId?.(deviceId);
}
};
}

View File

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

View File

@@ -0,0 +1,129 @@
import i18next from 'i18next';
import { registerE2eeAudioFiles } from '../../../features/e2ee/functions';
import { registerRecordingAudioFiles } from '../../../features/recording/functions';
import { IStore } from '../../app/types';
import { AudioSupportedLanguage } from '../media/constants';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import StateListenerRegistry from '../redux/StateListenerRegistry';
import { PLAY_SOUND, STOP_SOUND } from './actionTypes';
import logger from './logger';
/**
* Implements the entry point of the middleware of the feature base/sounds.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case PLAY_SOUND:
_playSound(store, action.soundId);
break;
case STOP_SOUND:
_stopSound(store, action.soundId);
break;
}
return next(action);
});
/**
* Plays sound from audio element registered in the Redux store.
*
* @param {Store} store - The Redux store instance.
* @param {string} soundId - Audio element identifier.
* @private
* @returns {void}
*/
function _playSound({ getState }: IStore, soundId: string) {
const sounds = getState()['features/base/sounds'];
const sound = sounds.get(soundId);
if (sound) {
if (sound.audioElement) {
sound.audioElement.play();
} else {
logger.warn(`PLAY_SOUND: sound not loaded yet for id: ${soundId}`);
}
} else {
logger.warn(`PLAY_SOUND: no sound found for id: ${soundId}`);
}
}
/**
* Stop sound from audio element registered in the Redux store.
*
* @param {Store} store - The Redux store instance.
* @param {string} soundId - Audio element identifier.
* @private
* @returns {void}
*/
function _stopSound({ getState }: IStore, soundId: string) {
const sounds = getState()['features/base/sounds'];
const sound = sounds.get(soundId);
if (sound) {
const { audioElement } = sound;
if (audioElement) {
audioElement.stop();
} else {
logger.warn(`STOP_SOUND: sound not loaded yet for id: ${soundId}`);
}
} else {
logger.warn(`STOP_SOUND: no sound found for id: ${soundId}`);
}
}
/**
* Returns whether the language is supported for audio messages.
*
* @param {string} language - The requested language.
* @returns {boolean}
*/
function isLanguageSupported(language: string): Boolean {
return Boolean(AudioSupportedLanguage[language as keyof typeof AudioSupportedLanguage]);
}
/**
* Checking if it's necessary to reload the translated files.
*
* @param {string} language - The next language.
* @param {string} prevLanguage - The previous language.
* @returns {boolean}
*/
function shouldReloadAudioFiles(language: string, prevLanguage: string): Boolean {
const isNextLanguageSupported = isLanguageSupported(language);
const isPrevLanguageSupported = isLanguageSupported(prevLanguage);
return (
// From an unsupported language (which defaulted to English) to a supported language (that isn't English).
isNextLanguageSupported && language !== AudioSupportedLanguage.en && !isPrevLanguageSupported
) || (
// From a supported language (that wasn't English) to English.
!isNextLanguageSupported && isPrevLanguageSupported && prevLanguage !== AudioSupportedLanguage.en
) || (
// From a supported language to another.
isNextLanguageSupported && isPrevLanguageSupported
);
}
/**
* Set up state change listener for language.
*/
StateListenerRegistry.register(
() => i18next.language,
(language, { dispatch }, prevLanguage): void => {
if (language !== prevLanguage && shouldReloadAudioFiles(language, prevLanguage)) {
registerE2eeAudioFiles(dispatch, true);
registerRecordingAudioFiles(dispatch, true);
}
}
);

View File

@@ -0,0 +1 @@
import './middleware.any';

View File

@@ -0,0 +1,23 @@
import { getAudioOutputDeviceId } from '../devices/functions.web';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import { _ADD_AUDIO_ELEMENT } from './actionTypes';
import './middleware.any';
/**
* Implements the entry point of the middleware of the feature base/sounds.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(_store => next => action => {
switch (action.type) {
case _ADD_AUDIO_ELEMENT:
action.audioElement?.setSinkId?.(getAudioOutputDeviceId());
break;
}
return next(action);
});

View File

@@ -0,0 +1,144 @@
import { AnyAction } from 'redux';
import { AudioElement } from '../media/components/AbstractAudio';
import ReducerRegistry from '../redux/ReducerRegistry';
import { assign } from '../redux/functions';
import {
REGISTER_SOUND,
UNREGISTER_SOUND,
_ADD_AUDIO_ELEMENT,
_REMOVE_AUDIO_ELEMENT
} from './actionTypes';
/**
* The structure use by this reducer to describe a sound.
*/
export type Sound = {
/**
* The HTMLAudioElement which implements the audio playback functionality.
* Becomes available once the sound resource gets loaded and the sound can
* not be played until that happens.
*/
audioElement?: AudioElement;
/**
* This field is container for all optional parameters related to the sound.
*/
options?: {
loop: boolean;
};
/**
* This field describes the source of the audio resource to be played. It
* can be either a path to the file or an object depending on the platform
* (native vs web).
*/
src?: Object | string;
};
/**
* Initial/default state of the feature {@code base/sounds}. It is a {@code Map}
* of globally stored sounds.
*
* @type {Map<string, Sound>}
*/
const DEFAULT_STATE = new Map();
export type ISoundsState = Map<string, Sound>;
/**
* The base/sounds feature's reducer.
*/
ReducerRegistry.register<ISoundsState>(
'features/base/sounds',
(state = DEFAULT_STATE, action): ISoundsState => {
switch (action.type) {
case _ADD_AUDIO_ELEMENT:
case _REMOVE_AUDIO_ELEMENT:
return _addOrRemoveAudioElement(state, action);
case REGISTER_SOUND:
return _registerSound(state, action);
case UNREGISTER_SOUND:
return _unregisterSound(state, action);
default:
return state;
}
});
/**
* Adds or removes {@link AudioElement} associated with a {@link Sound}.
*
* @param {Map<string, Sound>} state - The current Redux state of this feature.
* @param {_ADD_AUDIO_ELEMENT | _REMOVE_AUDIO_ELEMENT} action - The action to be
* handled.
* @private
* @returns {Map<string, Sound>}
*/
function _addOrRemoveAudioElement(state: ISoundsState, action: AnyAction) {
const isAddAction = action.type === _ADD_AUDIO_ELEMENT;
const nextState = new Map(state);
const { soundId } = action;
const sound = nextState.get(soundId);
if (sound) {
if (isAddAction) {
nextState.set(soundId,
assign(sound, {
audioElement: action.audioElement
}));
} else {
nextState.set(soundId,
assign(sound, {
audioElement: undefined
}));
}
}
return nextState;
}
/**
* Registers a new {@link Sound} for given id and source. It will make
* the {@link SoundCollection} component render HTMLAudioElement for given
* source making it available for playback through the redux actions.
*
* @param {Map<string, Sound>} state - The current Redux state of the sounds
* features.
* @param {REGISTER_SOUND} action - The register sound action.
* @private
* @returns {Map<string, Sound>}
*/
function _registerSound(state: ISoundsState, action: AnyAction) {
const nextState = new Map(state);
nextState.set(action.soundId, {
src: action.src,
options: action.options
});
return nextState;
}
/**
* Unregisters a {@link Sound} which will make the {@link SoundCollection}
* component stop rendering the corresponding HTMLAudioElement. This will
* result further in the audio resource disposal.
*
* @param {Map<string, Sound>} state - The current Redux state of this feature.
* @param {UNREGISTER_SOUND} action - The unregister sound action.
* @private
* @returns {Map<string, Sound>}
*/
function _unregisterSound(state: ISoundsState, action: AnyAction) {
const nextState = new Map(state);
nextState.delete(action.soundId);
return nextState;
}