"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: {}, session: null, filter: "", scheduledOnly: false, 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; let speedControlPending = false; let speedControlError = null; let schedulerData = null; let schedulerItems = new Map(); let schedulerError = ""; let schedulerLoadPromise = null; let schedulerSettingsPending = false; let schedulerHistoryLoading = false; let schedulerHistoryLoaded = false; let schedulerHistoryEvents = []; let schedulerHistoryHasMore = false; let schedulerHistoryLatestID = -1; const schedulerActionPending = new Set(); const schedulerActionErrors = new Map(); const schedulerRows = new Map(); 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 loadSession() { const data = await fetchJSON("/api/session"); state.session = data && typeof data === "object" ? data : null; } async function sync() { if (syncInFlight) { syncQueued = true; return; } syncInFlight = true; setSyncing(true); try { await Promise.all([loadTorrents(), loadStats(), loadSession()]); state.lastError = null; speedControlError = 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(); loadScheduler(); } catch (err) { actionStates.set(id, { pending: null, error: err.message || String(err), pendingText: "", }); updateCardByID(id); } } async function setAlternativeSpeed(enabled) { if (speedControlPending || !state.session) return; speedControlPending = true; speedControlError = null; renderHeader(); try { await fetchJSON("/api/session/alternative-speed", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled }), }); await sync(); speedControlPending = false; renderHeader(); } catch (err) { speedControlError = err.message || String(err); speedControlPending = false; renderHeader(); } } /* ---------- 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 formatDateTime(unix) { if (!unix) return "Never"; const date = new Date(unix * 1000); return Number.isNaN(date.getTime()) ? "Never" : date.toLocaleString(); } /* ---------- Scheduler ---------- */ function schedulerStatus(item) { if (!item.available) return { label: "Unavailable", key: "" }; if (item.active) return item.status === 6 ? { label: "Active", key: "active" } : { label: item.status === 5 ? "Queued" : "Starting", key: "waiting" }; if (item.optedIn) return { label: "Waiting", key: "waiting" }; return { label: "Available", key: "" }; } function schedulerItemFor(torrent) { const hash = (torrent.hashString || "").toLowerCase(); return schedulerItems.get(hash); } function renderScheduleControls() { const filter = $("#scheduled-filter"); filter.disabled = !schedulerData; filter.setAttribute("aria-pressed", String(state.scheduledOnly)); const count = schedulerData ? state.torrents.filter((torrent) => schedulerItemFor(torrent)?.optedIn).length : 0; setText(filter, schedulerData ? `Scheduled (${count})` : "Scheduled"); const status = $("#schedule-status"); const message = schedulerError || (schedulerData && schedulerData.error) || ""; setText(status, message ? `Scheduler: ${message}` : ""); status.classList.toggle("hidden", !message); } function createSchedulerRow() { const row = document.createElement("div"); row.className = "scheduler-row"; row.innerHTML = `
`; return row; } function renderScheduler() { const error = $("#scheduler-error"); const message = schedulerError || (schedulerData && schedulerData.error) || ""; setText(error, message); error.classList.toggle("hidden", !message); if (!schedulerData) return; const slots = $("#scheduler-slots"); if (document.activeElement !== slots && !schedulerSettingsPending) slots.value = String(schedulerData.maxActive); $("#scheduler-settings").querySelector("button").disabled = schedulerSettingsPending; const list = $("#scheduler-torrents"); const items = schedulerData.torrents || []; const visible = new Set(); items.forEach((item, index) => { visible.add(item.hashString); let row = schedulerRows.get(item.hashString); if (!row) { row = createSchedulerRow(); schedulerRows.set(item.hashString, row); } row.dataset.hash = item.hashString; const name = row.querySelector(".scheduler-row-name"); setText(name, item.name || item.hashString); name.title = item.name || item.hashString; setText(row.querySelector(".scheduler-row-meta"), item.optedIn ? `Last seeded: ${formatDateTime(item.lastSeededAt)} · Idle clock: ${formatDateTime(item.lastUploadAt)}` : "Not scheduled"); const status = schedulerStatus(item); const badge = row.querySelector(".scheduler-state"); badge.className = `scheduler-state ${status.key}`.trim(); setText(badge, status.label); const button = row.querySelector(".scheduler-toggle"); button.disabled = schedulerActionPending.has(item.hashString); button.setAttribute("aria-pressed", String(Boolean(item.optedIn))); button.setAttribute("aria-label", `${item.optedIn ? "Remove" : "Add"} ${item.name || "torrent"} ${item.optedIn ? "from" : "to"} scheduler`); setText(button, schedulerActionPending.has(item.hashString) ? "Saving..." : (item.optedIn ? "Opt out" : "Opt in")); const current = list.children[index]; if (current !== row) list.insertBefore(row, current || null); }); for (const [hash, row] of schedulerRows) { if (!visible.has(hash)) { row.remove(); schedulerRows.delete(hash); } } $("#scheduler-empty").classList.toggle("hidden", items.length > 0); const active = items.filter((item) => item.active).length; const optedIn = items.filter((item) => item.optedIn).length; setText($("#scheduler-count"), `${active} / ${schedulerData.maxActive} active slots · ${optedIn} opted in`); } function renderSchedulerHistory() { const list = $("#scheduler-history"); list.replaceChildren(); for (const event of schedulerHistoryEvents) { const row = document.createElement("li"); const description = document.createElement("span"); const names = document.createElement("strong"); const movement = event.fromName && event.toName ? `${event.fromName} → ${event.toName}` : (event.toName ? `${event.toName} activated` : `${event.fromName || "Torrent"} paused`); setText(names, movement); description.append(names, document.createTextNode(` · ${event.reason}`)); const time = document.createElement("time"); time.dateTime = new Date(event.happenedAt * 1000).toISOString(); setText(time, formatDateTime(event.happenedAt)); row.append(description, time); list.append(row); } $("#scheduler-history-empty").classList.toggle("hidden", schedulerHistoryEvents.length > 0); $("#scheduler-history-more").classList.toggle("hidden", !schedulerHistoryHasMore); $("#scheduler-history-more").disabled = schedulerHistoryLoading; } async function loadSchedulerHistory(reset = false) { if (schedulerHistoryLoading) return; schedulerHistoryLoading = true; try { const before = !reset && schedulerHistoryEvents.length ? `?before=${schedulerHistoryEvents[schedulerHistoryEvents.length - 1].id}` : ""; const result = await fetchJSON(`/api/scheduler/history${before}`); schedulerHistoryEvents = reset ? (result.events || []) : schedulerHistoryEvents.concat(result.events || []); schedulerHistoryHasMore = Boolean(result.hasMore); schedulerHistoryLoaded = true; schedulerHistoryLatestID = schedulerData ? schedulerData.latestEventId : schedulerHistoryLatestID; schedulerError = ""; } catch (err) { schedulerError = err.message || String(err); renderScheduler(); } finally { schedulerHistoryLoading = false; renderSchedulerHistory(); } } async function loadScheduler() { if (schedulerLoadPromise) return schedulerLoadPromise; schedulerLoadPromise = (async () => { try { schedulerData = await fetchJSON("/api/scheduler"); schedulerItems = new Map((schedulerData.torrents || []).map((item) => [item.hashString.toLowerCase(), item])); schedulerError = ""; renderScheduler(); if (location.hash === "#scheduler" && (!schedulerHistoryLoaded || schedulerData.latestEventId !== schedulerHistoryLatestID)) { await loadSchedulerHistory(true); } } catch (err) { schedulerError = err.message || String(err); renderScheduler(); } finally { renderScheduleControls(); renderList(); } })(); try { await schedulerLoadPromise; } finally { schedulerLoadPromise = null; } } async function setSchedulerSlots(value) { if (schedulerSettingsPending) return; schedulerSettingsPending = true; renderScheduler(); try { await fetchJSON("/api/scheduler/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ maxActive: value }), }); setText($("#scheduler-feedback"), "Active slots saved."); schedulerError = ""; await loadScheduler(); } catch (err) { schedulerError = err.message || String(err); } finally { schedulerSettingsPending = false; renderScheduler(); } } async function setSchedulerOptIn(hash, enabled) { if (schedulerActionPending.has(hash)) return; schedulerActionPending.add(hash); schedulerActionErrors.delete(hash); renderScheduler(); renderList(); try { await fetchJSON(`/api/scheduler/torrents/${hash}/opt-in`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled }), }); schedulerError = ""; if (schedulerLoadPromise) await schedulerLoadPromise; await loadScheduler(); } catch (err) { schedulerError = err.message || String(err); schedulerActionErrors.set(hash, schedulerError); } finally { schedulerActionPending.delete(hash); renderScheduler(); renderScheduleControls(); renderList(); } } function activateTab(name) { const selected = name === "scheduler" ? "scheduler" : "torrents"; for (const tabName of ["torrents", "scheduler"]) { const tab = $(`#tab-${tabName}`); const active = tabName === selected; tab.classList.toggle("active", active); tab.setAttribute("aria-selected", String(active)); tab.tabIndex = active ? 0 : -1; $(`#panel-${tabName}`).hidden = !active; } if (selected === "scheduler") loadScheduler(); } 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); renderSpeedControls(); } function sessionValue(session, kebab, snake) { if (!session) return null; if (session[kebab] != null) return session[kebab]; return snake ? session[snake] : null; } function sessionSpeedBytes(session) { const units = session && session.units; const value = units && (units["speed-bytes"] ?? units.speed_bytes ?? units.speedBytes); return Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : 1000; } function activeSpeedCap(session, direction) { const enabled = Boolean(sessionValue(session, "alt-speed-enabled", "alt_speed_enabled")); const alt = direction === "down" ? sessionValue(session, "alt-speed-down", "alt_speed_down") : sessionValue(session, "alt-speed-up", "alt_speed_up"); const normalEnabled = Boolean(sessionValue( session, direction === "down" ? "speed-limit-down-enabled" : "speed-limit-up-enabled", direction === "down" ? "speed_limit_down_enabled" : "speed_limit_up_enabled", )); const normal = direction === "down" ? sessionValue(session, "speed-limit-down", "speed_limit_down") : sessionValue(session, "speed-limit-up", "speed_limit_up"); const value = enabled ? alt : (normalEnabled ? normal : null); const number = Number(value); return Number.isFinite(number) && number > 0 ? number * sessionSpeedBytes(session) : null; } function renderSpeedControls() { const toggle = $("#alt-speed-toggle"); const toggleState = $("#alt-speed-state"); const status = $("#speed-control-status"); if (!toggle || !toggleState || !status) return; const session = state.session; const enabled = Boolean(sessionValue(session, "alt-speed-enabled", "alt_speed_enabled")); toggle.disabled = !session || speedControlPending; toggle.setAttribute("aria-pressed", String(enabled)); toggle.classList.toggle("is-saving", speedControlPending); setText(toggleState, !session ? (state.lastError ? "Unavailable" : "Loading...") : (speedControlPending ? "Saving..." : (enabled ? "On" : "Off"))); const caps = [ { id: "speed-cap-down", value: activeSpeedCap(session, "down") }, { id: "speed-cap-up", value: activeSpeedCap(session, "up") }, ]; for (const cap of caps) { const el = $("#" + cap.id); if (!el) continue; el.classList.toggle("hidden", cap.value == null); const valueEl = el.querySelector("strong"); if (valueEl && cap.value != null) setText(valueEl, humanSpeed(cap.value)); } setText(status, speedControlError || ""); } 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 = `
Size Ratio Peers
Downloaded
Uploaded
↓ Speed
↑ Speed
ETA
Availability
Peers @ 100%
Finished
`; 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 scheduleItem = schedulerItemFor(t); const scheduled = Boolean(scheduleItem && scheduleItem.optedIn); const scheduleBadge = field(card, "schedule-badge"); scheduleBadge.classList.toggle("is-scheduled", scheduled); setText(scheduleBadge, !schedulerData ? (schedulerError ? "Schedule unavailable" : "Schedule loading") : scheduled ? `Scheduled · ${schedulerStatus(scheduleItem).label}` : "Not scheduled"); scheduleBadge.title = !schedulerData ? (schedulerError ? "Scheduler state is unavailable" : "Loading scheduler state") : (!scheduleItem ? "Torrent must be complete and error-free before scheduling" : ""); const scheduleButton = card.querySelector("[data-schedule-button]"); const schedulable = Boolean(scheduleItem && (scheduleItem.available || scheduled)); scheduleButton.classList.toggle("hidden", !schedulable); scheduleButton.disabled = schedulerActionPending.has((t.hashString || "").toLowerCase()); scheduleButton.setAttribute("aria-pressed", String(scheduled)); scheduleButton.setAttribute("aria-label", `${scheduled ? "Remove" : "Add"} ${t.name || "torrent"} ${scheduled ? "from" : "to"} scheduler`); setText(scheduleButton, scheduleButton.disabled ? "Saving..." : (scheduled ? "Unschedule" : "Schedule")); 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) || schedulerActionErrors.get((t.hashString || "").toLowerCase()) || ""; 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 || []); if (state.scheduledOnly) torrents = torrents.filter((t) => schedulerItemFor(t)?.optedIn); 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)); setText($("#empty-title"), state.filter ? "No matching torrents" : (state.scheduledOnly ? "No scheduled torrents" : "No torrents")); setText($("#empty-sub"), state.filter ? "Try a different filter." : (state.scheduledOnly ? "Schedule a completed torrent here to see it in this view." : "Add torrents to your Transmission client to see them here.")); } function renderAll() { renderHeader(); renderScheduleControls(); renderList(); } /* ---------- Events ---------- */ function wireEvents() { document.querySelectorAll(".app-tab").forEach((tab) => { tab.addEventListener("click", () => { location.hash = tab.id === "tab-scheduler" ? "scheduler" : "torrents"; }); tab.addEventListener("keydown", (event) => { if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; event.preventDefault(); const next = event.key === "Home" ? "torrents" : event.key === "End" ? "scheduler" : (tab.id === "tab-torrents" ? "scheduler" : "torrents"); location.hash = next; $(`#tab-${next}`).focus(); }); }); window.addEventListener("hashchange", () => activateTab(location.hash.slice(1))); $("#scheduler-settings").addEventListener("submit", (event) => { event.preventDefault(); const value = Number($("#scheduler-slots").value); if (!Number.isInteger(value) || value < 1 || value > 100) { schedulerError = "Active slots must be a whole number from 1 to 100."; renderScheduler(); return; } setSchedulerSlots(value); }); $("#scheduler-torrents").addEventListener("click", (event) => { const button = event.target.closest(".scheduler-toggle"); if (!button || button.disabled) return; const hash = button.closest(".scheduler-row").dataset.hash; const item = schedulerData && schedulerData.torrents.find((t) => t.hashString === hash); if (item) setSchedulerOptIn(hash, !item.optedIn); }); $("#scheduler-history-more").addEventListener("click", () => loadSchedulerHistory(false)); const filterInput = $("#filter"); filterInput.addEventListener("input", (e) => { state.filter = e.target.value; renderList(); }); $("#scheduled-filter").addEventListener("click", () => { state.scheduledOnly = !state.scheduledOnly; renderScheduleControls(); 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); }); } const speedToggle = $("#alt-speed-toggle"); if (speedToggle) { speedToggle.addEventListener("click", () => { const enabled = !Boolean(sessionValue(state.session, "alt-speed-enabled", "alt_speed_enabled")); setAlternativeSpeed(enabled); }); } $("#torrents").addEventListener("click", (e) => { const scheduleButton = e.target.closest("[data-schedule-button]"); if (scheduleButton && !scheduleButton.disabled) { const card = scheduleButton.closest(".card"); const torrent = (state.torrents || []).find((t) => String(t.id) === card.dataset.id); const item = torrent && schedulerItemFor(torrent); if (item) setSchedulerOptIn(item.hashString, !item.optedIn); return; } 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(); loadScheduler(); } }); } /* ---------- Init ---------- */ function boot() { wireEvents(); activateTab(location.hash.slice(1)); setRefreshInterval(state.refreshMs); setInterval(() => { if (!document.hidden) loadScheduler(); }, 10000); sync(); if (location.hash !== "#scheduler") loadScheduler(); } document.addEventListener("DOMContentLoaded", boot);