mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-01 18:32:27 +00:00
Merge pull request #276 from PR0M3TH3AN/codex/add-videolistview-class-with-dependencies
Refactor video list rendering into VideoListView
This commit is contained in:
2 files changed
+939
-439
No files matched your search
@@ -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]");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user