Refactor web app controller shared and media modules
This commit is contained in:
898
WebApp/src/lib/app/controller-media.js
Normal file
898
WebApp/src/lib/app/controller-media.js
Normal file
@@ -0,0 +1,898 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
const RECORDING_VIDEO_BITS_PER_SECOND = 850_000;
|
||||||
|
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 }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createControllerMediaModule = ({
|
||||||
|
api,
|
||||||
|
createMotionDetector,
|
||||||
|
getAppState,
|
||||||
|
setAppState,
|
||||||
|
patchAppState,
|
||||||
|
pushToast,
|
||||||
|
addActivity,
|
||||||
|
getMotionDetectionProfile,
|
||||||
|
updateMotionDetectionState
|
||||||
|
}) => {
|
||||||
|
let localCameraStream = null;
|
||||||
|
let activeMediaRecorder = null;
|
||||||
|
let activeRecordingChunks = [];
|
||||||
|
let activeRecordingStartedAt = null;
|
||||||
|
let activeRecordingStreamSessionId = null;
|
||||||
|
let lastMotionEventId = null;
|
||||||
|
let motionDetector = null;
|
||||||
|
let detectorMotionActive = false;
|
||||||
|
let autoMotionTransitionInFlight = false;
|
||||||
|
|
||||||
|
let cameraVideoElement = null;
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const updateMotionDetectionRuntime = (updates) => {
|
||||||
|
patchAppState((state) => ({
|
||||||
|
motionDetection: {
|
||||||
|
...state.motionDetection,
|
||||||
|
...updates
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
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 isAutoMotionEventActive = () => {
|
||||||
|
const state = getAppState();
|
||||||
|
return Boolean(state.isMotionActive && state.activeMotionSource === 'auto' && lastMotionEventId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMotionDetectionPauseReason = () => {
|
||||||
|
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||||
|
return 'page hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = getAppState();
|
||||||
|
if (state.device?.role !== 'camera') {
|
||||||
|
return 'device is not a camera';
|
||||||
|
}
|
||||||
|
if (!state.motionDetection?.enabled) {
|
||||||
|
return 'detector disarmed';
|
||||||
|
}
|
||||||
|
if (!state.socketConnected) {
|
||||||
|
return 'realtime connection offline';
|
||||||
|
}
|
||||||
|
if (!state.cameraPreviewReady) {
|
||||||
|
return 'camera preview unavailable';
|
||||||
|
}
|
||||||
|
if (!localCameraStream) {
|
||||||
|
return 'camera stream unavailable';
|
||||||
|
}
|
||||||
|
if (!cameraVideoElement) {
|
||||||
|
return 'preview element unavailable';
|
||||||
|
}
|
||||||
|
return 'idle';
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldRunMotionDetector = () => {
|
||||||
|
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = getAppState();
|
||||||
|
return Boolean(
|
||||||
|
state.device?.role === 'camera' &&
|
||||||
|
state.motionDetection?.enabled &&
|
||||||
|
state.socketConnected &&
|
||||||
|
state.cameraPreviewReady &&
|
||||||
|
localCameraStream &&
|
||||||
|
cameraVideoElement
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachCameraStreamToElement = () => {
|
||||||
|
if (!cameraVideoElement) return;
|
||||||
|
cameraVideoElement.srcObject = localCameraStream;
|
||||||
|
if (localCameraStream) {
|
||||||
|
void cameraVideoElement.play().catch(() => {});
|
||||||
|
}
|
||||||
|
applyMotionDetectionReadiness();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPreferredRecordingMimeType = () => {
|
||||||
|
if (typeof MediaRecorder === 'undefined') return '';
|
||||||
|
const preferredTypes = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'];
|
||||||
|
return preferredTypes.find((type) => MediaRecorder.isTypeSupported(type)) ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const toEvenDimension = (value) => {
|
||||||
|
const rounded = Math.max(2, Math.floor(value));
|
||||||
|
return rounded % 2 === 0 ? rounded : rounded - 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const summarizeFetchFailure = async (response) => {
|
||||||
|
if (!response) {
|
||||||
|
return 'no response returned';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [`status ${response.status}`];
|
||||||
|
try {
|
||||||
|
const bodyText = (await response.text()).trim();
|
||||||
|
if (bodyText) {
|
||||||
|
parts.push(bodyText.slice(0, 240));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore body parsing failures for diagnostics.
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' · ');
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
localCameraStream.getTracks().forEach((track) => track.stop());
|
||||||
|
localCameraStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const constraintCandidates = [];
|
||||||
|
if (requestedCameraInputId) {
|
||||||
|
constraintCandidates.push(
|
||||||
|
{
|
||||||
|
video: buildCameraConstraints(requestedCameraInputId),
|
||||||
|
audio: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
video: {
|
||||||
|
deviceId: { exact: requestedCameraInputId }
|
||||||
|
},
|
||||||
|
audio: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
|
if (localCameraStream) {
|
||||||
|
localCameraStream.getTracks().forEach((track) => track.stop());
|
||||||
|
localCameraStream = null;
|
||||||
|
}
|
||||||
|
if (cameraVideoElement) {
|
||||||
|
cameraVideoElement.srcObject = null;
|
||||||
|
}
|
||||||
|
setAppState({ cameraPreviewReady: false });
|
||||||
|
applyMotionDetectionReadiness();
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureMotionDetector = () => {
|
||||||
|
if (motionDetector) {
|
||||||
|
return motionDetector;
|
||||||
|
}
|
||||||
|
|
||||||
|
motionDetector = createMotionDetector({
|
||||||
|
getSourceElement: () => cameraVideoElement,
|
||||||
|
getConfig: () => getAppState().motionDetection,
|
||||||
|
onUpdate: ({ state, score, activeMotion, activeSince }) => {
|
||||||
|
const current = getAppState().motionDetection;
|
||||||
|
const nextState = state === 'cooldown' && !activeMotion ? 'monitoring' : state;
|
||||||
|
const motionBecameActive = activeMotion && !detectorMotionActive;
|
||||||
|
const motionBecameInactive = !activeMotion && detectorMotionActive;
|
||||||
|
detectorMotionActive = activeMotion;
|
||||||
|
updateMotionDetectionRuntime({
|
||||||
|
state: nextState,
|
||||||
|
score,
|
||||||
|
lastTriggeredAt: motionBecameActive
|
||||||
|
? activeSince
|
||||||
|
? new Date(activeSince).toISOString()
|
||||||
|
: new Date().toISOString()
|
||||||
|
: current.lastTriggeredAt
|
||||||
|
});
|
||||||
|
|
||||||
|
if (motionBecameActive || motionBecameInactive) {
|
||||||
|
void syncAutoMotionLifecycle({ activeMotion });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return motionDetector;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyMotionDetectionReadiness = () => {
|
||||||
|
const detector = ensureMotionDetector();
|
||||||
|
if (shouldRunMotionDetector()) {
|
||||||
|
if (!detector.isRunning()) {
|
||||||
|
detector.start();
|
||||||
|
addActivity('Motion Detection', 'Detector monitoring started');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pauseReason = getMotionDetectionPauseReason();
|
||||||
|
detectorMotionActive = false;
|
||||||
|
if (isAutoMotionEventActive()) {
|
||||||
|
void syncAutoMotionLifecycle({ activeMotion: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detector.isRunning()) {
|
||||||
|
detector.stop('idle');
|
||||||
|
addActivity('Motion Detection', `Detector monitoring paused (${pauseReason})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMotionDetectionRuntime({ state: 'idle', score: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const startLocalRecording = async () => {
|
||||||
|
if (!localCameraStream || typeof MediaRecorder === 'undefined') {
|
||||||
|
addActivity('Recording', 'MediaRecorder unavailable');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeMediaRecorder?.state === 'recording') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeRecordingChunks = [];
|
||||||
|
activeRecordingStartedAt = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mimeType = getPreferredRecordingMimeType();
|
||||||
|
const recorderOptions = {
|
||||||
|
videoBitsPerSecond: RECORDING_VIDEO_BITS_PER_SECOND
|
||||||
|
};
|
||||||
|
if (mimeType) {
|
||||||
|
recorderOptions.mimeType = mimeType;
|
||||||
|
}
|
||||||
|
activeMediaRecorder = new MediaRecorder(localCameraStream, recorderOptions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create MediaRecorder', error);
|
||||||
|
addActivity('Recording', 'Failed to start recorder');
|
||||||
|
activeMediaRecorder = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeMediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data && event.data.size > 0) {
|
||||||
|
activeRecordingChunks.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
activeMediaRecorder.start(1000);
|
||||||
|
setAppState({ cameraStatus: 'recording' });
|
||||||
|
addActivity('Recording', 'Local recording started');
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopLocalRecording = async () => {
|
||||||
|
if (!activeMediaRecorder || activeMediaRecorder.state === 'inactive') {
|
||||||
|
setAppState({ cameraStatus: 'idle' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
const recorder = activeMediaRecorder;
|
||||||
|
const startedAt = activeRecordingStartedAt ?? Date.now();
|
||||||
|
|
||||||
|
recorder.onstop = () => {
|
||||||
|
const mimeType = recorder.mimeType || 'video/webm';
|
||||||
|
const blob = activeRecordingChunks.length > 0 ? new Blob(activeRecordingChunks, { type: mimeType }) : null;
|
||||||
|
const durationSeconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
||||||
|
|
||||||
|
activeMediaRecorder = null;
|
||||||
|
activeRecordingChunks = [];
|
||||||
|
activeRecordingStartedAt = null;
|
||||||
|
setAppState({ cameraStatus: 'idle' });
|
||||||
|
|
||||||
|
resolve(blob ? { blob, durationSeconds } : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.onerror = () => {
|
||||||
|
activeMediaRecorder = null;
|
||||||
|
activeRecordingChunks = [];
|
||||||
|
activeRecordingStartedAt = null;
|
||||||
|
setAppState({ cameraStatus: 'idle' });
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.stop();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const compressRecordingBlob = async (sourceBlob) => {
|
||||||
|
if (!sourceBlob || sourceBlob.size === 0) return sourceBlob;
|
||||||
|
if (typeof document === 'undefined' || typeof MediaRecorder === 'undefined') return sourceBlob;
|
||||||
|
|
||||||
|
const mimeType = getPreferredRecordingMimeType();
|
||||||
|
if (!mimeType) return sourceBlob;
|
||||||
|
|
||||||
|
const sourceUrl = URL.createObjectURL(sourceBlob);
|
||||||
|
const videoEl = document.createElement('video');
|
||||||
|
videoEl.muted = true;
|
||||||
|
videoEl.playsInline = true;
|
||||||
|
videoEl.preload = 'auto';
|
||||||
|
|
||||||
|
let rafId = null;
|
||||||
|
let captureStream = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
videoEl.onloadedmetadata = resolve;
|
||||||
|
videoEl.onerror = () => reject(new Error('Failed loading recorded clip'));
|
||||||
|
videoEl.src = sourceUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceWidth = videoEl.videoWidth || COMPRESSED_UPLOAD_MAX_WIDTH;
|
||||||
|
const sourceHeight = videoEl.videoHeight || COMPRESSED_UPLOAD_MAX_HEIGHT;
|
||||||
|
const scale = Math.min(1, COMPRESSED_UPLOAD_MAX_WIDTH / sourceWidth, COMPRESSED_UPLOAD_MAX_HEIGHT / sourceHeight);
|
||||||
|
const width = toEvenDimension(sourceWidth * scale);
|
||||||
|
const height = toEvenDimension(sourceHeight * scale);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context || typeof canvas.captureStream !== 'function') {
|
||||||
|
return sourceBlob;
|
||||||
|
}
|
||||||
|
|
||||||
|
captureStream = canvas.captureStream(COMPRESSED_UPLOAD_FRAME_RATE);
|
||||||
|
const compressedChunks = [];
|
||||||
|
const recorder = new MediaRecorder(captureStream, {
|
||||||
|
mimeType,
|
||||||
|
videoBitsPerSecond: COMPRESSED_UPLOAD_BITS_PER_SECOND
|
||||||
|
});
|
||||||
|
|
||||||
|
const recorderStopped = new Promise((resolve, reject) => {
|
||||||
|
recorder.ondataavailable = (event) => {
|
||||||
|
if (event.data?.size > 0) {
|
||||||
|
compressedChunks.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
recorder.onerror = (event) => {
|
||||||
|
const message = event?.error?.message || 'Compression recorder failed';
|
||||||
|
reject(new Error(message));
|
||||||
|
};
|
||||||
|
recorder.onstop = () => {
|
||||||
|
resolve(new Blob(compressedChunks, { type: recorder.mimeType || mimeType }));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const drawFrame = () => {
|
||||||
|
if (videoEl.paused || videoEl.ended) return;
|
||||||
|
context.drawImage(videoEl, 0, 0, width, height);
|
||||||
|
rafId = requestAnimationFrame(drawFrame);
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.start(300);
|
||||||
|
await videoEl.play();
|
||||||
|
drawFrame();
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
videoEl.onended = resolve;
|
||||||
|
videoEl.onerror = () => reject(new Error('Failed during compression playback'));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
recorder.stop();
|
||||||
|
const compressedBlob = await recorderStopped;
|
||||||
|
if (!compressedBlob || compressedBlob.size === 0 || compressedBlob.size >= sourceBlob.size) {
|
||||||
|
return sourceBlob;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reductionPct = Math.round(((sourceBlob.size - compressedBlob.size) / sourceBlob.size) * 100);
|
||||||
|
addActivity('Recording', `Compressed clip by ${reductionPct}% before upload`);
|
||||||
|
return compressedBlob;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Recording compression failed, uploading original clip', error);
|
||||||
|
return sourceBlob;
|
||||||
|
} finally {
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
}
|
||||||
|
if (captureStream) {
|
||||||
|
captureStream.getTracks().forEach((track) => track.stop());
|
||||||
|
}
|
||||||
|
videoEl.pause();
|
||||||
|
videoEl.removeAttribute('src');
|
||||||
|
videoEl.load();
|
||||||
|
URL.revokeObjectURL(sourceUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalizeRecordingForStream = async (streamSessionId, captureResult) => {
|
||||||
|
const currentDevice = getAppState().device;
|
||||||
|
if (!currentDevice?.id) {
|
||||||
|
addActivity('Recording', 'No device identity for finalize');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||||
|
const recs = await api.ops.listRecordings().catch(() => ({ recordings: [] }));
|
||||||
|
const recording = (recs.recordings || []).find(
|
||||||
|
(rec) => rec.streamSessionId === streamSessionId && rec.status === 'awaiting_upload'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recording?.id) {
|
||||||
|
try {
|
||||||
|
if (!captureResult?.blob || captureResult.blob.size === 0) {
|
||||||
|
throw new Error('No captured video blob to upload');
|
||||||
|
}
|
||||||
|
|
||||||
|
addActivity('Recording', `Preparing upload for stream ${streamSessionId}`);
|
||||||
|
const compressedBlob = await compressRecordingBlob(captureResult.blob);
|
||||||
|
|
||||||
|
const uploadMeta = await api.request('/videos/upload-url', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
fileName: `stream-${streamSessionId}.webm`,
|
||||||
|
deviceId: currentDevice.id,
|
||||||
|
prefix: 'recordings',
|
||||||
|
recordingId: recording.id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const uploadOrigin = (() => {
|
||||||
|
try {
|
||||||
|
return new URL(uploadMeta.uploadUrl).origin;
|
||||||
|
} catch {
|
||||||
|
return 'invalid upload URL';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
console.info('[recording.upload] upload URL issued', {
|
||||||
|
recordingId: recording.id,
|
||||||
|
streamSessionId,
|
||||||
|
objectKey: uploadMeta.objectKey,
|
||||||
|
bucket: uploadMeta.bucket,
|
||||||
|
uploadOrigin,
|
||||||
|
blobSize: compressedBlob.size,
|
||||||
|
blobType: compressedBlob.type || 'video/webm'
|
||||||
|
});
|
||||||
|
addActivity(
|
||||||
|
'Recording',
|
||||||
|
`Upload URL ready for ${uploadMeta.objectKey} via ${uploadOrigin}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadResponse = await fetch(uploadMeta.uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': compressedBlob.type || 'video/webm' },
|
||||||
|
body: compressedBlob
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
throw new Error(`Upload failed: ${await summarizeFetchFailure(uploadResponse)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info('[recording.upload] object uploaded', {
|
||||||
|
recordingId: recording.id,
|
||||||
|
streamSessionId,
|
||||||
|
objectKey: uploadMeta.objectKey,
|
||||||
|
bucket: uploadMeta.bucket,
|
||||||
|
status: uploadResponse.status,
|
||||||
|
sizeBytes: compressedBlob.size
|
||||||
|
});
|
||||||
|
addActivity('Recording', `Upload completed for ${uploadMeta.objectKey}`);
|
||||||
|
|
||||||
|
await api.events.finalizeRecording(recording.id, {
|
||||||
|
objectKey: uploadMeta.objectKey,
|
||||||
|
bucket: uploadMeta.bucket,
|
||||||
|
durationSeconds: captureResult.durationSeconds,
|
||||||
|
sizeBytes: compressedBlob.size
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info('[recording.upload] recording finalized', {
|
||||||
|
recordingId: recording.id,
|
||||||
|
streamSessionId,
|
||||||
|
objectKey: uploadMeta.objectKey,
|
||||||
|
durationSeconds: captureResult.durationSeconds,
|
||||||
|
sizeBytes: compressedBlob.size
|
||||||
|
});
|
||||||
|
addActivity('Recording', 'Recording uploaded and finalized');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[recording.upload] stream upload failed, falling back to simulated key', {
|
||||||
|
recordingId: recording.id,
|
||||||
|
streamSessionId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
addActivity(
|
||||||
|
'Recording',
|
||||||
|
`Upload failed for stream ${streamSessionId}: ${error instanceof Error ? error.message : 'unknown error'}`
|
||||||
|
);
|
||||||
|
const fallbackObjectKey = `sim/${streamSessionId}/${Date.now()}.webm`;
|
||||||
|
await api.events.finalizeRecording(recording.id, {
|
||||||
|
objectKey: fallbackObjectKey,
|
||||||
|
durationSeconds: captureResult?.durationSeconds ?? 6,
|
||||||
|
sizeBytes: captureResult?.blob?.size ?? 5000000
|
||||||
|
});
|
||||||
|
addActivity('Recording', 'Upload failed; finalized with simulator fallback');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(350);
|
||||||
|
}
|
||||||
|
|
||||||
|
addActivity('Recording', 'No recording row found to finalize');
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadStandaloneMotionRecording = async (captureResult) => {
|
||||||
|
const currentDevice = getAppState().device;
|
||||||
|
if (!currentDevice?.id) {
|
||||||
|
addActivity('Recording', 'Cannot upload motion clip without device identity');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!captureResult?.blob || captureResult.blob.size === 0) {
|
||||||
|
addActivity('Recording', 'No motion clip captured for upload');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
addActivity('Recording', 'Preparing standalone motion clip upload');
|
||||||
|
const compressedBlob = await compressRecordingBlob(captureResult.blob);
|
||||||
|
const uploadMeta = await api.request('/videos/upload-url', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
fileName: `motion-${Date.now()}.webm`,
|
||||||
|
deviceId: currentDevice.id,
|
||||||
|
prefix: 'recordings',
|
||||||
|
eventId: lastMotionEventId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const uploadOrigin = (() => {
|
||||||
|
try {
|
||||||
|
return new URL(uploadMeta.uploadUrl).origin;
|
||||||
|
} catch {
|
||||||
|
return 'invalid upload URL';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
console.info('[recording.upload] standalone upload URL issued', {
|
||||||
|
eventId: lastMotionEventId,
|
||||||
|
recordingId: uploadMeta.video?.id,
|
||||||
|
objectKey: uploadMeta.objectKey,
|
||||||
|
bucket: uploadMeta.bucket,
|
||||||
|
uploadOrigin,
|
||||||
|
blobSize: compressedBlob.size,
|
||||||
|
blobType: compressedBlob.type || 'video/webm'
|
||||||
|
});
|
||||||
|
addActivity('Recording', `Standalone upload URL ready via ${uploadOrigin}`);
|
||||||
|
|
||||||
|
const uploadResponse = await fetch(uploadMeta.uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': compressedBlob.type || 'video/webm' },
|
||||||
|
body: compressedBlob
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
throw new Error(`Upload failed: ${await summarizeFetchFailure(uploadResponse)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info('[recording.upload] standalone object uploaded', {
|
||||||
|
eventId: lastMotionEventId,
|
||||||
|
recordingId: uploadMeta.video?.id,
|
||||||
|
objectKey: uploadMeta.objectKey,
|
||||||
|
bucket: uploadMeta.bucket,
|
||||||
|
status: uploadResponse.status,
|
||||||
|
sizeBytes: compressedBlob.size
|
||||||
|
});
|
||||||
|
addActivity('Recording', `Standalone upload completed for ${uploadMeta.objectKey}`);
|
||||||
|
|
||||||
|
await api.events.finalizeRecording(uploadMeta.video.id, {
|
||||||
|
objectKey: uploadMeta.objectKey,
|
||||||
|
bucket: uploadMeta.bucket,
|
||||||
|
durationSeconds: captureResult.durationSeconds,
|
||||||
|
sizeBytes: compressedBlob.size
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info('[recording.upload] standalone recording finalized', {
|
||||||
|
eventId: lastMotionEventId,
|
||||||
|
recordingId: uploadMeta.video?.id,
|
||||||
|
objectKey: uploadMeta.objectKey,
|
||||||
|
durationSeconds: captureResult.durationSeconds,
|
||||||
|
sizeBytes: compressedBlob.size
|
||||||
|
});
|
||||||
|
addActivity('Recording', `Motion clip uploaded (${uploadMeta.objectKey})`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[recording.upload] standalone motion upload failed', {
|
||||||
|
eventId: lastMotionEventId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
addActivity(
|
||||||
|
'Recording',
|
||||||
|
`Standalone motion upload failed: ${error instanceof Error ? error.message : 'unknown error'}`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMotionStartPayload = (source = 'manual') =>
|
||||||
|
source === 'auto'
|
||||||
|
? { title: 'Automatic Motion', triggeredBy: 'auto_motion' }
|
||||||
|
: { title: 'Simulated Motion', triggeredBy: 'motion' };
|
||||||
|
|
||||||
|
const startMotionEvent = async ({ source = 'manual' } = {}) => {
|
||||||
|
if (getAppState().isMotionActive || lastMotionEventId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.events.startMotion(getMotionStartPayload(source));
|
||||||
|
await startCameraPreview();
|
||||||
|
await startLocalRecording();
|
||||||
|
lastMotionEventId = response.event.id;
|
||||||
|
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
setAppState({
|
||||||
|
isMotionActive: true,
|
||||||
|
activeMotionSource: source
|
||||||
|
});
|
||||||
|
|
||||||
|
if (source === 'auto') {
|
||||||
|
updateMotionDetectionRuntime({ lastTriggeredAt: startedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
pushToast(source === 'auto' ? 'Automatic motion event started' : 'Motion Event Started', 'success');
|
||||||
|
addActivity(
|
||||||
|
'Motion',
|
||||||
|
source === 'auto' ? `Automatic motion event started (${response.event.id})` : `Started event ${response.event.id}`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const endMotionEvent = async ({ source = 'manual' } = {}) => {
|
||||||
|
if (!lastMotionEventId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = lastMotionEventId;
|
||||||
|
const streamSessionId = activeRecordingStreamSessionId;
|
||||||
|
|
||||||
|
if (streamSessionId) {
|
||||||
|
await api.streams.end(streamSessionId);
|
||||||
|
addActivity('Stream', `Ended stream ${streamSessionId}`);
|
||||||
|
} else if (activeMediaRecorder?.state === 'recording') {
|
||||||
|
const captureResult = await stopLocalRecording();
|
||||||
|
await uploadStandaloneMotionRecording(captureResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.events.endMotion(eventId);
|
||||||
|
lastMotionEventId = null;
|
||||||
|
setAppState({
|
||||||
|
isMotionActive: false,
|
||||||
|
activeMotionSource: null
|
||||||
|
});
|
||||||
|
|
||||||
|
pushToast(source === 'auto' ? 'Automatic motion event ended' : 'Motion Ended', 'success');
|
||||||
|
addActivity(
|
||||||
|
'Motion',
|
||||||
|
source === 'auto' ? `Automatic motion event ended (${eventId})` : `Ended event ${eventId}`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncAutoMotionLifecycle = async ({ activeMotion }) => {
|
||||||
|
if (autoMotionTransitionInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeMotion) {
|
||||||
|
if (getAppState().isMotionActive || lastMotionEventId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoMotionTransitionInFlight = true;
|
||||||
|
try {
|
||||||
|
await startMotionEvent({ source: 'auto' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to auto-start motion event', error);
|
||||||
|
pushToast(error.message || 'Failed to start automatic motion event', 'error');
|
||||||
|
} finally {
|
||||||
|
autoMotionTransitionInFlight = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAutoMotionEventActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoMotionTransitionInFlight = true;
|
||||||
|
try {
|
||||||
|
await endMotionEvent({ source: 'auto' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to auto-end motion event', error);
|
||||||
|
pushToast(error.message || 'Failed to end automatic motion event', 'error');
|
||||||
|
} finally {
|
||||||
|
autoMotionTransitionInFlight = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCameraStreamRequest = async ({ streamId, requesterDeviceId, startOfferToClient }) => {
|
||||||
|
if (!streamId || !requesterDeviceId) {
|
||||||
|
throw new Error('Missing stream request context');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready = await startCameraPreview();
|
||||||
|
if (!ready) {
|
||||||
|
throw new Error('Camera permission is required before streaming');
|
||||||
|
}
|
||||||
|
|
||||||
|
activeRecordingStreamSessionId = streamId;
|
||||||
|
await api.streams.accept(streamId);
|
||||||
|
await startLocalRecording();
|
||||||
|
await startOfferToClient(streamId, requesterDeviceId);
|
||||||
|
addActivity('Stream', 'Accepted stream request and started WebRTC offer');
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupMediaState = async () => {
|
||||||
|
await stopLocalRecording();
|
||||||
|
stopCameraPreview();
|
||||||
|
if (motionDetector?.isRunning()) {
|
||||||
|
motionDetector.stop('idle');
|
||||||
|
}
|
||||||
|
activeRecordingStreamSessionId = null;
|
||||||
|
lastMotionEventId = null;
|
||||||
|
detectorMotionActive = false;
|
||||||
|
autoMotionTransitionInFlight = false;
|
||||||
|
updateMotionDetectionRuntime({ state: 'idle', score: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
refreshCameraInputDevices,
|
||||||
|
startCameraPreview,
|
||||||
|
stopCameraPreview,
|
||||||
|
applyMotionDetectionReadiness,
|
||||||
|
startLocalRecording,
|
||||||
|
stopLocalRecording,
|
||||||
|
finalizeRecordingForStream,
|
||||||
|
startMotionEvent,
|
||||||
|
endMotionEvent,
|
||||||
|
syncAutoMotionLifecycle,
|
||||||
|
handleCameraStreamRequest,
|
||||||
|
onMediaDeviceChange() {
|
||||||
|
void refreshCameraInputDevices();
|
||||||
|
},
|
||||||
|
onVisibilityChange() {
|
||||||
|
applyMotionDetectionReadiness();
|
||||||
|
},
|
||||||
|
setCameraVideoElement(element) {
|
||||||
|
cameraVideoElement = element;
|
||||||
|
attachCameraStreamToElement();
|
||||||
|
void refreshCameraInputDevices();
|
||||||
|
},
|
||||||
|
getLocalCameraStream() {
|
||||||
|
return localCameraStream;
|
||||||
|
},
|
||||||
|
setActiveRecordingStreamSessionId(streamSessionId) {
|
||||||
|
activeRecordingStreamSessionId = streamSessionId;
|
||||||
|
},
|
||||||
|
getActiveRecordingStreamSessionId() {
|
||||||
|
return activeRecordingStreamSessionId;
|
||||||
|
},
|
||||||
|
clearActiveRecordingStreamSession(streamSessionId = null) {
|
||||||
|
if (!streamSessionId || activeRecordingStreamSessionId === streamSessionId) {
|
||||||
|
activeRecordingStreamSessionId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isRecordingActive() {
|
||||||
|
return activeMediaRecorder?.state === 'recording';
|
||||||
|
},
|
||||||
|
cleanupMediaState
|
||||||
|
};
|
||||||
|
};
|
||||||
472
WebApp/src/lib/app/controller-shared.js
Normal file
472
WebApp/src/lib/app/controller-shared.js
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
const PAGE_PATHS = {
|
||||||
|
auth: '/',
|
||||||
|
onboarding: '/onboarding',
|
||||||
|
camera: '/camera',
|
||||||
|
client: '/client',
|
||||||
|
activity: '/activity',
|
||||||
|
settings: '/settings'
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEVICE_STORAGE_KEY = 'mobileSimDevice';
|
||||||
|
const MOTION_DETECTION_SETTINGS_STORAGE_KEY = 'securecam-motion-detection-settings';
|
||||||
|
|
||||||
|
export const INVALID_DEVICE_TOKEN_ERRORS = new Set([
|
||||||
|
'Missing device token',
|
||||||
|
'Invalid device token',
|
||||||
|
'Device not found',
|
||||||
|
'Token role does not match device role'
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const MOTION_DETECTION_PROFILES = {
|
||||||
|
low_power: {
|
||||||
|
profile: 'low_power',
|
||||||
|
label: 'Low Power',
|
||||||
|
description: 'Least heat and battery usage, slower trigger response.',
|
||||||
|
sampleIntervalMs: 1400,
|
||||||
|
burstIntervalMs: 500,
|
||||||
|
triggerThreshold: 0.15,
|
||||||
|
releaseThreshold: 0.05,
|
||||||
|
consecutiveTriggerFrames: 3,
|
||||||
|
cooldownMs: 12000,
|
||||||
|
minimumEventMs: 6000
|
||||||
|
},
|
||||||
|
balanced: {
|
||||||
|
profile: 'balanced',
|
||||||
|
label: 'Balanced',
|
||||||
|
description: 'Recommended default for a plugged-in foreground browser.',
|
||||||
|
sampleIntervalMs: 1000,
|
||||||
|
burstIntervalMs: 300,
|
||||||
|
triggerThreshold: 0.12,
|
||||||
|
releaseThreshold: 0.04,
|
||||||
|
consecutiveTriggerFrames: 3,
|
||||||
|
cooldownMs: 9000,
|
||||||
|
minimumEventMs: 6000
|
||||||
|
},
|
||||||
|
responsive: {
|
||||||
|
profile: 'responsive',
|
||||||
|
label: 'Responsive',
|
||||||
|
description: 'Faster trigger response with higher CPU and thermal cost.',
|
||||||
|
sampleIntervalMs: 700,
|
||||||
|
burstIntervalMs: 220,
|
||||||
|
triggerThreshold: 0.1,
|
||||||
|
releaseThreshold: 0.035,
|
||||||
|
consecutiveTriggerFrames: 2,
|
||||||
|
cooldownMs: 7000,
|
||||||
|
minimumEventMs: 6000
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MAX_STREAM_DIAGNOSTIC_SESSIONS = 12;
|
||||||
|
export const MAX_STREAM_DIAGNOSTIC_ENTRIES = 24;
|
||||||
|
|
||||||
|
const normalizePath = (path) => path.replace(/\/+$/, '') || '/';
|
||||||
|
|
||||||
|
export const pageFromPath = (path) => {
|
||||||
|
switch (normalizePath(path)) {
|
||||||
|
case '/onboarding':
|
||||||
|
return 'onboarding';
|
||||||
|
case '/camera':
|
||||||
|
return 'camera';
|
||||||
|
case '/client':
|
||||||
|
return 'client';
|
||||||
|
case '/activity':
|
||||||
|
return 'activity';
|
||||||
|
case '/settings':
|
||||||
|
return 'settings';
|
||||||
|
default:
|
||||||
|
return 'auth';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getHomePageKeyForRole = (role) => (role === 'camera' ? 'camera' : 'client');
|
||||||
|
|
||||||
|
export const getMotionDetectionProfile = (profile) =>
|
||||||
|
MOTION_DETECTION_PROFILES[profile] ?? MOTION_DETECTION_PROFILES.balanced;
|
||||||
|
|
||||||
|
const getDefaultMotionDetectionState = () => ({
|
||||||
|
enabled: false,
|
||||||
|
profile: MOTION_DETECTION_PROFILES.balanced.profile,
|
||||||
|
state: 'idle',
|
||||||
|
score: 0,
|
||||||
|
debug: false,
|
||||||
|
lastTriggeredAt: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildMotionDetectionState = (overrides = {}) => {
|
||||||
|
const defaults = getDefaultMotionDetectionState();
|
||||||
|
const nextProfile = getMotionDetectionProfile(overrides.profile ?? defaults.profile);
|
||||||
|
return {
|
||||||
|
...defaults,
|
||||||
|
...nextProfile,
|
||||||
|
...overrides,
|
||||||
|
profile: nextProfile.profile
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeMotionDetectionSettings = (value) => {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return buildMotionDetectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildMotionDetectionState({
|
||||||
|
enabled: Boolean(value.enabled),
|
||||||
|
profile: typeof value.profile === 'string' ? value.profile : undefined,
|
||||||
|
debug: Boolean(value.debug)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeMotionDetectionState = (value) => {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return buildMotionDetectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildMotionDetectionState({
|
||||||
|
enabled: Boolean(value.enabled),
|
||||||
|
profile: typeof value.profile === 'string' ? value.profile : undefined,
|
||||||
|
state: typeof value.state === 'string' ? value.state : undefined,
|
||||||
|
score: typeof value.score === 'number' ? value.score : undefined,
|
||||||
|
debug: Boolean(value.debug),
|
||||||
|
lastTriggeredAt: typeof value.lastTriggeredAt === 'string' ? value.lastTriggeredAt : null
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createControllerShared = ({ api, getAppState, setAppState, patchAppState }) => {
|
||||||
|
const makeId = () => {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentPath = () => normalizePath(window.location.pathname);
|
||||||
|
|
||||||
|
const getPathForScreen = (screen, role) => {
|
||||||
|
if (screen === 'home') {
|
||||||
|
return PAGE_PATHS[getHomePageKeyForRole(role)];
|
||||||
|
}
|
||||||
|
return PAGE_PATHS[screen] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToScreen = (screen, options = {}) => {
|
||||||
|
const { replace = false, role = getAppState().device?.role } = options;
|
||||||
|
const targetPath = getPathForScreen(screen, role);
|
||||||
|
if (!targetPath) return false;
|
||||||
|
|
||||||
|
if (getCurrentPath() !== normalizePath(targetPath)) {
|
||||||
|
if (replace) {
|
||||||
|
window.location.replace(targetPath);
|
||||||
|
} else {
|
||||||
|
window.location.assign(targetPath);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAppState({ page: pageFromPath(targetPath) });
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushToast = (message, type = 'info') => {
|
||||||
|
const id = makeId();
|
||||||
|
patchAppState((state) => ({
|
||||||
|
toasts: [...state.toasts, { id, message, type }].slice(-6)
|
||||||
|
}));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
patchAppState((state) => ({
|
||||||
|
toasts: state.toasts.filter((toast) => toast.id !== id)
|
||||||
|
}));
|
||||||
|
}, 3200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeToast = (id) => {
|
||||||
|
patchAppState((state) => ({
|
||||||
|
toasts: state.toasts.filter((toast) => toast.id !== id)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addActivity = (type, message) => {
|
||||||
|
const item = {
|
||||||
|
id: makeId(),
|
||||||
|
type,
|
||||||
|
message,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
patchAppState((state) => ({
|
||||||
|
activityLog: [item, ...state.activityLog].slice(0, 200)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const pruneStreamDiagnostics = (diagnostics) => {
|
||||||
|
const entries = Object.entries(diagnostics || {});
|
||||||
|
if (entries.length <= MAX_STREAM_DIAGNOSTIC_SESSIONS) {
|
||||||
|
return diagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
entries
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
new Date(right[1]?.updatedAt || 0).getTime() - new Date(left[1]?.updatedAt || 0).getTime()
|
||||||
|
)
|
||||||
|
.slice(0, MAX_STREAM_DIAGNOSTIC_SESSIONS)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushStreamDiagnostic = (streamSessionId, stage, message, level = 'info', meta = {}) => {
|
||||||
|
if (!streamSessionId || !stage || !message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
patchAppState((state) => {
|
||||||
|
const current = state.streamDiagnostics?.[streamSessionId] ?? {
|
||||||
|
streamSessionId,
|
||||||
|
cameraDeviceId: null,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
entries: []
|
||||||
|
};
|
||||||
|
const next = {
|
||||||
|
...current,
|
||||||
|
...meta,
|
||||||
|
streamSessionId,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: makeId(),
|
||||||
|
stage,
|
||||||
|
message,
|
||||||
|
level,
|
||||||
|
createdAt
|
||||||
|
},
|
||||||
|
...(current.entries || [])
|
||||||
|
].slice(0, MAX_STREAM_DIAGNOSTIC_ENTRIES)
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
streamDiagnostics: pruneStreamDiagnostics({
|
||||||
|
...(state.streamDiagnostics || {}),
|
||||||
|
[streamSessionId]: next
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMotionDetectionSettings = () => {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return buildMotionDetectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(MOTION_DETECTION_SETTINGS_STORAGE_KEY);
|
||||||
|
if (!saved) {
|
||||||
|
return buildMotionDetectionState();
|
||||||
|
}
|
||||||
|
return sanitizeMotionDetectionSettings(JSON.parse(saved));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load motion detection settings', error);
|
||||||
|
return buildMotionDetectionState();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistMotionDetectionSettings = (motionDetection) => {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
MOTION_DETECTION_SETTINGS_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
enabled: Boolean(motionDetection?.enabled),
|
||||||
|
profile: motionDetection?.profile ?? MOTION_DETECTION_PROFILES.balanced.profile,
|
||||||
|
debug: Boolean(motionDetection?.debug)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save motion detection settings', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMotionDetectionState = (updates) => {
|
||||||
|
const current = normalizeMotionDetectionState(getAppState().motionDetection);
|
||||||
|
const next =
|
||||||
|
typeof updates === 'function'
|
||||||
|
? normalizeMotionDetectionState({
|
||||||
|
...current,
|
||||||
|
...updates(current)
|
||||||
|
})
|
||||||
|
: normalizeMotionDetectionState({
|
||||||
|
...current,
|
||||||
|
...updates
|
||||||
|
});
|
||||||
|
|
||||||
|
setAppState({ motionDetection: next });
|
||||||
|
persistMotionDetectionSettings(next);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readSavedDeviceRecord = () => {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = localStorage.getItem(DEVICE_STORAGE_KEY);
|
||||||
|
if (!saved) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(saved);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse saved device', error);
|
||||||
|
localStorage.removeItem(DEVICE_STORAGE_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistSavedDeviceRecord = ({ device, deviceToken, userId }) => {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
DEVICE_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
device,
|
||||||
|
deviceToken,
|
||||||
|
userId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSavedDeviceRecord = () => {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.removeItem(DEVICE_STORAGE_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySavedDeviceState = (device, deviceToken) => {
|
||||||
|
setAppState({
|
||||||
|
device,
|
||||||
|
deviceToken,
|
||||||
|
onboardingForm: {
|
||||||
|
...getAppState().onboardingForm,
|
||||||
|
name: device?.name ?? '',
|
||||||
|
role: device?.role ?? 'client',
|
||||||
|
pushToken: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearDeviceState = () => {
|
||||||
|
setAppState({
|
||||||
|
device: null,
|
||||||
|
deviceToken: null,
|
||||||
|
socketConnected: false,
|
||||||
|
isMotionActive: false,
|
||||||
|
activeMotionSource: null,
|
||||||
|
cameraStatus: 'idle',
|
||||||
|
cameraPreviewReady: false,
|
||||||
|
linkedCameras: [],
|
||||||
|
recordings: [],
|
||||||
|
activeCameraDeviceId: null,
|
||||||
|
activeStreamSessionId: null,
|
||||||
|
openLinkedCameraMenuId: null,
|
||||||
|
cameraSessions: {},
|
||||||
|
connectedStreamSessionIds: [],
|
||||||
|
clientStreamMode: 'none',
|
||||||
|
clientPlaceholderText: 'Select a camera to view',
|
||||||
|
onboardingForm: {
|
||||||
|
...getAppState().onboardingForm,
|
||||||
|
name: '',
|
||||||
|
role: 'client',
|
||||||
|
pushToken: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreSavedDeviceForSession = async (session, options = {}) => {
|
||||||
|
const { showMissingToast = false, showInvalidToast = false } = options;
|
||||||
|
const saved = readSavedDeviceRecord();
|
||||||
|
|
||||||
|
if (!saved) {
|
||||||
|
if (showMissingToast) {
|
||||||
|
pushToast('No saved device found', 'info');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionUserId = session?.user?.id;
|
||||||
|
const savedUserId = typeof saved.userId === 'string' ? saved.userId : null;
|
||||||
|
const savedDeviceId = saved?.device?.id;
|
||||||
|
const savedDeviceToken = typeof saved?.deviceToken === 'string' ? saved.deviceToken : '';
|
||||||
|
|
||||||
|
if (!sessionUserId || !savedDeviceId || !savedDeviceToken) {
|
||||||
|
clearSavedDeviceRecord();
|
||||||
|
clearDeviceState();
|
||||||
|
if (showInvalidToast) {
|
||||||
|
pushToast('Saved device is incomplete. Please register again.', 'error');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedUserId && savedUserId !== sessionUserId) {
|
||||||
|
clearSavedDeviceRecord();
|
||||||
|
clearDeviceState();
|
||||||
|
if (showInvalidToast) {
|
||||||
|
pushToast('Saved device belongs to a different account.', 'info');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.devices.list();
|
||||||
|
const matchingDevice = result.devices?.find((device) => device.id === savedDeviceId);
|
||||||
|
|
||||||
|
if (!matchingDevice) {
|
||||||
|
clearSavedDeviceRecord();
|
||||||
|
clearDeviceState();
|
||||||
|
if (showInvalidToast) {
|
||||||
|
pushToast('Saved device was not found for this account.', 'info');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
applySavedDeviceState(matchingDevice, savedDeviceToken);
|
||||||
|
persistSavedDeviceRecord({
|
||||||
|
device: matchingDevice,
|
||||||
|
deviceToken: savedDeviceToken,
|
||||||
|
userId: sessionUserId
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to restore saved device', error);
|
||||||
|
if (showInvalidToast) {
|
||||||
|
pushToast('Unable to restore saved device right now.', 'error');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
makeId,
|
||||||
|
pushToast,
|
||||||
|
removeToast,
|
||||||
|
addActivity,
|
||||||
|
pushStreamDiagnostic,
|
||||||
|
loadMotionDetectionSettings,
|
||||||
|
updateMotionDetectionState,
|
||||||
|
navigateToScreen,
|
||||||
|
persistSavedDeviceRecord,
|
||||||
|
clearSavedDeviceRecord,
|
||||||
|
applySavedDeviceState,
|
||||||
|
clearDeviceState,
|
||||||
|
restoreSavedDeviceForSession
|
||||||
|
};
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user