Compare commits
4 Commits
thumbnail-
...
7624ca559a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7624ca559a | ||
|
|
49992c1db0 | ||
|
|
b4031b5d0e | ||
|
|
1dbac33359 |
@@ -1,5 +1,11 @@
|
|||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* A card keeps its optional parts (live badge, uploader, duration, tags) at all
|
||||||
|
times and hides the ones this video doesn't need, so any pooled card fits any
|
||||||
|
video -- see bindCard. Several of those carry their own `display`, which beats
|
||||||
|
the UA rule for [hidden], so say it once and mean it. */
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Warm "classic" dark: deep charcoal with a hint of brown, never pure black. */
|
/* Warm "classic" dark: deep charcoal with a hint of brown, never pure black. */
|
||||||
--bg-primary: #14110d;
|
--bg-primary: #14110d;
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ App.enhance = App.enhance || {};
|
|||||||
if (!grid || !fineHover) return;
|
if (!grid || !fineHover) return;
|
||||||
let dwellTimer = null;
|
let dwellTimer = null;
|
||||||
let activeCard = null;
|
let activeCard = null;
|
||||||
|
// The card element alone doesn't identify what is being previewed: the
|
||||||
|
// grid pools its cards, so the same element can come back showing a
|
||||||
|
// different video (via relayout or a new search, neither of which
|
||||||
|
// scrolls, so clearPreview never runs). Remembering the video too keeps
|
||||||
|
// the "already previewing this" check honest.
|
||||||
|
let activeVideo = null;
|
||||||
|
|
||||||
|
const isActive = (card) => card === activeCard &&
|
||||||
|
(!App.videos.getVideoForCard || App.videos.getVideoForCard(card) === activeVideo);
|
||||||
|
|
||||||
const clearPreview = () => {
|
const clearPreview = () => {
|
||||||
if (dwellTimer) { clearTimeout(dwellTimer); dwellTimer = null; }
|
if (dwellTimer) { clearTimeout(dwellTimer); dwellTimer = null; }
|
||||||
@@ -68,6 +77,7 @@ App.enhance = App.enhance || {};
|
|||||||
if (vid) { try { vid.pause(); } catch (e) {} vid.remove(); }
|
if (vid) { try { vid.pause(); } catch (e) {} vid.remove(); }
|
||||||
activeCard.classList.remove('is-previewing');
|
activeCard.classList.remove('is-previewing');
|
||||||
activeCard = null;
|
activeCard = null;
|
||||||
|
activeVideo = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,10 +115,14 @@ App.enhance = App.enhance || {};
|
|||||||
|
|
||||||
grid.addEventListener('pointerover', (e) => {
|
grid.addEventListener('pointerover', (e) => {
|
||||||
const card = e.target.closest('.video-card');
|
const card = e.target.closest('.video-card');
|
||||||
if (!card || card === activeCard) return;
|
if (!card || isActive(card)) return;
|
||||||
clearPreview();
|
clearPreview();
|
||||||
activeCard = card;
|
activeCard = card;
|
||||||
dwellTimer = setTimeout(() => { dwellTimer = null; if (activeCard === card) startPreview(card); }, 600);
|
activeVideo = App.videos.getVideoForCard ? App.videos.getVideoForCard(card) : null;
|
||||||
|
dwellTimer = setTimeout(() => {
|
||||||
|
dwellTimer = null;
|
||||||
|
if (isActive(card)) startPreview(card);
|
||||||
|
}, 600);
|
||||||
});
|
});
|
||||||
grid.addEventListener('pointerout', (e) => {
|
grid.addEventListener('pointerout', (e) => {
|
||||||
const card = e.target.closest('.video-card');
|
const card = e.target.closest('.video-card');
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ App.favorites = App.favorites || {};
|
|||||||
(function() {
|
(function() {
|
||||||
const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY } = App.constants;
|
const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY } = App.constants;
|
||||||
|
|
||||||
|
// Both identities of every favorite, in one pass, held until the list
|
||||||
|
// changes (see setAll). This is read once per card built, and re-parsing the
|
||||||
|
// whole favorites list out of localStorage that often is what makes a long
|
||||||
|
// list felt on a scrolling grid.
|
||||||
|
let identityCache = null;
|
||||||
|
|
||||||
// Favorites storage helpers.
|
// Favorites storage helpers.
|
||||||
App.favorites.getAll = function() {
|
App.favorites.getAll = function() {
|
||||||
try {
|
try {
|
||||||
@@ -87,6 +93,7 @@ App.favorites = App.favorites || {};
|
|||||||
};
|
};
|
||||||
|
|
||||||
App.favorites.setAll = function(items) {
|
App.favorites.setAll = function(items) {
|
||||||
|
identityCache = null;
|
||||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items));
|
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,19 +188,49 @@ App.favorites = App.favorites || {};
|
|||||||
return { added, skipped, total: favorites.length };
|
return { added, skipped, total: favorites.length };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const identities = function() {
|
||||||
|
if (!identityCache) {
|
||||||
|
const keys = new Set();
|
||||||
|
const urls = new Set();
|
||||||
|
// getAll may rewrite the list (the repair pass), which clears the
|
||||||
|
// cache -- so build it only after that has run.
|
||||||
|
const items = App.favorites.getAll();
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (!item) return;
|
||||||
|
if (item.key) keys.add(item.key);
|
||||||
|
const urlKey = App.favorites.urlKey(item.url);
|
||||||
|
if (urlKey) urls.add(urlKey);
|
||||||
|
});
|
||||||
|
identityCache = { keys: keys, urls: urls };
|
||||||
|
}
|
||||||
|
return identityCache;
|
||||||
|
};
|
||||||
|
|
||||||
App.favorites.getSet = function() {
|
App.favorites.getSet = function() {
|
||||||
return new Set(App.favorites.getAll().map((item) => item.key));
|
return identities().keys;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Same set, addressed by URL. Imported favorites are keyed by URL rather
|
// Same set, addressed by URL. Imported favorites are keyed by URL rather
|
||||||
// than by a server id, so a listing card can only recognise one this way.
|
// than by a server id, so a listing card can only recognise one this way.
|
||||||
App.favorites.getUrlSet = function() {
|
App.favorites.getUrlSet = function() {
|
||||||
const urls = new Set();
|
return identities().urls;
|
||||||
App.favorites.getAll().forEach((item) => {
|
};
|
||||||
const urlKey = item && App.favorites.urlKey(item.url);
|
|
||||||
if (urlKey) urls.add(urlKey);
|
// Is this video a favorite, under either identity? A video reaches us from
|
||||||
});
|
// the listing keyed by the server's id and from a Hot Tub backup keyed by
|
||||||
return urls;
|
// its URL, and the same video must light up its heart whichever way the
|
||||||
|
// copy on disk got there.
|
||||||
|
App.favorites.has = function(video) {
|
||||||
|
const index = identities();
|
||||||
|
const key = App.favorites.getKey(video);
|
||||||
|
if (key && index.keys.has(key)) return true;
|
||||||
|
// Normalising a URL means parsing one, which is the expensive half of
|
||||||
|
// this and runs for every card that isn't a favorite by key. With
|
||||||
|
// nothing stored to match against, there is nothing to parse it for.
|
||||||
|
if (!index.urls.size) return false;
|
||||||
|
const meta = (video && video.meta) || video || {};
|
||||||
|
const urlKey = App.favorites.urlKey(video && (video.url || meta.url));
|
||||||
|
return !!(urlKey && index.urls.has(urlKey));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Is this video already a favorite, whichever way it got saved? Checked by
|
// Is this video already a favorite, whichever way it got saved? Checked by
|
||||||
@@ -209,6 +246,58 @@ App.favorites = App.favorites || {};
|
|||||||
return favorites.findIndex((item) => item && App.favorites.urlKey(item.url) === urlKey);
|
return favorites.findIndex((item) => item && App.favorites.urlKey(item.url) === urlKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Everything an entry carries, so a change can be told from a no-op.
|
||||||
|
const ENTRY_FIELDS = ['key', 'id', 'url', 'title', 'thumb', 'channel',
|
||||||
|
'uploader', 'duration', 'isLive', 'favoriteDate'];
|
||||||
|
|
||||||
|
const sameEntry = function(a, b) {
|
||||||
|
return ENTRY_FIELDS.every((field) =>
|
||||||
|
String(a[field] === undefined || a[field] === null ? '' : a[field]) ===
|
||||||
|
String(b[field] === undefined || b[field] === null ? '' : b[field]));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Brings stored favorites up to date from a page of listing videos.
|
||||||
|
//
|
||||||
|
// The same video reaches this client under two identities: saved from a
|
||||||
|
// card it carries the server's id, imported from a Hot Tub backup it
|
||||||
|
// carries only its URL. Matching on the URL is what recognises them as one
|
||||||
|
// thing -- and once they are matched, the listing's copy is the better one.
|
||||||
|
// It has the id every card keys on, and a thumbnail URL that hasn't been
|
||||||
|
// sitting in localStorage since whenever the backup was taken. So the
|
||||||
|
// stored entry is replaced by it, keeping only the date it was first saved:
|
||||||
|
// that is the one fact the listing doesn't know and the favorites sort
|
||||||
|
// depends on.
|
||||||
|
//
|
||||||
|
// Writes only when something actually differs, so the steady state of
|
||||||
|
// scrolling a listing full of favorites is no writes at all.
|
||||||
|
App.favorites.reconcile = function(videos) {
|
||||||
|
const items = Array.isArray(videos) ? videos : [];
|
||||||
|
if (!items.length) return 0;
|
||||||
|
const favorites = App.favorites.getAll();
|
||||||
|
if (!favorites.length) return 0;
|
||||||
|
|
||||||
|
let changed = 0;
|
||||||
|
items.forEach((video) => {
|
||||||
|
if (!video || !video.url) return;
|
||||||
|
const index = App.favorites.indexOfEntry(favorites, video);
|
||||||
|
if (index < 0) return;
|
||||||
|
const existing = favorites[index];
|
||||||
|
const upgraded = App.favorites.normalize(Object.assign({}, video, {
|
||||||
|
favoriteDate: existing.favoriteDate
|
||||||
|
}));
|
||||||
|
if (!upgraded || sameEntry(existing, upgraded)) return;
|
||||||
|
favorites[index] = upgraded;
|
||||||
|
changed++;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
App.favorites.setAll(favorites);
|
||||||
|
App.favorites.renderBar();
|
||||||
|
App.favorites.syncButtons();
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
};
|
||||||
|
|
||||||
App.favorites.isVisible = function() {
|
App.favorites.isVisible = function() {
|
||||||
return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false';
|
return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ App.feed = App.feed || {};
|
|||||||
|
|
||||||
const favBtn = slide.querySelector('.feed-fav-btn');
|
const favBtn = slide.querySelector('.feed-fav-btn');
|
||||||
if (favBtn && App.favorites) {
|
if (favBtn && App.favorites) {
|
||||||
App.favorites.setButtonState(favBtn, App.favorites.indexOfEntry(App.favorites.getAll(), v) >= 0);
|
App.favorites.setButtonState(favBtn, App.favorites.has(v));
|
||||||
favBtn.addEventListener('click', (event) => {
|
favBtn.addEventListener('click', (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
App.favorites.toggle(v);
|
App.favorites.toggle(v);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ App.player = App.player || {};
|
|||||||
historyPushed: false,
|
historyPushed: false,
|
||||||
idleTimer: null,
|
idleTimer: null,
|
||||||
originEl: null,
|
originEl: null,
|
||||||
|
originToken: null, // stamp proving originEl is still the card we opened
|
||||||
hudHovered: false, // mouse resting on the controls (desktop)
|
hudHovered: false, // mouse resting on the controls (desktop)
|
||||||
activeUrl: '', // media URL actually playing, for the format menu's tick
|
activeUrl: '', // media URL actually playing, for the format menu's tick
|
||||||
attemptToken: 0, // bumps on every open()/format switch to void stale async callbacks
|
attemptToken: 0, // bumps on every open()/format switch to void stale async callbacks
|
||||||
@@ -59,6 +60,28 @@ App.player = App.player || {};
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The card the player was opened from is owned by the grid, which pools and
|
||||||
|
// reuses its cards (see resetCard in videos.js). By the time the player lets
|
||||||
|
// go, that element may be showing a different video -- so it is stamped at
|
||||||
|
// open, and every later touch checks the stamp still matches. A recycled
|
||||||
|
// card has had it wiped, and simply stops answering.
|
||||||
|
let originSeq = 0;
|
||||||
|
|
||||||
|
const claimOrigin = function(el) {
|
||||||
|
if (!el) return null;
|
||||||
|
const token = String(++originSeq);
|
||||||
|
el.dataset.playerToken = token;
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
|
||||||
|
const withOrigin = function(el, token, fn) {
|
||||||
|
// `token` must be truthy in its own right: dataset yields undefined for
|
||||||
|
// a missing attribute, so without this an unstamped token would match
|
||||||
|
// every card that has no stamp -- including one the grid has recycled,
|
||||||
|
// which is precisely the case this guard exists to catch.
|
||||||
|
if (el && token && el.dataset.playerToken === token) fn(el);
|
||||||
|
};
|
||||||
|
|
||||||
const addCleanup = (fn) => cp.cleanups.push(fn);
|
const addCleanup = (fn) => cp.cleanups.push(fn);
|
||||||
const runCleanups = () => {
|
const runCleanups = () => {
|
||||||
cp.cleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
|
cp.cleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
|
||||||
@@ -295,7 +318,7 @@ App.player = App.player || {};
|
|||||||
const key = App.favorites.getKey(videoData);
|
const key = App.favorites.getKey(videoData);
|
||||||
if (!key) { btn.hidden = true; return; }
|
if (!key) { btn.hidden = true; return; }
|
||||||
btn.dataset.favKey = key;
|
btn.dataset.favKey = key;
|
||||||
App.favorites.setButtonState(btn, App.favorites.getSet().has(key));
|
App.favorites.setButtonState(btn, App.favorites.has(videoData));
|
||||||
const onClick = (event) => {
|
const onClick = (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
App.favorites.toggle(videoData);
|
App.favorites.toggle(videoData);
|
||||||
@@ -695,9 +718,10 @@ App.player = App.player || {};
|
|||||||
// the wrong card loaded or (via the token guard below) never clear
|
// the wrong card loaded or (via the token guard below) never clear
|
||||||
// this card's spinner at all.
|
// this card's spinner at all.
|
||||||
const originEl = (opts && opts.originEl) || null;
|
const originEl = (opts && opts.originEl) || null;
|
||||||
|
const originToken = originEl ? originEl.dataset.playerToken : null;
|
||||||
const sources = resolveSources(videoData);
|
const sources = resolveSources(videoData);
|
||||||
const clearLoading = () => {
|
const clearLoading = () => {
|
||||||
if (originEl) originEl.classList.remove('is-loading');
|
withOrigin(originEl, originToken, (el) => el.classList.remove('is-loading'));
|
||||||
};
|
};
|
||||||
const sourceUrl = (videoData && (videoData.url || (videoData.meta && videoData.meta.url))) || '';
|
const sourceUrl = (videoData && (videoData.url || (videoData.meta && videoData.meta.url))) || '';
|
||||||
|
|
||||||
@@ -921,11 +945,12 @@ App.player = App.player || {};
|
|||||||
cp.attemptToken++;
|
cp.attemptToken++;
|
||||||
cancelInFlight();
|
cancelInFlight();
|
||||||
clearIdleTimer();
|
clearIdleTimer();
|
||||||
if (cp.originEl) cp.originEl.classList.remove('is-loading');
|
withOrigin(cp.originEl, cp.originToken, (el) => el.classList.remove('is-loading'));
|
||||||
}
|
}
|
||||||
runCleanups();
|
runCleanups();
|
||||||
|
|
||||||
cp.originEl = opts && opts.originEl ? opts.originEl : null;
|
cp.originEl = opts && opts.originEl ? opts.originEl : null;
|
||||||
|
cp.originToken = claimOrigin(cp.originEl);
|
||||||
if (cp.originEl) cp.originEl.classList.add('is-loading');
|
if (cp.originEl) cp.originEl.classList.add('is-loading');
|
||||||
|
|
||||||
cp.container = buildContainer();
|
cp.container = buildContainer();
|
||||||
@@ -1031,10 +1056,12 @@ App.player = App.player || {};
|
|||||||
cp.historyPushed = false;
|
cp.historyPushed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cp.originEl) {
|
withOrigin(cp.originEl, cp.originToken, (el) => {
|
||||||
cp.originEl.classList.remove('is-loading');
|
el.classList.remove('is-loading');
|
||||||
cp.originEl = null;
|
delete el.dataset.playerToken;
|
||||||
}
|
});
|
||||||
|
cp.originEl = null;
|
||||||
|
cp.originToken = null;
|
||||||
cp.data = null;
|
cp.data = null;
|
||||||
cp.source = null; // voids a still-pending format resolve for this open
|
cp.source = null; // voids a still-pending format resolve for this open
|
||||||
cp.formatOverride = null;
|
cp.formatOverride = null;
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ App.videos = App.videos || {};
|
|||||||
const imageRoutes = new Map(); // host -> winning route; absent = unknown
|
const imageRoutes = new Map(); // host -> winning route; absent = unknown
|
||||||
const imageRacing = new Set(); // hosts with a race already deciding
|
const imageRacing = new Set(); // hosts with a race already deciding
|
||||||
const imageWaiting = new Map(); // host -> images held until it decides
|
const imageWaiting = new Map(); // host -> images held until it decides
|
||||||
|
let thumbSeq = 0; // generation, so stale work can be dropped
|
||||||
|
|
||||||
// A page of cards is built in one go, so every thumbnail from a host is
|
// A page of cards is built in one go, so every thumbnail from a host is
|
||||||
// attached before the first one has come back. Sending them all down the
|
// attached before the first one has come back. Sending them all down the
|
||||||
@@ -188,7 +189,13 @@ App.videos = App.videos || {};
|
|||||||
// and put on only once there is an image to caption -- otherwise every card
|
// and put on only once there is an image to caption -- otherwise every card
|
||||||
// spells out its own title over the placeholder while the host is being
|
// spells out its own title over the placeholder while the host is being
|
||||||
// decided, and permanently for an item that has no thumbnail at all.
|
// decided, and permanently for an item that has no thumbnail at all.
|
||||||
const showThumbnail = function(img, url) {
|
// `token` is the generation of the attachThumbnail call that started this
|
||||||
|
// work. A card can be recycled while its thumbnail is still being decided,
|
||||||
|
// and the callbacks that eventually fire still hold the old element -- so
|
||||||
|
// anything arriving for a generation the element has moved past is dropped
|
||||||
|
// rather than painted onto whatever video the card now shows.
|
||||||
|
const showThumbnail = function(img, url, token) {
|
||||||
|
if (token !== undefined && img.dataset.thumbToken !== token) return;
|
||||||
if (img.dataset.alt !== undefined) {
|
if (img.dataset.alt !== undefined) {
|
||||||
img.alt = img.dataset.alt;
|
img.alt = img.dataset.alt;
|
||||||
delete img.dataset.alt;
|
delete img.dataset.alt;
|
||||||
@@ -198,9 +205,28 @@ App.videos = App.videos || {};
|
|||||||
|
|
||||||
// Last resort on a route that normally works: one expired or missing image
|
// Last resort on a route that normally works: one expired or missing image
|
||||||
// shouldn't be left broken just because its host is fine in general.
|
// shouldn't be left broken just because its host is fine in general.
|
||||||
const attachProxyFallback = function(img, proxyUrl) {
|
const attachProxyFallback = function(img, proxyUrl, token) {
|
||||||
if (!proxyUrl) return;
|
if (!proxyUrl) return;
|
||||||
img.addEventListener('error', () => { showThumbnail(img, proxyUrl); }, { once: true });
|
// Checked here too, not just in showThumbnail: this *replaces* whatever
|
||||||
|
// fallback the image currently has, so a call arriving for a generation
|
||||||
|
// the element has moved past would take away the live one and leave a
|
||||||
|
// dead one -- the recycled card's thumbnail would then have no fallback
|
||||||
|
// at all if it failed.
|
||||||
|
if (token !== undefined && img.dataset.thumbToken !== token) return;
|
||||||
|
// Held on the element so detachThumbnail can take it off again. On the
|
||||||
|
// happy path it never fires and `once` never collects it, so a pooled
|
||||||
|
// image would otherwise accumulate one closure per mount it has served.
|
||||||
|
detachProxyFallback(img);
|
||||||
|
const onError = () => { showThumbnail(img, proxyUrl, token); };
|
||||||
|
img._thumbFallback = onError;
|
||||||
|
img.addEventListener('error', onError, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const detachProxyFallback = function(img) {
|
||||||
|
if (img && img._thumbFallback) {
|
||||||
|
img.removeEventListener('error', img._thumbFallback);
|
||||||
|
img._thumbFallback = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Releases the images held for `host`. `route` is the winner, or null when
|
// Releases the images held for `host`. `route` is the winner, or null when
|
||||||
@@ -213,11 +239,11 @@ App.videos = App.videos || {};
|
|||||||
imageWaiting.delete(host);
|
imageWaiting.delete(host);
|
||||||
waiting.forEach((entry) => {
|
waiting.forEach((entry) => {
|
||||||
if (route === IMAGE_PROXY) {
|
if (route === IMAGE_PROXY) {
|
||||||
showThumbnail(entry.img, entry.proxyUrl);
|
showThumbnail(entry.img, entry.proxyUrl, entry.token);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (route !== IMAGE_DIRECT) attachProxyFallback(entry.img, entry.proxyUrl);
|
if (route !== IMAGE_DIRECT) attachProxyFallback(entry.img, entry.proxyUrl, entry.token);
|
||||||
showThumbnail(entry.img, entry.directUrl);
|
showThumbnail(entry.img, entry.directUrl, entry.token);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -238,7 +264,7 @@ App.videos = App.videos || {};
|
|||||||
// pointed at the first one home. Racing on the visible element instead
|
// pointed at the first one home. Racing on the visible element instead
|
||||||
// would abort the loser -- and the loser is the request that answers the
|
// would abort the loser -- and the loser is the request that answers the
|
||||||
// second question.
|
// second question.
|
||||||
const raceThumbnail = function(img, directUrl, proxyUrl, host) {
|
const raceThumbnail = function(img, directUrl, proxyUrl, host, token) {
|
||||||
let shown = false;
|
let shown = false;
|
||||||
let outstanding = 2;
|
let outstanding = 2;
|
||||||
let routeFinal = false; // the direct verdict is in; nothing can revise it
|
let routeFinal = false; // the direct verdict is in; nothing can revise it
|
||||||
@@ -250,7 +276,7 @@ App.videos = App.videos || {};
|
|||||||
const show = function(url) {
|
const show = function(url) {
|
||||||
if (shown) return;
|
if (shown) return;
|
||||||
shown = true;
|
shown = true;
|
||||||
showThumbnail(img, url); // a cache hit; the probe has the bytes
|
showThumbnail(img, url, token); // a cache hit; the probe has the bytes
|
||||||
};
|
};
|
||||||
|
|
||||||
// Records the host's route and lets go of everything held for it. The
|
// Records the host's route and lets go of everything held for it. The
|
||||||
@@ -278,11 +304,11 @@ App.videos = App.videos || {};
|
|||||||
if (shown) return;
|
if (shown) return;
|
||||||
shown = true;
|
shown = true;
|
||||||
if (imageRoutes.get(host) === IMAGE_PROXY) {
|
if (imageRoutes.get(host) === IMAGE_PROXY) {
|
||||||
showThumbnail(img, proxyUrl);
|
showThumbnail(img, proxyUrl, token);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
attachProxyFallback(img, proxyUrl);
|
attachProxyFallback(img, proxyUrl, token);
|
||||||
showThumbnail(img, directUrl);
|
showThumbnail(img, directUrl, token);
|
||||||
};
|
};
|
||||||
|
|
||||||
const decide = function() {
|
const decide = function() {
|
||||||
@@ -383,9 +409,13 @@ App.videos = App.videos || {};
|
|||||||
const proxyUrl = App.videos.buildImageProxyUrl(directUrl);
|
const proxyUrl = App.videos.buildImageProxyUrl(directUrl);
|
||||||
const host = imageHostOf(directUrl);
|
const host = imageHostOf(directUrl);
|
||||||
const route = imageRoutes.get(host);
|
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 (route === IMAGE_PROXY) {
|
if (route === IMAGE_PROXY) {
|
||||||
showThumbnail(img, proxyUrl || directUrl);
|
showThumbnail(img, proxyUrl || directUrl, token);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (imageRacing.has(host)) {
|
if (imageRacing.has(host)) {
|
||||||
@@ -393,18 +423,28 @@ App.videos = App.videos || {};
|
|||||||
// guessing: guessing wrong costs this image a whole failed request
|
// guessing: guessing wrong costs this image a whole failed request
|
||||||
// before it even asks the route that was about to be proven.
|
// before it even asks the route that was about to be proven.
|
||||||
const waiting = imageWaiting.get(host) || [];
|
const waiting = imageWaiting.get(host) || [];
|
||||||
waiting.push({ img: img, directUrl: directUrl, proxyUrl: proxyUrl });
|
waiting.push({ img: img, directUrl: directUrl, proxyUrl: proxyUrl, token: token });
|
||||||
imageWaiting.set(host, waiting);
|
imageWaiting.set(host, waiting);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (route === IMAGE_DIRECT || !host || !proxyUrl) {
|
if (route === IMAGE_DIRECT || !host || !proxyUrl) {
|
||||||
// Known good, or nothing to race against: take the provider and keep
|
// Known good, or nothing to race against: take the provider and keep
|
||||||
// the proxy as this image's own fallback.
|
// the proxy as this image's own fallback.
|
||||||
attachProxyFallback(img, proxyUrl);
|
attachProxyFallback(img, proxyUrl, token);
|
||||||
showThumbnail(img, directUrl);
|
showThumbnail(img, directUrl, token);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
raceThumbnail(img, directUrl, proxyUrl, host);
|
raceThumbnail(img, directUrl, proxyUrl, host, token);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Voids whatever is still in flight for this element's thumbnail. Its
|
||||||
|
// generation moves on, so a race that settles later, or a proxy fallback
|
||||||
|
// that fires later, finds a token that no longer matches and does nothing.
|
||||||
|
App.videos.detachThumbnail = function(img) {
|
||||||
|
if (!img) return;
|
||||||
|
img.dataset.thumbToken = String(++thumbSeq);
|
||||||
|
delete img.dataset.alt;
|
||||||
|
detachProxyFallback(img);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Each channel in a group sends back a different number of videos per
|
// Each channel in a group sends back a different number of videos per
|
||||||
@@ -710,131 +750,275 @@ App.videos = App.videos || {};
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Builds a fully-wired video card element for `v`. Kept separate from
|
// ---------------------------------------------------------------------
|
||||||
// mounting so the virtualizer can create a card the moment it needs to be
|
// Cards
|
||||||
// on screen and throw it away once it scrolls out of the window.
|
//
|
||||||
App.videos.buildCard = function(v, options) {
|
// A card is built once and then reused: the virtualizer keeps a pool of
|
||||||
const favoritesSet = App.favorites.getSet();
|
// them and rebinds one to a new video rather than constructing another
|
||||||
const card = document.createElement('div');
|
// (see acquire/release in App.virtualGrid). Two things follow from that,
|
||||||
card.className = 'video-card';
|
// and both are load-bearing.
|
||||||
|
//
|
||||||
|
// Nothing on a card may close over the video it is currently showing --
|
||||||
|
// the card outlives the video. Every interaction is therefore handled by
|
||||||
|
// one delegated listener per event type on the grid, which resolves the
|
||||||
|
// video from the card under the pointer (see bindGridDelegation).
|
||||||
|
//
|
||||||
|
// And every card has the same shape whatever video it shows: the optional
|
||||||
|
// parts -- live badge, uploader, duration, tags -- are always present and
|
||||||
|
// hidden when unused, so any pooled card fits any video.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const CARD_TEMPLATE_HTML = `
|
||||||
|
<span class="live-badge" hidden>● LIVE</span>
|
||||||
|
<button class="favorite-btn" type="button" data-action="favorite" aria-pressed="false" aria-label="Add to favorites">♡</button>
|
||||||
|
<button class="video-menu-btn" type="button" data-action="menu" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
|
||||||
|
<div class="video-menu" role="menu">
|
||||||
|
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
|
||||||
|
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
||||||
|
</div>
|
||||||
|
<div class="video-thumb">
|
||||||
|
<img alt="" loading="lazy" decoding="async">
|
||||||
|
<div class="video-loading" aria-hidden="true">
|
||||||
|
<div class="video-loading-spinner"></div>
|
||||||
|
</div>
|
||||||
|
<button class="video-uploader uploader-link" type="button" data-action="uploader" hidden></button>
|
||||||
|
<span class="video-duration" hidden></span>
|
||||||
|
</div>
|
||||||
|
<h4 class="video-title"><span class="video-title-text"></span></h4>
|
||||||
|
<div class="video-tags" hidden></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Parsed once. Cloning this is roughly six times cheaper than asking the
|
||||||
|
// parser to read the same markup again for every card.
|
||||||
|
let cardTemplate = null;
|
||||||
|
|
||||||
|
// The parts bindCard writes to, found once when the card is created rather
|
||||||
|
// than looked up again on every rebind. Searching the subtree seven times
|
||||||
|
// per mount was most of what rebinding cost.
|
||||||
|
const cardRefs = function(card) {
|
||||||
|
if (!card._refs) {
|
||||||
|
card._refs = {
|
||||||
|
live: card.querySelector('.live-badge'),
|
||||||
|
favorite: card.querySelector('.favorite-btn'),
|
||||||
|
title: card.querySelector('.video-title-text'),
|
||||||
|
uploader: card.querySelector('.video-uploader'),
|
||||||
|
duration: card.querySelector('.video-duration'),
|
||||||
|
tags: card.querySelector('.video-tags'),
|
||||||
|
img: card.querySelector('img')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return card._refs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCardShell = function() {
|
||||||
|
if (!cardTemplate) {
|
||||||
|
cardTemplate = document.createElement('template');
|
||||||
|
cardTemplate.innerHTML = `<div class="video-card">${CARD_TEMPLATE_HTML}</div>`;
|
||||||
|
}
|
||||||
|
const card = cardTemplate.content.firstElementChild.cloneNode(true);
|
||||||
|
cardRefs(card);
|
||||||
|
return card;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tag buttons are the only part whose *count* varies, so they are adjusted
|
||||||
|
// rather than rebuilt: usually the card already has the right number.
|
||||||
|
const bindTags = function(container, tags) {
|
||||||
|
const list = Array.isArray(tags) ? tags.filter((tag) => tag) : [];
|
||||||
|
container.hidden = list.length === 0;
|
||||||
|
while (container.childElementCount > list.length) {
|
||||||
|
container.removeChild(container.lastElementChild);
|
||||||
|
}
|
||||||
|
while (container.childElementCount < list.length) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.className = 'video-tag';
|
||||||
|
button.type = 'button';
|
||||||
|
button.dataset.action = 'tag';
|
||||||
|
container.appendChild(button);
|
||||||
|
}
|
||||||
|
// Only the label is written: the delegated handler reads the tag off
|
||||||
|
// the button's own text. Writing it a second time into a data attribute
|
||||||
|
// cost more than everything else in a rebind put together -- dataset is
|
||||||
|
// a proxy, and this runs once per tag per card.
|
||||||
|
list.forEach((tag, index) => {
|
||||||
|
const button = container.children[index];
|
||||||
|
if (button.textContent !== tag) button.textContent = tag;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Points an existing card at `v`. This is the whole per-mount cost.
|
||||||
|
App.videos.bindCard = function(card, v) {
|
||||||
|
const refs = cardRefs(card);
|
||||||
card.dataset.videoId = v.id;
|
card.dataset.videoId = v.id;
|
||||||
const durationText = App.videos.formatDuration(v.duration);
|
cardVideo.set(card, v);
|
||||||
|
|
||||||
|
refs.live.hidden = !v.isLive;
|
||||||
|
|
||||||
const favoriteKey = App.favorites.getKey(v);
|
const favoriteKey = App.favorites.getKey(v);
|
||||||
|
refs.favorite.dataset.favKey = favoriteKey || '';
|
||||||
|
refs.favorite.dataset.favUrl = v.url || '';
|
||||||
|
// By either identity: a favorite imported from a backup is keyed by its
|
||||||
|
// URL, not by the id this card carries.
|
||||||
|
App.favorites.setButtonState(refs.favorite, !!favoriteKey && App.favorites.has(v));
|
||||||
|
|
||||||
|
refs.title.textContent = v.title || '';
|
||||||
|
|
||||||
const uploaderText = v.uploader || '';
|
const uploaderText = v.uploader || '';
|
||||||
const tags = Array.isArray(v.tags) ? v.tags.filter(tag => tag) : [];
|
refs.uploader.hidden = !uploaderText;
|
||||||
const tagsMarkup = tags.length
|
refs.uploader.textContent = uploaderText;
|
||||||
? `<div class="video-tags">${tags.map(tag => `<button class="video-tag" type="button" data-tag="${tag}">${tag}</button>`).join('')}</div>`
|
refs.uploader.dataset.uploader = uploaderText;
|
||||||
: '';
|
|
||||||
const liveBadge = v.isLive ? '<span class="live-badge">● LIVE</span>' : '';
|
const durationText = App.videos.formatDuration(v.duration);
|
||||||
card.innerHTML = `
|
refs.duration.hidden = !durationText;
|
||||||
${liveBadge}
|
refs.duration.textContent = durationText;
|
||||||
<button class="favorite-btn" type="button" aria-pressed="false" aria-label="Add to favorites" data-fav-key="${favoriteKey || ''}" data-fav-url="${v.url || ''}">♡</button>
|
|
||||||
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
|
bindTags(refs.tags, v.tags);
|
||||||
<div class="video-menu" role="menu">
|
|
||||||
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
|
// Set before attachThumbnail, which holds the caption back until there
|
||||||
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
// is an image to caption.
|
||||||
</div>
|
refs.img.alt = v.title || '';
|
||||||
<div class="video-thumb">
|
return card;
|
||||||
<img alt="${v.title}" loading="lazy" decoding="async">
|
};
|
||||||
<div class="video-loading" aria-hidden="true">
|
|
||||||
<div class="video-loading-spinner"></div>
|
// A card ready to show `v`, thumbnail and all.
|
||||||
</div>
|
App.videos.buildCard = function(v, options) {
|
||||||
${uploaderText ? `<button class="video-uploader uploader-link" type="button" data-uploader="${uploaderText}">${uploaderText}</button>` : ''}
|
const card = App.videos.bindCard(createCardShell(), v);
|
||||||
${durationText ? `<span class="video-duration">${durationText}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
<h4 class="video-title"><span class="video-title-text">${v.title}</span></h4>
|
|
||||||
${tagsMarkup}
|
|
||||||
`;
|
|
||||||
const thumb = card.querySelector('img');
|
|
||||||
// The layout probe (see shapeHeight) needs the card's shape, never its
|
// The layout probe (see shapeHeight) needs the card's shape, never its
|
||||||
// pixels: it measures against the CSS 16:9 placeholder and is removed in
|
// pixels: it measures against the CSS 16:9 placeholder and is removed in
|
||||||
// the same frame, so loading a thumbnail for it -- let alone racing one
|
// the same frame, so loading a thumbnail for it -- let alone racing one
|
||||||
// -- would be pure waste.
|
// -- would be pure waste.
|
||||||
if (!(options && options.skipThumbnail)) {
|
if (!(options && options.skipThumbnail)) {
|
||||||
App.videos.attachThumbnail(thumb, v.thumb);
|
App.videos.attachThumbnail(cardRefs(card).img, v.thumb);
|
||||||
}
|
}
|
||||||
const favoriteBtn = card.querySelector('.favorite-btn');
|
return card;
|
||||||
if (favoriteBtn && favoriteKey) {
|
};
|
||||||
App.favorites.setButtonState(favoriteBtn, favoritesSet.has(favoriteKey));
|
|
||||||
favoriteBtn.onclick = (event) => {
|
// Returns a card to a state where it shows nothing and remembers nothing,
|
||||||
event.stopPropagation();
|
// ready to be bound to another video. Anything left behind here surfaces as
|
||||||
App.favorites.toggle(v);
|
// one video's content on another video's card.
|
||||||
};
|
App.videos.resetCard = function(card) {
|
||||||
}
|
card.classList.remove('is-loading', 'is-title-active', 'is-revealing', 'is-previewing');
|
||||||
const titleWrap = card.querySelector('.video-title');
|
delete card.dataset.videoId;
|
||||||
const titleText = card.querySelector('.video-title-text');
|
delete card.dataset.titleFocused;
|
||||||
if (titleWrap && titleText) {
|
delete card.dataset.titleHovered;
|
||||||
card.addEventListener('focusin', () => {
|
delete card.dataset.titlePrimary;
|
||||||
card.dataset.titleFocused = '1';
|
// The player stamps this to recognise the card it was opened from; a
|
||||||
updateTitleActive(card);
|
// recycled card must stop answering to it.
|
||||||
});
|
delete card.dataset.playerToken;
|
||||||
card.addEventListener('focusout', () => {
|
|
||||||
card.dataset.titleFocused = '0';
|
// Added by favorites.toggle and normally taken off by animationend --
|
||||||
updateTitleActive(card);
|
// which never fires here, because release() detaches the card first and
|
||||||
});
|
// a detached element runs no animations. Left on, the next video this
|
||||||
if (titleEnv.useHoverFocus) {
|
// card shows replays a "just favourited" pop nobody asked for.
|
||||||
card.addEventListener('mouseenter', () => {
|
const favorite = cardRefs(card).favorite;
|
||||||
card.dataset.titleHovered = '1';
|
if (favorite) favorite.classList.remove('just-favorited');
|
||||||
updateTitleActive(card);
|
|
||||||
});
|
|
||||||
card.addEventListener('mouseleave', () => {
|
|
||||||
card.dataset.titleHovered = '0';
|
|
||||||
updateTitleActive(card);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// On touch devices the marquee observer is attached lazily by
|
|
||||||
// measureTitle (called on mount), and only for overflowing titles.
|
|
||||||
}
|
|
||||||
const uploaderBtn = card.querySelector('.uploader-link');
|
|
||||||
if (uploaderBtn) {
|
|
||||||
uploaderBtn.onclick = (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
const uploader = uploaderBtn.dataset.uploader || uploaderBtn.textContent || '';
|
|
||||||
App.videos.handleSearch(uploader);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const tagButtons = card.querySelectorAll('.video-tag');
|
|
||||||
if (tagButtons.length) {
|
|
||||||
tagButtons.forEach((tagBtn) => {
|
|
||||||
tagBtn.onclick = (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
const tag = tagBtn.dataset.tag || tagBtn.textContent || '';
|
|
||||||
App.videos.handleSearch(tag);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const menuBtn = card.querySelector('.video-menu-btn');
|
|
||||||
const menu = card.querySelector('.video-menu');
|
const menu = card.querySelector('.video-menu');
|
||||||
const showInfoBtn = card.querySelector('.video-menu-item[data-action="info"]');
|
if (menu) menu.classList.remove('open');
|
||||||
const downloadBtn = card.querySelector('.video-menu-item[data-action="download"]');
|
const menuBtn = card.querySelector('.video-menu-btn');
|
||||||
if (menuBtn && menu) {
|
if (menuBtn) menuBtn.setAttribute('aria-expanded', 'false');
|
||||||
menuBtn.onclick = (event) => {
|
|
||||||
event.stopPropagation();
|
const titleWrap = card.querySelector('.video-title');
|
||||||
App.videos.toggleMenu(menu, menuBtn);
|
if (titleWrap) titleWrap.classList.remove('has-marquee');
|
||||||
};
|
const titleText = card.querySelector('.video-title-text');
|
||||||
|
if (titleText) {
|
||||||
|
titleText.style.removeProperty('--marquee-distance');
|
||||||
|
titleText.style.removeProperty('--marquee-duration');
|
||||||
}
|
}
|
||||||
if (showInfoBtn) {
|
|
||||||
showInfoBtn.onclick = (event) => {
|
// The hover preview (enhance.js) parks a <video> inside the card.
|
||||||
event.stopPropagation();
|
const preview = card.querySelector('.card-preview');
|
||||||
App.ui.openInfo(v);
|
if (preview) {
|
||||||
App.videos.closeAllMenus();
|
try { preview.pause(); } catch (err) { /* ignore */ }
|
||||||
};
|
preview.remove();
|
||||||
}
|
}
|
||||||
if (downloadBtn) {
|
|
||||||
downloadBtn.onclick = (event) => {
|
const img = cardRefs(card).img;
|
||||||
event.stopPropagation();
|
if (img) {
|
||||||
App.videos.downloadVideo(v);
|
// Voids anything still in flight for the old thumbnail -- a pending
|
||||||
App.videos.closeAllMenus();
|
// race, a proxy fallback -- so it cannot land on the next video.
|
||||||
};
|
App.videos.detachThumbnail(img);
|
||||||
|
if (img._revealHandler) {
|
||||||
|
img.removeEventListener('load', img._revealHandler);
|
||||||
|
img._revealHandler = null;
|
||||||
|
}
|
||||||
|
img.classList.remove('is-loaded');
|
||||||
|
img.style.removeProperty('aspect-ratio');
|
||||||
|
img.removeAttribute('src');
|
||||||
|
img.alt = '';
|
||||||
}
|
}
|
||||||
card.onclick = () => {
|
return card;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One listener per event type, on the grid, for every card in it.
|
||||||
|
let gridDelegated = false;
|
||||||
|
|
||||||
|
const cardFor = function(target) {
|
||||||
|
const card = target && target.closest ? target.closest('.video-card') : null;
|
||||||
|
return card && cardVideo.has(card) ? card : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindGridDelegation = function() {
|
||||||
|
if (gridDelegated) return;
|
||||||
|
const grid = document.getElementById('video-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
gridDelegated = true;
|
||||||
|
|
||||||
|
grid.addEventListener('click', (event) => {
|
||||||
|
const card = cardFor(event.target);
|
||||||
|
if (!card) return;
|
||||||
|
const v = cardVideo.get(card);
|
||||||
|
const actionEl = event.target.closest('[data-action]');
|
||||||
|
const action = actionEl && card.contains(actionEl) ? actionEl.dataset.action : '';
|
||||||
|
if (action) {
|
||||||
|
// The document-level handler closes every open menu; stopping
|
||||||
|
// here is what lets the menu this click just opened stay open.
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
switch (action) {
|
||||||
|
case 'favorite':
|
||||||
|
App.favorites.toggle(v);
|
||||||
|
return;
|
||||||
|
case 'uploader':
|
||||||
|
App.videos.handleSearch(actionEl.dataset.uploader || actionEl.textContent || '');
|
||||||
|
return;
|
||||||
|
case 'tag':
|
||||||
|
App.videos.handleSearch(actionEl.textContent || '');
|
||||||
|
return;
|
||||||
|
case 'menu':
|
||||||
|
App.videos.toggleMenu(card.querySelector('.video-menu'), actionEl);
|
||||||
|
return;
|
||||||
|
case 'info':
|
||||||
|
App.videos.closeAllMenus();
|
||||||
|
App.ui.openInfo(v);
|
||||||
|
return;
|
||||||
|
case 'download':
|
||||||
|
App.videos.closeAllMenus();
|
||||||
|
App.videos.downloadVideo(v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (card.classList.contains('is-loading')) return;
|
if (card.classList.contains('is-loading')) return;
|
||||||
card.classList.add('is-loading');
|
card.classList.add('is-loading');
|
||||||
App.player.open(v, { originEl: card });
|
App.player.open(v, { originEl: card });
|
||||||
|
});
|
||||||
|
|
||||||
|
const setTitleFlag = function(event, flag, value) {
|
||||||
|
const card = cardFor(event.target);
|
||||||
|
if (!card) return;
|
||||||
|
// pointerover/pointerout also fire while moving between a card's own
|
||||||
|
// children; only a crossing of the card's own edge counts.
|
||||||
|
if (event.relatedTarget && card.contains(event.relatedTarget)) return;
|
||||||
|
card.dataset[flag] = value;
|
||||||
|
updateTitleActive(card);
|
||||||
};
|
};
|
||||||
cardVideo.set(card, v);
|
|
||||||
card.addEventListener('pointerenter', () => App.videos.ensureFormats(v), { once: true });
|
grid.addEventListener('focusin', (event) => setTitleFlag(event, 'titleFocused', '1'));
|
||||||
return card;
|
grid.addEventListener('focusout', (event) => setTitleFlag(event, 'titleFocused', '0'));
|
||||||
|
if (titleEnv.useHoverFocus) {
|
||||||
|
// mouseenter/mouseleave don't bubble, so they can't be delegated;
|
||||||
|
// pointerover/pointerout do, and the containment check above turns
|
||||||
|
// them into the same thing.
|
||||||
|
grid.addEventListener('pointerover', (event) => setTitleFlag(event, 'titleHovered', '1'));
|
||||||
|
grid.addEventListener('pointerout', (event) => setTitleFlag(event, 'titleHovered', '0'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Appends a freshly-loaded page of videos. The card DOM is *not* built here;
|
// Appends a freshly-loaded page of videos. The card DOM is *not* built here;
|
||||||
@@ -847,6 +1031,15 @@ App.videos = App.videos || {};
|
|||||||
App.virtualGrid.ensureInit();
|
App.virtualGrid.ensureInit();
|
||||||
|
|
||||||
const items = videos && Array.isArray(videos.items) ? videos.items : [];
|
const items = videos && Array.isArray(videos.items) ? videos.items : [];
|
||||||
|
// The server's copy of a video it already has saved is the better one,
|
||||||
|
// so bring the favorites up to date from it. Skipped while the favorites
|
||||||
|
// grid is the thing being rendered -- those items *are* the favorites,
|
||||||
|
// reshaped for the grid, and reconciling them against themselves would
|
||||||
|
// only write back what they came from.
|
||||||
|
if (App.favorites && typeof App.favorites.reconcile === 'function' &&
|
||||||
|
!(App.favoritesView && App.favoritesView.isActive())) {
|
||||||
|
App.favorites.reconcile(items);
|
||||||
|
}
|
||||||
const startLen = state.loadedVideos.length;
|
const startLen = state.loadedVideos.length;
|
||||||
items.forEach((v) => {
|
items.forEach((v) => {
|
||||||
if (state.renderedVideoIds.has(v.id)) return;
|
if (state.renderedVideoIds.has(v.id)) return;
|
||||||
@@ -1129,19 +1322,61 @@ App.videos = App.videos || {};
|
|||||||
card.style.width = l.width + 'px';
|
card.style.width = l.width + 'px';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cards are pooled, not thrown away: building one costs ~250us of
|
||||||
|
// parsing and allocation, rebinding an existing one ~4us, and scrolling
|
||||||
|
// a grid does nothing but mount and unmount. Bounded at roughly twice
|
||||||
|
// the mounted window so a long session can't grow the pool without end.
|
||||||
|
const pool = [];
|
||||||
|
const POOL_MAX = 60;
|
||||||
|
let builtCards = 0;
|
||||||
|
let recycledCards = 0;
|
||||||
|
|
||||||
|
const acquire = function(v) {
|
||||||
|
const card = pool.pop();
|
||||||
|
if (!card) {
|
||||||
|
builtCards++;
|
||||||
|
return App.videos.buildCard(v);
|
||||||
|
}
|
||||||
|
recycledCards++;
|
||||||
|
App.videos.bindCard(card, v);
|
||||||
|
App.videos.attachThumbnail(cardRefs(card).img, v.thumb);
|
||||||
|
return card;
|
||||||
|
};
|
||||||
|
|
||||||
|
const release = function(card) {
|
||||||
|
resolveObserver.unobserve(card);
|
||||||
|
if (titleObserver) {
|
||||||
|
titleObserver.unobserve(card);
|
||||||
|
titleVisibility.delete(card);
|
||||||
|
}
|
||||||
|
if (card._revealAnim) {
|
||||||
|
card.removeEventListener('animationend', card._revealAnim);
|
||||||
|
card._revealAnim = null;
|
||||||
|
}
|
||||||
|
card.remove();
|
||||||
|
cardVideo.delete(card);
|
||||||
|
App.videos.resetCard(card);
|
||||||
|
if (pool.length < POOL_MAX) pool.push(card);
|
||||||
|
};
|
||||||
|
|
||||||
const mount = function(i) {
|
const mount = function(i) {
|
||||||
if (mounted.has(i)) return;
|
if (mounted.has(i)) return;
|
||||||
const v = state.loadedVideos[i];
|
const v = state.loadedVideos[i];
|
||||||
const l = layout[i];
|
const l = layout[i];
|
||||||
if (!v || !l) return;
|
if (!v || !l) return;
|
||||||
const card = App.videos.buildCard(v);
|
const card = acquire(v);
|
||||||
place(card, l);
|
place(card, l);
|
||||||
// Entrance animation only the first time an index appears, so cards
|
// Entrance animation only the first time an index appears, so cards
|
||||||
// don't re-animate every time they scroll back into the window.
|
// don't re-animate every time they scroll back into the window.
|
||||||
if (!revealed.has(i)) {
|
if (!revealed.has(i)) {
|
||||||
revealed.add(i);
|
revealed.add(i);
|
||||||
card.classList.add('is-revealing');
|
card.classList.add('is-revealing');
|
||||||
card.addEventListener('animationend', () => card.classList.remove('is-revealing'), { once: true });
|
// Kept so release can take it off again: a card recycled before
|
||||||
|
// the animation ends would otherwise carry the listener for the
|
||||||
|
// rest of its life.
|
||||||
|
const onRevealEnd = () => card.classList.remove('is-revealing');
|
||||||
|
card._revealAnim = onRevealEnd;
|
||||||
|
card.addEventListener('animationend', onRevealEnd, { once: true });
|
||||||
}
|
}
|
||||||
grid().appendChild(card);
|
grid().appendChild(card);
|
||||||
mounted.set(i, card);
|
mounted.set(i, card);
|
||||||
@@ -1150,6 +1385,11 @@ App.videos = App.videos || {};
|
|||||||
const img = card.querySelector('img');
|
const img = card.querySelector('img');
|
||||||
if (img) {
|
if (img) {
|
||||||
const reveal = () => {
|
const reveal = () => {
|
||||||
|
// Release nulls this, so a reveal already queued for a frame
|
||||||
|
// that lands after the card was rebound does nothing --
|
||||||
|
// covers the requestAnimationFrame path, which unlike the
|
||||||
|
// load listener cannot simply be removed.
|
||||||
|
if (img._revealHandler !== reveal) return;
|
||||||
img.style.aspectRatio = 'auto';
|
img.style.aspectRatio = 'auto';
|
||||||
img.classList.add('is-loaded');
|
img.classList.add('is-loaded');
|
||||||
// Remember the real aspect ratio so any later re-pack (see
|
// Remember the real aspect ratio so any later re-pack (see
|
||||||
@@ -1160,6 +1400,10 @@ App.videos = App.videos || {};
|
|||||||
}
|
}
|
||||||
if (mounted.get(i) === card) correct(i);
|
if (mounted.get(i) === card) correct(i);
|
||||||
};
|
};
|
||||||
|
// Held on the element so release can remove it: this closes
|
||||||
|
// over `v` and `i`, and a recycled image firing it again would
|
||||||
|
// file the new picture's dimensions under the old video's id.
|
||||||
|
img._revealHandler = reveal;
|
||||||
if (img.complete && img.naturalHeight > 0) requestAnimationFrame(reveal);
|
if (img.complete && img.naturalHeight > 0) requestAnimationFrame(reveal);
|
||||||
else img.addEventListener('load', reveal);
|
else img.addEventListener('load', reveal);
|
||||||
}
|
}
|
||||||
@@ -1171,14 +1415,8 @@ App.videos = App.videos || {};
|
|||||||
const unmount = function(i) {
|
const unmount = function(i) {
|
||||||
const card = mounted.get(i);
|
const card = mounted.get(i);
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
resolveObserver.unobserve(card);
|
|
||||||
if (titleObserver) {
|
|
||||||
titleObserver.unobserve(card);
|
|
||||||
titleVisibility.delete(card);
|
|
||||||
}
|
|
||||||
cardVideo.delete(card);
|
|
||||||
card.remove();
|
|
||||||
mounted.delete(i);
|
mounted.delete(i);
|
||||||
|
release(card);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mounts cards intersecting the viewport window, unmounts the rest.
|
// Mounts cards intersecting the viewport window, unmounts the rest.
|
||||||
@@ -1192,12 +1430,21 @@ App.videos = App.videos || {};
|
|||||||
const end = viewTop + vh * (1 + OVERSCAN);
|
const end = viewTop + vh * (1 + OVERSCAN);
|
||||||
let anchorIndex = -1;
|
let anchorIndex = -1;
|
||||||
let anchorTop = Infinity;
|
let anchorTop = Infinity;
|
||||||
|
// Everything leaving the window is released before anything entering
|
||||||
|
// it is mounted, so the cards just freed are available to the cards
|
||||||
|
// arriving. Mounting first -- which is what walking the list in
|
||||||
|
// index order does when scrolling up -- finds the pool empty and
|
||||||
|
// builds a card it was about to be handed.
|
||||||
|
const entering = [];
|
||||||
for (let i = 0; i < layout.length; i++) {
|
for (let i = 0; i < layout.length; i++) {
|
||||||
const l = layout[i];
|
const l = layout[i];
|
||||||
if (!l) continue;
|
if (!l) continue;
|
||||||
const visible = l.top < end && (l.top + l.height) > start;
|
const visible = l.top < end && (l.top + l.height) > start;
|
||||||
if (visible) mount(i);
|
if (visible) {
|
||||||
else if (mounted.has(i)) unmount(i);
|
if (!mounted.has(i)) entering.push(i);
|
||||||
|
} else if (mounted.has(i)) {
|
||||||
|
unmount(i);
|
||||||
|
}
|
||||||
// Topmost card still crossing the viewport top: the reader's
|
// Topmost card still crossing the viewport top: the reader's
|
||||||
// place in the list, kept fresh on every scroll pass (see
|
// place in the list, kept fresh on every scroll pass (see
|
||||||
// lastAnchor).
|
// lastAnchor).
|
||||||
@@ -1206,6 +1453,7 @@ App.videos = App.videos || {};
|
|||||||
anchorIndex = i;
|
anchorIndex = i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (let k = 0; k < entering.length; k++) mount(entering[k]);
|
||||||
// Frozen while a resize settles: the browser moves the scroll
|
// Frozen while a resize settles: the browser moves the scroll
|
||||||
// position itself during a rotation, and tracking that would replace
|
// position itself during a rotation, and tracking that would replace
|
||||||
// the reader's real place with wherever the browser landed.
|
// the reader's real place with wherever the browser landed.
|
||||||
@@ -1349,6 +1597,7 @@ App.videos = App.videos || {};
|
|||||||
|
|
||||||
const ensureInit = function() {
|
const ensureInit = function() {
|
||||||
if (!cols) measureMetrics();
|
if (!cols) measureMetrics();
|
||||||
|
bindGridDelegation();
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
settleWidth = window.innerWidth || 0;
|
settleWidth = window.innerWidth || 0;
|
||||||
@@ -1367,6 +1616,8 @@ App.videos = App.videos || {};
|
|||||||
};
|
};
|
||||||
|
|
||||||
const reset = function() {
|
const reset = function() {
|
||||||
|
// Releases rather than discards: a new result set is about to mount
|
||||||
|
// its own cards, and these are exactly the right shape for it.
|
||||||
mounted.forEach((card, i) => unmount(i));
|
mounted.forEach((card, i) => unmount(i));
|
||||||
mounted.clear();
|
mounted.clear();
|
||||||
revealed.clear(); // new result set should animate in again
|
revealed.clear(); // new result set should animate in again
|
||||||
@@ -1374,7 +1625,7 @@ App.videos = App.videos || {};
|
|||||||
colBottoms = [];
|
colBottoms = [];
|
||||||
colItems = [];
|
colItems = [];
|
||||||
const el = grid();
|
const el = grid();
|
||||||
if (el) { el.innerHTML = ''; el.style.height = '0px'; }
|
if (el) { el.style.height = '0px'; }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Removes a single video's card from the grid: its DOM element is
|
// Removes a single video's card from the grid: its DOM element is
|
||||||
@@ -1389,7 +1640,11 @@ App.videos = App.videos || {};
|
|||||||
relayout();
|
relayout();
|
||||||
};
|
};
|
||||||
|
|
||||||
return { ensureInit, packFrom, update: scheduleUpdate, relayout, removeVideo, reset, isNearEnd };
|
const stats = function() {
|
||||||
|
return { built: builtCards, recycled: recycledCards, pooled: pool.length };
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ensureInit, packFrom, update: scheduleUpdate, relayout, removeVideo, reset, isNearEnd, stats };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
App.videos.updateLoadMoreState = function() {
|
App.videos.updateLoadMoreState = function() {
|
||||||
|
|||||||
238
tests/smoke_grid.py
Normal file
238
tests/smoke_grid.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Grid smoke tests.
|
||||||
|
|
||||||
|
Run against a locally running backend:
|
||||||
|
|
||||||
|
backend/main.py &
|
||||||
|
.venv/bin/python tests/smoke_grid.py
|
||||||
|
|
||||||
|
The load-bearing check here is `content bleed`: every mounted card must render
|
||||||
|
the video its own data-video-id names. Nothing enforced that before cards were
|
||||||
|
recycled, because a card was thrown away the moment it left the window; once
|
||||||
|
cards are reused, a release path that forgets to clear something shows one
|
||||||
|
video's title, thumbnail or heart on another video's card.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:5000/"
|
||||||
|
SERVER = "https://hottubapp.io"
|
||||||
|
CHANNEL = "xvideos"
|
||||||
|
|
||||||
|
SEED = """([server, channel]) => {
|
||||||
|
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||||
|
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||||
|
localStorage.removeItem('session');
|
||||||
|
localStorage.setItem('favorites', JSON.stringify([]));
|
||||||
|
}"""
|
||||||
|
|
||||||
|
# Everything a mounted card renders, next to what its own id says it should.
|
||||||
|
INSPECT = """() => {
|
||||||
|
const byId = new Map();
|
||||||
|
(App.state.loadedVideos || []).forEach((v) => byId.set(String(v.id), v));
|
||||||
|
return Array.from(document.querySelectorAll('#video-grid .video-card')).map((card) => {
|
||||||
|
const id = card.dataset.videoId;
|
||||||
|
const v = byId.get(String(id)) || null;
|
||||||
|
const img = card.querySelector('img');
|
||||||
|
const dur = card.querySelector('.video-duration');
|
||||||
|
const up = card.querySelector('.video-uploader');
|
||||||
|
const fav = card.querySelector('.favorite-btn');
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
known: !!v,
|
||||||
|
title_shown: (card.querySelector('.video-title-text') || {}).textContent || '',
|
||||||
|
title_expected: v ? (v.title || '') : null,
|
||||||
|
// A thumbnail is served either straight from the provider or via
|
||||||
|
// /api/image?url=<encoded>; compare on the provider URL either way.
|
||||||
|
src_shown: (() => {
|
||||||
|
const raw = img ? (img.getAttribute('src') || '') : '';
|
||||||
|
if (!raw) return '';
|
||||||
|
try {
|
||||||
|
const u = new URL(raw, location.href);
|
||||||
|
return u.pathname === '/api/image'
|
||||||
|
? (u.searchParams.get('url') || raw) : raw;
|
||||||
|
} catch (e) { return raw; }
|
||||||
|
})(),
|
||||||
|
thumb_expected: v ? (v.thumb || '') : null,
|
||||||
|
duration_shown: dur && !dur.hidden ? dur.textContent : '',
|
||||||
|
duration_expected: v ? (App.videos.formatDuration(v.duration) || '') : null,
|
||||||
|
uploader_shown: up && !up.hidden ? (up.dataset.uploader || up.textContent || '') : '',
|
||||||
|
uploader_expected: v ? (v.uploader || '') : null,
|
||||||
|
heart_shown: fav ? fav.classList.contains('is-favorite') : null,
|
||||||
|
heart_expected: v ? App.favorites.has(v) : null,
|
||||||
|
stale_loading: card.classList.contains('is-loading'),
|
||||||
|
stale_pop: fav ? fav.classList.contains('just-favorited') : false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
class Checks:
|
||||||
|
def __init__(self):
|
||||||
|
self.failed = 0
|
||||||
|
|
||||||
|
def ok(self, label, condition, detail=""):
|
||||||
|
mark = "PASS" if condition else "FAIL"
|
||||||
|
if not condition:
|
||||||
|
self.failed += 1
|
||||||
|
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
|
||||||
|
|
||||||
|
|
||||||
|
def boot(page):
|
||||||
|
"""Seed a known server/channel, then wait for the grid to fill.
|
||||||
|
|
||||||
|
Startup renders from the status cached in localStorage and refreshes it in
|
||||||
|
the background, so the first visit has to wait for that round trip before
|
||||||
|
any video is loaded.
|
||||||
|
"""
|
||||||
|
page.goto(BASE, wait_until="domcontentloaded")
|
||||||
|
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||||
|
page.goto(BASE, wait_until="load")
|
||||||
|
try:
|
||||||
|
page.wait_for_selector(".video-card", timeout=90000)
|
||||||
|
except Exception:
|
||||||
|
# One reload, in case the status refresh or the listing request failed.
|
||||||
|
page.goto(BASE, wait_until="load")
|
||||||
|
page.wait_for_selector(".video-card", timeout=90000)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
|
||||||
|
|
||||||
|
def grow(page, want=80, tries=14):
|
||||||
|
"""Load enough videos that the grid is taller than the mount window.
|
||||||
|
|
||||||
|
The virtualizer keeps everything within 1.2 viewports of the screen mounted,
|
||||||
|
so a short list never unmounts anything and never exercises recycling.
|
||||||
|
"""
|
||||||
|
for _ in range(tries):
|
||||||
|
if page.evaluate("() => App.state.loadedVideos.length") >= want:
|
||||||
|
break
|
||||||
|
page.evaluate("() => window.scrollTo(0, document.documentElement.scrollHeight)")
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
return page.evaluate("() => App.state.loadedVideos.length")
|
||||||
|
|
||||||
|
|
||||||
|
def scroll_around(page, downs=10):
|
||||||
|
"""Churn the mount/unmount path: far down, then back to the top."""
|
||||||
|
for _ in range(downs):
|
||||||
|
page.evaluate("() => window.scrollBy(0, window.innerHeight * 1.5)")
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
page.wait_for_timeout(1200)
|
||||||
|
page.evaluate("() => window.scrollTo(0, 0)")
|
||||||
|
page.wait_for_timeout(1200)
|
||||||
|
for _ in range(downs // 2):
|
||||||
|
page.evaluate("() => window.scrollBy(0, window.innerHeight * 2.5)")
|
||||||
|
page.wait_for_timeout(400)
|
||||||
|
page.wait_for_timeout(1500)
|
||||||
|
|
||||||
|
|
||||||
|
def check_cards(c, cards, phase):
|
||||||
|
print(f"\n{phase}: {len(cards)} cards mounted")
|
||||||
|
c.ok(f"{phase}: cards are mounted", len(cards) > 0)
|
||||||
|
c.ok(f"{phase}: every card's id is a loaded video",
|
||||||
|
all(x["known"] for x in cards),
|
||||||
|
str([x["id"] for x in cards if not x["known"]][:3]))
|
||||||
|
|
||||||
|
ids = [x["id"] for x in cards]
|
||||||
|
c.ok(f"{phase}: no duplicate cards for one video", len(ids) == len(set(ids)))
|
||||||
|
|
||||||
|
for field in ("title", "duration", "uploader"):
|
||||||
|
bad = [x for x in cards
|
||||||
|
if x["known"] and (x[f"{field}_shown"] or "") != (x[f"{field}_expected"] or "")]
|
||||||
|
c.ok(f"{phase}: {field} matches the card's own video", not bad,
|
||||||
|
f"{len(bad)} mismatched, e.g. id={bad[0]['id']} "
|
||||||
|
f"shown={bad[0][f'{field}_shown']!r} expected={bad[0][f'{field}_expected']!r}"
|
||||||
|
if bad else "")
|
||||||
|
|
||||||
|
# The thumbnail may be served direct or through /api/image, so compare on
|
||||||
|
# the underlying provider URL rather than the literal src.
|
||||||
|
bad_src = [x for x in cards if x["known"] and x["src_shown"]
|
||||||
|
and x["thumb_expected"] and x["thumb_expected"] not in x["src_shown"]
|
||||||
|
and x["thumb_expected"].split("?")[0] not in x["src_shown"]]
|
||||||
|
c.ok(f"{phase}: thumbnail belongs to the card's own video", not bad_src,
|
||||||
|
f"{len(bad_src)} mismatched, e.g. id={bad_src[0]['id']}" if bad_src else "")
|
||||||
|
|
||||||
|
bad_heart = [x for x in cards if x["known"] and x["heart_shown"] != x["heart_expected"]]
|
||||||
|
c.ok(f"{phase}: heart state matches the card's own video", not bad_heart,
|
||||||
|
f"{len(bad_heart)} mismatched" if bad_heart else "")
|
||||||
|
|
||||||
|
stale = [x for x in cards if x["stale_loading"]]
|
||||||
|
c.ok(f"{phase}: no card left in the loading state", not stale,
|
||||||
|
f"{len(stale)} stuck" if stale else "")
|
||||||
|
|
||||||
|
# The favourite pop is animated away by animationend, which never fires on a
|
||||||
|
# card released mid-animation -- so it can ride into the pool and replay on
|
||||||
|
# whatever video the card is bound to next.
|
||||||
|
popping = [x for x in cards if x["stale_pop"]]
|
||||||
|
c.ok(f"{phase}: no card replaying the favourite animation", not popping,
|
||||||
|
f"{len(popping)} popping, e.g. id={popping[0]['id']}" if popping else "")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
c = Checks()
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(args=["--no-sandbox"])
|
||||||
|
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||||
|
boot(page)
|
||||||
|
|
||||||
|
check_cards(c, page.evaluate(INSPECT), "on first render")
|
||||||
|
|
||||||
|
# Informational: how many videos it took to outgrow the mount window
|
||||||
|
# varies with viewport and page size. Whether that was *enough* is
|
||||||
|
# asserted properly at the end, on the pool's hit rate.
|
||||||
|
print(f"\ngrew the listing to {grow(page)} videos")
|
||||||
|
|
||||||
|
scroll_around(page)
|
||||||
|
check_cards(c, page.evaluate(INSPECT), "after scrolling down and back")
|
||||||
|
|
||||||
|
# Favouriting must land on the clicked card and survive remounting.
|
||||||
|
page.evaluate("""() => {
|
||||||
|
const card = document.querySelector('#video-grid .video-card');
|
||||||
|
card.querySelector('.favorite-btn').click();
|
||||||
|
}""")
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
favourited = page.evaluate("() => App.favorites.getAll().map(f => f.key)")
|
||||||
|
c.ok("favouriting stores exactly one entry", len(favourited) == 1, str(favourited))
|
||||||
|
|
||||||
|
scroll_around(page, downs=4)
|
||||||
|
cards = page.evaluate(INSPECT)
|
||||||
|
check_cards(c, cards, "after favouriting and scrolling")
|
||||||
|
|
||||||
|
# The menu still opens on a card that has been through the cycle.
|
||||||
|
opened = page.evaluate("""() => {
|
||||||
|
const card = document.querySelector('#video-grid .video-card');
|
||||||
|
card.querySelector('.video-menu-btn').click();
|
||||||
|
return card.querySelector('.video-menu').classList.contains('open');
|
||||||
|
}""")
|
||||||
|
c.ok("the card menu opens after recycling", opened)
|
||||||
|
|
||||||
|
# Tag clicks read the button's own text now, not a data attribute.
|
||||||
|
searched = page.evaluate("""() => {
|
||||||
|
const tag = document.querySelector('#video-grid .video-card .video-tag');
|
||||||
|
if (!tag) return 'no-tags';
|
||||||
|
const label = tag.textContent;
|
||||||
|
tag.click();
|
||||||
|
return document.getElementById('search-input').value === label ? 'ok' : 'mismatch';
|
||||||
|
}""")
|
||||||
|
c.ok("clicking a tag searches for it", searched in ("ok", "no-tags"), searched)
|
||||||
|
page.wait_for_timeout(1500)
|
||||||
|
|
||||||
|
stats = page.evaluate("""
|
||||||
|
() => (App.virtualGrid.stats && App.virtualGrid.stats()) || null
|
||||||
|
""")
|
||||||
|
if stats:
|
||||||
|
total = (stats.get("built", 0) + stats.get("recycled", 0)) or 1
|
||||||
|
rate = 100 * stats.get("recycled", 0) // total
|
||||||
|
print(f"\npool: {stats.get('recycled', 0)} recycled / {total} mounts "
|
||||||
|
f"({rate}% hit rate), {stats.get('pooled', 0)} idle in pool")
|
||||||
|
# A hit rate near zero means cards are still being built per mount,
|
||||||
|
# so none of the checks above actually exercised a recycled card.
|
||||||
|
c.ok("cards are actually being recycled", rate >= 50, f"{rate}% hit rate")
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed")
|
||||||
|
return 1 if c.failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user