openreplay/spot/entrypoints/offscreen/main.js
2024-09-10 16:50:26 +02:00

404 lines
10 KiB
JavaScript

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 = [
"video/webm;codecs=h264",
"video/webm;codecs=avc1",
"video/webm;codecs=av1",
"video/mp4;codecs=avc1",
"video/webm;codecs=vp8,opus",
"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(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(e);
this.audioTrack = this.createPlaceholderAudioTrack();
}
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);
}
});