// Shared camera stream manager for webcam access (WebcamFileSource, Scan) - centralizes device // enumeration, preferred-camera memory, stream reuse, and disconnect/reconnect handling. class CameraManager { constructor() { this.availableCameras = []; this.activeStream = null; this.selectedCameraId = null; this.isInitialized = false; this.streamUsers = 0; } async enumerateDevices() { try { const devices = await navigator.mediaDevices.enumerateDevices(); const videoDevices = devices.filter((d) => d.kind === 'videoinput' && d.deviceId); if (videoDevices.length === 0) return this.availableCameras; const uniqueMap = new Map(); videoDevices.forEach((device) => uniqueMap.set(device.deviceId, device)); this.availableCameras = Array.from(uniqueMap.values()); return this.availableCameras; } catch (err) { console.error('Error enumerating devices:', err); return []; } } loadPreferredCamera() { const savedCameras = this.getRecentCameras(); if (savedCameras.length === 0) return null; if (this.availableCameras.length === 0) { this.selectedCameraId = savedCameras[0]; return savedCameras[0]; } for (const cameraId of savedCameras) { if (this.availableCameras.some((cam) => cam.deviceId === cameraId)) { this.selectedCameraId = cameraId; return cameraId; } } return null; } getRecentCameras() { try { const saved = localStorage.getItem('recentCameraIds'); return saved ? JSON.parse(saved) : []; } catch (err) { console.error('Error loading recent cameras:', err); return []; } } savePreferredCamera(cameraId) { if (!cameraId) return; const recentCameras = this.getRecentCameras(); const updated = [cameraId, ...recentCameras.filter((id) => id !== cameraId)]; try { localStorage.setItem('recentCameraIds', JSON.stringify(updated)); this.selectedCameraId = cameraId; } catch (err) { console.error('Error saving recent cameras:', err); } } async openStream(cameraId = null) { const targetCameraId = cameraId || this.selectedCameraId; if (this.activeStream) { const currentSettings = this.activeStream.getVideoTracks()[0].getSettings(); if (currentSettings.deviceId === targetCameraId) { this.streamUsers++; return this.activeStream; } this.closeStream(true); } if (!this.isInitialized) { await this.enumerateDevices(); this.isInitialized = true; } this.selectedCameraId = this._selectCamera(targetCameraId); const constraints = { video: this.selectedCameraId ? { deviceId: { ideal: this.selectedCameraId } } : { facingMode: 'environment' }, audio: false, }; try { this.activeStream = await navigator.mediaDevices.getUserMedia(constraints); this.streamUsers = 1; await this.enumerateDevices(); const actualDeviceId = this.activeStream.getVideoTracks()[0]?.getSettings().deviceId; if (actualDeviceId) this.selectedCameraId = actualDeviceId; this.activeStream.getVideoTracks().forEach((track) => { track.onended = () => this.handleTrackEnded(track); }); return this.activeStream; } catch (err) { console.error('Error opening camera stream:', err); if (err.name === 'OverconstrainedError' && this.selectedCameraId) { this.selectedCameraId = null; localStorage.removeItem('recentCameraIds'); return await this.openStream(); } throw err; } } _selectCamera(targetCameraId) { if (targetCameraId) return targetCameraId; const preferredCamera = this.loadPreferredCamera(); if (preferredCamera) return preferredCamera; return this.availableCameras[0]?.deviceId || null; } async switchCamera(cameraId) { this.savePreferredCamera(cameraId); const wasActive = this.streamUsers > 0; this.closeStream(true); return wasActive ? await this.openStream(cameraId) : null; } async handleTrackEnded(track) { console.warn('Camera track ended (may have been unplugged)'); const disconnectedDeviceId = track.getSettings().deviceId; this.activeStream = null; this.streamUsers = 0; await this.enumerateDevices(); if (this.availableCameras.some((cam) => cam.deviceId === disconnectedDeviceId)) return; if (this.availableCameras.length === 0) { console.error('No cameras available after disconnection'); this._dispatchCameraEvent('camera-disconnected', { error: 'No cameras available' }); return; } const fallbackCameraId = this._selectFallbackCamera(); const fallbackCamera = this.availableCameras.find((cam) => cam.deviceId === fallbackCameraId); try { await this.openStream(fallbackCameraId); this._dispatchCameraEvent('camera-reconnected', { deviceId: fallbackCameraId, label: fallbackCamera?.label || 'Unknown', }); } catch (err) { console.error('Failed to open fallback camera:', err); this._dispatchCameraEvent('camera-disconnected', { error: 'No cameras available' }); } } _selectFallbackCamera() { const recentCameras = this.getRecentCameras(); for (const cameraId of recentCameras) { if (this.availableCameras.some((cam) => cam.deviceId === cameraId)) { return cameraId; } } return this.availableCameras[0]?.deviceId; } _cleanCameraLabel(label) { if (!label) return 'Unknown'; const parts = label.split(':').map((p) => p.trim()); if (parts.length === 2 && parts[0] === parts[1]) { return parts[0]; } return label; } _dispatchCameraEvent(eventName, detail) { if (detail.label) { detail.label = this._cleanCameraLabel(detail.label); } window.dispatchEvent(new CustomEvent(eventName, { detail })); } closeStream(force = false) { if (!this.activeStream) return; this.streamUsers = force ? 0 : Math.max(0, this.streamUsers - 1); if (this.streamUsers === 0) { this.activeStream.getTracks().forEach((track) => track.stop()); this.activeStream = null; } } getAvailableCameras() { return this.availableCameras; } getActiveStream() { return this.activeStream; } getSelectedCameraId() { return this.selectedCameraId; } } export default new CameraManager();