// Toolbar popup: daemon status dot, active downloads with live progress, pause/resume. // docs/05 §3. No download logic — this only renders state and forwards pause/resume // intent over the panel bridge (background/bridge.ts) to the transport the background // page owns. import { PanelClient } from '../shared/panel-client.js'; import type { PanelStatus } from '../background/bridge.js'; import { formatEta, formatSpeed, PopupStore, progressPercent, type TaskRow } from './store.js'; const client = new PanelClient(); const store = new PopupStore(); const $status = document.querySelector('#status-dot')!; const $statusText = document.querySelector('#status-text')!; const $list = document.querySelector('#task-list')!; const $empty = document.querySelector('#empty')!; const $startButton = document.querySelector('#start-daemon')!; function renderStatus(status: PanelStatus): void { $status.className = `dot dot-${status.state}`; if (status.fatal) { $statusText.textContent = status.fatal; } else if (status.needsPairing) { $statusText.textContent = status.retryAfterSec ? `Pairing locked — retry in ${status.retryAfterSec}s` : 'Velox needs pairing — open Options'; } else if (status.state === 'connected') { $statusText.textContent = status.daemonVersion ? `Connected · v${status.daemonVersion}` : 'Connected'; } else if (status.state === 'connecting') { $statusText.textContent = 'Connecting…'; } else { $statusText.textContent = "Velox isn't running."; } $startButton.hidden = status.state === 'connected'; } // Built with createElement rather than innerHTML: AMO's linter flags dynamic innerHTML // on sight, and building nodes directly means a filename can never be parsed as markup. function buildRow(row: TaskRow): HTMLLIElement { const pct = progressPercent(row); const canPause = row.state === 'downloading' || row.state === 'connecting' || row.state === 'queued'; const canResume = row.state === 'paused' || row.state === 'retry_wait' || row.state === 'failed'; const li = document.createElement('li'); li.className = 'task'; li.dataset['taskId'] = row.taskId; const name = document.createElement('div'); name.className = 'task-name'; name.title = row.filename; name.textContent = row.filename; const bar = document.createElement('div'); bar.className = 'task-bar'; const barFill = document.createElement('div'); barFill.className = 'task-bar-fill'; barFill.style.width = `${pct ?? 0}%`; bar.appendChild(barFill); const meta = document.createElement('div'); meta.className = 'task-meta'; const state = document.createElement('span'); state.className = 'task-state'; state.textContent = row.state; const speed = document.createElement('span'); speed.className = 'task-speed'; speed.textContent = formatSpeed(row.speedBps); const eta = document.createElement('span'); eta.className = 'task-eta'; eta.textContent = formatEta(row.etaSeconds); const actions = document.createElement('span'); actions.className = 'task-actions'; if (canPause) actions.appendChild(makeActionButton('pause', row.taskId, 'Pause')); if (canResume) actions.appendChild(makeActionButton('resume', row.taskId, 'Resume')); meta.append(state, speed, eta, actions); li.append(name, bar, meta); return li; } function makeActionButton(action: 'pause' | 'resume', taskId: string, label: string): HTMLButtonElement { const button = document.createElement('button'); button.dataset['action'] = action; button.dataset['taskId'] = taskId; button.textContent = label; return button; } function render(): void { const rows = store.rows(); $empty.hidden = rows.length > 0; $list.replaceChildren(...rows.map(buildRow)); } $list.addEventListener('click', (e) => { const target = e.target as HTMLElement; const action = target.dataset['action']; const taskId = target.dataset['taskId']; if (!action || !taskId) return; if (action === 'pause') void client.call('download.pause', { taskIds: [taskId] }); else if (action === 'resume') void client.call('download.resume', { taskIds: [taskId] }); }); $startButton.addEventListener('click', () => { // The transport reconnects on its own with backoff; this just gives the user // something to click rather than staring at a red dot (docs/05 §5). client.reconnect(); }); async function start(): Promise { client.onStatus(renderStatus); client.on('event.task.added', (evt) => { store.onAdded(evt); render(); }); // event.task.progress arrives at up to 4 Hz (EVENTS contract) — this repaint rides // that rate directly rather than adding a second timer, so the popup never exceeds it. client.on('event.task.progress', (evt) => { store.onProgress(evt); render(); }); client.on('event.task.state', (evt) => { store.onState(evt); render(); }); client.on('event.task.removed', (evt) => { store.onRemoved(evt); render(); }); try { const list = await client.call('download.list', { filter: { states: ['queued', 'connecting', 'downloading', 'paused', 'retry_wait', 'assembling', 'verifying'] }, limit: 100, }); store.setInitial(list.items); } catch { // Daemon unreachable — status dot already shows red; the list just stays empty. } render(); } void start();