UNPKG

advanced-screen-recorder

Version:

An advanced screen recording utility for the browser with time limits.

280 lines (279 loc) 15.3 kB
export var AdvancedRecorderStatus; (function (AdvancedRecorderStatus) { AdvancedRecorderStatus["IDLE"] = "idle"; AdvancedRecorderStatus["REQUESTING_PERMISSION"] = "requesting_permission"; AdvancedRecorderStatus["PERMISSION_DENIED"] = "permission_denied"; AdvancedRecorderStatus["RECORDING"] = "recording"; AdvancedRecorderStatus["STOPPED"] = "stopped"; AdvancedRecorderStatus["ERROR"] = "error"; })(AdvancedRecorderStatus || (AdvancedRecorderStatus = {})); const DEFAULT_MEDIA_STREAM_CONSTRAINTS = { video: { // Ensure this is an object by default // Example: width: { ideal: 1920 }, height: { ideal: 1080 } // cursor: 'always' // 'always' is not a standard MediaTrackConstraint value for cursor // displaySurface: 'monitor', // Example, can be 'window', 'browser' }, audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 44100, }, }; const DEFAULT_FILENAME_PREFIX = 'advanced-screen-recording-'; export class AdvancedScreenRecorder { constructor(options = {}) { this.stream = null; this.mediaRecorder = null; this.recordedBlobs = []; this.currentStatus = AdvancedRecorderStatus.IDLE; this.recordingTimerId = null; // For setTimeout ID this.handleExternalStop = () => { // Check if any track is still active. If all relevant tracks (video, and audio if requested) are ended, then stop. // This is to prevent stopping if only one track (e.g., audio) is stopped by user but video is still going. // However, getDisplayMedia typically links the lifetime of tracks. If one stops (e.g. user clicks "Stop sharing"), all stop. if (this.currentStatus === AdvancedRecorderStatus.RECORDING) { const videoTrackActive = this.stream?.getVideoTracks().some(t => t.readyState === 'live'); const audioRequested = !!this.options.mediaStreamConstraints.audio; // true if audio is true or an object const audioTrackActive = audioRequested ? this.stream?.getAudioTracks().some(t => t.readyState === 'live') : true; // if audio not requested, consider it 'not an issue' if (!videoTrackActive || !audioTrackActive) { console.info('A screen sharing track ended (e.g., user clicked "Stop sharing" in browser UI).'); this.stopRecording(); } } }; const resolvedMsc = {}; // Default video constraints (guaranteed to be an object by our const definition) const defaultVideoConstraints = DEFAULT_MEDIA_STREAM_CONSTRAINTS.video; // Default audio constraints (guaranteed to be an object by our const definition) const defaultAudioConstraints = DEFAULT_MEDIA_STREAM_CONSTRAINTS.audio; // Resolve video constraints if (options.mediaStreamConstraints?.video !== undefined) { if (typeof options.mediaStreamConstraints.video === 'boolean') { resolvedMsc.video = options.mediaStreamConstraints.video; } else { // It's MediaTrackConstraints (an object) resolvedMsc.video = { ...defaultVideoConstraints, ...options.mediaStreamConstraints.video }; } } else { // If user provided mediaStreamConstraints but not video, respect that it might be intentionally omitted // However, getDisplayMedia requires at least one of video or audio to be true or an object. // For simplicity here, we'll default to our defined video constraints if options.mediaStreamConstraints.video is entirely absent. resolvedMsc.video = defaultVideoConstraints; } // Resolve audio constraints if (options.mediaStreamConstraints?.audio !== undefined) { if (typeof options.mediaStreamConstraints.audio === 'boolean') { resolvedMsc.audio = options.mediaStreamConstraints.audio; } else { // It's MediaTrackConstraints (an object) resolvedMsc.audio = { ...defaultAudioConstraints, ...options.mediaStreamConstraints.audio }; } } else { resolvedMsc.audio = defaultAudioConstraints; } // Ensure at least one of video or audio is requested if user provides an empty mediaStreamConstraints object if (options.mediaStreamConstraints && resolvedMsc.video === undefined && resolvedMsc.audio === undefined) { // If user passed {} for mediaStreamConstraints, default to enabling video. // Or, one could throw an error if this state is undesirable. console.warn("mediaStreamConstraints provided but both video and audio are undefined/false. Defaulting to video enabled."); resolvedMsc.video = defaultVideoConstraints; } this.options = { mediaStreamConstraints: resolvedMsc, mediaRecorderOptions: options.mediaRecorderOptions, onStatusChange: options.onStatusChange || (() => { }), onRecordingComplete: options.onRecordingComplete || (() => { }), maxRecordingTimeSeconds: options.maxRecordingTimeSeconds, filenamePrefix: options.filenamePrefix || DEFAULT_FILENAME_PREFIX, }; this.updateStatus(AdvancedRecorderStatus.IDLE); } updateStatus(status, details) { this.currentStatus = status; this.options.onStatusChange(status, details); } getStatus() { return this.currentStatus; } async startRecording() { if (this.currentStatus === AdvancedRecorderStatus.RECORDING) { console.warn('Recording is already in progress.'); return; } this.clearRecordingTimer(); // Clear any existing timer if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { const error = new Error('Screen recording API not supported in this browser.'); this.updateStatus(AdvancedRecorderStatus.ERROR, error); throw error; } this.updateStatus(AdvancedRecorderStatus.REQUESTING_PERMISSION); this.recordedBlobs = []; try { this.stream = await navigator.mediaDevices.getDisplayMedia(this.options.mediaStreamConstraints); } catch (error) { console.error('Error getting display media:', error); if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') { this.updateStatus(AdvancedRecorderStatus.PERMISSION_DENIED, 'Permission to access screen was denied.'); } else { this.updateStatus(AdvancedRecorderStatus.ERROR, error); } this.cleanupStreamAndRecorder(); throw error; } // Handle if user stops sharing via browser UI (for video track) this.stream.getVideoTracks()[0]?.addEventListener('ended', this.handleExternalStop); // Handle if user stops sharing via browser UI (for audio track, if present and requested) if (this.options.mediaStreamConstraints.audio && this.stream.getAudioTracks().length > 0) { this.stream.getAudioTracks()[0]?.addEventListener('ended', this.handleExternalStop); } try { let recorderOptions = this.options.mediaRecorderOptions; if (!recorderOptions || !recorderOptions.mimeType) { const preferredMimeTypes = [ 'video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/x-matroska;codecs=avc1,opus', // For MKV if supported 'video/mp4;codecs=h264,aac', // MP4 often needs specific browser support or libraries for muxing 'video/webm', ]; const supportedMimeType = preferredMimeTypes.find(type => MediaRecorder.isTypeSupported(type)); recorderOptions = { ...recorderOptions, mimeType: supportedMimeType || 'video/webm' }; // Fallback } this.mediaRecorder = new MediaRecorder(this.stream, recorderOptions); this.mediaRecorder.ondataavailable = (event) => { if (event.data && event.data.size > 0) { this.recordedBlobs.push(event.data); } }; this.mediaRecorder.onstop = () => { // Timer is cleared by stopRecording or cleanup const mimeType = this.mediaRecorder?.mimeType || 'video/webm'; const blob = new Blob(this.recordedBlobs, { type: mimeType }); const filename = this.generateFilename(mimeType); this.updateStatus(AdvancedRecorderStatus.STOPPED, 'Recording finished.'); this.options.onRecordingComplete(blob, filename); this.cleanupStreamAndRecorder(); // Final cleanup }; this.mediaRecorder.onerror = (event) => { const mediaRecorderError = event.error || new Error('MediaRecorder encountered an unknown error.'); console.error('MediaRecorder error:', mediaRecorderError); this.updateStatus(AdvancedRecorderStatus.ERROR, mediaRecorderError); this.cleanupStreamAndRecorder(); // This will clear the timer }; this.mediaRecorder.start(); // Default timeslice, or could be configurable this.updateStatus(AdvancedRecorderStatus.RECORDING); if (this.options.maxRecordingTimeSeconds && this.options.maxRecordingTimeSeconds > 0) { const durationInSeconds = Number(this.options.maxRecordingTimeSeconds); if (Number.isFinite(durationInSeconds) && durationInSeconds > 0) { console.info(`Recording will automatically stop after ${durationInSeconds} seconds.`); this.recordingTimerId = window.setTimeout(() => { if (this.currentStatus === AdvancedRecorderStatus.RECORDING) { console.info(`Maximum recording time of ${durationInSeconds} seconds reached. Stopping recording.`); this.stopRecording(); } }, durationInSeconds * 1000); } else { console.warn(`Invalid maxRecordingTimeSeconds value: ${this.options.maxRecordingTimeSeconds}. Recording will not be time-limited by this setting.`); } } } catch (error) { console.error('Error setting up MediaRecorder:', error); this.updateStatus(AdvancedRecorderStatus.ERROR, error); this.cleanupStreamAndRecorder(); // This will clear the timer throw error; } } stopRecording() { this.clearRecordingTimer(); if (this.currentStatus !== AdvancedRecorderStatus.RECORDING) { // console.warn(`No active recording to stop. Current status: ${this.currentStatus}`); if (this.stream) { // If stream exists from a failed attempt or already stopped session this.cleanupStreamAndRecorder(); // Only transition to IDLE if not already in a definitive terminal state. if (this.currentStatus !== AdvancedRecorderStatus.ERROR && this.currentStatus !== AdvancedRecorderStatus.PERMISSION_DENIED && this.currentStatus !== AdvancedRecorderStatus.STOPPED) { this.updateStatus(AdvancedRecorderStatus.IDLE); } } return; } if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') { try { this.mediaRecorder.stop(); // This will trigger 'onstop' which handles status update and cleanup } catch (error) { console.error("Error explicitly stopping MediaRecorder:", error); this.updateStatus(AdvancedRecorderStatus.ERROR, new Error("Failed to stop MediaRecorder.")); this.cleanupStreamAndRecorder(); // Force cleanup and status update if stop fails } } else { // If mediaRecorder is already inactive, or doesn't exist (should not happen if status is RECORDING) this.cleanupStreamAndRecorder(); // Ensure cleanup // If status was still RECORDING, it means onstop didn't fire correctly or MR was null. if (this.currentStatus === AdvancedRecorderStatus.RECORDING) { // Redundant check, but safe this.updateStatus(AdvancedRecorderStatus.STOPPED, "Recording stopped; MediaRecorder was inactive or missing."); } } } clearRecordingTimer() { if (this.recordingTimerId !== null) { clearTimeout(this.recordingTimerId); this.recordingTimerId = null; } } cleanupStreamAndRecorder() { this.clearRecordingTimer(); // Ensure timer is always cleared on cleanup if (this.stream) { this.stream.getTracks().forEach(track => { track.removeEventListener('ended', this.handleExternalStop); // Remove listener track.stop(); }); this.stream = null; } // No need to call mediaRecorder.stop() here; this is a cleanup. // If stop was intended, it should have been called, triggering onstop. this.mediaRecorder = null; this.recordedBlobs = []; // Clear any partial data } generateFilename(mimeType) { const date = new Date(); const timestamp = `${date.getFullYear()}${(date.getMonth() + 1).toString().padStart(2, '0')}${date.getDate().toString().padStart(2, '0')}_${date.getHours().toString().padStart(2, '0')}${date.getMinutes().toString().padStart(2, '0')}${date.getSeconds().toString().padStart(2, '0')}`; let extension = '.webm'; // Default if (mimeType) { if (mimeType.includes('mp4')) extension = '.mp4'; else if (mimeType.includes('webm')) extension = '.webm'; else if (mimeType.includes('x-matroska')) extension = '.mkv'; // Add more specific extension mappings if needed based on common mime types } return `${this.options.filenamePrefix}${timestamp}${extension}`; } static downloadBlob(blob, filename) { if (typeof document === 'undefined') { console.error('Cannot download blob: `document` is not available (e.g., running in Node.js).'); return; } const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.style.display = 'none'; a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); // Clean up the DOM URL.revokeObjectURL(url); // Free up memory } } // Export the enum also for consumers who might want to use it directly // e.g. import { AdvancedScreenRecorder, ASPlayerStatus } from 'advanced-screen-recorder'; export { AdvancedRecorderStatus as ASPlayerStatus };