diff --git a/js/app.js b/js/app.js index e379a8d6..f0b307c8 100644 --- a/js/app.js +++ b/js/app.js @@ -11,6 +11,7 @@ import { deriveTorrentPlaybackConfig } from "./playbackUtils.js"; import { URL_FIRST_ENABLED } from "./constants.js"; import { trackVideoView } from "./analytics.js"; import { attachHealthBadges } from "./gridHealth.js"; +import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { initialWhitelist, initialBlacklist, @@ -3693,9 +3694,17 @@ class bitvidApp { if (cardEl.dataset.urlHealthReason) { delete cardEl.dataset.urlHealthReason; } + cardEl.dataset.urlHealthEventId = video.id || ""; + cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl); } else { cardEl.dataset.urlHealthState = "offline"; cardEl.dataset.urlHealthReason = "missing-source"; + if (cardEl.dataset.urlHealthEventId) { + delete cardEl.dataset.urlHealthEventId; + } + if (cardEl.dataset.urlHealthUrl) { + delete cardEl.dataset.urlHealthUrl; + } } if (magnetProvided && magnetSupported) { cardEl.dataset.streamHealthState = "checking"; @@ -3832,11 +3841,18 @@ class bitvidApp { if (trimmedUrl) { const badgeEl = cardEl.querySelector("[data-url-health-state]"); if (badgeEl) { - this.handleUrlHealthBadge({ - video, - url: trimmedUrl, - badgeEl, - }); + badgeEl.dataset.urlHealthEventId = video.id || ""; + badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl); + } + } else { + const badgeEl = cardEl.querySelector("[data-url-health-state]"); + if (badgeEl) { + if (badgeEl.dataset.urlHealthEventId) { + delete badgeEl.dataset.urlHealthEventId; + } + if (badgeEl.dataset.urlHealthUrl) { + delete badgeEl.dataset.urlHealthUrl; + } } } } @@ -3852,6 +3868,10 @@ class bitvidApp { 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 diff --git a/js/channelProfile.js b/js/channelProfile.js index c9a08660..d6adfcdf 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -7,6 +7,7 @@ import { import { app } from "./app.js"; import { subscriptions } from "./subscriptions.js"; // <-- NEW import import { attachHealthBadges } from "./gridHealth.js"; +import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { initialBlacklist, initialWhitelist } from "./lists.js"; import { isWhitelistEnabled } from "./config.js"; @@ -445,9 +446,17 @@ async function loadUserVideos(pubkey) { if (cardEl.dataset.urlHealthReason) { delete cardEl.dataset.urlHealthReason; } + cardEl.dataset.urlHealthEventId = video.id || ""; + cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl); } else { cardEl.dataset.urlHealthState = "offline"; cardEl.dataset.urlHealthReason = "missing-source"; + if (cardEl.dataset.urlHealthEventId) { + delete cardEl.dataset.urlHealthEventId; + } + if (cardEl.dataset.urlHealthUrl) { + delete cardEl.dataset.urlHealthUrl; + } } if (magnetProvided && magnetSupported) { cardEl.dataset.streamHealthState = "checking"; @@ -491,14 +500,18 @@ async function loadUserVideos(pubkey) { } }); - if (trimmedUrl) { - const badgeEl = cardEl.querySelector("[data-url-health-state]"); - if (badgeEl) { - app.handleUrlHealthBadge({ - video, - url: trimmedUrl, - badgeEl, - }); + const badgeEl = cardEl.querySelector("[data-url-health-state]"); + if (badgeEl) { + if (trimmedUrl) { + badgeEl.dataset.urlHealthEventId = video.id || ""; + badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl); + } else { + if (badgeEl.dataset.urlHealthEventId) { + delete badgeEl.dataset.urlHealthEventId; + } + if (badgeEl.dataset.urlHealthUrl) { + delete badgeEl.dataset.urlHealthUrl; + } } } @@ -508,6 +521,10 @@ async function loadUserVideos(pubkey) { container.appendChild(fragment); attachHealthBadges(container); + attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => { + const video = app.videosMap.get(eventId) || { id: eventId }; + app.handleUrlHealthBadge({ video, url, badgeEl }); + }); window.app.videoList = container; window.app.attachVideoListHandler(); diff --git a/js/subscriptions.js b/js/subscriptions.js index 9a55fef9..34e129e0 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -4,6 +4,7 @@ import { convertEventToVideo as sharedConvertEventToVideo, } from "./nostr.js"; import { attachHealthBadges } from "./gridHealth.js"; +import { attachUrlHealthBadges } from "./urlHealthObserver.js"; function getAbsoluteShareUrl(nevent) { if (!nevent) { @@ -479,9 +480,17 @@ class SubscriptionsManager { if (cardEl.dataset.urlHealthReason) { delete cardEl.dataset.urlHealthReason; } + cardEl.dataset.urlHealthEventId = video.id || ""; + cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl); } else { cardEl.dataset.urlHealthState = "offline"; cardEl.dataset.urlHealthReason = "missing-source"; + if (cardEl.dataset.urlHealthEventId) { + delete cardEl.dataset.urlHealthEventId; + } + if (cardEl.dataset.urlHealthUrl) { + delete cardEl.dataset.urlHealthUrl; + } } if (magnetProvided && magnetSupported) { cardEl.dataset.streamHealthState = "checking"; @@ -519,14 +528,18 @@ class SubscriptionsManager { delete cardEl.dataset.magnet; } - if (trimmedUrl && window.app?.handleUrlHealthBadge) { - const badgeEl = cardEl.querySelector("[data-url-health-state]"); - if (badgeEl) { - window.app.handleUrlHealthBadge({ - video, - url: trimmedUrl, - badgeEl, - }); + const badgeEl = cardEl.querySelector("[data-url-health-state]"); + if (badgeEl) { + if (trimmedUrl) { + badgeEl.dataset.urlHealthEventId = video.id || ""; + badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl); + } else { + if (badgeEl.dataset.urlHealthEventId) { + delete badgeEl.dataset.urlHealthEventId; + } + if (badgeEl.dataset.urlHealthUrl) { + delete badgeEl.dataset.urlHealthUrl; + } } } } @@ -535,6 +548,14 @@ class SubscriptionsManager { container.appendChild(fragment); attachHealthBadges(container); + attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => { + if (!window.app?.handleUrlHealthBadge) { + return; + } + const video = + window.app.videosMap?.get?.(eventId) || { id: eventId }; + window.app.handleUrlHealthBadge({ video, url, badgeEl }); + }); if (window.app) { window.app.videoList = container; diff --git a/js/urlHealthObserver.js b/js/urlHealthObserver.js new file mode 100644 index 00000000..05e16920 --- /dev/null +++ b/js/urlHealthObserver.js @@ -0,0 +1,124 @@ +const containerState = new WeakMap(); +const ROOT_MARGIN = "0px"; +const THRESHOLD = 0.25; + +function decodeUrl(value) { + if (typeof value !== "string" || !value) { + return ""; + } + try { + return decodeURIComponent(value); + } catch (err) { + return value; + } +} + +function ensureState(container) { + let state = containerState.get(container); + if (state) { + return state; + } + + const observedCards = new WeakSet(); + const observer = new IntersectionObserver( + (entries) => { + processEntries(entries, state); + }, + { root: null, rootMargin: ROOT_MARGIN, threshold: THRESHOLD } + ); + + state = { + observer, + observedCards, + onCheck: null, + }; + containerState.set(container, state); + return state; +} + +function processEntries(entries, state) { + if (!state?.onCheck || !Array.isArray(entries) || !entries.length) { + return; + } + + entries.forEach((entry) => { + if (!entry.isIntersecting || entry.intersectionRatio <= 0) { + return; + } + + const card = entry.target; + if (!(card instanceof HTMLElement)) { + return; + } + + const badgeEl = card.querySelector("[data-url-health-state]"); + if (!(badgeEl instanceof HTMLElement)) { + return; + } + + const encodedUrl = + card.dataset.urlHealthUrl || badgeEl.dataset.urlHealthUrl || ""; + const eventId = + card.dataset.urlHealthEventId || badgeEl.dataset.urlHealthEventId || ""; + + if (!encodedUrl || !eventId) { + return; + } + + const currentState = + card.dataset.urlHealthState || badgeEl.dataset.urlHealthState; + if (currentState && currentState !== "checking") { + return; + } + + const url = decodeUrl(encodedUrl); + if (!url) { + return; + } + + try { + state.onCheck({ card, badgeEl, url, eventId }); + } catch (err) { + console.warn("[urlHealthObserver] onCheck handler failed", err); + } + }); +} + +export function attachUrlHealthBadges(container, onCheck) { + if (!(container instanceof HTMLElement)) { + return; + } + + const state = ensureState(container); + if (typeof onCheck === "function") { + state.onCheck = onCheck; + } + + const cards = container.querySelectorAll(".video-card"); + cards.forEach((card) => { + if (!(card instanceof HTMLElement)) { + return; + } + if (state.observedCards.has(card)) { + return; + } + state.observedCards.add(card); + state.observer.observe(card); + }); + + const records = state.observer.takeRecords(); + if (records.length) { + processEntries(records, state); + } +} + +export function refreshUrlHealthBadges(container) { + const state = containerState.get(container); + if (!state) { + return; + } + const records = state.observer.takeRecords(); + if (records.length) { + processEntries(records, state); + } +}