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
357 lines
15 KiB
JavaScript
357 lines
15 KiB
JavaScript
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;
|
|
};
|
|
})();
|