383 lines
15 KiB
JavaScript
383 lines
15 KiB
JavaScript
'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')); });
|