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_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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user