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