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

688 lines
19 KiB
JavaScript

/* @flow */
import Bourne from '@hapi/bourne';
import { i18next } from '../../base/i18n';
import logger from '../logger';
import {
FlacAdapter,
OggAdapter,
WavAdapter,
downloadBlob
} from '../recording';
import { sessionManager } from '../session';
/**
* 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';
/**
* One-time command used to trigger the moderator to resend the commands.
* This is a workaround for newly-joined clients to receive remote presence.
*/
const COMMAND_PING = 'localRecPing';
/**
* One-time command sent upon receiving a {@code COMMAND_PING}.
* Only the moderator sends this command.
* This command does not carry any information itself, but rather forces the
* XMPP server to resend the remote presence.
*/
const COMMAND_PONG = 'localRecPong';
/**
* Participant property key for local recording stats.
*/
const PROPERTY_STATS = 'localRecStats';
/**
* Supported recording formats.
*/
const RECORDING_FORMATS = new Set([ 'flac', 'wav', 'ogg' ]);
/**
* Default recording format.
*/
const DEFAULT_RECORDING_FORMAT = 'flac';
/**
* States of the {@code RecordingController}.
*/
const ControllerState = Object.freeze({
/**
* Idle (not recording).
*/
IDLE: Symbol('IDLE'),
/**
* Starting.
*/
STARTING: Symbol('STARTING'),
/**
* Engaged (recording).
*/
RECORDING: Symbol('RECORDING'),
/**
* Stopping.
*/
STOPPING: Symbol('STOPPING'),
/**
* Failed, due to error during starting / stopping process.
*/
FAILED: Symbol('FAILED')
});
/**
* 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;
/**
* Whether or not the audio is muted in the UI. This is stored as internal
* state of {@code RecordingController} because we might have recording
* sessions that start muted.
*/
_isMuted = false;
/**
* The ID of the active microphone.
*
* @private
*/
_micDeviceId = 'default';
/**
* 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: ?(messageKey: string, messageParams?: Object) => void;
/**
* FIXME: callback function for the {@code RecordingController} to notify
* UI it wants to display a warning. Keeps {@code RecordingController}
* decoupled from UI.
*/
_onWarning: ?(messageKey: string, messageParams?: Object) => 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.registerEvents = this.registerEvents.bind(this);
this.getParticipantsStats = this.getParticipantsStats.bind(this);
this._onStartCommand = this._onStartCommand.bind(this);
this._onStopCommand = this._onStopCommand.bind(this);
this._onPingCommand = this._onPingCommand.bind(this);
this._doStartRecording = this._doStartRecording.bind(this);
this._doStopRecording = this._doStopRecording.bind(this);
this._updateStats = this._updateStats.bind(this);
this._switchToNewSession = this._switchToNewSession.bind(this);
}
registerEvents: () => void;
/**
* Registers listeners for XMPP events.
*
* @param {JitsiConference} conference - A {@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._conference
.addCommandListener(COMMAND_PING, this._onPingCommand);
this._registered = true;
}
if (!this._conference.isModerator()) {
this._conference.sendCommandOnce(COMMAND_PING, {});
}
}
}
/**
* Sets the event handler for {@code onStateChanged}.
*
* @param {Function} delegate - The event handler.
* @returns {void}
*/
set onStateChanged(delegate: Function) {
this._onStateChanged = delegate;
}
/**
* Sets the event handler for {@code onNotify}.
*
* @param {Function} delegate - The event handler.
* @returns {void}
*/
set onNotify(delegate: Function) {
this._onNotify = delegate;
}
/**
* Sets the event handler for {@code onWarning}.
*
* @param {Function} delegate - The event handler.
* @returns {void}
*/
set onWarning(delegate: Function) {
this._onWarning = delegate;
}
/**
* 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 if (this._onWarning) {
this._onWarning('localRecording.messages.notModerator');
}
}
/**
* 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 if (this._onWarning) {
this._onWarning('localRecording.messages.notModerator');
}
}
}
/**
* 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].exportRecordedData()
.then(args => {
const { data, format } = args;
const filename = `session_${sessionToken}`
+ `_${this._conference.myUserId()}.${format}`;
downloadBlob(data, filename);
})
.catch(error => {
logger.error('Failed to download audio for'
+ ` session ${sessionToken}. Error: ${error}`);
});
} else {
logger.error(`Invalid session token for download ${sessionToken}`);
}
}
/**
* Changes the current microphone.
*
* @param {string} micDeviceId - The new microphone device ID.
* @returns {void}
*/
setMicDevice(micDeviceId: string) {
if (micDeviceId !== this._micDeviceId) {
this._micDeviceId = String(micDeviceId);
if (this._state === ControllerState.RECORDING) {
// sessionManager.endSegment(this._currentSessionToken);
logger.log('Before switching microphone...');
this._adapters[this._currentSessionToken]
.setMicDevice(this._micDeviceId)
.then(() => {
logger.log('Finished switching microphone.');
// sessionManager.beginSegment(this._currentSesoken);
})
.catch(() => {
logger.error('Failed to switch microphone');
});
}
logger.log(`Switch microphone to ${this._micDeviceId}`);
}
}
/**
* Mute or unmute audio. When muted, the ongoing local recording should
* produce silence.
*
* @param {boolean} muted - If the audio should be muted.
* @returns {void}
*/
setMuted(muted: boolean) {
this._isMuted = Boolean(muted);
if (this._state === ControllerState.RECORDING) {
this._adapters[this._currentSessionToken].setMuted(this._isMuted);
}
}
/**
* Switches the recording format.
*
* @param {string} newFormat - The new format.
* @returns {void}
*/
switchFormat(newFormat: string) {
if (!RECORDING_FORMATS.has(newFormat)) {
logger.log(`Unknown format ${newFormat}. Ignoring...`);
return;
}
this._format = newFormat;
logger.log(`Recording format switched to ${newFormat}`);
// the new format will be used in the next recording session
}
/**
* 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:
Bourne.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.me'),
recordingStats: this.getLocalStats(),
isSelf: true
};
return result;
}
_changeState: (symbol) => void;
/**
* Changes the current state of {@code RecordingController}.
*
* @private
* @param {symbol} newState - The new state.
* @returns {void}
*/
_changeState(newState: symbol) {
if (this._state !== newState) {
logger.log(`state change: ${this._state.toString()} -> `
+ `${newState.toString()}`);
this._state = newState;
}
}
_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._changeState(ControllerState.STARTING);
this._switchToNewSession(sessionToken, format);
this._doStartRecording();
} else if (this._state === ControllerState.RECORDING
&& this._currentSessionToken !== sessionToken) {
// There is local recording going on, but not for the same session.
// This means the current state might be out-of-sync with the
// moderator's, so we need to restart the recording.
this._changeState(ControllerState.STOPPING);
this._doStopRecording().then(() => {
this._changeState(ControllerState.STARTING);
this._switchToNewSession(sessionToken, format);
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._changeState(ControllerState.STOPPING);
this._doStopRecording();
}
}
_onPingCommand: () => void;
/**
* Callback function for XMPP event.
*
* @private
* @returns {void}
*/
_onPingCommand() {
if (this._conference.isModerator()) {
logger.log('Received ping, sending pong.');
this._conference.sendCommandOnce(COMMAND_PONG, {});
}
}
/**
* Generates a token that can be used to distinguish each local recording
* session.
*
* @returns {number}
*/
_getRandomToken() {
return Math.floor(Math.random() * 100000000) + 1;
}
_doStartRecording: () => void;
/**
* Starts the recording locally.
*
* @private
* @returns {void}
*/
_doStartRecording() {
if (this._state === ControllerState.STARTING) {
const delegate = this._adapters[this._currentSessionToken];
delegate.start(this._micDeviceId)
.then(() => {
this._changeState(ControllerState.RECORDING);
sessionManager.beginSegment(this._currentSessionToken);
logger.log('Local recording engaged.');
if (this._onNotify) {
this._onNotify('localRecording.messages.engaged');
}
if (this._onStateChanged) {
this._onStateChanged(true);
}
delegate.setMuted(this._isMuted);
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.STOPPING) {
const token = this._currentSessionToken;
return this._adapters[this._currentSessionToken]
.stop()
.then(() => {
this._changeState(ControllerState.IDLE);
sessionManager.endSegment(this._currentSessionToken);
logger.log('Local recording unengaged.');
this.downloadRecordedData(token);
const messageKey
= this._conference.isModerator()
? 'localRecording.messages.finishedModerator'
: 'localRecording.messages.finished';
const messageParams = {
token
};
if (this._onNotify) {
this._onNotify(messageKey, messageParams);
}
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 */
}
_switchToNewSession: (string, string) => void;
/**
* Switches to a new local recording session.
*
* @param {string} sessionToken - The session Token.
* @param {string} format - The recording format for the session.
* @returns {void}
*/
_switchToNewSession(sessionToken, format) {
this._format = format;
this._currentSessionToken = sessionToken;
logger.log(`New session: ${this._currentSessionToken}, `
+ `format: ${this._format}`);
this._adapters[sessionToken]
= this._createRecordingAdapter();
sessionManager.createSession(sessionToken, this._format);
}
/**
* 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();