/* 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 { INVALID_DEVICE_TOKEN_ERRORS, MAX_STREAM_DIAGNOSTIC_ENTRIES, createControllerShared, getHomePageKeyForRole, getMotionDetectionProfile, pageFromPath } from './controller-shared'; import { createControllerClientModule } from './controller-client'; import { createControllerMediaModule } from './controller-media'; import { getAppState, patchAppState, resetAppState, setAppState } from './store'; const SOCKET_HEARTBEAT_INTERVAL_MS = 10_000; const parseRtcUrls = (value = '') => value .split(',') .map((item) => item.trim()) .filter(Boolean); const buildIceServers = () => { const stunUrls = parseRtcUrls(import.meta.env.VITE_STUN_URLS ?? 'stun:stun.l.google.com:19302'); const turnUrls = parseRtcUrls(import.meta.env.VITE_TURN_URLS ?? ''); const turnUsername = (import.meta.env.VITE_TURN_USERNAME ?? '').trim(); const turnCredential = (import.meta.env.VITE_TURN_CREDENTIAL ?? '').trim(); const iceServers = []; if (stunUrls.length > 0) { iceServers.push({ urls: stunUrls }); } if (turnUrls.length > 0) { iceServers.push({ urls: turnUrls, username: turnUsername, credential: turnCredential }); } return iceServers.length > 0 ? iceServers : [{ urls: 'stun:stun.l.google.com:19302' }]; }; const rtcConfig = { iceServers: buildIceServers() }; let initialized = false; let initPromise = null; let socket = null; let pollInterval = null; let socketHeartbeatInterval = null; const handleBeforeUnload = () => { void cleanupConnectionState(); }; let clientVideoElement = null; const peerConnections = new Map(); const remoteStreams = new Map(); const hiddenClientStreamElements = new Map(); const pendingCandidatesMap = new Map(); const streamTimers = new Map(); const connectedPeers = new Set(); const requestedStreams = new Set(); const { makeId, pushToast, removeToast, addActivity, pushStreamDiagnostic, loadMotionDetectionSettings, updateMotionDetectionState, navigateToScreen, persistSavedDeviceRecord, clearSavedDeviceRecord, applySavedDeviceState, clearDeviceState, restoreSavedDeviceForSession } = createControllerShared({ api, getAppState, setAppState, patchAppState }); const clientController = createControllerClientModule({ api, getAppState, setAppState, patchAppState, makeId }); const mediaController = createControllerMediaModule({ api, createMotionDetector, getAppState, setAppState, patchAppState, pushToast, addActivity, getMotionDetectionProfile, updateMotionDetectionState }); const { getLinkedCamera, getCameraLabel, markMotionNotificationRead, markAllNotificationsRead, pushMotionNotification, openRecordingModal, closeRecordingModal, pollClientData } = clientController; const { refreshCameraInputDevices, startCameraPreview, applyMotionDetectionReadiness, stopLocalRecording, finalizeRecordingForStream, startMotionEvent, endMotionEvent, handleCameraStreamRequest, onMediaDeviceChange, onVisibilityChange, setCameraVideoElement: bindCameraVideoElement, getLocalCameraStream, getActiveRecordingStreamSessionId, clearActiveRecordingStreamSession, isRecordingActive, cleanupMediaState } = mediaController; const setConnectedStreamSessionIds = () => { setAppState({ connectedStreamSessionIds: Array.from(connectedPeers) }); }; const bindClientStreamSession = (cameraDeviceId, streamSessionId, reason = '') => { if (!cameraDeviceId || !streamSessionId) { return; } const currentState = getAppState(); const nextCameraSessions = { ...(currentState.cameraSessions || {}), [cameraDeviceId]: streamSessionId }; const nextState = { cameraSessions: nextCameraSessions }; if (currentState.activeCameraDeviceId === cameraDeviceId) { nextState.activeStreamSessionId = streamSessionId; } setAppState(nextState); if (reason) { pushStreamDiagnostic(streamSessionId, 'session', reason, 'info', { cameraDeviceId }); } if (currentState.activeCameraDeviceId === cameraDeviceId) { if (remoteStreams.has(streamSessionId)) { attachClientStreamToElement(); setClientStreamMode('video'); } else if (getAppState().clientStreamMode !== 'video') { setClientStreamMode('connecting'); } } }; 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 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; } clientVideoElement.muted = true; pushStreamDiagnostic(activeStreamSessionId, 'viewer', 'Attached remote stream to client video element'); void clientVideoElement.play().catch((error) => { pushStreamDiagnostic( activeStreamSessionId, 'viewer', `Autoplay blocked: ${error?.name || 'play_failed'}`, 'error' ); addActivity('Stream', `Autoplay blocked for ${activeStreamSessionId}: ${error?.name || 'play_failed'}`); }); }; const primeClientStreamPlayback = (streamSessionId, stream) => { if (!streamSessionId || !stream) return; let hiddenVideoElement = hiddenClientStreamElements.get(streamSessionId); if (!hiddenVideoElement && typeof document !== 'undefined') { hiddenVideoElement = document.createElement('video'); hiddenVideoElement.muted = true; hiddenVideoElement.autoplay = true; hiddenVideoElement.playsInline = true; hiddenVideoElement.style.position = 'fixed'; hiddenVideoElement.style.width = '1px'; hiddenVideoElement.style.height = '1px'; hiddenVideoElement.style.opacity = '0'; hiddenVideoElement.style.pointerEvents = 'none'; hiddenVideoElement.style.left = '-9999px'; hiddenVideoElement.style.top = '-9999px'; document.body.appendChild(hiddenVideoElement); hiddenClientStreamElements.set(streamSessionId, hiddenVideoElement); } if (!hiddenVideoElement) return; if (hiddenVideoElement.srcObject !== stream) { hiddenVideoElement.srcObject = stream; } pushStreamDiagnostic(streamSessionId, 'viewer', 'Primed remote stream in hidden video element'); void hiddenVideoElement.play().catch(() => {}); }; 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; } if (activeStreamSessionId) { pushStreamDiagnostic(activeStreamSessionId, 'viewer', 'Cleared active client stream viewer'); } setClientStreamMode('none'); }; const endClientStreamSession = async (streamSessionId, { teardown = true } = {}) => { if (!streamSessionId) return; pushStreamDiagnostic(streamSessionId, 'session', 'Ending client stream session'); 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 teardownPeerConnection = (streamSessionId) => { if (!streamSessionId) { for (const connection of peerConnections.values()) { connection.close(); } peerConnections.clear(); remoteStreams.clear(); for (const hiddenVideoElement of hiddenClientStreamElements.values()) { hiddenVideoElement.pause(); hiddenVideoElement.srcObject = null; hiddenVideoElement.remove(); } hiddenClientStreamElements.clear(); pendingCandidatesMap.clear(); connectedPeers.clear(); setConnectedStreamSessionIds(); setAppState({ cameraSessions: {} }); clearClientStream(); return; } pushStreamDiagnostic(streamSessionId, 'peer', 'Tearing down peer connection'); if (peerConnections.has(streamSessionId)) { peerConnections.get(streamSessionId)?.close(); peerConnections.delete(streamSessionId); } remoteStreams.delete(streamSessionId); if (hiddenClientStreamElements.has(streamSessionId)) { const hiddenVideoElement = hiddenClientStreamElements.get(streamSessionId); hiddenVideoElement?.pause(); if (hiddenVideoElement) { hiddenVideoElement.srcObject = null; hiddenVideoElement.remove(); } hiddenClientStreamElements.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); if (queued.length > 0) { pushStreamDiagnostic(streamSessionId, 'signal', `Applying ${queued.length} queued ICE candidate(s)`); } for (const candidate of queued) { try { await connection.addIceCandidate(new RTCIceCandidate(candidate.data)); } catch (error) { pushStreamDiagnostic(streamSessionId, 'error', 'Dropped queued ICE candidate', '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); pushStreamDiagnostic( streamSessionId, 'peer', asCamera ? 'Created camera-side RTCPeerConnection' : 'Created client-side RTCPeerConnection' ); connection.onicecandidate = (event) => { if (!socket || !event.candidate) return; pushStreamDiagnostic(streamSessionId, 'signal', 'Sent ICE candidate'); socket.emit('webrtc:signal', { toDeviceId: targetDeviceId, streamSessionId, signalType: 'candidate', data: event.candidate.toJSON() }); }; connection.onconnectionstatechange = () => { pushStreamDiagnostic( streamSessionId, 'peer', `Connection state changed to ${connection.connectionState}`, connection.connectionState === 'failed' ? 'error' : 'info' ); 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.oniceconnectionstatechange = () => { pushStreamDiagnostic( streamSessionId, 'peer', `ICE connection state changed to ${connection.iceConnectionState}`, connection.iceConnectionState === 'failed' ? 'error' : 'info' ); }; connection.ontrack = (event) => { if (streamTimers.has(streamSessionId)) { clearTimeout(streamTimers.get(streamSessionId)); streamTimers.delete(streamSessionId); } const [stream] = event.streams; if (!stream) return; pushStreamDiagnostic(streamSessionId, 'media', 'Received remote media track'); connectedPeers.add(streamSessionId); setConnectedStreamSessionIds(); remoteStreams.set(streamSessionId, stream); if (getAppState().activeStreamSessionId === streamSessionId) { attachClientStreamToElement(); setClientStreamMode('video'); return; } primeClientStreamPlayback(streamSessionId, stream); }; if (asCamera) { const ready = await startCameraPreview(); const localCameraStream = getLocalCameraStream(); 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); pushStreamDiagnostic(streamSessionId, 'signal', 'Created and sent WebRTC offer'); socket.emit('webrtc:signal', { toDeviceId: requesterDeviceId, streamSessionId, signalType: 'offer', data: offer }); }; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 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 ensureRealtimeConnection = async ({ timeoutMs = 4000 } = {}) => { if (socket?.connected || getAppState().socketConnected) { return true; } if (!getAppState().deviceToken) { return false; } connectSocket(); const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { if (socket?.connected || getAppState().socketConnected) { return true; } await sleep(100); } return Boolean(socket?.connected || getAppState().socketConnected); }; 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(); clearActiveRecordingStreamSession(); teardownPeerConnection(); setAppState({ activeCameraDeviceId: null, activeStreamSessionId: null }); patchAppState((state) => ({ streamDiagnostics: Object.fromEntries( Object.entries(state.streamDiagnostics || {}).map(([streamSessionId, diagnostic]) => [ streamSessionId, { ...diagnostic, updatedAt: new Date().toISOString(), entries: [ { id: makeId(), stage: 'realtime', message: 'Realtime socket disconnected', level: 'error', createdAt: new Date().toISOString() }, ...(diagnostic.entries || []) ].slice(0, MAX_STREAM_DIAGNOSTIC_ENTRIES) } ]) ) })); 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, startOfferToClient }); 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 === 'client') { bindClientStreamSession( payload.cameraDeviceId, payload.streamSessionId, 'Requester received stream session creation event' ); return; } if (getAppState().device?.role !== 'camera') return; try { await handleCameraStreamRequest({ streamId: payload.streamSessionId, requesterDeviceId: payload.requesterDeviceId, startOfferToClient }); } 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...'); pushStreamDiagnostic(payload.streamSessionId, 'session', 'Received stream:started from realtime gateway'); bindClientStreamSession(payload.cameraDeviceId, payload.streamSessionId); 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; pushStreamDiagnostic(streamSessionId, 'session', 'Received stream:ended event'); teardownPeerConnection(streamSessionId); if (streamSessionId === getAppState().activeStreamSessionId) { setAppState({ activeStreamSessionId: null }); } if (getAppState().device?.role === 'camera') { const shouldFinalize = getActiveRecordingStreamSessionId() === streamSessionId || isRecordingActive(); if (shouldFinalize) { const captureResult = await stopLocalRecording(); await finalizeRecordingForStream(streamSessionId, captureResult); } clearActiveRecordingStreamSession(streamSessionId); } }); 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; pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Received WebRTC offer'); addActivity('WebRTC', 'Offer received'); const connection = await ensurePeerConnection({ streamSessionId: payload.streamSessionId, targetDeviceId: payload.fromDeviceId, asCamera: false }); await connection.setRemoteDescription(new RTCSessionDescription(payload.data)); pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Applied remote offer'); await applyQueuedCandidates(connection, payload.streamSessionId, payload.fromDeviceId); const answer = await connection.createAnswer(); await connection.setLocalDescription(answer); pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Created local answer'); socket.emit('webrtc:signal', { toDeviceId: payload.fromDeviceId, streamSessionId: payload.streamSessionId, signalType: 'answer', data: answer }); pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Sent WebRTC answer'); addActivity('WebRTC', 'Answer sent'); return; } if (payload.signalType === 'answer') { const connection = peerConnections.get(payload.streamSessionId); if (device.role !== 'camera' || !connection) return; pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Received WebRTC answer'); if (connection.signalingState !== 'have-local-offer') { if (connection.signalingState === 'stable' && connection.remoteDescription?.type === 'answer') { return; } return; } await connection.setRemoteDescription(new RTCSessionDescription(payload.data)); pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Applied remote answer'); 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) { pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Queued remote ICE candidate'); queueRemoteCandidate(payload); return; } await connection.addIceCandidate(new RTCIceCandidate(payload.data)); pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Applied remote ICE candidate'); return; } if (payload.signalType === 'hangup') { pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Received remote hangup'); teardownPeerConnection(payload.streamSessionId); if (getAppState().activeStreamSessionId === payload.streamSessionId) { setAppState({ activeStreamSessionId: null }); } addActivity('Stream', 'Remote stream ended'); } } catch (error) { pushStreamDiagnostic( payload.streamSessionId, 'error', error?.message || 'Failed handling WebRTC signal', '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'; if (payload?.streamSessionId) { pushStreamDiagnostic(payload.streamSessionId, 'error', message, 'error'); } addActivity('WebRTC', message); pushToast(message, 'error'); }); }; const stopPolling = () => { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } }; const startPolling = () => { stopPolling(); void pollClientData(); pollInterval = setInterval(() => { void pollClientData(); }, 5000); }; const cleanupConnectionState = async () => { stopPolling(); stopSocketHeartbeat(); await cleanupMediaState(); teardownPeerConnection(); 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); const initialPage = page === 'app' ? state.deviceToken ? getHomePageKeyForRole(state.device?.role) : 'onboarding' : page; setAppState({ page: initialPage }); 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' || page === 'app') && 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', handleBeforeUnload); 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); } window.removeEventListener('beforeunload', handleBeforeUnload); 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 })); }, setAuthMode(isRegistering) { setAppState({ 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() { 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(getLocalCameraStream()); 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) { const realtimeReady = await ensureRealtimeConnection(); if (!realtimeReady) { pushToast('Realtime connection unavailable. Reconnect and try again.', 'error'); return; } try { requestedStreams.add(cameraDeviceId); const result = await api.streams.request(cameraDeviceId); requestedStreams.delete(cameraDeviceId); const streamSessionId = result?.streamSession?.id; if (streamSessionId) { bindClientStreamSession( cameraDeviceId, streamSessionId, 'Bound requested stream session from HTTP response' ); pushStreamDiagnostic(streamSessionId, 'request', 'Backend accepted stream request'); } } 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) { pushStreamDiagnostic(reusableSessionId, 'viewer', 'Reusing existing stream session for selected camera'); 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) { await 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() { void markAllNotificationsRead(); }, clearNotifications() { patchAppState((state) => ({ motionNotifications: state.motionNotifications.filter((notification) => !notification.isRead) })); }, refreshClientData() { void pollClientData(); }, runDiagnostics() { pushToast('Diagnostics complete: realtime connected', 'success'); }, removeToast, setCameraVideoElement(element) { bindCameraVideoElement(element); }, setClientVideoElement(element) { clientVideoElement = element; attachClientStreamToElement(); } }; export const appController = { init, destroy, ...actions };