Outgoing call ringtones (#2949)

* fix(PresenceLabel): Use translated strings for the presence label.

* feat(sounds): Implements loop and stop functionality.

* feat(invite): Add ringtones.

* fix(invite): Code style issues.
This commit is contained in:
hristoterezov 2018-05-16 10:03:10 -05:00 committed by Дамян Минков
parent ee74f11c3d
commit c344a83376
19 changed files with 362 additions and 14 deletions

View File

@ -577,5 +577,15 @@
"appNotInstalled": "You need the __app__ mobile app to join this meeting on your phone.", "appNotInstalled": "You need the __app__ mobile app to join this meeting on your phone.",
"downloadApp": "Download the app", "downloadApp": "Download the app",
"openApp": "Continue to the app" "openApp": "Continue to the app"
},
"presenceStatus": {
"invited": "Invited",
"ringing": "Ringing",
"calling": "Calling",
"connected": "Connected",
"connecting": "Connecting",
"busy": "Busy",
"rejected": "Rejected",
"ignored": "Ignored"
} }
} }

View File

@ -2,8 +2,10 @@
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { i18next } from '../../../react/features/base/i18n';
import { PresenceLabel } from '../../../react/features/presence-status'; import { PresenceLabel } from '../../../react/features/presence-status';
/* eslint-enable no-unused-vars */ /* eslint-enable no-unused-vars */
@ -456,7 +458,9 @@ export default class LargeVideoManager {
if (presenceLabelContainer.length) { if (presenceLabelContainer.length) {
ReactDOM.render( ReactDOM.render(
<Provider store = { APP.store }> <Provider store = { APP.store }>
<PresenceLabel participantID = { id } /> <I18nextProvider i18n = { i18next }>
<PresenceLabel participantID = { id } />
</I18nextProvider>
</Provider>, </Provider>,
presenceLabelContainer.get(0)); presenceLabelContainer.get(0));
} }

View File

@ -609,7 +609,9 @@ RemoteVideo.prototype.addPresenceLabel = function() {
if (presenceLabelContainer) { if (presenceLabelContainer) {
ReactDOM.render( ReactDOM.render(
<Provider store = { APP.store }> <Provider store = { APP.store }>
<PresenceLabel participantID = { this.id } /> <I18nextProvider i18n = { i18next }>
<PresenceLabel participantID = { this.id } />
</I18nextProvider>
</Provider>, </Provider>,
presenceLabelContainer); presenceLabelContainer);
} }

View File

@ -7,6 +7,7 @@ import { Component } from 'react';
* playback. * playback.
*/ */
export type AudioElement = { export type AudioElement = {
currentTime?: number,
pause: () => void, pause: () => void,
play: () => void, play: () => void,
setSinkId?: string => void setSinkId?: string => void
@ -32,7 +33,8 @@ type Props = {
* @type {Object | string} * @type {Object | string}
*/ */
src: Object | string, src: Object | string,
stream: Object stream: Object,
loop?: ?boolean
} }
/** /**

View File

@ -74,4 +74,17 @@ export default class Audio extends AbstractAudio {
// writing to not render anything. // writing to not render anything.
return null; return null;
} }
/**
* Stops the sound if it's currently playing.
*
* @returns {void}
*/
stop() {
// Currently not implemented for mobile. If needed, a possible
// implementation is:
// if (this._sound) {
// this._sound.stop();
// }
}
} }

View File

@ -41,8 +41,11 @@ export default class Audio extends AbstractAudio {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
render() { render() {
const loop = this.props.loop ? 'true' : null;
return ( return (
<audio <audio
loop = { loop }
onCanPlayThrough = { this._onCanPlayThrough } onCanPlayThrough = { this._onCanPlayThrough }
preload = 'auto' preload = 'auto'
@ -52,6 +55,20 @@ export default class Audio extends AbstractAudio {
); );
} }
/**
* Stops the audio HTML element.
*
* @returns {void}
*/
stop() {
if (this._ref) {
this._ref.pause();
// $FlowFixMe
this._ref.currentTime = 0;
}
}
/** /**
* If audio element reference has been set and the file has been * If audio element reference has been set and the file has been
* loaded then {@link setAudioElementImpl} will be called to eventually add * loaded then {@link setAudioElementImpl} will be called to eventually add

View File

@ -42,6 +42,16 @@ export const PLAY_SOUND = Symbol('PLAY_SOUND');
*/ */
export const REGISTER_SOUND = Symbol('REGISTER_SOUND'); export const REGISTER_SOUND = Symbol('REGISTER_SOUND');
/**
* The type of (redux) action to stop a sound from the sounds collection.
*
* {
* type: STOP_SOUND,
* soundId: string
* }
*/
export const STOP_SOUND = Symbol('STOP_SOUND');
/** /**
* The type of (redux) action to unregister an existing sound from the sounds * The type of (redux) action to unregister an existing sound from the sounds
* collection. * collection.

View File

@ -7,6 +7,7 @@ import {
_REMOVE_AUDIO_ELEMENT, _REMOVE_AUDIO_ELEMENT,
PLAY_SOUND, PLAY_SOUND,
REGISTER_SOUND, REGISTER_SOUND,
STOP_SOUND,
UNREGISTER_SOUND UNREGISTER_SOUND
} from './actionTypes'; } from './actionTypes';
import { getSoundsPath } from './functions'; import { getSoundsPath } from './functions';
@ -84,17 +85,41 @@ export function playSound(soundId: string): Object {
* created for given source object. * created for given source object.
* @param {string} soundName - The name of bundled audio file that will be * @param {string} soundName - The name of bundled audio file that will be
* associated with the given {@code soundId}. * associated with the given {@code soundId}.
* @param {Object} options - Optional paramaters.
* @param {boolean} options.loop - True in order to loop the sound.
* @returns {{ * @returns {{
* type: REGISTER_SOUND, * type: REGISTER_SOUND,
* soundId: string, * soundId: string,
* src: string * src: string,
* options: {
* loop: boolean
* }
* }} * }}
*/ */
export function registerSound(soundId: string, soundName: string): Object { export function registerSound(
soundId: string, soundName: string, options: Object = {}): Object {
return { return {
type: REGISTER_SOUND, type: REGISTER_SOUND,
soundId, soundId,
src: `${getSoundsPath()}/${soundName}` 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): Object {
return {
type: STOP_SOUND,
soundId
}; };
} }

View File

@ -55,12 +55,15 @@ class SoundCollection extends Component<Props> {
const sounds = []; const sounds = [];
for (const [ soundId, sound ] of this.props._sounds.entries()) { for (const [ soundId, sound ] of this.props._sounds.entries()) {
const { options, src } = sound;
sounds.push( sounds.push(
React.createElement( React.createElement(
Audio, { Audio, {
key, key,
setRef: this._setRef.bind(this, soundId), setRef: this._setRef.bind(this, soundId),
src: sound.src src,
loop: options.loop
})); }));
key += 1; key += 1;
} }

View File

@ -2,7 +2,7 @@
import { MiddlewareRegistry } from '../redux'; import { MiddlewareRegistry } from '../redux';
import { PLAY_SOUND } from './actionTypes'; import { PLAY_SOUND, STOP_SOUND } from './actionTypes';
const logger = require('jitsi-meet-logger').getLogger(__filename); const logger = require('jitsi-meet-logger').getLogger(__filename);
@ -17,6 +17,9 @@ MiddlewareRegistry.register(store => next => action => {
case PLAY_SOUND: case PLAY_SOUND:
_playSound(store, action.soundId); _playSound(store, action.soundId);
break; break;
case STOP_SOUND:
_stopSound(store, action.soundId);
break;
} }
return next(action); return next(action);
@ -44,3 +47,28 @@ function _playSound({ getState }, soundId) {
logger.warn(`PLAY_SOUND: no sound found for id: ${soundId}`); 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 }, soundId) {
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}`);
}
}

View File

@ -29,7 +29,12 @@ export type Sound = {
* can be either a path to the file or an object depending on the platform * can be either a path to the file or an object depending on the platform
* (native vs web). * (native vs web).
*/ */
src: Object | string src: Object | string,
/**
* This field is container for all optional parameters related to the sound.
*/
options: Object
} }
/** /**
@ -115,7 +120,8 @@ function _registerSound(state, action) {
const nextState = new Map(state); const nextState = new Map(state);
nextState.set(action.soundId, { nextState.set(action.soundId, {
src: action.src src: action.src,
options: action.options
}); });
return nextState; return nextState;

View File

@ -0,0 +1,14 @@
/**
* The identifier of the sound to be played when the status of an outgoing call
* is ringing.
*
* @type {string}
*/
export const OUTGOING_CALL_RINGING_SOUND_ID = 'OUTGOING_CALL_RINGING_SOUND_ID';
/**
* The identifier of the sound to be played when outgoing call is started.
*
* @type {string}
*/
export const OUTGOING_CALL_START_SOUND_ID = 'OUTGOING_CALL_START_SOUND_ID';

View File

@ -1,11 +1,38 @@
// @flow // @flow
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app';
import {
getParticipantById,
PARTICIPANT_UPDATED,
PARTICIPANT_LEFT
} from '../base/participants';
import { MiddlewareRegistry } from '../base/redux'; import { MiddlewareRegistry } from '../base/redux';
import {
playSound,
registerSound,
stopSound,
unregisterSound
} from '../base/sounds';
import {
CALLING,
INVITED,
RINGING
} from '../presence-status';
import { UPDATE_DIAL_IN_NUMBERS_FAILED } from './actionTypes'; import { UPDATE_DIAL_IN_NUMBERS_FAILED } from './actionTypes';
import {
OUTGOING_CALL_START_SOUND_ID,
OUTGOING_CALL_RINGING_SOUND_ID
} from './constants';
import {
OUTGOING_CALL_START_FILE,
OUTGOING_CALL_RINGING_FILE
} from './sounds';
const logger = require('jitsi-meet-logger').getLogger(__filename); const logger = require('jitsi-meet-logger').getLogger(__filename);
declare var interfaceConfig: Object;
/** /**
* The middleware of the feature invite common to mobile/react-native and * The middleware of the feature invite common to mobile/react-native and
* Web/React. * Web/React.
@ -13,11 +40,66 @@ const logger = require('jitsi-meet-logger').getLogger(__filename);
* @param {Store} store - The redux store. * @param {Store} store - The redux store.
* @returns {Function} * @returns {Function}
*/ */
// eslint-disable-next-line no-unused-vars
MiddlewareRegistry.register(store => next => action => { MiddlewareRegistry.register(store => next => action => {
let oldParticipantPresence;
if (action.type === PARTICIPANT_UPDATED
|| action.type === PARTICIPANT_LEFT) {
oldParticipantPresence
= _getParticipantPresence(store.getState(), action.participant.id);
}
const result = next(action); const result = next(action);
switch (action.type) { switch (action.type) {
case APP_WILL_MOUNT:
store.dispatch(
registerSound(
OUTGOING_CALL_START_SOUND_ID,
OUTGOING_CALL_START_FILE));
store.dispatch(
registerSound(
OUTGOING_CALL_RINGING_SOUND_ID,
OUTGOING_CALL_RINGING_FILE,
{ loop: true }));
break;
case APP_WILL_UNMOUNT:
store.dispatch(unregisterSound(OUTGOING_CALL_START_SOUND_ID));
store.dispatch(unregisterSound(OUTGOING_CALL_RINGING_SOUND_ID));
break;
case PARTICIPANT_LEFT:
case PARTICIPANT_UPDATED: {
const newParticipantPresence
= _getParticipantPresence(store.getState(), action.participant.id);
if (oldParticipantPresence === newParticipantPresence) {
break;
}
switch (oldParticipantPresence) {
case CALLING:
case INVITED:
store.dispatch(stopSound(OUTGOING_CALL_START_SOUND_ID));
break;
case RINGING:
store.dispatch(stopSound(OUTGOING_CALL_RINGING_SOUND_ID));
break;
}
switch (newParticipantPresence) {
case CALLING:
case INVITED:
store.dispatch(playSound(OUTGOING_CALL_START_SOUND_ID));
break;
case RINGING:
store.dispatch(playSound(OUTGOING_CALL_RINGING_SOUND_ID));
}
break;
}
case UPDATE_DIAL_IN_NUMBERS_FAILED: case UPDATE_DIAL_IN_NUMBERS_FAILED:
logger.error( logger.error(
'Error encountered while fetching dial-in numbers:', 'Error encountered while fetching dial-in numbers:',
@ -27,3 +109,24 @@ MiddlewareRegistry.register(store => next => action => {
return result; return result;
}); });
/**
* Returns the presence status of a participant associated with the passed id.
*
* @param {Object} state - The redux state.
* @param {string} id - The id of the participant.
* @returns {string} - The presence status.
*/
function _getParticipantPresence(state, id) {
if (!id) {
return undefined;
}
const participants = state['features/base/participants'];
const participantById = getParticipantById(participants, id);
if (!participantById) {
return undefined;
}
return participantById.presence;
}

View File

@ -0,0 +1,11 @@
/**
* The name of the sound file which will be played when the status of an
* outgoing call is ringing.
*/
export const OUTGOING_CALL_RINGING_FILE = 'outgoingRinging.wav';
/**
* The name of the sound file which will be played when outgoing call is
* started.
*/
export const OUTGOING_CALL_START_FILE = 'outgoingStart.wav';

View File

@ -2,8 +2,11 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { translate } from '../../base/i18n';
import { getParticipantById } from '../../base/participants'; import { getParticipantById } from '../../base/participants';
import { STATUS_TO_I18N_KEY } from '../constants';
/** /**
* React {@code Component} for displaying the current presence status of a * React {@code Component} for displaying the current presence status of a
* participant. * participant.
@ -35,7 +38,12 @@ class PresenceLabel extends Component {
/** /**
* The ID of the participant whose presence status shoul display. * The ID of the participant whose presence status shoul display.
*/ */
participantID: PropTypes.string participantID: PropTypes.string,
/**
* Invoked to obtain translated strings.
*/
t: PropTypes.func
}; };
/** /**
@ -51,10 +59,31 @@ class PresenceLabel extends Component {
<div <div
className className
= { `presence-label ${_presence ? '' : 'no-presence'}` }> = { `presence-label ${_presence ? '' : 'no-presence'}` }>
{ _presence } { this._getPresenceText() }
</div> </div>
); );
} }
/**
* Returns the text associated with the current presence status.
*
* @returns {string}
*/
_getPresenceText() {
const { _presence, t } = this.props;
if (!_presence) {
return null;
}
const i18nKey = STATUS_TO_I18N_KEY[_presence];
if (!i18nKey) { // fallback to status value
return _presence;
}
return t(i18nKey);
}
} }
/** /**
@ -79,4 +108,4 @@ function _mapStateToProps(state, ownProps) {
}; };
} }
export default connect(_mapStateToProps)(PresenceLabel); export default translate(connect(_mapStateToProps)(PresenceLabel));

View File

@ -0,0 +1,70 @@
/**
* Тhe status for a participant when it's invited to a conference.
*
* @type {string}
*/
export const INVITED = 'Invited';
/**
* Тhe status for a participant when a call has been initiated.
*
* @type {string}
*/
export const CALLING = 'Calling';
/**
* Тhe status for a participant when the invite is received and its device(s)
* are ringing.
*
* @type {string}
*/
export const RINGING = 'Ringing';
/**
* A status for a participant that indicates the call is connected.
* NOTE: Currently used for phone numbers only.
*
* @type {string}
*/
export const CONNECTED = 'Connected';
/**
* A status for a participant that indicates the call is in process of
* connecting.
* NOTE: Currently used for phone numbers only.
*
* @type {string}
*/
export const CONNECTING = 'Connecting';
/**
* The status for a participant when the invitation is received but the user
* has responded with busy message.
*/
export const BUSY = 'Busy';
/**
* The status for a participant when the invitation is rejected.
*/
export const REJECTED = 'Rejected';
/**
* The status for a participant when the invitation is ignored.
*/
export const IGNORED = 'Ignored';
/**
* Maps the presence status values to i18n translation keys.
*
* @type {Object<String, String>}
*/
export const STATUS_TO_I18N_KEY = {
'Invited': 'presenceStatus.invited',
'Ringing': 'presenceStatus.ringing',
'Calling': 'presenceStatus.calling',
'Connected': 'presenceStatus.connected',
'Connecting': 'presenceStatus.connecting',
'Busy': 'presenceStatus.busy',
'Rejected': 'presenceStatus.rejected',
'Ignored': 'presenceStatus.ignored'
};

View File

@ -1 +1,2 @@
export * from './components'; export * from './components';
export * from './constants';

BIN
sounds/outgoingRinging.wav Normal file

Binary file not shown.

BIN
sounds/outgoingStart.wav Normal file

Binary file not shown.