From c458857f0addbc15f024baf6789d5772154ec5bc Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Thu, 5 Mar 2026 10:50:00 +0000 Subject: [PATCH] Add camera input selection and fallback capture flow --- WebApp/src/lib/app/controller.js | 174 +++++++++++++++++++++++--- WebApp/src/lib/app/store.js | 3 +- WebApp/src/routes/camera/+page.svelte | 28 +++++ 3 files changed, 187 insertions(+), 18 deletions(-) diff --git a/WebApp/src/lib/app/controller.js b/WebApp/src/lib/app/controller.js index fa7bfb5..3154394 100644 --- a/WebApp/src/lib/app/controller.js +++ b/WebApp/src/lib/app/controller.js @@ -19,6 +19,11 @@ const COMPRESSED_UPLOAD_MAX_WIDTH = 640; const COMPRESSED_UPLOAD_MAX_HEIGHT = 360; const COMPRESSED_UPLOAD_FRAME_RATE = 12; const COMPRESSED_UPLOAD_BITS_PER_SECOND = 450_000; +const DEFAULT_CAMERA_CONSTRAINTS = { + width: { ideal: 640, max: 960 }, + height: { ideal: 360, max: 540 }, + frameRate: { ideal: 15, max: 24 } +}; const rtcConfig = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] @@ -155,6 +160,60 @@ const setClientStreamMode = (mode) => { })); }; +const getCameraDeviceIdFromStream = (stream) => { + if (!stream) return ''; + const videoTrack = stream.getVideoTracks?.()[0]; + if (!videoTrack) return ''; + const settings = videoTrack.getSettings?.() || {}; + return typeof settings.deviceId === 'string' ? settings.deviceId : ''; +}; + +const buildCameraConstraints = (cameraDeviceId = '') => { + if (!cameraDeviceId) return { ...DEFAULT_CAMERA_CONSTRAINTS }; + return { + ...DEFAULT_CAMERA_CONSTRAINTS, + deviceId: { exact: cameraDeviceId } + }; +}; + +const refreshCameraInputDevices = async () => { + if (!navigator.mediaDevices?.enumerateDevices) { + setAppState({ cameraInputDevices: [], selectedCameraInputId: '' }); + return []; + } + + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const cameraInputDevices = devices + .filter((device) => device.kind === 'videoinput' && device.deviceId) + .map((device, index) => ({ + id: device.deviceId, + label: device.label || `Camera ${index + 1}` + })); + + const selectedCameraInputId = getAppState().selectedCameraInputId; + const streamCameraInputId = getCameraDeviceIdFromStream(localCameraStream); + const candidateCameraInputId = selectedCameraInputId || streamCameraInputId || ''; + const nextSelectedCameraInputId = cameraInputDevices.some((device) => device.id === candidateCameraInputId) + ? candidateCameraInputId + : (cameraInputDevices[0]?.id ?? ''); + + setAppState({ + cameraInputDevices, + selectedCameraInputId: nextSelectedCameraInputId + }); + return cameraInputDevices; + } catch (error) { + console.error('Failed to enumerate cameras', error); + addActivity('Camera', 'Failed to enumerate camera inputs'); + return []; + } +}; + +const onMediaDeviceChange = () => { + void refreshCameraInputDevices(); +}; + const attachCameraStreamToElement = () => { if (!cameraVideoElement) return; cameraVideoElement.srcObject = localCameraStream; @@ -178,37 +237,87 @@ const attachClientStreamToElement = () => { void clientVideoElement.play().catch(() => {}); }; -const startCameraPreview = async () => { +const startCameraPreview = async (cameraInputId = getAppState().selectedCameraInputId) => { if (!navigator.mediaDevices?.getUserMedia) { pushToast('Camera API is not available in this browser', 'error'); return false; } + const requestedCameraInputId = typeof cameraInputId === 'string' ? cameraInputId.trim() : ''; + const activeCameraInputId = getCameraDeviceIdFromStream(localCameraStream); + if (localCameraStream) { - attachCameraStreamToElement(); - setAppState({ cameraPreviewReady: true }); - return true; + if (!requestedCameraInputId || requestedCameraInputId === activeCameraInputId) { + attachCameraStreamToElement(); + setAppState({ cameraPreviewReady: true }); + void refreshCameraInputDevices(); + return true; + } + + localCameraStream.getTracks().forEach((track) => track.stop()); + localCameraStream = null; } - try { - localCameraStream = await navigator.mediaDevices.getUserMedia({ - video: { - width: { ideal: 640, max: 960 }, - height: { ideal: 360, max: 540 }, - frameRate: { ideal: 15, max: 24 } + const constraintCandidates = []; + if (requestedCameraInputId) { + constraintCandidates.push( + { + video: buildCameraConstraints(requestedCameraInputId), + audio: false }, + { + video: { + deviceId: { exact: requestedCameraInputId } + }, + audio: false + } + ); + } + + constraintCandidates.push( + { + video: buildCameraConstraints(), audio: false - }); - attachCameraStreamToElement(); - setAppState({ cameraPreviewReady: true }); - addActivity('Camera', 'Camera access granted'); - return true; - } catch { + }, + { + video: true, + audio: false + } + ); + + let lastError = null; + for (const constraints of constraintCandidates) { + try { + localCameraStream = await navigator.mediaDevices.getUserMedia(constraints); + break; + } catch (error) { + lastError = error; + } + } + + if (!localCameraStream) { pushToast('Camera permission denied or unavailable', 'error'); addActivity('Camera', 'Camera access failed'); setAppState({ cameraPreviewReady: false }); return false; } + + const nextSelectedCameraInputId = getCameraDeviceIdFromStream(localCameraStream) || requestedCameraInputId || ''; + attachCameraStreamToElement(); + setAppState({ + cameraPreviewReady: true, + selectedCameraInputId: nextSelectedCameraInputId + }); + await refreshCameraInputDevices(); + + if (requestedCameraInputId && nextSelectedCameraInputId && nextSelectedCameraInputId !== requestedCameraInputId) { + pushToast('Selected camera unavailable, using another camera', 'info'); + } + if (lastError && !requestedCameraInputId) { + addActivity('Camera', `Applied fallback camera constraints (${lastError.name || 'unknown_error'})`); + } + addActivity('Camera', 'Camera access granted'); + return true; }; const stopCameraPreview = () => { @@ -1142,6 +1251,9 @@ const init = async () => { initPromise = (async () => { setAppState({ page: pageFromPath(window.location.pathname) }); + if (navigator.mediaDevices?.addEventListener) { + navigator.mediaDevices.addEventListener('devicechange', onMediaDeviceChange); + } const saved = localStorage.getItem('mobileSimDevice'); if (saved) { @@ -1177,6 +1289,7 @@ const init = async () => { } enforceRouteForSession(); + void refreshCameraInputDevices(); window.addEventListener('beforeunload', () => { void cleanupConnectionState(); @@ -1192,6 +1305,9 @@ const init = async () => { }; const destroy = async () => { + if (navigator.mediaDevices?.removeEventListener) { + navigator.mediaDevices.removeEventListener('devicechange', onMediaDeviceChange); + } initialized = false; await cleanupConnectionState(); }; @@ -1366,6 +1482,30 @@ const actions = { connectSocket(); }, + async refreshCameraInputs(showToast = true) { + const inputs = await refreshCameraInputDevices(); + if (!showToast) return; + if (inputs.length === 0) { + pushToast('No camera inputs detected', 'info'); + return; + } + pushToast('Camera list refreshed', 'success'); + }, + + async selectCameraInput(cameraInputId) { + const nextCameraInputId = typeof cameraInputId === 'string' ? cameraInputId.trim() : ''; + if (!nextCameraInputId) return; + + setAppState({ selectedCameraInputId: nextCameraInputId }); + const isPreviewRunning = Boolean(localCameraStream); + if (!isPreviewRunning) return; + + const ready = await startCameraPreview(nextCameraInputId); + if (!ready) { + pushToast('Failed to switch camera', 'error'); + } + }, + async linkCamera() { const id = prompt('Enter Camera Device ID:'); if (!id) return; @@ -1539,6 +1679,7 @@ const actions = { setCameraVideoElement(element) { cameraVideoElement = element; attachCameraStreamToElement(); + void refreshCameraInputDevices(); }, setClientVideoElement(element) { @@ -1552,4 +1693,3 @@ export const appController = { destroy, ...actions }; - diff --git a/WebApp/src/lib/app/store.js b/WebApp/src/lib/app/store.js index e0efbd1..438b5ed 100644 --- a/WebApp/src/lib/app/store.js +++ b/WebApp/src/lib/app/store.js @@ -11,6 +11,8 @@ export const createInitialState = () => ({ isMotionActive: false, cameraStatus: 'idle', cameraPreviewReady: false, + cameraInputDevices: [], + selectedCameraInputId: '', linkedCameras: [], recordings: [], motionNotifications: [], @@ -63,4 +65,3 @@ export const resetAppState = (keep = {}) => { export const unreadNotificationsCount = derived(appState, ($state) => $state.motionNotifications.reduce((count, notification) => count + (notification.isRead ? 0 : 1), 0) ); - diff --git a/WebApp/src/routes/camera/+page.svelte b/WebApp/src/routes/camera/+page.svelte index 5052873..6ef3716 100644 --- a/WebApp/src/routes/camera/+page.svelte +++ b/WebApp/src/routes/camera/+page.svelte @@ -74,6 +74,34 @@

Actions / Options

+
+
+ + +
+ +
{#if !$appState.isMotionActive}