From b0cc77331545a83715f764738512fa8561baeefe Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 18 Oct 2025 11:39:38 -0400 Subject: [PATCH] Handle torrent cleanup before hosted playback --- js/app.js | 50 +++++++++ tests/video-modal-accessibility.test.mjs | 130 ++++++++++++++++++++++- 2 files changed, 176 insertions(+), 4 deletions(-) diff --git a/js/app.js b/js/app.js index 41b221e1..3743dc13 100644 --- a/js/app.js +++ b/js/app.js @@ -1434,6 +1434,21 @@ class Application { if (!needsRehydrate) { this.modalVideo = existingVideoElement; + + if ( + existingVideoElement && + this.videoModal && + typeof this.videoModal.setVideoElement === "function" + ) { + const modalVideo = + typeof this.videoModal.getVideoElement === "function" + ? this.videoModal.getVideoElement() + : null; + if (modalVideo !== existingVideoElement) { + this.videoModal.setVideoElement(existingVideoElement); + } + } + return { root: existingRoot, videoElement: existingVideoElement, @@ -1468,6 +1483,12 @@ class Application { if (readyVideoConnected) { this.modalVideo = readyVideoElement; + if ( + this.videoModal && + typeof this.videoModal.setVideoElement === "function" + ) { + this.videoModal.setVideoElement(readyVideoElement); + } } else { this.modalVideo = null; } @@ -6231,6 +6252,7 @@ class Application { } const sanitizedUrl = typeof url === "string" ? url.trim() : ""; const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : ""; + const previousSource = this.playSource || null; const requestSignature = JSON.stringify({ url: sanitizedUrl, magnet: trimmedMagnet, @@ -6256,7 +6278,35 @@ class Application { await this.waitForCleanup(); this.cancelPendingViewLogging(); + if ( + previousSource === "torrent" && + sanitizedUrl && + this.playbackService && + this.playbackService.torrentClient && + typeof this.playbackService.torrentClient.cleanup === "function" + ) { + try { + this.log( + "[playVideoWithFallback] Previous playback used WebTorrent; cleaning up before preparing hosted session.", + ); + await this.playbackService.torrentClient.cleanup(); + } catch (error) { + devLogger.warn( + "[playVideoWithFallback] Pre-playback torrent cleanup threw:", + error, + ); + } + } + let modalVideoEl = this.modalVideo; + const modalVideoFromController = + this.videoModal && typeof this.videoModal.getVideoElement === "function" + ? this.videoModal.getVideoElement() + : null; + if (modalVideoFromController && modalVideoFromController !== modalVideoEl) { + modalVideoEl = modalVideoFromController; + this.modalVideo = modalVideoEl; + } if (!modalVideoEl) { try { const { videoElement } = await this.ensureVideoModalReady({ diff --git a/tests/video-modal-accessibility.test.mjs b/tests/video-modal-accessibility.test.mjs index 4d28312d..81893fe8 100644 --- a/tests/video-modal-accessibility.test.mjs +++ b/tests/video-modal-accessibility.test.mjs @@ -183,16 +183,28 @@ function createVideoModalPlaybackStub(document) { } function createPlaybackService(records) { + const torrentClient = { + cleanupCalls: 0, + async cleanup() { + this.cleanupCalls += 1; + return undefined; + }, + }; + return { + torrentClient, createSession(options) { const record = { options, videoElement: options.videoElement, isConnectedDuringStart: false, + source: null, }; records.push(record); - return { + const listeners = new Map(); + + const session = { matchesRequestSignature(signature) { return signature === options.requestSignature; }, @@ -200,16 +212,56 @@ function createPlaybackService(records) { getMagnetForPlayback: () => "", getFallbackMagnet: () => "", getMagnetProvided: () => Boolean(options.magnet), - on: () => () => {}, - start: () => { + on(eventName, handler) { + if (typeof handler !== "function") { + return () => {}; + } + if (!listeners.has(eventName)) { + listeners.set(eventName, new Set()); + } + const handlers = listeners.get(eventName); + handlers.add(handler); + return () => { + handlers.delete(handler); + if (!handlers.size) { + listeners.delete(eventName); + } + }; + }, + emit(eventName, detail) { + const handlers = listeners.get(eventName); + if (!handlers || !handlers.size) { + return; + } + for (const handler of Array.from(handlers)) { + try { + handler(detail); + } catch (error) { + console.warn( + `[tests] session listener for "${eventName}" threw`, + error, + ); + } + } + }, + async start() { const element = options.videoElement || null; record.videoElement = element; record.isConnectedDuringStart = Boolean( element && element.isConnected, ); - return Promise.resolve({ source: "url" }); + const hasUrl = Boolean(options.url && options.url.trim()); + const hasMagnet = Boolean(options.magnet && options.magnet.trim()); + record.source = hasUrl ? "url" : hasMagnet ? "torrent" : null; + this.emit("video-prepared", { videoElement: element }); + if (record.source) { + this.emit("sourcechange", { source: record.source }); + } + return { source: record.source }; }, }; + + return session; }, }; } @@ -409,6 +461,76 @@ test( }, ); +test( + "torrent-only playback resets the modal video before the next hosted session", + async (t) => { + const { app, modalStub, playbackRecords, cleanup } = + await setupPlaybackHarness(); + t.after(cleanup); + + const magnet = `magnet:?xt=urn:btih:${"a".repeat(40)}`; + await app.playVideoWithFallback({ magnet }); + + assert.equal( + playbackRecords.length, + 1, + "magnet playback should create the first session", + ); + const torrentRecord = playbackRecords[0]; + assert.equal( + torrentRecord.source, + "torrent", + "first session should report torrent playback", + ); + const torrentVideo = torrentRecord.videoElement; + assert.ok(torrentVideo, "torrent session should supply a video element"); + assert.equal( + Boolean(torrentVideo?.isConnected), + true, + "torrent playback should start with a connected element", + ); + + const playbackUrl = "https://cdn.example.com/next-video.mp4"; + await app.playVideoWithFallback({ url: playbackUrl }); + + assert.equal( + playbackRecords.length, + 2, + "subsequent hosted playback should create a second session", + ); + const hostedRecord = playbackRecords[1]; + assert.equal( + hostedRecord.source, + "url", + "second session should report hosted playback", + ); + assert.ok(hostedRecord.videoElement, "hosted playback needs a video element"); + assert.equal( + Boolean(hostedRecord.videoElement?.isConnected), + true, + "hosted playback should reuse a connected element", + ); + assert.notStrictEqual( + hostedRecord.videoElement, + torrentVideo, + "hosted playback should swap in a refreshed video element", + ); + assert.strictEqual( + modalStub.getVideoElement(), + hostedRecord.videoElement, + "modal should expose the refreshed hosted video element", + ); + assert.ok( + modalStub.setVideoElementCalls.includes(hostedRecord.videoElement), + "modal controller should receive the refreshed hosted element", + ); + assert.ok( + app.playbackService.torrentClient.cleanupCalls >= 1, + "torrent cleanup should run before the hosted playback begins", + ); + }, +); + test("backdrop data-dismiss closes the modal and restores focus", async (t) => { const { window, document, modal, playerModal, trigger, cleanup } = await setupModal();