virtual video item cards

This commit is contained in:
Simon
2026-06-23 20:05:59 +00:00
parent 55828c9726
commit b9ff61244c
2 changed files with 336 additions and 203 deletions

View File

@@ -889,14 +889,9 @@ body.theme-light .setting-item select option {
box-shadow: 0 6px 16px var(--shadow); box-shadow: 0 6px 16px var(--shadow);
position: relative; position: relative;
margin-bottom: 0; margin-bottom: 0;
/* Off-screen cards are kept in the DOM (so infinite scroll and scroll /* The grid is virtualized in JS (see App.virtualGrid): only cards near the
position are untouched) but the browser skips their style, layout, and viewport are in the DOM at all, and each is absolutely positioned at a
paint work. This is what keeps a grid of hundreds of cards smooth on precomputed (top,left). */
mobile. `auto` lets the browser remember each card's real rendered height
so the scroll height stays stable; the fallback is only a first-paint
estimate for cards that have never been on screen. */
content-visibility: auto;
contain-intrinsic-size: auto 300px;
} }
.video-card:hover { .video-card:hover {
@@ -907,7 +902,8 @@ body.theme-light .setting-item select option {
.video-card img { .video-card img {
width: 100%; width: 100%;
height: auto; aspect-ratio: 16 / 9;
object-fit: cover;
display: block; display: block;
background: var(--bg-tertiary); background: var(--bg-tertiary);
} }

View File

@@ -103,9 +103,8 @@ App.videos = App.videos || {};
document.querySelectorAll('.video-card').forEach((card) => { document.querySelectorAll('.video-card').forEach((card) => {
measureTitle(card); measureTitle(card);
}); });
// Column width may have changed, re-wrapping titles and changing // The virtualizer re-packs and remounts on resize via its own
// card heights, so re-run the (batched) full masonry pass. // listener; nothing else to do here.
App.videos.applyMasonryLayout();
}); });
}; };
@@ -299,17 +298,11 @@ App.videos = App.videos || {};
} }
}; };
// Renders new cards for videos, wiring favorites + playback behavior. // Builds a fully-wired video card element for `v`. Kept separate from
App.videos.renderVideos = function(videos) { // mounting so the virtualizer can create a card the moment it needs to be
const grid = document.getElementById('video-grid'); // on screen and throw it away once it scrolls out of the window.
if (!grid) return; App.videos.buildCard = function(v) {
const items = videos && Array.isArray(videos.items) ? videos.items : [];
const favoritesSet = App.favorites.getSet(); const favoritesSet = App.favorites.getSet();
items.forEach(v => {
if (state.renderedVideoIds.has(v.id)) return;
state.loadedVideos.push(v);
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'video-card'; card.className = 'video-card';
card.dataset.videoId = v.id; card.dataset.videoId = v.id;
@@ -340,11 +333,6 @@ App.videos = App.videos || {};
`; `;
const thumb = card.querySelector('img'); const thumb = card.querySelector('img');
App.videos.attachNoReferrerRetry(thumb); App.videos.attachNoReferrerRetry(thumb);
if (thumb) {
// Only this card's height can change when its own thumbnail
// loads, so reposition just this card instead of the whole grid.
thumb.addEventListener('load', () => App.videos.layoutCard(card));
}
const favoriteBtn = card.querySelector('.favorite-btn'); const favoriteBtn = card.querySelector('.favorite-btn');
if (favoriteBtn && favoriteKey) { if (favoriteBtn && favoriteKey) {
App.favorites.setButtonState(favoriteBtn, favoritesSet.has(favoriteKey)); App.favorites.setButtonState(favoriteBtn, favoritesSet.has(favoriteKey));
@@ -356,10 +344,6 @@ App.videos = App.videos || {};
const titleWrap = card.querySelector('.video-title'); const titleWrap = card.querySelector('.video-title');
const titleText = card.querySelector('.video-title-text'); const titleText = card.querySelector('.video-title-text');
if (titleWrap && titleText) { if (titleWrap && titleText) {
requestAnimationFrame(() => {
measureTitle(card);
App.videos.layoutCard(card);
});
card.addEventListener('focusin', () => { card.addEventListener('focusin', () => {
card.dataset.titleFocused = '1'; card.dataset.titleFocused = '1';
updateTitleActive(card); updateTitleActive(card);
@@ -379,7 +363,7 @@ App.videos = App.videos || {};
}); });
} }
// On touch devices the marquee observer is attached lazily by // On touch devices the marquee observer is attached lazily by
// measureTitle, but only for cards whose title actually overflows. // measureTitle (called on mount), and only for overflowing titles.
} }
const uploaderBtn = card.querySelector('.uploader-link'); const uploaderBtn = card.querySelector('.uploader-link');
if (uploaderBtn) { if (uploaderBtn) {
@@ -428,17 +412,32 @@ App.videos = App.videos || {};
card.classList.add('is-loading'); card.classList.add('is-loading');
App.player.open(v, { originEl: card }); App.player.open(v, { originEl: card });
}; };
grid.appendChild(card);
// Resolve formats + probe direct playability on demand: when the
// card scrolls near the viewport, or the moment it's hovered.
cardVideo.set(card, v); cardVideo.set(card, v);
probeObserver.observe(card);
card.addEventListener('pointerenter', () => App.videos.resolveAndProbe(v), { once: true }); card.addEventListener('pointerenter', () => App.videos.resolveAndProbe(v), { once: true });
state.renderedVideoIds.add(v.id); return card;
}); };
// Appends a freshly-loaded page of videos. The card DOM is *not* built here;
// we only grow the data buffer, extend the masonry layout for the new
// items, then let the virtualizer mount whatever currently falls inside the
// viewport window.
App.videos.renderVideos = function(videos) {
const grid = document.getElementById('video-grid');
if (!grid) return;
App.virtualGrid.ensureInit();
const items = videos && Array.isArray(videos.items) ? videos.items : [];
const startLen = state.loadedVideos.length;
items.forEach((v) => {
if (state.renderedVideoIds.has(v.id)) return;
state.renderedVideoIds.add(v.id);
state.loadedVideos.push(v);
});
if (state.loadedVideos.length > startLen) {
App.virtualGrid.packFrom(startLen);
}
App.virtualGrid.update();
// Each new card lays itself out via its own rAF / image-load handler
// above, so there is no need to relayout the whole grid here.
if (App.feed && typeof App.feed.renderSlides === 'function') { if (App.feed && typeof App.feed.renderSlides === 'function') {
App.feed.renderSlides(); App.feed.renderSlides();
} }
@@ -490,8 +489,7 @@ App.videos = App.videos || {};
state.renderedVideoIds.clear(); state.renderedVideoIds.clear();
state.loadedVideos = []; state.loadedVideos = [];
state.groupCursors = null; state.groupCursors = null;
const grid = document.getElementById('video-grid'); App.virtualGrid.reset();
if (grid) grid.innerHTML = "";
if (App.feed && typeof App.feed.reset === 'function') { if (App.feed && typeof App.feed.reset === 'function') {
App.feed.reset(); App.feed.reset();
} }
@@ -510,8 +508,7 @@ App.videos = App.videos || {};
state.renderedVideoIds.clear(); state.renderedVideoIds.clear();
state.loadedVideos = []; state.loadedVideos = [];
state.groupCursors = null; state.groupCursors = null;
const grid = document.getElementById('video-grid'); App.virtualGrid.reset();
if (grid) grid.innerHTML = "";
if (App.feed && typeof App.feed.reset === 'function') { if (App.feed && typeof App.feed.reset === 'function') {
App.feed.reset(); App.feed.reset();
} }
@@ -529,67 +526,207 @@ App.videos = App.videos || {};
} }
}; };
// Grid track geometry is identical for every card and only changes when the // ---------------------------------------------------------------------
// viewport crosses a breakpoint, so we read it from the DOM once and cache // Virtualized masonry grid
// it. Reading getComputedStyle per card (per image load) was a needless //
// layout read on the hot path. // The full set of loaded videos lives in state.loadedVideos. Only the cards
let cachedGridMetrics = null; // whose computed position falls within the viewport (plus an overscan
const getGridMetrics = function() { // buffer) are kept in the DOM; the rest are unmounted. The container is
if (cachedGridMetrics) return cachedGridMetrics; // given an explicit pixel height and every card is absolutely positioned at
const grid = document.getElementById('video-grid'); // a precomputed (top,left), so:
if (!grid) return null; // * the scrollbar and scroll position are identical to a fully-rendered
const styles = window.getComputedStyle(grid); // grid, and
if (styles.display !== 'grid') return null; // * mounting/unmounting a card never moves any other card -- positions are
const rowHeight = parseInt(styles.getPropertyValue('grid-auto-rows'), 10); // assigned once and never change -- so there is no scroll jank.
const rowGap = parseInt(styles.getPropertyValue('row-gap') || styles.getPropertyValue('gap'), 10) || 0; //
if (!rowHeight) return null; // Card heights are deterministic (single-line title, fixed 16:9 thumbnail,
cachedGridMetrics = { rowHeight, rowGap }; // optional tag/uploader/duration rows), so we measure one card per distinct
return cachedGridMetrics; // "shape" and reuse that height. Packing is shortest-column-first.
// ---------------------------------------------------------------------
App.virtualGrid = (function() {
const mounted = new Map(); // loadedVideos index -> card element
const layout = []; // index -> { top, left, width, height }
const heightCache = new Map(); // shape signature -> measured px height
let colBottoms = []; // running bottom y of each column
let cols = 0, colWidth = 0, gap = 16, padX = 24, padY = 24;
let initialized = false;
let rafPending = false;
const OVERSCAN = 1.2; // viewports of cards kept mounted off-screen
const grid = () => document.getElementById('video-grid');
const measureMetrics = function() {
const el = grid();
if (!el) return false;
const mobile = window.matchMedia('(max-width: 768px)').matches;
const large = window.matchMedia('(min-width: 1600px)').matches;
gap = mobile ? 12 : (large ? 24 : 16);
padX = mobile ? 16 : (large ? 48 : 24);
padY = mobile ? 16 : (large ? 32 : 24);
// We own positioning, so neutralize the CSS grid + padding and read
// the resulting content width (which still honors max-width:auto
// centering).
el.style.display = 'block';
el.style.position = 'relative';
el.style.padding = '0';
const inner = el.clientWidth - padX * 2;
if (inner <= 0) return false;
cols = mobile ? 2 : Math.max(1, Math.floor((inner + gap) / (260 + gap)));
colWidth = (inner - gap * (cols - 1)) / cols;
return true;
}; };
const spanFor = function(itemHeight, metrics) { const signatureOf = function(v) {
return Math.ceil((itemHeight + metrics.rowGap) / (metrics.rowHeight + metrics.rowGap)); const hasTags = Array.isArray(v.tags) && v.tags.some((t) => t);
return [Math.round(colWidth), v.isLive ? 1 : 0, hasTags ? 1 : 0,
v.uploader ? 1 : 0, (v.duration > 0) ? 1 : 0].join('|');
}; };
// Masonry placement for a single card. Each card's row span is independent // Height of a card of `v`'s shape at the current column width. Measured
// of its siblings, so a newly loaded thumbnail only needs to re-measure its // once per shape via a hidden probe, then cached.
// own card -- not relayout the entire (potentially huge) grid. This is the const heightOf = function(v) {
// O(1) replacement for the old whole-grid pass that ran on every image load. const sig = signatureOf(v);
App.videos.layoutCard = function(card) { const cached = heightCache.get(sig);
if (cached != null) return cached;
const el = grid();
if (!el) return 240;
const probe = App.videos.buildCard(v);
probe.style.position = 'absolute';
probe.style.visibility = 'hidden';
probe.style.left = '-99999px';
probe.style.top = '0';
probe.style.width = colWidth + 'px';
el.appendChild(probe);
const h = probe.getBoundingClientRect().height;
el.removeChild(probe);
heightCache.set(sig, h || 240);
return h || 240;
};
const setContainerHeight = function() {
const el = grid();
if (!el) return;
const maxBottom = colBottoms.length ? Math.max.apply(null, colBottoms) : padY;
el.style.height = Math.max(0, maxBottom - gap + padY) + 'px';
};
// Assigns positions to items [start, end). Earlier items keep their
// positions because colBottoms carries forward unchanged.
const packFrom = function(start) {
if (!cols) { if (!measureMetrics()) return; }
if (!colBottoms.length) colBottoms = new Array(cols).fill(padY);
for (let i = start; i < state.loadedVideos.length; i++) {
const v = state.loadedVideos[i];
const h = heightOf(v);
let col = 0;
for (let c = 1; c < cols; c++) {
if (colBottoms[c] < colBottoms[col]) col = c;
}
const top = colBottoms[col];
layout[i] = {
top,
left: padX + col * (colWidth + gap),
width: colWidth,
height: h
};
colBottoms[col] = top + h + gap;
}
setContainerHeight();
};
const place = function(card, l) {
card.style.position = 'absolute';
card.style.top = l.top + 'px';
card.style.left = l.left + 'px';
card.style.width = l.width + 'px';
};
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);
place(card, l);
grid().appendChild(card);
mounted.set(i, card);
// Marquee + direct-playability probe only matter for on-screen cards.
requestAnimationFrame(() => { if (mounted.get(i) === card) measureTitle(card); });
probeObserver.observe(card);
};
const unmount = function(i) {
const card = mounted.get(i);
if (!card) return; if (!card) return;
const metrics = getGridMetrics(); probeObserver.unobserve(card);
if (!metrics) return; if (titleObserver) {
const itemHeight = card.getBoundingClientRect().height; titleObserver.unobserve(card);
if (!itemHeight) return; titleVisibility.delete(card);
card.style.gridRowEnd = `span ${spanFor(itemHeight, metrics)}`; }
cardVideo.delete(card);
card.remove();
mounted.delete(i);
}; };
// Full relayout, used only when the column width actually changes (resize / // Mounts cards intersecting the viewport window, unmounts the rest.
// breakpoint), since that re-wraps titles and changes every card's height. const update = function() {
// Reads are batched ahead of writes so we don't thrash layout per card the const el = grid();
// way the old per-item read-then-write loop did. if (!el || !state.loadedVideos.length) return;
let masonryRaf = null; const rectTop = el.getBoundingClientRect().top; // container top vs viewport
App.videos.applyMasonryLayout = function() { const vh = window.innerHeight || 800;
const grid = document.getElementById('video-grid'); const viewTop = -rectTop; // viewport top in container space
if (!grid) return; const start = viewTop - vh * OVERSCAN;
cachedGridMetrics = null; const end = viewTop + vh * (1 + OVERSCAN);
const metrics = getGridMetrics(); for (let i = 0; i < layout.length; i++) {
if (!metrics) return; const l = layout[i];
const cards = Array.from(grid.children); if (!l) continue;
const heights = cards.map((item) => item.getBoundingClientRect().height); const visible = l.top < end && (l.top + l.height) > start;
cards.forEach((item, i) => { if (visible) mount(i);
if (heights[i]) item.style.gridRowEnd = `span ${spanFor(heights[i], metrics)}`; else if (mounted.has(i)) unmount(i);
}
};
const scheduleUpdate = function() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => { rafPending = false; update(); });
};
// Full re-pack + remount, used when the column geometry changes.
const relayout = function() {
if (!measureMetrics()) return;
heightCache.clear(); // colWidth changed -> heights differ
mounted.forEach((card, i) => unmount(i));
layout.length = 0;
colBottoms = new Array(cols).fill(padY);
packFrom(0);
update();
};
let resizeRaf = null;
const ensureInit = function() {
if (!cols) measureMetrics();
if (initialized) return;
initialized = true;
window.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', () => {
if (resizeRaf) cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(relayout);
}); });
}; };
App.videos.scheduleMasonryLayout = function() { const reset = function() {
if (masonryRaf) cancelAnimationFrame(masonryRaf); mounted.forEach((card, i) => unmount(i));
masonryRaf = requestAnimationFrame(() => { mounted.clear();
masonryRaf = null; layout.length = 0;
App.videos.applyMasonryLayout(); colBottoms = [];
}); const el = grid();
if (el) { el.innerHTML = ''; el.style.height = '0px'; }
}; };
return { ensureInit, packFrom, update: scheduleUpdate, relayout, reset };
})();
App.videos.updateLoadMoreState = function() { App.videos.updateLoadMoreState = function() {
const loadMoreBtn = document.getElementById('load-more-btn'); const loadMoreBtn = document.getElementById('load-more-btn');
if (!loadMoreBtn) return; if (!loadMoreBtn) return;