jm-tech-power-official/Flash Download
A desktop download manager that opens up to 32 connections against one file and can send each of them out a different proxy route.
Public
Uncategorized
Clone
jm-tech-power-official/flash-download.jmproj
src/renderer/app.js
'use strict';
/* Flash Download renderer.
The segment lane strip is the progress indicator everywhere — there is no
aggregate progress bar, because the slowest lane sets the finish time. */
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const el = (tag, cls) => { const n = document.createElement(tag); if (cls) n.className = cls; return n; };
const STEPS = [1, 2, 4, 8, 16, 32];
const isMac = navigator.platform.toLowerCase().includes('mac');
const MOD = isMac ? 'Cmd' : 'Ctrl';
const ui = { view: 'active', selected: null, state: null, probe: null, probeSeq: 0, seenStates: new Map() };
// ---- formatting -------------------------------------------------------------
const UNITS = [['B', 1], ['KB', 1024], ['MB', 1024 ** 2], ['GB', 1024 ** 3], ['TB', 1024 ** 4]];
function unitFor(n) {
let u = UNITS[0];
for (const candidate of UNITS) if (n >= candidate[1]) u = candidate;
return u;
}
function size(n) {
if (n == null) return '—';
const [name, div] = unitFor(n);
const v = n / div;
return `${v.toFixed(div === 1 ? 0 : v < 10 ? 2 : v < 100 ? 1 : 0)} ${name}`;
}
function pair(a, b) {
if (b == null) return size(a);
const [name, div] = unitFor(b);
const d = div === 1 ? 0 : 2;
return `${(Math.min(a, b) / div).toFixed(d)} / ${(b / div).toFixed(d)} ${name}`;
}
function rate(n) {
if (!n || n < 1) return '0 B/s';
const [name, div] = unitFor(n);
const v = n / div;
return `${v.toFixed(v < 10 ? 2 : v < 100 ? 1 : 0)} ${name}/s`;
}
function eta(seconds) {
if (seconds == null || !isFinite(seconds)) return '—';
const s = Math.max(0, Math.round(seconds));
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
: `${m}:${String(sec).padStart(2, '0')}`;
}
const pct = (n) => (n == null ? 0 : Math.max(0, Math.min(100, n)));
// ---- status vocabulary ------------------------------------------------------
function statusOf(d) {
switch (d.state) {
case 'probing': return { text: 'Checking the server', cls: 'idle' };
case 'queued': return { text: 'Queued', cls: 'idle' };
case 'paused': return { text: 'Paused', cls: 'idle' };
case 'verifying': return { text: `Verifying ${Math.round(pct(d.verify.progress))}%`, cls: 'good' };
case 'done': return { text: d.verify && d.verify.hash ? 'Verified' : 'Finished', cls: 'good' };
case 'cancelled': return { text: 'Cancelled', cls: 'idle' };
case 'failed': return { text: d.error || 'Failed', cls: 'bad' };
default: return { text: rate(d.rate), cls: '' };
}
}
// ---- lane strip -------------------------------------------------------------
/** Lanes are keyed by connection id so a running fill is never torn down. */
function updateLanes(host, lanes, { compact = false } = {}) {
host.classList.toggle('compact', compact);
const keep = new Set();
lanes.forEach((lane, index) => {
keep.add(lane.key);
let row = host._lanes && host._lanes.get(lane.key);
if (!row) {
const root = el('div', 'lane new');
root.style.setProperty('animation-delay', `${index * 40}ms`);
const id = el('span', 'lane-id');
const track = el('div', 'lane-track');
const fill = el('i', 'lane-fill');
const rateEl = el('span', 'lane-rate');
track.append(fill);
root.append(id, track, rateEl);
host.append(root);
setTimeout(() => root.classList.remove('new'), 1000);
row = { root, id, fill, rate: rateEl };
(host._lanes ||= new Map()).set(lane.key, row);
}
row.root.className = `lane ${lane.cls}${row.root.classList.contains('new') ? ' new' : ''}`;
if (row.id.textContent !== lane.label) row.id.textContent = lane.label;
row.fill.style.width = `${pct(lane.pct)}%`;
if (row.rate.textContent !== lane.right) row.rate.textContent = lane.right;
});
if (host._lanes) {
for (const [key, row] of host._lanes) {
if (keep.has(key)) continue;
row.root.remove();
host._lanes.delete(key);
}
}
}
/** Map a download to the lanes the strip should show. */
function lanesFor(d) {
if (d.state === 'verifying') {
return [{ key: 'verify', label: 'all', cls: 'verify', pct: pct(d.verify.progress), right: `${Math.round(pct(d.verify.progress))}%` }];
}
if (!d.lanes.length) {
return [{ key: 'none', label: 'c1', cls: 'idle', pct: 0, right: '—' }];
}
return d.lanes.map((l) => ({
key: l.id,
label: `c${l.id}`,
cls: l.state,
pct: l.pct == null ? (d.state === 'done' ? 100 : 0) : l.pct,
right: l.state === 'done' ? 'done'
: l.state === 'stalled' || l.state === 'retrying' ? '0.0'
: l.pct == null ? '—'
: (l.rate / 1024 ** 2).toFixed(1),
}));
}
// ---- download rows ----------------------------------------------------------
const rows = new Map();
function buildRow(d) {
const root = el('article', 'dl');
root.tabIndex = 0;
root.dataset.id = d.id;
const top = el('div', 'dl-top');
const name = el('span', 'dl-name');
const meta = el('div', 'dl-meta');
const chipConn = el('span', 'chip chip-rt');
const sizeEl = el('span', '');
const rateEl = el('span', 'dl-rate');
const etaEl = el('span', '');
meta.append(chipConn, sizeEl, rateEl, etaEl);
const acts = el('div', 'dl-acts');
const primary = el('button', 'icbtn');
const dismiss = el('button', 'icbtn');
acts.append(primary, dismiss);
top.append(name, meta, acts);
const axis = el('div', 'axis');
const axisBar = el('div', 'axis-bar');
axis.append(el('span'), axisBar, el('span'));
const lanes = el('div', 'lanes');
const foot = el('div', 'axis');
const sum = el('div', 'strip-sum');
foot.append(sum);
root.append(top, axis, lanes, foot);
root.addEventListener('click', () => select(d.id));
root.addEventListener('dblclick', () => window.flash.openFile(d.id));
root.addEventListener('contextmenu', (event) => { event.preventDefault(); select(d.id); openMenu(event, d.id); });
primary.addEventListener('click', (event) => { event.stopPropagation(); primaryAction(rows.get(d.id).data); });
dismiss.addEventListener('click', (event) => { event.stopPropagation(); dismissAction(rows.get(d.id).data); });
return { root, name, chipConn, sizeEl, rateEl, etaEl, primary, dismiss, axis, axisBar, lanes, sum, data: d };
}
const AXIS_TICKS = 4;
function updateRow(r, d) {
r.data = d;
r.root.classList.toggle('on', ui.selected === d.id);
if (r.name.textContent !== (d.name || d.url)) r.name.textContent = d.name || d.url;
const routes = d.routesInUse.length;
const conn = d.rangeable
? `${d.connections} CONN${routes > 1 ? ` · ${routes} ROUTES` : routes === 1 ? ` · ${String(d.routesInUse[0]).toUpperCase()}` : ''}`
: 'SINGLE STREAM';
if (r.chipConn.textContent !== conn) r.chipConn.textContent = conn;
r.chipConn.className = `chip ${d.rangeable ? 'chip-rt' : ''}`;
r.sizeEl.textContent = d.state === 'done' ? size(d.size) : pair(d.received, d.size);
const status = statusOf(d);
r.rateEl.className = `dl-rate ${status.cls}`;
if (r.rateEl.textContent !== status.text) r.rateEl.textContent = status.text;
const showEta = d.state === 'active' && d.eta != null;
r.etaEl.textContent = showEta ? `${eta(d.eta)} left` : '';
// Buttons name what they do to this download, right now.
const running = d.state === 'active' || d.state === 'probing';
r.primary.textContent = running ? '❙❙' : d.state === 'verifying' ? '·' : d.state === 'done' ? '▤' : d.state === 'failed' ? '↻' : '▶';
r.primary.setAttribute('aria-label',
running ? `Pause ${d.name}` : d.state === 'done' ? `Show ${d.name} in folder` : d.state === 'failed' ? `Retry ${d.name}` : `Resume ${d.name}`);
r.primary.disabled = d.state === 'verifying';
r.dismiss.textContent = '✕';
r.dismiss.setAttribute('aria-label', `Remove ${d.name} from the list`);
// Byte-range axis, only where it means something.
const wantAxis = d.rangeable && d.size && d.lanes.length > 1 && d.state !== 'done';
r.axis.hidden = !wantAxis;
if (wantAxis && r.axisBar.dataset.size !== String(d.size)) {
r.axisBar.dataset.size = String(d.size);
r.axisBar.textContent = '';
for (let i = 0; i <= AXIS_TICKS; i++) {
const tick = el('span');
tick.textContent = i === 0 ? '0' : size((d.size * i) / AXIS_TICKS);
r.axisBar.append(tick);
}
}
updateLanes(r.lanes, lanesFor(d), { compact: ui.selected !== d.id });
const parts = [];
if (d.slowest && d.state === 'active') {
const stalled = d.slowest.stalledMs > 0;
parts.push(`slowest lane <b class="${stalled ? 'bad' : ''}">c${d.slowest.id}${stalled ? ` · stalled ${Math.round(d.slowest.stalledMs / 1000)}s` : ''}</b>`);
}
if (d.state === 'active' && d.lanes.length > 1) parts.push(`spread <b>±${d.spread.toFixed(1)}%</b>`);
if (d.retries) parts.push(`retries <b>${d.retries}</b>`);
if (d.rebalances) parts.push(`rebalanced <b>${d.rebalances}×</b>`);
if (d.state === 'done' && d.verify && d.verify.hash) parts.push(`sha256 <b>${d.verify.hash.slice(0, 12)}…</b>`);
const html = parts.join(' ');
if (r.sum.dataset.html !== html) { r.sum.dataset.html = html; r.sum.innerHTML = html; }
r.sum.parentElement.hidden = !parts.length;
}
function primaryAction(d) {
if (d.state === 'active' || d.state === 'probing') return window.flash.pause(d.id);
if (d.state === 'done') return window.flash.reveal(d.id);
if (d.state === 'failed') return window.flash.retry(d.id);
return window.flash.resume(d.id);
}
function dismissAction(d) {
if (d.state === 'active' || d.state === 'probing' || d.state === 'verifying') return window.flash.cancel(d.id);
return window.flash.remove(d.id, false);
}
const FILTERS = {
active: (d) => ['active', 'probing', 'verifying'].includes(d.state),
queued: (d) => d.state === 'queued',
paused: (d) => d.state === 'paused',
done: (d) => d.state === 'done',
failed: (d) => d.state === 'failed' || d.state === 'cancelled',
};
function renderList(state) {
const filter = FILTERS[ui.view] || (() => true);
const list = $('#list');
const visible = state.downloads.filter(filter);
const keep = new Set();
visible.forEach((d, index) => {
keep.add(d.id);
let r = rows.get(d.id);
if (!r) { r = buildRow(d); rows.set(d.id, r); }
updateRow(r, d);
const at = list.children[index];
if (at !== r.root) list.insertBefore(r.root, at || null);
});
for (const [id, r] of rows) {
if (keep.has(id)) continue;
r.root.remove();
rows.delete(id);
}
const empty = $('#empty');
empty.hidden = visible.length > 0;
if (!visible.length) {
const copy = {
active: ['Nothing downloading', `Paste a link above, or copy one anywhere and Flash Download will offer it here.`],
queued: ['Nothing queued', 'Downloads wait here when more are running than the limit allows.'],
paused: ['Nothing paused', 'Paused downloads keep their byte ranges and resume exactly where they stopped.'],
done: ['Nothing finished yet', 'Completed downloads are listed here with their checksum.'],
failed: ['Nothing failed', 'Downloads that run out of retries land here with the reason.'],
}[ui.view] || ['Nothing here', ''];
$('#empty-title').textContent = copy[0];
$('#empty-body').textContent = copy[1];
}
}
function select(id) {
ui.selected = id;
if (ui.state) render(ui.state);
}
// ---- inspector --------------------------------------------------------------
function graphSvg(values, peak) {
const w = 244, h = 78, left = 8, right = 236, top = 12, bottom = 62;
if (!values.length) values = [0];
const max = Math.max(peak, ...values, 1);
const step = values.length > 1 ? (right - left) / (values.length - 1) : 0;
const pt = (v, i) => [left + i * step, bottom - (v / max) * (bottom - top)];
const points = values.map(pt);
const line = points.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
const last = points[points.length - 1];
return `<svg viewBox="0 0 ${w} ${h}" width="100%" height="${h}" role="img" aria-label="Throughput over the last minute, peaking at ${rate(max)}.">
<g stroke="#19222F" stroke-width="1">
<line x1="${left}" y1="${top + 2}" x2="${right}" y2="${top + 2}"/>
<line x1="${left}" y1="${(top + bottom) / 2}" x2="${right}" y2="${(top + bottom) / 2}"/>
<line x1="${left}" y1="${bottom}" x2="${right}" y2="${bottom}"/>
</g>
<defs><linearGradient id="gq" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFB22C" stop-opacity=".30"/><stop offset="1" stop-color="#FFB22C" stop-opacity="0"/>
</linearGradient></defs>
<path d="M${line} L${last[0].toFixed(1)},${bottom} L${left},${bottom} Z" fill="url(#gq)" stroke="none"/>
<polyline points="${line}" fill="none" stroke="#FFB22C" stroke-width="1.6" stroke-linejoin="round"/>
<circle cx="${last[0].toFixed(1)}" cy="${last[1].toFixed(1)}" r="2.6" fill="#FFE4A8" stroke="#D9701A" stroke-width="1"/>
<text x="${left}" y="74" fill="#5D718E" font-family="IBM Plex Mono, monospace" font-size="8">−60s</text>
<text x="${right}" y="74" fill="#5D718E" font-family="IBM Plex Mono, monospace" font-size="8" text-anchor="end">${rate(values[values.length - 1])}</text>
</svg>`;
}
function block(title, aside) {
const root = el('div', 'insp-blk');
const head = el('div', 'insp-h');
const h = el('h5'); h.textContent = title;
head.append(h);
if (aside) { const s = el('span'); s.textContent = aside; head.append(s); }
root.append(head);
return root;
}
function kv(dt, dd, cls) {
const row = el('div', 'kv');
const a = el('dt'); a.textContent = dt;
const b = el('dd'); b.textContent = dd;
if (cls) b.style.color = cls;
row.append(a, b);
return row;
}
function renderInspector(state) {
const host = $('#inspector');
const d = state.downloads.find((x) => x.id === ui.selected)
|| state.downloads.find((x) => x.state === 'active')
|| state.downloads[0];
if (!d) {
if (host.dataset.mode !== 'empty') {
host.dataset.mode = 'empty';
host.textContent = '';
const note = el('p', 'hint');
note.textContent = 'Select a download to see its connections, routes and throughput.';
host.append(note);
}
return;
}
host.dataset.mode = d.id;
host.textContent = '';
const peak = Math.max(0, ...d.graph);
const g = block('Throughput · 60s', `peak ${rate(peak)}`);
const holder = el('div');
holder.innerHTML = graphSvg(d.graph.length ? d.graph : [0], peak);
g.append(holder);
host.append(g);
const live = d.lanes.filter((l) => l.state !== 'done');
const conn = block('Connections', `${live.length} open of ${d.lanes.length}`);
const wrap = el('div', 'tbl-wrap');
const table = el('table', 'tbl');
const thead = el('thead');
thead.innerHTML = '<tr><th>C</th><th>Range</th><th>Route</th><th>MB/s</th></tr>';
const tbody = el('tbody');
const shown = (live.length ? live : d.lanes).slice(0, 12);
for (const lane of shown) {
const tr = el('tr');
const cells = [
`c${lane.id}`,
lane.end == null ? '—' : `${size(lane.start)}–${size(lane.end + 1)}`,
lane.routeId || 'direct',
lane.state === 'done' ? 'done' : (lane.rate / 1024 ** 2).toFixed(1),
];
cells.forEach((text, i) => {
const td = el('td');
td.textContent = text;
if (i === 2) td.className = 'route';
if (i === 3 && (lane.state === 'stalled' || lane.state === 'retrying')) td.style.color = 'var(--red)';
tr.append(td);
});
tbody.append(tr);
}
table.append(thead, tbody);
wrap.append(table);
conn.append(wrap);
if (d.lanes.length > shown.length) {
const more = el('p', 'hint');
more.textContent = `${d.lanes.length - shown.length} more lanes not shown.`;
conn.append(more);
}
host.append(conn);
const server = block('Server');
server.append(kv('Range requests', d.rangeable ? 'bytes · accepted' : 'not offered', d.rangeable ? 'var(--ok)' : 'var(--tx-2)'));
if (d.etag) server.append(kv('ETag', d.etag.replace(/"/g, '')));
server.append(kv('Size', size(d.size)));
if (d.verify && d.verify.hash) server.append(kv('SHA256', `${d.verify.hash.slice(0, 16)}…`, 'var(--ok)'));
if (d.filePath) server.append(kv('Saved to', d.filePath));
host.append(server);
if (d.state === 'active') {
const btn = el('button', 'btn btn-sm');
btn.type = 'button';
btn.style.width = '100%';
btn.style.marginTop = '14px';
btn.textContent = 'Rebalance connections';
btn.addEventListener('click', () => window.flash.rebalance(d.id));
host.append(btn);
}
}
// ---- rail, strip ------------------------------------------------------------
function renderRail(state) {
const c = state.totals.counts;
const map = { active: c.active, queued: c.queued, paused: c.paused, done: c.done, failed: c.failed, routes: state.routes.length };
for (const [key, value] of Object.entries(map)) {
const node = $(`[data-count="${key}"]`);
if (node && node.textContent !== String(value)) node.textContent = String(value);
}
$$('#rail .nav').forEach((n) => n.classList.toggle('on', n.dataset.view === ui.view));
}
function renderStrip(state) {
const t = state.totals;
const strip = $('#strip');
const bits = [
`<b>${rate(t.rate)}</b> down`,
`${t.openLanes} of ${t.maxLanes || 0} connections`,
`routes <b>${t.routes.up} up</b>${t.routes.degraded ? ` · <span class="bad">${t.routes.degraded} degraded</span>` : ''}${t.routes.off ? ` · ${t.routes.off} off` : ''}`,
t.speedLimit ? `limit <b>${rate(t.speedLimit)}</b>` : 'no speed limit',
];
const html = bits.join('<span class="sep"></span>');
if (strip.dataset.html !== html) { strip.dataset.html = html; strip.innerHTML = html; }
// Colour and words have to agree: a red chip never reads "3 ROUTES UP".
const health = $('#route-health');
const text = t.routes.degraded
? `${t.routes.up} UP · ${t.routes.degraded} DEGRADED`
: `${t.routes.up} ROUTE${t.routes.up === 1 ? '' : 'S'} UP`;
if (health.textContent !== text) health.textContent = text;
health.className = `chip ${t.routes.degraded ? 'chip-bad' : 'chip-ok'}`;
}
// ---- routes view ------------------------------------------------------------
function renderRoutes(state) {
const body = $('#routes-table tbody');
const key = JSON.stringify(state.routes);
if (body.dataset.key === key) return;
body.dataset.key = key;
body.textContent = '';
for (const r of state.routes) {
const tr = el('tr');
if (!r.enabled) tr.style.opacity = '.5';
const cells = [
r.label,
r.type === 'direct' ? '—' : r.type.toUpperCase(),
r.latencyMs == null ? '—' : `${r.latencyMs} ms`,
String(r.lanes),
r.lastError || '—',
];
cells.forEach((text, i) => {
const td = el('td');
td.textContent = text;
if (i === 3 && r.lanes > 0) td.style.color = 'var(--amber)';
if (i === 4 && r.lastError) td.style.color = 'var(--red)';
tr.append(td);
});
const stateTd = el('td');
const chip = el('span', `chip ${!r.enabled ? 'chip-off' : r.health === 'degraded' ? 'chip-bad' : 'chip-ok'}`);
chip.textContent = !r.enabled ? 'OFF' : r.health === 'degraded' ? 'DEGRADED' : 'UP';
stateTd.append(chip);
const actTd = el('td');
const acts = el('div', 'dl-acts');
const test = el('button', 'icbtn');
test.type = 'button'; test.textContent = '↻'; test.setAttribute('aria-label', `Test ${r.label}`);
test.addEventListener('click', () => window.flash.testRoute(r.id));
acts.append(test);
if (r.type !== 'direct') {
const toggle = el('button', 'icbtn');
toggle.type = 'button';
toggle.textContent = r.enabled ? '◉' : '○';
toggle.setAttribute('aria-label', r.enabled ? `Turn ${r.label} off` : `Turn ${r.label} on`);
toggle.addEventListener('click', () => window.flash.updateRoute(r.id, { enabled: !r.enabled }));
const del = el('button', 'icbtn');
del.type = 'button'; del.textContent = '✕'; del.setAttribute('aria-label', `Remove ${r.label}`);
del.addEventListener('click', () => window.flash.removeRoute(r.id));
acts.append(toggle, del);
}
actTd.append(acts);
tr.append(stateTd, actTd);
body.append(tr);
}
}
// ---- settings view ----------------------------------------------------------
function buildSteps(host, name, onPick) {
host.textContent = '';
for (const n of STEPS) {
const input = el('input');
input.type = 'radio'; input.name = name; input.id = `${name}-${n}`; input.value = String(n);
const label = el('label');
label.setAttribute('for', input.id);
label.textContent = String(n);
input.addEventListener('change', () => onPick(n));
host.append(input, label);
}
}
function setSteps(host, value) {
const input = $(`input[value="${value}"]`, host) || $('input', host);
if (input) input.checked = true;
}
function fillSettings(state) {
const s = state.settings;
setSteps($('#set-connections'), s.connections);
$('#set-concurrent').value = s.maxConcurrent;
$('#set-limit').value = s.speedLimit ? (s.speedLimit / 1024 ** 2).toFixed(1) : '';
$('#set-dir').value = s.downloadDir || '';
$('#set-verify').checked = !!s.verifyAfter;
$('#set-clip').checked = !!s.captureClipboard;
$('#set-drop').checked = !!s.dropFailingRoutes;
}
// ---- views ------------------------------------------------------------------
function setView(view) {
ui.view = view;
const isList = !!FILTERS[view];
$('#view-list').hidden = !isList;
$('#view-routes').hidden = view !== 'routes';
$('#view-settings').hidden = view !== 'settings';
$('#capture-box').hidden = !isList;
$('#capture-kbd').hidden = !isList;
$('#capture-start').hidden = !isList;
$('#view-title').hidden = isList;
$('#view-actions').hidden = isList;
if (!isList) {
$('#view-title').textContent = view === 'routes' ? 'Route pool' : 'Settings';
const acts = $('#view-actions');
acts.textContent = '';
if (view === 'routes') {
const test = el('button', 'btn btn-sm'); test.type = 'button'; test.textContent = 'Test all';
test.addEventListener('click', () => window.flash.testAllRoutes());
const add = el('button', 'btn btn-pri btn-sm'); add.type = 'button'; add.textContent = 'Add route';
add.addEventListener('click', () => $('#route-dialog').showModal());
acts.append(test, add);
}
}
if (view === 'settings' && ui.state) fillSettings(ui.state);
if (ui.state) render(ui.state);
}
function render(state) {
ui.state = state;
renderRail(state);
renderStrip(state);
if (FILTERS[ui.view]) renderList(state);
if (ui.view === 'routes') renderRoutes(state);
renderInspector(state);
noticeFinished(state);
}
// ---- toasts -----------------------------------------------------------------
function toast({ title, body, kind }) {
const node = el('div', `toast ${kind === 'bad' ? 'bad' : ''}`);
const wrap = el('div');
const h = el('h5'); h.textContent = title;
wrap.append(h);
if (body) { const p = el('p'); p.textContent = body; wrap.append(p); }
node.append(wrap);
$('#toasts').append(node);
setTimeout(() => node.remove(), 6000);
}
/** A toast reports a finished fact; anything in progress belongs in the row. */
function noticeFinished(state) {
for (const d of state.downloads) {
const was = ui.seenStates.get(d.id);
ui.seenStates.set(d.id, d.state);
if (was === undefined || was === d.state) continue;
if (d.state === 'done') {
const took = d.startedAt && d.finishedAt ? ` in ${eta((d.finishedAt - d.startedAt) / 1000)}` : '';
toast({
title: `Finished ${d.name}`,
body: d.verify && d.verify.hash
? `SHA256 ${d.verify.hash.slice(0, 16)}… · ${size(d.size)}${took}.`
: `${size(d.size)}${took}.`,
});
} else if (d.state === 'failed') {
toast({ kind: 'bad', title: `${d.name} stopped`, body: d.error || 'Out of retries.' });
}
}
}
// ---- context menu -----------------------------------------------------------
function openMenu(event, id) {
const d = ui.state.downloads.find((x) => x.id === id);
if (!d) return;
const menu = $('#menu');
menu.textContent = '';
const running = d.state === 'active' || d.state === 'probing';
const items = [
running ? ['Pause', 'Space', () => window.flash.pause(id)]
: d.state === 'done' ? null : ['Resume', 'Space', () => window.flash.resume(id)],
d.state === 'done' ? ['Open file', `${MOD}+O`, () => window.flash.openFile(id)] : null,
['Show in folder', `${MOD}+Enter`, () => window.flash.reveal(id)],
['Copy source link', `${MOD}+C`, () => navigator.clipboard.writeText(d.url)],
null,
d.state === 'active' ? ['Rebalance connections', '', () => window.flash.rebalance(id)] : null,
d.state === 'failed' ? ['Try again', '', () => window.flash.retry(id)] : null,
null,
['Remove from list', 'Delete', () => window.flash.remove(id, false)],
['Delete file from disk', '', () => window.flash.remove(id, true), 'dgr'],
];
for (const item of items) {
if (!item) {
if (menu.lastElementChild && menu.lastElementChild.tagName !== 'HR') menu.append(el('hr'));
continue;
}
const [label, key, action, cls] = item;
const btn = el('button', cls || '');
btn.type = 'button';
btn.textContent = label;
if (key) { const s = el('span'); s.textContent = key; btn.append(s); }
btn.addEventListener('click', () => { closeMenu(); action(); });
menu.append(btn);
}
menu.hidden = false;
const w = menu.offsetWidth, h = menu.offsetHeight;
menu.style.left = `${Math.min(event.clientX, window.innerWidth - w - 8)}px`;
menu.style.top = `${Math.min(event.clientY, window.innerHeight - h - 8)}px`;
}
const closeMenu = () => { $('#menu').hidden = true; };
// ---- add dialog -------------------------------------------------------------
function previewLanes(count, total) {
const each = total ? size(total / count) : '—';
updateLanes($('#add-preview'), Array.from({ length: count }, (_, i) => ({
key: i + 1, label: `c${i + 1}`, cls: 'idle', pct: 0, right: each,
})), { compact: true });
$('#add-preview-note').textContent = total
? `${count} range${count === 1 ? '' : 's'} of ${each}. ${ui.probe && ui.probe.acceptsRanges ? 'The server accepts range requests, so all of them open at once.' : 'The server has not offered range requests, so this will use one connection.'}`
: 'Checking what the server will allow…';
}
function connectionsPicked() {
const checked = $('#add-connections input:checked');
return checked ? Number(checked.value) : 8;
}
async function runProbe(url) {
const seq = ++ui.probeSeq;
const chip = $('#add-probe');
chip.className = 'chip';
chip.textContent = 'Checking…';
$('#add-hint').textContent = '';
$('#add-hint').className = 'hint';
const res = await window.flash.probe(url);
if (seq !== ui.probeSeq) return;
if (!res.ok) {
ui.probe = null;
chip.className = 'chip chip-bad';
chip.textContent = 'UNREACHABLE';
$('#add-hint').className = 'hint bad';
$('#add-hint').textContent = `${res.error}. Check the link, or pick a different route below.`;
previewLanes(1, null);
return;
}
ui.probe = res.value;
chip.className = `chip ${res.value.acceptsRanges ? 'chip-ok' : 'chip-amber'}`;
chip.textContent = res.value.acceptsRanges ? 'RANGE REQUESTS OK' : 'NO RANGE SUPPORT';
$('#add-hint').textContent = `${size(res.value.size)}${res.value.contentType ? ` · ${res.value.contentType.split(';')[0]}` : ''}`;
if (!$('#add-name').value) $('#add-name').value = res.value.filename;
previewLanes(res.value.acceptsRanges ? connectionsPicked() : 1, res.value.size);
}
function openAdd(url) {
const state = ui.state;
ui.probe = null;
$('#add-url').value = url || '';
$('#add-name').value = '';
$('#add-dir').value = state.settings.downloadDir || '';
$('#add-verify').checked = !!state.settings.verifyAfter;
setSteps($('#add-connections'), state.settings.connections);
const select = $('#add-route');
select.textContent = '';
const spread = el('option'); spread.value = 'spread';
spread.textContent = `Spread across pool — ${state.totals.routes.up} route${state.totals.routes.up === 1 ? '' : 's'} up`;
select.append(spread);
for (const r of state.routes) {
if (!r.enabled) continue;
const option = el('option');
option.value = r.id;
option.textContent = r.type === 'direct' ? 'Direct — no proxy' : `${r.label} · ${r.host}:${r.port}`;
select.append(option);
}
select.value = state.settings.routeMode;
previewLanes(state.settings.connections, null);
$('#add-dialog').showModal();
if (url) runProbe(url);
else $('#add-url').focus();
}
async function submitAdd(startNow) {
const url = $('#add-url').value.trim();
if (!url) return;
const res = await window.flash.add({
url,
name: $('#add-name').value.trim() || null,
dir: $('#add-dir').value,
connections: connectionsPicked(),
routeMode: $('#add-route').value,
verifyAfter: $('#add-verify').checked,
startNow,
});
if (!res.ok) return toast({ kind: 'bad', title: 'Could not add that link', body: res.error });
$('#add-dialog').close();
$('#capture-url').value = '';
setView(startNow ? 'active' : 'queued');
}
// ---- wiring -----------------------------------------------------------------
function wire() {
$$('#rail .nav').forEach((n) => n.addEventListener('click', () => setView(n.dataset.view)));
$$('#wctl button').forEach((b) => b.addEventListener('click', () => window.flash.win[b.dataset.win]()));
$('#capture-start').addEventListener('click', () => openAdd($('#capture-url').value.trim()));
$('#capture-url').addEventListener('keydown', (event) => {
if (event.key === 'Enter') openAdd($('#capture-url').value.trim());
});
$('#empty-action').addEventListener('click', () => openAdd($('#capture-url').value.trim()));
buildSteps($('#add-connections'), 'add-conn', (n) => previewLanes(ui.probe && !ui.probe.acceptsRanges ? 1 : n, ui.probe && ui.probe.size));
buildSteps($('#set-connections'), 'set-conn', (n) => window.flash.updateSettings({ connections: n }));
let probeTimer = null;
$('#add-url').addEventListener('input', () => {
clearTimeout(probeTimer);
const url = $('#add-url').value.trim();
if (!/^https?:\/\/\S+$/i.test(url)) return;
probeTimer = setTimeout(() => runProbe(url), 400);
});
$('#add-dir-pick').addEventListener('click', async () => {
const res = await window.flash.chooseFolder($('#add-dir').value);
if (res.ok && res.value) $('#add-dir').value = res.value;
});
$('#add-cancel').addEventListener('click', () => $('#add-dialog').close());
$('#add-queue').addEventListener('click', () => submitAdd(false));
$('#add-form').addEventListener('submit', (event) => { event.preventDefault(); submitAdd(true); });
$('#rt-cancel').addEventListener('click', () => $('#route-dialog').close());
$('#route-form').addEventListener('submit', async (event) => {
event.preventDefault();
const res = await window.flash.addRoute({
label: $('#rt-label').value.trim() || `${$('#rt-host').value}:${$('#rt-port').value}`,
type: $('#rt-type').value,
host: $('#rt-host').value.trim(),
port: Number($('#rt-port').value),
username: $('#rt-user').value.trim() || null,
password: $('#rt-pass').value || null,
});
if (!res.ok) return toast({ kind: 'bad', title: 'Could not add that route', body: res.error });
$('#route-form').reset();
$('#route-dialog').close();
});
$('#set-concurrent').addEventListener('change', (e) => window.flash.updateSettings({ maxConcurrent: Math.max(1, Number(e.target.value) || 1) }));
$('#set-limit').addEventListener('change', (e) => {
const raw = e.target.value.trim().toLowerCase();
const mb = raw === '' || raw === 'unlimited' ? 0 : parseFloat(raw);
window.flash.updateSettings({ speedLimit: isFinite(mb) && mb > 0 ? Math.round(mb * 1024 ** 2) : 0 });
});
$('#set-dir-pick').addEventListener('click', async () => {
const res = await window.flash.chooseFolder($('#set-dir').value);
if (res.ok && res.value) { $('#set-dir').value = res.value; window.flash.updateSettings({ downloadDir: res.value }); }
});
$('#set-verify').addEventListener('change', (e) => window.flash.updateSettings({ verifyAfter: e.target.checked }));
$('#set-clip').addEventListener('change', (e) => window.flash.updateSettings({ captureClipboard: e.target.checked }));
$('#set-drop').addEventListener('change', (e) => window.flash.updateSettings({ dropFailingRoutes: e.target.checked }));
window.addEventListener('click', closeMenu);
window.addEventListener('blur', closeMenu);
window.addEventListener('resize', closeMenu);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') return closeMenu();
const typing = ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName);
if (typing || !ui.selected || !ui.state) return;
const d = ui.state.downloads.find((x) => x.id === ui.selected);
if (!d) return;
if (event.key === ' ') { event.preventDefault(); primaryAction(d); }
else if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); window.flash.remove(d.id, false); }
else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); window.flash.reveal(d.id); }
else if (event.key.toLowerCase() === 'c' && (event.metaKey || event.ctrlKey)) navigator.clipboard.writeText(d.url);
});
window.flash.onState(render);
window.flash.onNotice((n) => toast(n));
window.flash.onClipboardUrl((url) => {
const input = $('#capture-url');
if (input.value.trim()) return; // never clobber something being typed
input.value = url;
input.classList.add('offered');
});
// A link handed over from the launcher or a second launch is an explicit
// request, so it opens the dialog rather than waiting in the capture bar.
window.flash.onOpenUrl((url) => {
if (!FILTERS[ui.view]) setView('active');
openAdd(url);
});
window.flash.onWinState(({ maximized }) => {
const btn = $('#wctl [data-win="maximize"]');
btn.textContent = maximized ? '❐' : '▢';
btn.setAttribute('aria-label', maximized ? 'Restore' : 'Maximize');
});
}
// ---- boot -------------------------------------------------------------------
(async () => {
const info = await window.flash.win.info();
if (info.ok && info.value.platform === 'darwin') document.body.classList.add('mac');
$('#capture-kbd').textContent = `${MOD}+V to capture`;
wire();
const res = await window.flash.getState();
if (res.ok) { render(res.value); fillSettings(res.value); }
setView('active');
})();