/* eslint-disable @typescript-eslint/ban-ts-comment */ // @ts-nocheck import { io } from 'socket.io-client'; import { api, getBackendUrl } from './api'; import { createMotionDetector } from './motion-detector'; import { getAppState, patchAppState, resetAppState, setAppState } from './store'; const PAGE_PATHS = { auth: '/', onboarding: '/onboarding', camera: '/camera', client: '/client', activity: '/activity', settings: '/settings' }; const DEVICE_STORAGE_KEY = 'mobileSimDevice'; const INVALID_DEVICE_TOKEN_ERRORS = new Set([ 'Missing device token', 'Invalid device token', 'Device not found', 'Token role does not match device role' ]); const MOTION_DETECTION_SETTINGS_STORAGE_KEY = 'securecam-motion-detection-settings'; 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: 9000 }, 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: 8000 }, 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: 7000 } }; 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 } }; const SOCKET_HEARTBEAT_INTERVAL_MS = 10_000; const rtcConfig = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }; let initialized = false; let initPromise = null; let socket = null; let pollInterval = null; let socketHeartbeatInterval = null; 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; let clientVideoElement = null; const peerConnections = new Map(); const remoteStreams = new Map(); const pendingCandidatesMap = new Map(); const streamTimers = new Map(); const connectedPeers = new Set(); const requestedStreams = new Set(); const makeId = () => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return `${Date.now()}-${Math.random().toString(16).slice(2)}`; }; const getDefaultMotionDetectionState = () => ({ enabled: false, profile: MOTION_DETECTION_PROFILES.balanced.profile, state: 'idle', score: 0, debug: false, lastTriggeredAt: null }); const getMotionDetectionProfile = (profile) => MOTION_DETECTION_PROFILES[profile] ?? MOTION_DETECTION_PROFILES.balanced; 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 }); }; 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 normalizePath = (path) => path.replace(/\/+$/, '') || '/'; const getCurrentPath = () => normalizePath(window.location.pathname); const getHomePageKeyForRole = (role) => (role === 'camera' ? 'camera' : 'client'); const getPathForScreen = (screen, role) => { if (screen === 'home') { return PAGE_PATHS[getHomePageKeyForRole(role)]; } return PAGE_PATHS[screen] || null; }; 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'; } }; 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 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; } }; const setConnectedStreamSessionIds = () => { setAppState({ connectedStreamSessionIds: Array.from(connectedPeers) }); }; 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 setClientStreamMode = (mode) => { let clientPlaceholderText = 'Select a camera to view'; if (mode === 'connecting') clientPlaceholderText = 'Connecting stream...'; if (mode === 'unavailable') clientPlaceholderText = 'Stream unavailable'; if (mode === 'none') clientPlaceholderText = 'Select a camera to view'; patchAppState(() => ({ clientStreamMode: mode, clientPlaceholderText })); }; const getCameraDeviceIdFromStream = (stream) => { if (!stream) return ''; const videoTrack = stream.getVideoTracks?.()[0]; if (!videoTrack) return ''; const settings = videoTrack.getSettings?.() || {}; return typeof settings.deviceId === 'string' ? settings.deviceId : ''; }; const buildCameraConstraints = (cameraDeviceId = '') => { if (!cameraDeviceId) return { ...DEFAULT_CAMERA_CONSTRAINTS }; return { ...DEFAULT_CAMERA_CONSTRAINTS, deviceId: { exact: cameraDeviceId } }; }; const refreshCameraInputDevices = async () => { if (!navigator.mediaDevices?.enumerateDevices) { setAppState({ cameraInputDevices: [], selectedCameraInputId: '' }); return []; } try { const devices = await navigator.mediaDevices.enumerateDevices(); const cameraInputDevices = devices .filter((device) => device.kind === 'videoinput' && device.deviceId) .map((device, index) => ({ id: device.deviceId, label: device.label || `Camera ${index + 1}` })); const selectedCameraInputId = getAppState().selectedCameraInputId; const streamCameraInputId = getCameraDeviceIdFromStream(localCameraStream); const candidateCameraInputId = selectedCameraInputId || streamCameraInputId || ''; const nextSelectedCameraInputId = cameraInputDevices.some((device) => device.id === candidateCameraInputId) ? candidateCameraInputId : (cameraInputDevices[0]?.id ?? ''); setAppState({ cameraInputDevices, selectedCameraInputId: nextSelectedCameraInputId }); return cameraInputDevices; } catch (error) { console.error('Failed to enumerate cameras', error); addActivity('Camera', 'Failed to enumerate camera inputs'); return []; } }; const onMediaDeviceChange = () => { void refreshCameraInputDevices(); }; const attachCameraStreamToElement = () => { if (!cameraVideoElement) return; cameraVideoElement.srcObject = localCameraStream; if (localCameraStream) { void cameraVideoElement.play().catch(() => {}); } applyMotionDetectionReadiness(); }; const attachClientStreamToElement = () => { if (!clientVideoElement) return; const { activeStreamSessionId } = getAppState(); if (!activeStreamSessionId) { clientVideoElement.srcObject = null; return; } const stream = remoteStreams.get(activeStreamSessionId); if (!stream) return; if (clientVideoElement.srcObject !== stream) { clientVideoElement.srcObject = stream; } void clientVideoElement.play().catch(() => {}); }; 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 updateMotionDetectionRuntime = (updates) => { patchAppState((state) => ({ motionDetection: { ...state.motionDetection, ...updates } })); }; 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 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 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 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 onVisibilityChange = () => { applyMotionDetectionReadiness(); }; const clearClientStream = () => { const { activeStreamSessionId } = getAppState(); if (activeStreamSessionId && streamTimers.has(activeStreamSessionId)) { clearTimeout(streamTimers.get(activeStreamSessionId)); streamTimers.delete(activeStreamSessionId); } if (activeStreamSessionId && remoteStreams.has(activeStreamSessionId)) { remoteStreams.get(activeStreamSessionId)?.getTracks().forEach((track) => track.stop()); remoteStreams.delete(activeStreamSessionId); } if (clientVideoElement) { clientVideoElement.srcObject = null; } setClientStreamMode('none'); }; const endClientStreamSession = async (streamSessionId, { teardown = true } = {}) => { if (!streamSessionId) return; try { await api.streams.end(streamSessionId); } catch (error) { const message = error?.message || ''; if (!/cannot be ended|not found/i.test(message)) { console.warn('Failed ending client stream session', error); } } if (teardown) { teardownPeerConnection(streamSessionId); } }; const hasReusableClientStreamSession = (streamSessionId) => Boolean(streamSessionId && (remoteStreams.has(streamSessionId) || streamTimers.has(streamSessionId))); const removeCameraSessionMapping = (streamSessionId) => { if (!streamSessionId) return; patchAppState((state) => ({ cameraSessions: Object.fromEntries( Object.entries(state.cameraSessions || {}).filter(([, sessionId]) => sessionId !== streamSessionId) ) })); }; const getLinkedCamera = (cameraDeviceId) => getAppState().linkedCameras.find((camera) => camera.cameraDeviceId === cameraDeviceId); const getCameraLabel = (cameraDeviceId, cameraName) => { const explicitName = typeof cameraName === 'string' ? cameraName.trim() : ''; if (explicitName) return explicitName; const linkedName = getLinkedCamera(cameraDeviceId)?.cameraName; if (typeof linkedName === 'string' && linkedName.trim()) { return linkedName.trim(); } return `Camera ${cameraDeviceId?.substring(0, 6) ?? 'Unknown'}`; }; const pushMotionNotification = (cameraDeviceId) => { if (!cameraDeviceId) return; const notification = { id: makeId(), cameraDeviceId, message: `${getCameraLabel(cameraDeviceId)} has detected movement`, createdAt: new Date().toISOString(), isRead: false }; patchAppState((state) => ({ motionNotifications: [notification, ...state.motionNotifications].slice(0, 50) })); }; const markMotionNotificationRead = (notificationId) => { patchAppState((state) => ({ motionNotifications: state.motionNotifications.map((notification) => notification.id === notificationId ? { ...notification, isRead: true } : notification ) })); }; const markAllNotificationsRead = () => { patchAppState((state) => ({ motionNotifications: state.motionNotifications.map((notification) => notification.isRead ? notification : { ...notification, isRead: true } ) })); }; const openRecordingModal = (downloadUrl, title) => { setAppState({ recordingModal: { open: true, title: title || 'Recording Playback', url: downloadUrl } }); }; const closeRecordingModal = () => { setAppState({ recordingModal: { open: false, title: 'Recording Playback', url: '' } }); }; 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 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 toEvenDimension = (value) => { const rounded = Math.max(2, Math.floor(value)); return rounded % 2 === 0 ? rounded : rounded - 1; }; 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 teardownPeerConnection = (streamSessionId) => { if (!streamSessionId) { for (const connection of peerConnections.values()) { connection.close(); } peerConnections.clear(); remoteStreams.clear(); pendingCandidatesMap.clear(); connectedPeers.clear(); setConnectedStreamSessionIds(); setAppState({ cameraSessions: {} }); clearClientStream(); return; } if (peerConnections.has(streamSessionId)) { peerConnections.get(streamSessionId)?.close(); peerConnections.delete(streamSessionId); } remoteStreams.delete(streamSessionId); pendingCandidatesMap.delete(streamSessionId); connectedPeers.delete(streamSessionId); setConnectedStreamSessionIds(); removeCameraSessionMapping(streamSessionId); if (getAppState().activeStreamSessionId === streamSessionId) { clearClientStream(); } }; const queueRemoteCandidate = ({ streamSessionId, fromDeviceId, data }) => { if (!streamSessionId || !fromDeviceId || !data) return; if (!pendingCandidatesMap.has(streamSessionId)) { pendingCandidatesMap.set(streamSessionId, []); } const queue = pendingCandidatesMap.get(streamSessionId); queue.push({ streamSessionId, fromDeviceId, data, createdAt: Date.now() }); const cutoff = Date.now() - 120000; pendingCandidatesMap.set( streamSessionId, queue.filter((item) => item.createdAt >= cutoff).slice(-200) ); }; const takeQueuedCandidates = (streamSessionId, fromDeviceId) => { if (!pendingCandidatesMap.has(streamSessionId)) return []; const queue = pendingCandidatesMap.get(streamSessionId); const queued = queue.filter((item) => item.fromDeviceId === fromDeviceId); pendingCandidatesMap.set( streamSessionId, queue.filter((item) => item.fromDeviceId !== fromDeviceId) ); return queued; }; const applyQueuedCandidates = async (connection, streamSessionId, fromDeviceId) => { if (!connection?.remoteDescription) return; const queued = takeQueuedCandidates(streamSessionId, fromDeviceId); for (const candidate of queued) { try { await connection.addIceCandidate(new RTCIceCandidate(candidate.data)); } catch (error) { console.warn('Dropping queued ICE candidate', error); } } }; const ensurePeerConnection = async ({ streamSessionId, targetDeviceId, asCamera }) => { if (peerConnections.has(streamSessionId)) { return peerConnections.get(streamSessionId); } const connection = new RTCPeerConnection(rtcConfig); peerConnections.set(streamSessionId, connection); connection.onicecandidate = (event) => { if (!socket || !event.candidate) return; socket.emit('webrtc:signal', { toDeviceId: targetDeviceId, streamSessionId, signalType: 'candidate', data: event.candidate.toJSON() }); }; connection.onconnectionstatechange = () => { if (connection.connectionState === 'connected') { addActivity('WebRTC', `Peer connected for ${streamSessionId}`); connectedPeers.add(streamSessionId); setConnectedStreamSessionIds(); } else if ( connection.connectionState === 'failed' || connection.connectionState === 'disconnected' || connection.connectionState === 'closed' ) { addActivity('WebRTC', `Peer ${connection.connectionState} for ${streamSessionId}`); connectedPeers.delete(streamSessionId); setConnectedStreamSessionIds(); if (getAppState().device?.role === 'client' && getAppState().activeStreamSessionId === streamSessionId) { if (connection.connectionState === 'failed' || connection.connectionState === 'closed') { setClientStreamMode('unavailable'); } } } }; connection.ontrack = (event) => { if (streamTimers.has(streamSessionId)) { clearTimeout(streamTimers.get(streamSessionId)); streamTimers.delete(streamSessionId); } const [stream] = event.streams; if (!stream) return; connectedPeers.add(streamSessionId); setConnectedStreamSessionIds(); remoteStreams.set(streamSessionId, stream); if (getAppState().activeStreamSessionId === streamSessionId) { attachClientStreamToElement(); setClientStreamMode('video'); } }; if (asCamera) { const ready = await startCameraPreview(); if (!ready || !localCameraStream || localCameraStream.getVideoTracks().length === 0) { throw new Error('Camera stream unavailable for WebRTC publish'); } localCameraStream.getTracks().forEach((track) => connection.addTrack(track, localCameraStream)); } return connection; }; const startOfferToClient = async (streamSessionId, requesterDeviceId) => { if (!socket) return; const connection = await ensurePeerConnection({ streamSessionId, targetDeviceId: requesterDeviceId, asCamera: true }); const offer = await connection.createOffer(); await connection.setLocalDescription(offer); socket.emit('webrtc:signal', { toDeviceId: requesterDeviceId, streamSessionId, signalType: 'offer', data: offer }); }; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 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'); } 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 uploadResponse = await fetch(uploadMeta.uploadUrl, { method: 'PUT', headers: { 'Content-Type': compressedBlob.type || 'video/webm' }, body: compressedBlob }); if (!uploadResponse.ok) { throw new Error(`Upload failed with status ${uploadResponse.status}`); } await api.events.finalizeRecording(recording.id, { objectKey: uploadMeta.objectKey, bucket: uploadMeta.bucket, durationSeconds: captureResult.durationSeconds, sizeBytes: compressedBlob.size }); addActivity('Recording', 'Recording uploaded and finalized'); return true; } catch (error) { console.error('Recording upload failed, falling back to simulated key', error); const fallbackObjectKey = `sim/${streamSessionId}/${Date.now()}.webm`; await api.events.finalizeRecording(recording.id, { objectKey: fallbackObjectKey, durationSeconds: captureResult?.durationSeconds ?? 15, 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 { 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 uploadResponse = await fetch(uploadMeta.uploadUrl, { method: 'PUT', headers: { 'Content-Type': compressedBlob.type || 'video/webm' }, body: compressedBlob }); if (!uploadResponse.ok) { throw new Error(`Upload failed with status ${uploadResponse.status}`); } await api.events.finalizeRecording(uploadMeta.video.id, { objectKey: uploadMeta.objectKey, bucket: uploadMeta.bucket, durationSeconds: captureResult.durationSeconds, sizeBytes: compressedBlob.size }); addActivity('Recording', `Motion clip uploaded (${uploadMeta.objectKey})`); return true; } catch (error) { console.error('Standalone motion upload failed', error); addActivity('Recording', 'Standalone motion upload failed'); 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 }) => { 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 stopSocketHeartbeat = () => { if (socketHeartbeatInterval) { clearInterval(socketHeartbeatInterval); socketHeartbeatInterval = null; } }; const emitSocketHeartbeat = () => { if (!socket?.connected) { return; } socket.emit('heartbeat'); }; const startSocketHeartbeat = () => { stopSocketHeartbeat(); emitSocketHeartbeat(); socketHeartbeatInterval = setInterval(() => { emitSocketHeartbeat(); }, SOCKET_HEARTBEAT_INTERVAL_MS); }; const connectSocket = () => { const { deviceToken } = getAppState(); if (!deviceToken) return; stopSocketHeartbeat(); if (socket) socket.disconnect(); socket = io(getBackendUrl(), { auth: { token: deviceToken }, withCredentials: true }); socket.on('connect', () => { startSocketHeartbeat(); setAppState({ socketConnected: true }); addActivity('System', 'Connected to realtime server'); if (getAppState().device?.role === 'camera') { void startCameraPreview(); } applyMotionDetectionReadiness(); }); socket.on('disconnect', () => { stopSocketHeartbeat(); setAppState({ socketConnected: false }); void stopLocalRecording(); teardownPeerConnection(); setAppState({ activeCameraDeviceId: null, activeStreamSessionId: null }); applyMotionDetectionReadiness(); }); socket.on('connect_error', (error) => { const message = error?.message || 'Realtime connection failed'; stopSocketHeartbeat(); setAppState({ socketConnected: false }); addActivity('System', `Realtime connection failed: ${message}`); if (INVALID_DEVICE_TOKEN_ERRORS.has(message)) { void invalidateSavedDevice('Saved device is invalid for this account. Please register this browser again.'); return; } pushToast(message, 'error'); applyMotionDetectionReadiness(); }); socket.on('command:received', async (payload) => { addActivity('Command', `Received ${payload.commandType}`); try { if (payload.commandType === 'start_stream') { await handleCameraStreamRequest({ streamId: payload.payload.streamSessionId, requesterDeviceId: payload.sourceDeviceId }); socket.emit('command:ack', { commandId: payload.commandId, status: 'acknowledged' }); } } catch (error) { socket.emit('command:ack', { commandId: payload.commandId, status: 'rejected', error: error.message }); } }); socket.on('stream:requested', async (payload) => { if (getAppState().device?.role !== 'camera') return; try { await handleCameraStreamRequest({ streamId: payload.streamSessionId, requesterDeviceId: payload.requesterDeviceId }); } catch (error) { console.error('Failed handling direct stream request', error); pushToast(error.message || 'Failed to accept stream request', 'error'); } }); socket.on('motion:detected', (payload) => { const cameraDeviceId = payload.cameraDeviceId || payload.deviceId; addActivity('Motion', `${getCameraLabel(cameraDeviceId)} has detected movement`); pushToast('Motion Detected!', 'info'); pushMotionNotification(cameraDeviceId); if (cameraDeviceId) { setAppState({ activeCameraDeviceId: cameraDeviceId }); void actions.requestStream(cameraDeviceId); } }); socket.on('stream:started', async (payload) => { addActivity('Stream', 'Stream is live, connecting...'); const currentState = getAppState(); const cameraSessions = { ...currentState.cameraSessions, [payload.cameraDeviceId]: payload.streamSessionId }; setAppState({ cameraSessions }); if (payload.cameraDeviceId === currentState.activeCameraDeviceId) { setAppState({ activeStreamSessionId: payload.streamSessionId }); setClientStreamMode('connecting'); } streamTimers.set( payload.streamSessionId, setTimeout(() => { if (!remoteStreams.has(payload.streamSessionId)) { addActivity('Stream', `No remote video track received for ${payload.streamSessionId}`); if (getAppState().activeStreamSessionId === payload.streamSessionId) { setClientStreamMode('unavailable'); } } }, 6000) ); }); socket.on('stream:ended', async (payload) => { if (!payload?.streamSessionId) return; const streamSessionId = payload.streamSessionId; teardownPeerConnection(streamSessionId); if (streamSessionId === getAppState().activeStreamSessionId) { setAppState({ activeStreamSessionId: null }); } if (getAppState().device?.role === 'camera') { const shouldFinalize = activeRecordingStreamSessionId === streamSessionId || activeMediaRecorder?.state === 'recording'; if (shouldFinalize) { const captureResult = await stopLocalRecording(); await finalizeRecordingForStream(streamSessionId, captureResult); } if (activeRecordingStreamSessionId === streamSessionId) { activeRecordingStreamSessionId = null; } } }); socket.on('webrtc:signal', async (payload) => { const device = getAppState().device; if (!device || !payload?.streamSessionId || !payload?.signalType || !payload?.fromDeviceId) return; try { if (payload.signalType === 'offer') { if (device.role !== 'client') return; addActivity('WebRTC', 'Offer received'); const connection = await ensurePeerConnection({ streamSessionId: payload.streamSessionId, targetDeviceId: payload.fromDeviceId, asCamera: false }); await connection.setRemoteDescription(new RTCSessionDescription(payload.data)); await applyQueuedCandidates(connection, payload.streamSessionId, payload.fromDeviceId); const answer = await connection.createAnswer(); await connection.setLocalDescription(answer); socket.emit('webrtc:signal', { toDeviceId: payload.fromDeviceId, streamSessionId: payload.streamSessionId, signalType: 'answer', data: answer }); addActivity('WebRTC', 'Answer sent'); return; } if (payload.signalType === 'answer') { const connection = peerConnections.get(payload.streamSessionId); if (device.role !== 'camera' || !connection) return; if (connection.signalingState !== 'have-local-offer') { if (connection.signalingState === 'stable' && connection.remoteDescription?.type === 'answer') { return; } return; } await connection.setRemoteDescription(new RTCSessionDescription(payload.data)); await applyQueuedCandidates(connection, payload.streamSessionId, payload.fromDeviceId); addActivity('WebRTC', 'Answer received and applied'); return; } if (payload.signalType === 'candidate') { if (!payload.data) return; const connection = peerConnections.get(payload.streamSessionId); if (!connection || !connection.remoteDescription) { queueRemoteCandidate(payload); return; } await connection.addIceCandidate(new RTCIceCandidate(payload.data)); return; } if (payload.signalType === 'hangup') { teardownPeerConnection(payload.streamSessionId); if (getAppState().activeStreamSessionId === payload.streamSessionId) { setAppState({ activeStreamSessionId: null }); } addActivity('Stream', 'Remote stream ended'); } } catch (error) { console.error('Failed handling WebRTC signal', error); pushToast('WebRTC negotiation failed', 'error'); } }); socket.on('error:webrtc_signal', (payload) => { const message = payload?.message || 'WebRTC signaling error'; addActivity('WebRTC', message); pushToast(message, 'error'); }); }; const stopPolling = () => { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } }; const pollClientData = async () => { const { device } = getAppState(); if (!device || device.role !== 'client') return; const [recs, links, deviceList] = await Promise.all([ api.ops.listRecordings().catch(() => ({ recordings: [] })), api.devices.listLinks().catch(() => ({ links: [] })), api.devices.list().catch(() => ({ devices: [] })) ]); const cameraById = new Map( (deviceList.devices || []) .filter((entry) => entry.role === 'camera') .map((entry) => [entry.id, entry]) ); const linkedCameras = (links.links || []).map((link) => { const camera = cameraById.get(link.cameraDeviceId); return { ...link, cameraName: camera?.name ?? null, cameraStatus: camera?.status ?? 'offline' }; }); setAppState({ recordings: recs.recordings || [], linkedCameras }); }; const startPolling = () => { stopPolling(); void pollClientData(); pollInterval = setInterval(() => { void pollClientData(); }, 5000); }; const cleanupConnectionState = async () => { stopPolling(); stopSocketHeartbeat(); await stopLocalRecording(); teardownPeerConnection(); stopCameraPreview(); if (motionDetector?.isRunning()) { motionDetector.stop('idle'); } if (socket) { socket.disconnect(); socket = null; } requestedStreams.clear(); }; const invalidateSavedDevice = async (message, options = {}) => { const { showToast = true } = options; clearSavedDeviceRecord(); await cleanupConnectionState(); clearDeviceState(); if (showToast) { pushToast(message || 'Saved device is no longer valid. Please register this browser again.', 'error'); } navigateToScreen('onboarding', { replace: true }); }; const enforceRouteForSession = () => { const state = getAppState(); const page = pageFromPath(window.location.pathname); setAppState({ page }); if (!state.session) { if (page !== 'auth') { navigateToScreen('auth', { replace: true }); } return; } if (!state.deviceToken) { if (page !== 'onboarding') { navigateToScreen('onboarding', { replace: true }); } return; } const expectedHome = getHomePageKeyForRole(state.device?.role); if ((page === 'auth' || page === 'onboarding') && expectedHome) { navigateToScreen('home', { replace: true, role: state.device?.role }); return; } if ((page === 'camera' || page === 'client') && page !== expectedHome) { navigateToScreen('home', { replace: true, role: state.device?.role }); } }; const init = async () => { if (initialized) return; if (initPromise) return initPromise; initPromise = (async () => { setAppState({ page: pageFromPath(window.location.pathname), loading: true }); if (navigator.mediaDevices?.addEventListener) { navigator.mediaDevices.addEventListener('devicechange', onMediaDeviceChange); } if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', onVisibilityChange); } setAppState({ motionDetection: loadMotionDetectionSettings() }); try { const session = await api.auth.getSession(); if (session?.session) { setAppState({ session }); const restoredSavedDevice = await restoreSavedDeviceForSession(session); if (restoredSavedDevice) { connectSocket(); startPolling(); } } else { setAppState({ session: null }); clearDeviceState(); } } catch { setAppState({ session: null }); clearDeviceState(); } enforceRouteForSession(); void refreshCameraInputDevices(); applyMotionDetectionReadiness(); window.addEventListener('beforeunload', () => { void cleanupConnectionState(); }); initialized = true; })().finally(() => { setAppState({ loading: false }); initPromise = null; }); return initPromise; }; const destroy = async () => { if (navigator.mediaDevices?.removeEventListener) { navigator.mediaDevices.removeEventListener('devicechange', onMediaDeviceChange); } if (typeof document !== 'undefined') { document.removeEventListener('visibilitychange', onVisibilityChange); } initialized = false; await cleanupConnectionState(); }; const actions = { setPage(page) { setAppState({ page }); if (page === 'activity') { markAllNotificationsRead(); } if (page === 'client') { void pollClientData(); } }, navigate(target) { if (target === 'activity') { markAllNotificationsRead(); } navigateToScreen(target); }, setAuthField(field, value) { patchAppState((state) => ({ authForm: { ...state.authForm, [field]: value } })); }, toggleAuthMode() { patchAppState((state) => ({ isRegistering: !state.isRegistering })); }, async submitAuth() { const state = getAppState(); const { email, password, name } = state.authForm; const normalizedName = name || email.split('@')[0]; try { if (state.isRegistering) { await api.auth.signUp({ email, password, name: normalizedName }); } await api.auth.signIn({ email, password }); const session = await api.auth.getSession(); setAppState({ session, authForm: { ...state.authForm, password: '' } }); pushToast(`Welcome, ${session.user.name}`, 'success'); const restoredSavedDevice = await restoreSavedDeviceForSession(session); if (restoredSavedDevice) { connectSocket(); startPolling(); navigateToScreen('home', { role: getAppState().device?.role }); } else { navigateToScreen('onboarding'); } } catch (error) { pushToast(error.message || 'Authentication failed', 'error'); } }, setOnboardingField(field, value) { patchAppState((state) => ({ onboardingForm: { ...state.onboardingForm, [field]: value } })); }, selectRole(role) { patchAppState((state) => ({ onboardingForm: { ...state.onboardingForm, role } })); }, async registerDevice() { const { onboardingForm } = getAppState(); const name = onboardingForm.name || 'Web Dashboard'; const role = onboardingForm.role; const pushToken = onboardingForm.pushToken; try { const payload = { name, role, platform: 'web', appVersion: 'webapp-1.0' }; if (pushToken?.trim()) { payload.pushToken = pushToken.trim(); } const result = await api.devices.register(payload); applySavedDeviceState(result.device, result.deviceToken); persistSavedDeviceRecord({ device: result.device, deviceToken: result.deviceToken, userId: getAppState().session?.user?.id ?? null }); pushToast('Device Registered', 'success'); connectSocket(); startPolling(); navigateToScreen('home', { role: result.device.role }); } catch (error) { pushToast(error.message || 'Device registration failed', 'error'); } }, loadSavedDevice() { const session = getAppState().session; if (!session) { pushToast('Please sign in before loading a saved device', 'error'); return; } void restoreSavedDeviceForSession(session, { showMissingToast: true, showInvalidToast: true }).then((restored) => { if (restored) { pushToast('Loaded saved device', 'success'); } }); }, async signOut() { try { await api.auth.signOut(); } catch { // ignore } await cleanupConnectionState(); clearSavedDeviceRecord(); const keep = { page: 'auth', toasts: [] }; resetAppState(keep); pushToast('Signed Out', 'info'); navigateToScreen('auth', { replace: true }); }, async startMotion() { try { await startMotionEvent({ source: 'manual' }); } catch (error) { pushToast(error.message || 'Failed to start motion', 'error'); } }, async endMotion() { if (!lastMotionEventId) return; try { await endMotionEvent({ source: 'manual' }); } catch (error) { pushToast(error.message || 'Failed to end motion', 'error'); } }, async goOnline() { await startCameraPreview(); 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'); } }, setMotionDetectionEnabled(enabled) { const motionDetection = updateMotionDetectionState({ enabled: Boolean(enabled) }); pushToast(motionDetection.enabled ? 'Automatic detection armed' : 'Automatic detection paused', 'info'); addActivity('Motion Detection', motionDetection.enabled ? 'Detector armed' : 'Detector paused'); applyMotionDetectionReadiness(); }, setMotionDetectionProfile(profile) { const nextProfile = getMotionDetectionProfile(profile); const motionDetection = updateMotionDetectionState({ profile: nextProfile.profile }); pushToast(`${nextProfile.label} profile selected`, 'success'); addActivity('Motion Detection', `Profile set to ${motionDetection.profile}`); applyMotionDetectionReadiness(); }, setMotionDetectionDebug(debug) { const motionDetection = updateMotionDetectionState({ debug: Boolean(debug) }); pushToast(motionDetection.debug ? 'Motion debug enabled' : 'Motion debug hidden', 'info'); }, async linkCamera() { const id = prompt('Enter Camera Device ID:'); if (!id) return; try { await api.devices.link(id, getAppState().device.id); pushToast('Camera Linked', 'success'); await pollClientData(); } catch (error) { pushToast(error.message || 'Failed to link camera', 'error'); } }, toggleLinkedCameraMenu(linkId) { patchAppState((state) => ({ openLinkedCameraMenuId: state.openLinkedCameraMenuId === linkId ? null : linkId })); }, closeLinkedCameraMenu() { setAppState({ openLinkedCameraMenuId: null }); }, async renameLinkedCamera(cameraDeviceId) { const linked = getLinkedCamera(cameraDeviceId); if (!linked?.cameraDeviceId) return; const currentName = linked.cameraName?.trim() || ''; const nextName = prompt('Enter a new camera name:', currentName || getCameraLabel(linked.cameraDeviceId)); if (nextName == null) return; const trimmedName = nextName.trim(); if (!trimmedName) { pushToast('Camera name cannot be empty', 'error'); return; } if (trimmedName === currentName) return; try { await api.devices.update(linked.cameraDeviceId, { name: trimmedName }); patchAppState((state) => ({ linkedCameras: state.linkedCameras.map((entry) => entry.cameraDeviceId === linked.cameraDeviceId ? { ...entry, cameraName: trimmedName } : entry ), openLinkedCameraMenuId: null })); pushToast('Camera Renamed', 'success'); } catch (error) { pushToast(error.message || 'Failed to rename camera', 'error'); } }, async deleteLinkedCamera(linkId) { const link = getAppState().linkedCameras.find((entry) => entry.id === linkId); if (!link) return; const cameraLabel = getCameraLabel(link.cameraDeviceId, link.cameraName); const confirmed = window.confirm(`Remove "${cameraLabel}" from linked cameras?`); if (!confirmed) return; try { await api.devices.unlink(linkId); const remaining = getAppState().linkedCameras.filter((entry) => entry.id !== linkId); const isDeletedCameraActive = getAppState().activeCameraDeviceId === link.cameraDeviceId; if (isDeletedCameraActive) { clearClientStream(); } requestedStreams.delete(link.cameraDeviceId); setAppState({ linkedCameras: remaining, activeCameraDeviceId: isDeletedCameraActive ? null : getAppState().activeCameraDeviceId, activeStreamSessionId: isDeletedCameraActive ? null : getAppState().activeStreamSessionId, openLinkedCameraMenuId: null }); pushToast('Camera Link Removed', 'success'); } catch (error) { pushToast(error.message || 'Failed to remove camera link', 'error'); } }, async requestStream(cameraDeviceId) { try { requestedStreams.add(cameraDeviceId); await api.streams.request(cameraDeviceId); } catch (error) { requestedStreams.delete(cameraDeviceId); pushToast(error.message || 'Failed to request stream', 'error'); } }, async selectCamera(cameraDeviceId) { const currentState = getAppState(); const sessions = currentState.cameraSessions || {}; const existingSessionId = sessions[cameraDeviceId] || null; const reusableSessionId = hasReusableClientStreamSession(existingSessionId) ? existingSessionId : null; const previousActiveStreamSessionId = currentState.activeStreamSessionId; const isSwitchingStreams = Boolean(previousActiveStreamSessionId) && previousActiveStreamSessionId !== reusableSessionId; if (currentState.activeCameraDeviceId !== cameraDeviceId || currentState.activeStreamSessionId !== reusableSessionId) { clearClientStream(); } setAppState({ activeCameraDeviceId: cameraDeviceId, activeStreamSessionId: reusableSessionId, openLinkedCameraMenuId: null }); if (isSwitchingStreams) { void endClientStreamSession(previousActiveStreamSessionId, { teardown: false }); } if (reusableSessionId) { attachClientStreamToElement(); setClientStreamMode(remoteStreams.has(reusableSessionId) ? 'video' : 'connecting'); return; } setClientStreamMode('connecting'); await actions.requestStream(cameraDeviceId); }, closeStreamViewer() { const streamSessionId = getAppState().activeStreamSessionId; setAppState({ activeCameraDeviceId: null, activeStreamSessionId: null }); clearClientStream(); void endClientStreamSession(streamSessionId, { teardown: false }); }, async openRecording(recordingId) { try { const result = await api.ops.getRecordingDownloadUrl(recordingId); if (!result?.downloadUrl) { pushToast('Recording URL unavailable', 'error'); return; } const recording = getAppState().recordings.find((entry) => entry.id === recordingId); const title = recording ? `${new Date(recording.createdAt).toLocaleString()} recording` : 'Recording Playback'; openRecordingModal(result.downloadUrl, title); } catch (error) { pushToast(error.message || 'Failed to load recording', 'error'); } }, closeRecordingModal() { closeRecordingModal(); }, async openMotionNotificationTarget(notificationId, cameraDeviceId) { markMotionNotificationRead(notificationId); if (!cameraDeviceId) return; const recs = await api.ops.listRecordings().catch(() => ({ recordings: [] })); const readyRecording = (recs.recordings || []) .filter((recording) => recording.cameraDeviceId === cameraDeviceId && recording.status === 'ready') .sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime())[0]; if (readyRecording?.id) { await actions.openRecording(readyRecording.id); return; } navigateToScreen('home', { role: getAppState().device?.role }); await actions.requestStream(cameraDeviceId); }, markAllNotificationsRead() { markAllNotificationsRead(); }, clearNotifications() { setAppState({ motionNotifications: [] }); }, refreshClientData() { void pollClientData(); }, runDiagnostics() { pushToast('Diagnostics complete: realtime connected', 'success'); }, removeToast, setCameraVideoElement(element) { cameraVideoElement = element; attachCameraStreamToElement(); void refreshCameraInputDevices(); }, setClientVideoElement(element) { clientVideoElement = element; attachClientStreamToElement(); } }; export const appController = { init, destroy, ...actions };