Files
bitvid/js/ui/components/VideoModal.js
T

579 lines
16 KiB
JavaScript

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;
}
}
});
}
}