jiti-meet/react/features/local-recording/controller/RecordingController.js

494 lines
13 KiB
JavaScript
Raw Normal View History

/* @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();