220 lines
7.1 KiB
JavaScript
220 lines
7.1 KiB
JavaScript
// 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();
|