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; }
|
||||
|
||||
/* 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 {
|
||||
/* Warm "classic" dark: deep charcoal with a hint of brown, never pure black. */
|
||||
--bg-primary: #14110d;
|
||||
|
||||
@@ -60,6 +60,15 @@ App.enhance = App.enhance || {};
|
||||
if (!grid || !fineHover) return;
|
||||
let dwellTimer = 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 = () => {
|
||||
if (dwellTimer) { clearTimeout(dwellTimer); dwellTimer = null; }
|
||||
@@ -68,6 +77,7 @@ App.enhance = App.enhance || {};
|
||||
if (vid) { try { vid.pause(); } catch (e) {} vid.remove(); }
|
||||
activeCard.classList.remove('is-previewing');
|
||||
activeCard = null;
|
||||
activeVideo = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,10 +115,14 @@ App.enhance = App.enhance || {};
|
||||
|
||||
grid.addEventListener('pointerover', (e) => {
|
||||
const card = e.target.closest('.video-card');
|
||||
if (!card || card === activeCard) return;
|
||||
if (!card || isActive(card)) return;
|
||||
clearPreview();
|
||||
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) => {
|
||||
const card = e.target.closest('.video-card');
|
||||
|
||||
@@ -4,6 +4,12 @@ App.favorites = App.favorites || {};
|
||||
(function() {
|
||||
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.
|
||||
App.favorites.getAll = function() {
|
||||
try {
|
||||
@@ -87,6 +93,7 @@ App.favorites = App.favorites || {};
|
||||
};
|
||||
|
||||
App.favorites.setAll = function(items) {
|
||||
identityCache = null;
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items));
|
||||
};
|
||||
|
||||
@@ -181,19 +188,49 @@ App.favorites = App.favorites || {};
|
||||
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() {
|
||||
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
|
||||
// than by a server id, so a listing card can only recognise one this way.
|
||||
App.favorites.getUrlSet = function() {
|
||||
const urls = new Set();
|
||||
App.favorites.getAll().forEach((item) => {
|
||||
const urlKey = item && App.favorites.urlKey(item.url);
|
||||
if (urlKey) urls.add(urlKey);
|
||||
});
|
||||
return urls;
|
||||
return identities().urls;
|
||||
};
|
||||
|
||||
// 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
|
||||
// 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
|
||||
@@ -209,6 +246,58 @@ App.favorites = App.favorites || {};
|
||||
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() {
|
||||
return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false';
|
||||
};
|
||||
|
||||
@@ -469,7 +469,7 @@ App.feed = App.feed || {};
|
||||
|
||||
const favBtn = slide.querySelector('.feed-fav-btn');
|
||||
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) => {
|
||||
event.stopPropagation();
|
||||
App.favorites.toggle(v);
|
||||
|
||||
@@ -23,6 +23,7 @@ App.player = App.player || {};
|
||||
historyPushed: false,
|
||||
idleTimer: null,
|
||||
originEl: null,
|
||||
originToken: null, // stamp proving originEl is still the card we opened
|
||||
hudHovered: false, // mouse resting on the controls (desktop)
|
||||
activeUrl: '', // media URL actually playing, for the format menu's tick
|
||||
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 runCleanups = () => {
|
||||
cp.cleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
|
||||
@@ -295,7 +318,7 @@ App.player = App.player || {};
|
||||
const key = App.favorites.getKey(videoData);
|
||||
if (!key) { btn.hidden = true; return; }
|
||||
btn.dataset.favKey = key;
|
||||
App.favorites.setButtonState(btn, App.favorites.getSet().has(key));
|
||||
App.favorites.setButtonState(btn, App.favorites.has(videoData));
|
||||
const onClick = (event) => {
|
||||
event.stopPropagation();
|
||||
App.favorites.toggle(videoData);
|
||||
@@ -695,9 +718,10 @@ App.player = App.player || {};
|
||||
// the wrong card loaded or (via the token guard below) never clear
|
||||
// this card's spinner at all.
|
||||
const originEl = (opts && opts.originEl) || null;
|
||||
const originToken = originEl ? originEl.dataset.playerToken : null;
|
||||
const sources = resolveSources(videoData);
|
||||
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))) || '';
|
||||
|
||||
@@ -921,11 +945,12 @@ App.player = App.player || {};
|
||||
cp.attemptToken++;
|
||||
cancelInFlight();
|
||||
clearIdleTimer();
|
||||
if (cp.originEl) cp.originEl.classList.remove('is-loading');
|
||||
withOrigin(cp.originEl, cp.originToken, (el) => el.classList.remove('is-loading'));
|
||||
}
|
||||
runCleanups();
|
||||
|
||||
cp.originEl = opts && opts.originEl ? opts.originEl : null;
|
||||
cp.originToken = claimOrigin(cp.originEl);
|
||||
if (cp.originEl) cp.originEl.classList.add('is-loading');
|
||||
|
||||
cp.container = buildContainer();
|
||||
@@ -1031,10 +1056,12 @@ App.player = App.player || {};
|
||||
cp.historyPushed = false;
|
||||
}
|
||||
|
||||
if (cp.originEl) {
|
||||
cp.originEl.classList.remove('is-loading');
|
||||
withOrigin(cp.originEl, cp.originToken, (el) => {
|
||||
el.classList.remove('is-loading');
|
||||
delete el.dataset.playerToken;
|
||||
});
|
||||
cp.originEl = null;
|
||||
}
|
||||
cp.originToken = null;
|
||||
cp.data = null;
|
||||
cp.source = null; // voids a still-pending format resolve for this open
|
||||
cp.formatOverride = null;
|
||||
|
||||
@@ -147,6 +147,7 @@ App.videos = App.videos || {};
|
||||
const imageRoutes = new Map(); // host -> winning route; absent = unknown
|
||||
const imageRacing = new Set(); // hosts with a race already deciding
|
||||
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
|
||||
// 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
|
||||
// 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.
|
||||
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) {
|
||||
img.alt = 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
|
||||
// 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;
|
||||
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
|
||||
@@ -213,11 +239,11 @@ App.videos = App.videos || {};
|
||||
imageWaiting.delete(host);
|
||||
waiting.forEach((entry) => {
|
||||
if (route === IMAGE_PROXY) {
|
||||
showThumbnail(entry.img, entry.proxyUrl);
|
||||
showThumbnail(entry.img, entry.proxyUrl, entry.token);
|
||||
return;
|
||||
}
|
||||
if (route !== IMAGE_DIRECT) attachProxyFallback(entry.img, entry.proxyUrl);
|
||||
showThumbnail(entry.img, entry.directUrl);
|
||||
if (route !== IMAGE_DIRECT) attachProxyFallback(entry.img, entry.proxyUrl, entry.token);
|
||||
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
|
||||
// would abort the loser -- and the loser is the request that answers the
|
||||
// second question.
|
||||
const raceThumbnail = function(img, directUrl, proxyUrl, host) {
|
||||
const raceThumbnail = function(img, directUrl, proxyUrl, host, token) {
|
||||
let shown = false;
|
||||
let outstanding = 2;
|
||||
let routeFinal = false; // the direct verdict is in; nothing can revise it
|
||||
@@ -250,7 +276,7 @@ App.videos = App.videos || {};
|
||||
const show = function(url) {
|
||||
if (shown) return;
|
||||
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
|
||||
@@ -278,11 +304,11 @@ App.videos = App.videos || {};
|
||||
if (shown) return;
|
||||
shown = true;
|
||||
if (imageRoutes.get(host) === IMAGE_PROXY) {
|
||||
showThumbnail(img, proxyUrl);
|
||||
showThumbnail(img, proxyUrl, token);
|
||||
return;
|
||||
}
|
||||
attachProxyFallback(img, proxyUrl);
|
||||
showThumbnail(img, directUrl);
|
||||
attachProxyFallback(img, proxyUrl, token);
|
||||
showThumbnail(img, directUrl, token);
|
||||
};
|
||||
|
||||
const decide = function() {
|
||||
@@ -383,9 +409,13 @@ App.videos = App.videos || {};
|
||||
const proxyUrl = App.videos.buildImageProxyUrl(directUrl);
|
||||
const host = imageHostOf(directUrl);
|
||||
const route = imageRoutes.get(host);
|
||||
// Every attach is a new generation, so work started for a previous one
|
||||
// stops being able to touch this element.
|
||||
const token = String(++thumbSeq);
|
||||
img.dataset.thumbToken = token;
|
||||
|
||||
if (route === IMAGE_PROXY) {
|
||||
showThumbnail(img, proxyUrl || directUrl);
|
||||
showThumbnail(img, proxyUrl || directUrl, token);
|
||||
return;
|
||||
}
|
||||
if (imageRacing.has(host)) {
|
||||
@@ -393,18 +423,28 @@ App.videos = App.videos || {};
|
||||
// guessing: guessing wrong costs this image a whole failed request
|
||||
// before it even asks the route that was about to be proven.
|
||||
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);
|
||||
return;
|
||||
}
|
||||
if (route === IMAGE_DIRECT || !host || !proxyUrl) {
|
||||
// Known good, or nothing to race against: take the provider and keep
|
||||
// the proxy as this image's own fallback.
|
||||
attachProxyFallback(img, proxyUrl);
|
||||
showThumbnail(img, directUrl);
|
||||
attachProxyFallback(img, proxyUrl, token);
|
||||
showThumbnail(img, directUrl, token);
|
||||
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
|
||||
@@ -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
|
||||
// on screen and throw it away once it scrolls out of the window.
|
||||
App.videos.buildCard = function(v, options) {
|
||||
const favoritesSet = App.favorites.getSet();
|
||||
const card = document.createElement('div');
|
||||
card.className = 'video-card';
|
||||
card.dataset.videoId = v.id;
|
||||
const durationText = App.videos.formatDuration(v.duration);
|
||||
const favoriteKey = App.favorites.getKey(v);
|
||||
const uploaderText = v.uploader || '';
|
||||
const tags = Array.isArray(v.tags) ? v.tags.filter(tag => tag) : [];
|
||||
const tagsMarkup = tags.length
|
||||
? `<div class="video-tags">${tags.map(tag => `<button class="video-tag" type="button" data-tag="${tag}">${tag}</button>`).join('')}</div>`
|
||||
: '';
|
||||
const liveBadge = v.isLive ? '<span class="live-badge">● LIVE</span>' : '';
|
||||
card.innerHTML = `
|
||||
${liveBadge}
|
||||
<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>
|
||||
// ---------------------------------------------------------------------
|
||||
// Cards
|
||||
//
|
||||
// A card is built once and then reused: the virtualizer keeps a pool of
|
||||
// them and rebinds one to a new video rather than constructing another
|
||||
// (see acquire/release in App.virtualGrid). Two things follow from that,
|
||||
// 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="${v.title}" loading="lazy" decoding="async">
|
||||
<img alt="" loading="lazy" decoding="async">
|
||||
<div class="video-loading" aria-hidden="true">
|
||||
<div class="video-loading-spinner"></div>
|
||||
</div>
|
||||
${uploaderText ? `<button class="video-uploader uploader-link" type="button" data-uploader="${uploaderText}">${uploaderText}</button>` : ''}
|
||||
${durationText ? `<span class="video-duration">${durationText}</span>` : ''}
|
||||
<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">${v.title}</span></h4>
|
||||
${tagsMarkup}
|
||||
<h4 class="video-title"><span class="video-title-text"></span></h4>
|
||||
<div class="video-tags" hidden></div>
|
||||
`;
|
||||
const thumb = card.querySelector('img');
|
||||
|
||||
// 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;
|
||||
cardVideo.set(card, v);
|
||||
|
||||
refs.live.hidden = !v.isLive;
|
||||
|
||||
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 || '';
|
||||
refs.uploader.hidden = !uploaderText;
|
||||
refs.uploader.textContent = uploaderText;
|
||||
refs.uploader.dataset.uploader = uploaderText;
|
||||
|
||||
const durationText = App.videos.formatDuration(v.duration);
|
||||
refs.duration.hidden = !durationText;
|
||||
refs.duration.textContent = durationText;
|
||||
|
||||
bindTags(refs.tags, v.tags);
|
||||
|
||||
// Set before attachThumbnail, which holds the caption back until there
|
||||
// is an image to caption.
|
||||
refs.img.alt = v.title || '';
|
||||
return card;
|
||||
};
|
||||
|
||||
// A card ready to show `v`, thumbnail and all.
|
||||
App.videos.buildCard = function(v, options) {
|
||||
const card = App.videos.bindCard(createCardShell(), v);
|
||||
// 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
|
||||
// the same frame, so loading a thumbnail for it -- let alone racing one
|
||||
// -- would be pure waste.
|
||||
if (!(options && options.skipThumbnail)) {
|
||||
App.videos.attachThumbnail(thumb, v.thumb);
|
||||
App.videos.attachThumbnail(cardRefs(card).img, v.thumb);
|
||||
}
|
||||
const favoriteBtn = card.querySelector('.favorite-btn');
|
||||
if (favoriteBtn && favoriteKey) {
|
||||
App.favorites.setButtonState(favoriteBtn, favoritesSet.has(favoriteKey));
|
||||
favoriteBtn.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
App.favorites.toggle(v);
|
||||
return card;
|
||||
};
|
||||
}
|
||||
const titleWrap = card.querySelector('.video-title');
|
||||
const titleText = card.querySelector('.video-title-text');
|
||||
if (titleWrap && titleText) {
|
||||
card.addEventListener('focusin', () => {
|
||||
card.dataset.titleFocused = '1';
|
||||
updateTitleActive(card);
|
||||
});
|
||||
card.addEventListener('focusout', () => {
|
||||
card.dataset.titleFocused = '0';
|
||||
updateTitleActive(card);
|
||||
});
|
||||
if (titleEnv.useHoverFocus) {
|
||||
card.addEventListener('mouseenter', () => {
|
||||
card.dataset.titleHovered = '1';
|
||||
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');
|
||||
|
||||
// Returns a card to a state where it shows nothing and remembers nothing,
|
||||
// ready to be bound to another video. Anything left behind here surfaces as
|
||||
// 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');
|
||||
delete card.dataset.videoId;
|
||||
delete card.dataset.titleFocused;
|
||||
delete card.dataset.titleHovered;
|
||||
delete card.dataset.titlePrimary;
|
||||
// The player stamps this to recognise the card it was opened from; a
|
||||
// recycled card must stop answering to it.
|
||||
delete card.dataset.playerToken;
|
||||
|
||||
// Added by favorites.toggle and normally taken off by animationend --
|
||||
// which never fires here, because release() detaches the card first and
|
||||
// a detached element runs no animations. Left on, the next video this
|
||||
// card shows replays a "just favourited" pop nobody asked for.
|
||||
const favorite = cardRefs(card).favorite;
|
||||
if (favorite) favorite.classList.remove('just-favorited');
|
||||
|
||||
const menu = card.querySelector('.video-menu');
|
||||
const showInfoBtn = card.querySelector('.video-menu-item[data-action="info"]');
|
||||
const downloadBtn = card.querySelector('.video-menu-item[data-action="download"]');
|
||||
if (menuBtn && menu) {
|
||||
menuBtn.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
App.videos.toggleMenu(menu, menuBtn);
|
||||
};
|
||||
if (menu) menu.classList.remove('open');
|
||||
const menuBtn = card.querySelector('.video-menu-btn');
|
||||
if (menuBtn) menuBtn.setAttribute('aria-expanded', 'false');
|
||||
|
||||
const titleWrap = card.querySelector('.video-title');
|
||||
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.
|
||||
const preview = card.querySelector('.card-preview');
|
||||
if (preview) {
|
||||
try { preview.pause(); } catch (err) { /* ignore */ }
|
||||
preview.remove();
|
||||
}
|
||||
|
||||
const img = cardRefs(card).img;
|
||||
if (img) {
|
||||
// Voids anything still in flight for the old thumbnail -- a pending
|
||||
// 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 = '';
|
||||
}
|
||||
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();
|
||||
};
|
||||
}
|
||||
if (downloadBtn) {
|
||||
downloadBtn.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
App.videos.downloadVideo(v);
|
||||
App.videos.closeAllMenus();
|
||||
};
|
||||
return;
|
||||
}
|
||||
card.onclick = () => {
|
||||
if (card.classList.contains('is-loading')) return;
|
||||
card.classList.add('is-loading');
|
||||
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 });
|
||||
return card;
|
||||
|
||||
grid.addEventListener('focusin', (event) => setTitleFlag(event, 'titleFocused', '1'));
|
||||
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;
|
||||
@@ -847,6 +1031,15 @@ App.videos = App.videos || {};
|
||||
App.virtualGrid.ensureInit();
|
||||
|
||||
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;
|
||||
items.forEach((v) => {
|
||||
if (state.renderedVideoIds.has(v.id)) return;
|
||||
@@ -1129,19 +1322,61 @@ App.videos = App.videos || {};
|
||||
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) {
|
||||
if (mounted.has(i)) return;
|
||||
const v = state.loadedVideos[i];
|
||||
const l = layout[i];
|
||||
if (!v || !l) return;
|
||||
const card = App.videos.buildCard(v);
|
||||
const card = acquire(v);
|
||||
place(card, l);
|
||||
// Entrance animation only the first time an index appears, so cards
|
||||
// don't re-animate every time they scroll back into the window.
|
||||
if (!revealed.has(i)) {
|
||||
revealed.add(i);
|
||||
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);
|
||||
mounted.set(i, card);
|
||||
@@ -1150,6 +1385,11 @@ App.videos = App.videos || {};
|
||||
const img = card.querySelector('img');
|
||||
if (img) {
|
||||
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.classList.add('is-loaded');
|
||||
// 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);
|
||||
};
|
||||
// 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);
|
||||
else img.addEventListener('load', reveal);
|
||||
}
|
||||
@@ -1171,14 +1415,8 @@ App.videos = App.videos || {};
|
||||
const unmount = function(i) {
|
||||
const card = mounted.get(i);
|
||||
if (!card) return;
|
||||
resolveObserver.unobserve(card);
|
||||
if (titleObserver) {
|
||||
titleObserver.unobserve(card);
|
||||
titleVisibility.delete(card);
|
||||
}
|
||||
cardVideo.delete(card);
|
||||
card.remove();
|
||||
mounted.delete(i);
|
||||
release(card);
|
||||
};
|
||||
|
||||
// Mounts cards intersecting the viewport window, unmounts the rest.
|
||||
@@ -1192,12 +1430,21 @@ App.videos = App.videos || {};
|
||||
const end = viewTop + vh * (1 + OVERSCAN);
|
||||
let anchorIndex = -1;
|
||||
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++) {
|
||||
const l = layout[i];
|
||||
if (!l) continue;
|
||||
const visible = l.top < end && (l.top + l.height) > start;
|
||||
if (visible) mount(i);
|
||||
else if (mounted.has(i)) unmount(i);
|
||||
if (visible) {
|
||||
if (!mounted.has(i)) entering.push(i);
|
||||
} else if (mounted.has(i)) {
|
||||
unmount(i);
|
||||
}
|
||||
// Topmost card still crossing the viewport top: the reader's
|
||||
// place in the list, kept fresh on every scroll pass (see
|
||||
// lastAnchor).
|
||||
@@ -1206,6 +1453,7 @@ App.videos = App.videos || {};
|
||||
anchorIndex = i;
|
||||
}
|
||||
}
|
||||
for (let k = 0; k < entering.length; k++) mount(entering[k]);
|
||||
// Frozen while a resize settles: the browser moves the scroll
|
||||
// position itself during a rotation, and tracking that would replace
|
||||
// the reader's real place with wherever the browser landed.
|
||||
@@ -1349,6 +1597,7 @@ App.videos = App.videos || {};
|
||||
|
||||
const ensureInit = function() {
|
||||
if (!cols) measureMetrics();
|
||||
bindGridDelegation();
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
settleWidth = window.innerWidth || 0;
|
||||
@@ -1367,6 +1616,8 @@ App.videos = App.videos || {};
|
||||
};
|
||||
|
||||
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.clear();
|
||||
revealed.clear(); // new result set should animate in again
|
||||
@@ -1374,7 +1625,7 @@ App.videos = App.videos || {};
|
||||
colBottoms = [];
|
||||
colItems = [];
|
||||
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
|
||||
@@ -1389,7 +1640,11 @@ App.videos = App.videos || {};
|
||||
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() {
|
||||
|
||||
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