413 lines
11 KiB
JavaScript
413 lines
11 KiB
JavaScript
/**
|
|
* 24 MB; hardlimit for video chunk
|
|
* */
|
|
const hardLimit = 24 * 1024 * 1024; // 24 MB
|
|
|
|
function getRecordingSettings(qualityValue) {
|
|
const settingsMap = {
|
|
"4k": { audioBitsPerSecond: 192000, videoBitsPerSecond: 40000000, width: 4096, height: 2160 },
|
|
"1080p": { audioBitsPerSecond: 192000, videoBitsPerSecond: 8000000, width: 1920, height: 1080 },
|
|
// @default
|
|
"720p": { audioBitsPerSecond: 96000, videoBitsPerSecond: 2500000, width: 1280, height: 720 },
|
|
"480p": { audioBitsPerSecond: 96000, videoBitsPerSecond: 2500000, width: 854, height: 480 },
|
|
"360p": { audioBitsPerSecond: 96000, videoBitsPerSecond: 1000000, width: 640, height: 360 },
|
|
"240p": { audioBitsPerSecond: 64000, videoBitsPerSecond: 500000, width: 426, height: 240 },
|
|
};
|
|
|
|
const defaultSettings = { audioBitsPerSecond: 128000, videoBitsPerSecond: 5000000, width: 1920, height: 1080 };
|
|
const { audioBitsPerSecond, videoBitsPerSecond, width, height } = settingsMap[qualityValue] || defaultSettings;
|
|
const duration = 3 * 60 * 1000; // 3 minutes
|
|
|
|
const mimeTypes = [
|
|
// fastest trimming and HLS
|
|
"video/webm;codecs=h264",
|
|
"video/webm;codecs=avc1",
|
|
"video/webm;codecs=av1",
|
|
"video/mp4;codecs=avc1",
|
|
"video/webm;codecs=vp8,opus",
|
|
// best performance
|
|
"video/webm;codecs=vp9,opus",
|
|
];
|
|
|
|
let mimeType = mimeTypes[0];
|
|
|
|
const constrains = {
|
|
frameRate: {
|
|
min: 20,
|
|
max: 30,
|
|
},
|
|
audio: true,
|
|
video: {
|
|
width: {
|
|
exact: width,
|
|
},
|
|
height: {
|
|
exact: height,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = {
|
|
constrains,
|
|
mimeType,
|
|
audioBitsPerSecond,
|
|
videoBitsPerSecond,
|
|
duration,
|
|
};
|
|
return result;
|
|
}
|
|
|
|
class ScreenRecorder {
|
|
isRecording = false;
|
|
videoBlob = null;
|
|
videoUrl = null;
|
|
duration = 0;
|
|
mRecorder;
|
|
videoStream;
|
|
|
|
constructor() {
|
|
this.isRecording = false;
|
|
this.mRecorder = null;
|
|
this.chunks = [];
|
|
this.stream = null;
|
|
this.settings = null;
|
|
this.audioTrack = null;
|
|
}
|
|
|
|
clearAll() {
|
|
this.isRecording = false;
|
|
this.videoBlob = null;
|
|
this.videoUrl = null;
|
|
this.duration = 0;
|
|
this.mRecorder = null;
|
|
this.chunks = [];
|
|
this.stream = null;
|
|
this.audioTrack = null;
|
|
}
|
|
|
|
init(settings) {
|
|
this.settings = settings;
|
|
}
|
|
|
|
durationInt = null;
|
|
trackDuration = () => {
|
|
this.durationInt = setInterval(() => {
|
|
this.duration += 1000;
|
|
}, 1000);
|
|
};
|
|
|
|
clearDurationInterval = () => {
|
|
clearInterval(this.durationInt);
|
|
this.durationInt = null;
|
|
};
|
|
|
|
async startRecording(type, streamId, microphone = false, audioId) {
|
|
this.duration = 0;
|
|
if (this.isRecording) {
|
|
throw new Error("Called startRecording while recording is in progress.");
|
|
}
|
|
const combinedStream = await this._getStream(
|
|
type,
|
|
streamId,
|
|
microphone,
|
|
audioId,
|
|
);
|
|
|
|
this.stream = combinedStream;
|
|
this.mRecorder = new MediaRecorder(combinedStream, {
|
|
mimeType: this.settings.mimeType,
|
|
audioBitsPerSecond: this.settings.audioBitsPerSecond,
|
|
videoBitsPerSecond: this.settings.videoBitsPerSecond,
|
|
videoKeyFrameIntervalDuration: 1000,
|
|
});
|
|
|
|
this.mRecorder.ondataavailable = this._handleDataAvailable;
|
|
this.mRecorder.onstop = this._handleStop;
|
|
|
|
this.mRecorder.start();
|
|
this.isRecording = true;
|
|
this.trackDuration();
|
|
}
|
|
|
|
stop() {
|
|
if (this.mRecorder) {
|
|
this.mRecorder.stop();
|
|
this.mRecorder = null;
|
|
}
|
|
|
|
const clearAll = () => {
|
|
if (this.stream) {
|
|
this.stream.getTracks().forEach((track) => track.stop());
|
|
this.stream = null;
|
|
}
|
|
if (this.audioTrack) {
|
|
this.audioTrack.stop();
|
|
this.audioTrack = null;
|
|
}
|
|
this.isRecording = false;
|
|
this.clearDurationInterval();
|
|
};
|
|
|
|
if (!this.recorded) {
|
|
let tries = 0;
|
|
const int = setInterval(() => {
|
|
if (this.recorded || tries > 20) {
|
|
clearInterval(int);
|
|
clearAll();
|
|
this.recorded = false;
|
|
if (tries > 20) {
|
|
console.error("Failed to stop recording properly");
|
|
}
|
|
}
|
|
tries++;
|
|
}, 250);
|
|
} else {
|
|
clearAll();
|
|
}
|
|
}
|
|
|
|
pause() {
|
|
if (this.mRecorder) {
|
|
this.mRecorder.pause();
|
|
this.clearDurationInterval();
|
|
}
|
|
}
|
|
|
|
resume() {
|
|
if (this.mRecorder) {
|
|
this.mRecorder.resume();
|
|
this.trackDuration();
|
|
}
|
|
}
|
|
|
|
muteMicrophone() {
|
|
if (this.audioTrack) {
|
|
this.audioTrack.enabled = false;
|
|
}
|
|
}
|
|
|
|
async unmuteMicrophone() {
|
|
if (this.audioTrack) {
|
|
this.audioTrack.enabled = true;
|
|
}
|
|
}
|
|
|
|
async _getStream(type, streamId, useMicrophone = false, audioId) {
|
|
const constraints = this.settings;
|
|
this.videoStream = null;
|
|
let microphoneStream = null;
|
|
try {
|
|
this.videoStream = await (type === "tab"
|
|
? navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
mandatory: {
|
|
chromeMediaSource: "tab",
|
|
chromeMediaSourceId: streamId,
|
|
},
|
|
},
|
|
video: {
|
|
mandatory: {
|
|
chromeMediaSource: "tab",
|
|
chromeMediaSourceId: streamId,
|
|
},
|
|
},
|
|
})
|
|
: navigator.mediaDevices.getDisplayMedia({
|
|
...constraints,
|
|
video: {
|
|
...constraints.video,
|
|
displaySurface: "monitor",
|
|
},
|
|
}));
|
|
} catch (e) {
|
|
console.error('get stream error:', e);
|
|
throw e;
|
|
}
|
|
// try {
|
|
// microphoneStream = await navigator.mediaDevices.getUserMedia({
|
|
// audio: { echoCancellation: false, deviceId: audioId },
|
|
// });
|
|
// this.audioTrack = microphoneStream.getAudioTracks()[0];
|
|
// if (!useMicrophone) {
|
|
// this.audioTrack.enabled = false;
|
|
// }
|
|
// } catch (e) {
|
|
// console.error('get audio error', e);
|
|
// }
|
|
try {
|
|
this.audioTrack = this.createPlaceholderAudioTrack();
|
|
} catch (e) {
|
|
console.error('get audio error', e);
|
|
}
|
|
|
|
const existingAudioTracks = this.videoStream.getAudioTracks();
|
|
if (existingAudioTracks.length > 0) {
|
|
existingAudioTracks.forEach(track => track.enabled = true);
|
|
}
|
|
|
|
return new MediaStream([
|
|
...this.videoStream.getVideoTracks(),
|
|
this.audioTrack,
|
|
]);
|
|
}
|
|
|
|
createPlaceholderAudioTrack = () => {
|
|
const audioContext = new (window.AudioContext ||
|
|
window.webkitAudioContext)();
|
|
const oscillator = audioContext.createOscillator();
|
|
const gainNode = audioContext.createGain();
|
|
const destination = audioContext.createMediaStreamDestination();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(destination);
|
|
gainNode.gain.value = 0; // Set volume to 0
|
|
oscillator.start();
|
|
|
|
return destination.stream.getAudioTracks()[0];
|
|
};
|
|
|
|
_handleDataAvailable = (event) => {
|
|
if (event.data.size > 0) {
|
|
this.chunks.push(event.data);
|
|
}
|
|
};
|
|
|
|
recorded = false;
|
|
_handleStop = () => {
|
|
const blob = new Blob(this.chunks, { type: this.settings.mimeType });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
this.videoBlob = blob;
|
|
this.videoUrl = url;
|
|
this.videoStream.getTracks().forEach((track) => track.stop());
|
|
if (this.audioTrack) {
|
|
this.audioTrack.stop();
|
|
}
|
|
this.recorded = true;
|
|
};
|
|
|
|
getVideoData = (iteration = 0) => {
|
|
return new Promise((resolve) => {
|
|
if (this.recorded) {
|
|
resolve({
|
|
blob: this.videoBlob,
|
|
mtype: this.settings.mimeType,
|
|
});
|
|
} else {
|
|
if (iteration > 10 * 1000) {
|
|
return resolve({ blob: null, mtype: null });
|
|
}
|
|
setTimeout(() => {
|
|
resolve(this.getVideoData(iteration++));
|
|
}, 100);
|
|
}
|
|
});
|
|
};
|
|
}
|
|
|
|
let recorder = new ScreenRecorder();
|
|
recorder.init(getRecordingSettings("720p"));
|
|
browser.runtime.onMessage.addListener((message, _, respond) => {
|
|
if (message.target === "offscreen") {
|
|
if (message.type === "offscr:start-recording") {
|
|
recorder.clearAll();
|
|
recorder
|
|
.startRecording(
|
|
message.area,
|
|
message.data,
|
|
message.microphone,
|
|
message.audioId,
|
|
)
|
|
.then(() => {
|
|
respond({ success: true, time: Date.now() });
|
|
})
|
|
.catch(e => {
|
|
console.error(e);
|
|
respond({ success: false, time: Date.now() });
|
|
})
|
|
return true;
|
|
}
|
|
if (message.type === "offscr:get-ts") {
|
|
respond({ time: recorder.duration });
|
|
return true;
|
|
}
|
|
if (message.type === "offscr:check-status") {
|
|
respond({ status: recorder.isRecording, time: recorder.duration });
|
|
return true;
|
|
}
|
|
if (message.type === "offscr:stop-discard") {
|
|
recorder.stop();
|
|
recorder.clearAll();
|
|
}
|
|
if (message.type === "offscr:stop-recording") {
|
|
recorder.stop();
|
|
const duration = recorder.duration;
|
|
recorder.getVideoData().then((data) => {
|
|
if (!data.blob) {
|
|
respond({ status: "empty" });
|
|
}
|
|
convertBlobToBase64(data.blob).then(({ result, size }) => {
|
|
if (size > hardLimit) {
|
|
respond({ status: "parts" });
|
|
result.forEach((chunk, i) => {
|
|
void browser.runtime.sendMessage({
|
|
type: "offscr:video-data-chunk",
|
|
data: chunk,
|
|
index: i,
|
|
total: result.length,
|
|
duration,
|
|
});
|
|
});
|
|
} else {
|
|
respond({ status: "full", base64data: result, duration });
|
|
}
|
|
recorder.clearAll();
|
|
});
|
|
});
|
|
return true;
|
|
}
|
|
if (message.type === "offscr:pause-recording") {
|
|
recorder.pause();
|
|
return "paused";
|
|
}
|
|
if (message.type === "offscr:resume-recording") {
|
|
recorder.resume();
|
|
return "resumed";
|
|
}
|
|
if (message.type === "offscr:mute-microphone") {
|
|
recorder.muteMicrophone();
|
|
return "muted";
|
|
}
|
|
if (message.type === "offscr:unmute-microphone") {
|
|
void recorder.unmuteMicrophone();
|
|
return "unmuted";
|
|
}
|
|
return "miss";
|
|
}
|
|
});
|
|
|
|
const convertBlobToBase64 = (blob) =>
|
|
new Promise((resolve) => {
|
|
const reader = new FileReader();
|
|
try {
|
|
reader.readAsDataURL(blob);
|
|
reader.onloadend = () => {
|
|
const parts = [];
|
|
const base64data = reader.result;
|
|
if (base64data.length > hardLimit) {
|
|
const chunkSize = hardLimit;
|
|
for (let i = 0; i < base64data.length; i += chunkSize) {
|
|
parts.push(base64data.slice(i, i + chunkSize));
|
|
}
|
|
} else {
|
|
parts.push(base64data);
|
|
}
|
|
resolve({
|
|
result: parts,
|
|
size: base64data.length,
|
|
});
|
|
};
|
|
} catch (e) {
|
|
console.error(e, blob, reader);
|
|
}
|
|
});
|