Refactor web app controller shared and media modules

This commit is contained in:
2026-04-15 19:00:00 +01:00
parent ec1e54e8f2
commit 78d14cb73f
3 changed files with 1438 additions and 1292 deletions

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

View 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