advanced-screen-recorder
Version:
An advanced screen recording utility for the browser with time limits.
280 lines (279 loc) • 15.3 kB
JavaScript
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 };