Prefer cached stills and stream first frames in the grid
This commit is contained in:
+47
-38
@@ -412,17 +412,6 @@
|
|||||||
return el(className ?? "figcaption", "vk-media-banner", parts.join(" · "));
|
return el(className ?? "figcaption", "vk-media-banner", parts.join(" · "));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Grid thumbnail source: cached preview, then the local file.
|
|
||||||
* Attachment URLs are direct files; embed URLs are pages, never rendered as media.
|
|
||||||
*/
|
|
||||||
function gridSource(item) {
|
|
||||||
if (item.thumbUrl) return item.thumbUrl;
|
|
||||||
if (item.localUrl) return item.localUrl;
|
|
||||||
if (item.source !== "embed") return item.url;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function placeholder(media, item) {
|
function placeholder(media, item) {
|
||||||
media.replaceChildren(el("span", "vk-media-card-kind", item.kind.toUpperCase()));
|
media.replaceChildren(el("span", "vk-media-card-kind", item.kind.toUpperCase()));
|
||||||
}
|
}
|
||||||
@@ -447,36 +436,18 @@
|
|||||||
function renderCard(item) {
|
function renderCard(item) {
|
||||||
const figure = el("figure", "vk-media-figure");
|
const figure = el("figure", "vk-media-figure");
|
||||||
const media = el("div", "vk-media-media");
|
const media = el("div", "vk-media-media");
|
||||||
const source = gridSource(item);
|
const imageSource = item.localUrl || item.thumbUrl;
|
||||||
const videoSource = item.localUrl || item.playUrl || (item.source !== "embed" ? item.url : null);
|
const fallbackSource = item.source !== "embed" ? item.url : null;
|
||||||
const playableVideo =
|
const streamPath = `/api/media/${item.scrapeId}/${item.itemId}/stream`;
|
||||||
!!videoSource &&
|
|
||||||
(/\.(mp4|webm|m4v)(\?|$)/i.test(videoSource) || videoSource === item.playUrl);
|
|
||||||
const animatedGif =
|
const animatedGif =
|
||||||
item.kind === "gif" && item.remotePreviewUrl && /\.gif(\?|$)/i.test(item.remotePreviewUrl)
|
item.kind === "gif" && item.remotePreviewUrl && /\.gif(\?|$)/i.test(item.remotePreviewUrl)
|
||||||
? item.remotePreviewUrl
|
? item.remotePreviewUrl
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (item.kind === "video" && videoSource && playableVideo) {
|
if (imageSource) {
|
||||||
// Discord has no server-side video thumbnails; the browser shows the first frame.
|
|
||||||
const video = document.createElement("video");
|
|
||||||
video.preload = "metadata";
|
|
||||||
video.muted = true;
|
|
||||||
video.playsInline = true;
|
|
||||||
video.src = videoSource;
|
|
||||||
video.addEventListener(
|
|
||||||
"loadedmetadata",
|
|
||||||
() => {
|
|
||||||
if (video.duration) video.currentTime = Math.min(0.1, video.duration / 2);
|
|
||||||
},
|
|
||||||
{ once: true }
|
|
||||||
);
|
|
||||||
video.addEventListener("error", () => placeholder(media, item), { once: true });
|
|
||||||
media.append(video);
|
|
||||||
} else if (source && /^(https?:|\/)/.test(source)) {
|
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
img.loading = "lazy";
|
img.loading = "lazy";
|
||||||
img.src = source;
|
img.src = imageSource;
|
||||||
img.alt = item.kind;
|
img.alt = item.kind;
|
||||||
img.addEventListener("error", () => placeholder(media, item), { once: true });
|
img.addEventListener("error", () => placeholder(media, item), { once: true });
|
||||||
|
|
||||||
@@ -486,10 +457,34 @@
|
|||||||
img.src = animatedGif;
|
img.src = animatedGif;
|
||||||
});
|
});
|
||||||
media.addEventListener("mouseleave", () => {
|
media.addEventListener("mouseleave", () => {
|
||||||
img.src = source;
|
img.src = imageSource;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
media.append(img);
|
||||||
|
} else if (item.kind === "video") {
|
||||||
|
// No thumbnail: take the first frame through the same-origin stream endpoint,
|
||||||
|
// which refreshes expired URLs and avoids cross-origin blocking.
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.preload = "metadata";
|
||||||
|
video.muted = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.src = streamPath;
|
||||||
|
video.addEventListener(
|
||||||
|
"loadedmetadata",
|
||||||
|
() => {
|
||||||
|
if (video.duration) video.currentTime = Math.min(0.1, video.duration / 2);
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
video.addEventListener("error", () => placeholder(media, item), { once: true });
|
||||||
|
media.append(video);
|
||||||
|
} else if (fallbackSource && /^(https?:|\/)/.test(fallbackSource)) {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.loading = "lazy";
|
||||||
|
img.src = fallbackSource;
|
||||||
|
img.alt = item.kind;
|
||||||
|
img.addEventListener("error", () => placeholder(media, item), { once: true });
|
||||||
media.append(img);
|
media.append(img);
|
||||||
} else {
|
} else {
|
||||||
placeholder(media, item);
|
placeholder(media, item);
|
||||||
@@ -1124,7 +1119,7 @@
|
|||||||
const streamUrl = `/api/media/${item.scrapeId}/${item.itemId}/stream`;
|
const streamUrl = `/api/media/${item.scrapeId}/${item.itemId}/stream`;
|
||||||
const source = item.localUrl || item.thumbUrl || streamUrl;
|
const source = item.localUrl || item.thumbUrl || streamUrl;
|
||||||
const embed = embedUrlFor(item);
|
const embed = embedUrlFor(item);
|
||||||
const directVideo = item.localUrl || item.playUrl || (item.source !== "embed" ? streamUrl : null);
|
const directVideo = item.localUrl || (item.kind === "video" ? streamUrl : null);
|
||||||
const canPlay = !!embed || (item.kind === "video" && !!directVideo);
|
const canPlay = !!embed || (item.kind === "video" && !!directVideo);
|
||||||
|
|
||||||
// A play request on something we cannot play falls back to the external page.
|
// A play request on something we cannot play falls back to the external page.
|
||||||
@@ -1156,13 +1151,27 @@
|
|||||||
{ once: true }
|
{ once: true }
|
||||||
);
|
);
|
||||||
media.append(video);
|
media.append(video);
|
||||||
|
} else if (item.kind === "video") {
|
||||||
|
// No still available: show the first frame through the stream endpoint.
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.preload = "metadata";
|
||||||
|
video.muted = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.src = streamUrl;
|
||||||
|
video.addEventListener(
|
||||||
|
"loadedmetadata",
|
||||||
|
() => {
|
||||||
|
if (video.duration) video.currentTime = Math.min(0.1, video.duration / 2);
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
media.append(video);
|
||||||
|
media.append(viewerPlayIcon(item));
|
||||||
} else if (/^(https?:|\/)/.test(source)) {
|
} else if (/^(https?:|\/)/.test(source)) {
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
img.src = source;
|
img.src = source;
|
||||||
img.alt = item.kind;
|
img.alt = item.kind;
|
||||||
media.append(img);
|
media.append(img);
|
||||||
|
|
||||||
if (item.kind === "video") media.append(viewerPlayIcon(item));
|
|
||||||
} else {
|
} else {
|
||||||
media.append(el("span", "vk-media-card-kind", item.kind.toUpperCase()));
|
media.append(el("span", "vk-media-card-kind", item.kind.toUpperCase()));
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-6
@@ -391,16 +391,20 @@ export class MediaService {
|
|||||||
const file = this.getMediaFile(scrapeId, itemId);
|
const file = this.getMediaFile(scrapeId, itemId);
|
||||||
if (file) return { kind: "file", ...file };
|
if (file) return { kind: "file", ...file };
|
||||||
|
|
||||||
|
const item = this.getMediaItem(scrapeId, itemId);
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
return { kind: "remote", url: item.play_url ?? item.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-fetch the source message for a fresh signed URL. Null when not possible. */
|
||||||
|
async refreshMediaUrl(scrapeId: string, itemId: number): Promise<string | null> {
|
||||||
const meta = this.engine.get(scrapeId);
|
const meta = this.engine.get(scrapeId);
|
||||||
const item = this.getMediaItem(scrapeId, itemId);
|
const item = this.getMediaItem(scrapeId, itemId);
|
||||||
if (!meta || !item) return null;
|
if (!meta || !item) return null;
|
||||||
|
if (meta.backend !== "slopbot" || !meta.guildId || !this.bot) return null;
|
||||||
|
|
||||||
if (meta.backend === "slopbot" && meta.guildId && this.bot) {
|
return this.refreshUrls(meta.id, meta.channelId, item);
|
||||||
const refreshed = await this.refreshUrls(meta.id, meta.channelId, item);
|
|
||||||
if (refreshed) return { kind: "remote", url: refreshed };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { kind: "remote", url: item.play_url ?? item.url };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-fetch the source message and update expired URLs. Returns the fresh playable URL. */
|
/** Re-fetch the source message and update expired URLs. Returns the fresh playable URL. */
|
||||||
|
|||||||
+14
-3
@@ -789,9 +789,20 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const upstream = await fetch(source.url, {
|
const rangeHeaders: Record<string, string> = req.headers.range
|
||||||
headers: req.headers.range ? { range: String(req.headers.range) } : {}
|
? { range: String(req.headers.range) }
|
||||||
});
|
: {};
|
||||||
|
let upstream = await fetch(source.url, { headers: rangeHeaders });
|
||||||
|
|
||||||
|
// The signed CDN URL may have expired; re-fetch the message once and retry.
|
||||||
|
if (!upstream.ok && [403, 404, 410].includes(upstream.status)) {
|
||||||
|
const refreshed = await deps.media.refreshMediaUrl(
|
||||||
|
String(req.params.scrapeId),
|
||||||
|
Number(req.params.itemId)
|
||||||
|
);
|
||||||
|
if (refreshed) upstream = await fetch(refreshed, { headers: rangeHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
if (!upstream.ok && upstream.status !== 206) {
|
if (!upstream.ok && upstream.status !== 206) {
|
||||||
throw new HttpError(502, `upstream ${upstream.status}`);
|
throw new HttpError(502, `upstream ${upstream.status}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user