From 053623bdaccfcb9353f1ed96b5e3aa033c6e6e27 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 14 Oct 2025 10:26:41 -0400
Subject: [PATCH] Refactor beacon runtime to design system modules
---
package-lock.json | 41 --
package.json | 5 -
scripts/build-beacon.mjs | 11 +-
scripts/check-inline-styles.mjs | 4 -
torrent/app.js | 907 ++++++++++++++++++++------------
torrent/beacon.html | 243 ++++++++-
torrent/dist/beacon.js | 1 +
torrent/dist/beacon.vendor.js | 84 ---
torrent/src/beacon-vendor.js | 60 ++-
torrent/ui/toastService.js | 8 +-
torrent/ui/torrentTable.js | 303 +++++++----
torrent/views/full.html | 290 ----------
12 files changed, 1069 insertions(+), 888 deletions(-)
create mode 100644 torrent/dist/beacon.js
delete mode 100644 torrent/dist/beacon.vendor.js
delete mode 100644 torrent/views/full.html
diff --git a/package-lock.json b/package-lock.json
index cfbdb820..d33e39ec 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,11 +9,6 @@
"version": "1.0.0",
"license": "GPL-3.0-or-later",
"dependencies": {
- "angular": "^1.8.3",
- "angular-route": "^1.8.3",
- "angular-sanitize": "^1.8.3",
- "moment": "^2.30.1",
- "ng-file-upload": "^12.2.13",
"webtorrent": "^1.9.7"
},
"devDependencies": {
@@ -1028,27 +1023,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
- "node_modules/angular": {
- "version": "1.8.3",
- "resolved": "https://registry.npmjs.org/angular/-/angular-1.8.3.tgz",
- "integrity": "sha512-5qjkWIQQVsHj4Sb5TcEs4WZWpFeVFHXwxEBHUhrny41D8UrBAd6T/6nPPAsLngJCReIOqi95W3mxdveveutpZw==",
- "deprecated": "For the actively supported Angular, see https://www.npmjs.com/package/@angular/core. AngularJS support has officially ended. For extended AngularJS support options, see https://goo.gle/angularjs-path-forward.",
- "license": "MIT"
- },
- "node_modules/angular-route": {
- "version": "1.8.3",
- "resolved": "https://registry.npmjs.org/angular-route/-/angular-route-1.8.3.tgz",
- "integrity": "sha512-kpIcRmDR2+o1FxDVVYy8Rvfab86/7LDbOgTRb9T+X9ewPQiBRuDEnZtM3oJYBiQLvAXDYTJXHV48n/bGE9Mv2g==",
- "deprecated": "For the actively supported Angular, see https://www.npmjs.com/package/@angular/core. AngularJS support has officially ended. For extended AngularJS support options, see https://goo.gle/angularjs-path-forward.",
- "license": "MIT"
- },
- "node_modules/angular-sanitize": {
- "version": "1.8.3",
- "resolved": "https://registry.npmjs.org/angular-sanitize/-/angular-sanitize-1.8.3.tgz",
- "integrity": "sha512-2rxdqzlUVafUeWOwvY/FtyWk1pFTyCtzreeiTytG9m4smpuAEKaIJAjYeVwWsoV+nlTOcgpwV4W1OCmR+BQbUg==",
- "deprecated": "For the actively supported Angular, see https://www.npmjs.com/package/@angular/core. AngularJS support has officially ended. For extended AngularJS support options, see https://goo.gle/angularjs-path-forward.",
- "license": "MIT"
- },
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
@@ -3699,15 +3673,6 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
- "node_modules/moment": {
- "version": "2.30.1",
- "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
- "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
- "license": "MIT",
- "engines": {
- "node": "*"
- }
- },
"node_modules/mp4-box-encoding": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/mp4-box-encoding/-/mp4-box-encoding-1.4.1.tgz",
@@ -3788,12 +3753,6 @@
"integrity": "sha512-IXGPhl/yAiUU597gz+k5OYxYZkmLSWTcPPcpQjWABud9OK6m/ZNLrVdcEu4e7NgmOObFIhgZVg1jecPYT/6AoA==",
"license": "MIT"
},
- "node_modules/ng-file-upload": {
- "version": "12.2.13",
- "resolved": "https://registry.npmjs.org/ng-file-upload/-/ng-file-upload-12.2.13.tgz",
- "integrity": "sha512-YFjxwmVcNssYc8hq8eCbtYSliPnrObozSZRC9yg4GoDtI0xGKUeHgEcOh+nsMF/9cTzMB/lQZAYdf29oyQgF0g==",
- "license": "MIT"
- },
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
diff --git a/package.json b/package.json
index 7ce35295..d29bef65 100644
--- a/package.json
+++ b/package.json
@@ -37,11 +37,6 @@
},
"license": "GPL-3.0-or-later",
"dependencies": {
- "angular": "^1.8.3",
- "angular-route": "^1.8.3",
- "angular-sanitize": "^1.8.3",
- "moment": "^2.30.1",
- "ng-file-upload": "^12.2.13",
"webtorrent": "^1.9.7"
}
}
diff --git a/scripts/build-beacon.mjs b/scripts/build-beacon.mjs
index ab6786d7..b85fd20a 100755
--- a/scripts/build-beacon.mjs
+++ b/scripts/build-beacon.mjs
@@ -10,19 +10,20 @@ const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..");
const entryPoint = path.join(repoRoot, "torrent/src/beacon-vendor.js");
const outDir = path.join(repoRoot, "torrent/dist");
+const outFile = path.join(outDir, "beacon.js");
await mkdir(outDir, { recursive: true });
await build({
bundle: true,
- entryPoints: { "beacon.vendor": entryPoint },
- outdir: outDir,
- format: "iife",
+ entryPoints: [entryPoint],
+ outfile: outFile,
+ format: "esm",
+ splitting: false,
platform: "browser",
- target: ["es2019"],
+ target: ["es2020"],
minify: true,
sourcemap: false,
- entryNames: "[name]",
loader: {
".css": "css",
".woff": "dataurl",
diff --git a/scripts/check-inline-styles.mjs b/scripts/check-inline-styles.mjs
index 3bbdd516..d43aec48 100644
--- a/scripts/check-inline-styles.mjs
+++ b/scripts/check-inline-styles.mjs
@@ -40,10 +40,6 @@ const IGNORED_FILES = new Set([
]);
const VIOLATION_ALLOWLIST = new Map([
- [
- "torrent/dist/beacon.vendor.js",
- new Set(["Direct .style usage", "style.cssText usage"]),
- ],
["js/ui/utils/floatingPanelStyles.js", new Set(["Direct .style usage"])],
]);
diff --git a/torrent/app.js b/torrent/app.js
index 684125ce..ac10efbd 100644
--- a/torrent/app.js
+++ b/torrent/app.js
@@ -1,381 +1,624 @@
-/* global WebTorrent, angular, moment */
-import { registerBeaconToast } from "./ui/toastService.js";
-import { registerTorrentTable } from "./ui/torrentTable.js";
+import { createBeaconToast } from "./ui/toastService.js";
+import { createTorrentTable } from "./ui/torrentTable.js";
-const VERSION = "1.1";
+const VERSION = "2.0";
-// A smaller set of WebSocket trackers for reliability.
-const trackers = [
+const TRACKERS = [
"wss://tracker.btorrent.xyz",
"wss://tracker.openwebtorrent.com",
];
-// Basic torrent options.
-const torrentOpts = { announce: trackers };
-const trackerOpts = { announce: trackers };
+const TORRENT_OPTIONS = { announce: TRACKERS };
+const TRACKER_OPTIONS = { announce: TRACKERS };
-// Simple debug logger.
-function dbg(msg) {
- console.log("[DEBUG]", msg);
+function formatBytes(num, speed = false) {
+ if (typeof num !== "number" || Number.isNaN(num)) {
+ return "";
+ }
+
+ if (num < 1) {
+ return speed ? "" : "0 B";
+ }
+
+ const units = ["B", "kB", "MB", "GB", "TB"];
+ const exponent = Math.min(Math.floor(Math.log(num) / Math.log(1000)), units.length - 1);
+ const value = (num / Math.pow(1000, exponent)).toFixed(1);
+ return `${value} ${units[exponent]}${speed ? "/s" : ""}`;
}
-// Create a WebTorrent client.
-const client = new WebTorrent({ tracker: trackerOpts });
+function formatProgress(value) {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return "";
+ }
-// Angular app definition.
-const app = angular.module("BTorrent", ["ngRoute", "ngFileUpload"]);
+ return `${(value * 100).toFixed(1)}%`;
+}
-registerBeaconToast(app);
-registerTorrentTable(app);
+function formatRatio(value) {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ return "0.00";
+ }
-// Configure Angular routes.
-app.config([
- "$compileProvider",
- "$locationProvider",
- "$routeProvider",
- function ($compileProvider, $locationProvider, $routeProvider) {
- // Allow magnet, blob, etc. in Angular URLs.
- $compileProvider.aHrefSanitizationWhitelist(
- /^\s*(https?|magnet|blob|javascript):/
- );
- $locationProvider.html5Mode(false).hashPrefix("");
+ return value.toFixed(2);
+}
- // Define basic routes.
- $routeProvider
- .when("/view", {
- templateUrl: "views/view.html",
- controller: "ViewCtrl",
- })
- .when("/download", {
- templateUrl: "views/download.html",
- controller: "DownloadCtrl",
- })
- .otherwise({
- templateUrl: "views/full.html",
- controller: "FullCtrl",
- });
- },
-]);
+function torrentsMatch(a, b) {
+ if (!a || !b) {
+ return false;
+ }
-// Warn user before they unload if torrents are still active.
-app.run([
- "$rootScope",
- function ($rootScope) {
- window.addEventListener("beforeunload", (e) => {
- if ($rootScope.client && $rootScope.client.torrents.length > 0) {
- e.preventDefault();
- e.returnValue =
- "Transfers are in progress. Are you sure you want to leave or refresh?";
- return e.returnValue;
- }
- });
- },
-]);
+ if (a === b) {
+ return true;
+ }
-// Main BTorrent controller.
-app.controller("BTorrentCtrl", [
- "$scope",
- "$rootScope",
- "$http",
- "$log",
- "beaconToast",
- function ($scope, $rootScope, $http, $log, beaconToast) {
- dbg("Starting app.js version " + VERSION);
+ if (a.infoHash && b.infoHash && a.infoHash === b.infoHash) {
+ return true;
+ }
- if (!WebTorrent.WEBRTC_SUPPORT) {
- beaconToast.error("Please use a browser with WebRTC support.", { sticky: true });
+ if (a.magnetURI && b.magnetURI && a.magnetURI === b.magnetURI) {
+ return true;
+ }
+
+ return false;
+}
+
+function prepareTorrentFiles(torrent) {
+ if (!torrent || !Array.isArray(torrent.files)) {
+ return;
+ }
+
+ torrent.files.forEach((file) => {
+ if (typeof file.priority !== "string") {
+ file.priority = "0";
}
- $rootScope.client = client;
- $rootScope.selectedTorrent = null;
- $rootScope.processing = false;
-
- // Global error handler.
- client.on("error", (err) => {
- dbg("Torrent client error: " + err);
- beaconToast.error(err.message || err);
- $rootScope.processing = false;
- });
-
- /**
- * Called whenever a new torrent is added or we seed files.
- */
- $rootScope.onTorrent = function (torrent, isSeed) {
- dbg("Torrent added: " + torrent.magnetURI);
- $rootScope.processing = false;
-
- if (!isSeed) {
- beaconToast.info(`Received ${torrent.name} metadata`);
- }
- if (!$rootScope.selectedTorrent) {
- $rootScope.selectedTorrent = torrent;
- }
-
- // Generate file.blobURL for direct downloading in the file table.
- torrent.files.forEach((file) => {
- file.getBlobURL((err, url) => {
- if (!err) {
- file.url = url;
- }
- });
+ if (typeof file.getBlobURL === "function") {
+ file.getBlobURL((error, url) => {
+ if (error || !url) {
+ return;
+ }
+ file.url = url;
});
- };
+ }
+ });
- /**
- * Add a magnet link or .torrent URL.
- */
- $rootScope.addMagnet = function (magnet) {
- if (!magnet) return;
- $rootScope.processing = true;
- dbg("Adding magnet: " + magnet);
- client.add(magnet, torrentOpts, (torrent) => {
- $rootScope.onTorrent(torrent, false);
- $scope.$applyAsync();
+ if (torrent.torrentFile && typeof Blob === "function" && typeof URL !== "undefined") {
+ try {
+ const blob = new Blob([torrent.torrentFile], {
+ type: "application/x-bittorrent",
});
- };
-
- /**
- * Seed local files.
- */
- $rootScope.seedFiles = function (files) {
- if (!files || !files.length) return;
- $rootScope.processing = true;
- dbg(`Seeding ${files.length} file(s)`);
- client.seed(files, torrentOpts, (torrent) => {
- $rootScope.onTorrent(torrent, true);
- $scope.$applyAsync();
- });
- };
-
- /**
- * Remove/destroy a selected torrent.
- */
- $rootScope.destroyedTorrent = function (err) {
- if (err) {
- console.error("Failed to destroy torrent:", err);
+ torrent.torrentFileBlobURL = URL.createObjectURL(blob);
+ if (!torrent.fileName) {
+ const name = torrent.name || "download";
+ torrent.fileName = `${name}.torrent`;
}
- dbg("Destroyed torrent", $rootScope.selectedTorrent);
- $rootScope.selectedTorrent = null;
- $rootScope.processing = false;
- };
+ } catch (error) {
+ console.warn("[beacon] Failed to create torrent blob URL", error);
+ }
+ }
+}
- /**
- * Change the priority of an individual file (high, low, or don't download).
- */
- $rootScope.changePriority = function (file) {
- if (file.priority === "-1") {
- file.deselect();
- dbg("Deselected file", file);
+function setFilePriority(file, value) {
+ if (!file) {
+ return;
+ }
+
+ const priorityValue = typeof value === "string" ? value : String(value ?? "0");
+ file.priority = priorityValue;
+
+ if (priorityValue === "-1") {
+ if (typeof file.deselect === "function") {
+ file.deselect();
+ }
+ return;
+ }
+
+ const numericPriority = Number.parseInt(priorityValue, 10);
+ if (!Number.isFinite(numericPriority)) {
+ return;
+ }
+
+ if (typeof file.select === "function") {
+ file.select(numericPriority);
+ }
+}
+
+function selectNextTorrent(current, torrents) {
+ if (!Array.isArray(torrents) || torrents.length === 0) {
+ return null;
+ }
+
+ if (!current) {
+ return torrents[0];
+ }
+
+ const existing = torrents.find((torrent) => torrentsMatch(torrent, current));
+ if (existing) {
+ return existing;
+ }
+
+ return torrents[0];
+}
+
+export function createBeaconApp({
+ documentRef = typeof document !== "undefined" ? document : null,
+ WebTorrentCtor,
+} = {}) {
+ if (!documentRef) {
+ throw new Error("createBeaconApp requires a document reference");
+ }
+
+ const view = documentRef.defaultView || globalThis;
+ const WebTorrentClass =
+ typeof WebTorrentCtor === "function"
+ ? WebTorrentCtor
+ : typeof view?.WebTorrent === "function"
+ ? view.WebTorrent
+ : null;
+
+ if (!WebTorrentClass) {
+ throw new Error("createBeaconApp requires a WebTorrent constructor");
+ }
+
+ const toast = createBeaconToast(documentRef);
+ const client = new WebTorrentClass({ tracker: TRACKER_OPTIONS });
+
+ const elements = {
+ form: documentRef.querySelector('[data-beacon="magnet-form"]'),
+ magnetInput: documentRef.querySelector('[data-beacon="magnet-input"]'),
+ seedButton: documentRef.querySelector('[data-beacon="seed-button"]'),
+ seedInput: documentRef.querySelector('[data-beacon="seed-input"]'),
+ tableRoot: documentRef.querySelector('[data-beacon="torrent-table"]'),
+ selectedSection: documentRef.querySelector('[data-beacon="selected"]'),
+ selectedName: documentRef.querySelector('[data-beacon="selected-name"]'),
+ pauseButton: documentRef.querySelector('[data-beacon-action="pause"]'),
+ resumeButton: documentRef.querySelector('[data-beacon-action="resume"]'),
+ downloadAllButton: documentRef.querySelector('[data-beacon-action="download-all"]'),
+ removeButton: documentRef.querySelector('[data-beacon-action="remove"]'),
+ copyMagnetButton: documentRef.querySelector('[data-beacon-action="copy-magnet"]'),
+ infoHashValue: documentRef.querySelector('[data-beacon="info-hash"]'),
+ fileList: documentRef.querySelector('[data-beacon="file-list"]'),
+ stats: documentRef.querySelector('[data-beacon="client-stats"]'),
+ overlay: documentRef.querySelector('[data-beacon="processing-overlay"]'),
+ };
+
+ const table = elements.tableRoot
+ ? createTorrentTable({
+ documentRef,
+ root: elements.tableRoot,
+ formatters: {
+ bytes: (value, speed = false) => formatBytes(value, speed),
+ progress: (value) => formatProgress(value),
+ ratio: (value) => formatRatio(value),
+ integer: (value) => (typeof value === "number" && Number.isFinite(value) ? Math.floor(value).toString() : "0"),
+ bytesPerSecond: (value) => formatBytes(value, true),
+ },
+ onSelect(torrent) {
+ state.selectedTorrent = torrent;
+ renderSelectedTorrent();
+ },
+ })
+ : null;
+
+ const state = {
+ selectedTorrent: null,
+ processing: false,
+ };
+
+ const cleanupTasks = [];
+ const intervalHandles = [];
+
+ function addCleanup(task) {
+ cleanupTasks.push(task);
+ }
+
+ function setProcessing(flag) {
+ state.processing = Boolean(flag);
+ if (elements.overlay) {
+ elements.overlay.hidden = !state.processing;
+ }
+ }
+
+ function getTorrents() {
+ return Array.isArray(client.torrents) ? [...client.torrents] : [];
+ }
+
+ function updateStats() {
+ if (!elements.stats) {
+ return;
+ }
+
+ const download = formatBytes(client.downloadSpeed, true) || "0 B/s";
+ const upload = formatBytes(client.uploadSpeed, true) || "0 B/s";
+ const ratio = formatRatio(client.ratio);
+
+ elements.stats.textContent = `Client stats: ↓ ${download} · ↑ ${upload} · Ratio: ${ratio}`;
+ }
+
+ function renderFileList(torrent) {
+ if (!elements.fileList) {
+ return;
+ }
+
+ elements.fileList.innerHTML = "";
+ if (!torrent || !Array.isArray(torrent.files) || !torrent.files.length) {
+ return;
+ }
+
+ torrent.files.forEach((file, index) => {
+ const row = documentRef.createElement("tr");
+ row.className = "align-top";
+
+ const nameCell = documentRef.createElement("td");
+ nameCell.className = "px-3 py-2";
+ if (file.done && file.url) {
+ const link = documentRef.createElement("a");
+ link.className = "text-info hover:text-info-strong";
+ link.href = file.url;
+ link.download = file.name || `file-${index + 1}`;
+ link.textContent = file.name || `File ${index + 1}`;
+ nameCell.appendChild(link);
} else {
- file.select(file.priority);
- dbg(`Selected with priority ${file.priority}`, file);
+ const span = documentRef.createElement("span");
+ span.textContent = file.name || `File ${index + 1}`;
+ nameCell.appendChild(span);
}
- };
+ row.appendChild(nameCell);
- /**
- * Download all files in the selected torrent if it's 100% done.
- * Creates a blob for each file, triggers a native download with .
- */
- $rootScope.downloadAll = function (torrent) {
- if (!torrent) return;
- if (torrent.progress < 1) {
- alert("Torrent is not finished downloading yet.");
+ const sizeCell = documentRef.createElement("td");
+ sizeCell.className = "px-3 py-2 text-muted-strong";
+ sizeCell.textContent = formatBytes(file.length) || "0 B";
+ row.appendChild(sizeCell);
+
+ const priorityCell = documentRef.createElement("td");
+ priorityCell.className = "px-3 py-2";
+ const select = documentRef.createElement("select");
+ select.className = "select";
+ select.name = `${file.name || `file-${index + 1}`}-priority`;
+ select.setAttribute("aria-label", `Set priority for ${file.name || `file ${index + 1}`}`);
+
+ const options = [
+ { value: "1", label: "High priority" },
+ { value: "0", label: "Low priority" },
+ { value: "-1", label: "Don't download" },
+ ];
+
+ options.forEach((option) => {
+ const optionEl = documentRef.createElement("option");
+ optionEl.value = option.value;
+ optionEl.textContent = option.label;
+ if (option.value === file.priority) {
+ optionEl.selected = true;
+ }
+ select.appendChild(optionEl);
+ });
+
+ select.value = file.priority ?? "0";
+ select.addEventListener("change", (event) => {
+ setFilePriority(file, event.target.value);
+ });
+
+ priorityCell.appendChild(select);
+ row.appendChild(priorityCell);
+
+ elements.fileList.appendChild(row);
+ });
+ }
+
+ function renderSelectedTorrent() {
+ if (!elements.selectedSection) {
+ return;
+ }
+
+ const torrents = getTorrents();
+ state.selectedTorrent = selectNextTorrent(state.selectedTorrent, torrents);
+
+ const torrent = state.selectedTorrent;
+ elements.selectedSection.hidden = !torrent;
+
+ if (!torrent) {
+ return;
+ }
+
+ if (elements.selectedName) {
+ elements.selectedName.textContent = torrent.name || "Untitled torrent";
+ }
+
+ if (elements.pauseButton) {
+ elements.pauseButton.hidden = torrent.paused === true;
+ elements.pauseButton.disabled = torrent.paused === true;
+ }
+
+ if (elements.resumeButton) {
+ elements.resumeButton.hidden = torrent.paused !== true;
+ elements.resumeButton.disabled = torrent.paused !== true;
+ }
+
+ if (elements.downloadAllButton) {
+ elements.downloadAllButton.disabled = !(torrent.progress >= 1);
+ }
+
+ if (elements.infoHashValue) {
+ elements.infoHashValue.textContent = torrent.infoHash || "";
+ }
+
+ renderFileList(torrent);
+ }
+
+ function handleTorrentReady(torrent, { isSeed = false } = {}) {
+ setProcessing(false);
+ prepareTorrentFiles(torrent);
+
+ if (!isSeed) {
+ toast.info(`Received ${torrent.name || "torrent"} metadata`);
+ }
+
+ if (!state.selectedTorrent) {
+ state.selectedTorrent = torrent;
+ }
+
+ renderSelectedTorrent();
+ table?.render(getTorrents(), state.selectedTorrent);
+ }
+
+ function addMagnet(magnet) {
+ const trimmed = typeof magnet === "string" ? magnet.trim() : "";
+ if (!trimmed) {
+ return;
+ }
+
+ setProcessing(true);
+ client.add(trimmed, TORRENT_OPTIONS, (torrent) => {
+ handleTorrentReady(torrent, { isSeed: false });
+ });
+ }
+
+ function seedFiles(files) {
+ if (!Array.isArray(files) || files.length === 0) {
+ return;
+ }
+
+ setProcessing(true);
+ client.seed(files, TORRENT_OPTIONS, (torrent) => {
+ handleTorrentReady(torrent, { isSeed: true });
+ toast.success(`Seeding ${torrent.files.length} file(s)`);
+ });
+ }
+
+ function downloadAll(torrent) {
+ if (!torrent) {
+ return;
+ }
+
+ if (torrent.progress < 1) {
+ toast.warn("Torrent is not finished downloading yet.");
+ return;
+ }
+
+ torrent.files.forEach((file) => {
+ if (typeof file.getBlob !== "function") {
return;
}
- torrent.files.forEach((file) => {
- file.getBlob((err, blob) => {
- if (err) {
- console.error("Failed to get blob for file:", file.name, err);
- return;
- }
- // Create an anchor to trigger the download.
- const blobUrl = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.classList.add("beacon-hidden-download");
- a.setAttribute("data-beacon-hidden-download", "true");
- a.href = blobUrl;
- a.download = file.name;
- // Append, click, remove, revoke.
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(blobUrl);
- });
- });
- };
- /**
- * Copy the magnet URI of a torrent to the clipboard.
- */
- $rootScope.copyMagnetURI = function (torrent) {
- if (!torrent || !torrent.magnetURI) return;
- const magnetURI = torrent.magnetURI;
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard
- .writeText(magnetURI)
- .then(() =>
- beaconToast.success("Magnet URI copied to clipboard!")
- )
- .catch((err) => {
- console.error("Clipboard error:", err);
- beaconToast.error("Failed to copy magnet URI");
- });
- } else {
- try {
- const textarea = document.createElement("textarea");
- textarea.value = magnetURI;
- textarea.classList.add("beacon-clipboard-offscreen");
- textarea.setAttribute("data-beacon-clipboard-state", "offscreen");
- document.body.appendChild(textarea);
- textarea.select();
- document.execCommand("copy");
- document.body.removeChild(textarea);
- beaconToast.success("Magnet URI copied (fallback)!");
- } catch (err) {
- console.error("Clipboard fallback error:", err);
- beaconToast.error("Failed to copy magnet URI");
+ file.getBlob((err, blob) => {
+ if (err || !blob) {
+ console.error("[beacon] Failed to get blob for file", file?.name, err);
+ toast.error("Failed to prepare file for download.");
+ return;
}
- }
- };
- /**
- * Save the .torrent file itself (via torrent.torrentFileBlobURL).
- */
- $rootScope.saveTorrentFile = function (torrent) {
- if (!torrent || !torrent.torrentFileBlobURL) return;
- const fileName = torrent.fileName || `${torrent.name}.torrent`;
- // Create a hidden to force download of the .torrent file.
- const a = document.createElement("a");
- a.classList.add("beacon-hidden-download");
- a.setAttribute("data-beacon-hidden-download", "true");
- a.href = torrent.torrentFileBlobURL;
- a.download = fileName;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- };
- },
-]);
+ const blobUrl = URL.createObjectURL(blob);
+ const anchor = documentRef.createElement("a");
+ anchor.className = "beacon-hidden-download";
+ anchor.setAttribute("data-beacon-hidden-download", "true");
+ anchor.href = blobUrl;
+ anchor.download = file.name || "download";
+ documentRef.body.appendChild(anchor);
+ anchor.click();
+ documentRef.body.removeChild(anchor);
+ URL.revokeObjectURL(blobUrl);
+ });
+ });
+ }
-// FullCtrl: handles the magnet input and torrent table for /full route.
-app.controller("FullCtrl", [
- "$scope",
- "$rootScope",
- "$location",
- "$interval",
- function ($scope, $rootScope, $location, $interval) {
- // Handle magnet input
- $scope.addMagnet = function () {
- $rootScope.addMagnet($scope.torrentInput);
- $scope.torrentInput = "";
- };
-
- // If we have a #magnet in the URL, add it automatically
- if ($location.hash()) {
- $rootScope.addMagnet($location.hash());
+ function copyMagnet(torrent) {
+ if (!torrent?.magnetURI) {
+ return;
}
- const syncTorrents = () => {
- const torrents = ($rootScope.client && $rootScope.client.torrents) || [];
- $scope.torrentRows = torrents;
+ const magnet = torrent.magnetURI;
+ const clipboard = view?.navigator?.clipboard;
- if ($rootScope.selectedTorrent) {
- const stillPresent = torrents.some((torrent) => {
- return (
- torrent === $rootScope.selectedTorrent ||
- (torrent?.magnetURI &&
- torrent.magnetURI === $rootScope.selectedTorrent.magnetURI) ||
- (torrent?.infoHash &&
- torrent.infoHash === $rootScope.selectedTorrent.infoHash)
- );
+ if (clipboard && typeof clipboard.writeText === "function") {
+ clipboard
+ .writeText(magnet)
+ .then(() => {
+ toast.success("Magnet URI copied to clipboard!");
+ })
+ .catch((error) => {
+ console.error("[beacon] Clipboard error", error);
+ toast.error("Failed to copy magnet URI");
});
+ return;
+ }
- if (!stillPresent) {
- $rootScope.selectedTorrent = torrents[0] || null;
- }
- } else if (torrents.length) {
- $rootScope.selectedTorrent = torrents[0];
+ try {
+ const textarea = documentRef.createElement("textarea");
+ textarea.value = magnet;
+ textarea.className = "beacon-clipboard-offscreen";
+ textarea.setAttribute("data-beacon-clipboard-state", "offscreen");
+ documentRef.body.appendChild(textarea);
+ textarea.select();
+ documentRef.execCommand?.("copy");
+ documentRef.body.removeChild(textarea);
+ toast.success("Magnet URI copied (fallback)!");
+ } catch (error) {
+ console.error("[beacon] Clipboard fallback error", error);
+ toast.error("Failed to copy magnet URI");
+ }
+ }
+
+ function removeTorrent(torrent) {
+ if (!torrent) {
+ return;
+ }
+
+ torrent.destroy((error) => {
+ if (error) {
+ console.error("[beacon] Failed to destroy torrent", error);
+ toast.error("Failed to remove torrent");
}
+
+ const torrents = getTorrents();
+ state.selectedTorrent = selectNextTorrent(null, torrents);
+ table?.render(torrents, state.selectedTorrent);
+ renderSelectedTorrent();
+ });
+ }
+
+ function mount() {
+ console.log(`[beacon] Starting beacon runtime v${VERSION}`);
+
+ if (!toast) {
+ console.warn("[beacon] Toast service unavailable");
+ }
+
+ if (!WebTorrentClass.WEBRTC_SUPPORT) {
+ toast?.error("Please use a browser with WebRTC support.", { sticky: true });
+ }
+
+ if (elements.form) {
+ const submitHandler = (event) => {
+ event.preventDefault();
+ addMagnet(elements.magnetInput?.value || "");
+ if (elements.magnetInput) {
+ elements.magnetInput.value = "";
+ }
+ };
+ elements.form.addEventListener("submit", submitHandler);
+ addCleanup(() => elements.form.removeEventListener("submit", submitHandler));
+ }
+
+ if (elements.seedButton && elements.seedInput) {
+ const seedClickHandler = () => {
+ elements.seedInput.click();
+ };
+ const seedChangeHandler = () => {
+ const files = Array.from(elements.seedInput.files || []);
+ elements.seedInput.value = "";
+ seedFiles(files);
+ };
+ elements.seedButton.addEventListener("click", seedClickHandler);
+ elements.seedInput.addEventListener("change", seedChangeHandler);
+ addCleanup(() => {
+ elements.seedButton.removeEventListener("click", seedClickHandler);
+ elements.seedInput.removeEventListener("change", seedChangeHandler);
+ });
+ }
+
+ if (elements.pauseButton) {
+ const handler = () => {
+ if (state.selectedTorrent && typeof state.selectedTorrent.pause === "function") {
+ state.selectedTorrent.pause();
+ renderSelectedTorrent();
+ }
+ };
+ elements.pauseButton.addEventListener("click", handler);
+ addCleanup(() => elements.pauseButton.removeEventListener("click", handler));
+ }
+
+ if (elements.resumeButton) {
+ const handler = () => {
+ if (state.selectedTorrent && typeof state.selectedTorrent.resume === "function") {
+ state.selectedTorrent.resume();
+ renderSelectedTorrent();
+ }
+ };
+ elements.resumeButton.addEventListener("click", handler);
+ addCleanup(() => elements.resumeButton.removeEventListener("click", handler));
+ }
+
+ if (elements.downloadAllButton) {
+ const handler = () => downloadAll(state.selectedTorrent);
+ elements.downloadAllButton.addEventListener("click", handler);
+ addCleanup(() => elements.downloadAllButton.removeEventListener("click", handler));
+ }
+
+ if (elements.removeButton) {
+ const handler = () => removeTorrent(state.selectedTorrent);
+ elements.removeButton.addEventListener("click", handler);
+ addCleanup(() => elements.removeButton.removeEventListener("click", handler));
+ }
+
+ if (elements.copyMagnetButton) {
+ const handler = () => copyMagnet(state.selectedTorrent);
+ elements.copyMagnetButton.addEventListener("click", handler);
+ addCleanup(() => elements.copyMagnetButton.removeEventListener("click", handler));
+ }
+
+ const errorHandler = (error) => {
+ console.error("[beacon] Torrent client error", error);
+ toast?.error(error?.message || String(error));
+ setProcessing(false);
};
+ client.on("error", errorHandler);
+ addCleanup(() => client.removeListener("error", errorHandler));
- $scope.selectTorrent = function (torrent) {
- $rootScope.selectedTorrent = torrent;
+ const beforeUnloadHandler = (event) => {
+ if (client.torrents && client.torrents.length > 0) {
+ event.preventDefault();
+ event.returnValue =
+ "Transfers are in progress. Are you sure you want to leave or refresh?";
+ return event.returnValue;
+ }
+ return undefined;
};
+ if (view && typeof view.addEventListener === "function") {
+ view.addEventListener("beforeunload", beforeUnloadHandler);
+ addCleanup(() => view.removeEventListener("beforeunload", beforeUnloadHandler));
+ }
- syncTorrents();
- const poller = $interval(syncTorrents, 1000);
+ table?.render(getTorrents(), state.selectedTorrent);
+ renderSelectedTorrent();
+ updateStats();
+ setProcessing(false);
- $scope.$on("$destroy", function () {
- if (poller) {
- $interval.cancel(poller);
+ const intervalId = view.setInterval(() => {
+ table?.render(getTorrents(), state.selectedTorrent);
+ renderSelectedTorrent();
+ updateStats();
+ }, 1000);
+ intervalHandles.push(intervalId);
+
+ const hash = view?.location?.hash;
+ if (typeof hash === "string" && hash.length > 1) {
+ addMagnet(hash.slice(1));
+ }
+ }
+
+ function destroy() {
+ intervalHandles.forEach((id) => view.clearInterval(id));
+ intervalHandles.length = 0;
+
+ cleanupTasks.forEach((task) => {
+ try {
+ task();
+ } catch (error) {
+ console.warn("[beacon] Cleanup task failed", error);
}
});
- },
-]);
+ cleanupTasks.length = 0;
-// DownloadCtrl / ViewCtrl are minimal in this example.
-app.controller("DownloadCtrl", [
- "$scope",
- "$rootScope",
- "$location",
- function ($scope, $rootScope, $location) {
- $scope.addMagnet = function () {
- $rootScope.addMagnet($scope.torrentInput);
- $scope.torrentInput = "";
- };
- if ($location.hash()) {
- $rootScope.addMagnet($location.hash());
+ table?.destroy();
+
+ try {
+ client.destroy();
+ } catch (error) {
+ console.warn("[beacon] Failed to destroy client", error);
}
- },
-]);
+ }
-app.controller("ViewCtrl", [
- "$scope",
- "$rootScope",
- "$location",
- function ($scope, $rootScope, $location) {
- $scope.addMagnet = function () {
- $rootScope.addMagnet($scope.torrentInput);
- $scope.torrentInput = "";
- };
- if ($location.hash()) {
- $rootScope.addMagnet($location.hash());
- }
- },
-]);
+ return { mount, destroy };
+}
-// Angular filters for size, progress, etc.
-app.filter("pbytes", function () {
- return function (num, speed) {
- if (isNaN(num)) return "";
- if (num < 1) return speed ? "" : "0 B";
- const units = ["B", "kB", "MB", "GB", "TB"];
- const exponent = Math.min(Math.floor(Math.log(num) / Math.log(1000)), 4);
- const val = (num / Math.pow(1000, exponent)).toFixed(1) * 1;
- return `${val} ${units[exponent]}${speed ? "/s" : ""}`;
- };
-});
-
-app.filter("progress", function () {
- return function (val) {
- if (typeof val !== "number") return "";
- return (val * 100).toFixed(1) + "%";
- };
-});
-
-app.filter("html", [
- "$sce",
- function ($sce) {
- return function (input) {
- return $sce.trustAsHtml(input);
- };
- },
-]);
-
-app.filter("humanTime", function () {
- return function (millis) {
- if (millis < 1000) return "";
- const duration = moment.duration(millis).humanize();
- return duration.charAt(0).toUpperCase() + duration.slice(1);
- };
-});
+export default createBeaconApp;
diff --git a/torrent/beacon.html b/torrent/beacon.html
index 2a8ae261..1102fa9a 100644
--- a/torrent/beacon.html
+++ b/torrent/beacon.html
@@ -1,5 +1,5 @@
-
+
@@ -13,20 +13,16 @@
content="bitvid, client, webtorrent, browser, torrent, stream, bittorrent, torrenting, sharing, filesharing"
/>
-
-
-
-
+
+
-
-
-
+