'use strict'; const connectionStatus = document.getElementById('connectionStatus'); const deviceStatus = document.getElementById('deviceStatus'); const deviceList = document.getElementById('deviceList'); const treeSpacer = document.getElementById('treeSpacer'); const treeWindow = document.getElementById('treeWindow'); const deviceResults = document.getElementById('deviceResults'); const statusIndicator = document.getElementById('statusIndicator'); const deviceSearch = document.getElementById('deviceSearch'); const disconnectBtn = document.getElementById('disconnectBtn'); const selectedDeviceId = document.getElementById('selectedDeviceId'); const startProxyBtn = document.getElementById('startProxyBtn'); const stopProxyBtn = document.getElementById('stopProxyBtn'); const checkUpdateBtn = document.getElementById('checkUpdateBtn'); const updateNotice = document.getElementById('updateNotice'); const updateMessage = document.getElementById('updateMessage'); const openReleasesBtn = document.getElementById('openReleasesBtn'); const dismissUpdateBtn = document.getElementById('dismissUpdateBtn'); const pairingSection = document.getElementById('pairingSection'); const managePairingBtn = document.getElementById('managePairingBtn'); const pairingState = document.getElementById('pairingState'); const pairingSecret = document.getElementById('pairingSecret'); const pairingSecretRow = document.getElementById('pairingSecretRow'); const rotatePairingBtn = document.getElementById('rotatePairingBtn'); const revokePairingBtn = document.getElementById('revokePairingBtn'); const hidePairingBtn = document.getElementById('hidePairingBtn'); const ROW_HEIGHT = window.AptSidebarView.ROW_HEIGHT; let connection = { connected: false, origin: null, activeProxies: [] }; let selectedDevice = null; let hierarchyModel = null; let sidebarController = null; let loadGeneration = 0; let searchTimer = null; let scrollFrame = null; let pairingView = { paired: false, secretVisible: false, manuallyOpen: false }; function renderPairingVisibility() { const visible = window.AptRendererController.shouldShowPairingOnboarding({ ...pairingView, connected: connection.connected, }); pairingSection.hidden = !visible; managePairingBtn.hidden = visible || !pairingView.paired; hidePairingBtn.hidden = !pairingView.paired; } function showStatus(element, message, type) { element.textContent = message; element.className = `status-message ${type}`; element.style.display = 'block'; } function activeDeviceIds() { return new Set((connection.activeProxies || []).map((proxy) => String(proxy.deviceId || '').toLowerCase())); } function deviceStatusFor(device) { const value = String(device && (device.displayStatus ?? device.status ?? device.online) || '').toLowerCase(); return ['green', 'online', 'live', 'connected', 'true'].includes(value); } function updateProxyButtons() { const id = selectedDevice && selectedDevice.id; const active = id ? activeDeviceIds().has(id.toLowerCase()) : false; startProxyBtn.disabled = !connection.connected || !id || active; stopProxyBtn.disabled = !id || !active; } function renderConnectionState(state) { connection = { connected: state && state.connected === true, origin: state && typeof state.origin === 'string' ? state.origin : null, activeProxies: state && Array.isArray(state.activeProxies) ? state.activeProxies : [], }; const dot = statusIndicator.querySelector('.status-dot'); const text = statusIndicator.querySelector('.status-text'); dot.className = `status-dot ${connection.connected ? 'online' : 'offline'}`; text.textContent = connection.connected ? 'Connected' : 'Disconnected'; disconnectBtn.disabled = !connection.connected; if (connection.connected) { pairingView.secretVisible = false; pairingView.manuallyOpen = false; pairingSecret.value = ''; pairingSecretRow.style.display = 'none'; } renderPairingVisibility(); updateProxyButtons(); if (sidebarController) sidebarController.refresh(); } function showTreePlaceholder(message) { treeSpacer.style.height = '0px'; treeWindow.style.transform = ''; const placeholder = document.createElement('p'); placeholder.className = 'placeholder-text'; placeholder.textContent = message; treeWindow.replaceChildren(placeholder); deviceList.removeAttribute('aria-activedescendant'); } function clearHierarchy() { loadGeneration += 1; if (searchTimer) clearTimeout(searchTimer); searchTimer = null; if (sidebarController) sidebarController.destroy(); sidebarController = null; hierarchyModel = null; selectedDevice = null; selectedDeviceId.value = ''; deviceSearch.value = ''; deviceResults.textContent = connection.connected ? 'Loading cameras' : 'Connect to load cameras'; showTreePlaceholder(connection.connected ? 'Loading devices...' : 'Connect through the paired Chrome extension to load devices'); updateProxyButtons(); } function syncSelection(snapshot) { const selectedKey = snapshot && snapshot.state.selectedKey; const camera = selectedKey && hierarchyModel ? hierarchyModel.cameraByKey.get(selectedKey) : null; if (!camera) return; if (selectedDevice && selectedDevice.id === camera.source.id) return; selectedDevice = camera.source; selectedDeviceId.value = selectedDevice.id; showStatus(connectionStatus, `Selected device: ${selectedDevice.name || 'Unnamed Device'}`, 'info'); updateProxyButtons(); } function syncTreeScroll(snapshot) { if (!snapshot) return; const target = snapshot.state.scrollTop; if (Math.abs(deviceList.scrollTop - target) >= ROW_HEIGHT) deviceList.scrollTop = target; } function describeHierarchyWarning(hierarchy, model) { const diagnostics = hierarchy && hierarchy.diagnostics && typeof hierarchy.diagnostics === 'object' ? hierarchy.diagnostics : {}; const metadataErrors = hierarchy && hierarchy.metadataErrors && typeof hierarchy.metadataErrors === 'object' ? hierarchy.metadataErrors : {}; const unavailable = ['sites', 'groups'].filter((kind) => metadataErrors[kind] || (diagnostics[kind] && diagnostics[kind].error)); const issueCount = model.diagnostics.length; if (!unavailable.length && issueCount === 0) return ''; const parts = []; if (unavailable.length) parts.push(`${unavailable.join(' and ')} metadata unavailable`); if (issueCount) parts.push(`${issueCount} hierarchy warning${issueCount === 1 ? '' : 's'}`); return parts.join('; '); } function installHierarchy(hierarchy) { hierarchyModel = window.AptDeviceTree.buildDeviceTreeModel(hierarchy); const view = window.AptSidebarView.createSidebarView({ document, tree: deviceList, spacer: treeSpacer, windowElement: treeWindow, model: hierarchyModel, getActiveDeviceIds: activeDeviceIds, isOnline: deviceStatusFor, onCommit: (snapshot) => { const count = snapshot.state.searchQuery && snapshot.state.searchCameraKeys ? snapshot.state.searchCameraKeys.size : hierarchyModel.cameraCount; deviceResults.textContent = snapshot.state.searchQuery ? `${count} exact search result${count === 1 ? '' : 's'}.` : `${count} camera${count === 1 ? '' : 's'}.`; }, }); sidebarController = window.AptSidebarController.createSidebarController({ model: hierarchyModel, view, rowHeight: ROW_HEIGHT, overscan: 16, initialState: { viewportHeight: deviceList.clientHeight }, }); } async function loadDevices() { if (!connection.connected) return; const generation = ++loadGeneration; const origin = connection.origin; showStatus(deviceStatus, 'Fetching camera hierarchy...', 'info'); try { const result = await window.electronAPI.getDeviceHierarchy(); if (generation !== loadGeneration || !connection.connected || connection.origin !== origin) return; if (!result.success) { showStatus(deviceStatus, result.message || 'Failed to load cameras', 'error'); showTreePlaceholder('Could not load cameras'); return; } const hierarchy = result.hierarchy || { devices: [], sites: [], groups: [] }; installHierarchy(hierarchy); const warning = describeHierarchyWarning(hierarchy, hierarchyModel); const count = hierarchyModel.cameraCount; showStatus( deviceStatus, warning ? `Found ${count} camera${count === 1 ? '' : 's'}; ${warning}. Cameras remain available in fallback hierarchy.` : `Found ${count} camera${count === 1 ? '' : 's'}`, warning ? 'warning' : 'success', ); } catch { if (generation !== loadGeneration) return; showStatus(deviceStatus, 'Could not load cameras.', 'error'); showTreePlaceholder('Could not load cameras'); } } function dispatchTree(action) { if (!sidebarController) return null; const snapshot = sidebarController.dispatch(action); syncSelection(snapshot); syncTreeScroll(snapshot); return snapshot; } const rendererController = window.AptRendererController.createRendererController({ disconnect: () => window.electronAPI.disconnect(), renderConnectionState, clearDisconnectedState: () => { clearHierarchy(); }, showConnectionStatus: (message, type) => showStatus(connectionStatus, message, type), }); async function handleDisconnect() { await rendererController.disconnect(); } async function handleStartProxy() { const id = selectedDevice && selectedDevice.id; if (!id) return; startProxyBtn.disabled = true; showStatus(connectionStatus, `Starting proxy for ${selectedDevice.name || 'selected device'}...`, 'info'); const result = await window.electronAPI.launchProxy(id); if (result.success) { renderConnectionState(await window.electronAPI.getConnectionState()); showStatus(connectionStatus, 'Camera proxy started.', 'success'); } else { showStatus(connectionStatus, result.message || 'Failed to start camera proxy.', 'error'); updateProxyButtons(); } } async function handleStopProxy() { const id = selectedDevice && selectedDevice.id; if (!id) return; stopProxyBtn.disabled = true; const result = await window.electronAPI.stopProxy(id); renderConnectionState(await window.electronAPI.getConnectionState()); showStatus(connectionStatus, result.success ? 'Camera proxy stopped.' : result.message || 'Failed to stop camera proxy.', result.success ? 'success' : 'error'); } async function checkForUpdates(showCurrent = true) { checkUpdateBtn.disabled = true; try { const result = await window.electronAPI.checkForUpdates(); if (!result.success) { if (showCurrent) showStatus(connectionStatus, result.message, 'error'); return; } if (result.status === 'update-available') { updateMessage.textContent = `${result.releaseName || `Version ${result.latestVersion}`} is available. APT will not download or run updates; review it on GitPeji.`; updateNotice.style.display = 'flex'; checkUpdateBtn.classList.add('update-available'); } else if (showCurrent) { const message = result.status === 'no-release' ? 'No published release is available on GitPeji.' : `You are on the latest version (v${result.currentVersion}).`; showStatus(connectionStatus, message, 'success'); } } finally { checkUpdateBtn.disabled = false; } } function renderPairingStatus(result) { pairingView = { paired: result.paired === true, secretVisible: Boolean(result.secret), manuallyOpen: Boolean(result.secret) || result.paired !== true, }; pairingState.textContent = result.paired ? 'Paired' : 'Revoked / not paired'; pairingState.className = result.paired ? 'paired' : 'unpaired'; revokePairingBtn.disabled = !result.paired; if (result.secret) { pairingSecret.value = result.secret; pairingSecretRow.style.display = 'block'; showStatus(connectionStatus, 'One-time bridge pairing secret generated. Paste it into the extension now; it will not be shown again.', 'info'); } else { pairingSecret.value = ''; pairingSecretRow.style.display = 'none'; } renderPairingVisibility(); } async function runSearch(query) { if (!sidebarController) return; const result = await sidebarController.search(query); if (result && !result.cancelled) { const count = result.count; deviceResults.textContent = query.trim() ? `${count} exact search result${count === 1 ? '' : 's'}.` : `${hierarchyModel.cameraCount} camera${hierarchyModel.cameraCount === 1 ? '' : 's'}.`; } } async function initialize() { renderConnectionState(await window.electronAPI.getConnectionState()); renderPairingStatus(await window.electronAPI.getPairingStatus()); if (connection.connected) await loadDevices(); window.electronAPI.onConnectionStateChanged(async (state) => { const wasConnected = connection.connected; const previousOrigin = connection.origin; renderConnectionState(state); if (!state.connected) { clearHierarchy(); return; } if (state.connected && (!wasConnected || state.origin !== previousOrigin)) { clearHierarchy(); showStatus(connectionStatus, `Connected to ${state.origin}`, 'success'); await loadDevices(); } }); setTimeout(() => checkForUpdates(false), 2000); } deviceList.addEventListener('click', (event) => { const row = event.target.closest('.tree-row[data-row-key]'); if (!row || !deviceList.contains(row)) return; dispatchTree({ type: 'CLICK', rowKey: row.dataset.rowKey }); deviceList.focus(); }); deviceList.addEventListener('keydown', (event) => { if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter', ' ', 'Spacebar', 'Escape'].includes(event.key)) return; event.preventDefault(); dispatchTree({ type: 'KEY', key: event.key }); if (event.key === 'Escape') { deviceSearch.value = ''; runSearch(''); } }); deviceList.addEventListener('scroll', () => { if (scrollFrame || !sidebarController) return; scrollFrame = requestAnimationFrame(() => { scrollFrame = null; sidebarController.dispatch({ type: 'SET_VIEWPORT', scrollTop: deviceList.scrollTop, viewportHeight: deviceList.clientHeight }); }); }); deviceSearch.addEventListener('input', () => { if (searchTimer) clearTimeout(searchTimer); searchTimer = setTimeout(() => runSearch(deviceSearch.value), 125); }); deviceSearch.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; event.preventDefault(); if (searchTimer) clearTimeout(searchTimer); deviceSearch.value = ''; runSearch(''); }); disconnectBtn.addEventListener('click', handleDisconnect); startProxyBtn.addEventListener('click', handleStartProxy); stopProxyBtn.addEventListener('click', handleStopProxy); checkUpdateBtn.addEventListener('click', () => checkForUpdates(true)); openReleasesBtn.addEventListener('click', () => window.electronAPI.openFixedReleasesPage()); dismissUpdateBtn.addEventListener('click', () => { updateNotice.style.display = 'none'; }); rotatePairingBtn.addEventListener('click', async () => renderPairingStatus(await window.electronAPI.rotatePairing())); revokePairingBtn.addEventListener('click', async () => renderPairingStatus(await window.electronAPI.revokePairing())); managePairingBtn.addEventListener('click', () => { pairingView.manuallyOpen = true; renderPairingVisibility(); }); hidePairingBtn.addEventListener('click', () => { pairingView.manuallyOpen = false; pairingView.secretVisible = false; pairingSecret.value = ''; pairingSecretRow.style.display = 'none'; renderPairingVisibility(); }); document.addEventListener('DOMContentLoaded', () => { initialize().catch(() => showStatus(connectionStatus, 'Application initialization failed.', 'error')); });