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/gridHealth.js b/js/gridHealth.js index 7aa0cb78..e81515c7 100644 --- a/js/gridHealth.js +++ b/js/gridHealth.js @@ -165,8 +165,9 @@ function queueProbe(magnet, cacheKey, priority = 0) { const PROBE_CACHE_TTL_MS = 5 * 60 * 1000; const PROBE_TIMEOUT_MS = 8000; -const PROBE_CONCURRENCY = 6; +const PROBE_CONCURRENCY = 12; const PROBE_POLL_COUNT = 3; +const PRIORITY_BASELINE = 1_000_000; class ProbeQueue { constructor(max = 2) { @@ -285,48 +286,107 @@ function getViewportCenter() { }; } -function calculateDistanceSquared(entry, viewportCenter) { - if (!viewportCenter) { - return Number.POSITIVE_INFINITY; - } - const rect = getIntersectionRect(entry); - if (!rect) { - return Number.POSITIVE_INFINITY; - } - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; - const dx = centerX - viewportCenter.x; - const dy = centerY - viewportCenter.y; - return dx * dx + dy * dy; -} - -function computeVisibilityPriority({ ratio, distance }) { - const normalizedRatio = Number.isFinite(ratio) - ? Math.min(Math.max(ratio, 0), 1) - : 0; - const distanceScore = Number.isFinite(distance) ? 1 / (1 + distance) : 0; - return normalizedRatio * 1000 + distanceScore; -} - function prioritizeEntries(entries, viewportCenter) { if (!Array.isArray(entries) || entries.length === 0) { return []; } - return entries + const filtered = entries .filter((entry) => entry.isIntersecting && entry.target instanceof HTMLElement) .map((entry) => { + const rect = getIntersectionRect(entry); + if (!rect) { + return null; + } const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0; - const distance = calculateDistanceSquared(entry, viewportCenter); - const priority = computeVisibilityPriority({ ratio, distance }); - return { entry, ratio, distance, priority }; + const centerY = rect.top + rect.height / 2; + const verticalDistance = viewportCenter + ? Math.abs(centerY - viewportCenter.y) + : Number.POSITIVE_INFINITY; + return { entry, ratio, centerY, verticalDistance }; }) - .sort((a, b) => { - if (b.priority !== a.priority) { - return b.priority - a.priority; + .filter(Boolean); + + if (filtered.length === 0) { + return []; + } + + if (!viewportCenter) { + return filtered + .sort((a, b) => { + if (b.ratio !== a.ratio) { + return b.ratio - a.ratio; + } + return a.centerY - b.centerY; + }) + .map((item, index) => ({ + entry: item.entry, + priority: PRIORITY_BASELINE - index, + })); + } + + const ordered = filtered + .slice() + .sort((a, b) => a.centerY - b.centerY); + + let centerIndex = 0; + let minDistance = Number.POSITIVE_INFINITY; + for (let i = 0; i < ordered.length; i += 1) { + const candidate = ordered[i]; + if (candidate.verticalDistance < minDistance) { + minDistance = candidate.verticalDistance; + centerIndex = i; + } + } + + const prioritized = []; + const pushCandidate = (candidate) => { + if (!candidate) { + return; + } + prioritized.push(candidate); + }; + + pushCandidate(ordered[centerIndex]); + + let left = centerIndex - 1; + let right = centerIndex + 1; + while (left >= 0 || right < ordered.length) { + const leftCandidate = left >= 0 ? ordered[left] : null; + const rightCandidate = right < ordered.length ? ordered[right] : null; + + if (leftCandidate && rightCandidate) { + const leftDistance = leftCandidate.verticalDistance; + const rightDistance = rightCandidate.verticalDistance; + const distanceDelta = Math.abs(leftDistance - rightDistance); + if (distanceDelta <= 0.5) { + if (rightCandidate.ratio > leftCandidate.ratio) { + pushCandidate(rightCandidate); + right += 1; + } else { + pushCandidate(leftCandidate); + left -= 1; + } + } else if (leftDistance < rightDistance) { + pushCandidate(leftCandidate); + left -= 1; + } else { + pushCandidate(rightCandidate); + right += 1; } - return a.distance - b.distance; - }); + } else if (rightCandidate) { + pushCandidate(rightCandidate); + right += 1; + } else if (leftCandidate) { + pushCandidate(leftCandidate); + left -= 1; + } + } + + return prioritized.map((candidate, index) => ({ + entry: candidate.entry, + priority: PRIORITY_BASELINE - index, + })); } function processObserverEntries(entries, state) { 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); + } +}