stash
This commit is contained in:
parent
5c3b7fc252
commit
bbe52e4a78
9 changed files with 817 additions and 287 deletions
222
frontend/src/cameraManager.js
Normal file
222
frontend/src/cameraManager.js
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue