238 lines
6.8 KiB
JavaScript
238 lines
6.8 KiB
JavaScript
const DEFAULT_CONCURRENCY = 6;
|
|
const DEFAULT_REFRESH_MS = 30_000;
|
|
const DEFAULT_VISIBILITY_THRESHOLD = 0.1;
|
|
const DEFAULT_ROOT_MARGIN = "200px";
|
|
|
|
const sharedInflight = { count: 0 };
|
|
const sharedQueue = [];
|
|
let sharedConcurrency = DEFAULT_CONCURRENCY;
|
|
|
|
function pumpQueue() {
|
|
while (sharedInflight.count < sharedConcurrency && sharedQueue.length > 0) {
|
|
const job = sharedQueue.shift();
|
|
sharedInflight.count++;
|
|
Promise.resolve()
|
|
.then(() => job())
|
|
.catch((err) => console.warn("[thumb] job failed", err))
|
|
.finally(() => {
|
|
sharedInflight.count--;
|
|
pumpQueue();
|
|
});
|
|
}
|
|
}
|
|
|
|
function enqueueJob(job) {
|
|
sharedQueue.push(job);
|
|
pumpQueue();
|
|
}
|
|
|
|
export function setGlobalConcurrency(n) {
|
|
sharedConcurrency = Math.max(1, Math.min(32, Number(n) || DEFAULT_CONCURRENCY));
|
|
pumpQueue();
|
|
}
|
|
|
|
async function triggerAgentThumbnail(clientId) {
|
|
try {
|
|
const res = await fetch(`/api/clients/${encodeURIComponent(clientId)}/thumbnail`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
});
|
|
if (!res.ok) return null;
|
|
return await res.json().catch(() => null);
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function buildThumbnailUrl(clientId, version) {
|
|
const base = `/api/clients/${encodeURIComponent(clientId)}/thumbnail`;
|
|
return version > 0 ? `${base}?v=${version}` : base;
|
|
}
|
|
|
|
export function applyImageSrcSmooth(img, url) {
|
|
if (!img || !url) return;
|
|
if (img.dataset.thumbUrl === url) return;
|
|
img.dataset.thumbUrl = url;
|
|
const gen = (img._thumbGen = ((img._thumbGen || 0) + 1));
|
|
const newImg = img.cloneNode(false);
|
|
newImg.dataset.thumbUrl = url;
|
|
newImg.style.display = "block";
|
|
const swap = () => {
|
|
if (img._thumbGen !== gen) return;
|
|
if (img.parentNode) img.replaceWith(newImg);
|
|
};
|
|
newImg.addEventListener("error", () => {}, { once: true });
|
|
newImg.src = url;
|
|
if (typeof newImg.decode === "function") {
|
|
newImg.decode().then(swap).catch(() => {});
|
|
} else {
|
|
newImg.addEventListener("load", swap, { once: true });
|
|
}
|
|
}
|
|
|
|
export class ThumbnailLoader {
|
|
constructor(options = {}) {
|
|
this.refreshIntervalMs = Math.max(500, Number(options.refreshIntervalMs) || DEFAULT_REFRESH_MS);
|
|
this.rootMargin = options.rootMargin || DEFAULT_ROOT_MARGIN;
|
|
this.threshold = options.threshold ?? DEFAULT_VISIBILITY_THRESHOLD;
|
|
this.onFrame = typeof options.onFrame === "function" ? options.onFrame : null;
|
|
this.onError = typeof options.onError === "function" ? options.onError : null;
|
|
this.onTrigger = typeof options.onTrigger === "function" ? options.onTrigger : null;
|
|
this.records = new Map();
|
|
this.tickTimer = null;
|
|
this.observer = null;
|
|
if (typeof IntersectionObserver === "function") {
|
|
this.observer = new IntersectionObserver(
|
|
(entries) => this.handleIntersection(entries),
|
|
{ rootMargin: this.rootMargin, threshold: this.threshold },
|
|
);
|
|
}
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (!document.hidden) this.tick();
|
|
});
|
|
}
|
|
|
|
setRefreshInterval(ms) {
|
|
const next = Math.max(500, Number(ms) || DEFAULT_REFRESH_MS);
|
|
if (next === this.refreshIntervalMs) return;
|
|
this.refreshIntervalMs = next;
|
|
if (this.tickTimer) {
|
|
clearInterval(this.tickTimer);
|
|
this.tickTimer = setInterval(() => this.tick(), Math.min(2000, Math.max(500, next / 4)));
|
|
}
|
|
}
|
|
|
|
observe(element, clientId, initialVersion = 0) {
|
|
if (!element || !clientId) return;
|
|
const prior = this.records.get(element);
|
|
if (prior && prior.clientId === clientId) {
|
|
prior.version = Math.max(initialVersion, prior.version);
|
|
this.applyVersion(element, prior);
|
|
return;
|
|
}
|
|
if (prior) this.unobserve(prior.element);
|
|
const rec = {
|
|
element,
|
|
clientId,
|
|
version: initialVersion || 0,
|
|
visible: false,
|
|
lastTriggerAt: 0,
|
|
inflight: false,
|
|
};
|
|
this.records.set(element, rec);
|
|
this.applyVersion(element, rec);
|
|
if (this.observer) {
|
|
this.observer.observe(element);
|
|
} else {
|
|
rec.visible = true;
|
|
}
|
|
this.ensureLoop();
|
|
}
|
|
|
|
unobserve(element) {
|
|
if (!element) return;
|
|
const rec = this.records.get(element);
|
|
if (!rec) return;
|
|
this.records.delete(element);
|
|
if (this.observer) this.observer.unobserve(element);
|
|
if (this.records.size === 0 && this.tickTimer) {
|
|
clearInterval(this.tickTimer);
|
|
this.tickTimer = null;
|
|
}
|
|
}
|
|
|
|
setVersion(clientId, version) {
|
|
if (!clientId) return;
|
|
let touched = false;
|
|
for (const rec of this.records.values()) {
|
|
if (rec.clientId !== clientId) continue;
|
|
if (version > rec.version) {
|
|
rec.version = version;
|
|
this.applyVersion(rec.element, rec);
|
|
touched = true;
|
|
}
|
|
}
|
|
return touched;
|
|
}
|
|
|
|
refreshNow(clientId) {
|
|
for (const rec of this.records.values()) {
|
|
if (clientId && rec.clientId !== clientId) continue;
|
|
rec.lastTriggerAt = 0;
|
|
}
|
|
this.tick();
|
|
}
|
|
|
|
applyVersion(element, rec) {
|
|
if (!element || !rec) return;
|
|
const img = element.querySelector("img[data-thumb-img]");
|
|
if (!img) return;
|
|
applyImageSrcSmooth(img, buildThumbnailUrl(rec.clientId, rec.version));
|
|
}
|
|
|
|
handleIntersection(entries) {
|
|
for (const entry of entries) {
|
|
const rec = this.records.get(entry.target);
|
|
if (!rec) continue;
|
|
rec.visible = entry.isIntersecting;
|
|
}
|
|
this.tick();
|
|
}
|
|
|
|
ensureLoop() {
|
|
if (this.tickTimer) return;
|
|
this.tickTimer = setInterval(
|
|
() => this.tick(),
|
|
Math.min(2000, Math.max(500, this.refreshIntervalMs / 4)),
|
|
);
|
|
}
|
|
|
|
tick() {
|
|
if (document.hidden) return;
|
|
const now = Date.now();
|
|
for (const rec of this.records.values()) {
|
|
if (!rec.visible || rec.inflight) continue;
|
|
if (now - rec.lastTriggerAt < this.refreshIntervalMs) continue;
|
|
rec.lastTriggerAt = now;
|
|
rec.inflight = true;
|
|
if (this.onTrigger) {
|
|
try { this.onTrigger(rec.clientId, rec.element); } catch {}
|
|
}
|
|
enqueueJob(async () => {
|
|
try {
|
|
const result = await triggerAgentThumbnail(rec.clientId);
|
|
if (result && typeof result.version === "number" && result.version > rec.version) {
|
|
rec.version = result.version;
|
|
this.applyVersion(rec.element, rec);
|
|
if (this.onFrame) {
|
|
try { this.onFrame(rec.clientId, rec.element, result); } catch {}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (this.onError) {
|
|
try { this.onError(rec.clientId, err); } catch {}
|
|
}
|
|
} finally {
|
|
rec.inflight = false;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
if (this.observer) {
|
|
this.observer.disconnect();
|
|
this.observer = null;
|
|
}
|
|
if (this.tickTimer) {
|
|
clearInterval(this.tickTimer);
|
|
this.tickTimer = null;
|
|
}
|
|
this.records.clear();
|
|
}
|
|
}
|
|
|
|
export function thumbnailUrl(clientId, version = 0) {
|
|
return buildThumbnailUrl(clientId, version);
|
|
}
|