diff --git a/frontend/index.html b/frontend/index.html
index 0556773..f95fa80 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -276,6 +276,7 @@
+
diff --git a/frontend/js/favorites.js b/frontend/js/favorites.js
index 0ce0de6..64cce58 100644
--- a/frontend/js/favorites.js
+++ b/frontend/js/favorites.js
@@ -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;
diff --git a/frontend/js/feed.js b/frontend/js/feed.js
index fd0d063..eae199a 100644
--- a/frontend/js/feed.js
+++ b/frontend/js/feed.js
@@ -807,7 +807,7 @@ App.feed = App.feed || {};
`;
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);
};
diff --git a/frontend/js/posters.js b/frontend/js/posters.js
new file mode 100644
index 0000000..843d556
--- /dev/null
+++ b/frontend/js/posters.js
@@ -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
+ //