Add camera input selection and fallback capture flow

This commit is contained in:
2026-03-05 10:50:00 +00:00
parent 08433b3923
commit c458857f0a
3 changed files with 187 additions and 18 deletions

View File

@@ -19,6 +19,11 @@ const COMPRESSED_UPLOAD_MAX_WIDTH = 640;
const COMPRESSED_UPLOAD_MAX_HEIGHT = 360; const COMPRESSED_UPLOAD_MAX_HEIGHT = 360;
const COMPRESSED_UPLOAD_FRAME_RATE = 12; const COMPRESSED_UPLOAD_FRAME_RATE = 12;
const COMPRESSED_UPLOAD_BITS_PER_SECOND = 450_000; 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 = { const rtcConfig = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] 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 = () => { const attachCameraStreamToElement = () => {
if (!cameraVideoElement) return; if (!cameraVideoElement) return;
cameraVideoElement.srcObject = localCameraStream; cameraVideoElement.srcObject = localCameraStream;
@@ -178,37 +237,87 @@ const attachClientStreamToElement = () => {
void clientVideoElement.play().catch(() => {}); void clientVideoElement.play().catch(() => {});
}; };
const startCameraPreview = async () => { const startCameraPreview = async (cameraInputId = getAppState().selectedCameraInputId) => {
if (!navigator.mediaDevices?.getUserMedia) { if (!navigator.mediaDevices?.getUserMedia) {
pushToast('Camera API is not available in this browser', 'error'); pushToast('Camera API is not available in this browser', 'error');
return false; return false;
} }
const requestedCameraInputId = typeof cameraInputId === 'string' ? cameraInputId.trim() : '';
const activeCameraInputId = getCameraDeviceIdFromStream(localCameraStream);
if (localCameraStream) { if (localCameraStream) {
attachCameraStreamToElement(); if (!requestedCameraInputId || requestedCameraInputId === activeCameraInputId) {
setAppState({ cameraPreviewReady: true }); attachCameraStreamToElement();
return true; setAppState({ cameraPreviewReady: true });
void refreshCameraInputDevices();
return true;
}
localCameraStream.getTracks().forEach((track) => track.stop());
localCameraStream = null;
} }
try { const constraintCandidates = [];
localCameraStream = await navigator.mediaDevices.getUserMedia({ if (requestedCameraInputId) {
video: { constraintCandidates.push(
width: { ideal: 640, max: 960 }, {
height: { ideal: 360, max: 540 }, video: buildCameraConstraints(requestedCameraInputId),
frameRate: { ideal: 15, max: 24 } audio: false
}, },
{
video: {
deviceId: { exact: requestedCameraInputId }
},
audio: false
}
);
}
constraintCandidates.push(
{
video: buildCameraConstraints(),
audio: false audio: false
}); },
attachCameraStreamToElement(); {
setAppState({ cameraPreviewReady: true }); video: true,
addActivity('Camera', 'Camera access granted'); audio: false
return true; }
} catch { );
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'); pushToast('Camera permission denied or unavailable', 'error');
addActivity('Camera', 'Camera access failed'); addActivity('Camera', 'Camera access failed');
setAppState({ cameraPreviewReady: false }); setAppState({ cameraPreviewReady: false });
return 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 = () => { const stopCameraPreview = () => {
@@ -1142,6 +1251,9 @@ const init = async () => {
initPromise = (async () => { initPromise = (async () => {
setAppState({ page: pageFromPath(window.location.pathname) }); setAppState({ page: pageFromPath(window.location.pathname) });
if (navigator.mediaDevices?.addEventListener) {
navigator.mediaDevices.addEventListener('devicechange', onMediaDeviceChange);
}
const saved = localStorage.getItem('mobileSimDevice'); const saved = localStorage.getItem('mobileSimDevice');
if (saved) { if (saved) {
@@ -1177,6 +1289,7 @@ const init = async () => {
} }
enforceRouteForSession(); enforceRouteForSession();
void refreshCameraInputDevices();
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
void cleanupConnectionState(); void cleanupConnectionState();
@@ -1192,6 +1305,9 @@ const init = async () => {
}; };
const destroy = async () => { const destroy = async () => {
if (navigator.mediaDevices?.removeEventListener) {
navigator.mediaDevices.removeEventListener('devicechange', onMediaDeviceChange);
}
initialized = false; initialized = false;
await cleanupConnectionState(); await cleanupConnectionState();
}; };
@@ -1366,6 +1482,30 @@ const actions = {
connectSocket(); 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() { async linkCamera() {
const id = prompt('Enter Camera Device ID:'); const id = prompt('Enter Camera Device ID:');
if (!id) return; if (!id) return;
@@ -1539,6 +1679,7 @@ const actions = {
setCameraVideoElement(element) { setCameraVideoElement(element) {
cameraVideoElement = element; cameraVideoElement = element;
attachCameraStreamToElement(); attachCameraStreamToElement();
void refreshCameraInputDevices();
}, },
setClientVideoElement(element) { setClientVideoElement(element) {
@@ -1552,4 +1693,3 @@ export const appController = {
destroy, destroy,
...actions ...actions
}; };

View File

@@ -11,6 +11,8 @@ export const createInitialState = () => ({
isMotionActive: false, isMotionActive: false,
cameraStatus: 'idle', cameraStatus: 'idle',
cameraPreviewReady: false, cameraPreviewReady: false,
cameraInputDevices: [],
selectedCameraInputId: '',
linkedCameras: [], linkedCameras: [],
recordings: [], recordings: [],
motionNotifications: [], motionNotifications: [],
@@ -63,4 +65,3 @@ export const resetAppState = (keep = {}) => {
export const unreadNotificationsCount = derived(appState, ($state) => export const unreadNotificationsCount = derived(appState, ($state) =>
$state.motionNotifications.reduce((count, notification) => count + (notification.isRead ? 0 : 1), 0) $state.motionNotifications.reduce((count, notification) => count + (notification.isRead ? 0 : 1), 0)
); );

View File

@@ -74,6 +74,34 @@
<div class="glass-card p-6 rounded-3xl border border-white/5 space-y-4 min-h-[220px]"> <div class="glass-card p-6 rounded-3xl border border-white/5 space-y-4 min-h-[220px]">
<h3 class="text-xs font-bold text-gray-500 uppercase tracking-wider">Actions / Options</h3> <h3 class="text-xs font-bold text-gray-500 uppercase tracking-wider">Actions / Options</h3>
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<label for="cameraInputSelect" class="text-[11px] text-gray-400 uppercase tracking-wider font-semibold">
Camera Input
</label>
<button
id="refreshCameraInputsBtn"
class="btn btn-ghost btn-xs rounded-lg text-gray-400 hover:text-white"
onclick={() => appController.refreshCameraInputs()}
>
Refresh
</button>
</div>
<select
id="cameraInputSelect"
class="select select-sm w-full bg-black/40 border-white/10 text-gray-200 rounded-xl"
value={$appState.selectedCameraInputId}
onchange={(event) => appController.selectCameraInput((event.currentTarget as HTMLSelectElement).value)}
>
{#if $appState.cameraInputDevices.length === 0}
<option value="">No camera inputs found</option>
{:else}
{#each $appState.cameraInputDevices as cameraInput (cameraInput.id)}
<option value={cameraInput.id}>{cameraInput.label}</option>
{/each}
{/if}
</select>
</div>
<div class="space-y-3"> <div class="space-y-3">
{#if !$appState.isMotionActive} {#if !$appState.isMotionActive}
<button <button