mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 00:12:25 +00:00
Merge pull request #2518 from PR0M3TH3AN/agents/weekly/refactor-agent-video-modal-13827376005693961609
Refactor Video Modal Event Handling to Controller
This commit is contained in:
4 files changed
+385
-124
No files matched your search
@@ -0,0 +1,32 @@
|
||||
# Weekly Agent Task Completion: refactor-agent
|
||||
|
||||
- **Agent Name**: refactor-agent
|
||||
- **Cadence**: weekly
|
||||
- **Date**: 2026-02-15
|
||||
- **Status**: Completed
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
Refactored `js/app.js` to extract video modal event handling logic into `js/ui/videoModalController.js`.
|
||||
|
||||
### Key Changes
|
||||
- Modified `js/ui/videoModalController.js`:
|
||||
- Added `bindEvents()` method to attach event listeners to the video modal component.
|
||||
- Implemented handler methods: `handleShareNostr`, `handleCopyCdn`, `handleCopyMagnetCallback`, `handleSourceSwitch`.
|
||||
- Updated constructor to accept necessary callbacks and dependencies (`showSuccess`, `playVideoWithFallback`, etc.).
|
||||
- Modified `js/app.js`:
|
||||
- Removed `_bindVideoModalEvents` method.
|
||||
- Updated `VideoModalController` instantiation to pass new callbacks.
|
||||
- Called `this.videoModalController.bindEvents()` in `_initModals`.
|
||||
- Added `tests/unit/ui/videoModalController.test.mjs` to test the new controller logic.
|
||||
|
||||
## Verification
|
||||
- Ran `npm run lint`: Passed.
|
||||
- Ran `npm run test:unit:shard1`: Passed.
|
||||
- Ran new unit tests: Passed.
|
||||
|
||||
## AGENTS.md Checklist Compliance
|
||||
- **Separation of Concerns**: Moved DOM event handling logic from `bitvidApp` to `VideoModalController`.
|
||||
- **State Management**: Controller reads state via getters (`getCurrentVideo`, `getPlaySource`) passed from App.
|
||||
- **Execution Flow**: Controller invokes callbacks (`playVideoWithFallback`) provided by App.
|
||||
- **Atomicity**: Change is limited to Video Modal event handling extraction.
|
||||
@@ -309,9 +309,19 @@ class Application {
|
||||
getVideoModal: () => this.videoModal,
|
||||
callbacks: {
|
||||
showError: (msg) => this.showError(msg),
|
||||
showSuccess: (msg) => this.showSuccess(msg),
|
||||
showStatus: (msg, opts) => this.showStatus(msg, opts),
|
||||
getLastModalTrigger: () => this.lastModalTrigger,
|
||||
setLastModalTrigger: (val) => this.setLastModalTrigger(val),
|
||||
getCurrentVideo: () => this.currentVideo,
|
||||
getPlaySource: () => this.playSource,
|
||||
getStreamHealthSnapshots: () => this.streamHealthSnapshots,
|
||||
getCachedUrlHealth: (id, url) => this.getCachedUrlHealth(id, url),
|
||||
handleCopyMagnet: () => this.handleCopyMagnet(),
|
||||
openShareNostrModal: (opts) => this.openShareNostrModal(opts),
|
||||
playVideoWithFallback: (opts) => this.playVideoWithFallback(opts),
|
||||
attachMoreMenuHandlers: (container) =>
|
||||
this.attachMoreMenuHandlers(container),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -850,131 +860,11 @@ class Application {
|
||||
);
|
||||
}
|
||||
|
||||
_bindVideoModalEvents() {
|
||||
const modalRoot = this.videoModal.getRoot();
|
||||
if (modalRoot) {
|
||||
this.attachMoreMenuHandlers(modalRoot);
|
||||
}
|
||||
if (
|
||||
this.videoModal &&
|
||||
typeof this.videoModal.addEventListener === "function"
|
||||
) {
|
||||
this.videoModal.addEventListener("video:share-nostr", (event) => {
|
||||
this.openShareNostrModal({
|
||||
video: event?.detail?.video || null,
|
||||
triggerElement: event?.detail?.trigger || null,
|
||||
});
|
||||
});
|
||||
|
||||
this.videoModal.addEventListener("video:copy-cdn", (event) => {
|
||||
const video = event?.detail?.video || this.currentVideo;
|
||||
const url = video?.url || "";
|
||||
if (!url) {
|
||||
this.showError("No CDN link available to copy.");
|
||||
return;
|
||||
}
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => this.showSuccess("CDN link copied to clipboard!"))
|
||||
.catch(() => this.showError("Failed to copy CDN link."));
|
||||
});
|
||||
|
||||
this.videoModal.addEventListener("video:copy-magnet", () => {
|
||||
this.handleCopyMagnet();
|
||||
});
|
||||
|
||||
this.videoModal.addEventListener("playback:switch-source", (event) => {
|
||||
const detail = event?.detail || {};
|
||||
const { source } = detail;
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
const modalVideo = detail?.video || null;
|
||||
const fallbackVideo = this.currentVideo || null;
|
||||
const video = {
|
||||
...(fallbackVideo || {}),
|
||||
...(modalVideo || {}),
|
||||
};
|
||||
const urlCandidate =
|
||||
typeof video.url === "string" ? video.url.trim() : "";
|
||||
const magnetCandidate =
|
||||
typeof video.magnet === "string" ? video.magnet.trim() : "";
|
||||
|
||||
if (!modalVideo && !fallbackVideo) {
|
||||
devLogger.warn("[app] Playback source switch missing video data.");
|
||||
return;
|
||||
}
|
||||
|
||||
const magnetAvailable = Boolean(magnetCandidate);
|
||||
const cachedStreamHealth =
|
||||
video?.id && this.streamHealthSnapshots instanceof Map
|
||||
? this.streamHealthSnapshots.get(video.id)
|
||||
: null;
|
||||
const cachedPeers = Number.isFinite(cachedStreamHealth?.peers)
|
||||
? cachedStreamHealth.peers
|
||||
: null;
|
||||
const hasActivePeers =
|
||||
cachedPeers === null ? null : cachedPeers > 0;
|
||||
const cachedUrlHealth =
|
||||
video?.id && urlCandidate
|
||||
? this.getCachedUrlHealth(video.id, urlCandidate)
|
||||
: null;
|
||||
const cdnUnavailable =
|
||||
!urlCandidate ||
|
||||
["offline", "timeout"].includes(cachedUrlHealth?.status);
|
||||
|
||||
if (source === "torrent" && !magnetAvailable) {
|
||||
userLogger.warn(
|
||||
"[app] Unable to switch to torrent playback: missing magnet.",
|
||||
);
|
||||
this.showError(
|
||||
"Torrent playback is unavailable for this video. No magnet was provided.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (source === "torrent" && hasActivePeers === false) {
|
||||
userLogger.warn(
|
||||
"[app] Switching to torrent playback despite 0 active peers detected.",
|
||||
);
|
||||
this.showStatus(
|
||||
"Warning: No peers detected. Playback may fail or stall.",
|
||||
{ autoHideMs: 5000 }
|
||||
);
|
||||
// Proceed anyway
|
||||
}
|
||||
|
||||
if (source === "url" && cdnUnavailable) {
|
||||
userLogger.warn(
|
||||
"[app] Unable to switch to CDN playback: URL unavailable.",
|
||||
);
|
||||
this.showError(
|
||||
"CDN playback is unavailable right now, staying on the torrent stream.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.playSource && source === this.playSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playVideoWithFallback({
|
||||
url: urlCandidate,
|
||||
magnet: magnetCandidate,
|
||||
forcedSource: source,
|
||||
}).catch((error) => {
|
||||
devLogger.warn(
|
||||
"[app] Failed to switch playback source:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_initModals() {
|
||||
const videoModalPromise = this.videoModal.load().then(() => {
|
||||
this._bindVideoModalEvents();
|
||||
if (this.videoModalController) {
|
||||
this.videoModalController.bindEvents();
|
||||
}
|
||||
});
|
||||
|
||||
const uploadModalPromise = this.uploadModal.load().catch((error) => {
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
|
||||
export default class VideoModalController {
|
||||
constructor({
|
||||
getVideoModal,
|
||||
callbacks: {
|
||||
showError,
|
||||
showSuccess,
|
||||
showStatus,
|
||||
getLastModalTrigger,
|
||||
setLastModalTrigger,
|
||||
getCurrentVideo,
|
||||
getPlaySource,
|
||||
getStreamHealthSnapshots,
|
||||
getCachedUrlHealth,
|
||||
handleCopyMagnet,
|
||||
openShareNostrModal,
|
||||
playVideoWithFallback,
|
||||
attachMoreMenuHandlers,
|
||||
} = {},
|
||||
}) {
|
||||
this.getVideoModal = getVideoModal;
|
||||
this.showError = showError;
|
||||
this.showSuccess = showSuccess;
|
||||
this.showStatus = showStatus;
|
||||
this.getLastModalTrigger = getLastModalTrigger;
|
||||
this.setLastModalTrigger = setLastModalTrigger;
|
||||
this.getCurrentVideo = getCurrentVideo;
|
||||
this.getPlaySource = getPlaySource;
|
||||
this.getStreamHealthSnapshots = getStreamHealthSnapshots;
|
||||
this.getCachedUrlHealth = getCachedUrlHealth;
|
||||
this.handleCopyMagnetCallback = handleCopyMagnet;
|
||||
this.openShareNostrModalCallback = openShareNostrModal;
|
||||
this.playVideoWithFallbackCallback = playVideoWithFallback;
|
||||
this.attachMoreMenuHandlersCallback = attachMoreMenuHandlers;
|
||||
|
||||
this.videoModalReadyPromise = null;
|
||||
}
|
||||
@@ -27,6 +45,178 @@ export default class VideoModalController {
|
||||
devLogger.log("[VideoModalController]", message, ...args);
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
if (!this.videoModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modalRoot =
|
||||
typeof this.videoModal.getRoot === "function"
|
||||
? this.videoModal.getRoot()
|
||||
: null;
|
||||
|
||||
if (modalRoot && this.attachMoreMenuHandlersCallback) {
|
||||
this.attachMoreMenuHandlersCallback(modalRoot);
|
||||
}
|
||||
|
||||
if (typeof this.videoModal.addEventListener === "function") {
|
||||
this.videoModal.addEventListener("video:share-nostr", (event) => {
|
||||
this.handleShareNostr(event);
|
||||
});
|
||||
|
||||
this.videoModal.addEventListener("video:copy-cdn", (event) => {
|
||||
this.handleCopyCdn(event);
|
||||
});
|
||||
|
||||
this.videoModal.addEventListener("video:copy-magnet", () => {
|
||||
if (this.handleCopyMagnetCallback) {
|
||||
this.handleCopyMagnetCallback();
|
||||
}
|
||||
});
|
||||
|
||||
this.videoModal.addEventListener("playback:switch-source", (event) => {
|
||||
this.handleSourceSwitch(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleShareNostr(event) {
|
||||
if (this.openShareNostrModalCallback) {
|
||||
this.openShareNostrModalCallback({
|
||||
video: event?.detail?.video || null,
|
||||
triggerElement: event?.detail?.trigger || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleCopyCdn(event) {
|
||||
const video =
|
||||
event?.detail?.video ||
|
||||
(this.getCurrentVideo ? this.getCurrentVideo() : null);
|
||||
const url = video?.url || "";
|
||||
if (!url) {
|
||||
if (this.showError) {
|
||||
this.showError("No CDN link available to copy.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
if (this.showSuccess) {
|
||||
this.showSuccess("CDN link copied to clipboard!");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (this.showError) {
|
||||
this.showError("Failed to copy CDN link.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleSourceSwitch(event) {
|
||||
const detail = event?.detail || {};
|
||||
const { source } = detail;
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modalVideo = detail?.video || null;
|
||||
const fallbackVideo = this.getCurrentVideo ? this.getCurrentVideo() : null;
|
||||
const video = {
|
||||
...(fallbackVideo || {}),
|
||||
...(modalVideo || {}),
|
||||
};
|
||||
const urlCandidate =
|
||||
typeof video.url === "string" ? video.url.trim() : "";
|
||||
const magnetCandidate =
|
||||
typeof video.magnet === "string" ? video.magnet.trim() : "";
|
||||
|
||||
if (!modalVideo && !fallbackVideo) {
|
||||
devLogger.warn(
|
||||
"[VideoModalController] Playback source switch missing video data.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const magnetAvailable = Boolean(magnetCandidate);
|
||||
const streamHealthSnapshots = this.getStreamHealthSnapshots
|
||||
? this.getStreamHealthSnapshots()
|
||||
: null;
|
||||
|
||||
const cachedStreamHealth =
|
||||
video?.id && streamHealthSnapshots instanceof Map
|
||||
? streamHealthSnapshots.get(video.id)
|
||||
: null;
|
||||
const cachedPeers = Number.isFinite(cachedStreamHealth?.peers)
|
||||
? cachedStreamHealth.peers
|
||||
: null;
|
||||
const hasActivePeers = cachedPeers === null ? null : cachedPeers > 0;
|
||||
|
||||
const cachedUrlHealth =
|
||||
video?.id && urlCandidate && this.getCachedUrlHealth
|
||||
? this.getCachedUrlHealth(video.id, urlCandidate)
|
||||
: null;
|
||||
const cdnUnavailable =
|
||||
!urlCandidate ||
|
||||
["offline", "timeout"].includes(cachedUrlHealth?.status);
|
||||
|
||||
if (source === "torrent" && !magnetAvailable) {
|
||||
userLogger.warn(
|
||||
"[VideoModalController] Unable to switch to torrent playback: missing magnet.",
|
||||
);
|
||||
if (this.showError) {
|
||||
this.showError(
|
||||
"Torrent playback is unavailable for this video. No magnet was provided.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (source === "torrent" && hasActivePeers === false) {
|
||||
userLogger.warn(
|
||||
"[VideoModalController] Switching to torrent playback despite 0 active peers detected.",
|
||||
);
|
||||
if (this.showStatus) {
|
||||
this.showStatus(
|
||||
"Warning: No peers detected. Playback may fail or stall.",
|
||||
{ autoHideMs: 5000 },
|
||||
);
|
||||
}
|
||||
// Proceed anyway
|
||||
}
|
||||
|
||||
if (source === "url" && cdnUnavailable) {
|
||||
userLogger.warn(
|
||||
"[VideoModalController] Unable to switch to CDN playback: URL unavailable.",
|
||||
);
|
||||
if (this.showError) {
|
||||
this.showError(
|
||||
"CDN playback is unavailable right now, staying on the torrent stream.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSource = this.getPlaySource ? this.getPlaySource() : null;
|
||||
if (currentSource && source === currentSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.playVideoWithFallbackCallback) {
|
||||
this.playVideoWithFallbackCallback({
|
||||
url: urlCandidate,
|
||||
magnet: magnetCandidate,
|
||||
forcedSource: source,
|
||||
}).catch((error) => {
|
||||
devLogger.warn(
|
||||
"[VideoModalController] Failed to switch playback source:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the modal and set the "Please stand by" poster on the video.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Mock globals before importing modules that might use them
|
||||
if (!globalThis.window) {
|
||||
globalThis.window = {
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
}
|
||||
if (!globalThis.navigator) {
|
||||
globalThis.navigator = {
|
||||
userAgent: "node",
|
||||
clipboard: {
|
||||
writeText: async () => {},
|
||||
}
|
||||
};
|
||||
}
|
||||
if (!globalThis.localStorage) {
|
||||
globalThis.localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// Now import the controller
|
||||
import VideoModalController from "../../../js/ui/videoModalController.js";
|
||||
|
||||
test("VideoModalController bindEvents attaches listeners", (t) => {
|
||||
const listeners = {};
|
||||
const mockVideoModal = {
|
||||
addEventListener: (event, handler) => {
|
||||
listeners[event] = handler;
|
||||
},
|
||||
getRoot: () => ({}),
|
||||
};
|
||||
|
||||
const controller = new VideoModalController({
|
||||
getVideoModal: () => mockVideoModal,
|
||||
callbacks: {
|
||||
attachMoreMenuHandlers: () => {},
|
||||
}
|
||||
});
|
||||
|
||||
controller.bindEvents();
|
||||
|
||||
assert.ok(listeners["video:share-nostr"], "should attach share handler");
|
||||
assert.ok(listeners["video:copy-cdn"], "should attach copy-cdn handler");
|
||||
assert.ok(listeners["video:copy-magnet"], "should attach copy-magnet handler");
|
||||
assert.ok(listeners["playback:switch-source"], "should attach switch-source handler");
|
||||
});
|
||||
|
||||
test("VideoModalController handleShareNostr triggers callback", (t) => {
|
||||
let called = false;
|
||||
let args = null;
|
||||
const controller = new VideoModalController({
|
||||
getVideoModal: () => ({}),
|
||||
callbacks: {
|
||||
openShareNostrModal: (opts) => {
|
||||
called = true;
|
||||
args = opts;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
controller.handleShareNostr({ detail: { video: { id: "1" } } });
|
||||
assert.equal(called, true);
|
||||
assert.equal(args.video.id, "1");
|
||||
});
|
||||
|
||||
test("VideoModalController handleCopyCdn triggers clipboard write", async (t) => {
|
||||
let clipboardText = "";
|
||||
if (!globalThis.navigator.clipboard) {
|
||||
Object.defineProperty(globalThis.navigator, 'clipboard', {
|
||||
value: {},
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
|
||||
const originalWriteText = globalThis.navigator.clipboard.writeText;
|
||||
globalThis.navigator.clipboard.writeText = async (text) => {
|
||||
clipboardText = text;
|
||||
};
|
||||
|
||||
t.after(() => {
|
||||
if (originalWriteText) {
|
||||
globalThis.navigator.clipboard.writeText = originalWriteText;
|
||||
} else {
|
||||
delete globalThis.navigator.clipboard.writeText;
|
||||
}
|
||||
});
|
||||
|
||||
let successCalled = false;
|
||||
const controller = new VideoModalController({
|
||||
getVideoModal: () => ({}),
|
||||
callbacks: {
|
||||
getCurrentVideo: () => ({ url: "https://example.com/video.mp4" }),
|
||||
showSuccess: () => { successCalled = true; }
|
||||
}
|
||||
});
|
||||
|
||||
await controller.handleCopyCdn({});
|
||||
assert.equal(clipboardText, "https://example.com/video.mp4");
|
||||
// We can't easily await the promise chain inside handleCopyCdn because it doesn't return the promise.
|
||||
// But since the mock writeText is synchronous (returns resolved promise), the callback should fire in microtask.
|
||||
// We can wait a bit.
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.equal(successCalled, true);
|
||||
});
|
||||
|
||||
test("VideoModalController handleCopyMagnet triggers callback", (t) => {
|
||||
let called = false;
|
||||
const controller = new VideoModalController({
|
||||
getVideoModal: () => ({}),
|
||||
callbacks: {
|
||||
handleCopyMagnet: () => { called = true; }
|
||||
}
|
||||
});
|
||||
|
||||
// Manually trigger the logic that would be called by the event listener
|
||||
if (controller.handleCopyMagnetCallback) {
|
||||
controller.handleCopyMagnetCallback();
|
||||
}
|
||||
assert.equal(called, true);
|
||||
});
|
||||
|
||||
test("VideoModalController handleSourceSwitch calls playVideoWithFallback", async (t) => {
|
||||
let called = false;
|
||||
let args = null;
|
||||
const controller = new VideoModalController({
|
||||
getVideoModal: () => ({}),
|
||||
callbacks: {
|
||||
getCurrentVideo: () => ({ id: "vid1", url: "http://url", magnet: "magnet:?" }),
|
||||
getStreamHealthSnapshots: () => new Map([["vid1", { peers: 5 }]]),
|
||||
getCachedUrlHealth: () => ({ status: "ok" }),
|
||||
playVideoWithFallback: async (opts) => {
|
||||
called = true;
|
||||
args = opts;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
controller.handleSourceSwitch({ detail: { source: "torrent", video: { id: "vid1" } } });
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.equal(called, true);
|
||||
assert.equal(args.forcedSource, "torrent");
|
||||
});
|
||||
Reference in new issue
Block a user