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

View File

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

View File

@ -7,6 +7,7 @@ import { Component } from 'react';
* playback.
*/
export type AudioElement = {
currentTime?: number,
pause: () => void,
play: () => void,
setSinkId?: string => void
@ -32,7 +33,8 @@ type Props = {
* @type {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.
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}
*/
render() {
const loop = this.props.loop ? 'true' : null;
return (
<audio
loop = { loop }
onCanPlayThrough = { this._onCanPlayThrough }
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
* 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');
/**
* 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
* collection.

View File

@ -7,6 +7,7 @@ import {
_REMOVE_AUDIO_ELEMENT,
PLAY_SOUND,
REGISTER_SOUND,
STOP_SOUND,
UNREGISTER_SOUND
} from './actionTypes';
import { getSoundsPath } from './functions';
@ -84,17 +85,41 @@ export function playSound(soundId: string): Object {
* 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 paramaters.
* @param {boolean} options.loop - True in order to loop the sound.
* @returns {{
* type: REGISTER_SOUND,
* 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 {
type: REGISTER_SOUND,
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 = [];
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: sound.src
src,
loop: options.loop
}));
key += 1;
}

View File

@ -2,7 +2,7 @@
import { MiddlewareRegistry } from '../redux';
import { PLAY_SOUND } from './actionTypes';
import { PLAY_SOUND, STOP_SOUND } from './actionTypes';
const logger = require('jitsi-meet-logger').getLogger(__filename);
@ -17,6 +17,9 @@ MiddlewareRegistry.register(store => next => action => {
case PLAY_SOUND:
_playSound(store, action.soundId);
break;
case STOP_SOUND:
_stopSound(store, action.soundId);
break;
}
return next(action);
@ -44,3 +47,28 @@ function _playSound({ getState }, 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
* (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);
nextState.set(action.soundId, {
src: action.src
src: action.src,
options: action.options
});
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
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app';
import {
getParticipantById,
PARTICIPANT_UPDATED,
PARTICIPANT_LEFT
} from '../base/participants';
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 {
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);
declare var interfaceConfig: Object;
/**
* The middleware of the feature invite common to mobile/react-native and
* Web/React.
@ -13,11 +40,66 @@ const logger = require('jitsi-meet-logger').getLogger(__filename);
* @param {Store} store - The redux store.
* @returns {Function}
*/
// eslint-disable-next-line no-unused-vars
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);
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:
logger.error(
'Error encountered while fetching dial-in numbers:',
@ -27,3 +109,24 @@ MiddlewareRegistry.register(store => next => action => {
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 { connect } from 'react-redux';
import { translate } from '../../base/i18n';
import { getParticipantById } from '../../base/participants';
import { STATUS_TO_I18N_KEY } from '../constants';
/**
* React {@code Component} for displaying the current presence status of a
* participant.
@ -35,7 +38,12 @@ class PresenceLabel extends Component {
/**
* 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
className
= { `presence-label ${_presence ? '' : 'no-presence'}` }>
{ _presence }
{ this._getPresenceText() }
</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 './constants';

BIN
sounds/outgoingRinging.wav Normal file

Binary file not shown.

BIN
sounds/outgoingStart.wav Normal file

Binary file not shown.