diff --git a/frontend/css/style.css b/frontend/css/style.css
index 2ab8a55..390df84 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -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;
diff --git a/frontend/js/enhance.js b/frontend/js/enhance.js
index a9c5d38..a7efb8d 100644
--- a/frontend/js/enhance.js
+++ b/frontend/js/enhance.js
@@ -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');
diff --git a/frontend/js/favorites.js b/frontend/js/favorites.js
index 447824b..da595db 100644
--- a/frontend/js/favorites.js
+++ b/frontend/js/favorites.js
@@ -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));
diff --git a/frontend/js/player.js b/frontend/js/player.js
index e2fb464..815b1b3 100644
--- a/frontend/js/player.js
+++ b/frontend/js/player.js
@@ -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,24 @@ 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) {
+ if (el && 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 +714,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 +941,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 +1052,12 @@ App.player = App.player || {};
cp.historyPushed = false;
}
- if (cp.originEl) {
- cp.originEl.classList.remove('is-loading');
- cp.originEl = null;
- }
+ 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;
diff --git a/frontend/js/videos.js b/frontend/js/videos.js
index e825500..f0b51e4 100644
--- a/frontend/js/videos.js
+++ b/frontend/js/videos.js
@@ -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,22 @@ 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 });
+ // 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 +233,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 +258,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 +270,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 +298,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 +403,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 +417,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 +744,268 @@ 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';
+ // ---------------------------------------------------------------------
+ // 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 = `
+ ● LIVE
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ // 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 = `
${CARD_TEMPLATE_HTML}
`;
+ }
+ 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;
- const durationText = App.videos.formatDuration(v.duration);
+ 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 || '';
- const tags = Array.isArray(v.tags) ? v.tags.filter(tag => tag) : [];
- const tagsMarkup = tags.length
- ? `