feat: add join/leave sounds on mobile

Adds base/sounds feature which allows other features to register a sound
source under specified id. A new SoundsCollection component will then
render corresponding HTMLAudioElement for each such sound. Once "setRef"
callback is called by the HTMLAudioElement, this element will be added
to the Redux store. When that happens sound can be played through the
new 'playSound' action which will call play() method on the stored
HTMLAudioElement instance.
This commit is contained in:
paweldomas 2018-02-26 13:37:12 -06:00 committed by Lyubo Marinov
parent bfb45ed0e8
commit 60e03e3dec
26 changed files with 862 additions and 100 deletions

View File

@ -29,6 +29,7 @@ dependencies {
compile project(':react-native-immersive')
compile project(':react-native-keep-awake')
compile project(':react-native-locale-detector')
compile project(':react-native-sound')
compile project(':react-native-vector-icons')
compile project(':react-native-webrtc')
}

View File

@ -122,6 +122,7 @@ public class JitsiMeetView extends FrameLayout {
.addPackage(new com.oney.WebRTCModule.WebRTCModulePackage())
.addPackage(new com.RNFetchBlob.RNFetchBlobPackage())
.addPackage(new com.rnimmersive.RNImmersivePackage())
.addPackage(new com.zmxv.RNSound.RNSoundPackage())
.addPackage(new ReactPackageAdapter() {
@Override
public List<NativeModule> createNativeModules(

View File

@ -11,6 +11,8 @@ include ':react-native-keep-awake'
project(':react-native-keep-awake').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-keep-awake/android')
include ':react-native-locale-detector'
project(':react-native-locale-detector').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-locale-detector/android')
include ':react-native-sound'
project(':react-native-sound').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-sound/android')
include ':react-native-vector-icons'
project(':react-native-vector-icons').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-vector-icons/android')
include ':react-native-webrtc'

View File

@ -28,6 +28,7 @@ target 'JitsiMeet' do
pod 'react-native-locale-detector',
:path => '../node_modules/react-native-locale-detector'
pod 'react-native-webrtc', :path => '../node_modules/react-native-webrtc'
pod 'RNSound', :path => '../node_modules/react-native-sound'
pod 'RNVectorIcons', :path => '../node_modules/react-native-vector-icons'
end

View File

@ -41,6 +41,11 @@ PODS:
- React/Core
- React/fishhook
- React/RCTBlob
- RNSound (0.10.4):
- React/Core
- RNSound/Core (= 0.10.4)
- RNSound/Core (0.10.4):
- React/Core
- RNVectorIcons (4.4.2):
- React
- yoga (0.51.0.React)
@ -61,6 +66,7 @@ DEPENDENCIES:
- React/RCTNetwork (from `../node_modules/react-native`)
- React/RCTText (from `../node_modules/react-native`)
- React/RCTWebSocket (from `../node_modules/react-native`)
- RNSound (from `../node_modules/react-native-sound`)
- RNVectorIcons (from `../node_modules/react-native-vector-icons`)
- yoga (from `../node_modules/react-native/ReactCommon/yoga`)
@ -77,6 +83,8 @@ EXTERNAL SOURCES:
:path: ../node_modules/react-native-locale-detector
react-native-webrtc:
:path: ../node_modules/react-native-webrtc
RNSound:
:path: ../node_modules/react-native-sound
RNVectorIcons:
:path: ../node_modules/react-native-vector-icons
yoga:
@ -89,9 +97,10 @@ SPEC CHECKSUMS:
react-native-keep-awake: 0de4bd66de0c23178107dce0c2fcc3354b2a8e94
react-native-locale-detector: d1b2c6fe5abb56e3a1efb6c2d6f308c05c4251f1
react-native-webrtc: bc044ca9530fc802e7533f247aa08fe1b6bf8dc5
RNSound: d0818fe2435254fe30540fae48a429c5ffb72e09
RNVectorIcons: c0dbfbf6068fefa240c37b0f71bd03b45dddac44
yoga: 17521bbb0dd54a47c0b3ac43253e78cdac7488e0
PODFILE CHECKSUM: fabd6b6c27f8e1849f0668db3f403bf536ac8903
PODFILE CHECKSUM: 1e6ce4da1b385720c726f3f131a6aaf08bf9c0ba
COCOAPODS: 1.4.0

View File

@ -503,11 +503,6 @@ UI.addUser = function(user) {
APP.store.dispatch(showParticipantJoinedNotification(displayName));
}
if (!config.startAudioMuted
|| config.startAudioMuted > APP.conference.membersCount) {
UIUtil.playSoundNotification('userJoined');
}
// Add Peer's container
VideoLayout.addParticipantContainer(user);
@ -529,11 +524,6 @@ UI.removeUser = function(id, displayName) {
messageHandler.participantNotification(
displayName, 'notify.somebody', 'disconnected', 'notify.disconnected');
if (!config.startAudioMuted
|| config.startAudioMuted > APP.conference.membersCount) {
UIUtil.playSoundNotification('userLeft');
}
VideoLayout.removeParticipantContainer(id);
};

5
package-lock.json generated
View File

@ -9905,6 +9905,11 @@
"resolved": "https://registry.npmjs.org/react-native-prompt/-/react-native-prompt-1.0.0.tgz",
"integrity": "sha1-QeDsKqfdjxLzo+6Dr51jxLZw+KE="
},
"react-native-sound": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/react-native-sound/-/react-native-sound-0.10.4.tgz",
"integrity": "sha512-V9v4CjKgv8ekQRLOJSoKA7pxJ03F4Ih3T/RfMIlMWLktz7v/O4sdJPjRBLOzZRqAnr9FWTLbSk1ZCjioXh3mjQ=="
},
"react-native-vector-icons": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-4.4.2.tgz",

View File

@ -62,6 +62,7 @@
"react-native-keep-awake": "2.0.6",
"react-native-locale-detector": "github:jitsi/react-native-locale-detector#cc76092fc4335488a28a9529c8b50afae2c3ecdc",
"react-native-prompt": "1.0.0",
"react-native-sound": "0.10.4",
"react-native-vector-icons": "4.4.2",
"react-native-webrtc": "github:jitsi/react-native-webrtc#626818af40384356617f70366133317b6a475171",
"react-redux": "5.0.6",

View File

@ -15,6 +15,7 @@ import {
import '../../base/profile';
import { Fragment, RouteRegistry } from '../../base/react';
import { MiddlewareRegistry, ReducerRegistry } from '../../base/redux';
import { SoundCollection } from '../../base/sounds';
import { PersistenceRegistry } from '../../base/storage';
import { toURLString } from '../../base/util';
import { OverlayContainer } from '../../overlay';
@ -274,6 +275,7 @@ export class AbstractApp extends Component {
<Provider store = { this._getStore() }>
<Fragment>
{ this._createElement(component) }
<SoundCollection />
<OverlayContainer />
</Fragment>
</Provider>
@ -501,7 +503,7 @@ export class AbstractApp extends Component {
/**
* Navigates this {@code AbstractApp} to (i.e. opens) a specific URL.
*
* @param {string|Object} url - The URL to navigate this {@code AbstractApp}
* @param {Object|string} url - The URL to navigate this {@code AbstractApp}
* to (i.e. the URL to open).
* @protected
* @returns {void}

View File

@ -254,7 +254,7 @@ class CalleeInfo extends Component<Props, State> {
if (this.state.renderAudio && this.state.ringing) {
return (
<Audio
ref = { this._setAudio }
setRef = { this._setAudio }
src = './sounds/ring.ogg' />
);
}

View File

@ -1,35 +1,54 @@
// @flow
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Component } from 'react';
/**
* Describes audio element interface used in the base/media feature for audio
* playback.
*/
export type AudioElement = {
play: Function,
pause: Function
}
/**
* {@code AbstractAudio} component's property types.
*/
type Props = {
/**
* A callback which will be called with {@code AbstractAudio} instance once
* the audio element is loaded.
*/
setRef: ?Function,
/**
* The URL of a media resource to use in the element.
*
* NOTE on react-native sound files are imported through 'require' and then
* passed as the 'src' parameter which means their type will be 'any'.
*
* @type {Object | string}
*/
src: Object | string,
stream: Object
}
/**
* The React {@link Component} which is similar to Web's
* {@code HTMLAudioElement}.
*/
export default class AbstractAudio extends Component<*> {
export default class AbstractAudio extends Component<Props> {
/**
* The (reference to the) {@link ReactElement} which actually implements
* this {@code AbstractAudio}.
* The {@link AudioElement} instance which implements the audio playback
* functionality.
*/
_ref: ?Object;
_setRef: Function;
_audioElementImpl: ?AudioElement;
/**
* {@code AbstractAudio} component's property types.
*
* @static
* {@link setAudioElementImpl} bound to <code>this</code>.
*/
static propTypes = {
/**
* The URL of a media resource to use in the element.
*
* @type {string}
*/
src: PropTypes.string,
stream: PropTypes.object
};
setAudioElementImpl: Function;
/**
* Initializes a new {@code AbstractAudio} instance.
@ -41,7 +60,7 @@ export default class AbstractAudio extends Component<*> {
super(props);
// Bind event handlers so they are only bound once for every instance.
this._setRef = this._setRef.bind(this);
this.setAudioElementImpl = this.setAudioElementImpl.bind(this);
}
/**
@ -51,7 +70,7 @@ export default class AbstractAudio extends Component<*> {
* @returns {void}
*/
pause() {
this._ref && typeof this._ref.pause === 'function' && this._ref.pause();
this._audioElementImpl && this._audioElementImpl.pause();
}
/**
@ -61,56 +80,23 @@ export default class AbstractAudio extends Component<*> {
* @returns {void}
*/
play() {
this._ref && typeof this._ref.play === 'function' && this._ref.play();
this._audioElementImpl && this._audioElementImpl.play();
}
/**
* Renders this {@code AbstractAudio} as a React {@link Component} of a
* specific type.
* Set the (reference to the) {@link AudioElement} object which implements
* the audio playback functionality.
*
* @param {string|ReactClass} type - The type of the React {@code Component}
* which is to be rendered.
* @param {Object|undefined} props - The read-only React {@code Component}
* properties, if any, to render. If {@code undefined}, the props of this
* instance will be rendered.
* @param {AudioElement} element - The {@link AudioElement} instance
* which implements the audio playback functionality.
* @protected
* @returns {ReactElement}
*/
_render(type, props) {
const {
children,
/* eslint-disable no-unused-vars */
// The following properties are consumed by React itself so they are
// to not be propagated.
ref,
/* eslint-enable no-unused-vars */
...filteredProps
} = props || this.props;
return (
React.createElement(
type,
{
...filteredProps,
ref: this._setRef
},
children));
}
/**
* Set the (reference to the) {@link ReactElement} which actually implements
* this {@code AbstractAudio}.
*
* @param {Object} ref - The (reference to the) {@code ReactElement} which
* actually implements this {@code AbstractAudio}.
* @private
* @returns {void}
*/
_setRef(ref) {
this._ref = ref;
setAudioElementImpl(element: ?AudioElement) {
this._audioElementImpl = element;
if (typeof this.props.setRef === 'function') {
this.props.setRef(element ? this : null);
}
}
}

View File

@ -1,19 +1,66 @@
/* @flow */
import Sound from 'react-native-sound';
import AbstractAudio from '../AbstractAudio';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* The React Native/mobile {@link Component} which is similar to Web's
* {@code HTMLAudioElement} and wraps around react-native-webrtc's
* {@link RTCView}.
*/
export default class Audio extends AbstractAudio {
/**
* {@code Audio} component's property types.
*
* @static
* Reference to 'react-native-sound} {@link Sound} instance.
*/
static propTypes = AbstractAudio.propTypes;
_sound: Sound
/**
* A callback passed to the 'react-native-sound''s {@link Sound} instance,
* called when loading sound is finished.
*
* @param {Object} error - The error object passed by
* the 'react-native-sound' library.
* @returns {void}
* @private
*/
_soundLoadedCallback(error) {
if (error) {
logger.error('Failed to load sound', error);
} else {
this.setAudioElementImpl(this._sound);
}
}
/**
* Will load the sound, after the component did mount.
*
* @returns {void}
*/
componentDidMount() {
this._sound
= this.props.src
? new Sound(
this.props.src,
this._soundLoadedCallback.bind(this))
: null;
}
/**
* Will dispose sound resources (if any) when component is about to unmount.
*
* @returns {void}
*/
componentWillUnmount() {
if (this._sound) {
this.setAudioElementImpl(null);
this._sound.release();
this._sound = null;
}
}
/**
* Implements React's {@link Component#render()}.

View File

@ -1,5 +1,7 @@
/* @flow */
import React from 'react';
import AbstractAudio from '../AbstractAudio';
/**
@ -8,11 +10,38 @@ import AbstractAudio from '../AbstractAudio';
*/
export default class Audio extends AbstractAudio {
/**
* {@code Audio} component's property types.
*
* @static
* Set to <code>true</code> when the whole file is loaded.
*/
static propTypes = AbstractAudio.propTypes;
_audioFileLoaded: boolean;
/**
* {@link _onCanPlayThrough} bound to "this".
*/
_onCanPlayThrough: Function;
/**
* Reference to the HTML audio element, stored until the file is ready.
*/
_ref: HTMLAudioElement;
/**
* {@link _setRef} bound to "this".
*/
_setRef: Function;
/**
* Creates new <code>Audio</code> element instance with given props.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: Object) {
super(props);
// Bind event handlers so they are only bound once for every instance.
this._onCanPlayThrough = this._onCanPlayThrough.bind(this);
this._setRef = this._setRef.bind(this);
}
/**
* Implements React's {@link Component#render()}.
@ -21,6 +50,60 @@ export default class Audio extends AbstractAudio {
* @returns {ReactElement}
*/
render() {
return super._render('audio');
return (
<audio
onCanPlayThrough = { this._onCanPlayThrough }
preload = 'auto'
ref = { this._setRef }
src = { this.props.src } />
);
}
/**
* If audio element reference has been set and the file has been
* loaded then {@link setAudioElementImpl} will be called to eventually add
* the audio to the Redux store.
*
* @private
* @returns {void}
*/
_maybeSetAudioElementImpl() {
if (this._ref && this._audioFileLoaded) {
this.setAudioElementImpl(this._ref);
}
}
/**
* Called when 'canplaythrough' event is triggered on the audio element,
* which means that the whole file has been loaded.
*
* @private
* @returns {void}
*/
_onCanPlayThrough() {
this._audioFileLoaded = true;
this._maybeSetAudioElementImpl();
}
/**
* Sets the reference to the HTML audio element.
*
* @param {HTMLAudioElement} audioElement - The HTML audio element instance.
* @private
* @returns {void}
*/
_setRef(audioElement: HTMLAudioElement) {
this._ref = audioElement;
if (audioElement) {
this._maybeSetAudioElementImpl();
} else {
// AbstractAudioElement is supposed to trigger "removeAudio" only if
// it was previously added, so it's safe to just call it.
this.setAudioElementImpl(null);
// Reset the loaded flag, as the audio element is being removed from
// the DOM tree.
this._audioFileLoaded = false;
}
}
}

View File

@ -28,6 +28,20 @@ export const LOCAL_PARTICIPANT_DEFAULT_ID = 'local';
*/
export const MAX_DISPLAY_NAME_LENGTH = 50;
/**
* The identifier of the sound to be played when new remote participant joins
* the room.
* @type {string}
*/
export const PARTICIPANT_JOINED_SOUND_ID = 'PARTICIPANT_JOINED_SOUND';
/**
* The identifier of the sound to be played when remote participant leaves
* the room.
* @type {string}
*/
export const PARTICIPANT_LEFT_SOUND_ID = 'PARTICIPANT_LEFT_SOUND';
/**
* The set of possible XMPP MUC roles for conference participants.
*

View File

@ -2,11 +2,13 @@
import UIEvents from '../../../../service/UI/UIEvents';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../../app';
import {
CONFERENCE_JOINED,
CONFERENCE_LEFT
} from '../conference';
import { MiddlewareRegistry } from '../redux';
import { playSound, registerSound, unregisterSound } from '../sounds';
import { localParticipantIdChanged } from './actions';
import {
@ -14,13 +16,23 @@ import {
MUTE_REMOTE_PARTICIPANT,
PARTICIPANT_DISPLAY_NAME_CHANGED,
PARTICIPANT_JOINED,
PARTICIPANT_LEFT,
PARTICIPANT_UPDATED
} from './actionTypes';
import { LOCAL_PARTICIPANT_DEFAULT_ID } from './constants';
import {
LOCAL_PARTICIPANT_DEFAULT_ID,
PARTICIPANT_JOINED_SOUND_ID,
PARTICIPANT_LEFT_SOUND_ID
} from './constants';
import {
getAvatarURLByParticipantId,
getLocalParticipant
getLocalParticipant,
getParticipantCount
} from './functions';
import {
PARTICIPANT_JOINED_SRC,
PARTICIPANT_LEFT_SRC
} from './sounds';
declare var APP: Object;
@ -34,7 +46,18 @@ declare var APP: Object;
MiddlewareRegistry.register(store => next => action => {
const { conference } = store.getState()['features/base/conference'];
if (action.type === PARTICIPANT_JOINED
|| action.type === PARTICIPANT_LEFT) {
_maybePlaySounds(store, action);
}
switch (action.type) {
case APP_WILL_MOUNT:
_registerSounds(store);
break;
case APP_WILL_UNMOUNT:
_unregisterSounds(store);
break;
case CONFERENCE_JOINED:
store.dispatch(localParticipantIdChanged(action.conference.myUserId()));
break;
@ -100,3 +123,59 @@ MiddlewareRegistry.register(store => next => action => {
return next(action);
});
/**
* Plays sounds when participants join/leave conference.
*
* @param {Store} store - The Redux store.
* @param {Action} action - The Redux action. Should be either
* {@link PARTICIPANT_JOINED} or {@link PARTICIPANT_LEFT}.
* @private
* @returns {void}
*/
function _maybePlaySounds({ getState, dispatch }, action) {
const state = getState();
const { startAudioMuted } = state['features/base/config'];
// We're not playing sounds for local participant
// nor when the user is joining past the "startAudioMuted" limit.
// The intention there was to not play user joined notification in big
// conferences where 100th person is joining.
if (!action.participant.local
&& (!startAudioMuted
|| getParticipantCount(state) < startAudioMuted)) {
if (action.type === PARTICIPANT_JOINED) {
dispatch(playSound(PARTICIPANT_JOINED_SOUND_ID));
} else if (action.type === PARTICIPANT_LEFT) {
dispatch(playSound(PARTICIPANT_LEFT_SOUND_ID));
}
}
}
/**
* Registers sounds related with the participants feature.
*
* @param {Store} store - The Redux store.
* @private
* @returns {void}
*/
function _registerSounds({ dispatch }) {
dispatch(
registerSound(PARTICIPANT_JOINED_SOUND_ID, PARTICIPANT_JOINED_SRC));
dispatch(
registerSound(PARTICIPANT_LEFT_SOUND_ID, PARTICIPANT_LEFT_SRC));
}
/**
* Unregisters sounds related with the participants feature.
*
* @param {Store} store - The Redux store.
* @private
* @returns {void}
*/
function _unregisterSounds({ dispatch }) {
dispatch(
unregisterSound(PARTICIPANT_JOINED_SOUND_ID, PARTICIPANT_JOINED_SRC));
dispatch(
unregisterSound(PARTICIPANT_LEFT_SOUND_ID, PARTICIPANT_LEFT_SRC));
}

View File

@ -0,0 +1,13 @@
/**
* Points to the sound file which will be played when new participant joins
* the conference.
*/
export const PARTICIPANT_JOINED_SRC
= require('../../../../sounds/joined.wav');
/**
* Points to the sound file which will be played when any participant leaves
* the conference.
*/
export const PARTICIPANT_LEFT_SRC
= require('../../../../sounds/left.wav');

View File

@ -0,0 +1,11 @@
/**
* Points to the sound file which will be played when new participant joins
* the conference.
*/
export const PARTICIPANT_JOINED_SRC = 'sounds/joined.wav';
/**
* Points to the sound file which will be played when any participant leaves
* the conference.
*/
export const PARTICIPANT_LEFT_SRC = 'sounds/left.wav';

View File

@ -0,0 +1,54 @@
/**
* 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 = Symbol('_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 = Symbol('_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 = Symbol('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 = Symbol('REGISTER_SOUND');
/**
* The type of (redux) action to unregister an existing sound from the sounds
* collection.
*
* {
* type: UNREGISTER_SOUND,
* soundId: string
* }
*/
export const UNREGISTER_SOUND = Symbol('UNREGISTER_SOUND');

View File

@ -0,0 +1,118 @@
// @flow
import type { AudioElement } from '../media';
import {
_ADD_AUDIO_ELEMENT,
_REMOVE_AUDIO_ELEMENT,
PLAY_SOUND,
REGISTER_SOUND,
UNREGISTER_SOUND
} from './actionTypes';
/**
* 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 {{
* type: PLAY_SOUND,
* soundId: string
* }}
*/
export function playSound(soundId: string): Object {
return {
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 {Object|string} src - Either path to an audio file or a raw object
* which specifies the audio resource that will be associated with the given
* {@code soundId}.
* @returns {{
* type: REGISTER_SOUND,
* soundId: string,
* src: (Object | string)
* }}
*/
export function registerSound(soundId: string, src: Object | string): Object {
return {
type: REGISTER_SOUND,
soundId,
src
};
}
/**
* 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): Object {
return {
type: UNREGISTER_SOUND,
soundId
};
}

View File

@ -0,0 +1,160 @@
// @flow
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { Audio } from '../../media';
import type { AudioElement } from '../../media';
import { Fragment } from '../../react';
import { _addAudioElement, _removeAudioElement } from '../actions';
import type { Sound } from '../reducer';
/**
* {@link SoundCollection}'s properties.
*/
type Props = {
/**
* 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<Props> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
let key = 0;
const sounds = [];
for (const [ soundId, sound ] of this.props._sounds.entries()) {
sounds.push(
React.createElement(
Audio, {
key,
setRef: this._setRef.bind(this, soundId),
src: sound.src
}));
key += 1;
}
return (
<Fragment>
{
sounds
}
</Fragment>
);
}
/**
* 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) {
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: Function) {
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 @@
export { default as SoundCollection } from './SoundCollection';

View File

@ -0,0 +1,6 @@
export * from './actions';
export * from './actionTypes';
export * from './components';
import './middleware';
import './reducer';

View File

@ -0,0 +1,46 @@
// @flow
import { MiddlewareRegistry } from '../redux';
import { PLAY_SOUND } from './actionTypes';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* Implements the entry point of the middleware of the feature base/media.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case PLAY_SOUND:
_playSound(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 }, soundId) {
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}`);
}
}

View File

@ -0,0 +1,140 @@
// @flow
import type { AudioElement } from '../media';
import { assign, ReducerRegistry } from '../redux';
import {
_ADD_AUDIO_ELEMENT,
_REMOVE_AUDIO_ELEMENT,
REGISTER_SOUND,
UNREGISTER_SOUND
} from './actionTypes';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* 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 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();
/**
* The base/sounds feature's reducer.
*/
ReducerRegistry.register(
'features/base/sounds',
(state = DEFAULT_STATE, action) => {
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, action) {
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
}));
}
} else {
const actionName
= isAddAction ? '_ADD_AUDIO_ELEMENT' : '_REMOVE_AUDIO_ELEMENT';
logger.error(`${actionName}: no sound for id: ${soundId}`);
}
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, action) {
const nextState = new Map(state);
nextState.set(action.soundId, {
src: action.src
});
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, action) {
const nextState = new Map(state);
nextState.delete(action.soundId);
return nextState;
}

View File

@ -327,12 +327,12 @@ function _standardURIToString(thiz: ?Object) {
* the one accepted by the constructor of Web's ExternalAPI is supported on both
* mobile/React Native and Web/React.
*
* @param {string|Object} obj - The URL to return a {@code String}
* @param {Object|string} obj - The URL to return a {@code String}
* representation of.
* @returns {string} - A {@code String} representation of the specified
* {@code obj} which is supposed to represent a URL.
*/
export function toURLString(obj: ?(string | Object)): ?string {
export function toURLString(obj: ?(Object | string)): ?string {
let str;
switch (typeof obj) {

View File

@ -158,14 +158,6 @@ class Filmstrip extends Component<*> {
onMouseOut = { this._onMouseOut }
onMouseOver = { this._onMouseOver } />
</div>
<audio
id = 'userJoined'
preload = 'auto'
src = 'sounds/joined.wav' />
<audio
id = 'userLeft'
preload = 'auto'
src = 'sounds/left.wav' />
</div>
</div>
);