628 lines
15 KiB
JavaScript
628 lines
15 KiB
JavaScript
const TabRecorder = (() => {
|
|
const qualityProfiles = {
|
|
low: {
|
|
width: 854,
|
|
height: 480,
|
|
frameRate: 15,
|
|
videoBitsPerSecond: 1000000
|
|
},
|
|
medium: {
|
|
width: 1280,
|
|
height: 720,
|
|
frameRate: 30,
|
|
videoBitsPerSecond: 2500000
|
|
},
|
|
high: {
|
|
width: 1920,
|
|
height: 1080,
|
|
frameRate: 30,
|
|
videoBitsPerSecond: 5000000
|
|
},
|
|
veryHigh: {
|
|
width: 1920,
|
|
height: 1080,
|
|
frameRate: 60,
|
|
videoBitsPerSecond: 8000000
|
|
}
|
|
};
|
|
|
|
let displayStream = null;
|
|
let microphoneStream = null;
|
|
let recordingStream = null;
|
|
let mediaRecorder = null;
|
|
|
|
let audioContext = null;
|
|
let audioDestination = null;
|
|
let tabAudioSource = null;
|
|
let microphoneAudioSource = null;
|
|
let tabGainNode = null;
|
|
let microphoneGainNode = null;
|
|
|
|
let tabVolume = 1;
|
|
let microphoneVolume = 1;
|
|
|
|
let recordedChunks = [];
|
|
let stopPromiseResolver = null;
|
|
|
|
function streamIsActive(stream) {
|
|
return stream?.getTracks().some(
|
|
track => track.readyState === "live"
|
|
);
|
|
}
|
|
|
|
function getSupportedMimeType() {
|
|
const mimeTypes = [
|
|
"video/webm;codecs=vp9,opus",
|
|
"video/webm;codecs=vp8,opus",
|
|
"video/webm",
|
|
"video/mp4"
|
|
];
|
|
|
|
return mimeTypes.find(type =>
|
|
MediaRecorder.isTypeSupported(type)
|
|
) || "";
|
|
}
|
|
|
|
function getFileExtension(mimeType) {
|
|
return mimeType.includes("mp4") ? "mp4" : "webm";
|
|
}
|
|
|
|
function downloadRecording(blob, mimeType) {
|
|
const extension = getFileExtension(mimeType);
|
|
|
|
const date = new Date()
|
|
.toISOString()
|
|
.replace(/[:.]/g, "-");
|
|
|
|
const fileName =
|
|
`tab-recording-${date}.${extension}`;
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
|
|
link.href = url;
|
|
link.download = fileName;
|
|
link.style.display = "none";
|
|
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
|
|
setTimeout(() => {
|
|
URL.revokeObjectURL(url);
|
|
}, 30000);
|
|
}
|
|
|
|
async function selectTab() {
|
|
if (!navigator.mediaDevices?.getDisplayMedia) {
|
|
throw new Error(
|
|
"Your browser does not support tab recording."
|
|
);
|
|
}
|
|
|
|
if (streamIsActive(displayStream)) {
|
|
return displayStream;
|
|
}
|
|
|
|
displayStream =
|
|
await navigator.mediaDevices.getDisplayMedia({
|
|
video: {
|
|
displaySurface: "browser",
|
|
width: {
|
|
ideal: 1920
|
|
},
|
|
height: {
|
|
ideal: 1080
|
|
},
|
|
frameRate: {
|
|
ideal: 30,
|
|
max: 60
|
|
}
|
|
},
|
|
|
|
// The user must enable "Share tab audio"
|
|
// in the browser window.
|
|
audio: true,
|
|
|
|
preferCurrentTab: true,
|
|
selfBrowserSurface: "include",
|
|
surfaceSwitching: "exclude",
|
|
systemAudio: "include"
|
|
});
|
|
|
|
const videoTrack =
|
|
displayStream.getVideoTracks()[0];
|
|
|
|
videoTrack.addEventListener("ended", async () => {
|
|
if (
|
|
mediaRecorder &&
|
|
mediaRecorder.state !== "inactive"
|
|
) {
|
|
await stopAndSave();
|
|
}
|
|
|
|
await stopMicrophone();
|
|
await closeAudioMixer();
|
|
|
|
displayStream = null;
|
|
});
|
|
|
|
return displayStream;
|
|
}
|
|
|
|
async function selectMicrophone() {
|
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
throw new Error(
|
|
"Your browser does not support microphone recording."
|
|
);
|
|
}
|
|
|
|
if (streamIsActive(microphoneStream)) {
|
|
return microphoneStream;
|
|
}
|
|
|
|
microphoneStream =
|
|
await navigator.mediaDevices.getUserMedia({
|
|
video: false,
|
|
|
|
audio: {
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true
|
|
}
|
|
});
|
|
|
|
return microphoneStream;
|
|
}
|
|
|
|
async function prepareSources() {
|
|
// The tab-selection permission appears first.
|
|
await selectTab();
|
|
|
|
try {
|
|
// The microphone permission appears after that.
|
|
await selectMicrophone();
|
|
} catch (error) {
|
|
displayStream
|
|
?.getTracks()
|
|
.forEach(track => track.stop());
|
|
|
|
displayStream = null;
|
|
|
|
throw new Error(
|
|
`The microphone could not be opened: ${error.message}`
|
|
);
|
|
}
|
|
}
|
|
|
|
async function createAudioMixer() {
|
|
await closeAudioMixer();
|
|
|
|
const AudioContextClass =
|
|
window.AudioContext ||
|
|
window.webkitAudioContext;
|
|
|
|
if (!AudioContextClass) {
|
|
throw new Error(
|
|
"Your browser does not support audio mixing."
|
|
);
|
|
}
|
|
|
|
audioContext = new AudioContextClass();
|
|
|
|
if (audioContext.state === "suspended") {
|
|
await audioContext.resume();
|
|
}
|
|
|
|
audioDestination =
|
|
audioContext.createMediaStreamDestination();
|
|
|
|
/*
|
|
* Add the tab audio to the final audio track.
|
|
*/
|
|
const tabAudioTracks =
|
|
displayStream.getAudioTracks();
|
|
|
|
if (tabAudioTracks.length > 0) {
|
|
const tabAudioStream =
|
|
new MediaStream(tabAudioTracks);
|
|
|
|
tabAudioSource =
|
|
audioContext.createMediaStreamSource(
|
|
tabAudioStream
|
|
);
|
|
|
|
tabGainNode = audioContext.createGain();
|
|
tabGainNode.gain.value = tabVolume;
|
|
|
|
tabAudioSource.connect(tabGainNode);
|
|
tabGainNode.connect(audioDestination);
|
|
} else {
|
|
console.warn(
|
|
"The selected tab does not contain an audio track. " +
|
|
'Make sure "Share tab audio" is enabled.'
|
|
);
|
|
}
|
|
|
|
/*
|
|
* Add the microphone audio to the final audio track.
|
|
*/
|
|
const microphoneAudioTracks =
|
|
microphoneStream.getAudioTracks();
|
|
|
|
if (microphoneAudioTracks.length > 0) {
|
|
const microphoneAudioStream =
|
|
new MediaStream(microphoneAudioTracks);
|
|
|
|
microphoneAudioSource =
|
|
audioContext.createMediaStreamSource(
|
|
microphoneAudioStream
|
|
);
|
|
|
|
microphoneGainNode =
|
|
audioContext.createGain();
|
|
|
|
microphoneGainNode.gain.value =
|
|
microphoneVolume;
|
|
|
|
microphoneAudioSource.connect(
|
|
microphoneGainNode
|
|
);
|
|
|
|
microphoneGainNode.connect(
|
|
audioDestination
|
|
);
|
|
}
|
|
|
|
/*
|
|
* Do not connect the microphone to
|
|
* audioContext.destination.
|
|
*
|
|
* Doing that could make your voice play through the
|
|
* speakers and cause echo or feedback.
|
|
*/
|
|
}
|
|
|
|
async function start(quality = "medium") {
|
|
if (
|
|
mediaRecorder &&
|
|
mediaRecorder.state !== "inactive"
|
|
) {
|
|
throw new Error(
|
|
"A recording is already running."
|
|
);
|
|
}
|
|
|
|
const profile = qualityProfiles[quality];
|
|
|
|
if (!profile) {
|
|
throw new Error(
|
|
`Invalid video quality: ${quality}`
|
|
);
|
|
}
|
|
|
|
await prepareSources();
|
|
|
|
const videoTrack =
|
|
displayStream.getVideoTracks()[0];
|
|
|
|
try {
|
|
await videoTrack.applyConstraints({
|
|
width: {
|
|
ideal: profile.width
|
|
},
|
|
height: {
|
|
ideal: profile.height
|
|
},
|
|
frameRate: {
|
|
ideal: profile.frameRate,
|
|
max: profile.frameRate
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.warn(
|
|
"The browser could not apply all video settings:",
|
|
error
|
|
);
|
|
}
|
|
|
|
await createAudioMixer();
|
|
|
|
const mixedAudioTracks =
|
|
audioDestination.stream.getAudioTracks();
|
|
|
|
recordingStream = new MediaStream([
|
|
...displayStream.getVideoTracks(),
|
|
...mixedAudioTracks
|
|
]);
|
|
|
|
const mimeType = getSupportedMimeType();
|
|
|
|
const options = {
|
|
videoBitsPerSecond:
|
|
profile.videoBitsPerSecond,
|
|
|
|
audioBitsPerSecond: 192000
|
|
};
|
|
|
|
if (mimeType) {
|
|
options.mimeType = mimeType;
|
|
}
|
|
|
|
recordedChunks = [];
|
|
|
|
mediaRecorder = new MediaRecorder(
|
|
recordingStream,
|
|
options
|
|
);
|
|
|
|
mediaRecorder.addEventListener(
|
|
"dataavailable",
|
|
event => {
|
|
if (event.data && event.data.size > 0) {
|
|
recordedChunks.push(event.data);
|
|
}
|
|
}
|
|
);
|
|
|
|
mediaRecorder.addEventListener(
|
|
"stop",
|
|
async () => {
|
|
const finalMimeType =
|
|
mediaRecorder.mimeType ||
|
|
mimeType ||
|
|
"video/webm";
|
|
|
|
const blob = new Blob(
|
|
recordedChunks,
|
|
{
|
|
type: finalMimeType
|
|
}
|
|
);
|
|
|
|
downloadRecording(
|
|
blob,
|
|
finalMimeType
|
|
);
|
|
|
|
recordedChunks = [];
|
|
recordingStream = null;
|
|
|
|
await closeAudioMixer();
|
|
|
|
if (stopPromiseResolver) {
|
|
stopPromiseResolver(blob);
|
|
stopPromiseResolver = null;
|
|
}
|
|
}
|
|
);
|
|
|
|
mediaRecorder.addEventListener(
|
|
"error",
|
|
event => {
|
|
console.error(
|
|
"Recording error:",
|
|
event.error
|
|
);
|
|
}
|
|
);
|
|
|
|
// Generate a recording chunk every second.
|
|
mediaRecorder.start(1000);
|
|
|
|
return {
|
|
state: mediaRecorder.state,
|
|
quality,
|
|
mimeType: mediaRecorder.mimeType,
|
|
hasTabAudio:
|
|
displayStream.getAudioTracks().length > 0,
|
|
hasMicrophoneAudio:
|
|
microphoneStream.getAudioTracks().length > 0
|
|
};
|
|
}
|
|
|
|
function pause() {
|
|
if (mediaRecorder?.state !== "recording") {
|
|
throw new Error(
|
|
"There is no active recording to pause."
|
|
);
|
|
}
|
|
|
|
mediaRecorder.pause();
|
|
}
|
|
|
|
function resume() {
|
|
if (mediaRecorder?.state !== "paused") {
|
|
throw new Error(
|
|
"There is no paused recording to resume."
|
|
);
|
|
}
|
|
|
|
mediaRecorder.resume();
|
|
}
|
|
|
|
function stopAndSave() {
|
|
if (
|
|
!mediaRecorder ||
|
|
mediaRecorder.state === "inactive"
|
|
) {
|
|
return Promise.resolve(null);
|
|
}
|
|
|
|
return new Promise(resolve => {
|
|
stopPromiseResolver = resolve;
|
|
mediaRecorder.stop();
|
|
});
|
|
}
|
|
|
|
function setMicrophoneVolume(volume) {
|
|
microphoneVolume = Math.max(
|
|
0,
|
|
Math.min(Number(volume), 2)
|
|
);
|
|
|
|
if (microphoneGainNode) {
|
|
microphoneGainNode.gain.value =
|
|
microphoneVolume;
|
|
}
|
|
}
|
|
|
|
function setTabVolume(volume) {
|
|
tabVolume = Math.max(
|
|
0,
|
|
Math.min(Number(volume), 2)
|
|
);
|
|
|
|
if (tabGainNode) {
|
|
tabGainNode.gain.value = tabVolume;
|
|
}
|
|
}
|
|
|
|
function muteMicrophone() {
|
|
setMicrophoneVolume(0);
|
|
}
|
|
|
|
function unmuteMicrophone() {
|
|
setMicrophoneVolume(1);
|
|
}
|
|
|
|
function muteTab() {
|
|
setTabVolume(0);
|
|
}
|
|
|
|
function unmuteTab() {
|
|
setTabVolume(1);
|
|
}
|
|
|
|
async function closeAudioMixer() {
|
|
try {
|
|
tabAudioSource?.disconnect();
|
|
microphoneAudioSource?.disconnect();
|
|
tabGainNode?.disconnect();
|
|
microphoneGainNode?.disconnect();
|
|
} catch (error) {
|
|
console.warn(
|
|
"An audio node could not be disconnected:",
|
|
error
|
|
);
|
|
}
|
|
|
|
if (
|
|
audioContext &&
|
|
audioContext.state !== "closed"
|
|
) {
|
|
await audioContext.close();
|
|
}
|
|
|
|
audioContext = null;
|
|
audioDestination = null;
|
|
tabAudioSource = null;
|
|
microphoneAudioSource = null;
|
|
tabGainNode = null;
|
|
microphoneGainNode = null;
|
|
}
|
|
|
|
async function stopMicrophone() {
|
|
microphoneStream
|
|
?.getTracks()
|
|
.forEach(track => track.stop());
|
|
|
|
microphoneStream = null;
|
|
}
|
|
|
|
async function finishCapture() {
|
|
if (
|
|
mediaRecorder &&
|
|
mediaRecorder.state !== "inactive"
|
|
) {
|
|
await stopAndSave();
|
|
}
|
|
|
|
displayStream
|
|
?.getTracks()
|
|
.forEach(track => track.stop());
|
|
|
|
microphoneStream
|
|
?.getTracks()
|
|
.forEach(track => track.stop());
|
|
|
|
displayStream = null;
|
|
microphoneStream = null;
|
|
recordingStream = null;
|
|
mediaRecorder = null;
|
|
|
|
await closeAudioMixer();
|
|
}
|
|
|
|
function getState() {
|
|
return {
|
|
recordingState:
|
|
mediaRecorder?.state || "inactive",
|
|
|
|
sharingTab:
|
|
streamIsActive(displayStream),
|
|
|
|
usingMicrophone:
|
|
streamIsActive(microphoneStream),
|
|
|
|
hasTabAudio:
|
|
displayStream
|
|
?.getAudioTracks()
|
|
.some(
|
|
track =>
|
|
track.readyState === "live"
|
|
) || false,
|
|
|
|
microphoneVolume,
|
|
tabVolume
|
|
};
|
|
}
|
|
|
|
return {
|
|
start,
|
|
pause,
|
|
resume,
|
|
stopAndSave,
|
|
finishCapture,
|
|
setMicrophoneVolume,
|
|
setTabVolume,
|
|
muteMicrophone,
|
|
unmuteMicrophone,
|
|
muteTab,
|
|
unmuteTab,
|
|
getState
|
|
};
|
|
})();
|
|
|
|
/*
|
|
// Start recording in high quality.
|
|
// The browser requests permission for the tab and microphone.
|
|
await TabRecorder.start("high");
|
|
|
|
// Reduce the tab audio to 70%.
|
|
TabRecorder.setTabVolume(0.7);
|
|
|
|
// Increase the microphone volume slightly.
|
|
TabRecorder.setMicrophoneVolume(1.2);
|
|
|
|
// Temporarily mute the microphone.
|
|
TabRecorder.muteMicrophone();
|
|
|
|
// Enable the microphone again.
|
|
TabRecorder.unmuteMicrophone();
|
|
|
|
// Pause the video.
|
|
TabRecorder.pause();
|
|
|
|
// Continue the same video.
|
|
TabRecorder.resume();
|
|
|
|
// Stop and automatically download the recording.
|
|
await TabRecorder.stopAndSave();
|
|
|
|
// Start another recording with a different quality.
|
|
await TabRecorder.start("medium");
|
|
|
|
// Stop the recording and completely stop sharing.
|
|
await TabRecorder.finishCapture();
|
|
*/ |