Add camera input selection and fallback capture flow
This commit is contained in:
@@ -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) {
|
||||
if (!requestedCameraInputId || requestedCameraInputId === activeCameraInputId) {
|
||||
attachCameraStreamToElement();
|
||||
setAppState({ cameraPreviewReady: true });
|
||||
void refreshCameraInputDevices();
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
localCameraStream = await navigator.mediaDevices.getUserMedia({
|
||||
localCameraStream.getTracks().forEach((track) => track.stop());
|
||||
localCameraStream = null;
|
||||
}
|
||||
|
||||
const constraintCandidates = [];
|
||||
if (requestedCameraInputId) {
|
||||
constraintCandidates.push(
|
||||
{
|
||||
video: buildCameraConstraints(requestedCameraInputId),
|
||||
audio: false
|
||||
},
|
||||
{
|
||||
video: {
|
||||
width: { ideal: 640, max: 960 },
|
||||
height: { ideal: 360, max: 540 },
|
||||
frameRate: { ideal: 15, max: 24 }
|
||||
deviceId: { exact: requestedCameraInputId }
|
||||
},
|
||||
audio: false
|
||||
});
|
||||
attachCameraStreamToElement();
|
||||
setAppState({ cameraPreviewReady: true });
|
||||
addActivity('Camera', 'Camera access granted');
|
||||
return true;
|
||||
} catch {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
constraintCandidates.push(
|
||||
{
|
||||
video: buildCameraConstraints(),
|
||||
audio: false
|
||||
},
|
||||
{
|
||||
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
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user