diff --git a/js/app.js b/js/app.js index 0268c1e1..4ce668d4 100644 --- a/js/app.js +++ b/js/app.js @@ -144,6 +144,7 @@ import { } from "./state/cache.js"; import ApplicationBootstrap from "./ui/applicationBootstrap.js"; import VideoModalCommentController from "./ui/videoModalCommentController.js"; +import TorrentStatusController from "./ui/torrentStatusController.js"; import ModerationActionController from "./services/moderationActionController.js"; import { bootstrapTrustedSeeds } from "./services/trustBootstrap.js"; @@ -223,6 +224,11 @@ class Application { this.commentController = null; this.initializeCommentController(); + this.torrentStatusController = new TorrentStatusController({ + getVideoModal: () => this.videoModal, + onRemovePoster: (reason) => this.forceRemoveModalPoster(reason), + }); + this.unsubscribeFromPubkeyState = subscribeToAppStateKey( "pubkey", (next, previous) => { @@ -7482,51 +7488,8 @@ class Application { * and do not re-trigger recursion here (no setTimeout). */ updateTorrentStatus(torrent) { - devLogger.log("[DEBUG] updateTorrentStatus called with torrent:", torrent); - - if (!torrent) { - devLogger.log("[DEBUG] torrent is null/undefined!"); - return; - } - - if (torrent.ready || (typeof torrent.progress === "number" && torrent.progress > 0)) { - // Belt-and-suspenders: if WebTorrent reports progress but the DOM events - // failed to fire we still rip off the loading GIF. This regression has - // bitten us in past releases, so the extra clear is intentional. - this.forceRemoveModalPoster( - torrent.ready ? "torrent-ready-flag" : "torrent-progress" - ); - } - - // Log only fields that actually exist on the torrent: - devLogger.log("[DEBUG] torrent.progress =", torrent.progress); - devLogger.log("[DEBUG] torrent.numPeers =", torrent.numPeers); - devLogger.log("[DEBUG] torrent.downloadSpeed =", torrent.downloadSpeed); - devLogger.log("[DEBUG] torrent.downloaded =", torrent.downloaded); - devLogger.log("[DEBUG] torrent.length =", torrent.length); - devLogger.log("[DEBUG] torrent.ready =", torrent.ready); - - // Use "Complete" vs. "Downloading" as the textual status. - if (this.videoModal) { - const fullyDownloaded = torrent.progress >= 1; - this.videoModal.updateStatus(fullyDownloaded ? "Complete" : "Downloading"); - - const percent = (torrent.progress * 100).toFixed(2); - this.videoModal.updateProgress(`${percent}%`); - this.videoModal.updatePeers(`Peers: ${torrent.numPeers}`); - - const kb = (torrent.downloadSpeed / 1024).toFixed(2); - this.videoModal.updateSpeed(`${kb} KB/s`); - - const downloadedMb = (torrent.downloaded / (1024 * 1024)).toFixed(2); - const lengthMb = (torrent.length / (1024 * 1024)).toFixed(2); - this.videoModal.updateDownloaded( - `${downloadedMb} MB / ${lengthMb} MB` - ); - - if (torrent.ready) { - this.videoModal.updateStatus("Ready to play"); - } + if (this.torrentStatusController) { + this.torrentStatusController.update(torrent); } } diff --git a/js/ui/torrentStatusController.js b/js/ui/torrentStatusController.js new file mode 100644 index 00000000..855aae11 --- /dev/null +++ b/js/ui/torrentStatusController.js @@ -0,0 +1,73 @@ +import { devLogger } from "../utils/logger.js"; + +export default class TorrentStatusController { + constructor({ getVideoModal, onRemovePoster }) { + if (typeof getVideoModal !== "function") { + throw new Error("TorrentStatusController requires a getVideoModal accessor."); + } + this.getVideoModal = getVideoModal; + this.onRemovePoster = typeof onRemovePoster === "function" ? onRemovePoster : () => {}; + } + + update(torrent) { + devLogger.log("[DEBUG] TorrentStatusController.update called with torrent:", torrent); + + if (!torrent) { + devLogger.log("[DEBUG] torrent is null/undefined!"); + return; + } + + if (torrent.ready || (typeof torrent.progress === "number" && torrent.progress > 0)) { + // Belt-and-suspenders: if WebTorrent reports progress but the DOM events + // failed to fire we still rip off the loading GIF. This regression has + // bitten us in past releases, so the extra clear is intentional. + this.onRemovePoster( + torrent.ready ? "torrent-ready-flag" : "torrent-progress" + ); + } + + // Log only fields that actually exist on the torrent: + devLogger.log("[DEBUG] torrent.progress =", torrent.progress); + devLogger.log("[DEBUG] torrent.numPeers =", torrent.numPeers); + devLogger.log("[DEBUG] torrent.downloadSpeed =", torrent.downloadSpeed); + devLogger.log("[DEBUG] torrent.downloaded =", torrent.downloaded); + devLogger.log("[DEBUG] torrent.length =", torrent.length); + devLogger.log("[DEBUG] torrent.ready =", torrent.ready); + + const videoModal = this.getVideoModal(); + + // Use "Complete" vs. "Downloading" as the textual status. + if (videoModal) { + const fullyDownloaded = torrent.progress >= 1; + if (typeof videoModal.updateStatus === "function") { + videoModal.updateStatus(fullyDownloaded ? "Complete" : "Downloading"); + } + + if (typeof videoModal.updateProgress === "function") { + const percent = (torrent.progress * 100).toFixed(2); + videoModal.updateProgress(`${percent}%`); + } + + if (typeof videoModal.updatePeers === "function") { + videoModal.updatePeers(`Peers: ${torrent.numPeers}`); + } + + if (typeof videoModal.updateSpeed === "function") { + const kb = (torrent.downloadSpeed / 1024).toFixed(2); + videoModal.updateSpeed(`${kb} KB/s`); + } + + if (typeof videoModal.updateDownloaded === "function") { + const downloadedMb = (torrent.downloaded / (1024 * 1024)).toFixed(2); + const lengthMb = (torrent.length / (1024 * 1024)).toFixed(2); + videoModal.updateDownloaded( + `${downloadedMb} MB / ${lengthMb} MB` + ); + } + + if (torrent.ready && typeof videoModal.updateStatus === "function") { + videoModal.updateStatus("Ready to play"); + } + } + } +} diff --git a/tests/ui/torrentStatusController.test.mjs b/tests/ui/torrentStatusController.test.mjs new file mode 100644 index 00000000..bb2e608b --- /dev/null +++ b/tests/ui/torrentStatusController.test.mjs @@ -0,0 +1,66 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import TorrentStatusController from "../../js/ui/torrentStatusController.js"; + +test("TorrentStatusController throws if accessor is missing", (t) => { + assert.throws(() => new TorrentStatusController({}), /accessor/); +}); + +test("TorrentStatusController updates video modal and calls onRemovePoster", (t) => { + let modalState = {}; + let posterRemoved = false; + + const mockModal = { + updateStatus: (msg) => { modalState.status = msg; }, + updateProgress: (msg) => { modalState.progress = msg; }, + updatePeers: (msg) => { modalState.peers = msg; }, + updateSpeed: (msg) => { modalState.speed = msg; }, + updateDownloaded: (msg) => { modalState.downloaded = msg; }, + }; + + const onRemovePoster = (reason) => { + posterRemoved = reason; + }; + + const controller = new TorrentStatusController({ + getVideoModal: () => mockModal, + onRemovePoster + }); + + // Test basic update + controller.update({ + ready: false, + progress: 0.5, + numPeers: 5, + downloadSpeed: 1024 * 100, // 100 KB/s + downloaded: 1024 * 1024 * 10, // 10 MB + length: 1024 * 1024 * 100 // 100 MB + }); + + assert.equal(modalState.status, "Downloading"); + assert.equal(modalState.progress, "50.00%"); + assert.equal(modalState.peers, "Peers: 5"); + assert.equal(modalState.speed, "100.00 KB/s"); + assert.equal(modalState.downloaded, "10.00 MB / 100.00 MB"); + assert.equal(posterRemoved, "torrent-progress"); + + // Test complete + controller.update({ + ready: true, + progress: 1, + numPeers: 0, + downloadSpeed: 0, + downloaded: 1024 * 1024 * 100, + length: 1024 * 1024 * 100 + }); + + // It sets "Complete" then "Ready to play" if ready is true + assert.equal(modalState.status, "Ready to play"); + assert.equal(modalState.progress, "100.00%"); + assert.equal(posterRemoved, "torrent-ready-flag"); +}); + +test("TorrentStatusController handles missing modal gracefully", (t) => { + const controller = new TorrentStatusController({ getVideoModal: () => null }); + assert.doesNotThrow(() => controller.update({ progress: 0.5 })); +});