diff --git a/components/embed-video-modal.html b/components/embed-video-modal.html new file mode 100644 index 00000000..9bfaef1e --- /dev/null +++ b/components/embed-video-modal.html @@ -0,0 +1,122 @@ + +
diff --git a/js/ui/ModalManager.js b/js/ui/ModalManager.js index 434db4f1..b48aff25 100644 --- a/js/ui/ModalManager.js +++ b/js/ui/ModalManager.js @@ -6,6 +6,7 @@ import { showLoginRequiredToZapNotification } from "../payments/zapNotifications import { VideoModal } from "./components/VideoModal.js"; import { RevertModal } from "./components/RevertModal.js"; import { ShareNostrModal } from "./components/ShareNostrModal.js"; +import { EmbedVideoModal } from "./components/EmbedVideoModal.js"; import initUploadModal from "./initUploadModal.js"; import initEditModal from "./initEditModal.js"; import initDeleteModal from "./initDeleteModal.js"; @@ -51,6 +52,7 @@ export default class ModalManager { this.eventDetailsModal = null; this.shareNostrModal = null; + this.embedVideoModal = null; this.zapController = null; } @@ -181,6 +183,18 @@ export default class ModalManager { }); app.shareNostrModal = this.shareNostrModal; + this.embedVideoModal = new EmbedVideoModal({ + removeTrackingScripts, + container: modalContainer, + document: doc, + getShareUrl: (eventId) => app.buildShareUrlFromEventId(eventId), + callbacks: { + showSuccess: (message) => app.showSuccess(message), + showError: (message) => app.showError(message), + }, + }); + app.embedVideoModal = this.embedVideoModal; + this.videoModal = (typeof this.ui.videoModal === "function" ? this.ui.videoModal({ app }) @@ -269,6 +283,31 @@ export default class ModalManager { this.videoModalHandlers.share, ); + this.videoModalHandlers.embed = (event) => { + const detail = event?.detail || {}; + const targetVideo = + detail && typeof detail.video === "object" + ? detail.video + : app.currentVideo || null; + if (!targetVideo) { + app.showError("No video is available to embed."); + return; + } + Promise.resolve( + this.embedVideoModal?.open({ + video: targetVideo, + triggerElement: event?.target || null, + }), + ).catch((error) => { + devLogger.error("[ModalManager] Failed to open embed modal:", error); + app.showError("Unable to open the embed modal."); + }); + }; + this.videoModal.addEventListener( + "video:embed", + this.videoModalHandlers.embed, + ); + this.videoModalHandlers.moderationOverride = (event) => { const detail = event?.detail || {}; const targetVideo = @@ -721,6 +760,18 @@ export default class ModalManager { app.shareNostrModal = null; } + if (this.embedVideoModal && typeof this.embedVideoModal.close === "function") { + try { + this.embedVideoModal.close(); + } catch (error) { + devLogger.warn("[ModalManager] Failed to close embed modal:", error); + } + } + this.embedVideoModal = null; + if (app.embedVideoModal) { + app.embedVideoModal = null; + } + if (this.revertModal && this.revertConfirmHandler) { try { this.revertModal.removeEventListener( @@ -793,6 +844,9 @@ export default class ModalManager { case "share": eventName = "video:share"; break; + case "embed": + eventName = "video:embed"; + break; case "moderationOverride": eventName = "video:moderation-override"; break; @@ -886,6 +940,7 @@ export default class ModalManager { app.videoModal = null; app.eventDetailsModal = null; app.zapController = null; + app.embedVideoModal = null; this.uploadModal = null; this.uploadModalEvents = null; @@ -903,5 +958,6 @@ export default class ModalManager { this.videoModalHandlers = {}; this.eventDetailsModal = null; this.zapController = null; + this.embedVideoModal = null; } } diff --git a/js/ui/components/EmbedVideoModal.js b/js/ui/components/EmbedVideoModal.js new file mode 100644 index 00000000..75358532 --- /dev/null +++ b/js/ui/components/EmbedVideoModal.js @@ -0,0 +1,313 @@ +import { + prepareStaticModal, + openStaticModal, + closeStaticModal, +} from "./staticModalAccessibility.js"; +import logger from "../../utils/logger.js"; + +const DEFAULT_WIDTH = 640; +const DEFAULT_HEIGHT = 360; + +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function escapeAttribute(value) { + if (value === null || value === undefined) { + return ""; + } + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function resolveDimension(value, fallback) { + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + return fallback; +} + +export class EmbedVideoModal { + constructor({ + removeTrackingScripts, + container, + document: doc, + getShareUrl, + callbacks = {}, + } = {}) { + this.document = doc || document; + this.container = container || this.document.getElementById("modalContainer") || this.document.body; + this.removeTrackingScripts = + typeof removeTrackingScripts === "function" ? removeTrackingScripts : () => {}; + this.getShareUrl = typeof getShareUrl === "function" ? getShareUrl : null; + this.callbacks = { + showSuccess: callbacks.showSuccess || (() => {}), + showError: callbacks.showError || (() => {}), + }; + + this.modal = null; + this.overlay = null; + this.panel = null; + this.closeButton = null; + this.cancelButton = null; + this.copyButton = null; + this.sourceCdn = null; + this.sourceP2p = null; + this.widthInput = null; + this.heightInput = null; + this.snippetTextarea = null; + + this.activeVideo = null; + this.bound = false; + + this.boundHandlers = { + close: () => this.close(), + copy: () => this.handleCopy(), + update: () => this.updateSnippet(), + }; + } + + async load() { + if (this.modal) { + return this.modal; + } + + if (!this.container) { + throw new Error("Modal container element not found."); + } + + let modal = this.container.querySelector("#embedVideoModal"); + if (!modal) { + const response = await fetch("components/embed-video-modal.html"); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const html = await response.text(); + const wrapper = this.document.createElement("div"); + wrapper.innerHTML = html; + this.removeTrackingScripts(wrapper); + this.container.appendChild(wrapper); + modal = wrapper.querySelector("#embedVideoModal"); + } + + if (!modal) { + throw new Error("Embed video modal markup missing after load."); + } + + this.cacheElements(modal); + prepareStaticModal({ root: modal, document: this.document }); + this.bindEvents(); + + return this.modal; + } + + cacheElements(modal) { + this.modal = modal; + this.overlay = modal.querySelector(".bv-modal-backdrop"); + this.panel = modal.querySelector(".modal-sheet") || modal; + this.closeButton = modal.querySelector("#closeEmbedVideoModal"); + this.cancelButton = modal.querySelector("#cancelEmbedVideo"); + this.copyButton = modal.querySelector("#copyEmbedVideo"); + this.sourceCdn = modal.querySelector("#embedVideoSourceCdn"); + this.sourceP2p = modal.querySelector("#embedVideoSourceP2p"); + this.widthInput = modal.querySelector("#embedVideoWidth"); + this.heightInput = modal.querySelector("#embedVideoHeight"); + this.snippetTextarea = modal.querySelector("#embedVideoSnippet"); + } + + bindEvents() { + if (this.bound || !this.modal) { + return; + } + + const closeTargets = [this.closeButton, this.cancelButton, this.overlay]; + closeTargets.forEach((target) => { + if (target) { + target.addEventListener("click", this.boundHandlers.close); + } + }); + + if (this.copyButton) { + this.copyButton.addEventListener("click", this.boundHandlers.copy); + } + + const updateTargets = [this.sourceCdn, this.sourceP2p]; + updateTargets.forEach((target) => { + if (target) { + target.addEventListener("change", this.boundHandlers.update); + } + }); + + const dimensionTargets = [this.widthInput, this.heightInput]; + dimensionTargets.forEach((target) => { + if (target) { + target.addEventListener("input", this.boundHandlers.update); + } + }); + + this.bound = true; + } + + setVideo(video) { + this.activeVideo = video && typeof video === "object" ? video : null; + if (!this.activeVideo) { + return; + } + + const hasUrl = isNonEmptyString(this.activeVideo.url); + const hasMagnet = isNonEmptyString(this.activeVideo.magnet); + + if (this.sourceCdn) { + this.sourceCdn.disabled = !hasUrl; + } + if (this.sourceP2p) { + this.sourceP2p.disabled = !hasMagnet; + } + + const shouldPreferCdn = hasUrl || !hasMagnet; + if (this.sourceCdn && this.sourceP2p) { + if (shouldPreferCdn) { + this.sourceCdn.checked = true; + this.sourceP2p.checked = false; + } else { + this.sourceCdn.checked = false; + this.sourceP2p.checked = true; + } + } + } + + getSelectedSource() { + if (this.sourceP2p?.checked) { + return "torrent"; + } + if (this.sourceCdn?.checked) { + return "url"; + } + return ""; + } + + buildEmbedUrl(source) { + if (!this.activeVideo) { + return ""; + } + + const rawShareUrl = + (isNonEmptyString(this.activeVideo.shareUrl) && this.activeVideo.shareUrl.trim()) || + (this.getShareUrl && this.getShareUrl(this.activeVideo.id)) || + ""; + + if (!rawShareUrl) { + return ""; + } + + try { + const url = new URL(rawShareUrl, this.document?.location?.origin || window.location.origin); + if (isNonEmptyString(source)) { + url.searchParams.set("source", source); + } + return url.toString(); + } catch (error) { + logger.user.warn("Embed modal could not build embed URL.", error); + return rawShareUrl; + } + } + + buildEmbedSnippet() { + if (!this.activeVideo) { + return ""; + } + + const source = this.getSelectedSource(); + const embedUrl = this.buildEmbedUrl(source); + if (!embedUrl) { + return ""; + } + + const width = resolveDimension(this.widthInput?.value, DEFAULT_WIDTH); + const height = resolveDimension(this.heightInput?.value, DEFAULT_HEIGHT); + const title = + (isNonEmptyString(this.activeVideo.title) && this.activeVideo.title.trim()) || + "Bitvid video"; + + return ``; + } + + updateSnippet() { + if (!this.snippetTextarea) { + return; + } + + const snippet = this.buildEmbedSnippet(); + this.snippetTextarea.value = snippet; + if (this.copyButton) { + this.copyButton.disabled = !snippet; + } + } + + async handleCopy() { + if (!this.snippetTextarea) { + return; + } + + const snippet = this.snippetTextarea.value; + if (!snippet) { + this.callbacks.showError("No embed snippet to copy."); + return; + } + + if (navigator?.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(snippet); + this.callbacks.showSuccess("Embed code copied to clipboard!"); + return; + } catch (error) { + logger.user.warn("Embed modal clipboard write failed.", error); + } + } + + const textarea = this.snippetTextarea; + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + let succeeded = false; + try { + succeeded = this.document.execCommand("copy"); + } catch (error) { + logger.user.warn("Embed modal copy fallback failed.", error); + succeeded = false; + } + + if (succeeded) { + this.callbacks.showSuccess("Embed code copied to clipboard!"); + } else { + this.callbacks.showError("Unable to copy embed code. Please copy it manually."); + } + } + + async open({ video, triggerElement } = {}) { + const modal = await this.load(); + if (!video || typeof video !== "object") { + this.callbacks.showError("No video available to embed."); + return; + } + + this.setVideo(video); + this.updateSnippet(); + + openStaticModal(modal, { triggerElement, document: this.document }); + } + + close() { + if (this.modal) { + closeStaticModal(this.modal, { document: this.document }); + } + this.activeVideo = null; + } +}