/** * 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: [], activityFeed: [], loading: false, // global loading spinner state if needed }); // --- 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 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) }), listLinks: () => API.request('/device-links'), link: (cameraDeviceId, clientDeviceId) => API.request('/device-links', { method: 'POST', body: JSON.stringify({ cameraDeviceId, clientDeviceId }) }), }, 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, objectKey) => API.request(`/recordings/${id}/finalize`, { method: 'POST', body: JSON.stringify({ objectKey, bucket: 'videos', durationSeconds: 15, sizeBytes: 5000000 }) }), }, 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 remoteClientStream = null; let peerConnection = null; let peerSessionId = null; let peerTargetDeviceId = null; let remoteStreamWaitTimer = null; let frameRelayTimer = null; let frameCanvas = null; let frameContext = null; 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) { // If we have a token, skip onboarding navigateBasedOnRole(); connectSocket(); startPolling(); } else { store.update({ screen: 'onboarding' }); } } else { store.update({ screen: 'auth' }); } } catch { store.update({ screen: 'auth' }); } }; const navigateBasedOnRole = () => { const { device } = store.get(); if (!device) return store.update({ screen: 'onboarding' }); // Default home screen based on role store.update({ screen: 'home' }); }; 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: true, 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 setClientStreamVisibility = (isVisible) => { const videoEl = $('clientStreamVideo'); const imageEl = $('clientStreamImage'); const placeholderEl = $('clientStreamPlaceholder'); if (videoEl) { videoEl.classList.toggle('hidden', !isVisible); } if (imageEl) { imageEl.classList.toggle('hidden', !isVisible); } if (placeholderEl) { placeholderEl.classList.toggle('hidden', isVisible); } }; const clearClientStream = () => { if (remoteStreamWaitTimer) { clearTimeout(remoteStreamWaitTimer); remoteStreamWaitTimer = null; } const videoEl = $('clientStreamVideo'); const imageEl = $('clientStreamImage'); if (remoteClientStream) { remoteClientStream.getTracks().forEach((track) => track.stop()); remoteClientStream = null; } if (videoEl) { videoEl.srcObject = null; } if (imageEl) { imageEl.src = ''; } setClientStreamVisibility(false); }; const stopFrameRelay = () => { if (frameRelayTimer) { clearInterval(frameRelayTimer); frameRelayTimer = null; } }; const startFrameRelay = async (streamSessionId, toDeviceId) => { if (!socket || !streamSessionId || !toDeviceId) 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 (!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(), }); }, 300); }; const teardownPeerConnection = () => { if (peerConnection) { peerConnection.onicecandidate = null; peerConnection.ontrack = null; peerConnection.onconnectionstatechange = null; peerConnection.close(); } peerConnection = null; peerSessionId = null; peerTargetDeviceId = null; clearClientStream(); }; const ensurePeerConnection = async ({ streamSessionId, targetDeviceId, asCamera, }) => { if (peerConnection && peerSessionId === streamSessionId && peerTargetDeviceId === targetDeviceId) { return peerConnection; } teardownPeerConnection(); const connection = new RTCPeerConnection(rtcConfig); peerConnection = connection; peerSessionId = streamSessionId; peerTargetDeviceId = targetDeviceId; connection.onicecandidate = (event) => { if (!socket || !event.candidate || !peerSessionId || !peerTargetDeviceId) return; socket.emit('webrtc:signal', { toDeviceId: peerTargetDeviceId, streamSessionId: peerSessionId, signalType: 'candidate', data: event.candidate.toJSON(), }); }; connection.onconnectionstatechange = () => { addActivity('WebRTC', `Peer ${connection.connectionState}`); if (connection.connectionState === 'failed' || connection.connectionState === 'disconnected' || connection.connectionState === 'closed') { if (store.get().device?.role === 'client') { clearClientStream(); } } }; connection.ontrack = (event) => { if (remoteStreamWaitTimer) { clearTimeout(remoteStreamWaitTimer); remoteStreamWaitTimer = null; } const [stream] = event.streams; if (!stream) return; remoteClientStream = stream; const videoEl = $('clientStreamVideo'); if (videoEl) { videoEl.srcObject = stream; setClientStreamVisibility(true); void videoEl.play().catch(() => {}); } }; 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 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(); teardownPeerConnection(); }); // 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'); } await API.streams.accept(streamId); await API.streams.getPublishCreds(streamId); if (payload.sourceDeviceId) { await startOfferToClient(streamId, payload.sourceDeviceId); await startFrameRelay(streamId, payload.sourceDeviceId); } addActivity('Stream', 'Accepted & Published'); // Auto-stop after 15s for simulation setTimeout(async () => { await API.streams.end(streamId); stopFrameRelay(); if (socket && payload.sourceDeviceId) { socket.emit('webrtc:signal', { toDeviceId: payload.sourceDeviceId, streamSessionId: streamId, signalType: 'hangup', }); } teardownPeerConnection(); addActivity('Stream', 'Ended auto-simulation'); }, 15000); } 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) => { addActivity('Motion', `Detected on camera ${payload.deviceId?.split('-')[0]}...`); Toast.show('Motion Detected!', 'info'); updateNotificationDot(true); }); socket.on('stream:started', async (payload) => { addActivity('Stream', 'Stream is live, connecting...'); clearClientStream(); try { await API.streams.getSubscribeCreds(payload.streamSessionId); Toast.show('Connected to Stream', 'success'); remoteStreamWaitTimer = setTimeout(() => { if (!remoteClientStream) { Toast.show('Stream connected but no video received', 'error'); addActivity('Stream', 'No remote video track received'); } }, 6000); } catch (e) { Toast.show('Stream connect failed', 'error'); } }); socket.on('stream:frame', (payload) => { if (!payload?.frame) return; if (remoteStreamWaitTimer) { clearTimeout(remoteStreamWaitTimer); remoteStreamWaitTimer = null; } const imageEl = $('clientStreamImage'); if (!imageEl) return; imageEl.src = payload.frame; imageEl.classList.remove('hidden'); const videoEl = $('clientStreamVideo'); if (videoEl) { videoEl.classList.add('hidden'); } setClientStreamVisibility(true); }); 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)); 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') { if (device.role !== 'camera' || !peerConnection) return; await peerConnection.setRemoteDescription(new RTCSessionDescription(payload.data)); addActivity('WebRTC', 'Answer received'); return; } if (payload.signalType === 'candidate') { if (!peerConnection || !payload.data) return; await peerConnection.addIceCandidate(new RTCIceCandidate(payload.data)); addActivity('WebRTC', 'ICE candidate added'); return; } if (payload.signalType === 'hangup') { teardownPeerConnection(); 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 = await API.ops.listRecordings().catch(() => ({ recordings: [] })); store.update({ recordings: recs.recordings || [] }); const links = await API.devices.listLinks().catch(() => ({ links: [] })); store.update({ linkedCameras: links.links || [] }); } 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) { navigateBasedOnRole(); connectSocket(); startPolling(); } else { store.update({ screen: 'onboarding' }); } } 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(); teardownPeerConnection(); stopCameraPreview(); localStorage.removeItem('mobileSimDevice'); Toast.show('Signed Out', 'info'); }, // Camera Actions startMotion: async () => { try { const res = await API.events.startMotion(); 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 { 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) { } }, requestStream: async (camId) => { try { Toast.show('Requesting Stream...', 'info'); 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; } window.open(result.downloadUrl, '_blank', 'noopener,noreferrer'); } catch (e) { // handled by API wrapper } }, }; // --- 5. Rendering --- const render = (state) => { // 1. Screen Visibility $$('section[id^="screen-"]').forEach(el => el.classList.add('hidden')); if (state.screen === 'home') { const homeId = state.device?.role === 'camera' ? 'screen-home-camera' : 'screen-home-client'; $(homeId).classList.remove('hidden'); } else { $(`screen-${state.screen}`).classList.remove('hidden'); } // 2. Top Bar Status const statusDot = $('#connectionStatus .status-dot'); const statusText = $('#connectionStatus span:last-child'); 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 (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'); 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') { const preview = $('cameraPreview'); const offlineOverlay = $('cameraOfflineOverlay'); 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 (state.linkedCameras.length === 0) { list.innerHTML = `

No cameras linked yet

`; } else { list.innerHTML = state.linkedCameras.map(link => `

Camera ${link.cameraDeviceId.substring(0, 6)}

${link.status}

`).join(''); } const recList = $('recordingsList'); 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(''); } } // 6. Settings Screen if (state.session?.user && state.screen === 'settings') { $('profileName').textContent = state.session.user.name; $('profileEmail').textContent = state.session.user.email; $('profileInitials').textContent = state.session.user.name.charAt(0).toUpperCase(); } }; const addActivity = (type, msg) => { const list = $('activityFeedList'); 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 (show) dot.classList.remove('hidden'); else dot.classList.add('hidden'); }; // --- 6. Event Listeners --- $('toggleAuthModeBtn').addEventListener('click', Actions.toggleAuthMode); $('signInBtn').addEventListener('click', Actions.submitAuth); $('registerBtn').addEventListener('click', Actions.registerDevice); $('loadSavedBtn').addEventListener('click', () => { /* Handle legacy loading if needed */ }); $$('#screen-onboarding [data-role]').forEach((btn) => { btn.addEventListener('click', () => Actions.selectRole(btn.dataset.role)); }); $('linkedCamerasList').addEventListener('click', (event) => { const target = event.target.closest('.request-stream-btn'); if (!target) return; const cameraDeviceId = target.dataset.cameraDeviceId; if (!cameraDeviceId) return; Actions.requestStream(cameraDeviceId); }); $('recordingsList').addEventListener('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); }); // Navbar $$('.nav-btn').forEach(btn => { btn.addEventListener('click', () => { store.update({ screen: btn.dataset.target }); if (btn.dataset.target === 'activity') updateNotificationDot(false); }); }); // Camera Controls $('cameraGoOnlineBtn').addEventListener('click', async () => { if (store.get().device?.role === 'camera') { await startCameraPreview(); } connectSocket(); }); $('startMotionBtn').addEventListener('click', Actions.startMotion); $('endMotionBtn').addEventListener('click', Actions.endMotion); // Client Controls $('linkCameraBtn').addEventListener('click', Actions.linkCamera); $('refreshClientBtn').addEventListener('click', startPolling); // Settings $('signOutBtn').addEventListener('click', Actions.signOut); // Init store.subscribe(render); init(); window.addEventListener('beforeunload', () => { stopFrameRelay(); teardownPeerConnection(); stopCameraPreview(); }); window.Actions = Actions;