Refactor video modal into reusable component

This commit is contained in:
thePR0M3TH3AN committed 2025-10-03 14:35:19 -04:00
1 parent 97e74875ae
commit cbf5203dcf
2 files changed
+743 -444

No files matched your search

+165 -444
View File
@@ -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.
*/
+578
View File
@@ -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;
}
}
});
}
}