Merge pull request #646 from PR0M3TH3AN/codex/update-video-modal-handling-and-testing

Fix video modal rehydration and add regression test
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-18 11:53:50 -04:00
commit be6f5a411c
2 files changed
+501 -26

No files matched your search

+84 -26
View File
@@ -1423,36 +1423,42 @@ 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;
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);
}
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;
}
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 +1477,31 @@ 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;
if (
this.videoModal &&
typeof this.videoModal.setVideoElement === "function"
) {
this.videoModal.setVideoElement(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,
};
}
@@ -6233,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,
@@ -6258,13 +6278,45 @@ 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;
if (!modalVideoEl) {
const modalVideoFromController =
this.videoModal && typeof this.videoModal.getVideoElement === "function"
? this.videoModal.getVideoElement()
: null;
if (modalVideoFromController && modalVideoFromController !== modalVideoEl) {
modalVideoEl = modalVideoFromController;
this.modalVideo = 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:",
@@ -6302,6 +6354,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();
+417
View File
@@ -114,6 +114,423 @@ 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) {
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);
const listeners = new Map();
const session = {
matchesRequestSignature(signature) {
return signature === options.requestSignature;
},
getPlaybackConfig: () => ({}),
getMagnetForPlayback: () => "",
getFallbackMagnet: () => "",
getMagnetProvided: () => Boolean(options.magnet),
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,
);
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;
},
};
}
async function setupPlaybackHarness() {
const dom = new JSDOM(
"<!DOCTYPE html><html><body><div id=\"app\"></div></body></html>",
{ 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(
"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();