diff --git a/WebApp/src/lib/app/api.js b/WebApp/src/lib/app/api.js new file mode 100644 index 0000000..79ee1b8 --- /dev/null +++ b/WebApp/src/lib/app/api.js @@ -0,0 +1,72 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck +import { getAppState } from './store'; + +const request = async (path, options = {}) => { + const { deviceToken } = getAppState(); + const headers = { 'Content-Type': 'application/json' }; + + if (deviceToken) { + headers.Authorization = `Bearer ${deviceToken}`; + } + + const response = await fetch(path, { + ...options, + headers: { + ...headers, + ...(options.headers || {}) + } + }); + + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.message || data.error || response.statusText || 'Request failed'); + } + + return data; +}; + +export const api = { + request, + auth: { + signUp: (data) => request('/api/auth/sign-up/email', { method: 'POST', body: JSON.stringify(data) }), + signIn: (data) => request('/api/auth/sign-in/email', { method: 'POST', body: JSON.stringify(data) }), + getSession: () => request('/api/auth/get-session'), + signOut: () => request('/api/auth/sign-out', { method: 'POST', body: JSON.stringify({}) }) + }, + devices: { + register: (data) => request('/devices/register', { method: 'POST', body: JSON.stringify(data) }), + list: () => request('/devices'), + update: (deviceId, data) => request(`/devices/${deviceId}`, { method: 'PATCH', body: JSON.stringify(data) }), + listLinks: () => request('/device-links'), + link: (cameraDeviceId, clientDeviceId) => + request('/device-links', { method: 'POST', body: JSON.stringify({ cameraDeviceId, clientDeviceId }) }), + unlink: (linkId) => request(`/device-links/${linkId}`, { method: 'DELETE' }) + }, + streams: { + request: (cameraDeviceId) => + request('/streams/request', { + method: 'POST', + body: JSON.stringify({ cameraDeviceId, reason: 'on_demand' }) + }), + accept: (id) => request(`/streams/${id}/accept`, { method: 'POST', body: JSON.stringify({}) }), + end: (id) => request(`/streams/${id}/end`, { method: 'POST', body: JSON.stringify({ reason: 'completed' }) }), + getPublishCreds: (id) => request(`/streams/${id}/publish-credentials`), + getSubscribeCreds: (id) => request(`/streams/${id}/subscribe-credentials`) + }, + events: { + startMotion: () => + request('/events/motion/start', { + method: 'POST', + body: JSON.stringify({ title: 'Simulated Motion', triggeredBy: 'motion' }) + }), + endMotion: (id) => request(`/events/${id}/motion/end`, { method: 'POST', body: JSON.stringify({ status: 'completed' }) }), + finalizeRecording: (id, payload) => request(`/recordings/${id}/finalize`, { method: 'POST', body: JSON.stringify(payload) }) + }, + ops: { + listRecordings: () => request('/recordings/me/list'), + getRecordingDownloadUrl: (recordingId) => request(`/recordings/${recordingId}/download-url`), + listNotifications: () => request('/push-notifications/me') + } +}; + diff --git a/WebApp/src/lib/app/controller.js b/WebApp/src/lib/app/controller.js new file mode 100644 index 0000000..fa7bfb5 --- /dev/null +++ b/WebApp/src/lib/app/controller.js @@ -0,0 +1,1555 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck +import { io } from 'socket.io-client'; + +import { api } from './api'; +import { getAppState, patchAppState, resetAppState, setAppState } from './store'; + +const PAGE_PATHS = { + auth: '/', + onboarding: '/onboarding', + camera: '/camera', + client: '/client', + activity: '/activity', + settings: '/settings' +}; + +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 rtcConfig = { + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] +}; + +let initialized = false; +let initPromise = null; +let socket = null; +let pollInterval = null; +let localCameraStream = null; +let activeMediaRecorder = null; +let activeRecordingChunks = []; +let activeRecordingStartedAt = null; +let activeRecordingStreamSessionId = null; +let lastMotionEventId = null; +let frameRelayTimer = null; +let frameRelayStartTimer = null; +let frameCanvas = null; +let frameContext = null; +let hasWebrtcEverConnected = false; +let webrtcConnected = 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 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 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((state) => ({ + clientStreamMode: mode, + clientPlaceholderText, + clientFallbackFrame: mode === 'image' ? state.clientFallbackFrame : '' + })); +}; + +const attachCameraStreamToElement = () => { + if (!cameraVideoElement) return; + cameraVideoElement.srcObject = localCameraStream; + if (localCameraStream) { + void cameraVideoElement.play().catch(() => {}); + } +}; + +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 () => { + if (!navigator.mediaDevices?.getUserMedia) { + pushToast('Camera API is not available in this browser', 'error'); + return false; + } + + if (localCameraStream) { + attachCameraStreamToElement(); + setAppState({ cameraPreviewReady: true }); + return true; + } + + try { + localCameraStream = await navigator.mediaDevices.getUserMedia({ + video: { + width: { ideal: 640, max: 960 }, + height: { ideal: 360, max: 540 }, + frameRate: { ideal: 15, max: 24 } + }, + audio: false + }); + attachCameraStreamToElement(); + setAppState({ cameraPreviewReady: true }); + addActivity('Camera', 'Camera access granted'); + return true; + } catch { + pushToast('Camera permission denied or unavailable', 'error'); + addActivity('Camera', 'Camera access failed'); + setAppState({ cameraPreviewReady: false }); + return false; + } +}; + +const stopCameraPreview = () => { + if (localCameraStream) { + localCameraStream.getTracks().forEach((track) => track.stop()); + localCameraStream = null; + } + if (cameraVideoElement) { + cameraVideoElement.srcObject = null; + } + setAppState({ cameraPreviewReady: false }); +}; + +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 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 stopFrameRelay = () => { + if (frameRelayStartTimer) { + clearTimeout(frameRelayStartTimer); + frameRelayStartTimer = null; + } + if (frameRelayTimer) { + clearInterval(frameRelayTimer); + frameRelayTimer = null; + } +}; + +const startFrameRelay = async (streamSessionId, toDeviceId) => { + if (!socket || !streamSessionId || !toDeviceId) return; + if (hasWebrtcEverConnected) return; + + const ready = await startCameraPreview(); + if (!ready) { + throw new Error('Camera permission is required before streaming'); + } + + if (!cameraVideoElement) return; + + stopFrameRelay(); + frameRelayTimer = setInterval(() => { + if (webrtcConnected || hasWebrtcEverConnected) return; + if ( + !socket || + cameraVideoElement.readyState < 2 || + !cameraVideoElement.videoWidth || + !cameraVideoElement.videoHeight + ) { + return; + } + + if (!frameCanvas) { + frameCanvas = document.createElement('canvas'); + frameContext = frameCanvas.getContext('2d'); + } + if (!frameCanvas || !frameContext) return; + + frameCanvas.width = cameraVideoElement.videoWidth; + frameCanvas.height = cameraVideoElement.videoHeight; + frameContext.drawImage(cameraVideoElement, 0, 0, frameCanvas.width, frameCanvas.height); + const frame = frameCanvas.toDataURL('image/jpeg', 0.6); + + socket.emit('stream:frame', { + toDeviceId, + streamSessionId, + frame, + capturedAt: new Date().toISOString() + }); + }, 600); +}; + +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(); + webrtcConnected = false; + hasWebrtcEverConnected = false; + clearClientStream(); + return; + } + + if (peerConnections.has(streamSessionId)) { + peerConnections.get(streamSessionId)?.close(); + peerConnections.delete(streamSessionId); + } + remoteStreams.delete(streamSessionId); + pendingCandidatesMap.delete(streamSessionId); + connectedPeers.delete(streamSessionId); + setConnectedStreamSessionIds(); + + 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(); + if (asCamera) { + webrtcConnected = true; + hasWebrtcEverConnected = true; + stopFrameRelay(); + } + } else if ( + connection.connectionState === 'failed' || + connection.connectionState === 'disconnected' || + connection.connectionState === 'closed' + ) { + addActivity('WebRTC', `Peer ${connection.connectionState} for ${streamSessionId}`); + connectedPeers.delete(streamSessionId); + setConnectedStreamSessionIds(); + if (asCamera) { + if (!hasWebrtcEverConnected) webrtcConnected = false; + if (connection.connectionState === 'failed' || connection.connectionState === 'closed') { + hasWebrtcEverConnected = false; + } + } + if (getAppState().device?.role === 'client' && getAppState().activeStreamSessionId === streamSessionId) { + if (connection.connectionState === 'failed' || connection.connectionState === 'closed') { + clearClientStream(); + } + } + } + }; + + 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' + }) + }); + + 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' + }) + }); + + 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}`); + } + + 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 connectSocket = () => { + const { deviceToken } = getAppState(); + if (!deviceToken) return; + + if (socket) socket.disconnect(); + socket = io({ auth: { token: deviceToken } }); + + socket.on('connect', () => { + setAppState({ socketConnected: true }); + addActivity('System', 'Connected to realtime server'); + if (getAppState().device?.role === 'camera') { + void startCameraPreview(); + } + }); + + socket.on('disconnect', () => { + setAppState({ socketConnected: false }); + stopFrameRelay(); + void stopLocalRecording(); + teardownPeerConnection(); + setAppState({ activeCameraDeviceId: null, activeStreamSessionId: null }); + }); + + socket.on('command:received', async (payload) => { + addActivity('Command', `Received ${payload.commandType}`); + + try { + if (payload.commandType === 'start_stream') { + const streamId = payload.payload.streamSessionId; + const ready = await startCameraPreview(); + if (!ready) { + throw new Error('Camera permission is required before streaming'); + } + activeRecordingStreamSessionId = streamId; + await api.streams.accept(streamId); + await api.streams.getPublishCreds(streamId); + await startLocalRecording(); + if (payload.sourceDeviceId) { + await startOfferToClient(streamId, payload.sourceDeviceId); + frameRelayStartTimer = setTimeout(() => { + if (!webrtcConnected && !hasWebrtcEverConnected) { + void startFrameRelay(streamId, payload.sourceDeviceId); + } + }, 2500); + addActivity('Stream', 'Accepted & Published'); + } + + 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('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'); + } + + try { + await api.streams.getSubscribeCreds(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) + ); + } catch (error) { + console.error('Stream connect failed', error); + } + }); + + socket.on('stream:frame', (payload) => { + if (connectedPeers.has(payload.streamSessionId)) return; + if (!payload?.frame) return; + + if (streamTimers.has(payload.streamSessionId)) { + clearTimeout(streamTimers.get(payload.streamSessionId)); + streamTimers.delete(payload.streamSessionId); + } + + if (payload.streamSessionId === getAppState().activeStreamSessionId) { + setAppState({ clientFallbackFrame: payload.frame }); + setClientStreamMode('image'); + } + }); + + 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 + }); + + for (const link of linkedCameras) { + if (!requestedStreams.has(link.cameraDeviceId)) { + requestedStreams.add(link.cameraDeviceId); + void actions.requestStream(link.cameraDeviceId); + } + } +}; + +const startPolling = () => { + stopPolling(); + void pollClientData(); + pollInterval = setInterval(() => { + void pollClientData(); + }, 5000); +}; + +const cleanupConnectionState = async () => { + stopPolling(); + stopFrameRelay(); + await stopLocalRecording(); + teardownPeerConnection(); + stopCameraPreview(); + if (socket) { + socket.disconnect(); + socket = null; + } + requestedStreams.clear(); +}; + +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) }); + + const saved = localStorage.getItem('mobileSimDevice'); + if (saved) { + try { + const parsed = JSON.parse(saved); + setAppState({ + device: parsed.device, + deviceToken: parsed.deviceToken, + onboardingForm: { + ...getAppState().onboardingForm, + name: parsed.device?.name ?? '', + role: parsed.device?.role ?? 'client' + } + }); + } catch (error) { + console.error('Failed to load saved device', error); + } + } + + try { + const session = await api.auth.getSession(); + if (session?.session) { + setAppState({ session }); + if (getAppState().deviceToken) { + connectSocket(); + startPolling(); + } + } else { + setAppState({ session: null }); + } + } catch { + setAppState({ session: null }); + } + + enforceRouteForSession(); + + window.addEventListener('beforeunload', () => { + void cleanupConnectionState(); + }); + + initialized = true; + })() + .finally(() => { + initPromise = null; + }); + + return initPromise; +}; + +const destroy = async () => { + 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'); + + if (getAppState().deviceToken) { + 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); + setAppState({ device: result.device, deviceToken: result.deviceToken }); + localStorage.setItem('mobileSimDevice', JSON.stringify({ device: result.device, deviceToken: result.deviceToken })); + + pushToast('Device Registered', 'success'); + connectSocket(); + startPolling(); + navigateToScreen('home', { role: result.device.role }); + } catch (error) { + pushToast(error.message || 'Device registration failed', 'error'); + } + }, + + loadSavedDevice() { + const saved = localStorage.getItem('mobileSimDevice'); + if (!saved) { + pushToast('No saved device found', 'info'); + return; + } + + try { + const parsed = JSON.parse(saved); + setAppState({ + device: parsed.device, + deviceToken: parsed.deviceToken, + onboardingForm: { + ...getAppState().onboardingForm, + name: parsed.device?.name ?? '', + role: parsed.device?.role ?? 'client' + } + }); + pushToast('Loaded saved device', 'success'); + } catch { + pushToast('Saved device is invalid', 'error'); + } + }, + + async signOut() { + try { + await api.auth.signOut(); + } catch { + // ignore + } + + await cleanupConnectionState(); + localStorage.removeItem('mobileSimDevice'); + const keep = { page: 'auth', toasts: [] }; + resetAppState(keep); + pushToast('Signed Out', 'info'); + navigateToScreen('auth', { replace: true }); + }, + + async startMotion() { + try { + const response = await api.events.startMotion(); + await startCameraPreview(); + await startLocalRecording(); + lastMotionEventId = response.event.id; + setAppState({ isMotionActive: true }); + pushToast('Motion Event Started', 'success'); + addActivity('Motion', `Started event ${response.event.id}`); + } catch (error) { + pushToast(error.message || 'Failed to start motion', 'error'); + } + }, + + async endMotion() { + if (!lastMotionEventId) return; + try { + 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(lastMotionEventId); + lastMotionEventId = null; + setAppState({ isMotionActive: false }); + pushToast('Motion Ended', 'success'); + addActivity('Motion', 'Ended event'); + } catch (error) { + pushToast(error.message || 'Failed to end motion', 'error'); + } + }, + + async goOnline() { + await startCameraPreview(); + connectSocket(); + }, + + 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 { + await api.streams.request(cameraDeviceId); + } catch (error) { + pushToast(error.message || 'Failed to request stream', 'error'); + } + }, + + selectCamera(cameraDeviceId) { + const sessions = getAppState().cameraSessions || {}; + setAppState({ + activeCameraDeviceId: cameraDeviceId, + activeStreamSessionId: sessions[cameraDeviceId] || null, + openLinkedCameraMenuId: null + }); + + if (!requestedStreams.has(cameraDeviceId)) { + requestedStreams.add(cameraDeviceId); + void actions.requestStream(cameraDeviceId); + } + + attachClientStreamToElement(); + if (!getAppState().activeStreamSessionId) { + setClientStreamMode('connecting'); + } + }, + + closeStreamViewer() { + setAppState({ activeCameraDeviceId: null, activeStreamSessionId: null }); + clearClientStream(); + }, + + 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(); + }, + + setClientVideoElement(element) { + clientVideoElement = element; + attachClientStreamToElement(); + } +}; + +export const appController = { + init, + destroy, + ...actions +}; + diff --git a/WebApp/src/lib/app/store.js b/WebApp/src/lib/app/store.js new file mode 100644 index 0000000..e0efbd1 --- /dev/null +++ b/WebApp/src/lib/app/store.js @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck +import { derived, get, writable } from 'svelte/store'; + +export const createInitialState = () => ({ + page: 'auth', + session: null, + device: null, + deviceToken: null, + socketConnected: false, + isMotionActive: false, + cameraStatus: 'idle', + cameraPreviewReady: false, + linkedCameras: [], + recordings: [], + motionNotifications: [], + activeCameraDeviceId: null, + activeStreamSessionId: null, + openLinkedCameraMenuId: null, + activityLog: [], + cameraSessions: {}, + connectedStreamSessionIds: [], + loading: false, + isRegistering: false, + authForm: { + email: '', + password: '', + name: '' + }, + onboardingForm: { + name: '', + role: 'client', + pushToken: '' + }, + toasts: [], + recordingModal: { + open: false, + title: 'Recording Playback', + url: '' + }, + clientStreamMode: 'none', + clientFallbackFrame: '', + clientPlaceholderText: 'Select a camera to view', + lastError: null +}); + +export const appState = writable(createInitialState()); + +export const setAppState = (partial) => { + appState.update((state) => ({ ...state, ...partial })); +}; + +export const patchAppState = (updater) => { + appState.update((state) => ({ ...state, ...updater(state) })); +}; + +export const getAppState = () => get(appState); + +export const resetAppState = (keep = {}) => { + appState.set({ ...createInitialState(), ...keep }); +}; + +export const unreadNotificationsCount = derived(appState, ($state) => + $state.motionNotifications.reduce((count, notification) => count + (notification.isRead ? 0 : 1), 0) +); +