From 0e5571d5a896ed402b25fa929b8deae5ecae63a1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 18 Oct 2025 10:44:33 -0400 Subject: [PATCH 1/3] Fix video modal rehydration and add regression test --- js/app.js | 58 ++--- tests/video-modal-accessibility.test.mjs | 295 +++++++++++++++++++++++ 2 files changed, 326 insertions(+), 27 deletions(-) diff --git a/js/app.js b/js/app.js index f1959c2d..41b221e1 100644 --- a/js/app.js +++ b/js/app.js @@ -1423,36 +1423,27 @@ class Application { const existingRoot = getRoot(); const existingVideoElement = getVideoElement(); + const rootConnected = Boolean(existingRoot && existingRoot.isConnected); + const hasVideoElement = Boolean(existingVideoElement); + const videoConnected = Boolean( + existingVideoElement && existingVideoElement.isConnected, + ); - if (existingRoot && existingRoot.isConnected) { - if (!existingVideoElement && ensureVideoElement) { - if (typeof this.videoModal.load === "function") { - await this.videoModal.load(); - } + const needsRehydrate = + !rootConnected || !hasVideoElement || !videoConnected; - const rehydratedRoot = getRoot(); - const rehydratedVideoElement = getVideoElement(); - - if (!rehydratedVideoElement) { - throw new Error("Video modal video element is not ready."); - } - - this.modalVideo = rehydratedVideoElement; - return { - root: rehydratedRoot || existingRoot, - videoElement: rehydratedVideoElement, - }; - } - - if (existingVideoElement) { - this.modalVideo = existingVideoElement; - } + if (!needsRehydrate) { + this.modalVideo = existingVideoElement; return { root: existingRoot, videoElement: existingVideoElement, }; } + if (!videoConnected) { + this.modalVideo = null; + } + if (!this.videoModalReadyPromise) { if (typeof this.videoModal.load !== "function") { throw new Error("Video modal does not expose a load() method."); @@ -1471,18 +1462,25 @@ class Application { const readyRoot = getRoot(); const readyVideoElement = getVideoElement(); + const readyVideoConnected = Boolean( + readyVideoElement && readyVideoElement.isConnected, + ); - if (ensureVideoElement && !readyVideoElement) { - throw new Error("Video modal video element is missing after load()."); + if (readyVideoConnected) { + this.modalVideo = readyVideoElement; + } else { + this.modalVideo = null; } - if (readyVideoElement) { - this.modalVideo = readyVideoElement; + if (ensureVideoElement && !readyVideoConnected) { + throw new Error( + "Video modal video element is missing after load().", + ); } return { root: readyRoot, - videoElement: readyVideoElement, + videoElement: readyVideoConnected ? readyVideoElement : null, }; } @@ -6302,6 +6300,12 @@ class Application { replaceNode: true, }); if (refreshedModal) { + if ( + this.videoModal && + typeof this.videoModal.setVideoElement === "function" + ) { + this.videoModal.setVideoElement(refreshedModal); + } this.modalVideo = refreshedModal; modalVideoEl = this.modalVideo; this.applyModalLoadingPoster(); diff --git a/tests/video-modal-accessibility.test.mjs b/tests/video-modal-accessibility.test.mjs index ec4a1438..4d28312d 100644 --- a/tests/video-modal-accessibility.test.mjs +++ b/tests/video-modal-accessibility.test.mjs @@ -114,6 +114,301 @@ async function setupModal({ lazyLoad = false } = {}) { return { window, document, modal, playerModal, trigger, cleanup }; } +function createVideoModalPlaybackStub(document) { + const root = document.createElement("div"); + root.id = "playerModal"; + const panel = document.createElement("div"); + panel.className = "bv-modal__panel"; + root.append(panel); + document.body.append(root); + + let currentVideo = document.createElement("video"); + panel.append(currentVideo); + + const listeners = new Map(); + + return { + loadCalls: 0, + setVideoElementCalls: [], + getRoot() { + return root; + }, + getVideoElement() { + return currentVideo; + }, + setVideoElement(element) { + this.setVideoElementCalls.push(element); + currentVideo = element; + }, + load() { + this.loadCalls += 1; + if (!root.isConnected) { + document.body.append(root); + } + if (!currentVideo || !currentVideo.isConnected) { + currentVideo = document.createElement("video"); + panel.replaceChildren(currentVideo); + } + }, + open() {}, + close() {}, + clearPosterCleanup() {}, + applyLoadingPoster() {}, + resetStats() {}, + setCopyEnabled() {}, + setShareEnabled() {}, + updateStatus() {}, + updateViewCountLabel() {}, + setViewCountPointer() {}, + getViewCountElement() { + return null; + }, + forceRemovePoster() { + return true; + }, + addEventListener(type, handler) { + listeners.set(type, handler); + }, + removeEventListener(type) { + listeners.delete(type); + }, + dispatchEvent(event) { + const handler = listeners.get(event?.type); + if (typeof handler === "function") { + handler(event); + } + return true; + }, + }; +} + +function createPlaybackService(records) { + return { + createSession(options) { + const record = { + options, + videoElement: options.videoElement, + isConnectedDuringStart: false, + }; + records.push(record); + + return { + matchesRequestSignature(signature) { + return signature === options.requestSignature; + }, + getPlaybackConfig: () => ({}), + getMagnetForPlayback: () => "", + getFallbackMagnet: () => "", + getMagnetProvided: () => Boolean(options.magnet), + on: () => () => {}, + start: () => { + const element = options.videoElement || null; + record.videoElement = element; + record.isConnectedDuringStart = Boolean( + element && element.isConnected, + ); + return Promise.resolve({ source: "url" }); + }, + }; + }, + }; +} + +async function setupPlaybackHarness() { + const dom = new JSDOM( + "
", + { url: "https://example.com/", pretendToBeVisual: true }, + ); + + const { window } = dom; + const { document } = window; + + globalThis.window = window; + globalThis.document = document; + globalThis.HTMLElement = window.HTMLElement; + globalThis.HTMLVideoElement = window.HTMLVideoElement; + globalThis.Element = window.Element; + globalThis.CustomEvent = window.CustomEvent; + globalThis.Event = window.Event; + globalThis.Node = window.Node; + globalThis.EventTarget = window.EventTarget; + globalThis.navigator = window.navigator; + globalThis.location = window.location; + globalThis.KeyboardEvent = window.KeyboardEvent; + globalThis.MouseEvent = window.MouseEvent; + globalThis.self = window; + + window.NostrTools = { + nip19: { + neventEncode: () => "nevent1example", + npubEncode: () => "npub1example", + decode: () => ({ type: "nevent", data: { id: "f".repeat(64) } }), + }, + }; + + applyDesignSystemAttributes(document); + + const modalStub = createVideoModalPlaybackStub(document); + const playbackRecords = []; + const playbackService = createPlaybackService(playbackRecords); + + const services = { + nostrService: { + getVideoSubscription: () => null, + getVideosMap: () => new Map(), + on: () => () => {}, + }, + feedEngine: { + run: () => {}, + registerFeed: () => {}, + getFeedDefinition: () => null, + }, + playbackService, + authService: { + on: () => () => {}, + }, + }; + + const helpers = { + mediaLoaderFactory: () => ({ observe() {}, unobserve() {} }), + }; + + const { Application } = await import("../js/app.js"); + const app = new Application({ + services, + helpers, + ui: { videoModal: () => modalStub }, + }); + + app.showError = () => {}; + app.showSuccess = () => {}; + app.showStatus = () => {}; + app.cleanup = () => Promise.resolve(); + app.waitForCleanup = () => Promise.resolve(); + app.cancelPendingViewLogging = () => {}; + app.clearActiveIntervals = () => {}; + app.preparePlaybackLogging = () => {}; + app.teardownModalViewCountSubscription = () => {}; + app.autoplayModalVideo = () => {}; + app.probeUrl = () => Promise.resolve({ ok: true }); + + const cleanup = () => { + try { + app.destroy?.(); + } catch (error) { + console.warn("[tests] app.destroy failed", error); + } + try { + resetRuntimeFlags(); + } catch (error) { + console.warn("[tests] failed to reset runtime flags", error); + } + dom.window.close(); + delete globalThis.window; + delete globalThis.document; + delete globalThis.HTMLElement; + delete globalThis.HTMLVideoElement; + delete globalThis.Element; + delete globalThis.CustomEvent; + delete globalThis.Event; + delete globalThis.Node; + delete globalThis.EventTarget; + delete globalThis.navigator; + delete globalThis.location; + delete globalThis.KeyboardEvent; + delete globalThis.MouseEvent; + delete globalThis.self; + }; + + return { app, modalStub, playbackRecords, window, cleanup }; +} + +test( + "video modal rehydrates and plays through a connected element across views", + async (t) => { + const { app, modalStub, playbackRecords, window, cleanup } = + await setupPlaybackHarness(); + t.after(cleanup); + + const playbackUrl = "https://cdn.example.com/video.mp4"; + app.currentVideo = { + id: "event123", + pubkey: "f".repeat(64), + tags: [], + title: "Test Video", + url: playbackUrl, + magnet: "", + }; + + await app.playVideoWithFallback({ url: playbackUrl }); + + assert.equal( + playbackRecords.length, + 1, + "first playback should create a session", + ); + const firstRecord = playbackRecords[0]; + assert.ok(firstRecord.videoElement, "first playback needs a video element"); + assert.equal( + firstRecord.isConnectedDuringStart, + true, + "initial playback should start with a connected element", + ); + + const initialVideoElement = firstRecord.videoElement; + assert.ok(initialVideoElement.isConnected); + + initialVideoElement.remove(); + assert.equal( + initialVideoElement.isConnected, + false, + "detaching the node should mark it disconnected", + ); + + window.location.hash = "#view=channel-profile&npub=testnpub"; + + await app.playVideoWithFallback({ url: playbackUrl }); + + assert.equal( + playbackRecords.length, + 2, + "second playback should create a new session", + ); + const secondRecord = playbackRecords[1]; + assert.ok( + secondRecord.videoElement, + "rehydrated playback should supply a video element", + ); + assert.notStrictEqual( + secondRecord.videoElement, + initialVideoElement, + "playback should use a refreshed video element", + ); + assert.equal( + secondRecord.isConnectedDuringStart, + true, + "rehydrated playback should start successfully", + ); + assert.ok( + secondRecord.videoElement.isConnected, + "refreshed video element should be connected", + ); + assert.strictEqual( + modalStub.getVideoElement(), + secondRecord.videoElement, + "modal should expose the refreshed video element", + ); + assert.ok( + modalStub.setVideoElementCalls.includes(secondRecord.videoElement), + "setVideoElement should receive the refreshed clone", + ); + assert.ok( + modalStub.loadCalls >= 1, + "modal should reload when the previous element is detached", + ); + }, +); + test("backdrop data-dismiss closes the modal and restores focus", async (t) => { const { window, document, modal, playerModal, trigger, cleanup } = await setupModal(); 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 2/3] 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(); From 2413c74f8d13a70593569948e0733cee1b070564 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 18 Oct 2025 11:51:36 -0400 Subject: [PATCH 3/3] Rehydrate modal video when previous element is detached --- js/app.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/js/app.js b/js/app.js index 3743dc13..cd0ab0c1 100644 --- a/js/app.js +++ b/js/app.js @@ -6307,12 +6307,16 @@ class Application { modalVideoEl = modalVideoFromController; this.modalVideo = modalVideoEl; } - if (!modalVideoEl) { + const modalVideoConnected = Boolean( + modalVideoEl && modalVideoEl.isConnected, + ); + if (!modalVideoEl || !modalVideoConnected) { try { const { videoElement } = await this.ensureVideoModalReady({ ensureVideoElement: true, }); modalVideoEl = videoElement; + this.modalVideo = modalVideoEl; } catch (error) { this.log( "[playVideoWithFallback] Failed to load video modal before playback:",