/** * SecureCam Mobile Simulator Logic * Refactored for modern UI/UX and stability. */ // --- 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: '/sim/mobile-sim-auth.html', onboarding: '/sim/mobile-sim-onboarding.html', camera: '/sim/mobile-sim-camera.html', client: '/sim/mobile-sim-client.html', activity: '/sim/mobile-sim-activity.html', settings: '/sim/mobile-sim-settings.html', }; const currentPageKey = 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); if (multiPageMode && targetPath && window.location.pathname !== targetPath) { if (replace) { window.location.replace(targetPath); } else { window.location.assign(targetPath); } 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', recordingId: recording.id, }), }); const uploadResponse = await fetch(uploadMeta.uploadUrl, { method: 'PUT', headers: { 'Content-Type': compressedBlob.type || 'video/webm' }, body: compressedBlob, }); if (!uploadResponse.ok) { throw new Error(`Upload failed with status ${uploadResponse.status}`); } await API.events.finalizeRecording(recording.id, { objectKey: uploadMeta.objectKey, bucket: uploadMeta.bucket, durationSeconds: captureResult.durationSeconds, sizeBytes: compressedBlob.size, }); addActivity('Recording', 'Recording uploaded and finalized'); return true; } catch (error) { console.error('Recording upload failed, falling back to simulated key', error); const fallbackObjectKey = `sim/${streamSessionId}/${Date.now()}.webm`; await API.events.finalizeRecording(recording.id, { objectKey: fallbackObjectKey, durationSeconds: captureResult?.durationSeconds ?? 15, sizeBytes: captureResult?.blob?.size ?? 5000000, }); addActivity('Recording', 'Upload failed; finalized with simulator fallback'); return true; } } await sleep(350); } addActivity('Recording', 'No recording row found to finalize'); return false; }; const uploadStandaloneMotionRecording = async (captureResult) => { const currentDevice = 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', eventId: lastMotionEventId, }), }); const uploadResponse = await fetch(uploadMeta.uploadUrl, { method: 'PUT', headers: { 'Content-Type': compressedBlob.type || 'video/webm' }, body: compressedBlob, }); if (!uploadResponse.ok) { throw new Error(`Upload failed with status ${uploadResponse.status}`); } await API.events.finalizeRecording(uploadMeta.video.id, { objectKey: uploadMeta.objectKey, bucket: uploadMeta.bucket, durationSeconds: captureResult.durationSeconds, sizeBytes: compressedBlob.size, }); addActivity('Recording', `Motion clip uploaded (${uploadMeta.objectKey})`); return true; } catch (error) { console.error('Standalone motion upload failed', error); addActivity('Recording', 'Standalone motion upload failed'); return false; } }; const 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;