diff --git a/docs/agents/task-logs/weekly/2026-02-15_01-10-00_refactor-agent_completed.md b/docs/agents/task-logs/weekly/2026-02-15_01-10-00_refactor-agent_completed.md new file mode 100644 index 00000000..ae8c291f --- /dev/null +++ b/docs/agents/task-logs/weekly/2026-02-15_01-10-00_refactor-agent_completed.md @@ -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. diff --git a/js/app.js b/js/app.js index 93040450..d1f7042b 100644 --- a/js/app.js +++ b/js/app.js @@ -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) => { diff --git a/js/ui/videoModalController.js b/js/ui/videoModalController.js index 7f32497a..f7f3a331 100644 --- a/js/ui/videoModalController.js +++ b/js/ui/videoModalController.js @@ -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. */ diff --git a/tests/unit/ui/videoModalController.test.mjs b/tests/unit/ui/videoModalController.test.mjs new file mode 100644 index 00000000..9dd79423 --- /dev/null +++ b/tests/unit/ui/videoModalController.test.mjs @@ -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"); +});