Take a thumbnail out of the video when the listed one is gone
A favorite outlives its thumbnail. Sources sign their CDN paths and the signature expires, so a video kept months ago has a `thumb` that 404s and a card that is a grey box with a title under it. Asking the source page for a fresh picture covers the case where the page still exists; nothing covered the case where it doesn't. The video does, because playback re-resolves from the page URL every time. So once every route to the listed picture is exhausted, a frame a quarter of the way in becomes the card's picture -- past the black lead-in, and a fair guess at what the video looks like. It goes through our own proxy, so the canvas can be read back, is scaled to card width and JPEG'd to a handful of KB, and is kept: later mounts and later sessions paint it without a request, instead of asking again for a picture that is known to be gone. Only for favorites, once per item, one at a time, and never for a live stream or a manifest. Two things worth knowing, both found with the browser rather than reasoned about: a browser fires `seeked` for its own initial seek to zero, so taking the frame on the first one captures the opening of every video; and the same item is on screen in more than one place, so the captures are deduped by a promise rather than the first asker getting the picture and the rest being told there isn't one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
@@ -276,6 +276,7 @@
|
||||
<script src="static/js/player.js"></script>
|
||||
<script src="static/js/favorites.js"></script>
|
||||
<script src="static/js/favoritesView.js"></script>
|
||||
<script src="static/js/posters.js"></script>
|
||||
<script src="static/js/sqlite.js"></script>
|
||||
<script src="static/js/hottubBackup.js"></script>
|
||||
<script src="static/js/videos.js"></script>
|
||||
|
||||
@@ -494,7 +494,7 @@ App.favorites = App.favorites || {};
|
||||
`;
|
||||
const thumb = card.querySelector('img');
|
||||
if (App.videos && typeof App.videos.attachThumbnail === 'function') {
|
||||
App.videos.attachThumbnail(thumb, item.thumb, App.videos.sourcePageUrl(item));
|
||||
App.videos.attachThumbnail(thumb, item.thumb, App.videos.sourcePageUrl(item), item);
|
||||
}
|
||||
card.onclick = () => {
|
||||
if (card.classList.contains('is-loading')) return;
|
||||
|
||||
@@ -807,7 +807,7 @@ App.feed = App.feed || {};
|
||||
</div>
|
||||
`;
|
||||
const poster = pane.querySelector('.feed-poster');
|
||||
App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v));
|
||||
App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v), v);
|
||||
const slideVideo = pane.querySelector('.feed-video');
|
||||
bindTimeline(pane, slideVideo);
|
||||
bindSharedControls(pane, slideVideo, v);
|
||||
@@ -1584,7 +1584,7 @@ App.feed = App.feed || {};
|
||||
const titleText = pane.querySelector('.feed-title-text');
|
||||
if (titleText) titleText.textContent = v.title || '';
|
||||
const poster = pane.querySelector('.feed-poster');
|
||||
if (poster) App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v));
|
||||
if (poster) App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v), v);
|
||||
loadSlideSource(pane, v, true);
|
||||
setPipMetadata(v);
|
||||
};
|
||||
|
||||
356
frontend/js/posters.js
Normal file
356
frontend/js/posters.js
Normal file
@@ -0,0 +1,356 @@
|
||||
window.App = window.App || {};
|
||||
App.posters = App.posters || {};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Captured posters
|
||||
//
|
||||
// A favorite outlives its thumbnail. The picture a listing hands out is a
|
||||
// signed CDN path on most sources, and the signature expires -- so a video kept
|
||||
// months ago has a `thumb` that 404s, and the card that stood for it is a grey
|
||||
// box with a title under it. Asking the source page for a fresh one (the
|
||||
// /api/poster repair in videos.js) covers the case where the page still exists
|
||||
// and still shows a picture; nothing covers the case where it doesn't.
|
||||
//
|
||||
// The video itself does, though: it is still playable, because playback
|
||||
// re-resolves from the page URL every time. So once every route to the listed
|
||||
// picture is exhausted, a frame a quarter of the way into the clip becomes the
|
||||
// card's picture -- far enough in to be past a title card or a black lead-in,
|
||||
// and a fair guess at what the video looks like.
|
||||
//
|
||||
// A frame costs a resolve and the stretch of video around that point, which is
|
||||
// why this happens once per item, for favorites only, and never again: the
|
||||
// frame is scaled down, JPEG'd, and kept in localStorage, so the next mount and
|
||||
// every later session paint it straight away without touching the network.
|
||||
// ---------------------------------------------------------------------------
|
||||
(function() {
|
||||
const INDEX_KEY = App.constants.POSTER_CACHE_KEY;
|
||||
const ENTRY_PREFIX = `${INDEX_KEY}:`;
|
||||
|
||||
// A thumbnail box is around 300 CSS px wide in the grid and half a phone
|
||||
// screen in the favorites bar, so this is a picture at roughly card size
|
||||
// and not a frame of video: the point of the cache is to be small.
|
||||
const WIDTH = 360;
|
||||
const QUALITY = 0.72;
|
||||
// How far into the clip the frame comes from.
|
||||
const SEEK_FRACTION = 0.25;
|
||||
// What the whole cache may take. Stored base64, so the string is a third
|
||||
// larger than the JPEG behind it -- around 20 KB an entry, which leaves
|
||||
// room for a hundred or so of them inside a localStorage budget that is
|
||||
// shared with the favorites themselves.
|
||||
const BUDGET_BYTES = 3 * 1024 * 1024;
|
||||
// How long a seek gets before the frame it was decoding anyway will do.
|
||||
const SEEK_PATIENCE_MS = 6000;
|
||||
// A capture is one seek into one file. If that has not produced a frame by
|
||||
// now, the source is not going to give us one.
|
||||
const FRAME_TIMEOUT_MS = 20000;
|
||||
// How many of the ranked sources are worth trying before giving up on an
|
||||
// item for this session.
|
||||
const SOURCE_TRIES = 2;
|
||||
|
||||
// Both halves of a 64-bit-ish hash of the item's identity: a cache key has
|
||||
// to be short enough to be a localStorage key and distinct enough that two
|
||||
// favorites never share a picture.
|
||||
const hash = function(text) {
|
||||
let fnv = 0x811c9dc5;
|
||||
let djb = 5381;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const code = text.charCodeAt(i);
|
||||
fnv = (fnv ^ code) >>> 0;
|
||||
fnv = (fnv + ((fnv << 1) + (fnv << 4) + (fnv << 7) + (fnv << 8) + (fnv << 24))) >>> 0;
|
||||
djb = (((djb << 5) + djb) + code) >>> 0;
|
||||
}
|
||||
return fnv.toString(16).padStart(8, '0') + djb.toString(16).padStart(8, '0');
|
||||
};
|
||||
|
||||
// The identity to cache under: the item's own URL, normalized the way
|
||||
// favorites normalize one, so the same video saved from a listing and
|
||||
// imported from a backup lands on the same picture.
|
||||
const keyOf = function(video) {
|
||||
if (!video || typeof video !== 'object') return '';
|
||||
const url = video.url || (video.meta && video.meta.url) || '';
|
||||
const basis = (url && App.favorites && App.favorites.urlKey(url)) || url ||
|
||||
(App.favorites && App.favorites.getKey(video)) || video.id || '';
|
||||
return basis ? hash(String(basis)) : '';
|
||||
};
|
||||
|
||||
// ---- the cache -------------------------------------------------------
|
||||
|
||||
// [{ k, n, at }], oldest capture first. Read once and kept: a card asks
|
||||
// whether it has a picture as it mounts, and parsing the index again for
|
||||
// every card of a scrolling grid is the one thing this must not cost.
|
||||
let index = null;
|
||||
|
||||
const readIndex = function() {
|
||||
if (index) return index;
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(INDEX_KEY));
|
||||
index = Array.isArray(parsed) ?
|
||||
parsed.filter((entry) => entry && entry.k).map((entry) => ({
|
||||
k: String(entry.k),
|
||||
n: Number(entry.n) || 0,
|
||||
at: Number(entry.at) || 0
|
||||
})) : [];
|
||||
} catch (err) {
|
||||
index = [];
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const writeIndex = function() {
|
||||
try {
|
||||
localStorage.setItem(INDEX_KEY, JSON.stringify(index));
|
||||
} catch (err) { /* nothing here is worth failing a card over */ }
|
||||
};
|
||||
|
||||
const drop = function(entry) {
|
||||
try {
|
||||
localStorage.removeItem(ENTRY_PREFIX + entry.k);
|
||||
} catch (err) { /* already gone */ }
|
||||
};
|
||||
|
||||
const store = function(key, src) {
|
||||
const list = readIndex().filter((entry) => entry.k !== key);
|
||||
list.push({ k: key, n: src.length, at: Date.now() });
|
||||
index = list;
|
||||
// Oldest captures go first. Not least-recently-seen: tracking that
|
||||
// would mean writing to storage every time a card mounts, which is a
|
||||
// worse trade than occasionally re-capturing a picture.
|
||||
let total = list.reduce((sum, entry) => sum + entry.n, 0);
|
||||
while (total > BUDGET_BYTES && index.length > 1) {
|
||||
const oldest = index.shift();
|
||||
total -= oldest.n;
|
||||
drop(oldest);
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(ENTRY_PREFIX + key, src);
|
||||
} catch (err) {
|
||||
// Out of room, and not necessarily because of us -- the store is
|
||||
// shared with the favorites and everything else. Make room by
|
||||
// giving up the oldest third of the cache and try once more.
|
||||
const giveUp = Math.max(1, Math.floor(index.length / 3));
|
||||
index.splice(0, giveUp).forEach(drop);
|
||||
try {
|
||||
localStorage.setItem(ENTRY_PREFIX + key, src);
|
||||
} catch (again) {
|
||||
index = index.filter((entry) => entry.k !== key);
|
||||
writeIndex();
|
||||
return '';
|
||||
}
|
||||
}
|
||||
writeIndex();
|
||||
return src;
|
||||
};
|
||||
|
||||
const read = function(key) {
|
||||
try {
|
||||
return localStorage.getItem(ENTRY_PREFIX + key) || '';
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// The picture already captured for this item, if there is one.
|
||||
App.posters.cached = function(video) {
|
||||
const key = keyOf(video);
|
||||
if (!key) return '';
|
||||
// The index says what we have without touching the entries, so an item
|
||||
// with no captured picture -- almost all of them -- costs a set lookup
|
||||
// rather than a read of a data URL out of storage.
|
||||
if (!readIndex().some((entry) => entry.k === key)) return '';
|
||||
const src = read(key);
|
||||
if (!src) {
|
||||
index = readIndex().filter((entry) => entry.k !== key);
|
||||
writeIndex();
|
||||
}
|
||||
return src;
|
||||
};
|
||||
|
||||
App.posters.forget = function(video) {
|
||||
const key = keyOf(video);
|
||||
if (!key) return;
|
||||
readIndex().filter((entry) => entry.k === key).forEach(drop);
|
||||
index = readIndex().filter((entry) => entry.k !== key);
|
||||
writeIndex();
|
||||
};
|
||||
|
||||
App.posters.clear = function() {
|
||||
readIndex().forEach(drop);
|
||||
index = [];
|
||||
writeIndex();
|
||||
};
|
||||
|
||||
App.posters.stats = function() {
|
||||
const list = readIndex();
|
||||
return { count: list.length, bytes: list.reduce((sum, entry) => sum + entry.n, 0) };
|
||||
};
|
||||
|
||||
// ---- taking the frame ------------------------------------------------
|
||||
|
||||
// Draws what the element is showing, at card size rather than video size.
|
||||
const drawFrame = function(el) {
|
||||
const width = el.videoWidth;
|
||||
const height = el.videoHeight;
|
||||
if (!width || !height) throw new Error('nothing was decoded');
|
||||
const scale = Math.min(1, WIDTH / width);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(width * scale));
|
||||
canvas.height = Math.max(1, Math.round(height * scale));
|
||||
canvas.getContext('2d').drawImage(el, 0, 0, canvas.width, canvas.height);
|
||||
// Tainted canvas throws here, which is why the frame is taken from our
|
||||
// own proxy rather than from the CDN: /api/stream is same-origin, so
|
||||
// the pixels can be read back.
|
||||
const src = canvas.toDataURL('image/jpeg', QUALITY);
|
||||
if (!src || src.length < 512) throw new Error('the frame came out empty');
|
||||
return src;
|
||||
};
|
||||
|
||||
const grabFrame = function(url, duration) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const el = document.createElement('video');
|
||||
el.muted = true;
|
||||
el.defaultMuted = true;
|
||||
el.playsInline = true;
|
||||
el.preload = 'auto';
|
||||
el.crossOrigin = 'anonymous';
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
// Off-screen, but in the document: an element the browser does not
|
||||
// consider part of a page is under no obligation to decode
|
||||
// anything.
|
||||
el.style.cssText = 'position:fixed;left:-9999px;top:0;width:2px;height:2px;' +
|
||||
'opacity:0;pointer-events:none';
|
||||
|
||||
let settled = false;
|
||||
let timer = null;
|
||||
let patience = null;
|
||||
// Where we asked to be, and whether we asked at all. A browser
|
||||
// fires `seeked` for its own initial seek to zero as the file
|
||||
// opens, and taking the frame on that one would mean every
|
||||
// captured picture is the first frame of the video -- usually a
|
||||
// black one -- however far in we meant to go.
|
||||
let target = 0;
|
||||
let asked = false;
|
||||
const done = function(err, src) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (patience) clearTimeout(patience);
|
||||
// Told to stop downloading before it leaves, or the request
|
||||
// outlives the element that made it.
|
||||
el.removeAttribute('src');
|
||||
try { el.load(); } catch (stop) { /* nothing to stop */ }
|
||||
el.remove();
|
||||
if (err) reject(err);
|
||||
else resolve(src);
|
||||
};
|
||||
const take = function() {
|
||||
try { done(null, drawFrame(el)); } catch (err) { done(err); }
|
||||
};
|
||||
|
||||
timer = setTimeout(() => done(new Error('no frame in time')), FRAME_TIMEOUT_MS);
|
||||
el.addEventListener('error', () => done(new Error('the source would not play')));
|
||||
el.addEventListener('loadedmetadata', () => {
|
||||
const length = Number(el.duration) || Number(duration) || 0;
|
||||
target = (length > 0 && isFinite(length)) ? length * SEEK_FRACTION : 0;
|
||||
// Nowhere to seek to -- a stream of unknown length -- so the
|
||||
// first frame it decodes is the one we get.
|
||||
if (target <= 0) return;
|
||||
asked = true;
|
||||
try { el.currentTime = target; } catch (err) { done(err); }
|
||||
// Some sources cannot be seeked in at all: no byte ranges, or
|
||||
// a length the browser does not trust. They still have an
|
||||
// opening frame, and a picture of the opening beats a grey box
|
||||
// -- but only once the seek has had its chance.
|
||||
patience = setTimeout(() => {
|
||||
if (el.readyState >= 2) take();
|
||||
}, SEEK_PATIENCE_MS);
|
||||
});
|
||||
el.addEventListener('seeked', () => {
|
||||
// Near enough: a seek lands on the nearest frame it can decode,
|
||||
// which is the keyframe before the mark, not the mark itself.
|
||||
if (asked && el.currentTime < target - 1) return;
|
||||
take();
|
||||
});
|
||||
el.addEventListener('loadeddata', () => { if (!asked) take(); });
|
||||
|
||||
document.body.appendChild(el);
|
||||
el.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
const captureOne = async function(video) {
|
||||
// Favorites keep no resolved formats on purpose (their URLs expire), so
|
||||
// this is where the item becomes playable again.
|
||||
try {
|
||||
await App.videos.ensureFormats(video);
|
||||
} catch (err) { /* the page-URL fallback below may still play */ }
|
||||
|
||||
const sources = App.videos.resolveStreamSources(video, {
|
||||
applyPreferredQuality: false,
|
||||
// The smallest format there is: this is going to end up 360 px
|
||||
// wide, so a 1080p stream would be bytes spent on pixels that get
|
||||
// thrown away.
|
||||
cheapest: true
|
||||
}).filter((source) => {
|
||||
// A manifest is a playlist of segments, not something a bare
|
||||
// <video> can open outside Safari, and the player's hls.js is a
|
||||
// heavier thing than a thumbnail is worth.
|
||||
const kind = App.videos.classifySource(source);
|
||||
return !kind.isHls && !source.isLive;
|
||||
}).slice(0, SOURCE_TRIES);
|
||||
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const url = App.videos.buildStreamUrlFromSource(sources[i]);
|
||||
if (!url) continue;
|
||||
try {
|
||||
return await grabFrame(url, Number(video.duration) || 0);
|
||||
} catch (err) {
|
||||
// Try the next format; if that was the last one, the item has
|
||||
// no picture and no amount of asking changes it.
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// The same item is on screen in more than one place -- the favorites bar
|
||||
// and the grid behind it, a card and the reel's own poster -- and all of
|
||||
// them ask. They wait on the one capture and all get the picture; the
|
||||
// alternative is the first one to ask getting a picture and the rest being
|
||||
// told there isn't one.
|
||||
const inFlight = new Map();
|
||||
// And one try per item per session once it has come back empty: a failed
|
||||
// capture is a resolve plus a download, and a grid that scrolls past the
|
||||
// same dead favorite ten times must not pay for it ten times.
|
||||
const failed = new Set();
|
||||
// One capture at a time. The reader is watching something; a thumbnail has
|
||||
// no business competing with it for bandwidth.
|
||||
let queue = Promise.resolve();
|
||||
|
||||
App.posters.capture = function(video) {
|
||||
const key = keyOf(video);
|
||||
if (!key) return Promise.resolve('');
|
||||
const have = App.posters.cached(video);
|
||||
if (have) return Promise.resolve(have);
|
||||
const running = inFlight.get(key);
|
||||
if (running) return running;
|
||||
if (failed.has(key)) return Promise.resolve('');
|
||||
// A live stream has no quarter mark, and whatever it is showing now is
|
||||
// not what it will be showing when the card is next looked at.
|
||||
if (video.isLive || (video.meta && video.meta.isLive)) return Promise.resolve('');
|
||||
|
||||
const run = queue.then(() => captureOne(video)).then((src) => {
|
||||
inFlight.delete(key);
|
||||
if (!src) {
|
||||
failed.add(key);
|
||||
return '';
|
||||
}
|
||||
return store(key, src);
|
||||
}, () => {
|
||||
inFlight.delete(key);
|
||||
failed.add(key);
|
||||
return '';
|
||||
});
|
||||
inFlight.set(key, run);
|
||||
queue = run.catch(() => '');
|
||||
return run;
|
||||
};
|
||||
})();
|
||||
@@ -26,7 +26,10 @@ App.constants = {
|
||||
PREFERRED_QUALITY_KEY: 'preferredQuality',
|
||||
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior',
|
||||
SEARCH_HISTORY_KEY: 'searchHistory',
|
||||
SAVED_SEARCHES_KEY: 'savedSearches'
|
||||
SAVED_SEARCHES_KEY: 'savedSearches',
|
||||
// Thumbnails captured out of a video whose listed picture is gone:
|
||||
// the index, plus one entry per picture under `<key>:<hash>`.
|
||||
POSTER_CACHE_KEY: 'posterCache'
|
||||
};
|
||||
|
||||
// Lazily injects hls.js the first time a stream actually needs it. Sessions
|
||||
|
||||
@@ -270,9 +270,24 @@ App.videos = App.videos || {};
|
||||
// being asked.
|
||||
const posterAsked = new Map(); // page URL -> promise of a replacement, or ''
|
||||
|
||||
// Last of all, once the listed picture is gone and the page has no
|
||||
// better one to offer: a frame out of the video itself. Only for something
|
||||
// the reader kept -- a capture costs a resolve and a stretch of video,
|
||||
// which is not worth spending on a card that scrolls past once -- and the
|
||||
// picture is cached, so an item pays for it once and no later session pays
|
||||
// at all. See App.posters.
|
||||
const capturePoster = function(img, token) {
|
||||
const video = img._thumbVideo;
|
||||
if (!video || !App.posters || !App.favorites || !App.favorites.has(video)) return;
|
||||
App.posters.capture(video).then((src) => {
|
||||
if (!src || img.dataset.thumbToken !== token) return;
|
||||
showThumbnail(img, src, token);
|
||||
});
|
||||
};
|
||||
|
||||
const repairThumbnail = function(img, token) {
|
||||
const page = img.dataset.thumbPage || '';
|
||||
if (!page) return;
|
||||
if (!page) { capturePoster(img, token); return; }
|
||||
let pending = posterAsked.get(page);
|
||||
if (!pending) {
|
||||
pending = fetch(`/api/poster?url=${encodeURIComponent(page)}`)
|
||||
@@ -282,7 +297,9 @@ App.videos = App.videos || {};
|
||||
posterAsked.set(page, pending);
|
||||
}
|
||||
pending.then((replacement) => {
|
||||
if (!replacement || img.dataset.thumbToken !== token) return;
|
||||
if (img.dataset.thumbToken !== token) return;
|
||||
// The page had nothing either, so there is one thing left to try.
|
||||
if (!replacement) { capturePoster(img, token); return; }
|
||||
// No second repair for this mount: the ladder below must not come
|
||||
// back here and ask the same page for the same picture forever.
|
||||
delete img.dataset.thumbPage;
|
||||
@@ -529,11 +546,14 @@ App.videos = App.videos || {};
|
||||
// racing the two the first time that host is seen. `page` is optional: the
|
||||
// item's own page, asked for a replacement if the picture turns out to be
|
||||
// gone.
|
||||
App.videos.attachThumbnail = function(img, url, page) {
|
||||
App.videos.attachThumbnail = function(img, url, page, video) {
|
||||
// An address that isn't one is the same thing as no thumbnail: the card
|
||||
// keeps its placeholder rather than chasing it. See usableThumbUrl.
|
||||
const directUrl = usableThumbUrl(url || (img && img.dataset.thumb) || '');
|
||||
if (!img) return;
|
||||
// The item this picture is of, kept for the capture at the end of the
|
||||
// ladder (see capturePoster).
|
||||
img._thumbVideo = video || null;
|
||||
// Held back until there is an image to caption -- see showThumbnail. An
|
||||
// item with no thumbnail keeps an empty alt: the card's own title sits
|
||||
// directly beneath the box, so there is nothing for it to add.
|
||||
@@ -541,7 +561,30 @@ App.videos = App.videos || {};
|
||||
img.dataset.alt = img.alt;
|
||||
img.alt = '';
|
||||
}
|
||||
if (!directUrl) return;
|
||||
// Every attach is a new generation, so work started for a previous one
|
||||
// stops being able to touch this element.
|
||||
const token = String(++thumbSeq);
|
||||
img.dataset.thumbToken = token;
|
||||
if (page) img.dataset.thumbPage = page;
|
||||
else delete img.dataset.thumbPage;
|
||||
|
||||
// A picture captured for this item earlier wins outright. The cache
|
||||
// only ever holds one because every route to the listed picture had
|
||||
// already failed, so going back to the wire for it would be a request
|
||||
// that is known to fail, on every mount, for the rest of the item's
|
||||
// life.
|
||||
const captured = App.posters ? App.posters.cached(video) : '';
|
||||
if (captured) {
|
||||
showThumbnail(img, captured, token);
|
||||
return;
|
||||
}
|
||||
if (!directUrl) {
|
||||
// Nothing listed to fail, so there is nothing to walk a ladder
|
||||
// down: a frame out of the video is the only picture this item is
|
||||
// ever going to have.
|
||||
capturePoster(img, token);
|
||||
return;
|
||||
}
|
||||
// A cross-origin Referer is what most hotlink protection keys on, and an
|
||||
// image needs none. Sending none is what lets the direct route work at
|
||||
// all on a fair number of providers -- and the direct route is the one
|
||||
@@ -551,12 +594,6 @@ App.videos = App.videos || {};
|
||||
const proxyUrl = App.videos.buildImageProxyUrl(directUrl);
|
||||
const host = imageHostOf(directUrl);
|
||||
const route = imageRoutes.get(host);
|
||||
// Every attach is a new generation, so work started for a previous one
|
||||
// stops being able to touch this element.
|
||||
const token = String(++thumbSeq);
|
||||
img.dataset.thumbToken = token;
|
||||
if (page) img.dataset.thumbPage = page;
|
||||
else delete img.dataset.thumbPage;
|
||||
|
||||
if (route === IMAGE_PROXY) {
|
||||
attachRetry(img, retryPlan(directUrl, proxyUrl, IMAGE_PROXY), token);
|
||||
@@ -590,6 +627,7 @@ App.videos = App.videos || {};
|
||||
img.dataset.thumbToken = String(++thumbSeq);
|
||||
delete img.dataset.alt;
|
||||
delete img.dataset.thumbPage;
|
||||
img._thumbVideo = null;
|
||||
detachRetry(img);
|
||||
};
|
||||
|
||||
@@ -1126,7 +1164,7 @@ App.videos = App.videos || {};
|
||||
// the same frame, so loading a thumbnail for it -- let alone racing one
|
||||
// -- would be pure waste.
|
||||
if (!(options && options.skipThumbnail)) {
|
||||
App.videos.attachThumbnail(cardRefs(card).img, v.thumb, App.videos.sourcePageUrl(v));
|
||||
App.videos.attachThumbnail(cardRefs(card).img, v.thumb, App.videos.sourcePageUrl(v), v);
|
||||
}
|
||||
return card;
|
||||
};
|
||||
@@ -1824,7 +1862,7 @@ App.videos = App.videos || {};
|
||||
if (!card || !v) return;
|
||||
unfilled.delete(i);
|
||||
const img = cardRefs(card).img;
|
||||
App.videos.attachThumbnail(img, v.thumb, App.videos.sourcePageUrl(v));
|
||||
App.videos.attachThumbnail(img, v.thumb, App.videos.sourcePageUrl(v), v);
|
||||
// Once the thumbnail loads, drop the 16:9 placeholder so it shows at
|
||||
// its true aspect ratio, clear the shimmer, then correct the height.
|
||||
if (img) {
|
||||
|
||||
Reference in New Issue
Block a user