diff --git a/js/dom/cardObserver.js b/js/dom/cardObserver.js new file mode 100644 index 00000000..53b6a1b6 --- /dev/null +++ b/js/dom/cardObserver.js @@ -0,0 +1,208 @@ +const DEFAULT_CARD_SELECTOR = ".video-card"; + +function defaultIsCardVisible(entry) { + if (!entry) { + return false; + } + const isIntersecting = Boolean(entry.isIntersecting); + const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0; + return isIntersecting && ratio > 0; +} + +function normalizeState(value) { + if (value && typeof value === "object") { + return value; + } + return {}; +} + +export function createCardObserver(options = {}) { + const { + rootMargin = "0px", + threshold = 0, + cardSelector = DEFAULT_CARD_SELECTOR, + isCardVisible = defaultIsCardVisible, + prepareEntries, + onCardVisible, + onCardRegister, + createState, + } = options; + + const containerState = new WeakMap(); + + function ensureState(container) { + let state = containerState.get(container); + if (state) { + return state; + } + + const observedCards = new WeakSet(); + const customState = normalizeState( + typeof createState === "function" ? createState(container) : {} + ); + + const stateWrapper = { + container, + observedCards, + customState, + observer: null, + }; + + const observer = new IntersectionObserver( + (entries) => { + handleEntries(entries, stateWrapper); + }, + { root: null, rootMargin, threshold } + ); + + stateWrapper.observer = observer; + containerState.set(container, stateWrapper); + return stateWrapper; + } + + function mapEntries(entries, state) { + if (!Array.isArray(entries) || entries.length === 0) { + return []; + } + + const context = { + container: state.container, + observer: state.observer, + state: state.customState, + }; + + if (typeof prepareEntries === "function") { + try { + const prepared = prepareEntries(entries, context); + if (Array.isArray(prepared)) { + return prepared; + } + } catch (err) { + console.warn("[cardObserver] prepareEntries failed", err); + } + } + + return entries; + } + + function handleEntries(entries, state) { + const mapped = mapEntries(entries, state); + if (!mapped.length) { + return; + } + + mapped.forEach((item) => { + let entry = item; + let card; + let meta; + + if (item && typeof item === "object" && "entry" in item) { + entry = item.entry; + card = item.card instanceof HTMLElement ? item.card : entry?.target; + if ("meta" in item) { + meta = item.meta; + } else if ("data" in item) { + meta = item.data; + } else if (typeof item.priority !== "undefined") { + meta = { priority: item.priority }; + } + } else if (entry && entry.target instanceof HTMLElement) { + card = entry.target; + } + + if (!(card instanceof HTMLElement) || !entry) { + return; + } + + let visible = false; + try { + visible = Boolean(isCardVisible(entry, card, { + container: state.container, + observer: state.observer, + state: state.customState, + })); + } catch (err) { + console.warn("[cardObserver] isCardVisible failed", err); + visible = false; + } + + if (!visible || typeof onCardVisible !== "function") { + return; + } + + try { + onCardVisible({ + card, + entry, + meta, + state: state.customState, + context: { + container: state.container, + observer: state.observer, + }, + }); + } catch (err) { + console.warn("[cardObserver] onCardVisible failed", err); + } + }); + } + + function observe(container) { + if (!(container instanceof HTMLElement)) { + return null; + } + + const state = ensureState(container); + const cards = container.querySelectorAll(cardSelector); + cards.forEach((card) => { + if (!(card instanceof HTMLElement)) { + return; + } + if (state.observedCards.has(card)) { + return; + } + state.observedCards.add(card); + if (typeof onCardRegister === "function") { + try { + onCardRegister({ + card, + state: state.customState, + context: { container }, + }); + } catch (err) { + console.warn("[cardObserver] onCardRegister failed", err); + } + } + state.observer.observe(card); + }); + + const records = state.observer.takeRecords(); + if (records.length) { + handleEntries(records, state); + } + + return state.customState; + } + + function refresh(container) { + const state = containerState.get(container); + if (!state) { + return; + } + const records = state.observer.takeRecords(); + if (records.length) { + handleEntries(records, state); + } + } + + function getState(container) { + const state = containerState.get(container); + return state ? state.customState : null; + } + + return { + observe, + refresh, + getState, + }; +} diff --git a/js/gridHealth.js b/js/gridHealth.js index 85a8821c..5c6c969c 100644 --- a/js/gridHealth.js +++ b/js/gridHealth.js @@ -1,7 +1,7 @@ +import { createCardObserver } from "./dom/cardObserver.js"; import { infoHashFromMagnet } from "./magnets.js"; import { TorrentClient, torrentClient } from "./webtorrent.js"; -const containerState = new WeakMap(); const ROOT_MARGIN = "200px 0px"; function now() { @@ -249,28 +249,6 @@ const probeQueue = new ProbeQueue(PROBE_CONCURRENCY); const probeCache = new Map(); const probeInflight = new Map(); -function ensureState(container) { - let state = containerState.get(container); - if (state) { - return state; - } - - const pendingByCard = new WeakMap(); - const observedCards = new WeakSet(); - state = { observer: null, pendingByCard, observedCards }; - - const observer = new IntersectionObserver( - (entries) => { - processObserverEntries(entries, state); - }, - { root: null, rootMargin: ROOT_MARGIN, threshold: 0.01 } - ); - - state.observer = observer; - containerState.set(container, state); - return state; -} - function getViewportCenter() { if (typeof window === "undefined") { return null; @@ -389,18 +367,6 @@ function prioritizeEntries(entries, viewportCenter) { })); } -function processObserverEntries(entries, state) { - if (!state || !entries || entries.length === 0) { - return; - } - const viewportCenter = getViewportCenter(); - const prioritized = prioritizeEntries(entries, viewportCenter); - prioritized.forEach(({ entry, priority }) => { - const card = entry.target; - handleCardVisible({ card, pendingByCard: state.pendingByCard, priority }); - }); -} - function getIntersectionRect(entry) { if (!entry) { return null; @@ -539,6 +505,10 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) { return; } + if (!(pendingByCard instanceof WeakMap)) { + return; + } + if (pendingByCard.has(card)) { return; } @@ -590,36 +560,50 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) { }); } +const gridCardObserver = createCardObserver({ + rootMargin: ROOT_MARGIN, + threshold: 0.01, + createState: () => ({ pendingByCard: new WeakMap() }), + prepareEntries: (entries) => { + const viewportCenter = getViewportCenter(); + return prioritizeEntries(entries, viewportCenter); + }, + isCardVisible: (entry) => { + if (!entry) { + return false; + } + const isIntersecting = Boolean(entry.isIntersecting); + const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0; + return isIntersecting && ratio > 0; + }, + onCardRegister: ({ card }) => { + if (!card.dataset.magnet) { + setBadge(card, "unhealthy", { reason: "missing-source" }); + } + }, + onCardVisible: ({ card, meta, state }) => { + if (!state || !(state.pendingByCard instanceof WeakMap)) { + return; + } + const priority = + meta && typeof meta === "object" && Number.isFinite(meta.priority) + ? meta.priority + : PRIORITY_BASELINE; + handleCardVisible({ + card, + pendingByCard: state.pendingByCard, + priority, + }); + }, +}); + export function attachHealthBadges(container) { if (!(container instanceof HTMLElement)) { return; } - const state = ensureState(container); - 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); - if (!card.dataset.magnet) { - setBadge(card, "unhealthy", { reason: "missing-source" }); - } - }); - processObserverEntries(state.observer.takeRecords(), state); + gridCardObserver.observe(container); } export function refreshHealthBadges(container) { - if (!(container instanceof HTMLElement)) { - return; - } - const state = containerState.get(container); - if (!state) { - return; - } - const records = state.observer.takeRecords(); - processObserverEntries(records, state); + gridCardObserver.refresh(container); } diff --git a/js/urlHealthObserver.js b/js/urlHealthObserver.js index 05e16920..37410ebf 100644 --- a/js/urlHealthObserver.js +++ b/js/urlHealthObserver.js @@ -1,4 +1,5 @@ -const containerState = new WeakMap(); +import { createCardObserver } from "./dom/cardObserver.js"; + const ROOT_MARGIN = "0px"; const THRESHOLD = 0.25; @@ -13,41 +14,20 @@ function decodeUrl(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 urlCardObserver = createCardObserver({ + rootMargin: ROOT_MARGIN, + threshold: THRESHOLD, + isCardVisible: (entry) => { + if (!entry) { + return false; } - - const card = entry.target; - if (!(card instanceof HTMLElement)) { + const isIntersecting = Boolean(entry.isIntersecting); + const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0; + return isIntersecting && ratio > 0; + }, + createState: () => ({ onCheck: null }), + onCardVisible: ({ card, state }) => { + if (!state || typeof state.onCheck !== "function") { return; } @@ -81,44 +61,20 @@ function processEntries(entries, state) { } 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") { + const state = urlCardObserver.observe(container); + if (state && 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); - } + urlCardObserver.refresh(container); }