This commit is contained in:
j3d1 2026-08-23 00:56:03 +02:00
parent 5c3b7fc252
commit bbe52e4a78
9 changed files with 817 additions and 287 deletions

View file

@ -0,0 +1,222 @@
// Shared camera stream manager used by any component that needs webcam access
// (WebcamFileSource, Scan). Centralizing this means every consumer gets device
// enumeration, preferred-camera memory, stream reuse and disconnect/reconnect
// handling for free instead of re-implementing it per component.
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();