Refactor health observers to share card observer utility

This commit is contained in:
thePR0M3TH3AN committed 2025-10-02 12:39:11 -04:00
1 parent aff2a81bf5
commit bf63808007
3 files changed
+272 -124

No files matched your search

+208
View File
@@ -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,
};
}
+44 -60
View File
@@ -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);
}
+20 -64
View File
@@ -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);
}