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_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
};

View File

@@ -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)
);

View File

@@ -74,6 +74,34 @@
<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>
<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">
{#if !$appState.isMotionActive}
<button