Compare commits

..

3 Commits

Author SHA1 Message Date
Simon
7624ca559a Close three holes in the card release path
A second review pass over the pool. All three are the same shape: state
or a listener outliving the video it belonged to.

attachProxyFallback replaces whatever fallback an image currently has,
so a call arriving late -- a race hitting its patience timeout after the
card was recycled -- took away the live listener and left a dead one, and
the new video's thumbnail would then fail with nothing behind it. This
one was self-inflicted: the detach came in last round to stop the
listeners accumulating, and introduced the clobber. It is token-guarded
now, like every other path that can arrive late.

The favourite pop is cleared by animationend, which never fires on a card
release() has already detached -- detached elements run no animations. So
the class rode into the pool and replayed on the next video the card
showed. Favourite something and flick-scroll to see it.

And withOrigin compared a token that dataset reports as undefined for an
unstamped card, which matched every unstamped card instead of none --
failing open in the guard whose whole purpose is noticing that the grid
has recycled the card out from under the player.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-09 15:57:52 +00:00
Simon
49992c1db0 Recycle grid cards instead of rebuilding them
Scrolling the grid did nothing but destroy cards and build near-identical
ones back: a template string parsed as innerHTML, ten querySelectors, and
a listener per interactive element, every time a card entered the window.
The virtualizer now keeps a pool and rebinds a card it already has --
18us against 136us to build one, and 74-83% of mounts are served from it.

Two things had to change first. Nothing on a card may close over the
video it is showing, because the card outlives the video, so every
interaction moved to one delegated listener per event type on the grid.
And every card now has the same shape whatever it shows: the optional
parts are always present and hidden when unused, so any pooled card fits
any video. That needed a global [hidden] rule, since .live-badge and
.video-tags carry their own display.

The rest is the release path, which is where this design lives or dies.
A thumbnail carries a generation, so a race or a proxy fallback settling
after the card moved on cannot paint over the video now showing. The
player stamps the card it was opened from, so a recycled element stops
answering for it. The reveal handler, the entrance-animation listener and
the hover preview are all taken back off. Anything missed here surfaces
as one video's title, thumbnail or heart on another video's card, which
is what the smoke suite scrolls back and forth to catch.

Two incidental fixes found while measuring: bindCard no longer writes a
data-tag per tag button (dataset is a proxy, and that alone cost more
than the rest of a rebind put together -- the handler reads the label off
the button), and favorites.has no longer parses a URL for every card that
isn't a favorite by key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-09 09:47:43 +00:00
Simon
b4031b5d0e Add a grid smoke suite, starting with content bleed
The repo has no tests, and the card pool that follows is exactly the kind of
change that breaks quietly: a release path that forgets to clear something
shows one video's title, thumbnail or heart on another video's card. So the
check that matters is that every mounted card renders the video its own
data-video-id names -- asserted after scrolling down and back, which is when
cards get reused.

Passes against the current build, where nothing is recycled yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-09 09:12:54 +00:00
6 changed files with 680 additions and 146 deletions

View File

@@ -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;

View File

@@ -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');

View File

@@ -224,6 +224,10 @@ App.favorites = App.favorites || {};
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));

View File

@@ -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 */ } });
@@ -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;

View File

@@ -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,132 +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 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) {
// By either identity: a favorite imported from a backup is keyed
// by its URL, not by the id this card carries.
App.favorites.setButtonState(favoriteBtn, App.favorites.has(v));
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;
@@ -1139,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);
@@ -1160,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
@@ -1170,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);
}
@@ -1181,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.
@@ -1202,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).
@@ -1216,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.
@@ -1359,6 +1597,7 @@ App.videos = App.videos || {};
const ensureInit = function() {
if (!cols) measureMetrics();
bindGridDelegation();
if (initialized) return;
initialized = true;
settleWidth = window.innerWidth || 0;
@@ -1377,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
@@ -1384,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
@@ -1399,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
View 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())