641 lines
19 KiB
JavaScript
641 lines
19 KiB
JavaScript
"use strict";
|
|
|
|
/* ============================================================
|
|
Transmission Manager - frontend
|
|
Vanilla SPA. Auto-refreshes on a selectable interval with in-place updates.
|
|
============================================================ */
|
|
|
|
const DEFAULT_REFRESH_MS = 3000;
|
|
const REFRESH_INTERVALS = new Set([1000, 3000, 5000, 10000]);
|
|
const TRANSMISSION_STATUSES = {
|
|
0: "stopped",
|
|
1: "check-wait",
|
|
2: "checking",
|
|
3: "download-wait",
|
|
4: "downloading",
|
|
5: "seed-wait",
|
|
6: "seeding",
|
|
};
|
|
|
|
const state = {
|
|
torrents: [],
|
|
stats: {},
|
|
filter: "",
|
|
sortKey: "status",
|
|
refreshMs: DEFAULT_REFRESH_MS,
|
|
lastError: null,
|
|
};
|
|
|
|
const torrentCards = new Map();
|
|
const actionStates = new Map();
|
|
let syncInFlight = false;
|
|
let syncQueued = false;
|
|
let refreshTimer = null;
|
|
|
|
const $ = (sel) => document.querySelector(sel);
|
|
|
|
/* ---------- Fetching ---------- */
|
|
async function fetchJSON(url, options = {}) {
|
|
const res = await fetch(url, { cache: "no-store", ...options });
|
|
const text = await res.text();
|
|
let data = null;
|
|
if (text) {
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch {
|
|
data = text;
|
|
}
|
|
}
|
|
if (!res.ok) {
|
|
const message = data && data.error ? data.error : `HTTP ${res.status} for ${url}`;
|
|
throw new Error(message);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
async function loadTorrents() {
|
|
const data = await fetchJSON("/api/torrents");
|
|
state.torrents = (data && data.torrents) || [];
|
|
}
|
|
|
|
async function loadStats() {
|
|
const data = await fetchJSON("/api/stats");
|
|
state.stats = data || {};
|
|
}
|
|
|
|
async function sync() {
|
|
if (syncInFlight) {
|
|
syncQueued = true;
|
|
return;
|
|
}
|
|
|
|
syncInFlight = true;
|
|
setSyncing(true);
|
|
try {
|
|
await Promise.all([loadTorrents(), loadStats()]);
|
|
state.lastError = null;
|
|
} catch (err) {
|
|
state.lastError = err.message || String(err);
|
|
} finally {
|
|
renderAll();
|
|
setSyncing(false);
|
|
const updated = $("#last-updated");
|
|
if (updated) updated.textContent = `updated ${formatTime(new Date())}`;
|
|
syncInFlight = false;
|
|
}
|
|
|
|
if (syncQueued) {
|
|
syncQueued = false;
|
|
sync();
|
|
}
|
|
}
|
|
|
|
function setSyncing(on) {
|
|
const el = $("#refresh-indicator");
|
|
if (!el) return;
|
|
el.classList.toggle("syncing", on);
|
|
updateRefreshMetadata(on);
|
|
}
|
|
|
|
function refreshLabel() {
|
|
return `${state.refreshMs / 1000}s`;
|
|
}
|
|
|
|
function updateRefreshMetadata(syncing = false) {
|
|
const label = refreshLabel();
|
|
const indicator = $("#refresh-indicator");
|
|
if (indicator) {
|
|
indicator.title = `Auto-refreshes every ${label}`;
|
|
indicator.setAttribute(
|
|
"aria-label",
|
|
syncing ? `Syncing; auto-refresh every ${label}` : `Auto-refresh active every ${label}`,
|
|
);
|
|
}
|
|
|
|
const hint = $("#error-hint");
|
|
if (hint) hint.textContent = `Retrying in ${label}...`;
|
|
}
|
|
|
|
function setRefreshInterval(ms) {
|
|
state.refreshMs = REFRESH_INTERVALS.has(ms) ? ms : DEFAULT_REFRESH_MS;
|
|
updateRefreshMetadata(syncInFlight);
|
|
|
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
refreshTimer = setInterval(sync, state.refreshMs);
|
|
}
|
|
|
|
async function runTorrentAction(id, action) {
|
|
const pendingText = action === "pause" ? "Pausing..." : "Resuming...";
|
|
actionStates.set(id, { pending: action, error: null, pendingText });
|
|
updateCardByID(id);
|
|
|
|
try {
|
|
await fetchJSON(`/api/torrents/${id}/${action}`, { method: "POST" });
|
|
actionStates.delete(id);
|
|
await sync();
|
|
} catch (err) {
|
|
actionStates.set(id, {
|
|
pending: null,
|
|
error: err.message || String(err),
|
|
pendingText: "",
|
|
});
|
|
updateCardByID(id);
|
|
}
|
|
}
|
|
|
|
/* ---------- Formatting helpers ---------- */
|
|
function humanSize(bytes) {
|
|
if (bytes == null || isNaN(bytes) || bytes < 0) return "—";
|
|
if (bytes === 0) return "0 B";
|
|
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
|
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
const v = bytes / Math.pow(1024, i);
|
|
const digits = i === 0 ? 0 : v < 10 ? 2 : v < 100 ? 1 : 0;
|
|
return `${v.toFixed(digits)} ${units[i]}`;
|
|
}
|
|
|
|
function humanSpeed(bytesPerSec) {
|
|
if (!bytesPerSec || bytesPerSec <= 0) return "0 kB/s";
|
|
return humanSize(bytesPerSec) + "/s";
|
|
}
|
|
|
|
function humanRatio(ratio) {
|
|
if (ratio == null || ratio < 0 || isNaN(ratio)) return "—";
|
|
if (ratio === -1) return "∞";
|
|
return ratio.toFixed(2);
|
|
}
|
|
|
|
function humanPct(p) {
|
|
if (p == null || isNaN(p)) return 0;
|
|
if (p < 0) return 0;
|
|
if (p >= 1) return 1;
|
|
return p;
|
|
}
|
|
|
|
function formatETA(sec) {
|
|
if (sec == null || sec < 0 || isNaN(sec)) return "—";
|
|
if (sec === 0) return "—";
|
|
if (sec < 60) return `${sec}s`;
|
|
const m = Math.floor(sec / 60);
|
|
if (m < 60) return `${m}m ${sec % 60}s`;
|
|
const h = Math.floor(m / 60);
|
|
if (h < 24) return `${h}h ${m % 60}m`;
|
|
const d = Math.floor(h / 24);
|
|
return `${d}d ${h % 24}h`;
|
|
}
|
|
|
|
function formatDate(unix) {
|
|
if (!unix || unix <= 0) return "—";
|
|
const d = new Date(unix * 1000);
|
|
if (isNaN(d.getTime())) return "—";
|
|
return d.toLocaleDateString(undefined, {
|
|
year: "numeric", month: "short", day: "numeric",
|
|
});
|
|
}
|
|
|
|
function formatTime(d) {
|
|
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
}
|
|
|
|
function peerStatus(t) {
|
|
const peers = Array.isArray(t.peers) ? t.peers : [];
|
|
const connected = t.peersConnected || peers.length || 0;
|
|
const at100 = peers.filter((p) => p && p.progress === 1.0).length;
|
|
if (connected === 0) return { connected: 0, at100: 0, label: "no peers" };
|
|
return {
|
|
connected,
|
|
at100,
|
|
label: `${at100}/${connected} @ 100%`,
|
|
};
|
|
}
|
|
|
|
/* ---------- Torrent status classification ---------- */
|
|
function classify(t) {
|
|
if (t.error && t.error !== 0) {
|
|
return { key: "error", label: "Error", dot: true };
|
|
}
|
|
const tr = TRANSMISSION_STATUSES[t.status];
|
|
switch (tr) {
|
|
case "downloading": return { key: "downloading", label: "Downloading", dot: true };
|
|
case "seeding": return { key: "seeding", label: "Seeding", dot: true };
|
|
case "stopped":
|
|
if (t.isFinished || (t.percentDone >= 1.0)) return { key: "finished", label: "Finished", dot: false };
|
|
return { key: "paused", label: "Paused", dot: false };
|
|
case "checking": return { key: "checking", label: "Verifying", dot: true };
|
|
case "check-wait": return { key: "queued", label: "Queued", dot: true };
|
|
case "download-wait": return { key: "queued", label: "Queued ↓", dot: true };
|
|
case "seed-wait": return { key: "queued", label: "Queued ↑", dot: true };
|
|
default:
|
|
if (t.isFinished || (t.percentDone >= 1.0)) return { key: "finished", label: "Finished", dot: false };
|
|
return { key: "idle", label: "Idle", dot: false };
|
|
}
|
|
}
|
|
|
|
function actionForStatus(cls) {
|
|
const pausable = ["checking", "downloading", "queued", "seeding"].includes(cls.key);
|
|
if (pausable) {
|
|
return { action: "pause", label: "Pause", pendingText: "Pausing..." };
|
|
}
|
|
return { action: "resume", label: "Resume", pendingText: "Resuming..." };
|
|
}
|
|
|
|
/* ---------- Sorting ---------- */
|
|
const STATUS_RANK = { error: 0, downloading: 1, seeding: 2, queued: 3, checking: 4, finished: 5, paused: 6, idle: 7 };
|
|
|
|
function compareName(a, b) {
|
|
return (a.name || "").localeCompare(b.name || "");
|
|
}
|
|
|
|
function activitySpeed(t) {
|
|
return (t.rateDownload || 0) + (t.rateUpload || 0);
|
|
}
|
|
|
|
function availabilityValue(t) {
|
|
if (typeof t.availability === "number" && Number.isFinite(t.availability)) {
|
|
return t.availability;
|
|
}
|
|
|
|
const pieces = Array.isArray(t.availability) ? t.availability : [];
|
|
if (!pieces.length) return null;
|
|
|
|
const counts = [];
|
|
let min = Infinity;
|
|
for (const piece of pieces) {
|
|
const raw = Number(piece);
|
|
if (!Number.isFinite(raw)) continue;
|
|
const count = raw < 0 ? 1 : raw;
|
|
counts.push(count);
|
|
if (count < min) min = count;
|
|
}
|
|
|
|
if (!counts.length) return null;
|
|
|
|
let aboveMin = 0;
|
|
for (const count of counts) {
|
|
if (count > min) aboveMin += 1;
|
|
}
|
|
|
|
return min + (aboveMin / counts.length);
|
|
}
|
|
|
|
function availabilitySortValue(t) {
|
|
const value = availabilityValue(t);
|
|
return value == null ? -1 : value;
|
|
}
|
|
|
|
function humanAvailability(value) {
|
|
if (value == null || !Number.isFinite(value) || value < 0) return "—";
|
|
return `${value.toFixed(value < 10 ? 2 : 1)}x`;
|
|
}
|
|
|
|
function sortTorrents(list) {
|
|
const key = state.sortKey;
|
|
const sorted = list.slice();
|
|
const availabilityValues = key === "availability"
|
|
? new Map(sorted.map((t) => [t, availabilitySortValue(t)]))
|
|
: null;
|
|
sorted.sort((a, b) => {
|
|
switch (key) {
|
|
case "name":
|
|
return compareName(a, b);
|
|
case "progress":
|
|
return (b.percentDone || 0) - (a.percentDone || 0) || compareName(a, b);
|
|
case "ratio":
|
|
return (b.uploadRatio || 0) - (a.uploadRatio || 0) || compareName(a, b);
|
|
case "size":
|
|
return (b.totalSize || 0) - (a.totalSize || 0) || compareName(a, b);
|
|
case "speed":
|
|
return (activitySpeed(b) - activitySpeed(a))
|
|
|| ((b.rateDownload || 0) - (a.rateDownload || 0))
|
|
|| ((b.rateUpload || 0) - (a.rateUpload || 0))
|
|
|| compareName(a, b);
|
|
case "availability":
|
|
return (availabilityValues.get(b) - availabilityValues.get(a))
|
|
|| ((b.percentDone || 0) - (a.percentDone || 0))
|
|
|| compareName(a, b);
|
|
case "status": {
|
|
const ra = STATUS_RANK[classify(a).key] ?? 99;
|
|
const rb = STATUS_RANK[classify(b).key] ?? 99;
|
|
if (ra !== rb) return ra - rb;
|
|
return (b.percentDone || 0) - (a.percentDone || 0) || compareName(a, b);
|
|
}
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
return sorted;
|
|
}
|
|
|
|
/* ---------- Rendering ---------- */
|
|
function renderHeader() {
|
|
const torrents = state.torrents || [];
|
|
let dl = 0, ul = 0;
|
|
for (const t of torrents) {
|
|
dl += t.rateDownload || 0;
|
|
ul += t.rateUpload || 0;
|
|
}
|
|
$("#stat-torrents").textContent = String(torrents.length);
|
|
$("#stat-dl").textContent = humanSpeed(dl);
|
|
$("#stat-ul").textContent = humanSpeed(ul);
|
|
}
|
|
|
|
function initial(name) {
|
|
const n = (name || "").trim();
|
|
if (!n) return "?";
|
|
const base = n.replace(/\.[a-z0-9]+$/i, "");
|
|
const m = base.match(/[A-Za-z0-9]/);
|
|
return m ? m[0].toUpperCase() : "?";
|
|
}
|
|
|
|
function createCard() {
|
|
const card = document.createElement("article");
|
|
card.className = "card is-new";
|
|
card.innerHTML = `
|
|
<div class="card-top">
|
|
<div class="favicon" data-field="initial"></div>
|
|
<div class="card-body">
|
|
<div class="card-name" data-field="name"></div>
|
|
<div class="card-meta">
|
|
<span class="meta-item"><span class="meta-label">Size</span> <span data-field="size"></span></span>
|
|
<span class="meta-item"><span class="meta-label">Ratio</span> <span data-field="ratio"></span></span>
|
|
<span class="meta-item"><span class="meta-label">Peers</span> <span data-field="peers"></span></span>
|
|
<span class="meta-item done-meta hidden" data-field="done-meta-wrap"><span class="meta-label">Done</span> <span data-field="done-meta"></span></span>
|
|
</div>
|
|
</div>
|
|
<div class="card-actions">
|
|
<div class="card-status-col">
|
|
<span class="badge" data-field="badge"><span class="dotb"></span><span data-field="status-label"></span></span>
|
|
<span class="pct" data-field="pct"></span>
|
|
</div>
|
|
<button type="button" class="action-btn" data-action-button></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="progress-wrap">
|
|
<div class="progress-bar" data-field="progress"></div>
|
|
</div>
|
|
|
|
<div class="card-bottom">
|
|
<div class="stat-cell">
|
|
<span class="k">Downloaded</span>
|
|
<span class="v" data-field="downloaded"></span>
|
|
</div>
|
|
<div class="stat-cell">
|
|
<span class="k">Uploaded</span>
|
|
<span class="v" data-field="uploaded"></span>
|
|
</div>
|
|
<div class="stat-cell">
|
|
<span class="k">↓ Speed</span>
|
|
<span class="v" data-field="download-speed"></span>
|
|
</div>
|
|
<div class="stat-cell">
|
|
<span class="k">↑ Speed</span>
|
|
<span class="v" data-field="upload-speed"></span>
|
|
</div>
|
|
<div class="stat-cell">
|
|
<span class="k">ETA</span>
|
|
<span class="v" data-field="eta"></span>
|
|
</div>
|
|
<div class="stat-cell">
|
|
<span class="k">Availability</span>
|
|
<span class="v mono" data-field="availability"></span>
|
|
</div>
|
|
<div class="stat-cell">
|
|
<span class="k">Peers @ 100%</span>
|
|
<span class="v mono" data-field="peers-100"></span>
|
|
</div>
|
|
<div class="stat-cell">
|
|
<span class="k">Finished</span>
|
|
<span class="v" data-field="finished"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="action-error hidden" data-field="action-error"></div>
|
|
<div class="error-string hidden" data-field="error-string"></div>
|
|
`;
|
|
card.addEventListener("animationend", () => card.classList.remove("is-new"), { once: true });
|
|
return card;
|
|
}
|
|
|
|
function field(card, name) {
|
|
return card.querySelector(`[data-field="${name}"]`);
|
|
}
|
|
|
|
function setText(el, value) {
|
|
const text = value == null ? "" : String(value);
|
|
if (el.textContent !== text) el.textContent = text;
|
|
}
|
|
|
|
function setValueClass(el, className) {
|
|
el.classList.remove("accent", "success", "warn", "dim");
|
|
if (className) el.classList.add(className);
|
|
}
|
|
|
|
function updateCard(card, t) {
|
|
const id = Number(t.id);
|
|
const cls = classify(t);
|
|
const pct = humanPct(t.percentDone);
|
|
const pctText = (pct * 100).toFixed(pct === 1 ? 0 : 1) + "%";
|
|
const isDone = pct >= 1.0;
|
|
const ps = peerStatus(t);
|
|
const availability = availabilityValue(t);
|
|
const dl = t.rateDownload || 0;
|
|
const ul = t.rateUpload || 0;
|
|
const eta = (dl > 0 && !isDone) ? formatETA(t.eta) : (isDone ? "—" : "∞");
|
|
const hasError = cls.key === "error";
|
|
|
|
let barClass = "";
|
|
if (hasError) barClass = "error";
|
|
else if (isDone) barClass = "done";
|
|
else if (cls.key === "paused" || cls.key === "finished") barClass = "paused";
|
|
|
|
card.dataset.id = String(id);
|
|
card.classList.toggle("is-error", hasError);
|
|
setText(field(card, "initial"), initial(t.name));
|
|
|
|
const nameEl = field(card, "name");
|
|
setText(nameEl, t.name || "Unnamed torrent");
|
|
nameEl.title = t.name || "";
|
|
|
|
setText(field(card, "size"), humanSize(t.totalSize));
|
|
setText(field(card, "ratio"), humanRatio(t.uploadRatio));
|
|
setText(field(card, "peers"), ps.label);
|
|
|
|
const doneMetaWrap = field(card, "done-meta-wrap");
|
|
doneMetaWrap.classList.toggle("hidden", !t.doneDate);
|
|
setText(field(card, "done-meta"), formatDate(t.doneDate));
|
|
|
|
const badge = field(card, "badge");
|
|
badge.className = `badge ${cls.key}`;
|
|
setText(field(card, "status-label"), cls.label);
|
|
setText(field(card, "pct"), pctText);
|
|
|
|
const progress = field(card, "progress");
|
|
progress.className = `progress-bar ${barClass}`.trim();
|
|
progress.style.width = `${(pct * 100).toFixed(2)}%`;
|
|
|
|
setText(field(card, "downloaded"), isDone ? `${humanSize(t.downloadedEver)} / ${humanSize(t.totalSize)}` : humanSize(t.downloadedEver));
|
|
setText(field(card, "uploaded"), humanSize(t.uploadedEver));
|
|
|
|
const dlEl = field(card, "download-speed");
|
|
setText(dlEl, humanSpeed(dl));
|
|
setValueClass(dlEl, dl > 0 ? "accent" : "dim");
|
|
|
|
const ulEl = field(card, "upload-speed");
|
|
setText(ulEl, humanSpeed(ul));
|
|
setValueClass(ulEl, ul > 0 ? "success" : "dim");
|
|
|
|
setText(field(card, "eta"), eta);
|
|
|
|
const availabilityEl = field(card, "availability");
|
|
setText(availabilityEl, humanAvailability(availability));
|
|
setValueClass(availabilityEl, availability == null ? "dim" : availability >= 1 ? "success" : "warn");
|
|
|
|
setText(field(card, "peers-100"), `${ps.at100}/${ps.connected}`);
|
|
setText(field(card, "finished"), formatDate(t.doneDate));
|
|
|
|
const errorEl = field(card, "error-string");
|
|
const errorText = hasError && t.errorString ? t.errorString : "";
|
|
setText(errorEl, errorText);
|
|
errorEl.classList.toggle("hidden", !errorText);
|
|
|
|
const action = actionForStatus(cls);
|
|
const actionState = actionStates.get(id);
|
|
const button = card.querySelector("[data-action-button]");
|
|
button.dataset.action = action.action;
|
|
button.classList.toggle("pause", action.action === "pause");
|
|
button.classList.toggle("resume", action.action === "resume");
|
|
button.disabled = Boolean(actionState && actionState.pending);
|
|
button.setAttribute("aria-label", `${action.label} ${t.name || "torrent"}`);
|
|
setText(button, actionState && actionState.pending ? actionState.pendingText : action.label);
|
|
|
|
const actionError = field(card, "action-error");
|
|
const actionErrorText = actionState && actionState.error ? actionState.error : "";
|
|
setText(actionError, actionErrorText);
|
|
actionError.classList.toggle("hidden", !actionErrorText);
|
|
}
|
|
|
|
function updateCardByID(id) {
|
|
const card = torrentCards.get(String(id));
|
|
const torrent = (state.torrents || []).find((t) => Number(t.id) === id);
|
|
if (card && torrent) updateCard(card, torrent);
|
|
}
|
|
|
|
function reconcileCards(torrents) {
|
|
const list = $("#torrents");
|
|
const visible = new Set();
|
|
|
|
torrents.forEach((t, index) => {
|
|
const id = String(t.id);
|
|
visible.add(id);
|
|
|
|
let card = torrentCards.get(id);
|
|
if (!card) {
|
|
card = createCard();
|
|
torrentCards.set(id, card);
|
|
}
|
|
|
|
updateCard(card, t);
|
|
|
|
const current = list.children[index];
|
|
if (current !== card) {
|
|
list.insertBefore(card, current || null);
|
|
}
|
|
});
|
|
|
|
for (const [id, card] of torrentCards) {
|
|
if (!visible.has(id)) {
|
|
card.remove();
|
|
torrentCards.delete(id);
|
|
actionStates.delete(Number(id));
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderList() {
|
|
const empty = $("#empty-state");
|
|
const errEl = $("#error-state");
|
|
const errMsg = $("#error-msg");
|
|
|
|
errEl.classList.toggle("hidden", !state.lastError);
|
|
if (state.lastError) {
|
|
errMsg.textContent = state.lastError;
|
|
}
|
|
|
|
if (state.lastError && (!state.torrents || state.torrents.length === 0)) {
|
|
reconcileCards([]);
|
|
empty.classList.add("hidden");
|
|
return;
|
|
}
|
|
|
|
let torrents = sortTorrents(state.torrents || []);
|
|
const f = state.filter.trim().toLowerCase();
|
|
if (f) torrents = torrents.filter((t) => (t.name || "").toLowerCase().includes(f));
|
|
|
|
reconcileCards(torrents);
|
|
empty.classList.toggle("hidden", torrents.length !== 0 || Boolean(state.lastError));
|
|
}
|
|
|
|
function renderAll() {
|
|
renderHeader();
|
|
renderList();
|
|
}
|
|
|
|
/* ---------- Events ---------- */
|
|
function wireEvents() {
|
|
const filterInput = $("#filter");
|
|
filterInput.addEventListener("input", (e) => {
|
|
state.filter = e.target.value;
|
|
renderList();
|
|
});
|
|
|
|
document.querySelectorAll(".sort-btn").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
document.querySelectorAll(".sort-btn").forEach((b) => b.classList.remove("active"));
|
|
btn.classList.add("active");
|
|
state.sortKey = btn.dataset.sort;
|
|
renderList();
|
|
});
|
|
});
|
|
|
|
const refreshSelect = $("#refresh-interval");
|
|
if (refreshSelect) {
|
|
refreshSelect.value = String(state.refreshMs);
|
|
refreshSelect.addEventListener("change", (e) => {
|
|
const next = Number(e.target.value);
|
|
if (!REFRESH_INTERVALS.has(next)) {
|
|
e.target.value = String(state.refreshMs);
|
|
return;
|
|
}
|
|
setRefreshInterval(next);
|
|
});
|
|
}
|
|
|
|
$("#torrents").addEventListener("click", (e) => {
|
|
const button = e.target.closest("[data-action-button]");
|
|
if (!button || button.disabled) return;
|
|
|
|
const card = button.closest(".card");
|
|
const id = Number(card && card.dataset.id);
|
|
const action = button.dataset.action;
|
|
if (!id || !["pause", "resume"].includes(action)) return;
|
|
|
|
runTorrentAction(id, action);
|
|
});
|
|
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (!document.hidden) {
|
|
sync();
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------- Init ---------- */
|
|
function boot() {
|
|
wireEvents();
|
|
setRefreshInterval(state.refreshMs);
|
|
sync();
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", boot);
|