Add infinite scroll, gone/private tiles, and live-only scrape strip
This commit is contained in:
@@ -40,6 +40,9 @@ mobile draft at 390px.
|
||||
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
|
||||
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
|
||||
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
|
||||
- [x] C28 Media grid usability. Infinite scroll (240 px threshold) with a Load more fallback,
|
||||
`previewError` exposed so tiles show PRIVATE (403) or GONE (404/410) with the reason in the
|
||||
tooltip, and the scrape strip lists only queued/running jobs — paused scrapes move to History.
|
||||
- [x] C27 Non-media stream guard. Rows awaiting backfill fell back to first-frame `<video>` with
|
||||
a provider page URL (YouTube embed, redirectors), and the stream route piped the HTML through
|
||||
(`text/html` errors). Now only real video files (mp4/webm/m4v) are framed, the stream route
|
||||
|
||||
+4
-2
@@ -167,8 +167,10 @@ that deletion is irreversible. The delete button is disabled for a 5 second coun
|
||||
preview is a static frame and the animated GIF plays while hovering. The status row reads
|
||||
`(Images/Videos) | Downloaded I/V | Cached I/V | Loaded I/V` — image counts include GIFs, and
|
||||
each slot is the count for the active filters. A scrape strip shows live progress with Stop; it
|
||||
polls every 4 seconds while the tab is open and shows `X/Yp (~Z%)` when the search total is
|
||||
known. Some channels deny the bot the search endpoint (`Missing Access`) even though history
|
||||
polls every 4 seconds while the tab is open, lists only queued/running jobs (paused ones live in
|
||||
History), and shows `X/Yp (~Z%)` when the search total is known. The grid scrolls infinitely
|
||||
(a **Load more** button appears as a fallback). Media whose source answers `403` shows a
|
||||
**PRIVATE** tile and `404`/`410` shows **GONE**, with the reason in the tooltip. Some channels deny the bot the search endpoint (`Missing Access`) even though history
|
||||
reads work, so the total is optional.
|
||||
**New scrape…** (rightmost) opens a popup with Server and Group chat trees (servers expand to
|
||||
channels; each row has Links and Download). Every scrape button asks for confirmation; the
|
||||
|
||||
+1
-1
@@ -258,7 +258,7 @@ replaceable.
|
||||
| POST | `/api/audio/stop` | none | Stop speech and clear the queue |
|
||||
| POST | `/api/audio/capture` | `start` | Start or stop capture |
|
||||
| GET | `/api/transcript` | `limit?`, `sessionId?` | Sessions and captured segments |
|
||||
| GET | `/api/media` | `kind?`, `source?`, `channelId?`, `authorId?`, `aspect?`, `resolution?`, `after?`, `before?`, `limit?`, `offset?` | Media items across every scrape, newest first. `source` is `attachment`, `sticker`, or `embed`; `aspect` and `resolution` are the preset keys; `after`/`before` are ISO times on `posted_at`. Items carry `authorName`, `postedAt`, `width`, `height`, `aspectRatio`, `contentType`, `durationSecs`; `localUrl`/`thumbUrl` when a file or preview exists |
|
||||
| GET | `/api/media` | `kind?`, `source?`, `channelId?`, `authorId?`, `aspect?`, `resolution?`, `after?`, `before?`, `limit?`, `offset?` | Media items across every scrape, newest first. `source` is `attachment`, `sticker`, or `embed`; `aspect` and `resolution` are the preset keys; `after`/`before` are ISO times on `posted_at`. Items carry `authorName`, `postedAt`, `width`, `height`, `aspectRatio`, `contentType`, `durationSecs`, `previewError`; `localUrl`/`thumbUrl` when a file or preview exists |
|
||||
| GET | `/api/media/:scrapeId/:itemId/file` | none | The downloaded file, content-typed from its extension |
|
||||
| GET | `/api/media/:scrapeId/:itemId/thumb` | none | The cached preview (type from `preview_ext`) |
|
||||
| GET | `/api/media/:scrapeId/:itemId/stream` | none | Playback/full-view source: the local file, or the remote URL refreshed from the source message. Supports `Range`. Non-media upstream responses (provider pages) return `415` |
|
||||
|
||||
@@ -136,6 +136,12 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vk-media-load-more {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: center;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.vk-media-prompt {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
|
||||
+37
-8
@@ -24,6 +24,8 @@
|
||||
authors: null,
|
||||
errors: [],
|
||||
loaded: false,
|
||||
loading: false,
|
||||
more: false,
|
||||
stats: null,
|
||||
metaFields: JSON.parse(
|
||||
localStorage.getItem("vk-media-meta-fields") ??
|
||||
@@ -227,24 +229,29 @@
|
||||
renderStatus();
|
||||
}
|
||||
|
||||
async function loadMedia(reset) {
|
||||
if (!state.loaded) return;
|
||||
const PAGE_SIZE = 60;
|
||||
|
||||
async function loadMedia(reset) {
|
||||
if (!state.loaded || state.loading) return;
|
||||
|
||||
state.loading = true;
|
||||
if (reset) {
|
||||
state.items = [];
|
||||
state.offset = 0;
|
||||
state.more = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await window.VK.api.get(
|
||||
`/api/media?limit=60&offset=${state.offset}&${filterQuery()}`
|
||||
`/api/media?limit=${PAGE_SIZE}&offset=${state.offset}&${filterQuery()}`
|
||||
);
|
||||
state.items = state.items.concat(items);
|
||||
state.offset += items.length;
|
||||
state.more = items.length >= PAGE_SIZE;
|
||||
} catch (error) {
|
||||
setStatus(`media load failed: ${error.message}`);
|
||||
renderGrid();
|
||||
return;
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
renderGrid();
|
||||
@@ -281,7 +288,8 @@
|
||||
|
||||
function renderScrapes() {
|
||||
scrapeList.replaceChildren();
|
||||
const active = state.scrapes.filter(scrape => ["queued", "running", "paused"].includes(scrape.state));
|
||||
// Only live jobs: a paused scrape is not running and belongs in History, not the strip.
|
||||
const active = state.scrapes.filter(scrape => ["queued", "running"].includes(scrape.state));
|
||||
|
||||
for (const scrape of active) {
|
||||
const row = el("div", "vk-media-scrape");
|
||||
@@ -421,7 +429,14 @@
|
||||
}
|
||||
|
||||
function placeholder(media, item) {
|
||||
media.replaceChildren(el("span", "vk-media-card-kind", item.kind.toUpperCase()));
|
||||
const error = item.previewError ?? "";
|
||||
let label = item.kind.toUpperCase();
|
||||
if (/\b403\b/.test(error)) label = "PRIVATE";
|
||||
else if (/\b(404|410)\b/.test(error)) label = "GONE";
|
||||
|
||||
const node = el("span", "vk-media-card-kind", label);
|
||||
if (error) node.title = error;
|
||||
media.replaceChildren(node);
|
||||
}
|
||||
|
||||
/** Only real video files can be framed; embed pages (YouTube etc.) never are. */
|
||||
@@ -535,7 +550,15 @@
|
||||
}
|
||||
|
||||
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."));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function filterSelect(options, value, onChange) {
|
||||
@@ -774,6 +797,12 @@
|
||||
if (state.view === "media") void loadScrapes();
|
||||
}, 4000);
|
||||
|
||||
// Infinite scroll: fetch the next page before the grid bottom is reached.
|
||||
grid.addEventListener("scroll", () => {
|
||||
if (state.loading || !state.more) return;
|
||||
if (grid.scrollTop + grid.clientHeight >= grid.scrollHeight - 240) void loadMedia(false);
|
||||
});
|
||||
|
||||
// ---- Scrape popup: server or group-chat trees ----
|
||||
|
||||
const scrapeModal = document.createElement("div");
|
||||
|
||||
@@ -121,6 +121,8 @@ export interface MediaItemResult {
|
||||
playUrl: string | null;
|
||||
/** The remote thumbnail URL from Discord's embed data (may be an animated GIF). */
|
||||
remotePreviewUrl: string | null;
|
||||
/** Why no preview exists, when a fetch failed (for example "http 404"). */
|
||||
previewError: string | null;
|
||||
localUrl: string | null;
|
||||
thumbUrl: string | null;
|
||||
provider: string | null;
|
||||
@@ -739,6 +741,7 @@ export class MediaService {
|
||||
url: row.url,
|
||||
playUrl: row.play_url,
|
||||
remotePreviewUrl: row.preview_url,
|
||||
previewError: row.preview_error,
|
||||
localUrl:
|
||||
row.file_path && !meta.downloadDir
|
||||
? `/api/media/${meta.id}/${row.id}/file`
|
||||
|
||||
Reference in New Issue
Block a user