Stream the media grid with a scroll buffer

This commit is contained in:
Fisto
2026-09-17 10:47:52 +02:00
parent efd721e83b
commit cc14140f34
6 changed files with 291 additions and 59 deletions
+224 -54
View File
@@ -35,6 +35,9 @@
zoom: Number(localStorage.getItem("vk-media-zoom") ?? 50),
draft: null,
prefetched: null,
prefetchPromise: null,
itemKeys: new Set(),
loadError: false,
pageSize: 60,
backfill: null,
preferences: null,
@@ -101,6 +104,14 @@
const tunablesHost = overlay.querySelector("#bot-tunables");
const botErrors = overlay.querySelector("#bot-errors");
// The stream buffer: extra scrollable room so hitting the bottom is never a dead stop.
const buffer = el("div", "vk-media-buffer");
const bufferNote = el("div", "vk-media-buffer-note", "");
const bufferRetry = button("Retry", () => void loadNextPage());
bufferRetry.classList.add("vk-media-buffer-retry");
bufferRetry.hidden = true;
buffer.append(bufferNote, bufferRetry);
const filterDrawer = el("div", "vk-media-drawer");
filterDrawer.hidden = true;
toolbar.after(filterDrawer);
@@ -297,58 +308,177 @@
renderStatus();
}
/** Append only the cards that are new, so existing images never get rebuilt. */
function appendPage(items) {
const fresh = [];
for (const item of items) {
const key = `${item.scrapeId}:${item.itemId}`;
if (state.itemKeys.has(key)) continue;
state.itemKeys.add(key);
fresh.push(item);
}
state.items = state.items.concat(fresh);
state.offset += items.length;
state.more = items.length >= state.pageSize;
state.loadError = false;
for (const item of fresh) grid.insertBefore(renderCard(item), buffer);
layoutBuffer();
renderStatus();
}
/** Reset the list and render everything from scratch. */
function renderCards() {
grid.replaceChildren();
for (const item of state.items) grid.append(renderCard(item));
if (!state.items.length) grid.append(el("div", "vk-media-status", "No media matches the filters."));
grid.append(buffer);
layoutBuffer();
}
async function loadMedia(reset) {
if (!state.loaded || state.loading) return;
state.loading = true;
state.loadError = false;
layoutBuffer();
if (reset) {
state.items = [];
state.itemKeys = new Set();
state.offset = 0;
state.more = false;
state.more = true;
state.prefetched = null;
state.prefetchPromise = null;
}
try {
const items = await window.VK.api.get(
`/api/media?limit=${state.pageSize}&offset=${state.offset}&${filterQuery()}`
);
state.items = state.items.concat(items);
state.offset += items.length;
state.more = items.length >= state.pageSize;
if (reset) {
for (const item of items) state.itemKeys.add(`${item.scrapeId}:${item.itemId}`);
state.items = items;
state.offset += items.length;
state.more = items.length >= state.pageSize;
renderCards();
} else {
appendPage(items);
}
} catch (error) {
state.loadError = true;
setStatus(`media load failed: ${error.message}`);
} finally {
state.loading = false;
}
renderGrid();
layoutBuffer();
void loadStats();
void prefetchNext();
void fillBuffer();
}
/** Warm the next page's JSON and preview images before the user reaches them. */
async function prefetchNext() {
if (!state.loaded || !state.more || state.prefetched || state.loading) return;
if (!state.loaded || !state.more || state.prefetched || state.prefetchPromise || state.loading) return;
try {
const items = await window.VK.api.get(
`/api/media?limit=${state.pageSize}&offset=${state.offset}&${filterQuery()}`
);
if (!items.length) {
state.more = false;
return;
}
state.prefetchPromise = window.VK.api
.get(`/api/media?limit=${state.pageSize}&offset=${state.offset}&${filterQuery()}`)
.then(items => {
if (!items.length) {
state.more = false;
return;
}
state.prefetched = items;
for (const item of items) {
const src = item.thumbUrl || item.localUrl;
if (!src) continue;
const image = new Image();
image.src = src;
}
} catch {
// Preloading is best effort; the scroll handler falls back to a request.
state.prefetched = items;
for (const item of items) {
const src = item.thumbUrl || item.localUrl;
if (!src) continue;
const image = new Image();
image.src = src;
}
})
.catch(() => {
// Preloading is best effort; the scroll handler falls back to a request.
})
.finally(() => {
state.prefetchPromise = null;
});
await state.prefetchPromise;
}
/** Ensure the next page is on its way when the buffer is close or already in view. */
async function loadNextPage() {
if (state.loading || !state.loaded || !state.more) return;
if (state.prefetchPromise) await state.prefetchPromise;
if (state.prefetched) {
const items = state.prefetched;
state.prefetched = null;
appendPage(items);
void prefetchNext();
void fillBuffer();
return;
}
await loadMedia(false);
}
/**
* The buffer keeps scolling alive while the next page loads. Its height is capped at
* 1.5 viewport heights and shrinks as the end of the filtered results comes closer.
*/
function layoutBuffer() {
if (!state.loaded) {
buffer.style.height = "0px";
bufferNote.textContent = "";
bufferRetry.hidden = true;
return;
}
if (!state.more && !state.loadError) {
buffer.style.height = "";
bufferNote.textContent = state.items.length ? "End of results" : "";
bufferRetry.hidden = true;
return;
}
const cap = Math.round((grid.clientHeight || 600) * 1.5);
const rowHeight = gridRowHeight();
const total = filteredTotal();
const remainingRows = total === null ? null : Math.ceil(Math.max(0, total - state.offset) / gridColumns());
const wanted = remainingRows === null ? cap : remainingRows * rowHeight;
buffer.style.height = `${Math.max(rowHeight, Math.min(cap, wanted))}px`;
bufferNote.textContent = state.loadError
? "Could not load more."
: state.loading
? "Loading more\u2026"
: "Scroll for more";
bufferRetry.hidden = !state.loadError;
}
/** Filtered total from the stats the status row already uses; null until known. */
function filteredTotal() {
const stats = state.stats;
if (!stats) return null;
return (
(stats.image?.total ?? 0) +
(stats.gif?.total ?? 0) +
(stats.video?.total ?? 0) +
(stats.audio?.total ?? 0)
);
}
/** Keep loading while the buffer is on screen, so short filtered pages never dead-stop. */
function fillBuffer() {
if (!state.more || state.loading || !state.loaded) return;
const top = buffer.getBoundingClientRect().top;
const bottom = grid.getBoundingClientRect().bottom;
if (top <= bottom + 320) void loadNextPage();
}
async function startScrape(channelId, guildId, mode, downloadDir, after, before) {
@@ -942,16 +1072,7 @@
return;
}
for (const item of state.items) grid.append(renderCard(item));
if (!state.items.length) {
grid.append(el("div", "vk-media-status", "No media matches the filters."));
} else if (state.more) {
const more = el("button", "btn btn-secondary vk-media-load-more", "Load more");
more.type = "button";
more.addEventListener("click", () => void loadMedia(false));
grid.append(more);
}
renderCards();
}
function filterSelect(options, value, onChange) {
@@ -1313,12 +1434,19 @@
return Math.round(150 + ((zoom - 50) / 50) * Math.max(0, gridWidth - 150));
}
function gridColumns() {
const width = cardWidth();
return Math.max(1, Math.floor(((grid.clientWidth || 900) + 8) / (width + 8)));
}
function gridRowHeight() {
return cardWidth() * 1.16 + 30;
}
/** Items per page: full rows, about 1.5 screens tall. */
function computePageSize() {
const width = cardWidth();
const columns = Math.max(1, Math.floor(((grid.clientWidth || 900) + 8) / (width + 8)));
const rowHeight = width * 1.16 + 30;
const rows = Math.max(1, Math.floor(((grid.clientHeight || 600) + 8) / (rowHeight + 8)));
const columns = gridColumns();
const rows = Math.max(1, Math.floor(((grid.clientHeight || 600) + 8) / (gridRowHeight() + 8)));
return Math.max(columns, columns * Math.max(1, Math.ceil(rows * 1.5)));
}
@@ -1328,6 +1456,7 @@
grid.style.gridTemplateColumns =
width >= gridWidth - 24 ? "1fr" : `repeat(auto-fill, minmax(${width}px, 1fr))`;
state.pageSize = computePageSize();
layoutBuffer();
}
function resetMediaView() {
@@ -1414,26 +1543,67 @@
if (state.view === "bot") void refreshBackfill();
}, 3000);
// Infinite scroll: fetch the next page before the grid bottom is reached.
// Infinite scroll: the buffer entering the viewport starts the next page.
const bufferObserver = new IntersectionObserver(
entries => {
if (entries.some(entry => entry.isIntersecting)) void loadNextPage();
},
{ root: grid, rootMargin: "400px 0px" }
);
bufferObserver.observe(buffer);
grid.addEventListener("scroll", () => {
if (state.loading || !state.loaded) return;
if (grid.scrollTop + grid.clientHeight < grid.scrollHeight - 240) return;
if (state.prefetched) {
const items = state.prefetched;
state.prefetched = null;
state.items = state.items.concat(items);
state.offset += items.length;
state.more = items.length >= state.pageSize;
renderGrid();
void loadStats();
void prefetchNext();
return;
}
if (state.more) void loadMedia(false);
if (!state.loaded) return;
const remaining = grid.scrollHeight - grid.scrollTop - grid.clientHeight;
if (remaining < 400) void loadNextPage();
});
// Past the buffer, a small elastic nudge instead of a hard stop while more is loading.
let elasticOffset = 0;
let elasticTimer = null;
function elastic(delta) {
if (!state.more || !state.loading) return;
const atBottom = grid.scrollTop + grid.clientHeight >= grid.scrollHeight - 2;
if (!atBottom) return;
elasticOffset = Math.max(-18, Math.min(0, elasticOffset - delta * 0.35));
grid.style.transition = "none";
grid.style.transform = `translateY(${elasticOffset}px)`;
clearTimeout(elasticTimer);
elasticTimer = setTimeout(() => {
grid.style.transition = "transform 160ms ease-out";
grid.style.transform = "";
elasticOffset = 0;
}, 60);
}
grid.addEventListener(
"wheel",
event => {
if (event.deltaY > 0) elastic(event.deltaY);
},
{ passive: true }
);
let touchStartY = 0;
grid.addEventListener(
"touchstart",
event => {
touchStartY = event.touches[0]?.clientY ?? 0;
},
{ passive: true }
);
grid.addEventListener(
"touchmove",
event => {
const y = event.touches[0]?.clientY ?? 0;
if (touchStartY - y > 0) elastic((touchStartY - y) / 8);
},
{ passive: true }
);
// ---- Scrape popup: server or group-chat trees ----
const scrapeModal = document.createElement("div");