From b46135cd36d177959d858a7e02870f9c08271b63 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:35:32 -0400 Subject: [PATCH] Refactor video list rendering into dedicated view --- js/app.js | 578 ++++++------------------- js/ui/views/VideoListView.js | 800 +++++++++++++++++++++++++++++++++++ 2 files changed, 939 insertions(+), 439 deletions(-) create mode 100644 js/ui/views/VideoListView.js diff --git a/js/app.js b/js/app.js index eb4d679f..603e62d5 100644 --- a/js/app.js +++ b/js/app.js @@ -47,7 +47,7 @@ import { VideoModal } from "./ui/components/VideoModal.js"; import { UploadModal } from "./ui/components/UploadModal.js"; import { EditModal } from "./ui/components/EditModal.js"; import { RevertModal } from "./ui/components/RevertModal.js"; -import { VideoCard } from "./ui/components/VideoCard.js"; +import { VideoListView } from "./ui/views/VideoListView.js"; import { getPubkey as getStoredPubkey, setPubkey as setStoredPubkey, @@ -113,7 +113,6 @@ const ADMIN_DM_IMAGE_URL = const BITVID_WEBSITE_URL = "https://bitvid.network/"; 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; /** * Basic validation for BitTorrent magnet URIs. @@ -602,9 +601,6 @@ class bitvidApp { this.loggedViewPointerKeys = new Set(); this.videoSubscription = nostrService.getVideoSubscription() || null; this.videoList = null; - this._videoListElement = null; - this._videoListClickHandler = null; - this.viewCountSubscriptions = new Map(); this.modalViewCountUnsub = null; // Videos stored as a Map (key=event.id) @@ -648,9 +644,120 @@ class bitvidApp { }, }); - this.lastRenderedVideoSignature = null; - this._lastRenderedVideoListElement = null; - this.renderedVideoIds = new Set(); + this.videoListView = new VideoListView({ + document, + mediaLoader: this.mediaLoader, + badgeHelpers: { attachHealthBadges, attachUrlHealthBadges }, + formatters: { + formatTimeAgo: (timestamp) => this.formatTimeAgo(timestamp), + formatViewCountLabel: (total) => this.formatViewCountLabel(total), + }, + helpers: { + escapeHtml: (value) => this.escapeHTML(value), + isMagnetSupported: (magnet) => isValidMagnetUri(magnet), + toLocaleString: (value) => + typeof value === "number" ? value.toLocaleString() : value, + }, + assets: { + fallbackThumbnailSrc: FALLBACK_THUMBNAIL_SRC, + unsupportedBtihMessage: UNSUPPORTED_BTITH_MESSAGE, + }, + state: { + loadedThumbnails: this.loadedThumbnails, + videosMap: this.videosMap, + }, + utils: { + dedupeVideos: (videos) => this.dedupeVideosByRoot(videos), + getAllEvents: () => Array.from(nostrClient.allEvents.values()), + hasOlderVersion: (video, events) => this.hasOlderVersion(video, events), + derivePointerInfo: (video) => this.deriveVideoPointerInfo(video), + persistWatchHistoryMetadata: (video, pointerInfo) => + this.persistWatchHistoryMetadataForVideo(video, pointerInfo), + getShareUrlBase: () => this.getShareUrlBase(), + buildShareUrlFromNevent: (nevent) => this.buildShareUrlFromNevent(nevent), + buildShareUrlFromEventId: (eventId) => this.buildShareUrlFromEventId(eventId), + canManageBlacklist: () => this.canCurrentUserManageBlacklist(), + canEditVideo: (video) => video?.pubkey === this.pubkey, + canDeleteVideo: (video) => video?.pubkey === this.pubkey, + batchFetchProfiles: (authorSet) => this.batchFetchProfiles(authorSet), + bindThumbnailFallbacks: (container) => this.bindThumbnailFallbacks(container), + handleUrlHealthBadge: (payload) => this.handleUrlHealthBadge(payload), + refreshDiscussionCounts: (videos) => this.refreshVideoDiscussionCounts(videos), + ensureGlobalMoreMenuHandlers: () => this.ensureGlobalMoreMenuHandlers(), + closeAllMenus: () => this.closeAllMoreMenus(), + }, + }); + + this.videoListView.setPlaybackHandler(({ videoId, url, magnet }) => { + if (videoId) { + Promise.resolve(this.playVideoByEventId(videoId)).catch((error) => { + console.error("[VideoListView] Failed to play by event id:", error); + }); + return; + } + Promise.resolve(this.playVideoWithFallback({ url, magnet })).catch( + (error) => { + console.error("[VideoListView] Failed to start playback:", error); + } + ); + }); + + this.videoListView.setEditHandler(({ video, index }) => { + if (!video?.id) { + return; + } + this.handleEditVideo({ + eventId: video.id, + index: Number.isFinite(index) ? index : null, + }); + }); + + this.videoListView.setRevertHandler(({ video, index }) => { + if (!video?.id) { + return; + } + this.handleRevertVideo({ + eventId: video.id, + index: Number.isFinite(index) ? index : null, + }); + }); + + this.videoListView.setDeleteHandler(({ video, index }) => { + if (!video?.id) { + return; + } + this.handleFullDeleteVideo({ + eventId: video.id, + index: Number.isFinite(index) ? index : null, + }); + }); + + this.videoListView.setBlacklistHandler(({ video, dataset }) => { + const detail = { + ...(dataset || {}), + author: dataset?.author || video?.pubkey || "", + }; + this.handleMoreMenuAction("blacklist-author", detail); + }); + + this.videoListView.addEventListener("video:share", (event) => { + const detail = event?.detail || {}; + const dataset = { + ...(detail.dataset || {}), + eventId: detail.eventId || detail.dataset?.eventId || detail.video?.id || "", + context: detail.dataset?.context || "card", + }; + this.handleMoreMenuAction(detail.action || "copy-link", dataset); + }); + + this.videoListView.addEventListener("video:context-action", (event) => { + const detail = event?.detail || {}; + const dataset = { + ...(detail.dataset || {}), + eventId: detail.dataset?.eventId || detail.video?.id || "", + }; + this.handleMoreMenuAction(detail.action, dataset); + }); // NEW: reference to the login modal's close button this.closeLoginModalBtn = @@ -744,25 +851,11 @@ class bitvidApp { } prepareForViewLoad() { - if (this._videoListElement && this._videoListClickHandler) { - try { - this._videoListElement.removeEventListener( - "click", - this._videoListClickHandler - ); - } catch (err) { - console.warn("[prepareForViewLoad] Failed to detach video list handler:", err); - } - this._videoListElement = null; - this._videoListClickHandler = null; + if (this.videoListView) { + this.videoListView.destroy(); } this.videoList = null; - this._lastRenderedVideoListElement = null; - this.lastRenderedVideoSignature = null; - if (this.renderedVideoIds) { - this.renderedVideoIds.clear(); - } if (this.mediaLoader && typeof this.mediaLoader.disconnect === "function") { this.mediaLoader.disconnect(); @@ -890,7 +983,9 @@ class bitvidApp { // 7. Once loaded, get a reference to #videoList this.videoList = document.getElementById("videoList"); - this.attachVideoListHandler(); + if (this.videoListView) { + this.videoListView.setContainer(this.videoList); + } // 8. Subscribe or fetch videos await this.loadVideos(); @@ -1021,117 +1116,16 @@ class bitvidApp { return `${formatViewCount(value)} ${label}`; } - ensureViewCountSubscription(pointerInfo) { - if (!pointerInfo || !pointerInfo.key || !pointerInfo.pointer) { - return null; - } - - const existing = this.viewCountSubscriptions.get(pointerInfo.key); - if (existing) { - return existing; - } - - const entry = { - pointer: pointerInfo.pointer, - key: pointerInfo.key, - token: null, - elements: new Set(), - lastTotal: null, - lastStatus: "idle", - lastText: "– views", - }; - - try { - const token = subscribeToVideoViewCount( - pointerInfo.pointer, - ({ total, status }) => { - let text; - if (Number.isFinite(total)) { - const numeric = Number(total); - entry.lastTotal = numeric; - text = this.formatViewCountLabel(numeric); - } else if (status === "hydrating") { - text = "Loading views…"; - } else { - text = "– views"; - } - - entry.lastStatus = status; - entry.lastText = text; - - for (const el of Array.from(entry.elements)) { - if (!el || !el.isConnected) { - entry.elements.delete(el); - continue; - } - el.textContent = text; - } - } - ); - entry.token = token; - this.viewCountSubscriptions.set(pointerInfo.key, entry); - return entry; - } catch (error) { - console.warn("[viewCount] Failed to subscribe to view counter:", error); - return null; - } - } - - registerVideoViewCountElement(cardEl, pointerInfo) { - if (!cardEl || !pointerInfo) { - return; - } - - const viewCountEl = cardEl.querySelector("[data-view-count]"); - if (!viewCountEl) { - return; - } - - if (!viewCountEl.textContent || !viewCountEl.textContent.trim()) { - viewCountEl.textContent = "– views"; - } - - const entry = this.ensureViewCountSubscription(pointerInfo); - if (!entry) { - return; - } - - entry.elements.add(viewCountEl); - if (typeof pointerInfo.key === "string") { - viewCountEl.dataset.viewPointer = pointerInfo.key; - } - viewCountEl.textContent = entry.lastText; - } - pruneDetachedViewCountElements() { - for (const entry of this.viewCountSubscriptions.values()) { - if (!entry || !(entry.elements instanceof Set)) { - continue; - } - for (const el of Array.from(entry.elements)) { - if (!el || !el.isConnected) { - entry.elements.delete(el); - } - } + if (this.videoListView) { + this.videoListView.pruneDetachedViewCountElements(); } } teardownAllViewCountSubscriptions() { - const keys = Array.from(this.viewCountSubscriptions.keys()); - keys.forEach((key) => { - const entry = this.viewCountSubscriptions.get(key); - if (entry && entry.token && entry.pointer) { - try { - unsubscribeFromVideoViewCount(entry.pointer, entry.token); - } catch (error) { - console.warn( - `[viewCount] Failed to unsubscribe from pointer ${key}:`, - error - ); - } - } - this.viewCountSubscriptions.delete(key); - }); + if (this.videoListView) { + this.videoListView.teardownAllViewCountSubscriptions(); + } } teardownModalViewCountSubscription() { @@ -3456,67 +3450,13 @@ class bitvidApp { } attachVideoListHandler() { - const nextTarget = document; - - if (this._videoListElement === nextTarget && this._videoListClickHandler) { + if (!this.videoListView) { return; } - - if (this._videoListElement && this._videoListClickHandler) { - this._videoListElement.removeEventListener( - "click", - this._videoListClickHandler - ); - this._videoListElement = null; - this._videoListClickHandler = null; + if (!this.videoList) { + this.videoList = document.getElementById("videoList"); } - - const handler = async (event) => { - const trigger = event.target.closest( - "[data-play-magnet],[data-play-url]" - ); - if (!trigger) { - return; - } - - const isPrimaryClick = - typeof event.button !== "number" || event.button === 0; - if (isPrimaryClick && !event.ctrlKey && !event.metaKey) { - event.preventDefault(); - - const rawUrlValue = - (trigger.dataset && typeof trigger.dataset.playUrl === "string" - ? trigger.dataset.playUrl - : null) ?? trigger.getAttribute("data-play-url") ?? ""; - const rawMagnetValue = - (trigger.dataset && typeof trigger.dataset.playMagnet === "string" - ? trigger.dataset.playMagnet - : null) ?? trigger.getAttribute("data-play-magnet") ?? ""; - - let url = ""; - if (rawUrlValue) { - try { - url = decodeURIComponent(rawUrlValue); - } catch (err) { - console.warn("Failed to decode data-play-url attribute:", err); - url = rawUrlValue; - } - } - - const magnet = typeof rawMagnetValue === "string" ? rawMagnetValue : ""; - const eventId = trigger.getAttribute("data-video-id"); - - if (eventId) { - await this.playVideoByEventId(eventId); - } else { - await this.playVideoWithFallback({ url, magnet }); - } - } - }; - - nextTarget.addEventListener("click", handler); - this._videoListElement = nextTarget; - this._videoListClickHandler = handler; + this.videoListView.setContainer(this.videoList || null); } /** @@ -5028,7 +4968,6 @@ class bitvidApp { console.log("Starting loadVideos... (forceFetch =", forceFetch, ")"); if (this.videoList) { - this.lastRenderedVideoSignature = null; this.videoList.innerHTML = getSidebarLoadingMarkup( "Fetching recent videos…" ); @@ -5047,6 +4986,9 @@ class bitvidApp { this.videoSubscription = nostrService.getVideoSubscription() || null; this.videosMap = nostrService.getVideosMap(); + if (this.videoListView) { + this.videoListView.state.videosMap = this.videosMap; + } } async loadOlderVideos(lastTimestamp) { @@ -5355,248 +5297,16 @@ class bitvidApp { } async renderVideoList(videos) { - if (!this.videoList) return; - - if (this._lastRenderedVideoListElement !== this.videoList) { - this.lastRenderedVideoSignature = null; - this._lastRenderedVideoListElement = this.videoList; - } - - const dedupedVideos = this.dedupeVideosByRoot(videos); - - const activePointerKeys = new Set(); - dedupedVideos.forEach((video) => { - const pointerInfo = this.deriveVideoPointerInfo(video); - if (pointerInfo) { - activePointerKeys.add(pointerInfo.key); - } - }); - - const keysToRemove = []; - for (const [key, entry] of this.viewCountSubscriptions.entries()) { - if (!activePointerKeys.has(key)) { - keysToRemove.push(key); - continue; - } - if (entry && entry.elements instanceof Set) { - entry.elements.clear(); - } - } - - keysToRemove.forEach((key) => { - const entry = this.viewCountSubscriptions.get(key); - if (entry && entry.pointer && entry.token) { - try { - unsubscribeFromVideoViewCount(entry.pointer, entry.token); - } catch (error) { - console.warn( - `[viewCount] Failed to unsubscribe from stale pointer ${key}:`, - error - ); - } - } - this.viewCountSubscriptions.delete(key); - }); - - if (this.loadedThumbnails && this.loadedThumbnails.size) { - const activeIds = new Set(); - dedupedVideos.forEach((video) => { - if (video && typeof video.id === "string" && video.id) { - activeIds.add(video.id); - } - }); - Array.from(this.loadedThumbnails.keys()).forEach((videoId) => { - if (!activeIds.has(videoId)) { - this.loadedThumbnails.delete(videoId); - } - }); - } - - // 1) If no videos - if (!dedupedVideos.length) { - this.renderedVideoIds.clear(); - this.videoCardInstances = []; - if (this.lastRenderedVideoSignature === EMPTY_VIDEO_LIST_SIGNATURE) { - return; - } - this.lastRenderedVideoSignature = EMPTY_VIDEO_LIST_SIGNATURE; - this.videoList.innerHTML = ` -
- No public videos available yet. Be the first to upload one! -
`; + if (!this.videoListView) { return; } - // 2) Sort newest first - dedupedVideos.sort((a, b) => b.created_at - a.created_at); - - const signaturePayload = dedupedVideos.map((video) => ({ - id: typeof video.id === "string" ? video.id : "", - createdAt: Number.isFinite(video.created_at) - ? video.created_at - : Number(video.created_at) || 0, - deleted: Boolean(video.deleted), - isPrivate: Boolean(video.isPrivate), - thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "", - url: typeof video.url === "string" ? video.url : "", - magnet: typeof video.magnet === "string" ? video.magnet : "", - enableComments: video.enableComments === false ? false : true, - })); - const signature = JSON.stringify(signaturePayload); - - if (signature === this.lastRenderedVideoSignature) { - return; + if (!this.videoList) { + this.videoList = document.getElementById("videoList"); + this.videoListView.setContainer(this.videoList || null); } - this.lastRenderedVideoSignature = signature; - const previouslyRenderedIds = new Set(this.renderedVideoIds); - this.renderedVideoIds.clear(); - - const fullAllEventsArray = Array.from(nostrClient.allEvents.values()); - const fragment = document.createDocumentFragment(); - const authorSet = new Set(); - const defaultShareBase = - this.getShareUrlBase() || - `${window.location?.origin || ""}${window.location?.pathname || ""}` || - (window.location?.href ? window.location.href.split(/[?#]/)[0] : ""); - const canManageBlacklist = this.canCurrentUserManageBlacklist(); - this.videoCardInstances = []; - - // 3) Build each card - dedupedVideos.forEach((video, index) => { - if (!video.id || !video.title) { - console.error("Video missing ID/title:", video); - return; - } - - authorSet.add(video.pubkey); - - const nevent = window.NostrTools.nip19.neventEncode({ id: video.id }); - const shareUrl = - this.buildShareUrlFromNevent(nevent) || - `${defaultShareBase}?v=${encodeURIComponent(nevent)}`; - const canEdit = video.pubkey === this.pubkey; - const highlightClass = - video.isPrivate && canEdit - ? "border-2 border-yellow-500" - : "border-none"; - const isNewlyRendered = !previouslyRenderedIds.has(video.id); - const animationClass = isNewlyRendered ? "video-card--enter" : ""; - const timeAgo = this.formatTimeAgo(video.created_at); - - // Check if there's an older version - let hasOlder = false; - if (canEdit && video.videoRootId) { - hasOlder = this.hasOlderVersion(video, fullAllEventsArray); - } - - const pointerInfo = this.deriveVideoPointerInfo(video); - if (pointerInfo) { - this.persistWatchHistoryMetadataForVideo(video, pointerInfo); - } - - const videoCard = new VideoCard({ - document, - video, - index, - shareUrl, - pointerInfo, - timeAgo, - highlightClass, - animationClass, - capabilities: { - canEdit, - canDelete: canEdit, - canRevert: hasOlder, - canManageBlacklist, - }, - helpers: { - escapeHtml: (value) => this.escapeHTML(value), - isMagnetSupported: (magnet) => isValidMagnetUri(magnet), - toLocaleString: (value) => - typeof value === "number" ? value.toLocaleString() : value, - }, - assets: { - fallbackThumbnailSrc: FALLBACK_THUMBNAIL_SRC, - unsupportedBtihMessage: UNSUPPORTED_BTITH_MESSAGE, - }, - state: { loadedThumbnails: this.loadedThumbnails }, - ensureGlobalMoreMenuHandlers: () => this.ensureGlobalMoreMenuHandlers(), - onRequestCloseAllMenus: () => this.closeAllMoreMenus(), - }); - - videoCard.onEdit = ({ video: editVideo, index: editIndex }) => { - this.handleEditVideo({ - eventId: editVideo.id, - index: Number.isFinite(editIndex) ? editIndex : null, - }); - }; - - videoCard.onRevert = ({ video: revertVideo, index: revertIndex }) => { - this.handleRevertVideo({ - eventId: revertVideo.id, - index: Number.isFinite(revertIndex) ? revertIndex : null, - }); - }; - - videoCard.onDelete = ({ video: deleteVideo, index: deleteIndex }) => { - this.handleFullDeleteVideo({ - eventId: deleteVideo.id, - index: Number.isFinite(deleteIndex) ? deleteIndex : null, - }); - }; - - videoCard.onMoreAction = ({ dataset }) => { - const action = dataset?.action || ""; - this.handleMoreMenuAction(action, dataset || {}); - }; - - videoCard.onAuthorNavigate = ({ pubkey }) => { - this.goToProfile(pubkey); - }; - - const cardEl = videoCard.getRoot(); - if (!cardEl) { - return; - } - - if (pointerInfo) { - this.registerVideoViewCountElement(cardEl, pointerInfo); - } - - if (video && video.id) { - this.videosMap.set(video.id, video); - } - - fragment.appendChild(cardEl); - this.renderedVideoIds.add(video.id); - this.videoCardInstances.push(videoCard); - }); - - // Clear old content, add new - this.videoList.innerHTML = ""; - this.videoList.appendChild(fragment); - attachHealthBadges(this.videoList); - attachUrlHealthBadges(this.videoList, ({ badgeEl, url, eventId }) => { - const video = this.videosMap.get(eventId) || { id: eventId }; - this.handleUrlHealthBadge({ video, url, badgeEl }); - }); - - // Ensure every thumbnail can recover with a fallback image if the primary - // source fails to load or returns a zero-sized response (some CDNs error - // with HTTP 200 + empty body). We set up the listeners before kicking off - // any lazy-loading observers so cached failures are covered as well. - this.bindThumbnailFallbacks(this.videoList); - - // Lazy-load images - const lazyEls = this.videoList.querySelectorAll("[data-lazy]"); - lazyEls.forEach((el) => this.mediaLoader.observe(el)); - - // 2) After building cards, do one batch profile fetch - this.batchFetchProfiles(authorSet); - - this.refreshVideoDiscussionCounts(dedupedVideos); - this.pruneDetachedViewCountElements(); + this.videoListView.render(videos); } refreshVideoDiscussionCounts(videos = []) { @@ -5898,18 +5608,8 @@ class bitvidApp { } closeAllMoreMenus() { - if (Array.isArray(this.videoCardInstances) && this.videoCardInstances.length) { - this.videoCardInstances.forEach((card) => { - if (!card) { - return; - } - if (typeof card.closeMoreMenu === "function") { - card.closeMoreMenu(); - } - if (typeof card.closeSettingsMenu === "function") { - card.closeSettingsMenu(); - } - }); + if (this.videoListView) { + this.videoListView.closeAllMenus(); } const menus = document.querySelectorAll("[data-more-menu]"); diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js new file mode 100644 index 00000000..d03afb7c --- /dev/null +++ b/js/ui/views/VideoListView.js @@ -0,0 +1,800 @@ +import { VideoCard } from "../components/VideoCard.js"; +import { + subscribeToVideoViewCount, + unsubscribeFromVideoViewCount, +} from "../../viewCounter.js"; + +const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__"; + +export class VideoListView { + constructor(options = {}) { + const { + document: doc = typeof document !== "undefined" ? document : null, + container = null, + mediaLoader = null, + badgeHelpers = {}, + formatters = {}, + helpers = {}, + assets = {}, + state = {}, + utils = {}, + } = options; + + this.document = doc; + this.window = this.document?.defaultView || + (typeof window !== "undefined" ? window : null); + this.container = null; + this.mediaLoader = mediaLoader; + + this.badgeHelpers = { + attachHealthBadges: badgeHelpers.attachHealthBadges || (() => {}), + attachUrlHealthBadges: + badgeHelpers.attachUrlHealthBadges || (() => {}), + }; + + this.formatters = { + formatTimeAgo: formatters.formatTimeAgo || ((timestamp) => timestamp), + formatViewCountLabel: + formatters.formatViewCountLabel || ((total) => `${total}`), + }; + + this.helpers = { + escapeHtml: helpers.escapeHtml || ((value) => value), + isMagnetSupported: helpers.isMagnetSupported || (() => false), + toLocaleString: helpers.toLocaleString || ((value) => value), + }; + + this.assets = { + fallbackThumbnailSrc: assets.fallbackThumbnailSrc || "", + unsupportedBtihMessage: assets.unsupportedBtihMessage || "", + }; + + this.state = { + loadedThumbnails: + state.loadedThumbnails instanceof Map ? state.loadedThumbnails : new Map(), + videosMap: state.videosMap instanceof Map ? state.videosMap : new Map(), + }; + + this.utils = { + dedupeVideos: + typeof utils.dedupeVideos === "function" + ? utils.dedupeVideos + : (videos) => (Array.isArray(videos) ? [...videos] : []), + getAllEvents: + typeof utils.getAllEvents === "function" ? utils.getAllEvents : () => [], + hasOlderVersion: + typeof utils.hasOlderVersion === "function" + ? utils.hasOlderVersion + : () => false, + derivePointerInfo: + typeof utils.derivePointerInfo === "function" + ? utils.derivePointerInfo + : () => null, + persistWatchHistoryMetadata: + typeof utils.persistWatchHistoryMetadata === "function" + ? utils.persistWatchHistoryMetadata + : () => {}, + getShareUrlBase: + typeof utils.getShareUrlBase === "function" + ? utils.getShareUrlBase + : () => "", + buildShareUrlFromNevent: + typeof utils.buildShareUrlFromNevent === "function" + ? utils.buildShareUrlFromNevent + : () => "", + buildShareUrlFromEventId: + typeof utils.buildShareUrlFromEventId === "function" + ? utils.buildShareUrlFromEventId + : () => "", + canManageBlacklist: + typeof utils.canManageBlacklist === "function" + ? utils.canManageBlacklist + : () => false, + canEditVideo: + typeof utils.canEditVideo === "function" ? utils.canEditVideo : () => false, + canDeleteVideo: + typeof utils.canDeleteVideo === "function" + ? utils.canDeleteVideo + : (video) => this.utils.canEditVideo(video), + batchFetchProfiles: + typeof utils.batchFetchProfiles === "function" + ? utils.batchFetchProfiles + : () => {}, + bindThumbnailFallbacks: + typeof utils.bindThumbnailFallbacks === "function" + ? utils.bindThumbnailFallbacks + : () => {}, + handleUrlHealthBadge: + typeof utils.handleUrlHealthBadge === "function" + ? utils.handleUrlHealthBadge + : () => {}, + refreshDiscussionCounts: + typeof utils.refreshDiscussionCounts === "function" + ? utils.refreshDiscussionCounts + : () => {}, + ensureGlobalMoreMenuHandlers: + typeof utils.ensureGlobalMoreMenuHandlers === "function" + ? utils.ensureGlobalMoreMenuHandlers + : () => {}, + closeAllMenus: + typeof utils.closeAllMenus === "function" ? utils.closeAllMenus : () => {}, + }; + + this.renderedVideoIds = new Set(); + this.videoCardInstances = []; + this.viewCountSubscriptions = new Map(); + this.currentVideos = []; + this.lastRenderedVideoSignature = null; + this._lastRenderedVideoListElement = null; + + this.handlers = { + playback: null, + edit: null, + revert: null, + delete: null, + blacklist: null, + }; + + this.emitter = typeof EventTarget !== "undefined" ? new EventTarget() : null; + this._boundClickHandler = this.handleContainerClick.bind(this); + + if (container) { + this.setContainer(container); + } + } + + addEventListener(type, listener, options) { + if (!this.emitter) return; + this.emitter.addEventListener(type, listener, options); + } + + removeEventListener(type, listener, options) { + if (!this.emitter) return; + this.emitter.removeEventListener(type, listener, options); + } + + dispatchEvent(event) { + if (!this.emitter) { + return false; + } + return this.emitter.dispatchEvent(event); + } + + setContainer(container) { + if (container === this.container) { + return; + } + + if (this.container) { + this.container.removeEventListener("click", this._boundClickHandler); + } + + this.container = container || null; + + if (this.container) { + this.container.addEventListener("click", this._boundClickHandler); + } + } + + destroy() { + this.setContainer(null); + this.teardownAllViewCountSubscriptions(); + this.renderedVideoIds.clear(); + this.videoCardInstances = []; + this.currentVideos = []; + this.lastRenderedVideoSignature = null; + this._lastRenderedVideoListElement = null; + } + + setPlaybackHandler(handler) { + this.handlers.playback = typeof handler === "function" ? handler : null; + } + + setEditHandler(handler) { + this.handlers.edit = typeof handler === "function" ? handler : null; + } + + setRevertHandler(handler) { + this.handlers.revert = typeof handler === "function" ? handler : null; + } + + setDeleteHandler(handler) { + this.handlers.delete = typeof handler === "function" ? handler : null; + } + + setBlacklistHandler(handler) { + this.handlers.blacklist = typeof handler === "function" ? handler : null; + } + + render(videos) { + if (!this.container) { + return []; + } + + if (this._lastRenderedVideoListElement !== this.container) { + this.lastRenderedVideoSignature = null; + this._lastRenderedVideoListElement = this.container; + } + + const source = Array.isArray(videos) ? videos : []; + const dedupedVideos = this.utils.dedupeVideos(source); + this.currentVideos = dedupedVideos.slice(); + + this.syncViewCountSubscriptions(dedupedVideos); + this.cleanupThumbnailCache(dedupedVideos); + + if (!dedupedVideos.length) { + this.renderedVideoIds.clear(); + this.videoCardInstances = []; + if (this.lastRenderedVideoSignature === EMPTY_VIDEO_LIST_SIGNATURE) { + return dedupedVideos; + } + this.lastRenderedVideoSignature = EMPTY_VIDEO_LIST_SIGNATURE; + this.container.innerHTML = ` ++ No public videos available yet. Be the first to upload one! +
`; + return dedupedVideos; + } + + dedupedVideos.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)); + + const signaturePayload = dedupedVideos.map((video) => ({ + id: typeof video?.id === "string" ? video.id : "", + createdAt: Number.isFinite(video?.created_at) + ? video.created_at + : Number(video?.created_at) || 0, + deleted: Boolean(video?.deleted), + isPrivate: Boolean(video?.isPrivate), + thumbnail: typeof video?.thumbnail === "string" ? video.thumbnail : "", + url: typeof video?.url === "string" ? video.url : "", + magnet: typeof video?.magnet === "string" ? video.magnet : "", + enableComments: video?.enableComments === false ? false : true, + })); + const signature = JSON.stringify(signaturePayload); + + if (signature === this.lastRenderedVideoSignature) { + return dedupedVideos; + } + this.lastRenderedVideoSignature = signature; + + const previouslyRenderedIds = new Set(this.renderedVideoIds); + this.renderedVideoIds.clear(); + + const allEvents = this.utils.getAllEvents(); + const fragment = this.document?.createDocumentFragment?.(); + if (!fragment) { + return dedupedVideos; + } + + const authorSet = new Set(); + const shareBase = this.computeShareBase(); + const canManageBlacklist = this.utils.canManageBlacklist(); + this.videoCardInstances = []; + + dedupedVideos.forEach((video, index) => { + if (!video || !video.id || !video.title) { + return; + } + + authorSet.add(video.pubkey); + + const shareUrl = this.buildShareUrl(video, shareBase); + const canEdit = this.utils.canEditVideo(video); + const canDelete = this.utils.canDeleteVideo(video); + const highlightClass = canEdit && video.isPrivate + ? "border-2 border-yellow-500" + : "border-none"; + const isNewlyRendered = !previouslyRenderedIds.has(video.id); + const animationClass = isNewlyRendered ? "video-card--enter" : ""; + const timeAgo = this.formatters.formatTimeAgo(video.created_at); + + let hasOlder = false; + if (canEdit && video.videoRootId) { + hasOlder = this.utils.hasOlderVersion(video, allEvents); + } + + const pointerInfo = this.utils.derivePointerInfo(video); + if (pointerInfo) { + this.utils.persistWatchHistoryMetadata(video, pointerInfo); + } + + const videoCard = new VideoCard({ + document: this.document, + video, + index, + shareUrl, + pointerInfo, + timeAgo, + highlightClass, + animationClass, + capabilities: { + canEdit, + canDelete, + canRevert: hasOlder, + canManageBlacklist, + }, + helpers: { + escapeHtml: this.helpers.escapeHtml, + isMagnetSupported: this.helpers.isMagnetSupported, + toLocaleString: this.helpers.toLocaleString, + }, + assets: { + fallbackThumbnailSrc: this.assets.fallbackThumbnailSrc, + unsupportedBtihMessage: this.assets.unsupportedBtihMessage, + }, + state: { loadedThumbnails: this.state.loadedThumbnails }, + ensureGlobalMoreMenuHandlers: () => + this.utils.ensureGlobalMoreMenuHandlers(), + onRequestCloseAllMenus: () => this.closeAllMenus(), + formatters: { + formatTimeAgo: this.formatters.formatTimeAgo, + }, + }); + + videoCard.onPlay = ({ event: domEvent, video: cardVideo }) => { + const trigger = domEvent?.currentTarget || domEvent?.target; + const detail = this.extractPlaybackDetail(trigger, cardVideo || video); + this.emitSelected(detail); + }; + + videoCard.onEdit = ({ video: editVideo, index: editIndex }) => { + if (this.handlers.edit) { + this.handlers.edit({ video: editVideo, index: editIndex }); + } + }; + + videoCard.onRevert = ({ video: revertVideo, index: revertIndex }) => { + if (this.handlers.revert) { + this.handlers.revert({ video: revertVideo, index: revertIndex }); + } + }; + + videoCard.onDelete = ({ video: deleteVideo, index: deleteIndex }) => { + if (this.handlers.delete) { + this.handlers.delete({ video: deleteVideo, index: deleteIndex }); + } + }; + + videoCard.onMoreAction = ({ dataset }) => { + const action = dataset?.action || ""; + if (action === "blacklist-author") { + if (this.handlers.blacklist) { + this.handlers.blacklist({ video, dataset }); + } + return; + } + + if (action === "copy-link") { + const shareDetail = this.buildShareDetail(video, dataset); + this.emitShare(shareDetail); + return; + } + + this.emitContextAction(action, { video, dataset }); + }; + + videoCard.onAuthorNavigate = ({ pubkey }) => { + this.emitContextAction("open-channel", { + video, + dataset: { author: pubkey || video.pubkey || "", context: "card" }, + }); + }; + + const cardEl = videoCard.getRoot(); + if (!cardEl) { + return; + } + + if (pointerInfo) { + this.registerVideoViewCountElement(cardEl, pointerInfo); + } + + if (video && video.id) { + this.state.videosMap.set(video.id, video); + } + + fragment.appendChild(cardEl); + this.renderedVideoIds.add(video.id); + this.videoCardInstances.push(videoCard); + }); + + this.container.innerHTML = ""; + this.container.appendChild(fragment); + + this.badgeHelpers.attachHealthBadges(this.container); + this.badgeHelpers.attachUrlHealthBadges(this.container, ({ badgeEl, url, eventId }) => { + this.utils.handleUrlHealthBadge({ + video: this.state.videosMap.get(eventId) || { id: eventId }, + url, + badgeEl, + }); + }); + + this.utils.bindThumbnailFallbacks(this.container); + + if (this.mediaLoader && typeof this.mediaLoader.observe === "function") { + const lazyEls = this.container.querySelectorAll("[data-lazy]"); + lazyEls.forEach((el) => this.mediaLoader.observe(el)); + } + + if (authorSet.size) { + this.utils.batchFetchProfiles(authorSet); + } + this.utils.refreshDiscussionCounts(dedupedVideos); + this.pruneDetachedViewCountElements(); + + return dedupedVideos; + } + + updateVideo(videoId, patch) { + if (!videoId) { + return null; + } + + const index = this.currentVideos.findIndex((video) => video?.id === videoId); + if (index === -1) { + return null; + } + + const current = this.currentVideos[index] || {}; + const next = + typeof patch === "function" ? patch({ ...current }) : { ...current, ...patch }; + + this.currentVideos[index] = next; + this.state.videosMap.set(videoId, next); + this.render(this.currentVideos.slice()); + return next; + } + + computeShareBase() { + const explicit = this.utils.getShareUrlBase(); + if (explicit) { + return explicit; + } + + const win = this.window; + if (win?.location) { + const origin = win.location.origin || ""; + const pathname = win.location.pathname || ""; + if (origin || pathname) { + return `${origin}${pathname}`; + } + if (win.location.href) { + return win.location.href.split(/[?#]/)[0]; + } + } + return ""; + } + + buildShareUrl(video, shareBase) { + if (!video?.id) { + return "#"; + } + + const win = this.window; + try { + const nevent = win?.NostrTools?.nip19?.neventEncode({ id: video.id }); + if (nevent) { + const explicit = this.utils.buildShareUrlFromNevent(nevent); + if (explicit) { + return explicit; + } + if (shareBase) { + return `${shareBase}?v=${encodeURIComponent(nevent)}`; + } + } + } catch (error) { + // ignore and fall back below + } + + if (shareBase) { + return `${shareBase}?v=${encodeURIComponent(video.id)}`; + } + + return "#"; + } + + syncViewCountSubscriptions(videos) { + const activePointerKeys = new Set(); + videos.forEach((video) => { + const pointerInfo = this.utils.derivePointerInfo(video); + if (pointerInfo?.key) { + activePointerKeys.add(pointerInfo.key); + } + }); + + const keysToRemove = []; + for (const [key, entry] of this.viewCountSubscriptions.entries()) { + if (!activePointerKeys.has(key)) { + keysToRemove.push(key); + continue; + } + if (entry && entry.elements instanceof Set) { + entry.elements.clear(); + } + } + + keysToRemove.forEach((key) => { + const entry = this.viewCountSubscriptions.get(key); + if (entry && entry.pointer && entry.token) { + try { + unsubscribeFromVideoViewCount(entry.pointer, entry.token); + } catch (error) { + console.warn( + `[viewCount] Failed to unsubscribe from stale pointer ${key}:`, + error + ); + } + } + this.viewCountSubscriptions.delete(key); + }); + } + + cleanupThumbnailCache(videos) { + const cache = this.state.loadedThumbnails; + if (!cache || !cache.size) { + return; + } + + const activeIds = new Set(); + videos.forEach((video) => { + if (video && typeof video.id === "string" && video.id) { + activeIds.add(video.id); + } + }); + + Array.from(cache.keys()).forEach((videoId) => { + if (!activeIds.has(videoId)) { + cache.delete(videoId); + } + }); + } + + extractPlaybackDetail(trigger, video) { + const element = trigger instanceof HTMLElement ? trigger : null; + const target = element?.closest("[data-play-url],[data-play-magnet]") || element; + + const rawUrlValue = + (target?.dataset && typeof target.dataset.playUrl === "string" + ? target.dataset.playUrl + : null) ?? target?.getAttribute?.("data-play-url") ?? ""; + const rawMagnetValue = + (target?.dataset && typeof target.dataset.playMagnet === "string" + ? target.dataset.playMagnet + : null) ?? target?.getAttribute?.("data-play-magnet") ?? ""; + + let url = ""; + if (rawUrlValue) { + try { + url = decodeURIComponent(rawUrlValue); + } catch (error) { + url = rawUrlValue; + } + } + + const magnet = typeof rawMagnetValue === "string" ? rawMagnetValue : ""; + const videoId = + target?.dataset?.videoId || target?.getAttribute?.("data-video-id") || video?.id || ""; + + return { videoId, url, magnet, video }; + } + + emitSelected(detail) { + const enriched = { + ...detail, + video: detail?.video || this.state.videosMap.get(detail?.videoId) || null, + }; + + if (this.handlers.playback) { + this.handlers.playback(enriched); + } + + this.emit("video:selected", enriched); + } + + buildShareDetail(video, dataset = {}) { + const eventId = dataset.eventId || video?.id || ""; + const shareUrl = this.utils.buildShareUrlFromEventId(eventId); + return { + action: "copy-link", + video, + eventId, + shareUrl, + dataset, + }; + } + + emitShare(detail) { + this.emit("video:share", detail); + } + + emitContextAction(action, payload = {}) { + if (!action) { + return; + } + this.emit("video:context-action", { action, ...payload }); + } + + emit(type, detail) { + if (this.emitter) { + this.emitter.dispatchEvent(new CustomEvent(type, { detail })); + } + if (this.container) { + this.container.dispatchEvent( + new CustomEvent(type, { detail, bubbles: true, composed: true }) + ); + } + } + + ensureViewCountSubscription(pointerInfo) { + if (!pointerInfo || !pointerInfo.key || !pointerInfo.pointer) { + return null; + } + + const existing = this.viewCountSubscriptions.get(pointerInfo.key); + if (existing) { + return existing; + } + + const entry = { + pointer: pointerInfo.pointer, + key: pointerInfo.key, + token: null, + elements: new Set(), + lastTotal: null, + lastStatus: "idle", + lastText: "– views", + }; + + try { + const token = subscribeToVideoViewCount(pointerInfo.pointer, ({ total, status }) => { + let text; + if (Number.isFinite(total)) { + const numeric = Number(total); + entry.lastTotal = numeric; + text = this.formatters.formatViewCountLabel(numeric); + } else if (status === "hydrating") { + text = "Loading views…"; + } else { + text = "– views"; + } + + entry.lastStatus = status; + entry.lastText = text; + + for (const el of Array.from(entry.elements)) { + if (!el || !el.isConnected) { + entry.elements.delete(el); + continue; + } + el.textContent = text; + } + }); + entry.token = token; + this.viewCountSubscriptions.set(pointerInfo.key, entry); + return entry; + } catch (error) { + console.warn("[viewCount] Failed to subscribe to view counter:", error); + return null; + } + } + + registerVideoViewCountElement(cardEl, pointerInfo) { + if (!cardEl || !pointerInfo) { + return; + } + + const viewCountEl = cardEl.querySelector("[data-view-count]"); + if (!viewCountEl) { + return; + } + + if (!viewCountEl.textContent || !viewCountEl.textContent.trim()) { + viewCountEl.textContent = "– views"; + } + + const entry = this.ensureViewCountSubscription(pointerInfo); + if (!entry) { + return; + } + + entry.elements.add(viewCountEl); + if (typeof pointerInfo.key === "string") { + viewCountEl.dataset.viewPointer = pointerInfo.key; + } + viewCountEl.textContent = entry.lastText; + } + + pruneDetachedViewCountElements() { + for (const entry of this.viewCountSubscriptions.values()) { + if (!entry || !(entry.elements instanceof Set)) { + continue; + } + for (const el of Array.from(entry.elements)) { + if (!el || !el.isConnected) { + entry.elements.delete(el); + } + } + } + } + + teardownAllViewCountSubscriptions() { + const keys = Array.from(this.viewCountSubscriptions.keys()); + keys.forEach((key) => { + const entry = this.viewCountSubscriptions.get(key); + if (entry && entry.token && entry.pointer) { + try { + unsubscribeFromVideoViewCount(entry.pointer, entry.token); + } catch (error) { + console.warn( + `[viewCount] Failed to unsubscribe from pointer ${key}:`, + error + ); + } + } + this.viewCountSubscriptions.delete(key); + }); + } + + closeAllMenus() { + if (Array.isArray(this.videoCardInstances) && this.videoCardInstances.length) { + this.videoCardInstances.forEach((card) => { + if (!card) { + return; + } + if (typeof card.closeMoreMenu === "function") { + card.closeMoreMenu(); + } + if (typeof card.closeSettingsMenu === "function") { + card.closeSettingsMenu(); + } + }); + } + + if (!this.document) { + return; + } + + const menus = this.document.querySelectorAll("[data-more-menu]"); + menus.forEach((menu) => { + if (menu instanceof HTMLElement) { + menu.classList.add("hidden"); + } + }); + + const buttons = this.document.querySelectorAll("[data-more-dropdown]"); + buttons.forEach((btn) => { + if (btn instanceof HTMLElement) { + btn.setAttribute("aria-expanded", "false"); + } + }); + } + + handleContainerClick(event) { + if (!event || !this.container) { + return; + } + + const target = event.target instanceof HTMLElement ? event.target : null; + if (!target) { + return; + } + + const trigger = target.closest("[data-play-magnet],[data-play-url]"); + if (!trigger || !this.container.contains(trigger)) { + return; + } + + const isPrimaryClick = + typeof event.button !== "number" || event.button === 0; + if (!isPrimaryClick || event.ctrlKey || event.metaKey) { + return; + } + + event.preventDefault(); + + const detail = this.extractPlaybackDetail(trigger, null); + this.emitSelected(detail); + } +}