import type { ProducerSensorDriver, ConnectionStatus, SensorFrame, SensorStream, VideoStreamConfig, MediaRecorderProducerConfig, FrameCallback, StreamUpdateCallback, StatusChangeCallback, UnsubscribeFn } from "../types/index.js"; /** * MediaRecorder Producer Driver * * Captures video/audio from browser MediaDevices using MediaRecorder API. * Simplified with best practices - uses WebM format and optimized settings. */ export class MediaRecorderProducer implements ProducerSensorDriver { readonly type = "producer" as const; readonly id: string; readonly name: string; private _status: ConnectionStatus = { isConnected: false }; private config: MediaRecorderProducerConfig; // MediaRecorder state private mediaStream: MediaStream | null = null; private mediaRecorder: MediaRecorder | null = null; private recordingDataChunks: Blob[] = []; // Stream management private activeStreams = new Map(); // Event callbacks private frameCallbacks: FrameCallback[] = []; private streamUpdateCallbacks: StreamUpdateCallback[] = []; private statusCallbacks: StatusChangeCallback[] = []; constructor(config: MediaRecorderProducerConfig) { this.config = config; this.id = `media-recorder-${Date.now()}`; this.name = "MediaRecorder Producer"; console.log("πŸŽ₯ Created MediaRecorder producer driver"); } get status(): ConnectionStatus { return this._status; } async connect(): Promise { console.log("πŸŽ₯ Connecting MediaRecorder producer..."); try { // Check if browser supports MediaRecorder if (!MediaRecorder.isTypeSupported) { throw new Error("MediaRecorder not supported in this browser"); } // Test basic media access const testStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); // Close test stream immediately testStream.getTracks().forEach(track => track.stop()); this._status = { isConnected: true, lastConnected: new Date(), error: undefined }; this.notifyStatusChange(); console.log("βœ… MediaRecorder producer connected successfully"); } catch (error) { this._status = { isConnected: false, error: `Connection failed: ${error}` }; this.notifyStatusChange(); throw error; } } async disconnect(): Promise { console.log("πŸŽ₯ Disconnecting MediaRecorder producer..."); // Stop all active streams for (const streamId of this.activeStreams.keys()) { await this.stopStream(streamId); } this._status = { isConnected: false }; this.notifyStatusChange(); console.log("βœ… MediaRecorder producer disconnected"); } async startStream(config: VideoStreamConfig): Promise { if (!this._status.isConnected) { throw new Error("Cannot start stream: producer not connected"); } console.log("πŸŽ₯ Starting MediaRecorder stream...", config); try { // Prepare media constraints with best practices const constraints: MediaStreamConstraints = { video: { width: config.width || 1280, height: config.height || 720, frameRate: config.frameRate || 30, facingMode: config.facingMode || "user", ...(config.deviceId && { deviceId: config.deviceId }) }, audio: true, ...this.config.constraints }; // Get media stream this.mediaStream = await navigator.mediaDevices.getUserMedia(constraints); // Create MediaRecorder with optimized WebM settings const mimeType = this.getBestWebMType(); this.mediaRecorder = new MediaRecorder(this.mediaStream, { mimeType, videoBitsPerSecond: this.config.videoBitsPerSecond || 2500000, audioBitsPerSecond: this.config.audioBitsPerSecond || 128000 }); // Create stream object const stream: SensorStream = { id: `stream-${Date.now()}`, name: `MediaRecorder Stream ${config.width}x${config.height}`, type: "video", config, active: true, startTime: new Date(), totalFrames: 0 }; this.activeStreams.set(stream.id, stream); // Set up MediaRecorder event handlers this.setupMediaRecorderEvents(stream); // Start recording with optimized interval const recordingInterval = this.config.recordingInterval || 100; this.mediaRecorder.start(recordingInterval); // Update status with stream info this._status.frameRate = config.frameRate; this._status.bitrate = this.config.videoBitsPerSecond; this.notifyStatusChange(); this.notifyStreamUpdate(stream); console.log(`βœ… MediaRecorder stream started: ${stream.id}`); return stream; } catch (error) { console.error("❌ Failed to start MediaRecorder stream:", error); throw error; } } async stopStream(streamId: string): Promise { console.log(`πŸŽ₯ Stopping MediaRecorder stream: ${streamId}`); const stream = this.activeStreams.get(streamId); if (!stream) { throw new Error(`Stream not found: ${streamId}`); } try { // Stop MediaRecorder if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") { this.mediaRecorder.stop(); } // Stop media stream tracks if (this.mediaStream) { this.mediaStream.getTracks().forEach(track => track.stop()); this.mediaStream = null; } // Update stream stream.active = false; stream.endTime = new Date(); this.activeStreams.delete(streamId); this.notifyStreamUpdate(stream); console.log(`βœ… MediaRecorder stream stopped: ${streamId}`); } catch (error) { console.error(`❌ Failed to stop stream ${streamId}:`, error); throw error; } } async pauseStream(streamId: string): Promise { console.log(`⏸️ Pausing MediaRecorder stream: ${streamId}`); const stream = this.activeStreams.get(streamId); if (!stream) { throw new Error(`Stream not found: ${streamId}`); } if (this.mediaRecorder && this.mediaRecorder.state === "recording") { this.mediaRecorder.pause(); this.notifyStreamUpdate(stream); } } async resumeStream(streamId: string): Promise { console.log(`▢️ Resuming MediaRecorder stream: ${streamId}`); const stream = this.activeStreams.get(streamId); if (!stream) { throw new Error(`Stream not found: ${streamId}`); } if (this.mediaRecorder && this.mediaRecorder.state === "paused") { this.mediaRecorder.resume(); this.notifyStreamUpdate(stream); } } getActiveStreams(): SensorStream[] { return Array.from(this.activeStreams.values()); } // Event subscription methods onFrame(callback: FrameCallback): UnsubscribeFn { this.frameCallbacks.push(callback); return () => { const index = this.frameCallbacks.indexOf(callback); if (index >= 0) { this.frameCallbacks.splice(index, 1); } }; } onStreamUpdate(callback: StreamUpdateCallback): UnsubscribeFn { this.streamUpdateCallbacks.push(callback); return () => { const index = this.streamUpdateCallbacks.indexOf(callback); if (index >= 0) { this.streamUpdateCallbacks.splice(index, 1); } }; } onStatusChange(callback: StatusChangeCallback): UnsubscribeFn { this.statusCallbacks.push(callback); return () => { const index = this.statusCallbacks.indexOf(callback); if (index >= 0) { this.statusCallbacks.splice(index, 1); } }; } // Private helper methods private setupMediaRecorderEvents(stream: SensorStream): void { if (!this.mediaRecorder) return; this.mediaRecorder.ondataavailable = (event) => { if (event.data && event.data.size > 0) { this.recordingDataChunks.push(event.data); // Create frame from chunk const frame: SensorFrame = { timestamp: Date.now(), type: "video", data: event.data, metadata: { width: stream.config.width, height: stream.config.height, frameRate: stream.config.frameRate, codec: "webm", bitrate: this.config.videoBitsPerSecond } }; // Update stream stats stream.totalFrames = (stream.totalFrames || 0) + 1; // Notify frame callbacks this.notifyFrame(frame); } }; this.mediaRecorder.onstop = () => { console.log("πŸŽ₯ MediaRecorder stopped"); // Create final frame with complete recording if (this.recordingDataChunks.length > 0) { const finalBlob = new Blob(this.recordingDataChunks, { type: "video/webm" }); const finalFrame: SensorFrame = { timestamp: Date.now(), type: "video", data: finalBlob, metadata: { width: stream.config.width, height: stream.config.height, codec: "webm", isComplete: true, totalSize: finalBlob.size } }; this.notifyFrame(finalFrame); } // Clear chunks this.recordingDataChunks = []; }; this.mediaRecorder.onerror = (event) => { console.error("❌ MediaRecorder error:", event); this._status.error = "Recording error occurred"; this.notifyStatusChange(); }; } private getBestWebMType(): string { // Best WebM types in order of preference const types = [ "video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus", "video/webm" ]; for (const type of types) { if (MediaRecorder.isTypeSupported(type)) { return type; } } return "video/webm"; // Fallback } private notifyFrame(frame: SensorFrame): void { this.frameCallbacks.forEach((callback) => { try { callback(frame); } catch (error) { console.error("Error in frame callback:", error); } }); } private notifyStreamUpdate(stream: SensorStream): void { this.streamUpdateCallbacks.forEach((callback) => { try { callback(stream); } catch (error) { console.error("Error in stream update callback:", error); } }); } private notifyStatusChange(): void { this.statusCallbacks.forEach((callback) => { try { callback(this._status); } catch (error) { console.error("Error in status change callback:", error); } }); } }