UNPKG

simple-web-screen-recorder

Version:

A simple screen recorder package for web projects with time limits and delay.

291 lines (258 loc) 12 kB
// src/index.js export class ScreenRecorder { constructor() { this.mediaStream = null; this.mediaRecorder = null; this.recordedBlobs = []; this.recordingStartTime = null; this.timerInterval = null; this.countdownInterval = null; this.onRecordingStart = null; this.onRecordingStop = null; this.onCountdown = null; this.onError = null; } _getMimeType(requestedFormat = 'webm') { const format = requestedFormat.toLowerCase(); let preferredType = null; console.log(`Requested format: ${format}`); const mp4Codecs = [ 'avc1.42E01E', 'avc1.4D401E', 'avc1.64001E', 'h264' ]; const webmCodecs = [ 'vp9,opus', 'vp9', 'vp8,opus', 'vp8' ]; if (format === 'mp4') { for (const codec of mp4Codecs) { const type = `video/mp4; codecs="${codec}"`; if (MediaRecorder.isTypeSupported(type)) { preferredType = type; break; } } if (!preferredType && MediaRecorder.isTypeSupported('video/mp4')) { preferredType = 'video/mp4'; } } else if (format === 'webm') { for (const codec of webmCodecs) { const type = `video/webm; codecs="${codec}"`; if (MediaRecorder.isTypeSupported(type)) { preferredType = type; break; } } if (!preferredType && MediaRecorder.isTypeSupported('video/webm')) { preferredType = 'video/webm'; } } else if (format === 'mkv') { if (MediaRecorder.isTypeSupported('video/x-matroska; codecs=avc1')) { preferredType = 'video/x-matroska; codecs=avc1'; } else if (MediaRecorder.isTypeSupported('video/x-matroska; codecs=vp9')) { preferredType = 'video/x-matroska; codecs=vp9'; } else if (MediaRecorder.isTypeSupported('video/x-matroska')) { preferredType = 'video/x-matroska'; } } // MOV direct recording is generally not supported by MediaRecorder // Fallback logic if (!preferredType) { console.warn(`Format '${format}' not directly supported with specific codecs or as requested, trying general fallbacks.`); if (format === 'mp4' && MediaRecorder.isTypeSupported('video/mp4')) { preferredType = 'video/mp4'; console.log("Falling back to generic 'video/mp4'."); } else { // Default to WebM as the most reliable fallback for (const codec of webmCodecs) { const type = `video/webm; codecs="${codec}"`; if (MediaRecorder.isTypeSupported(type)) { preferredType = type; break; } } if (!preferredType && MediaRecorder.isTypeSupported('video/webm')) { preferredType = 'video/webm'; } if (preferredType) { console.log(`Falling back to ${preferredType}.`); } } } if (!preferredType) { console.error("No suitable MIME type found for MediaRecorder. Recording will likely fail."); if (this.onError) this.onError(new Error("No suitable recording format supported by the browser.")); return { mimeType: null, fileExtension: 'unknown' }; } console.log(`Using effective MIME type: ${preferredType}`); let fileExtension = 'webm'; // Default if (preferredType.includes('mp4')) fileExtension = 'mp4'; else if (preferredType.includes('x-matroska')) fileExtension = 'mkv'; // MOV won't be matched here for recording typically return { mimeType: preferredType, fileExtension: fileExtension }; } async startRecording(options = {}) { const { timeLimit = null, // in seconds delay = 3, // in seconds audio = true, format = 'webm' } = options; if (this.mediaRecorder && this.mediaRecorder.state === "recording") { const err = new Error("Recording already in progress."); console.warn(err.message); if (this.onError) this.onError(err); return Promise.reject(err); // Return a rejected promise } this.recordedBlobs = []; try { this.mediaStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: audio }); this.mediaStream.getVideoTracks()[0].onended = () => { console.log("Screen sharing stopped by user (via browser UI)."); this.stopRecording(false); // Don't try to stop mediaRecorder if already stopped }; if (this.onCountdown) this.onCountdown(delay); let countdown = delay; return new Promise((resolve, reject) => { // Return a promise that resolves/rejects based on countdown/initiation this.countdownInterval = setInterval(() => { countdown--; if (this.onCountdown) this.onCountdown(countdown); if (countdown <= 0) { clearInterval(this.countdownInterval); this.countdownInterval = null; try { this._initiateRecording(timeLimit, format); resolve(); // Resolve when recording initiation starts } catch (initError) { if (this.onError) this.onError(initError); this._cleanup(); reject(initError); // Reject if initiation fails } } }, 1000); }); } catch (err) { console.error("Error acquiring media stream:", err); if (this.onError) this.onError(err); this._cleanup(); return Promise.reject(err); // Return a rejected promise for getDisplayMedia failure } } _initiateRecording(timeLimit, requestedFormat) { const { mimeType, fileExtension } = this._getMimeType(requestedFormat); if (!mimeType) { // Error already handled and onError callback called in _getMimeType this._cleanup(); throw new Error("MIME type selection failed."); // Throw to be caught if startRecording is awaited } try { this.mediaRecorder = new MediaRecorder(this.mediaStream, { mimeType }); } catch (e) { const err = new Error(`Failed to create MediaRecorder: ${e.message}. MimeType: ${mimeType}`); console.error(err.message, e); if (this.onError) this.onError(err); this._cleanup(); throw err; // Throw to be caught } this.mediaRecorder.onstop = (event) => { console.log("Recorder stopped."); if (this.timerInterval) { clearTimeout(this.timerInterval); this.timerInterval = null; } const blob = new Blob(this.recordedBlobs, { type: mimeType }); const fileName = `recording-${new Date().toISOString().replace(/[:.]/g, '-')}.${fileExtension}`; if (this.onRecordingStop) { this.onRecordingStop(blob, fileName, false); // false for 'not cancelled' } this._cleanup(); }; this.mediaRecorder.ondataavailable = (event) => { if (event.data && event.data.size > 0) { this.recordedBlobs.push(event.data); } }; this.mediaRecorder.start(); console.log("MediaRecorder started with MIME type:", this.mediaRecorder.mimeType, this.mediaRecorder); this.recordingStartTime = Date.now(); if (this.onRecordingStart) this.onRecordingStart(); if (timeLimit && timeLimit > 0) { this.timerInterval = setTimeout(() => { if (this.mediaRecorder && this.mediaRecorder.state === "recording") { console.log(`Time limit of ${timeLimit}s reached. Stopping.`); this.stopRecording(); } }, timeLimit * 1000); } } stopRecording(stopMediaRecorderInstance = true) { if (this.countdownInterval) { // Cancelling during countdown clearInterval(this.countdownInterval); this.countdownInterval = null; console.log("Recording cancelled during countdown."); if (this.onRecordingStop) { // Use the onRecordingStop callback to signal cancellation this.onRecordingStop(null, null, true); // blob, filename, cancelled = true } this._cleanup(); return; } if (this.mediaRecorder && this.mediaRecorder.state === "recording") { if (stopMediaRecorderInstance) { this.mediaRecorder.stop(); // This triggers 'onstop' } } else if (this.mediaRecorder && (this.mediaRecorder.state === "inactive" || this.mediaRecorder.state === "paused")) { console.warn("Recorder was already stopped or paused."); // If it was inactive but has data, onstop should have handled it. // If cleanup hasn't run, ensure it does. if (this.recordedBlobs.length > 0 && !this.mediaStream) { // Heuristic: if blobs exist but stream is gone //This might be redundant if onstop is always reliable } this._cleanup(); // Ensure cleanup if called explicitly and not already cleaning up } else { console.warn("Recorder not active or not initialized. Cannot stop."); this._cleanup(); // Still attempt cleanup } if (this.timerInterval) { clearTimeout(this.timerInterval); this.timerInterval = null; } } _cleanup() { console.log("Running cleanup..."); if (this.mediaStream) { this.mediaStream.getTracks().forEach(track => track.stop()); this.mediaStream = null; console.log("MediaStream tracks stopped."); } // MediaRecorder state is handled by its 'onstop' or if it fails to initialize. // Setting to null here ensures it's fresh for next recording. this.mediaRecorder = null; this.recordingStartTime = null; if (this.timerInterval) { clearTimeout(this.timerInterval); this.timerInterval = null; } if (this.countdownInterval) { clearInterval(this.countdownInterval); this.countdownInterval = null; } this.recordedBlobs = []; // Clear blobs after processing or cancellation console.log("Cleanup complete."); } static download(blob, filename) { if (!blob || !filename) { console.error("Blob or filename is missing for download."); return; } const url = URL.createObjectURL(blob); const a = document.createElement("a"); document.body.appendChild(a); a.style.display = "none"; a.href = url; a.download = filename; a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); console.log(`Download initiated for ${filename}`); } }