Merge pull request #276 from PR0M3TH3AN/codex/add-videolistview-class-with-dependencies

Refactor video list rendering into VideoListView
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-03 16:36:30 -04:00
commit f579c965a7
2 files changed
+939 -439

No files matched your search

+139 -439
View File
@@ -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 = `
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
No public videos available yet. Be the first to upload one!
</p>`;
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]");
+800
View File
@@ -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 = `
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
No public videos available yet. Be the first to upload one!
</p>`;
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);
}
}