Stream the media grid with a scroll buffer
This commit is contained in:
+224
-54
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user