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 //