Initial commit
This commit is contained in:
628
SIG.Web/Scripts/RecordTabNavigation.js
Normal file
628
SIG.Web/Scripts/RecordTabNavigation.js
Normal file
@@ -0,0 +1,628 @@
|
||||
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();
|
||||
*/
|
||||
Reference in New Issue
Block a user