Files
jitsi-meet/react/features/local-recording/controller/RecordingController.js
Radium Zheng 07bc70c2f5 Implement local recording
index.js of local recording

local-recording(ui): recording button

local-recording(encoding): flac support with libflac.js

Fixes in RecordingController; integration with UI

local-recording(controller): coordinate recording on different clients

local-recording(controller): allow recording on remote participants

local-recording(controller): global singleton

local-recording(controller): use middleware to init LocalRecording

cleanup and documentation in RecordingController

local-recording(refactor): "Delegate" -> "Adapter"

code style

stop eslint and flow from complaining

temp save: client status

fix linter issues

fix some docs; remove global LocalRecording instance

use node.js packaging for libflac.js; remove vendor/ folder

code style: flacEncodeWorker.js

use moment.js to do time diff

remove the use of console.log

code style: flac related files

remove excessive empty lines; and more docs

remove the use of clockTick for UI updates

initalize flacEncodeWorker properly, to avoid premature audio data transmission

move the realization of recordingController events
from LocalRecordingButton to middleware

i18n strings

minor markup changes in LocalRecordingInfoDialog

fix documentation
2018-08-08 09:18:16 +10:00

494 lines
13 KiB
JavaScript

/* @flow */
import { i18next } from '../../base/i18n';
import {
FlacAdapter,
OggAdapter,
WavAdapter
} from '../recording';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* XMPP command for signaling the start of local recording to all clients.
* Should be sent by the moderator only.
*/
const COMMAND_START = 'localRecStart';
/**
* XMPP command for signaling the stop of local recording to all clients.
* Should be sent by the moderator only.
*/
const COMMAND_STOP = 'localRecStop';
/**
* Participant property key for local recording stats.
*/
const PROPERTY_STATS = 'localRecStats';
/**
* Default recording format.
*/
const DEFAULT_RECORDING_FORMAT = 'flac';
/**
* States of the {@code RecordingController}.
*/
const ControllerState = Object.freeze({
/**
* Idle (not recording).
*/
IDLE: Symbol('IDLE'),
/**
* Engaged (recording).
*/
RECORDING: Symbol('RECORDING')
});
/**
* Type of the stats reported by each participant (client).
*/
type RecordingStats = {
/**
* Current local recording session token used by the participant.
*/
currentSessionToken: number,
/**
* Whether local recording is engaged on the participant's device.
*/
isRecording: boolean,
/**
* Total recorded bytes. (Reserved for future use.)
*/
recordedBytes: number,
/**
* Total recording duration. (Reserved for future use.)
*/
recordedLength: number
}
/**
* The component responsible for the coordination of local recording, across
* multiple participants.
* Current implementation requires that there is only one moderator in a room.
*/
class RecordingController {
/**
* For each recording session, there is a separate @{code RecordingAdapter}
* instance so that encoded bits from the previous sessions can still be
* retrieved after they ended.
*
* @private
*/
_adapters = {};
/**
* The {@code JitsiConference} instance.
*
* @private
*/
_conference: * = null;
/**
* Current recording session token.
* Session token is a number generated by the moderator, to ensure every
* client is in the same recording state.
*
* @private
*/
_currentSessionToken: number = -1;
/**
* Current state of {@code RecordingController}.
*
* @private
*/
_state = ControllerState.IDLE;
/**
* Current recording format. This will be in effect from the next
* recording session, i.e., if this value is changed during an on-going
* recording session, that on-going session will not use the new format.
*
* @private
*/
_format = DEFAULT_RECORDING_FORMAT;
/**
* Whether or not the {@code RecordingController} has registered for
* XMPP events. Prevents initialization from happening multiple times.
*
* @private
*/
_registered = false;
/**
* FIXME: callback function for the {@code RecordingController} to notify
* UI it wants to display a notice. Keeps {@code RecordingController}
* decoupled from UI.
*/
onNotify: ?(string) => void;
/**
* FIXME: callback function for the {@code RecordingController} to notify
* UI it wants to display a warning. Keeps {@code RecordingController}
* decoupled from UI.
*/
onWarning: ?(string) => void;
/**
* FIXME: callback function for the {@code RecordingController} to notify
* UI that the local recording state has changed.
*/
onStateChanged: ?(boolean) => void;
/**
* Constructor.
*
* @returns {void}
*/
constructor() {
this._updateStats = this._updateStats.bind(this);
this._onStartCommand = this._onStartCommand.bind(this);
this._onStopCommand = this._onStopCommand.bind(this);
this._doStartRecording = this._doStartRecording.bind(this);
this._doStopRecording = this._doStopRecording.bind(this);
this.registerEvents = this.registerEvents.bind(this);
this.getParticipantsStats = this.getParticipantsStats.bind(this);
}
registerEvents: () => void;
/**
* Registers listeners for XMPP events.
*
* @param {JitsiConference} conference - {@code JitsiConference} instance.
* @returns {void}
*/
registerEvents(conference: Object) {
if (!this._registered) {
this._conference = conference;
if (this._conference) {
this._conference
.addCommandListener(COMMAND_STOP, this._onStopCommand);
this._conference
.addCommandListener(COMMAND_START, this._onStartCommand);
this._registered = true;
}
}
}
/**
* Signals the participants to start local recording.
*
* @returns {void}
*/
startRecording() {
this.registerEvents();
if (this._conference && this._conference.isModerator()) {
this._conference.removeCommand(COMMAND_STOP);
this._conference.sendCommand(COMMAND_START, {
attributes: {
sessionToken: this._getRandomToken(),
format: this._format
}
});
} else {
const message = i18next.t('localRecording.messages.notModerator');
if (this.onWarning) {
this.onWarning(message);
}
}
}
/**
* Signals the participants to stop local recording.
*
* @returns {void}
*/
stopRecording() {
if (this._conference) {
if (this._conference.isModerator) {
this._conference.removeCommand(COMMAND_START);
this._conference.sendCommand(COMMAND_STOP, {
attributes: {
sessionToken: this._currentSessionToken
}
});
} else {
const message
= i18next.t('localRecording.messages.notModerator');
if (this.onWarning) {
this.onWarning(message);
}
}
}
}
/**
* Triggers the download of recorded data.
* Browser only.
*
* @param {number} sessionToken - The token of the session to download.
* @returns {void}
*/
downloadRecordedData(sessionToken: number) {
if (this._adapters[sessionToken]) {
this._adapters[sessionToken].download();
} else {
logger.error(`Invalid session token for download ${sessionToken}`);
}
}
/**
* Switches the recording format.
*
* @param {string} newFormat - The new format.
* @returns {void}
*/
switchFormat(newFormat: string) {
this._format = newFormat;
logger.log(`Recording format switched to ${newFormat}`);
// will be used next time
}
/**
* Returns the local recording stats.
*
* @returns {RecordingStats}
*/
getLocalStats(): RecordingStats {
return {
currentSessionToken: this._currentSessionToken,
isRecording: this._state === ControllerState.RECORDING,
recordedBytes: 0,
recordedLength: 0
};
}
getParticipantsStats: () => *;
/**
* Returns the remote participants' local recording stats.
*
* @returns {*}
*/
getParticipantsStats() {
const members
= this._conference.getParticipants()
.map(member => {
return {
id: member.getId(),
displayName: member.getDisplayName(),
recordingStats:
JSON.parse(member.getProperty(PROPERTY_STATS) || '{}'),
isSelf: false
};
});
// transform into a dictionary,
// for consistent ordering
const result = {};
for (let i = 0; i < members.length; ++i) {
result[members[i].id] = members[i];
}
const localId = this._conference.myUserId();
result[localId] = {
id: localId,
displayName: i18next.t('localRecording.localUser'),
recordingStats: this.getLocalStats(),
isSelf: true
};
return result;
}
_updateStats: () => void;
/**
* Sends out updates about the local recording stats via XMPP.
*
* @private
* @returns {void}
*/
_updateStats() {
if (this._conference) {
this._conference.setLocalParticipantProperty(PROPERTY_STATS,
JSON.stringify(this.getLocalStats()));
}
}
_onStartCommand: (*) => void;
/**
* Callback function for XMPP event.
*
* @private
* @param {*} value - The event args.
* @returns {void}
*/
_onStartCommand(value) {
const { sessionToken, format } = value.attributes;
if (this._state === ControllerState.IDLE) {
this._format = format;
this._currentSessionToken = sessionToken;
this._adapters[sessionToken]
= this._createRecordingAdapter();
this._doStartRecording();
} else if (this._currentSessionToken !== sessionToken) {
// we need to restart the recording
this._doStopRecording().then(() => {
this._format = format;
this._currentSessionToken = sessionToken;
this._adapters[sessionToken]
= this._createRecordingAdapter();
this._doStartRecording();
});
}
}
_onStopCommand: (*) => void;
/**
* Callback function for XMPP event.
*
* @private
* @param {*} value - The event args.
* @returns {void}
*/
_onStopCommand(value) {
if (this._state === ControllerState.RECORDING
&& this._currentSessionToken === value.attributes.sessionToken) {
this._doStopRecording();
}
}
/**
* Generates a token that can be used to distinguish each
* recording session.
*
* @returns {number}
*/
_getRandomToken() {
return Math.floor(Math.random() * 10000) + 1;
}
_doStartRecording: () => void;
/**
* Starts the recording locally.
*
* @private
* @returns {void}
*/
_doStartRecording() {
if (this._state === ControllerState.IDLE) {
this._state = ControllerState.RECORDING;
const delegate = this._adapters[this._currentSessionToken];
delegate.ensureInitialized()
.then(() => delegate.start())
.then(() => {
logger.log('Local recording engaged.');
const message = i18next.t('localRecording.messages.engaged');
if (this.onNotify) {
this.onNotify(message);
}
if (this.onStateChanged) {
this.onStateChanged(true);
}
this._updateStats();
})
.catch(err => {
logger.error('Failed to start local recording.', err);
});
}
}
_doStopRecording: () => Promise<void>;
/**
* Stops the recording locally.
*
* @private
* @returns {Promise<void>}
*/
_doStopRecording() {
if (this._state === ControllerState.RECORDING) {
const token = this._currentSessionToken;
return this._adapters[this._currentSessionToken]
.stop()
.then(() => {
this._state = ControllerState.IDLE;
logger.log('Local recording unengaged.');
this.downloadRecordedData(token);
const message
= i18next.t('localRecording.messages.finished',
{
token
});
if (this.onNotify) {
this.onNotify(message);
}
if (this.onStateChanged) {
this.onStateChanged(false);
}
this._updateStats();
})
.catch(err => {
logger.error('Failed to stop local recording.', err);
});
}
/* eslint-disable */
return (Promise.resolve(): Promise<void>);
// FIXME: better ways to satisfy flow and ESLint at the same time?
/* eslint-enable */
}
/**
* Creates a recording adapter according to the current recording format.
*
* @private
* @returns {RecordingAdapter}
*/
_createRecordingAdapter() {
logger.debug('[RecordingController] creating recording'
+ ` adapter for ${this._format} format.`);
switch (this._format) {
case 'ogg':
return new OggAdapter();
case 'flac':
return new FlacAdapter();
case 'wav':
return new WavAdapter();
default:
throw new Error(`Unknown format: ${this._format}`);
}
}
}
/**
* Global singleton of {@code RecordingController}.
*/
export const recordingController = new RecordingController();