edumeet/app/src/BrowserRecorder.js
2021-06-19 02:22:08 +03:00

575 lines
No EOL
14 KiB
JavaScript

import Logger from './Logger';
import streamSaver from 'streamsaver';
import { WritableStream } from 'web-streams-polyfill/ponyfill';
import { openDB, deleteDB } from 'idb';
import * as meActions from './actions/meActions';
import { store } from './store';
import * as requestActions from './actions/requestActions';
import { RECORDING_PAUSE, RECORDING_RESUME, RECORDING_STOP, RECORDING_START } from './actions/recorderActions';
export default class BrowserRecorder
{
constructor()
{
// react
this.intl = null;
// MediaRecorder
this.recorder = null;
this.recordingMimeType = null;
this.recordingData = [];
this.recorderStream = null;
this.gdmStream = null;
this.roomClient = null;
this.fileName = 'apple.webm';
this.logger = new Logger('Recorder');
// IndexedDB
this.idbDB = null;
this.logToIDB = null;
this.idbName = 'default';
this.idbStoreName = 'chunks';
// MIXER
this.ctx = null;
this.dest = null;
this.gainNode = null;
this.audioConsumersMap = new Map();
this.micProducerId = null;
this.micProducerStreamSource = null;
this.RECORDING_CONSTRAINTS = {
videoBitsPerSecond : 8000000,
video :
{
displaySurface : 'browser',
width : { ideal: 1920 }
},
audio : true,
advanced : [
{ width: 1920, height: 1080 },
{ width: 1280, height: 720 }
]
};
// 10 sec
this.RECORDING_SLICE_SIZE = 10000;
}
mixer(audiotrack, videostream)
{
// AUDIO
if (audiotrack != null)
{
this.ctx.createMediaStreamSource(
new MediaStream([ audiotrack ])
).connect(this.dest);
}
// VIDEO+AUDIO
if (videostream.getAudioTracks().length > 0)
{
this.ctx.createMediaStreamSource(videostream).connect(this.dest);
}
// VIDEOMIX
let tracks = this.dest.stream.getTracks();
tracks = tracks.concat(videostream.getVideoTracks());
return new MediaStream(tracks);
}
async startLocalRecording(
{
roomClient, additionalAudioTracks, recordingMimeType
})
{
this.roomClient = roomClient;
this.recordingMimeType = recordingMimeType;
this.logger.debug('startLocalRecording()');
this.ctx = new AudioContext();
this.dest = this.ctx.createMediaStreamDestination();
this.gainNode = this.ctx.createGain();
this.gainNode.connect(this.dest);
// Check
if (typeof MediaRecorder === undefined)
{
throw new Error('Unsupported media recording API');
}
// Check mimetype is supported by the browser
if (MediaRecorder.isTypeSupported(this.recordingMimeType) === false)
{
throw new Error('Unsupported media recording format %O', this.recordingMimeType);
}
try
{
// Screensharing
this.gdmStream = await navigator.mediaDevices.getDisplayMedia(
this.RECORDING_CONSTRAINTS
);
this.gdmStream.getVideoTracks().forEach((track) =>
{
track.addEventListener('ended', (e) =>
{
this.logger.debug(`gdmStream ${track.kind} track ended event: ${JSON.stringify(e)}`);
this.stopLocalRecording();
});
});
if (additionalAudioTracks.length>0)
{
// add mic track
this.recorderStream = this.mixer(additionalAudioTracks[0], this.gdmStream);
// add other audio tracks
for (let i = 1; i < additionalAudioTracks.length; i++)
{
this.addTrack(additionalAudioTracks[i]);
}
}
else
{
this.recorderStream = this.mixer(null, this.gdmStream);
}
this.recorder = new MediaRecorder(
this.recorderStream, { mimeType: this.recordingMimeType }
);
if (typeof indexedDB === 'undefined' || typeof indexedDB.open === 'undefined')
{
this.logger.warn('IndexedDB API is not available in this browser. Fallback to ');
this.logToIDB = false;
}
else
{
this.idbName = Date.now();
const idbStoreName = this.idbStoreName;
this.idbDB = await openDB(this.idbName, 1,
{
upgrade(db)
{
db.createObjectStore(idbStoreName);
}
}
);
}
let chunkCounter = 0;
// Save a recorded chunk (blob) to indexedDB
const saveToDB = async (data) =>
{
return await this.idbDB.put(this.idbStoreName, data, Date.now());
};
if (this.recorder)
{
this.recorder.ondataavailable = (e) =>
{
if (e.data && e.data.size > 0)
{
chunkCounter++;
this.logger.debug(`put chunk: ${chunkCounter}`);
if (this.logToIDB)
{
try
{
saveToDB(e.data);
}
catch (error)
{
this.logger.error('Error during saving data chunk to IndexedDB! error:%O', error);
}
}
else
{
this.recordingData.push(e.data);
}
}
};
this.recorder.onerror = (error) =>
{
this.logger.err(`Recorder onerror: ${error}`);
switch (error.name)
{
case 'SecurityError':
store.dispatch(requestActions.notify(
{
type : 'error',
text : this.intl.formatMessage({
id : 'room.localRecordingSecurityError',
defaultMessage : 'Recording the specified source is not allowed due to security restrictions. Check you client settings!'
})
}));
break;
case 'InvalidStateError':
default:
throw new Error(error);
}
};
this.recorder.onstop = (e) =>
{
this.logger.debug(`Logger stopped event: ${e}`);
if (this.logToIDB)
{
try
{
const useFallback = false;
if (useFallback)
{
this.idbDB.getAll(this.idbStoreName).then((blobs) =>
{
this.saveRecordingAndCleanup(blobs, this.idbDB, this.idbName);
});
}
else
{
// stream saver
// On firefox WritableStream isn't implemented yet web-streams-polyfill/ponyfill will fix it
if (!window.WritableStream)
{
streamSaver.WritableStream = WritableStream;
}
const fileStream = streamSaver.createWriteStream(`${this.idbName}.webm`, {
// size : blob.size // Makes the procentage visiable in the download
});
const writer = fileStream.getWriter();
this.idbDB.getAllKeys(this.idbStoreName).then((keys) =>
{
// recursive function to save the data from the indexed db
this.saveRecordingWithStreamSaver(
keys, writer, true, this.idbDB, this.idbName
);
});
}
}
catch (error)
{
this.logger.error('Error during getting all data chunks from IndexedDB! error: %O', error);
}
}
else
{
this.saveRecordingAndCleanup(this.recordingData, this.idbDB, this.idbName);
}
};
this.recorder.start(this.RECORDING_SLICE_SIZE);
meActions.setLocalRecordingState(RECORDING_START);
}
}
catch (error)
{
store.dispatch(requestActions.notify(
{
type : 'error',
text : this.intl.formatMessage({
id : 'room.unexpectedErrorDuringLocalRecording',
defaultMessage : 'Unexpected error ocurred during local recording'
})
}));
this.logger.error('startLocalRecording() [error:"%o"]', error);
if (this.recorder) this.recorder.stop();
store.dispatch(meActions.setLocalRecordingState(RECORDING_STOP));
if (typeof this.gdmStream !== 'undefined' && this.gdmStream && typeof this.gdmStream.getTracks === 'function')
{
this.gdmStream.getTracks().forEach((track) => track.stop());
}
this.gdmStream = null;
this.recorderStream = null;
this.recorder = null;
return -1;
}
try
{
await this.roomClient.sendRequest('setLocalRecording', { localRecordingState: RECORDING_START });
store.dispatch(meActions.setLocalRecordingState(RECORDING_START));
store.dispatch(requestActions.notify(
{
text : this.intl.formatMessage({
id : 'room.youStartedLocalRecording',
defaultMessage : 'You started local recording'
})
}));
}
catch (error)
{
store.dispatch(requestActions.notify(
{
type : 'error',
text : this.intl.formatMessage({
id : 'room.unexpectedErrorDuringLocalRecording',
defaultMessage : 'Unexpected error ocurred during local recording'
})
}));
this.logger.error('startLocalRecording() [error:"%o"]', error);
}
}
async stopLocalRecording()
{
this.logger.debug('stopLocalRecording()');
try
{
this.recorder.stop();
store.dispatch(requestActions.notify(
{
text : this.intl.formatMessage({
id : 'room.youStoppedLocalRecording',
defaultMessage : 'You stopped local recording'
})
}));
store.dispatch(meActions.setLocalRecordingState(RECORDING_STOP));
await this.roomClient.sendRequest('setLocalRecording', { localRecordingState: RECORDING_STOP });
}
catch (error)
{
store.dispatch(requestActions.notify(
{
type : 'error',
text : this.intl.formatMessage({
id : 'room.unexpectedErrorDuringLocalRecording',
defaultMessage : 'Unexpected error ocurred during local recording'
})
}));
this.logger.error('stopLocalRecording() [error:"%o"]', error);
}
}
async pauseLocalRecording()
{
this.recorder.pause();
store.dispatch(meActions.setLocalRecordingState(RECORDING_PAUSE));
await this.roomClient.sendRequest('setLocalRecording', { localRecordingState: RECORDING_PAUSE });
}
async resumeLocalRecording()
{
this.recorder.resume();
store.dispatch(meActions.setLocalRecordingState(RECORDING_RESUME));
await this.roomClient.sendRequest('setLocalRecording', { localRecordingState: RECORDING_RESUME });
}
invokeSaveAsDialog(blob)
{
const link = document.createElement('a');
link.style = 'display:none;opacity:0;color:transparent;';
link.href = URL.createObjectURL(blob);
link.download = this.fileName;
(document.body || document.documentElement).appendChild(link);
if (typeof link.click === 'function')
{
link.click();
}
else
{
link.target = '_blank';
link.dispatchEvent(new MouseEvent('click',
{
view : window,
bubbles : true,
cancelable : true
}));
}
URL.revokeObjectURL(link.href);
}
// save recording and destroy
saveRecordingAndCleanup(blobs, db, dbName)
{
// merge blob
const blob = new Blob(blobs, { type: this.recordingMimeType });
// Stop all used video/audio tracks
if (this.recorderStream && this.recorderStream.getTracks().length > 0)
this.recorderStream.getTracks().forEach((track) => track.stop());
if (this.gdmStream && this.gdmStream.getTracks().length > 0)
this.gdmStream.getTracks().forEach((track) => track.stop());
// save as
this.invokeSaveAsDialog(blob, `${dbName}.webm`);
// destroy
this.saveRecordingCleanup(db, dbName);
}
// save recording with Stream saver and destroy
saveRecordingWithStreamSaver(keys, writer, stop = false, db, dbName)
{
let readableStream = null;
let reader = null;
let pump = null;
const key = keys[0];
// on the first call we stop the streams (tab/screen sharing)
if (stop)
{
// Stop all used video/audio tracks
if (this.recorderStream && this.recorderStream.getTracks().length > 0)
this.recorderStream.getTracks().forEach((track) => track.stop());
if (this.gdmStream && this.gdmStream.getTracks().length > 0)
this.gdmStream.getTracks().forEach((track) => track.stop());
}
// we remove the key that we are removing
keys.shift();
db.get(this.idbStoreName, key).then((blob) =>
{
if (keys.length === 0)
{
// if this is the last key we close the writable stream and cleanup the indexedDB
readableStream = blob.stream();
reader = readableStream.getReader();
pump = () => reader.read()
.then((res) => (res.done
? this.saveRecordingCleanup(db, dbName, writer)
: writer.write(res.value).then(pump)));
pump();
}
else
{
// push data to the writable stream
readableStream = blob.stream();
reader = readableStream.getReader();
pump = () => reader.read()
.then((res) => (res.done
? this.saveRecordingWithStreamSaver(keys, writer, false, db, dbName)
: writer.write(res.value).then(pump)));
pump();
}
});
}
saveRecordingCleanup(db, dbName, writer = null)
{
if (writer != null)
{
writer.close();
}
// destroy
db.close();
deleteDB(dbName);
// delete all previouse recordings that might be left in indexedDB
// https://bugzilla.mozilla.org/show_bug.cgi?id=934640
if (indexedDB.databases instanceof Function)
{
indexedDB.databases().then((r) => r.forEach((dbdata) => deleteDB(dbdata.name)));
}
this.recordingMimeType = null;
this.recordingData = [];
this.recorder = null;
this.ctx.close();
}
recoverRecording(dbName)
{
try
{
openDB(dbName, 1).then((db) =>
{
db.getAll(this.idbStoreName).then((blobs) =>
{
this.saveRecordingAndCleanup(blobs, db, dbName);
});
}
);
}
catch (error)
{
this.logger.error('Error during save recovered recording error: %O', error);
}
}
checkMicProducer(producers)
{
// is it already appended to stream?
if (this.recorder != null && (this.recorder.state === 'recording' || this.recorder.state === 'paused'))
{
const micProducer = Object.values(producers).find((p) => p.source === 'mic');
if (micProducer && this.micProducerId != micProducer.id)
{
// delete/dc previous one
if (this.micProducerStreamSource)
{
this.micProducerStreamSource.disconnect(this.dest);
}
this.micProducerStreamSource = this.ctx.createMediaStreamSource(
new MediaStream([ micProducer.track ])
);
this.micProducerStreamSource.connect(this.dest);
// set Mic id
this.micProducerId = micProducer.id;
}
}
}
checkAudioConsumer(consumers)
{
if (this.recorder != null && (this.recorder.state === 'recording' || this.recorder.state === 'paused'))
{
const audioConsumers = Object.values(consumers).filter((p) => p.kind === 'audio');
for (let i = 0; i < audioConsumers.length; i++)
{
if (!this.audioConsumersMap.has(audioConsumers[i].id))
{
const audioConsumerStreamSource = this.ctx.createMediaStreamSource(
new MediaStream([ audioConsumers[i].track ])
);
audioConsumerStreamSource.connect(this.dest);
this.audioConsumersMap.set(audioConsumers[i].id, audioConsumerStreamSource);
}
}
for (const [ consumerId, aCStreamSource ] in this.audioConsumersMap.entries())
{
if (!audioConsumers.find((c) => consumerId == c.id))
{
aCStreamSource.disconnect(this.dest);
this.audioConsumersMap.delete(consumerId);
}
}
}
}
}