simple-web-screen-recorder
Version:
A simple screen recorder package for web projects with time limits and delay.
291 lines (258 loc) • 12 kB
JavaScript
// 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}`);
}
}