Files
bitvid/js/ui/views/VideoListView.js
T

1130 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { VideoCard } from "../components/VideoCard.js";
import {
subscribeToVideoViewCount,
unsubscribeFromVideoViewCount,
} from "../../viewCounter.js";
import { normalizeDesignSystemContext } from "../../designSystem.js";
import { userLogger } from "../../utils/logger.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 = {},
renderers = {},
allowNsfw = true,
designSystem = null,
} = 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(),
urlHealthByVideoId:
state.urlHealthByVideoId instanceof Map
? state.urlHealthByVideoId
: new Map(),
streamHealthByVideoId:
state.streamHealthByVideoId instanceof Map
? state.streamHealthByVideoId
: new Map(),
feedMetadata:
state.feedMetadata && typeof state.feedMetadata === "object"
? { ...state.feedMetadata }
: null,
};
this.renderers = {
getLoadingMarkup:
typeof renderers.getLoadingMarkup === "function"
? renderers.getLoadingMarkup
: () => "",
};
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
: () => "",
getKnownVideoPostedAt:
typeof utils.getKnownVideoPostedAt === "function"
? utils.getKnownVideoPostedAt
: () => null,
resolveVideoPostedAt:
typeof utils.resolveVideoPostedAt === "function"
? utils.resolveVideoPostedAt
: null,
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 : () => {},
requestMoreMenu:
typeof utils.requestMoreMenu === "function"
? utils.requestMoreMenu
: () => {},
closeMoreMenu:
typeof utils.closeMoreMenu === "function"
? utils.closeMoreMenu
: () => false,
requestSettingsMenu:
typeof utils.requestSettingsMenu === "function"
? utils.requestSettingsMenu
: () => {},
closeSettingsMenu:
typeof utils.closeSettingsMenu === "function"
? utils.closeSettingsMenu
: () => false,
};
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,
moderationOverride: null,
};
this.allowNsfw = allowNsfw !== false;
this.designSystem = normalizeDesignSystemContext(designSystem);
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);
}
}
mount(container) {
this.setContainer(container);
return this.container;
}
unmount() {
this.setContainer(null);
}
showLoading(message = "") {
if (!this.container) {
return;
}
const markup = this.renderers.getLoadingMarkup(message);
if (typeof markup === "string") {
this.container.innerHTML = markup;
}
}
destroy() {
this.unmount();
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;
}
setModerationOverrideHandler(handler) {
this.handlers.moderationOverride = typeof handler === "function" ? handler : null;
}
render(videos, metadata = null) {
if (!this.container) {
return [];
}
if (this._lastRenderedVideoListElement !== this.container) {
this.lastRenderedVideoSignature = null;
this._lastRenderedVideoListElement = this.container;
}
const normalizedMetadata =
metadata && typeof metadata === "object" ? { ...metadata } : null;
this.state.feedMetadata = normalizedMetadata;
const source = Array.isArray(videos) ? videos : [];
const dedupedVideos = this.utils.dedupeVideos(source);
const displayVideos = dedupedVideos.filter((video) => {
const canEdit = this.utils.canEditVideo(video);
const isPrivate = video?.isPrivate === true;
if (!this.allowNsfw && video?.isNsfw === true && !canEdit) {
return false;
}
return canEdit || !isPrivate;
});
this.syncViewCountSubscriptions(displayVideos);
this.cleanupThumbnailCache(displayVideos);
this.cleanupHealthCache(displayVideos);
if (!displayVideos.length) {
this.renderedVideoIds.clear();
this.videoCardInstances = [];
if (this.lastRenderedVideoSignature === EMPTY_VIDEO_LIST_SIGNATURE) {
return displayVideos;
}
this.lastRenderedVideoSignature = EMPTY_VIDEO_LIST_SIGNATURE;
this.container.innerHTML = `
<p class="flex justify-center items-center h-full w-full text-center text-subtle">
No public videos available yet. Be the first to upload one!
</p>`;
return displayVideos;
}
displayVideos.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
this.currentVideos = displayVideos.slice();
const signaturePayload = displayVideos.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),
isNsfw: Boolean(video?.isNsfw),
isForKids: Boolean(video?.isForKids),
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 displayVideos;
}
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 displayVideos;
}
const authorSet = new Set();
const shareBase = this.computeShareBase();
const canManageBlacklist = this.utils.canManageBlacklist();
this.videoCardInstances = [];
displayVideos.forEach((video, index) => {
if (!video || !video.id || !video.title) {
return;
}
authorSet.add(video.pubkey);
const reporterPubkeys = Array.isArray(video?.moderation?.reporterPubkeys)
? video.moderation.reporterPubkeys
: [];
reporterPubkeys.forEach((pubkey) => {
if (typeof pubkey === "string" && pubkey.trim()) {
authorSet.add(pubkey.trim());
}
});
const shareUrl = this.buildShareUrl(video, shareBase);
const canEdit = this.utils.canEditVideo(video);
const canDelete = this.utils.canDeleteVideo(video);
let cardState = "";
if (canEdit && video.isPrivate) {
cardState = "private";
}
const viewerSeesBlockedNsfw =
!this.allowNsfw && video?.isNsfw === true && canEdit;
if (viewerSeesBlockedNsfw) {
cardState = "critical";
}
const isNewlyRendered = !previouslyRenderedIds.has(video.id);
const motionState = isNewlyRendered ? "enter" : "";
const knownPostedAt = this.utils.getKnownVideoPostedAt(video);
const normalizedPostedAt = Number.isFinite(knownPostedAt)
? Math.floor(knownPostedAt)
: null;
const fallbackTimestamp =
normalizedPostedAt !== null
? normalizedPostedAt
: Number.isFinite(video?.created_at)
? Math.floor(video.created_at)
: null;
const timeAgo =
fallbackTimestamp !== null
? this.formatters.formatTimeAgo(fallbackTimestamp)
: "";
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,
postedAt: normalizedPostedAt,
cardState,
motionState,
nsfwContext: {
isNsfw: video?.isNsfw === true,
allowNsfw: this.allowNsfw,
viewerIsOwner: canEdit,
},
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,
urlHealthByVideoId: this.state.urlHealthByVideoId,
streamHealthByVideoId: this.state.streamHealthByVideoId,
},
ensureGlobalMoreMenuHandlers: () =>
this.utils.ensureGlobalMoreMenuHandlers(),
onRequestCloseAllMenus: (detail = {}) =>
this.closeAllMenus({ restoreFocus: false, ...detail }),
formatters: {
formatTimeAgo: this.formatters.formatTimeAgo,
},
designSystem: this.designSystem,
});
videoCard.onPlay = ({ event: domEvent, video: cardVideo }) => {
const trigger = domEvent?.currentTarget || domEvent?.target;
const detail = this.extractPlaybackDetail(trigger, cardVideo || video);
this.emitSelected(detail);
};
videoCard.onModerationOverride = ({ event: overrideEvent }) => {
if (!this.handlers.moderationOverride) {
return false;
}
const trigger =
overrideEvent?.currentTarget || overrideEvent?.target || null;
return this.handlers.moderationOverride({
event: overrideEvent,
video,
card: videoCard,
trigger,
});
};
videoCard.onEdit = ({ event: editEvent, video: editVideo, index: editIndex }) => {
if (this.handlers.edit) {
const trigger = editEvent?.currentTarget || editEvent?.target || null;
this.handlers.edit({
video: editVideo,
index: editIndex,
trigger,
});
}
};
videoCard.onRevert = ({
event: revertEvent,
video: revertVideo,
index: revertIndex,
}) => {
if (this.handlers.revert) {
const trigger = revertEvent?.currentTarget || revertEvent?.target || null;
this.handlers.revert({
video: revertVideo,
index: revertIndex,
trigger,
});
}
};
videoCard.onDelete = ({
event: deleteEvent,
video: deleteVideo,
index: deleteIndex,
}) => {
if (this.handlers.delete) {
const trigger = deleteEvent?.currentTarget || deleteEvent?.target || null;
this.handlers.delete({
video: deleteVideo,
index: deleteIndex,
trigger,
});
}
};
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" },
});
};
videoCard.onRequestMoreMenu = (detail = {}) => {
if (typeof this.utils.requestMoreMenu !== "function") {
return;
}
const payload = {
...detail,
video: detail.video || video,
pointerInfo: detail.pointerInfo || pointerInfo,
};
this.utils.requestMoreMenu(payload);
};
videoCard.onCloseMoreMenu = (detail = {}) => {
if (typeof this.utils.closeMoreMenu === "function") {
return this.utils.closeMoreMenu(detail);
}
return false;
};
videoCard.onRequestSettingsMenu = (detail = {}) => {
if (typeof this.utils.requestSettingsMenu === "function") {
this.utils.requestSettingsMenu(detail);
}
};
videoCard.onCloseSettingsMenu = (detail = {}) => {
if (typeof this.utils.closeSettingsMenu === "function") {
return this.utils.closeSettingsMenu(detail);
}
return false;
};
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);
const shouldResolvePostedAt =
normalizedPostedAt === null &&
typeof this.utils.resolveVideoPostedAt === "function";
if (shouldResolvePostedAt) {
Promise.resolve(this.utils.resolveVideoPostedAt(video))
.then((resolvedPostedAt) => {
if (!Number.isFinite(resolvedPostedAt)) {
return;
}
if (!this.videoCardInstances.includes(videoCard)) {
return;
}
if (videoCard.video?.id !== video.id) {
return;
}
videoCard.updatePostedAt(Math.floor(resolvedPostedAt));
})
.catch((error) => {
if (this.window?.userLogger?.warn) {
this.window.userLogger.warn(
"[VideoListView] Failed to resolve posted timestamp:",
error
);
}
});
}
});
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(displayVideos, {
container: this.container,
});
this.pruneDetachedViewCountElements();
return displayVideos;
}
cacheUrlHealth(videoId, entry) {
if (!videoId || !(this.state.urlHealthByVideoId instanceof Map)) {
return;
}
if (!entry || typeof entry !== "object") {
this.state.urlHealthByVideoId.delete(videoId);
return;
}
const normalized = {
status:
typeof entry.status === "string" && entry.status ? entry.status : "checking",
message:
typeof entry.message === "string" && entry.message
? entry.message
: "⏳ CDN",
};
if (Number.isFinite(entry.lastCheckedAt)) {
normalized.lastCheckedAt = Math.floor(entry.lastCheckedAt);
}
this.state.urlHealthByVideoId.set(videoId, normalized);
}
cacheStreamHealth(videoId, entry) {
if (!videoId || !(this.state.streamHealthByVideoId instanceof Map)) {
return;
}
if (!entry || typeof entry !== "object") {
this.state.streamHealthByVideoId.delete(videoId);
return;
}
const normalized = {
state:
typeof entry.state === "string" && entry.state ? entry.state : "unknown",
};
if (Number.isFinite(entry.peers)) {
normalized.peers = Math.max(0, Number(entry.peers));
}
if (typeof entry.reason === "string" && entry.reason) {
normalized.reason = entry.reason;
}
if (Number.isFinite(entry.checkedAt)) {
normalized.checkedAt = Math.floor(entry.checkedAt);
}
if (typeof entry.text === "string" && entry.text) {
normalized.text = entry.text;
}
if (typeof entry.tooltip === "string" && entry.tooltip) {
normalized.tooltip = entry.tooltip;
}
if (entry.role === "alert" || entry.role === "status") {
normalized.role = entry.role;
}
if (entry.ariaLive === "assertive" || entry.ariaLive === "polite") {
normalized.ariaLive = entry.ariaLive;
}
this.state.streamHealthByVideoId.set(videoId, normalized);
}
cleanupHealthCache(videos) {
const activeIds = new Set();
if (Array.isArray(videos)) {
videos.forEach((video) => {
if (video && typeof video.id === "string" && video.id) {
activeIds.add(video.id);
}
});
}
if (this.state.urlHealthByVideoId instanceof Map) {
Array.from(this.state.urlHealthByVideoId.keys()).forEach((videoId) => {
if (!activeIds.has(videoId)) {
this.state.urlHealthByVideoId.delete(videoId);
}
});
}
if (this.state.streamHealthByVideoId instanceof Map) {
Array.from(this.state.streamHealthByVideoId.keys()).forEach((videoId) => {
if (!activeIds.has(videoId)) {
this.state.streamHealthByVideoId.delete(videoId);
}
});
}
}
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(), this.state.feedMetadata);
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) {
userLogger.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, trigger: element };
}
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) {
userLogger.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) {
userLogger.warn(
`[viewCount] Failed to unsubscribe from pointer ${key}:`,
error
);
}
}
this.viewCountSubscriptions.delete(key);
});
}
closeAllMenus(options = {}) {
const restoreFocus = options?.restoreFocus !== false;
const skipCard = options?.skipCard || null;
const skipTrigger = options?.skipTrigger || null;
if (Array.isArray(this.videoCardInstances) && this.videoCardInstances.length) {
this.videoCardInstances.forEach((card) => {
if (!card) {
return;
}
if (skipCard && card === skipCard) {
if (typeof card.closeSettingsMenu === "function") {
card.closeSettingsMenu({ restoreFocus });
}
return;
}
if (typeof card.closeMoreMenu === "function") {
card.closeMoreMenu({ restoreFocus });
}
if (typeof card.closeSettingsMenu === "function") {
card.closeSettingsMenu({ restoreFocus });
}
});
}
if (options?.skipController) {
return;
}
if (typeof this.utils.closeAllMenus === "function") {
const payload = { skipView: true, restoreFocus };
if (skipTrigger) {
payload.skipTrigger = skipTrigger;
}
this.utils.closeAllMenus(payload);
}
}
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);
}
}