diff --git a/WebApp/src/lib/sim/mobile-sim.js b/WebApp/src/lib/sim/mobile-sim.js deleted file mode 100644 index f9427b3..0000000 --- a/WebApp/src/lib/sim/mobile-sim.js +++ /dev/null @@ -1,2040 +0,0 @@ -// @ts-nocheck -/** - * SecureCam Mobile Simulator Logic - * Refactored for modern UI/UX and stability. - */ - -import { io } from 'socket.io-client'; - -// --- 1. State Management --- -class Store { - constructor(initialState) { - this.state = initialState; - this.listeners = new Set(); - } - - get() { - return this.state; - } - - update(partialState) { - this.state = { ...this.state, ...partialState }; - this.notify(); - } - - subscribe(listener) { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - notify() { - this.listeners.forEach((listener) => listener(this.state)); - } -} - -const store = new Store({ - screen: 'auth', // auth, onboarding, home, activity, settings - session: null, - device: null, - deviceToken: null, - socketConnected: false, - isMotionActive: false, - cameraStatus: 'idle', // idle, recording, streaming - linkedCameras: [], - recordings: [], - motionNotifications: [], - activeCameraDeviceId: null, - activeStreamSessionId: null, - openLinkedCameraMenuId: null, - activityFeed: [], - loading: false, // global loading spinner state if needed -}); - -const PAGE_PATHS = { - auth: '/', - onboarding: '/onboarding', - camera: '/camera', - client: '/client', - activity: '/activity', - settings: '/settings', -}; - -const simPageElement = document.querySelector('[data-sim-page]'); -const currentPageKey = simPageElement?.dataset?.simPage || document.body?.dataset?.page || ''; -const multiPageMode = Boolean(currentPageKey); - -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 navigateToScreen = (screen, options = {}) => { - const { replace = false, role = store.get().device?.role } = options; - const targetPath = getPathForScreen(screen, role); - const currentPath = window.location.pathname.replace(/\/+$/, '') || '/'; - const normalizedTargetPath = targetPath?.replace(/\/+$/, '') || targetPath; - - if (multiPageMode && normalizedTargetPath && currentPath !== normalizedTargetPath) { - if (replace) { - window.location.replace(normalizedTargetPath); - } else { - window.location.assign(normalizedTargetPath); - } - return true; - } - - store.update({ screen }); - return false; -}; - -const getScreenForCurrentPage = () => { - if (currentPageKey === 'activity') return 'activity'; - if (currentPageKey === 'settings') return 'settings'; - if (currentPageKey === 'onboarding') return 'onboarding'; - if (currentPageKey === 'camera' || currentPageKey === 'client') return 'home'; - return 'auth'; -}; - -// --- 2. UI Utilities --- -const $ = (selector) => { - // If it looks like a simple ID (no spaces, dots, hash), use getElementById - if (/^[a-zA-Z0-9_\-]+$/.test(selector)) { - return document.getElementById(selector); - } - // Otherwise use querySelector (handles #id, .class, complex selectors) - return document.querySelector(selector); -}; -const $$ = (selector) => document.querySelectorAll(selector); -const escapeHtml = (value = '') => - String(value) - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); - -const Toast = { - show(message, type = 'info') { - const container = $('toast-container'); - const toast = document.createElement('div'); - - let alertClass = 'alert-info'; - let icon = ''; - - if (type === 'success') { - alertClass = 'alert-success'; - icon = ''; - } else if (type === 'error') { - alertClass = 'alert-error'; - icon = ''; - } - - toast.className = `alert ${alertClass} text-white shadow-lg text-xs py-2 px-3 flex flex-row gap-2 toast-enter`; - toast.innerHTML = `${icon}${message}`; - - container.appendChild(toast); - setTimeout(() => { - toast.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; - toast.style.opacity = '0'; - toast.style.transform = 'translateY(100%)'; - setTimeout(() => toast.remove(), 300); - }, 3000); - } -}; - -// --- 3. API Client --- -const API = { - async request(path, options = {}) { - const { deviceToken } = store.get(); - const headers = { 'Content-Type': 'application/json' }; - - if (deviceToken) { - headers['Authorization'] = `Bearer ${deviceToken}`; - } - - try { - const res = await fetch(path, { ...options, headers: { ...headers, ...options.headers } }); - const data = await res.json().catch(() => ({})); - - if (!res.ok) { - throw new Error(data.message || data.error || res.statusText); - } - return data; - } catch (e) { - Toast.show(e.message, 'error'); - throw e; - } - }, - - auth: { - signUp: (data) => API.request('/api/auth/sign-up/email', { method: 'POST', body: JSON.stringify(data) }), - signIn: (data) => API.request('/api/auth/sign-in/email', { method: 'POST', body: JSON.stringify(data) }), - getSession: () => API.request('/api/auth/get-session'), - signOut: () => API.request('/api/auth/sign-out', { method: 'POST', body: JSON.stringify({}) }), - }, - - devices: { - register: (data) => API.request('/devices/register', { method: 'POST', body: JSON.stringify(data) }), - list: () => API.request('/devices'), - update: (deviceId, data) => API.request(`/devices/${deviceId}`, { method: 'PATCH', body: JSON.stringify(data) }), - listLinks: () => API.request('/device-links'), - link: (cameraDeviceId, clientDeviceId) => API.request('/device-links', { method: 'POST', body: JSON.stringify({ cameraDeviceId, clientDeviceId }) }), - unlink: (linkId) => API.request(`/device-links/${linkId}`, { method: 'DELETE' }), - }, - - streams: { - request: (cameraDeviceId) => API.request('/streams/request', { method: 'POST', body: JSON.stringify({ cameraDeviceId, reason: 'on_demand' }) }), - accept: (id) => API.request(`/streams/${id}/accept`, { method: 'POST', body: JSON.stringify({}) }), - end: (id) => API.request(`/streams/${id}/end`, { method: 'POST', body: JSON.stringify({ reason: 'completed' }) }), - getPublishCreds: (id) => API.request(`/streams/${id}/publish-credentials`), - getSubscribeCreds: (id) => API.request(`/streams/${id}/subscribe-credentials`), - }, - - events: { - startMotion: () => API.request('/events/motion/start', { method: 'POST', body: JSON.stringify({ title: 'Simulated Motion', triggeredBy: 'motion' }) }), - endMotion: (id) => API.request(`/events/${id}/motion/end`, { method: 'POST', body: JSON.stringify({ status: 'completed' }) }), - finalizeRecording: (id, payload) => API.request(`/recordings/${id}/finalize`, { method: 'POST', body: JSON.stringify(payload) }), - }, - - ops: { - listRecordings: () => API.request('/recordings/me/list'), - getRecordingDownloadUrl: (recordingId) => API.request(`/recordings/${recordingId}/download-url`), - listNotifications: () => API.request('/push-notifications/me'), - } -}; - -// --- 4. Logic & Controllers --- - -let socket = null; -let pollInterval = null; -let localCameraStream = null; -let activeMediaRecorder = null; -let activeRecordingChunks = []; -let activeRecordingStartedAt = null; -let activeRecordingStreamSessionId = null; -let recordingModalUrl = null; -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; - -// Multi-stream state (for Client) -const peerConnections = new Map(); // streamSessionId -> RTCPeerConnection -const remoteStreams = new Map(); // streamSessionId -> MediaStream -const pendingCandidatesMap = new Map(); // streamSessionId -> Array -const streamTimers = new Map(); // streamSessionId -> frameRelay/wait timers -const connectedPeers = new Set(); // streamSessionId - -// Legacy fallback for camera single stream -let peerSessionId = null; -let peerTargetDeviceId = null; -let hasWebrtcEverConnected = false; -let webrtcConnected = false; -let frameRelayTimer = null; -let frameRelayStartTimer = null; -let frameCanvas = null; -let frameContext = null; - -const requestedStreams = new Set(); // cameraDeviceIds that have been requested - -const rtcConfig = { - iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], -}; - -const init = async () => { - // Load local storage - const saved = localStorage.getItem('mobileSimDevice'); - if (saved) { - try { - const parsed = JSON.parse(saved); - store.update({ device: parsed.device, deviceToken: parsed.deviceToken }); - } catch (e) { console.error('Failed to load saved device', e); } - } - - try { - const session = await API.auth.getSession(); - if (session && session.session) { - store.update({ session }); - if (store.get().deviceToken) { - const role = store.get().device?.role; - if (multiPageMode && (currentPageKey === 'auth' || currentPageKey === 'onboarding')) { - if (navigateToScreen('home', { replace: true, role })) return; - } - if (multiPageMode && (currentPageKey === 'camera' || currentPageKey === 'client')) { - const expectedHome = getHomePageKeyForRole(role); - if (expectedHome !== currentPageKey) { - if (navigateToScreen('home', { replace: true, role })) return; - } - } - - if (multiPageMode) { - store.update({ screen: getScreenForCurrentPage() }); - } else { - navigateBasedOnRole(); - } - connectSocket(); - startPolling(); - } else { - if (multiPageMode) { - if (currentPageKey !== 'onboarding') { - if (navigateToScreen('onboarding', { replace: true })) return; - } else { - store.update({ screen: 'onboarding' }); - } - } else { - store.update({ screen: 'onboarding' }); - } - } - } else { - if (multiPageMode) { - if (currentPageKey !== 'auth') { - if (navigateToScreen('auth', { replace: true })) return; - } else { - store.update({ screen: 'auth' }); - } - } else { - store.update({ screen: 'auth' }); - } - } - } catch { - if (multiPageMode) { - if (currentPageKey !== 'auth') { - if (navigateToScreen('auth', { replace: true })) return; - } else { - store.update({ screen: 'auth' }); - } - } else { - store.update({ screen: 'auth' }); - } - } -}; - -const navigateBasedOnRole = () => { - const { device } = store.get(); - if (!device) { - navigateToScreen('onboarding'); - return; - } - - // Default home screen based on role - navigateToScreen('home', { role: device.role }); -}; - -const startCameraPreview = async () => { - const videoEl = $('cameraVideo'); - if (!videoEl || !navigator.mediaDevices?.getUserMedia) { - Toast.show('Camera API is not available in this browser', 'error'); - return false; - } - - if (localCameraStream) { - videoEl.srcObject = localCameraStream; - videoEl.classList.remove('hidden'); - 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, - }); - videoEl.srcObject = localCameraStream; - videoEl.classList.remove('hidden'); - addActivity('Camera', 'Camera access granted'); - return true; - } catch (error) { - Toast.show('Camera permission denied or unavailable', 'error'); - addActivity('Camera', 'Camera access failed'); - return false; - } -}; - -const stopCameraPreview = () => { - const videoEl = $('cameraVideo'); - if (localCameraStream) { - localCameraStream.getTracks().forEach((track) => track.stop()); - localCameraStream = null; - } - if (videoEl) { - videoEl.srcObject = null; - videoEl.classList.add('hidden'); - } -}; - -const setClientStreamPlaceholderText = (text) => { - const placeholderEl = $('clientStreamPlaceholder'); - if (!placeholderEl) return; - const label = placeholderEl.querySelector('p'); - if (label) { - label.textContent = text; - } -}; - -const setClientStreamMode = (mode) => { - const videoEl = $('clientStreamVideo'); - const imageEl = $('clientStreamImage'); - const placeholderEl = $('clientStreamPlaceholder'); - - if (videoEl) videoEl.classList.toggle('hidden', mode !== 'video'); - if (imageEl) imageEl.classList.toggle('hidden', mode !== 'image'); - - if (!placeholderEl) return; - - if (mode === 'video' || mode === 'image') { - placeholderEl.classList.add('hidden'); - return; - } - - if (mode === 'unavailable') { - setClientStreamPlaceholderText('Stream unavailable'); - } else if (mode === 'connecting') { - setClientStreamPlaceholderText('Connecting stream...'); - } else { - setClientStreamPlaceholderText('Waiting for stream'); - } - placeholderEl.classList.remove('hidden'); -}; - -const clearClientStream = () => { - const { activeStreamSessionId } = store.get(); - if (activeStreamSessionId && streamTimers.has(activeStreamSessionId)) { - clearTimeout(streamTimers.get(activeStreamSessionId)); - streamTimers.delete(activeStreamSessionId); - } - const videoEl = $('clientStreamVideo'); - const imageEl = $('clientStreamImage'); - if (activeStreamSessionId && remoteStreams.has(activeStreamSessionId)) { - remoteStreams.get(activeStreamSessionId).getTracks().forEach((track) => track.stop()); - remoteStreams.delete(activeStreamSessionId); - } - if (videoEl) { - videoEl.srcObject = null; - } - if (imageEl) { - imageEl.src = ''; - } - setClientStreamMode('none'); -}; - -const getLinkedCamera = (cameraDeviceId) => - store.get().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: crypto.randomUUID(), - cameraDeviceId, - message: `${getCameraLabel(cameraDeviceId)} has detected movement`, - createdAt: new Date().toISOString(), - isRead: false, - }; - - store.update({ - motionNotifications: [notification, ...store.get().motionNotifications].slice(0, 50), - }); -}; - -const markMotionNotificationRead = (notificationId) => { - store.update({ - motionNotifications: store - .get() - .motionNotifications.map((notification) => - notification.id === notificationId ? { ...notification, isRead: true } : notification, - ), - }); -}; - -const markAllNotificationsRead = () => { - store.update({ - motionNotifications: store - .get() - .motionNotifications.map((notification) => (notification.isRead ? notification : { ...notification, isRead: true })), - }); -}; - -const openRecordingModal = (downloadUrl, title) => { - const modal = $('recordingModal'); - const videoEl = $('recordingModalVideo'); - const titleEl = $('recordingModalTitle'); - - if (!modal || !videoEl || !titleEl) return; - - recordingModalUrl = downloadUrl; - titleEl.textContent = title || 'Recording Playback'; - videoEl.src = downloadUrl; - modal.classList.remove('hidden'); - modal.classList.add('flex'); - void videoEl.play().catch(() => { }); -}; - -const closeRecordingModal = () => { - const modal = $('recordingModal'); - const videoEl = $('recordingModalVideo'); - if (!modal || !videoEl) return; - - modal.classList.add('hidden'); - modal.classList.remove('flex'); - videoEl.pause(); - videoEl.removeAttribute('src'); - videoEl.load(); - recordingModalUrl = null; -}; - -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'); - } - - const cameraVideoEl = $('cameraVideo'); - if (!cameraVideoEl) return; - - stopFrameRelay(); - frameRelayTimer = setInterval(() => { - if (webrtcConnected || hasWebrtcEverConnected) return; - if (!socket || cameraVideoEl.readyState < 2 || !cameraVideoEl.videoWidth || !cameraVideoEl.videoHeight) return; - - if (!frameCanvas) { - frameCanvas = document.createElement('canvas'); - frameContext = frameCanvas.getContext('2d'); - } - if (!frameCanvas || !frameContext) return; - - frameCanvas.width = cameraVideoEl.videoWidth; - frameCanvas.height = cameraVideoEl.videoHeight; - frameContext.drawImage(cameraVideoEl, 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); - addActivity('Recording', 'Local recording started'); - return true; -}; - -const stopLocalRecording = async () => { - if (!activeMediaRecorder || activeMediaRecorder.state === 'inactive') { - 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; - - resolve(blob ? { blob, durationSeconds } : null); - }; - - recorder.onerror = () => { - activeMediaRecorder = null; - activeRecordingChunks = []; - activeRecordingStartedAt = null; - 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) { - // Teardown all - for (const [sid, conn] of peerConnections.entries()) { - conn.close(); - } - peerConnections.clear(); - remoteStreams.clear(); - pendingCandidatesMap.clear(); - connectedPeers.clear(); - webrtcConnected = false; - hasWebrtcEverConnected = false; - clearClientStream(); - return; - } - - if (peerConnections.has(streamSessionId)) { - const conn = peerConnections.get(streamSessionId); - conn.close(); - peerConnections.delete(streamSessionId); - } - remoteStreams.delete(streamSessionId); - pendingCandidatesMap.delete(streamSessionId); - connectedPeers.delete(streamSessionId); - - if (peerSessionId === streamSessionId) { - peerSessionId = null; - peerTargetDeviceId = null; - webrtcConnected = false; - hasWebrtcEverConnected = false; - } - - if (store.get().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); - - if (asCamera) { - peerSessionId = streamSessionId; - peerTargetDeviceId = targetDeviceId; - } - - connection.onicecandidate = (event) => { - if (!socket || !event.candidate) return; - socket.emit('webrtc:signal', { - toDeviceId: targetDeviceId, - streamSessionId: streamSessionId, - signalType: 'candidate', - data: event.candidate.toJSON(), - }); - }; - - connection.onconnectionstatechange = () => { - if (connection.connectionState === 'connected') { - addActivity('WebRTC', `Peer connected for ${streamSessionId}`); - connectedPeers.add(streamSessionId); - 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); - if (asCamera) { - if (!hasWebrtcEverConnected) webrtcConnected = false; - if (connection.connectionState === 'failed' || connection.connectionState === 'closed') { - hasWebrtcEverConnected = false; - } - } - if (store.get().device?.role === 'client' && store.get().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); - remoteStreams.set(streamSessionId, stream); - - if (store.get().activeStreamSessionId === streamSessionId) { - const videoEl = $('clientStreamVideo'); - if (videoEl) { - videoEl.srcObject = stream; - setClientStreamMode('video'); - void videoEl.play().catch(() => { }); - store.notify(); // Re-render to show active feed - } - } else { - // If not active, play it hidden anyway so it connects properly - const tempVideo = document.createElement('video'); - tempVideo.srcObject = stream; - tempVideo.muted = true; - tempVideo.playsInline = true; - void tempVideo.play().catch(() => { }); - store.notify(); // Re-render to show stream active in list - } - }; - - if (asCamera) { - const ready = await startCameraPreview(); - if (!ready || !localCameraStream || localCameraStream.getVideoTracks().length === 0) { - throw new Error('Camera stream unavailable for WebRTC publish'); - } - if (localCameraStream) { - 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 = store.get().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 = store.get().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 } = store.get(); - if (!deviceToken) return; - - if (socket) socket.disconnect(); - - socket = io({ auth: { token: deviceToken } }); - - socket.on('connect', () => { - store.update({ socketConnected: true }); - addActivity('System', 'Connected to realtime server'); - if (store.get().device?.role === 'camera') { - startCameraPreview(); - } - }); - - socket.on('disconnect', () => { - store.update({ socketConnected: false }); - stopFrameRelay(); - void stopLocalRecording(); - teardownPeerConnection(); - store.update({ activeCameraDeviceId: null, activeStreamSessionId: null }); - }); - - // Handle commands (as Camera) - 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 (e) { - socket.emit('command:ack', { commandId: payload.commandId, status: 'rejected', error: e.message }); - } - }); - - // Handle Events (as Client) - socket.on('motion:detected', (payload) => { - const cameraDeviceId = payload.cameraDeviceId || payload.deviceId; - addActivity('Motion', `${getCameraLabel(cameraDeviceId)} has detected movement`); - Toast.show('Motion Detected!', 'info'); - pushMotionNotification(cameraDeviceId); - - // Auto display this camera's active stream on motion - if (cameraDeviceId) { - store.update({ activeCameraDeviceId: cameraDeviceId }); - const existingSession = store.get().activeStreamSessionId; - // If we don't know the exact session ID associated, requestStream will fetch a new one or join - // For simplicity, directly requesting stream again is fine (idempotent setup). - Actions.requestStream(cameraDeviceId); - } - }); - - socket.on('stream:started', async (payload) => { - addActivity('Stream', 'Stream is live, connecting...'); - - // Always store latest session ID for the camera - if (payload.cameraDeviceId === store.get().activeCameraDeviceId) { - store.update({ activeStreamSessionId: payload.streamSessionId }); - } - - // Track camera to stream session map - store.update({ - cameraSessions: { - ...(store.get().cameraSessions || {}), - [payload.cameraDeviceId]: payload.streamSessionId - } - }); - - try { - await API.streams.getSubscribeCreds(payload.streamSessionId); - console.log(`Connected to Stream ${payload.streamSessionId}`); - - streamTimers.set(payload.streamSessionId, setTimeout(() => { - if (!remoteStreams.has(payload.streamSessionId)) { - console.log(`Stream connected but no video received for ${payload.streamSessionId}`); - addActivity('Stream', `No remote video track received for ${payload.streamSessionId}`); - } - }, 6000)); - } catch (e) { - console.error('Stream connect failed', e); - } - }); - - 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 === store.get().activeStreamSessionId) { - const imageEl = $('clientStreamImage'); - if (!imageEl) return; - imageEl.src = payload.frame; - imageEl.classList.remove('hidden'); - const videoEl = $('clientStreamVideo'); - if (videoEl) { - videoEl.classList.add('hidden'); - } - setClientStreamMode('image'); - } - }); - - socket.on('stream:ended', async (payload) => { - if (payload?.streamSessionId) { - const streamSessionId = payload.streamSessionId; - teardownPeerConnection(payload.streamSessionId); - if (streamSessionId === store.get().activeStreamSessionId) { - store.update({ activeStreamSessionId: null }); - } - - if (store.get().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 = store.get().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 conn = peerConnections.get(payload.streamSessionId); - if (device.role !== 'camera' || !conn) return; - - if (conn.signalingState !== 'have-local-offer') { - if (conn.signalingState === 'stable' && conn.remoteDescription?.type === 'answer') { - return; - } - return; - } - await conn.setRemoteDescription(new RTCSessionDescription(payload.data)); - await applyQueuedCandidates(conn, payload.streamSessionId, payload.fromDeviceId); - addActivity('WebRTC', 'Answer received and applied'); - return; - } - - if (payload.signalType === 'candidate') { - if (!payload.data) return; - const conn = peerConnections.get(payload.streamSessionId); - if (!conn) { - queueRemoteCandidate(payload); - return; - } - if (!conn.remoteDescription) { - queueRemoteCandidate(payload); - return; - } - await conn.addIceCandidate(new RTCIceCandidate(payload.data)); - return; - } - - if (payload.signalType === 'hangup') { - teardownPeerConnection(payload.streamSessionId); - if (store.get().activeStreamSessionId === payload.streamSessionId) { - store.update({ activeStreamSessionId: null }); - } - addActivity('Stream', 'Remote stream ended'); - } - } catch (error) { - console.error('Failed handling WebRTC signal', error); - Toast.show('WebRTC negotiation failed', 'error'); - } - }); - - socket.on('error:webrtc_signal', (payload) => { - const message = payload?.message || 'WebRTC signaling error'; - addActivity('WebRTC', message); - Toast.show(message, 'error'); - }); -}; - -const startPolling = () => { - if (pollInterval) clearInterval(pollInterval); - - const poller = async () => { - const { device, screen } = store.get(); - if (!device) return; - - if (screen === 'home' && device.role === 'client') { - 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', - }; - }); - - store.update({ - recordings: recs.recordings || [], - linkedCameras, - }); - - // Request streams for all linked cameras if not already requested - for (const link of linkedCameras) { - if (!requestedStreams.has(link.cameraDeviceId)) { - requestedStreams.add(link.cameraDeviceId); - void Actions.requestStream(link.cameraDeviceId); - } - } - } - - if (screen === 'activity') { - // maybe poll notifications - } - }; - - poller(); - pollInterval = setInterval(poller, 5000); -}; - -// --- Actions --- - -const Actions = { - toggleAuthMode: () => { - const isRegistering = !$('authNameField').classList.contains('hidden'); - if (isRegistering) { - $('authNameField').classList.add('hidden'); - $('signInBtn').textContent = 'Sign In'; - $('toggleAuthModeBtn').textContent = 'Create an account'; - } else { - $('authNameField').classList.remove('hidden'); - $('signInBtn').textContent = 'Create Account'; - $('toggleAuthModeBtn').textContent = 'I already have an account'; - } - }, - - submitAuth: async () => { - const email = $('authEmail').value; - const password = $('authPassword').value; - const name = $('authName').value || email.split('@')[0]; - const isRegistering = !$('authNameField').classList.contains('hidden'); - - try { - if (isRegistering) { - await API.auth.signUp({ email, password, name }); - } - await API.auth.signIn({ email, password }); - const session = await API.auth.getSession(); - store.update({ session }); - Toast.show(`Welcome, ${session.user.name}`, 'success'); - - // Proceed - if (store.get().deviceToken) { - const role = store.get().device?.role; - if (multiPageMode && currentPageKey === 'auth') { - if (navigateToScreen('home', { replace: true, role })) return; - } else { - navigateBasedOnRole(); - } - connectSocket(); - startPolling(); - } else { - if (navigateToScreen('onboarding')) return; - } - } catch (e) { - // handled by API wrapper toast - } - }, - - selectRole: (role) => { - $('role').value = role; - const btnCamera = $('btn-role-camera'); - const btnClient = $('btn-role-client'); - - btnCamera.setAttribute('data-active', role === 'camera'); - btnClient.setAttribute('data-active', role === 'client'); - - // DaisyUI/Tailwind manual toggle logic for visual feedback - if (role === 'camera') { - btnCamera.classList.add('bg-blue-600', 'text-white'); - btnCamera.classList.remove('text-gray-400'); - btnClient.classList.remove('bg-blue-600', 'text-white'); - btnClient.classList.add('text-gray-400'); - } else { - btnClient.classList.add('bg-blue-600', 'text-white'); - btnClient.classList.remove('text-gray-400'); - btnCamera.classList.remove('bg-blue-600', 'text-white'); - btnCamera.classList.add('text-gray-400'); - } - }, - - registerDevice: async () => { - const name = $('deviceName').value || 'Web Simulator'; - const role = $('role').value; - const pushToken = $('pushToken').value; - - try { - const payload = { name, role, platform: 'web', appVersion: 'sim-2.0' }; - if (pushToken && pushToken.trim().length > 0) { - payload.pushToken = pushToken.trim(); - } - - const res = await API.devices.register(payload); - - store.update({ device: res.device, deviceToken: res.deviceToken }); - localStorage.setItem('mobileSimDevice', JSON.stringify({ device: res.device, deviceToken: res.deviceToken })); - - Toast.show('Device Registered', 'success'); - navigateBasedOnRole(); - connectSocket(); - startPolling(); - } catch (e) { - // handled - } - }, - - signOut: async () => { - await API.auth.signOut(); - store.update({ session: null, screen: 'auth', device: null, deviceToken: null, socketConnected: false }); - if (socket) socket.disconnect(); - stopFrameRelay(); - await stopLocalRecording(); - teardownPeerConnection(); - stopCameraPreview(); - localStorage.removeItem('mobileSimDevice'); - if (navigateToScreen('auth', { replace: true })) return; - Toast.show('Signed Out', 'info'); - }, - - // Camera Actions - startMotion: async () => { - try { - const res = await API.events.startMotion(); - await startCameraPreview(); - await startLocalRecording(); - store.update({ isMotionActive: true, lastMotionEventId: res.event.id }); - Toast.show('Motion Event Started', 'success'); - addActivity('Motion', 'Started event ' + res.event.id); - } catch (e) { } - }, - - endMotion: async () => { - const { lastMotionEventId } = store.get(); - 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); - store.update({ isMotionActive: false }); - Toast.show('Motion Ended', 'success'); - addActivity('Motion', 'Ended event'); - } catch (e) { } - }, - - // Client Actions - linkCamera: async () => { - const id = prompt('Enter Camera Device ID:'); // Simple prompt for now, could be better UI - if (!id) return; - try { - await API.devices.link(id, store.get().device.id); - Toast.show('Camera Linked', 'success'); - startPolling(); // refresh list - } catch (e) { } - }, - - renameLinkedCamera: async (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) { - Toast.show('Camera name cannot be empty', 'error'); - return; - } - - if (trimmedName === currentName) return; - - try { - await API.devices.update(linked.cameraDeviceId, { name: trimmedName }); - store.update({ - linkedCameras: store.get().linkedCameras.map((entry) => - entry.cameraDeviceId === linked.cameraDeviceId ? { ...entry, cameraName: trimmedName } : entry, - ), - }); - Toast.show('Camera Renamed', 'success'); - } catch (e) { } - }, - - deleteLinkedCamera: async (linkId) => { - const link = store.get().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 = store.get().linkedCameras.filter((entry) => entry.id !== linkId); - const isDeletedCameraActive = store.get().activeCameraDeviceId === link.cameraDeviceId; - - if (isDeletedCameraActive) { - clearClientStream(); - } - - requestedStreams.delete(link.cameraDeviceId); - - store.update({ - linkedCameras: remaining, - activeCameraDeviceId: isDeletedCameraActive ? null : store.get().activeCameraDeviceId, - activeStreamSessionId: isDeletedCameraActive ? null : store.get().activeStreamSessionId, - openLinkedCameraMenuId: null, - }); - - Toast.show('Camera Link Removed', 'success'); - } catch (e) { } - }, - - requestStream: async (camId) => { - try { - console.log(`Requesting Stream from ${camId}...`); - await API.streams.request(camId); - // Socket will handle the rest ('stream:started') - } catch (e) { } - }, - - openRecording: async (recordingId) => { - try { - const result = await API.ops.getRecordingDownloadUrl(recordingId); - if (!result?.downloadUrl) { - Toast.show('Recording URL unavailable', 'error'); - return; - } - const recording = store.get().recordings.find((entry) => entry.id === recordingId); - const title = recording ? `${new Date(recording.createdAt).toLocaleString()} recording` : 'Recording Playback'; - openRecordingModal(result.downloadUrl, title); - } catch (e) { - // handled by API wrapper - } - }, - - closeRecordingModal: () => { - closeRecordingModal(); - }, - - openMotionNotificationTarget: async (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; - } - - if (navigateToScreen('home')) return; - await Actions.requestStream(cameraDeviceId); - }, -}; - -// --- 5. Rendering --- - -const render = (state) => { - // 1. Screen Visibility - $$('section[id^="screen-"]').forEach(el => el.classList.add('hidden')); - - const showSectionById = (id) => { - const element = $(id); - if (!element) return false; - element.classList.remove('hidden'); - return true; - }; - - if (state.screen === 'home') { - const preferredHomeId = state.device?.role === 'camera' ? 'screen-home-camera' : 'screen-home-client'; - if (!showSectionById(preferredHomeId)) { - const fallbackHomeId = preferredHomeId === 'screen-home-camera' ? 'screen-home-client' : 'screen-home-camera'; - showSectionById(fallbackHomeId); - } - } else { - showSectionById(`screen-${state.screen}`); - } - - // 2. Top Bar Status - const statusDot = $('#connectionStatus .status-dot'); - const statusText = $('#connectionStatus span:last-child'); - if (statusDot && statusText) { - if (state.socketConnected) { - statusDot.className = 'status-dot status-online transition-colors duration-300'; - statusText.textContent = 'ONLINE'; - } else { - statusDot.className = 'status-dot status-offline transition-colors duration-300'; - statusText.textContent = 'OFFLINE'; - } - } - - const authBadge = $('authStatusBadge'); - if (authBadge) { - if (state.session?.user) { - authBadge.textContent = state.session.user.email; - authBadge.classList.add('text-blue-400'); - } else { - authBadge.textContent = 'Signed Out'; - authBadge.classList.remove('text-blue-400'); - } - } - - // 3. Bottom Nav Visibility & State - const nav = $('bottomNav'); - const unreadNotifications = state.motionNotifications.filter((notification) => !notification.isRead).length; - updateNotificationDot(unreadNotifications > 0); - - if (nav) { - if (state.session && state.device) { - nav.classList.remove('hidden'); - $$('.nav-btn').forEach(btn => { - const target = btn.dataset.target; - const isActive = target === state.screen || (target === 'home' && (state.screen === 'home-camera' || state.screen === 'home-client')); - btn.setAttribute('data-active', isActive); - }); - } else { - nav.classList.add('hidden'); - } - } - - // 4. Camera Mode specifics - if (state.device?.role === 'camera' && state.screen === 'home') { - const preview = $('cameraPreview'); - const offlineOverlay = $('cameraOfflineOverlay'); - const startMotionBtn = $('startMotionBtn'); - const endMotionBtn = $('endMotionBtn'); - - if (!preview || !offlineOverlay || !startMotionBtn || !endMotionBtn) return; - - if (state.socketConnected) { - offlineOverlay.classList.add('hidden'); - if (state.isMotionActive) { - preview.classList.remove('bg-black/50'); - preview.classList.add('bg-red-900/20'); - startMotionBtn.classList.add('hidden'); - endMotionBtn.classList.remove('hidden'); - endMotionBtn.disabled = false; - } else { - preview.classList.add('bg-black/50'); - preview.classList.remove('bg-red-900/20'); - startMotionBtn.classList.remove('hidden'); - endMotionBtn.classList.add('hidden'); - } - } else { - offlineOverlay.classList.remove('hidden'); - } - } - - // 5. Client Mode Lists - if (state.device?.role === 'client' && state.screen === 'home') { - const list = $('linkedCamerasList'); - if (list) { - if (state.linkedCameras.length === 0) { - list.innerHTML = `

No cameras linked yet

`; - } else { - list.innerHTML = state.linkedCameras.map(link => { - const cameraName = getCameraLabel(link.cameraDeviceId, link.cameraName); - const escapedCameraName = escapeHtml(cameraName); - const cameraStatus = (link.cameraStatus || '').toLowerCase() === 'online' ? 'Online' : 'Offline'; - const statusDotClass = cameraStatus === 'Online' ? 'bg-green-500' : 'bg-gray-600'; - - return ` -
-
- ${state.activeCameraDeviceId === link.cameraDeviceId - ? ` -
- - - - -

Viewing

-
- ` - : ` -
- - - -

${Array.from(connectedPeers).includes(link.cameraDeviceId) ? 'Live Stream Active' : (requestedStreams.has(link.cameraDeviceId) ? 'Connecting...' : 'Click to view')}

-
- ` - } -
-
-
-
-
-
-

${escapedCameraName}

-

${cameraStatus}

-
-
-
- - -
-
-
-
- `; - }).join(''); - - // Show/hide main wrapper - const viewerWrapper = $('clientStreamViewerWrapper'); - if (viewerWrapper) { - if (state.activeCameraDeviceId) { - viewerWrapper.classList.remove('hidden'); - const title = $('clientStreamViewerTitle'); - if (title) title.textContent = `Live Feed: ${getCameraLabel(state.activeCameraDeviceId)}`; - } else { - viewerWrapper.classList.add('hidden'); - } - } - - if (state.activeCameraDeviceId) { - // Find session ID for active camera if known - let foundSessionId = state.activeStreamSessionId; - const sessions = state.cameraSessions || {}; - if (!foundSessionId && sessions[state.activeCameraDeviceId]) { - foundSessionId = sessions[state.activeCameraDeviceId]; - } - - const currentStream = foundSessionId ? remoteStreams.get(foundSessionId) : null; - if (currentStream) { - const videoEl = $('clientStreamVideo'); - if (videoEl && videoEl.srcObject !== currentStream) { - videoEl.srcObject = currentStream; - setClientStreamMode('video'); - $('clientLiveDot')?.classList.remove('hidden'); - // Only play if it's not already playing to prevent interruptions - if (videoEl.paused) { - void videoEl.play().catch(() => { }); - } - } - } else { - $('clientLiveDot')?.classList.add('hidden'); - } - } else { - $('clientLiveDot')?.classList.add('hidden'); - } - - const imageEl = $('clientStreamImage'); - if (imageEl && !imageEl.dataset.errorBound) { - imageEl.dataset.errorBound = '1'; - imageEl.addEventListener('error', () => { - const videoEl = $('clientStreamVideo'); - if (videoEl) { - videoEl.classList.add('hidden'); - } - setClientStreamMode('unavailable'); - }); - } - } - } - - const recList = $('recordingsList'); - if (recList) { - if (state.recordings.length === 0) { - recList.innerHTML = `

No recordings found

`; - } else { - recList.innerHTML = state.recordings.slice(0, 5).map(rec => ` -
-
- ${new Date(rec.createdAt).toLocaleString()} - ${rec.durationSeconds != null ? `${rec.durationSeconds}s duration` : 'Duration pending'} ยท ${rec.status ?? 'unknown'} -
- -
- `).join(''); - } - } - } - - if (state.screen === 'activity') { - const activityFeed = $('activityFeedList'); - if (activityFeed) { - if (state.motionNotifications.length === 0) { - activityFeed.innerHTML = ` -
- - - -

No notifications yet

-
- `; - } else { - activityFeed.innerHTML = state.motionNotifications.map((notification) => ` - - `).join(''); - } - } - } - - // 6. Settings Screen - if (state.session?.user && state.screen === 'settings') { - const profileName = $('profileName'); - const profileEmail = $('profileEmail'); - const profileInitials = $('profileInitials'); - if (profileName) profileName.textContent = state.session.user.name; - if (profileEmail) profileEmail.textContent = state.session.user.email; - if (profileInitials) profileInitials.textContent = state.session.user.name.charAt(0).toUpperCase(); - } -}; - -const addActivity = (type, msg) => { - const list = $('activityFeedList'); - if (list) { - const item = document.createElement('div'); - item.className = 'p-3 rounded-lg bg-gray-900/40 border border-white/5 flex flex-col gap-1'; - item.innerHTML = ` -
- ${type} - ${new Date().toLocaleTimeString()} -
-

${msg}

- `; - list.prepend(item); - } - - // Also update camera logs if applicable - if ($('cameraLogs')) { - const logLine = document.createElement('div'); - logLine.textContent = `[${new Date().toLocaleTimeString()}] ${type}: ${msg}`; - $('cameraLogs').prepend(logLine); - } -}; - -const updateNotificationDot = (show) => { - const dot = $('notificationDot'); - if (!dot) return; - if (show) dot.classList.remove('hidden'); - else dot.classList.add('hidden'); -}; - -// --- 6. Event Listeners --- - -const bind = (id, eventName, handler) => { - const element = $(id); - if (!element) return; - element.addEventListener(eventName, handler); -}; - -bind('toggleAuthModeBtn', 'click', Actions.toggleAuthMode); -bind('signInBtn', 'click', Actions.submitAuth); -bind('registerBtn', 'click', Actions.registerDevice); -bind('loadSavedBtn', 'click', () => { /* Handle legacy loading if needed */ }); -$$('#screen-onboarding [data-role]').forEach((btn) => { - btn.addEventListener('click', () => Actions.selectRole(btn.dataset.role)); -}); -bind('recordingsList', 'click', (event) => { - const target = event.target.closest('.download-recording-btn'); - if (!target || target.disabled) return; - const recordingId = target.dataset.recordingId; - if (!recordingId) return; - Actions.openRecording(recordingId); -}); -bind('activityFeedList', 'click', (event) => { - const target = event.target.closest('.motion-notification-btn'); - if (!target) return; - const notificationId = target.dataset.notificationId; - const cameraDeviceId = target.dataset.cameraDeviceId; - if (!notificationId || !cameraDeviceId) return; - Actions.openMotionNotificationTarget(notificationId, cameraDeviceId); -}); - -$('linkedCamerasList')?.addEventListener('click', (event) => { - const menuToggleBtn = event.target.closest('.linked-camera-menu-toggle'); - if (menuToggleBtn) { - event.stopPropagation(); - const linkId = menuToggleBtn.dataset.linkId; - if (!linkId) return; - - const { openLinkedCameraMenuId } = store.get(); - store.update({ - openLinkedCameraMenuId: openLinkedCameraMenuId === linkId ? null : linkId, - }); - return; - } - - const renameBtn = event.target.closest('.linked-camera-rename-btn'); - if (renameBtn) { - event.stopPropagation(); - const cameraId = renameBtn.dataset.cameraId; - if (cameraId) { - store.update({ openLinkedCameraMenuId: null }); - void Actions.renameLinkedCamera(cameraId); - } - return; - } - - const deleteBtn = event.target.closest('.linked-camera-delete-btn'); - if (deleteBtn) { - event.stopPropagation(); - const linkId = deleteBtn.dataset.linkId; - if (linkId) { - store.update({ openLinkedCameraMenuId: null }); - void Actions.deleteLinkedCamera(linkId); - } - return; - } - - const card = event.target.closest('.camera-card'); - if (!card) return; - const camId = card.dataset.cameraId; - if (camId) { - const sessions = store.get().cameraSessions || {}; - store.update({ - activeCameraDeviceId: camId, - activeStreamSessionId: sessions[camId] || null, - openLinkedCameraMenuId: null, - }); - // If not currently streamed or requested, kick off request - if (!requestedStreams.has(camId)) { - requestedStreams.add(camId); - Actions.requestStream(camId); - } else { - // Just re-render - store.notify(); - } - } -}); - -document.addEventListener('click', (event) => { - if (!event.target.closest('.linked-camera-menu')) { - const { openLinkedCameraMenuId } = store.get(); - if (openLinkedCameraMenuId) { - store.update({ openLinkedCameraMenuId: null }); - } - } -}); - -// Navbar -$$('.nav-btn').forEach(btn => { - btn.addEventListener('click', () => { - if (btn.dataset.target === 'activity') { - markAllNotificationsRead(); - } - if (navigateToScreen(btn.dataset.target)) return; - }); -}); - -// Camera Controls -bind('cameraGoOnlineBtn', 'click', async () => { - if (store.get().device?.role === 'camera') { - await startCameraPreview(); - } - connectSocket(); -}); -bind('startMotionBtn', 'click', Actions.startMotion); -bind('endMotionBtn', 'click', Actions.endMotion); - -// Client Controls -bind('linkCameraBtn', 'click', Actions.linkCamera); -bind('refreshClientBtn', 'click', startPolling); - -// Settings -bind('signOutBtn', 'click', Actions.signOut); -bind('clearActivityBtn', 'click', () => { - store.update({ motionNotifications: [] }); -}); -bind('recordingModalCloseBtn', 'click', Actions.closeRecordingModal); -bind('recordingModal', 'click', (event) => { - if (event.target === $('recordingModal')) { - Actions.closeRecordingModal(); - } -}); -$('closeStreamViewerBtn')?.addEventListener('click', () => { - store.update({ activeCameraDeviceId: null, activeStreamSessionId: null }); -}); - -// Init -store.subscribe(render); -init(); - -window.addEventListener('beforeunload', () => { - stopFrameRelay(); - void stopLocalRecording(); - teardownPeerConnection(); - stopCameraPreview(); -}); - -window.Actions = Actions; diff --git a/WebApp/src/lib/sim/screens/ActivityScreen.svelte b/WebApp/src/lib/sim/screens/ActivityScreen.svelte deleted file mode 100644 index c741915..0000000 --- a/WebApp/src/lib/sim/screens/ActivityScreen.svelte +++ /dev/null @@ -1,27 +0,0 @@ -
-
-

Activity History

- -
- -
-
-
- - - -

All quiet. No notifications yet.

-
-
-
-
diff --git a/WebApp/src/lib/sim/screens/AuthScreen.svelte b/WebApp/src/lib/sim/screens/AuthScreen.svelte deleted file mode 100644 index defbd13..0000000 --- a/WebApp/src/lib/sim/screens/AuthScreen.svelte +++ /dev/null @@ -1,62 +0,0 @@ -
-
-
- - - -
-

SecureCam Web

-

Sign in to manage visual security from your browser.

-
- -
-
- - -
-
- -
- - -
- -
OR
- -
-
-
diff --git a/WebApp/src/lib/sim/screens/CameraScreen.svelte b/WebApp/src/lib/sim/screens/CameraScreen.svelte deleted file mode 100644 index e417bfc..0000000 --- a/WebApp/src/lib/sim/screens/CameraScreen.svelte +++ /dev/null @@ -1,81 +0,0 @@ -
-
-

Camera Feed (Broadcasting)

-
- -
-
-
- -
- - REC -
-
- -
- - - -

Camera Offline

- -
-
- -
-
-

Manual Controls

-
- - -
-
- -
-

System Logs

-
-
Awaiting connection...
-
-
-
-
-
diff --git a/WebApp/src/lib/sim/screens/ClientScreen.svelte b/WebApp/src/lib/sim/screens/ClientScreen.svelte deleted file mode 100644 index d8ff86f..0000000 --- a/WebApp/src/lib/sim/screens/ClientScreen.svelte +++ /dev/null @@ -1,88 +0,0 @@ -
-
-

Client Dashboard

-
- - -
-
- -
-

Your Cameras

-
-
-

No cameras linked yet

-
-
-
- -
- - -
-
-

Recent Recordings

-
-
-
-
-
diff --git a/WebApp/src/lib/sim/screens/OnboardingScreen.svelte b/WebApp/src/lib/sim/screens/OnboardingScreen.svelte deleted file mode 100644 index 8f1ffbc..0000000 --- a/WebApp/src/lib/sim/screens/OnboardingScreen.svelte +++ /dev/null @@ -1,69 +0,0 @@ -
-
-

Configure Device

-

Set up this browser simulator's role

-
- -
-
- - -
- -
- -
- - -
- -
- -
- - -
- -
- - -
-
-
diff --git a/WebApp/src/lib/sim/screens/SettingsScreen.svelte b/WebApp/src/lib/sim/screens/SettingsScreen.svelte deleted file mode 100644 index 29472ad..0000000 --- a/WebApp/src/lib/sim/screens/SettingsScreen.svelte +++ /dev/null @@ -1,74 +0,0 @@ -
-

Settings

- -
-
- U -
-
-

User

-

user@example.com

-
-
- -
- - -
- - -