diff --git a/js/app.js b/js/app.js index 70e6992d..5d2ec8b7 100644 --- a/js/app.js +++ b/js/app.js @@ -43,6 +43,7 @@ import { escapeHTML as escapeHtml, removeTrackingScripts, } from "./utils/domUtils.js"; +import { VideoModal } from "./ui/components/VideoModal.js"; import { getPubkey as getStoredPubkey, setPubkey as setStoredPubkey, @@ -110,13 +111,6 @@ const MAX_DISCUSSION_COUNT_VIDEOS = 24; const VIDEO_EVENT_KIND = 30078; const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__"; const HEX64_REGEX = /^[0-9a-f]{64}$/i; -// NOTE: The modal uses a "please stand by" animated poster while the torrent -// or direct URL boots up. We've regressed multiple times by forgetting to clear -// that poster once playback starts, which leaves the loading GIF covering the -// video even though audio is playing. Centralising the cleanup logic makes it -// easier to spot those regressions; see forceRemoveModalPoster() below. -const MODAL_LOADING_POSTER = "assets/gif/please-stand-by.gif"; - /** * Basic validation for BitTorrent magnet URIs. * @@ -543,29 +537,32 @@ class bitvidApp { this.speed = document.getElementById("speed") || null; this.downloaded = document.getElementById("downloaded") || null; - // Video player modal references (loaded via video-modal.html) - this.playerModal = null; - this.modalVideo = null; - this.modalStatus = null; - this.modalProgress = null; - this.modalPeers = null; - this.modalSpeed = null; - this.modalDownloaded = null; - this.closePlayerBtn = null; - this.videoTitle = null; - this.videoDescription = null; - this.videoTimestamp = null; - this.creatorAvatar = null; - this.creatorName = null; - this.creatorNpub = null; - this.copyMagnetBtn = null; - this.shareBtn = null; - this.modalZapBtn = null; - this.modalMoreBtn = null; - this.modalMoreMenu = null; - this.modalPosterCleanup = null; this.cleanupPromise = null; + this.videoModal = new VideoModal({ + removeTrackingScripts, + setGlobalModalState, + document, + logger: { + log: (message, ...args) => this.log(message, ...args), + }, + }); + this.videoModal.addEventListener("modal:close", () => { + this.hideModal(); + }); + this.videoModal.addEventListener("video:copy-magnet", () => { + this.handleCopyMagnet(); + }); + this.videoModal.addEventListener("video:share", () => { + this.shareActiveVideo(); + }); + this.videoModal.addEventListener("creator:navigate", () => { + this.openCreatorChannel(); + }); + this.videoModal.addEventListener("video:zap", () => { + window.alert("Zaps coming soon."); + }); + // Hide/Show Subscriptions Link this.subscriptionsLink = null; @@ -590,7 +587,6 @@ class bitvidApp { this._videoListClickHandler = null; this.viewCountSubscriptions = new Map(); this.modalViewCountUnsub = null; - this.videoViewCountEl = null; // Videos stored as a Map (key=event.id) this.videosMap = nostrService.getVideosMap(); @@ -681,6 +677,16 @@ class bitvidApp { ); } + get modalVideo() { + return this.videoModal ? this.videoModal.getVideoElement() : null; + } + + set modalVideo(videoElement) { + if (this.videoModal) { + this.videoModal.setVideoElement(videoElement); + } + } + loadSavedProfilesFromStorage() { const result = this.authService.loadSavedProfilesFromStorage(); this.renderSavedProfiles(); @@ -770,8 +776,11 @@ class bitvidApp { this.renderSavedProfiles(); // 1. Initialize the video modal (components/video-modal.html) - await this.initModal(); - this.updateModalElements(); + await this.videoModal.load(); + const modalRoot = this.videoModal.getRoot(); + if (modalRoot) { + this.attachMoreMenuHandlers(modalRoot); + } // 2. Initialize the upload modal (components/upload-modal.html) await this.initUploadModal(); @@ -866,154 +875,6 @@ class bitvidApp { } } - /** - * Initialize the main video modal (video-modal.html). - */ - async initModal() { - try { - const existingModal = document.getElementById("playerModal"); - if (existingModal) { - this.playerModal = existingModal; - return true; - } - - const resp = await fetch("components/video-modal.html"); - if (!resp.ok) { - throw new Error(`HTTP error! status: ${resp.status}`); - } - const html = await resp.text(); - - const modalContainer = document.getElementById("modalContainer"); - if (!modalContainer) { - throw new Error("Modal container element not found!"); - } - - // Instead of overwriting, we append a new DIV with the fetched HTML - const wrapper = document.createElement("div"); - wrapper.innerHTML = html; // set the markup - removeTrackingScripts(wrapper); - modalContainer.appendChild(wrapper); // append the markup - - // Now we can safely find elements inside: - const closeButton = document.getElementById("closeModal"); - if (!closeButton) { - throw new Error("Close button not found in video-modal!"); - } - closeButton.addEventListener("click", () => { - this.hideModal(); - }); - - // Setup scroll-based nav hide - const modalNav = document.getElementById("modalNav"); - const playerModal = document.getElementById("playerModal"); - if (!modalNav || !playerModal) { - throw new Error("Modal nav (#modalNav) or #playerModal not found!"); - } - const scrollRegion = - playerModal.querySelector(".player-modal__content") || playerModal; - let lastScrollY = 0; - scrollRegion.addEventListener("scroll", () => { - const currentScrollY = scrollRegion.scrollTop; - const shouldShowNav = - currentScrollY <= lastScrollY || currentScrollY < 50; - modalNav.style.transform = shouldShowNav - ? "translateY(0)" - : "translateY(-100%)"; - lastScrollY = currentScrollY; - }); - - this.videoViewCountEl = - document.getElementById("videoViewCount") || null; - - console.log("Video modal initialization successful"); - return true; - } catch (error) { - console.error("initModal failed:", error); - this.showError(`Failed to initialize video modal: ${error.message}`); - return false; - } - } - - updateModalElements() { - // Existing references - this.playerModal = document.getElementById("playerModal") || null; - this.modalVideo = document.getElementById("modalVideo") || null; - this.modalStatus = document.getElementById("modalStatus") || null; - this.modalProgress = document.getElementById("modalProgress") || null; - this.modalPeers = document.getElementById("modalPeers") || null; - this.modalSpeed = document.getElementById("modalSpeed") || null; - this.modalDownloaded = document.getElementById("modalDownloaded") || null; - this.closePlayerBtn = document.getElementById("closeModal") || null; - - this.videoTitle = document.getElementById("videoTitle") || null; - this.videoDescription = document.getElementById("videoDescription") || null; - this.videoTimestamp = document.getElementById("videoTimestamp") || null; - this.videoViewCountEl = - document.getElementById("videoViewCount") || null; - - // The two elements we want to make clickable - this.creatorAvatar = document.getElementById("creatorAvatar") || null; - this.creatorName = document.getElementById("creatorName") || null; - this.creatorNpub = document.getElementById("creatorNpub") || null; - - // Copy/Share buttons - this.copyMagnetBtn = document.getElementById("copyMagnetBtn") || null; - this.shareBtn = document.getElementById("shareBtn") || null; - this.modalZapBtn = document.getElementById("modalZapBtn") || null; - this.modalMoreBtn = document.getElementById("modalMoreBtn") || null; - this.modalMoreMenu = document.getElementById("moreDropdown-modal") || null; - this.setModalZapVisibility(false); - - // Attach existing event listeners for copy/share - if (this.copyMagnetBtn) { - this.copyMagnetBtn.addEventListener("click", () => { - this.handleCopyMagnet(); - }); - } - if (this.shareBtn) { - this.shareBtn.addEventListener("click", () => { - if (!this.currentVideo || !this.currentVideo.id) { - this.showError("No shareable video is loaded."); - return; - } - const shareUrl = this.buildShareUrlFromEventId(this.currentVideo.id); - if (!shareUrl) { - this.showError("Could not generate link."); - return; - } - navigator.clipboard - .writeText(shareUrl) - .then(() => this.showSuccess("Video link copied to clipboard!")) - .catch(() => this.showError("Failed to copy the link.")); - }); - } - if (this.modalZapBtn) { - this.modalZapBtn.addEventListener("click", () => { - window.alert("Zaps coming soon."); - }); - } - - // Add click handlers for avatar and name => channel profile - if (this.creatorAvatar) { - this.creatorAvatar.style.cursor = "pointer"; - this.creatorAvatar.addEventListener("click", () => { - this.openCreatorChannel(); - }); - } - if (this.creatorName) { - this.creatorName.style.cursor = "pointer"; - this.creatorName.addEventListener("click", () => { - this.openCreatorChannel(); - }); - } - - if (this.playerModal) { - this.attachMoreMenuHandlers(this.playerModal); - } - - this.syncModalMoreMenuData(); - } - goToProfile(pubkey) { if (!pubkey) { this.showError("No creator info available."); @@ -1053,95 +914,25 @@ class bitvidApp { /** * Show the modal and set the "Please stand by" poster on the video. */ - showModalWithPoster() { - if (this.playerModal) { - this.playerModal.style.display = "flex"; - this.playerModal.classList.remove("hidden"); - document.body.classList.add("modal-open"); - document.documentElement.classList.add("modal-open"); - const scrollRegion = - this.playerModal.querySelector(".player-modal__content") || - this.playerModal; - scrollRegion.scrollTop = 0; + showModalWithPoster(video = this.currentVideo) { + if (!this.videoModal) { + return; } - setGlobalModalState("player", true); - this.applyModalLoadingPoster(); + this.videoModal.open(video || this.currentVideo); } applyModalLoadingPoster() { - if (!this.modalVideo) { + if (!this.videoModal) { return; } - - if (typeof this.modalPosterCleanup === "function") { - this.modalPosterCleanup(); - this.modalPosterCleanup = null; - } - - const videoEl = this.modalVideo; - - const clearPoster = () => { - // Delegate to the shared helper so every code path clears the GIF in the - // same way. If we ever change how the poster works we only need to update - // forceRemoveModalPoster(). - this.forceRemoveModalPoster("playback-event"); - }; - - videoEl.addEventListener("loadeddata", clearPoster); - videoEl.addEventListener("playing", clearPoster); - - videoEl.poster = MODAL_LOADING_POSTER; - - this.modalPosterCleanup = () => { - videoEl.removeEventListener("loadeddata", clearPoster); - videoEl.removeEventListener("playing", clearPoster); - }; + this.videoModal.applyLoadingPoster(); } - /** - * Forcefully strip the modal's loading poster. - * - * The video modal shows an animated "please stand by" GIF while we wait for a - * `loadeddata` or `playing` event. Historically, small refactors would remove - * the matching cleanup path which left the GIF covering real playback. By - * routing every caller through this helper we can add defensive clears (for - * example when WebTorrent reports progress) without duplicating poster logic - * all over the file. - * - * @param {string} reason A debugging hint that documents why the poster was - * cleared. - * @returns {boolean} `true` if a poster attribute/value was removed. - */ forceRemoveModalPoster(reason = "manual-clear") { - if (!this.modalVideo) { + if (!this.videoModal) { return false; } - - const videoEl = this.modalVideo; - - if (typeof this.modalPosterCleanup === "function") { - this.modalPosterCleanup(); - this.modalPosterCleanup = null; - } - - const hadPoster = - videoEl.hasAttribute("poster") || - (typeof videoEl.poster === "string" && videoEl.poster !== ""); - - if (!hadPoster) { - return false; - } - - videoEl.poster = ""; - if (videoEl.hasAttribute("poster")) { - videoEl.removeAttribute("poster"); - } - - console.debug( - `[bitvidApp] Cleared modal loading poster (${reason}).` - ); - - return true; + return this.videoModal.forceRemovePoster(reason); } /** @@ -2512,16 +2303,15 @@ class bitvidApp { } } this.modalViewCountUnsub = null; - if (this.videoViewCountEl) { - this.videoViewCountEl.textContent = "– views"; - if (this.videoViewCountEl.dataset?.viewPointer) { - delete this.videoViewCountEl.dataset.viewPointer; - } + if (this.videoModal) { + this.videoModal.updateViewCountLabel("– views"); + this.videoModal.setViewCountPointer(null); } } subscribeModalViewCount(pointer, pointerKey) { - if (!this.videoViewCountEl) { + const viewEl = this.videoModal?.getViewCountElement() || null; + if (!viewEl) { return; } @@ -2531,23 +2321,35 @@ class bitvidApp { return; } - this.videoViewCountEl.textContent = "Loading views…"; + if (this.videoModal) { + this.videoModal.updateViewCountLabel("Loading views…"); + this.videoModal.setViewCountPointer(pointerKey); + } try { const token = subscribeToVideoViewCount(pointer, ({ total, status }) => { - if (!this.videoViewCountEl) { + const latestViewEl = this.videoModal?.getViewCountElement() || null; + if (!latestViewEl) { return; } if (Number.isFinite(total)) { const numeric = Number(total); - this.videoViewCountEl.textContent = this.formatViewCountLabel(numeric); + if (this.videoModal) { + this.videoModal.updateViewCountLabel( + this.formatViewCountLabel(numeric) + ); + } return; } if (status === "hydrating") { - this.videoViewCountEl.textContent = "Loading views…"; + if (this.videoModal) { + this.videoModal.updateViewCountLabel("Loading views…"); + } } else { - this.videoViewCountEl.textContent = "– views"; + if (this.videoModal) { + this.videoModal.updateViewCountLabel("– views"); + } } }); @@ -2563,10 +2365,12 @@ class bitvidApp { this.modalViewCountUnsub = null; } }; - this.videoViewCountEl.dataset.viewPointer = pointerKey; } catch (error) { console.warn("[viewCount] Failed to subscribe modal view counter:", error); - this.videoViewCountEl.textContent = "– views"; + if (this.videoModal) { + this.videoModal.updateViewCountLabel("– views"); + this.videoModal.setViewCountPointer(null); + } } } @@ -6741,53 +6545,26 @@ class bitvidApp { } resetTorrentStats() { - if (this.modalPeers) { - this.modalPeers.textContent = ""; - } - if (this.modalSpeed) { - this.modalSpeed.textContent = ""; - } - if (this.modalDownloaded) { - this.modalDownloaded.textContent = ""; - } - if (this.modalProgress) { - this.modalProgress.style.width = "0%"; + if (this.videoModal) { + this.videoModal.resetStats(); } } setCopyMagnetState(enabled) { - if (!this.copyMagnetBtn) { - return; + if (this.videoModal) { + this.videoModal.setCopyEnabled(enabled); } - this.copyMagnetBtn.disabled = !enabled; - this.copyMagnetBtn.setAttribute("aria-disabled", (!enabled).toString()); - this.copyMagnetBtn.classList.toggle("opacity-50", !enabled); - this.copyMagnetBtn.classList.toggle("cursor-not-allowed", !enabled); } setShareButtonState(enabled) { - if (!this.shareBtn) { - return; + if (this.videoModal) { + this.videoModal.setShareEnabled(enabled); } - this.shareBtn.disabled = !enabled; - this.shareBtn.setAttribute("aria-disabled", (!enabled).toString()); - this.shareBtn.classList.toggle("opacity-50", !enabled); - this.shareBtn.classList.toggle("cursor-not-allowed", !enabled); } setModalZapVisibility(visible) { - if (!this.modalZapBtn) { - return; - } - const shouldShow = !!visible; - this.modalZapBtn.classList.toggle("hidden", !shouldShow); - this.modalZapBtn.disabled = !shouldShow; - this.modalZapBtn.setAttribute("aria-disabled", (!shouldShow).toString()); - this.modalZapBtn.setAttribute("aria-hidden", (!shouldShow).toString()); - if (shouldShow) { - this.modalZapBtn.removeAttribute("tabindex"); - } else { - this.modalZapBtn.setAttribute("tabindex", "-1"); + if (this.videoModal) { + this.videoModal.setZapVisibility(visible); } } @@ -6883,20 +6660,22 @@ class bitvidApp { const peers = document.getElementById("peers"); const speed = document.getElementById("speed"); const downloaded = document.getElementById("downloaded"); - if (status && this.modalStatus) { - this.modalStatus.textContent = status.textContent; - } - if (progress && this.modalProgress) { - this.modalProgress.style.width = progress.style.width; - } - if (peers && this.modalPeers) { - this.modalPeers.textContent = peers.textContent; - } - if (speed && this.modalSpeed) { - this.modalSpeed.textContent = speed.textContent; - } - if (downloaded && this.modalDownloaded) { - this.modalDownloaded.textContent = downloaded.textContent; + if (this.videoModal) { + if (status) { + this.videoModal.updateStatus(status.textContent); + } + if (progress) { + this.videoModal.updateProgress(progress.style.width); + } + if (peers) { + this.videoModal.updatePeers(peers.textContent); + } + if (speed) { + this.videoModal.updateSpeed(speed.textContent); + } + if (downloaded) { + this.videoModal.updateDownloaded(downloaded.textContent); + } } }, 3000); this.activeIntervals.push(mirrorInterval); @@ -6921,21 +6700,8 @@ class bitvidApp { preserveObservers: true, }); - // 2) Hide the modal - if (this.playerModal) { - this.playerModal.style.display = "none"; - this.playerModal.classList.add("hidden"); - } - document.body.classList.remove("modal-open"); - document.documentElement.classList.remove("modal-open"); - setGlobalModalState("player", false); - if (typeof this.modalPosterCleanup === "function") { - this.modalPosterCleanup(); - this.modalPosterCleanup = null; - } - if (this.modalVideo) { - this.modalVideo.poster = ""; - this.modalVideo.removeAttribute("poster"); + if (this.videoModal) { + this.videoModal.close(); } this.currentMagnetUri = null; @@ -8445,46 +8211,13 @@ class bitvidApp { } syncModalMoreMenuData() { - if (!this.modalMoreMenu) { + if (!this.videoModal) { return; } - const buttons = this.modalMoreMenu.querySelectorAll("button[data-action]"); - buttons.forEach((button) => { - if (!(button instanceof HTMLElement)) { - return; - } - - const action = button.dataset.action || ""; - if (action === "blacklist-author") { - const canShow = this.canCurrentUserManageBlacklist(); - if (canShow && this.currentVideo && this.currentVideo.pubkey) { - button.dataset.author = this.currentVideo.pubkey; - button.classList.remove("hidden"); - button.setAttribute("aria-hidden", "false"); - } else { - delete button.dataset.author; - button.classList.add("hidden"); - button.setAttribute("aria-hidden", "true"); - } - return; - } - - if (action === "open-channel" || action === "block-author") { - if (this.currentVideo && this.currentVideo.pubkey) { - button.dataset.author = this.currentVideo.pubkey; - } else { - delete button.dataset.author; - } - } - - if (action === "copy-link" || action === "report") { - if (this.currentVideo && this.currentVideo.id) { - button.dataset.eventId = this.currentVideo.id; - } else { - delete button.dataset.eventId; - } - } + this.videoModal.syncMoreMenuData({ + currentVideo: this.currentVideo, + canManageBlacklist: this.canCurrentUserManageBlacklist(), }); } @@ -8728,41 +8461,26 @@ class bitvidApp { console.log("[DEBUG] torrent.ready =", torrent.ready); // Use "Complete" vs. "Downloading" as the textual status. - if (this.modalStatus) { + if (this.videoModal) { const fullyDownloaded = torrent.progress >= 1; - this.modalStatus.textContent = fullyDownloaded - ? "Complete" - : "Downloading"; - } + this.videoModal.updateStatus(fullyDownloaded ? "Complete" : "Downloading"); - // Update the progress bar - if (this.modalProgress) { const percent = (torrent.progress * 100).toFixed(2); - this.modalProgress.style.width = `${percent}%`; - } + this.videoModal.updateProgress(`${percent}%`); + this.videoModal.updatePeers(`Peers: ${torrent.numPeers}`); - // Update peers count - if (this.modalPeers) { - this.modalPeers.textContent = `Peers: ${torrent.numPeers}`; - } - - // Update speed in KB/s - if (this.modalSpeed) { const kb = (torrent.downloadSpeed / 1024).toFixed(2); - this.modalSpeed.textContent = `${kb} KB/s`; - } + this.videoModal.updateSpeed(`${kb} KB/s`); - // Update downloaded / total - if (this.modalDownloaded) { const downloadedMb = (torrent.downloaded / (1024 * 1024)).toFixed(2); const lengthMb = (torrent.length / (1024 * 1024)).toFixed(2); - this.modalDownloaded.textContent = `${downloadedMb} MB / ${lengthMb} MB`; - } + this.videoModal.updateDownloaded( + `${downloadedMb} MB / ${lengthMb} MB` + ); - // If you want to show a different text at 100% or if "ready" - // you can do it here: - if (torrent.ready && this.modalStatus) { - this.modalStatus.textContent = "Ready to play"; + if (torrent.ready) { + this.videoModal.updateStatus("Ready to play"); + } } } @@ -9316,8 +9034,8 @@ class bitvidApp { throw new Error("No magnet URI provided for torrent playback."); } if (!isValidMagnetUri(trimmedCandidate)) { - if (this.modalStatus) { - this.modalStatus.textContent = UNSUPPORTED_BTITH_MESSAGE; + if (this.videoModal) { + this.videoModal.updateStatus(UNSUPPORTED_BTITH_MESSAGE); } throw new Error(UNSUPPORTED_BTITH_MESSAGE); } @@ -9345,8 +9063,8 @@ class bitvidApp { await torrentClient.cleanup(); this.resetTorrentStats(); - if (this.modalStatus) { - this.modalStatus.textContent = "Streaming via WebTorrent"; + if (this.videoModal) { + this.videoModal.updateStatus("Streaming via WebTorrent"); } const torrentInstance = await torrentClient.streamVideo( @@ -9469,9 +9187,10 @@ class bitvidApp { }; subscribe("status", ({ message } = {}) => { - if (this.modalStatus) { - this.modalStatus.textContent = - typeof message === "string" ? message : ""; + if (this.videoModal) { + this.videoModal.updateStatus( + typeof message === "string" ? message : "" + ); } }); @@ -9711,27 +9430,19 @@ class bitvidApp { } const creatorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey; - if (this.videoTitle) { - this.videoTitle.textContent = video.title || "Untitled"; - } - if (this.videoDescription) { - this.videoDescription.textContent = - video.description || "No description available."; - } - if (this.videoTimestamp) { - this.videoTimestamp.textContent = this.formatTimeAgo(video.created_at); - } - if (this.creatorName) { - this.creatorName.textContent = creatorProfile.name; - } - if (this.creatorNpub) { - this.creatorNpub.textContent = `${creatorNpub.slice(0, 8)}...${creatorNpub.slice( - -4 - )}`; - } - if (this.creatorAvatar) { - this.creatorAvatar.src = creatorProfile.picture; - this.creatorAvatar.alt = creatorProfile.name; + if (this.videoModal) { + const formattedTimestamp = this.formatTimeAgo(video.created_at); + const displayNpub = `${creatorNpub.slice(0, 8)}...${creatorNpub.slice(-4)}`; + this.videoModal.updateMetadata({ + title: video.title || "Untitled", + description: video.description || "No description available.", + timestamp: formattedTimestamp, + creator: { + name: creatorProfile.name, + avatarUrl: creatorProfile.picture, + npub: displayNpub, + }, + }); } await this.playVideoWithFallback({ @@ -9798,25 +9509,17 @@ class bitvidApp { this.setCopyMagnetState(!!sanitizedMagnet); this.setShareButtonState(false); - if (this.videoTitle) { - this.videoTitle.textContent = title || "Untitled"; - } - if (this.videoDescription) { - this.videoDescription.textContent = - description || "No description available."; - } - if (this.videoTimestamp) { - this.videoTimestamp.textContent = ""; - } - if (this.creatorName) { - this.creatorName.textContent = "Unknown"; - } - if (this.creatorNpub) { - this.creatorNpub.textContent = ""; - } - if (this.creatorAvatar) { - this.creatorAvatar.src = "assets/svg/default-profile.svg"; - this.creatorAvatar.alt = "Unknown"; + if (this.videoModal) { + this.videoModal.updateMetadata({ + title: title || "Untitled", + description: description || "No description available.", + timestamp: "", + creator: { + name: "Unknown", + avatarUrl: "assets/svg/default-profile.svg", + npub: "", + }, + }); } const urlObj = new URL(window.location.href); @@ -9983,6 +9686,24 @@ class bitvidApp { console.log(msg); } + shareActiveVideo() { + if (!this.currentVideo || !this.currentVideo.id) { + this.showError("No shareable video is loaded."); + return; + } + + const shareUrl = this.buildShareUrlFromEventId(this.currentVideo.id); + if (!shareUrl) { + this.showError("Could not generate link."); + return; + } + + navigator.clipboard + .writeText(shareUrl) + .then(() => this.showSuccess("Video link copied to clipboard!")) + .catch(() => this.showError("Failed to copy the link.")); + } + /** * Copies the current video's magnet link to the clipboard. */ diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js new file mode 100644 index 00000000..232534eb --- /dev/null +++ b/js/ui/components/VideoModal.js @@ -0,0 +1,578 @@ +export class VideoModal { + constructor({ + removeTrackingScripts, + setGlobalModalState, + document: doc, + logger, + } = {}) { + if (!doc) { + throw new Error("VideoModal requires a document reference."); + } + if (typeof setGlobalModalState !== "function") { + throw new Error("VideoModal requires setGlobalModalState helper."); + } + + this.document = doc; + this.window = doc.defaultView || globalThis; + this.removeTrackingScripts = + typeof removeTrackingScripts === "function" + ? removeTrackingScripts + : () => {}; + this.setGlobalModalState = setGlobalModalState; + this.logger = logger || console; + this.eventTarget = new EventTarget(); + + this.loaded = false; + + this.playerModal = null; + this.scrollRegion = null; + this.modalVideo = null; + this.modalStatus = null; + this.modalProgress = null; + this.modalPeers = null; + this.modalSpeed = null; + this.modalDownloaded = null; + this.videoTitle = null; + this.videoDescription = null; + this.videoTimestamp = null; + this.videoViewCountEl = null; + this.creatorAvatar = null; + this.creatorName = null; + this.creatorNpub = null; + this.copyMagnetBtn = null; + this.shareBtn = null; + this.modalZapBtn = null; + this.modalMoreBtn = null; + this.modalMoreMenu = null; + + this.modalPosterCleanup = null; + this.videoEventCleanup = null; + + this.activeVideo = null; + + this.handleCopyRequest = this.handleCopyRequest.bind(this); + this.handleShareRequest = this.handleShareRequest.bind(this); + this.handleCreatorNavigation = this.handleCreatorNavigation.bind(this); + + this.MODAL_LOADING_POSTER = "assets/gif/please-stand-by.gif"; + } + + log(message, ...args) { + if (!message) { + return; + } + if (this.logger && typeof this.logger.log === "function") { + this.logger.log(message, ...args); + return; + } + if (typeof this.logger === "function") { + this.logger(message, ...args); + return; + } + console.log(message, ...args); + } + + addEventListener(type, listener, options) { + this.eventTarget.addEventListener(type, listener, options); + } + + removeEventListener(type, listener, options) { + this.eventTarget.removeEventListener(type, listener, options); + } + + dispatch(type, detail) { + const event = new CustomEvent(type, { detail }); + this.eventTarget.dispatchEvent(event); + } + + getRoot() { + return this.playerModal; + } + + getVideoElement() { + return this.modalVideo; + } + + setVideoElement(videoElement) { + this.detachVideoEvents(); + this.clearPosterCleanup(); + + if ( + this.window && + typeof this.window.HTMLVideoElement !== "undefined" && + videoElement instanceof this.window.HTMLVideoElement + ) { + this.modalVideo = videoElement; + this.bindVideoEvents(); + } else { + this.modalVideo = null; + } + + return this.modalVideo; + } + + async load() { + if (this.loaded && this.playerModal && this.playerModal.isConnected) { + return this.playerModal; + } + + const existing = this.document.getElementById("playerModal"); + if (existing) { + this.hydrate(existing); + this.loaded = true; + return this.playerModal; + } + + const response = await fetch("components/video-modal.html"); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const html = await response.text(); + const container = this.document.getElementById("modalContainer"); + if (!container) { + throw new Error("Modal container element not found!"); + } + + const wrapper = this.document.createElement("div"); + wrapper.innerHTML = html; + this.removeTrackingScripts(wrapper); + container.appendChild(wrapper); + + const playerModal = wrapper.querySelector("#playerModal"); + if (!playerModal) { + throw new Error("Player modal root not found in markup."); + } + + this.hydrate(playerModal); + this.loaded = true; + return this.playerModal; + } + + hydrate(playerModal) { + this.playerModal = playerModal; + this.scrollRegion = + playerModal.querySelector(".player-modal__content") || playerModal; + + this.modalVideo = playerModal.querySelector("#modalVideo") || null; + this.modalStatus = playerModal.querySelector("#modalStatus") || null; + this.modalProgress = playerModal.querySelector("#modalProgress") || null; + this.modalPeers = playerModal.querySelector("#modalPeers") || null; + this.modalSpeed = playerModal.querySelector("#modalSpeed") || null; + this.modalDownloaded = + playerModal.querySelector("#modalDownloaded") || null; + this.videoTitle = playerModal.querySelector("#videoTitle") || null; + this.videoDescription = + playerModal.querySelector("#videoDescription") || null; + this.videoTimestamp = + playerModal.querySelector("#videoTimestamp") || null; + this.videoViewCountEl = + playerModal.querySelector("#videoViewCount") || null; + this.creatorAvatar = + playerModal.querySelector("#creatorAvatar") || null; + this.creatorName = playerModal.querySelector("#creatorName") || null; + this.creatorNpub = playerModal.querySelector("#creatorNpub") || null; + this.copyMagnetBtn = playerModal.querySelector("#copyMagnetBtn") || null; + this.shareBtn = playerModal.querySelector("#shareBtn") || null; + this.modalZapBtn = playerModal.querySelector("#modalZapBtn") || null; + this.modalMoreBtn = playerModal.querySelector("#modalMoreBtn") || null; + this.modalMoreMenu = + playerModal.querySelector("#moreDropdown-modal") || null; + + const closeButton = playerModal.querySelector("#closeModal"); + if (closeButton) { + closeButton.addEventListener("click", () => { + this.dispatch("modal:close", { video: this.activeVideo }); + }); + } + + const modalNav = playerModal.querySelector("#modalNav"); + if (modalNav && this.scrollRegion) { + let lastScrollY = 0; + this.scrollRegion.addEventListener("scroll", () => { + const currentScrollY = this.scrollRegion.scrollTop; + const shouldShowNav = + currentScrollY <= lastScrollY || currentScrollY < 50; + modalNav.style.transform = shouldShowNav + ? "translateY(0)" + : "translateY(-100%)"; + lastScrollY = currentScrollY; + }); + } + + this.bindVideoEvents(); + this.bindActionButtons(); + this.setZapVisibility(false); + this.setCopyEnabled(false); + this.setShareEnabled(false); + this.resetStats(); + } + + bindVideoEvents() { + if ( + !this.modalVideo || + !this.window || + typeof this.window.HTMLVideoElement === "undefined" || + !(this.modalVideo instanceof this.window.HTMLVideoElement) + ) { + return; + } + + const loadedHandler = () => { + this.dispatch("playback:loadeddata", { + video: this.modalVideo, + active: this.activeVideo, + }); + }; + const playingHandler = () => { + this.dispatch("playback:playing", { + video: this.modalVideo, + active: this.activeVideo, + }); + }; + + this.modalVideo.addEventListener("loadeddata", loadedHandler); + this.modalVideo.addEventListener("playing", playingHandler); + + this.videoEventCleanup = () => { + if (!this.modalVideo) { + return; + } + this.modalVideo.removeEventListener("loadeddata", loadedHandler); + this.modalVideo.removeEventListener("playing", playingHandler); + this.videoEventCleanup = null; + }; + } + + detachVideoEvents() { + if (typeof this.videoEventCleanup === "function") { + this.videoEventCleanup(); + } + this.videoEventCleanup = null; + } + + bindActionButtons() { + if (this.copyMagnetBtn) { + this.copyMagnetBtn.addEventListener("click", this.handleCopyRequest); + } + if (this.shareBtn) { + this.shareBtn.addEventListener("click", this.handleShareRequest); + } + + if (this.modalZapBtn) { + this.modalZapBtn.addEventListener("click", (event) => { + event?.preventDefault?.(); + this.dispatch("video:zap", { video: this.activeVideo }); + }); + } + + if (this.creatorAvatar) { + this.creatorAvatar.style.cursor = "pointer"; + this.creatorAvatar.addEventListener("click", this.handleCreatorNavigation); + } + if (this.creatorName) { + this.creatorName.style.cursor = "pointer"; + this.creatorName.addEventListener("click", this.handleCreatorNavigation); + } + } + + handleCopyRequest(event) { + event?.preventDefault?.(); + if (this.copyMagnetBtn?.disabled) { + return; + } + this.dispatch("video:copy-magnet", { video: this.activeVideo }); + } + + handleShareRequest(event) { + event?.preventDefault?.(); + if (this.shareBtn?.disabled) { + return; + } + this.dispatch("video:share", { video: this.activeVideo }); + } + + handleCreatorNavigation(event) { + event?.preventDefault?.(); + this.dispatch("creator:navigate", { video: this.activeVideo }); + } + + open(video) { + this.activeVideo = video || null; + if (!this.playerModal) { + return; + } + + this.playerModal.style.display = "flex"; + this.playerModal.classList.remove("hidden"); + this.document.body.classList.add("modal-open"); + this.document.documentElement.classList.add("modal-open"); + if (this.scrollRegion) { + this.scrollRegion.scrollTop = 0; + } + this.setGlobalModalState("player", true); + this.applyLoadingPoster(); + } + + close() { + this.activeVideo = null; + if (this.playerModal) { + this.playerModal.style.display = "none"; + this.playerModal.classList.add("hidden"); + } + this.document.body.classList.remove("modal-open"); + this.document.documentElement.classList.remove("modal-open"); + this.setGlobalModalState("player", false); + this.forceRemovePoster("close"); + } + + applyLoadingPoster() { + if (!this.modalVideo) { + return; + } + + this.clearPosterCleanup(); + + const clearPoster = () => { + this.forceRemovePoster("playback-event"); + }; + + this.modalVideo.addEventListener("loadeddata", clearPoster); + this.modalVideo.addEventListener("playing", clearPoster); + this.modalVideo.poster = this.MODAL_LOADING_POSTER; + + this.modalPosterCleanup = () => { + if (!this.modalVideo) { + return; + } + this.modalVideo.removeEventListener("loadeddata", clearPoster); + this.modalVideo.removeEventListener("playing", clearPoster); + this.modalPosterCleanup = null; + }; + } + + clearPosterCleanup() { + if (typeof this.modalPosterCleanup === "function") { + this.modalPosterCleanup(); + } + this.modalPosterCleanup = null; + } + + forceRemovePoster(reason = "manual-clear") { + if (!this.modalVideo) { + return false; + } + + this.clearPosterCleanup(); + + const videoEl = this.modalVideo; + const hadPoster = + videoEl.hasAttribute("poster") || + (typeof videoEl.poster === "string" && videoEl.poster !== ""); + + if (!hadPoster) { + return false; + } + + videoEl.poster = ""; + if (videoEl.hasAttribute("poster")) { + videoEl.removeAttribute("poster"); + } + + this.log(`[VideoModal] Cleared loading poster (${reason}).`); + return true; + } + + resetStats() { + this.updatePeers(""); + this.updateSpeed(""); + this.updateDownloaded(""); + this.updateProgress("0%"); + } + + updateStatus(message) { + if (this.modalStatus) { + this.modalStatus.textContent = message || ""; + } + } + + updatePeers(text) { + if (this.modalPeers) { + this.modalPeers.textContent = text || ""; + } + } + + updateSpeed(text) { + if (this.modalSpeed) { + this.modalSpeed.textContent = text || ""; + } + } + + updateDownloaded(text) { + if (this.modalDownloaded) { + this.modalDownloaded.textContent = text || ""; + } + } + + updateProgress(value) { + if (!this.modalProgress) { + return; + } + if (typeof value === "number" && Number.isFinite(value)) { + this.modalProgress.style.width = `${Math.max(0, value)}%`; + return; + } + if (typeof value === "string") { + this.modalProgress.style.width = value; + return; + } + this.modalProgress.style.width = "0%"; + } + + setCopyEnabled(enabled) { + if (!this.copyMagnetBtn) { + return; + } + this.copyMagnetBtn.disabled = !enabled; + this.copyMagnetBtn.setAttribute( + "aria-disabled", + (!enabled).toString() + ); + this.copyMagnetBtn.classList.toggle("opacity-50", !enabled); + this.copyMagnetBtn.classList.toggle("cursor-not-allowed", !enabled); + } + + setShareEnabled(enabled) { + if (!this.shareBtn) { + return; + } + this.shareBtn.disabled = !enabled; + this.shareBtn.setAttribute( + "aria-disabled", + (!enabled).toString() + ); + this.shareBtn.classList.toggle("opacity-50", !enabled); + this.shareBtn.classList.toggle("cursor-not-allowed", !enabled); + } + + setZapVisibility(visible) { + if (!this.modalZapBtn) { + return; + } + this.modalZapBtn.classList.toggle("hidden", !visible); + this.modalZapBtn.disabled = !visible; + this.modalZapBtn.setAttribute("aria-disabled", (!visible).toString()); + this.modalZapBtn.setAttribute("aria-hidden", (!visible).toString()); + if (visible) { + this.modalZapBtn.removeAttribute("tabindex"); + } else { + this.modalZapBtn.setAttribute("tabindex", "-1"); + } + } + + getViewCountElement() { + return this.videoViewCountEl; + } + + updateViewCountLabel(text) { + if (this.videoViewCountEl) { + this.videoViewCountEl.textContent = text || ""; + } + } + + setViewCountPointer(pointerKey) { + if (!this.videoViewCountEl) { + return; + } + if (pointerKey) { + this.videoViewCountEl.dataset.viewPointer = pointerKey; + } else if (this.videoViewCountEl.dataset?.viewPointer) { + delete this.videoViewCountEl.dataset.viewPointer; + } + } + + updateMetadata({ + title, + description, + timestamp, + viewCount, + creator, + } = {}) { + if (this.videoTitle) { + this.videoTitle.textContent = title || "Untitled"; + } + if (this.videoDescription) { + this.videoDescription.textContent = description || ""; + } + if (this.videoTimestamp) { + this.videoTimestamp.textContent = timestamp || ""; + } + if (typeof viewCount === "string") { + this.updateViewCountLabel(viewCount); + } + if (creator) { + this.updateCreator(creator); + } + } + + updateCreator({ name, avatarUrl, npub } = {}) { + if (this.creatorName) { + this.creatorName.textContent = name || "Unknown"; + } + if (this.creatorAvatar) { + this.creatorAvatar.src = avatarUrl || "assets/svg/default-profile.svg"; + this.creatorAvatar.alt = name || "Unknown"; + } + if (this.creatorNpub) { + this.creatorNpub.textContent = npub || ""; + } + } + + syncMoreMenuData({ currentVideo, canManageBlacklist }) { + if (!this.modalMoreMenu) { + return; + } + + const buttons = this.modalMoreMenu.querySelectorAll("button[data-action]"); + const HTMLElementCtor = + this.window && typeof this.window.HTMLElement !== "undefined" + ? this.window.HTMLElement + : null; + + buttons.forEach((button) => { + if (HTMLElementCtor && !(button instanceof HTMLElementCtor)) { + return; + } + + const action = button.dataset.action || ""; + if (action === "blacklist-author") { + if (canManageBlacklist && currentVideo?.pubkey) { + button.dataset.author = currentVideo.pubkey; + button.classList.remove("hidden"); + button.setAttribute("aria-hidden", "false"); + } else { + delete button.dataset.author; + button.classList.add("hidden"); + button.setAttribute("aria-hidden", "true"); + } + return; + } + + if (action === "open-channel" || action === "block-author") { + if (currentVideo?.pubkey) { + button.dataset.author = currentVideo.pubkey; + } else { + delete button.dataset.author; + } + } + + if (action === "copy-link" || action === "report") { + if (currentVideo?.id) { + button.dataset.eventId = currentVideo.id; + } else { + delete button.dataset.eventId; + } + } + }); + } +}