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(); */