From 37162978543ef12f695984be3e81c7e4dfc16aaf Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:31:51 -0400 Subject: [PATCH] Add similar content support to video modal --- js/ui/components/VideoModal.js | 480 +++++++++++++++++++++++++++++++++ 1 file changed, 480 insertions(+) diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index 11d93ea3..5d43ca26 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -5,6 +5,12 @@ import { attachAmbientBackground } from "../ambientBackground.js"; import { applyDesignSystemAttributes } from "../../designSystem.js"; import { devLogger } from "../../utils/logger.js"; import { renderTagPillStrip } from "./tagPillList.js"; +import { VideoCard } from "./VideoCard.js"; +import { + subscribeToVideoViewCount, + unsubscribeFromVideoViewCount, + formatViewCount, +} from "../../viewCounter.js"; export class VideoModal { constructor({ @@ -83,6 +89,13 @@ export class VideoModal { }; this.reactionNumberFormatter = null; + this.similarContentContainer = null; + this.similarContentHeading = null; + this.similarContentList = null; + this.similarContentCards = []; + this.similarContentViewCountSubscriptions = []; + this.pendingSimilarContent = null; + this.modalAccessibility = null; this.modalNavScrollHandler = null; @@ -291,6 +304,8 @@ export class VideoModal { ); } + this.clearSimilarContent({ preservePending: true }); + this.playerModal = playerModal; this.modalPanel = playerModal.querySelector(".bv-modal__panel") || null; this.modalBackdrop = playerModal.querySelector("[data-dismiss]") || null; @@ -305,6 +320,33 @@ export class VideoModal { this.cleanupVideoTags(this.videoTagsRoot); } + const similarHeading = + playerModal.querySelector("#playerModalSimilarContentHeading") || null; + const similarList = + playerModal.querySelector("#playerModalSimilarContentList") || null; + const similarSection = + similarList?.closest( + "[aria-labelledby='playerModalSimilarContentHeading']" + ) || similarHeading?.closest( + "[aria-labelledby='playerModalSimilarContentHeading']" + ) || null; + const similarContainer = + similarList?.closest(".watch-container") || + similarHeading?.closest(".watch-container") || + similarSection?.closest(".watch-container") || + similarSection || + null; + + this.similarContentHeading = similarHeading; + this.similarContentList = similarList; + this.similarContentContainer = similarContainer; + + if (similarList && similarList.children.length) { + this.toggleSimilarContentVisibility(true); + } else { + this.toggleSimilarContentVisibility(false); + } + const previousTags = Array.isArray(this.videoTagsData) ? [...this.videoTagsData] : []; @@ -353,6 +395,12 @@ export class VideoModal { this.renderVideoTags(previousTags); + if (Array.isArray(this.pendingSimilarContent)) { + const pendingSimilar = this.pendingSimilarContent; + this.pendingSimilarContent = null; + this.setSimilarContent(pendingSimilar); + } + this.ambientCanvas = playerModal.querySelector("#ambientCanvas") || null; this.modalZapDialog = playerModal.querySelector("#modalZapDialog") || null; @@ -1058,6 +1106,7 @@ export class VideoModal { this.modalMorePopover.close({ restoreFocus: false }); } this.renderVideoTags([]); + this.clearSimilarContent(); this.forceRemovePoster("close"); } @@ -2245,6 +2294,437 @@ export class VideoModal { } } + setSimilarContent(entries = []) { + const normalizedItems = Array.isArray(entries) + ? entries.filter((item) => item && typeof item === "object") + : []; + + if (!this.similarContentList) { + this.pendingSimilarContent = normalizedItems.slice(); + return; + } + + this.pendingSimilarContent = null; + this.clearSimilarContent(); + + if (!normalizedItems.length) { + return; + } + + const fragment = this.document.createDocumentFragment(); + const renderedCards = []; + const viewSubscriptions = []; + + normalizedItems.forEach((item, position) => { + const baseVideo = + item && typeof item === "object" && item.video && typeof item.video === "object" + ? item.video + : item; + if ( + !baseVideo || + typeof baseVideo !== "object" || + !baseVideo.id || + !baseVideo.title + ) { + return; + } + + const pointerInfo = this.normalizeSimilarPointerInfo(item, baseVideo); + const shareUrl = + typeof item.shareUrl === "string" && item.shareUrl.trim() + ? item.shareUrl.trim() + : typeof baseVideo.shareUrl === "string" && baseVideo.shareUrl.trim() + ? baseVideo.shareUrl.trim() + : typeof baseVideo.url === "string" && baseVideo.url.trim() + ? baseVideo.url.trim() + : "#"; + const timeAgo = + typeof item.timeAgo === "string" && item.timeAgo + ? item.timeAgo + : typeof baseVideo.timeAgo === "string" && baseVideo.timeAgo + ? baseVideo.timeAgo + : ""; + const postedAtCandidate = + Number.isFinite(item.postedAt) + ? Math.floor(item.postedAt) + : Number.isFinite(baseVideo.postedAt) + ? Math.floor(baseVideo.postedAt) + : Number.isFinite(baseVideo.rootCreatedAt) + ? Math.floor(baseVideo.rootCreatedAt) + : Number.isFinite(baseVideo.created_at) + ? Math.floor(baseVideo.created_at) + : null; + + const capabilities = + item && typeof item.capabilities === "object" && item.capabilities + ? { ...item.capabilities } + : {}; + const nsfwContext = + item && typeof item.nsfwContext === "object" && item.nsfwContext + ? { ...item.nsfwContext } + : { + isNsfw: baseVideo?.isNsfw === true, + allowNsfw: item?.allowNsfw !== false, + viewerIsOwner: + item?.viewerIsOwner === true || + Boolean(item?.capabilities?.canEdit), + }; + + const cardOptions = { + document: this.document, + video: baseVideo, + index: position, + shareUrl, + pointerInfo, + timeAgo, + postedAt: postedAtCandidate, + capabilities, + nsfwContext, + variant: "compact", + }; + + if (typeof item.cardState === "string" && item.cardState) { + cardOptions.cardState = item.cardState; + } + if (typeof item.motionState === "string" && item.motionState) { + cardOptions.motionState = item.motionState; + } + if (item.helpers && typeof item.helpers === "object") { + cardOptions.helpers = item.helpers; + } + if (item.assets && typeof item.assets === "object") { + cardOptions.assets = item.assets; + } + if (item.state && typeof item.state === "object") { + cardOptions.state = item.state; + } + if (item.designSystem) { + cardOptions.designSystem = item.designSystem; + } + + let card; + try { + card = new VideoCard(cardOptions); + } catch (error) { + this.log("[VideoModal] Failed to render similar content card", error); + return; + } + + const cardIndex = renderedCards.length; + this.prepareSimilarVideoCard(card, cardOptions, cardIndex); + + const root = card.getRoot(); + if (!root) { + return; + } + + const listItem = this.document.createElement("li"); + listItem.classList.add("player-modal__module-item"); + listItem.appendChild(root); + fragment.appendChild(listItem); + renderedCards.push(card); + + const viewSubscription = this.attachSimilarCardViewCounter( + card, + pointerInfo + ); + if (viewSubscription) { + viewSubscriptions.push(viewSubscription); + } + }); + + this.similarContentList.appendChild(fragment); + this.similarContentCards = renderedCards; + this.similarContentViewCountSubscriptions = viewSubscriptions; + this.toggleSimilarContentVisibility(renderedCards.length > 0); + } + + clearSimilarContent(options = {}) { + this.teardownSimilarContentSubscriptions(); + + if (Array.isArray(this.similarContentCards)) { + this.similarContentCards.forEach((card) => { + if (!card) { + return; + } + try { + card.closeMoreMenu?.({ restoreFocus: false }); + } catch (error) { + this.log("[VideoModal] Failed to close more menu during cleanup", error); + } + try { + card.closeSettingsMenu?.({ restoreFocus: false }); + } catch (error) { + this.log( + "[VideoModal] Failed to close settings menu during cleanup", + error + ); + } + card.onPlay = null; + }); + } + + this.similarContentCards = []; + if (this.similarContentList) { + this.similarContentList.textContent = ""; + } + + if (!options?.preservePending) { + this.pendingSimilarContent = null; + } + + this.toggleSimilarContentVisibility(false); + } + + teardownSimilarContentSubscriptions() { + if (!Array.isArray(this.similarContentViewCountSubscriptions)) { + this.similarContentViewCountSubscriptions = []; + return; + } + + for (const entry of this.similarContentViewCountSubscriptions) { + if (!entry || !entry.pointer || !entry.token) { + continue; + } + try { + unsubscribeFromVideoViewCount(entry.pointer, entry.token); + } catch (error) { + this.log( + "[VideoModal] Failed to unsubscribe similar view counter", + error + ); + } + } + + this.similarContentViewCountSubscriptions = []; + } + + toggleSimilarContentVisibility(isVisible) { + const container = this.similarContentContainer; + if (container) { + if (isVisible) { + container.removeAttribute("hidden"); + container.setAttribute("aria-hidden", "false"); + } else { + container.setAttribute("hidden", ""); + container.setAttribute("aria-hidden", "true"); + } + } + + const heading = this.similarContentHeading; + if (heading) { + if (isVisible) { + heading.removeAttribute("hidden"); + heading.setAttribute("aria-hidden", "false"); + } else { + heading.setAttribute("hidden", ""); + heading.setAttribute("aria-hidden", "true"); + } + } + + const list = this.similarContentList; + if (list) { + if (isVisible) { + list.removeAttribute("hidden"); + list.setAttribute("aria-hidden", "false"); + } else { + list.setAttribute("hidden", ""); + list.setAttribute("aria-hidden", "true"); + } + } + } + + normalizeSimilarPointerInfo(entry, video) { + const candidateInfo = + entry && typeof entry.pointerInfo === "object" + ? entry.pointerInfo + : video && typeof video.pointerInfo === "object" + ? video.pointerInfo + : null; + + let pointer = candidateInfo?.pointer ?? null; + let key = + typeof candidateInfo?.key === "string" && candidateInfo.key + ? candidateInfo.key + : typeof candidateInfo?.pointerKey === "string" && candidateInfo.pointerKey + ? candidateInfo.pointerKey + : ""; + + if (!pointer) { + pointer = + entry?.pointer ?? entry?.pointerTag ?? video?.pointer ?? video?.pointerTag ?? null; + } + + if (!key) { + const keyCandidates = [ + entry?.pointerKey, + entry?.pointerId, + entry?.pointerIdentifier, + video?.pointerKey, + video?.pointerId, + video?.pointerIdentifier, + ]; + for (const candidateKey of keyCandidates) { + if (typeof candidateKey === "string" && candidateKey.trim()) { + key = candidateKey.trim(); + break; + } + } + } + + if (!pointer) { + if (!key) { + return null; + } + return { pointer: null, key }; + } + + if (!key) { + key = this.derivePointerKeyFromInput(pointer); + } + + return { pointer, key }; + } + + derivePointerKeyFromInput(pointer) { + if (!pointer) { + return ""; + } + if (Array.isArray(pointer)) { + const [type, value] = pointer; + if (typeof value === "string" && value.trim()) { + const normalizedType = type === "a" ? "a" : "e"; + return `${normalizedType}:${value.trim()}`; + } + return ""; + } + if (typeof pointer === "object") { + if (typeof pointer.key === "string" && pointer.key.trim()) { + return pointer.key.trim(); + } + if ( + typeof pointer.pointerKey === "string" && + pointer.pointerKey.trim() + ) { + return pointer.pointerKey.trim(); + } + if ( + typeof pointer.type === "string" && + typeof pointer.value === "string" && + pointer.value.trim() + ) { + const normalizedType = pointer.type === "a" ? "a" : "e"; + return `${normalizedType}:${pointer.value.trim()}`; + } + if (Array.isArray(pointer.tag)) { + return this.derivePointerKeyFromInput(pointer.tag); + } + return ""; + } + if (typeof pointer === "string") { + const trimmed = pointer.trim(); + if (!trimmed) { + return ""; + } + if (/^(?:naddr|nevent)/i.test(trimmed)) { + return ""; + } + if (trimmed.includes(":")) { + return trimmed; + } + return `e:${trimmed}`; + } + return ""; + } + + prepareSimilarVideoCard(card, cardOptions, index) { + if (!card) { + return; + } + + card.onPlay = ({ event, video: selectedVideo, card: sourceCard }) => { + this.dispatch("similar:select", { + event, + video: selectedVideo || cardOptions.video, + card: sourceCard || card, + index, + pointerInfo: cardOptions.pointerInfo || null, + shareUrl: cardOptions.shareUrl || "#", + }); + }; + + if (card.moreMenuButton) { + const button = card.moreMenuButton; + const parent = button.parentElement; + if (parent) { + parent.removeChild(button); + if (!parent.childElementCount) { + parent.remove(); + } + } else { + button.remove(); + } + card.moreMenuButton = null; + } + } + + attachSimilarCardViewCounter(card, pointerInfo) { + if (!card || typeof card.getViewCountElement !== "function") { + return null; + } + + const viewEl = card.getViewCountElement(); + if (!viewEl) { + return null; + } + + if (pointerInfo?.key) { + viewEl.dataset.viewPointer = pointerInfo.key; + } else if (viewEl.dataset?.viewPointer) { + delete viewEl.dataset.viewPointer; + } + + if (!pointerInfo || !pointerInfo.pointer) { + viewEl.textContent = "– views"; + return null; + } + + viewEl.textContent = "Loading views…"; + + try { + const token = subscribeToVideoViewCount( + pointerInfo.pointer, + ({ total, status }) => { + if (!viewEl || !viewEl.isConnected) { + return; + } + viewEl.textContent = this.getViewCountLabel(total, status); + } + ); + return { pointer: pointerInfo.pointer, token }; + } catch (error) { + this.log("[VideoModal] Failed to subscribe similar view counter", error); + viewEl.textContent = "– views"; + return null; + } + } + + getViewCountLabel(total, status) { + if (Number.isFinite(total)) { + return this.formatViewCountLabel(Number(total)); + } + if (status === "hydrating") { + return "Loading views…"; + } + return "– views"; + } + + formatViewCountLabel(total) { + const numeric = Number.isFinite(total) ? Math.max(0, Number(total)) : 0; + const label = numeric === 1 ? "view" : "views"; + return `${formatViewCount(numeric)} ${label}`; + } + syncMoreMenuData({ currentVideo, canManageBlacklist }) { this.modalMoreMenuContext.video = currentVideo || null; this.modalMoreMenuContext.canManageBlacklist = !!canManageBlacklist;