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);
position: relative;
margin-bottom: 0;
/* Off-screen cards are kept in the DOM (so infinite scroll and scroll
position are untouched) but the browser skips their style, layout, and
paint work. This is what keeps a grid of hundreds of cards smooth on
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;
/* The grid is virtualized in JS (see App.virtualGrid): only cards near the
viewport are in the DOM at all, and each is absolutely positioned at a
precomputed (top,left). */
}
.video-card:hover {
@@ -907,7 +902,8 @@ body.theme-light .setting-item select option {
.video-card img {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
object-fit: cover;
display: block;
background: var(--bg-tertiary);
}

View File

@@ -103,9 +103,8 @@ App.videos = App.videos || {};
document.querySelectorAll('.video-card').forEach((card) => {
measureTitle(card);
});
// Column width may have changed, re-wrapping titles and changing
// card heights, so re-run the (batched) full masonry pass.
App.videos.applyMasonryLayout();
// The virtualizer re-packs and remounts on resize via its own
// listener; nothing else to do here.
});
};
@@ -299,146 +298,146 @@ 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
// 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) {
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 || ''}">♡</button>
<button class="video-menu-btn" type="button" 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>
<img src="${v.thumb}" alt="${v.title}" loading="lazy" decoding="async">
<div class="video-loading" aria-hidden="true">
<div class="video-loading-spinner"></div>
</div>
<h4 class="video-title"><span class="video-title-text">${v.title}</span></h4>
${tagsMarkup}
${uploaderText ? `<p class="video-meta"><button class="uploader-link" type="button" data-uploader="${uploaderText}">${uploaderText}</button></p>` : ''}
${durationText ? `<p class="video-duration">${durationText}</p>` : ''}
`;
const thumb = card.querySelector('img');
App.videos.attachNoReferrerRetry(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);
};
}
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');
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 (showInfoBtn) {
showInfoBtn.onclick = (event) => {
event.stopPropagation();
App.ui.showInfo(v);
App.videos.closeAllMenus();
};
}
if (downloadBtn) {
downloadBtn.onclick = (event) => {
event.stopPropagation();
App.videos.downloadVideo(v);
App.videos.closeAllMenus();
};
}
card.onclick = () => {
if (card.classList.contains('is-loading')) return;
card.classList.add('is-loading');
App.player.open(v, { originEl: card });
};
cardVideo.set(card, v);
card.addEventListener('pointerenter', () => App.videos.resolveAndProbe(v), { once: true });
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 favoritesSet = App.favorites.getSet();
items.forEach(v => {
const startLen = state.loadedVideos.length;
items.forEach((v) => {
if (state.renderedVideoIds.has(v.id)) return;
state.loadedVideos.push(v);
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 || ''}">♡</button>
<button class="video-menu-btn" type="button" 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>
<img src="${v.thumb}" alt="${v.title}" loading="lazy" decoding="async">
<div class="video-loading" aria-hidden="true">
<div class="video-loading-spinner"></div>
</div>
<h4 class="video-title"><span class="video-title-text">${v.title}</span></h4>
${tagsMarkup}
${uploaderText ? `<p class="video-meta"><button class="uploader-link" type="button" data-uploader="${uploaderText}">${uploaderText}</button></p>` : ''}
${durationText ? `<p class="video-duration">${durationText}</p>` : ''}
`;
const thumb = card.querySelector('img');
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');
if (favoriteBtn && favoriteKey) {
App.favorites.setButtonState(favoriteBtn, favoritesSet.has(favoriteKey));
favoriteBtn.onclick = (event) => {
event.stopPropagation();
App.favorites.toggle(v);
};
}
const titleWrap = card.querySelector('.video-title');
const titleText = card.querySelector('.video-title-text');
if (titleWrap && titleText) {
requestAnimationFrame(() => {
measureTitle(card);
App.videos.layoutCard(card);
});
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, but only for cards whose title actually overflows.
}
const uploaderBtn = card.querySelector('.uploader-link');
if (uploaderBtn) {
uploaderBtn.onclick = (event) => {
event.stopPropagation();
const uploader = uploaderBtn.dataset.uploader || uploaderBtn.textContent || '';
App.videos.handleSearch(uploader);
};
}
const tagButtons = card.querySelectorAll('.video-tag');
if (tagButtons.length) {
tagButtons.forEach((tagBtn) => {
tagBtn.onclick = (event) => {
event.stopPropagation();
const tag = tagBtn.dataset.tag || tagBtn.textContent || '';
App.videos.handleSearch(tag);
};
});
}
const menuBtn = card.querySelector('.video-menu-btn');
const menu = card.querySelector('.video-menu');
const 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 (showInfoBtn) {
showInfoBtn.onclick = (event) => {
event.stopPropagation();
App.ui.showInfo(v);
App.videos.closeAllMenus();
};
}
if (downloadBtn) {
downloadBtn.onclick = (event) => {
event.stopPropagation();
App.videos.downloadVideo(v);
App.videos.closeAllMenus();
};
}
card.onclick = () => {
if (card.classList.contains('is-loading')) return;
card.classList.add('is-loading');
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);
probeObserver.observe(card);
card.addEventListener('pointerenter', () => App.videos.resolveAndProbe(v), { once: true });
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') {
App.feed.renderSlides();
}
@@ -490,8 +489,7 @@ App.videos = App.videos || {};
state.renderedVideoIds.clear();
state.loadedVideos = [];
state.groupCursors = null;
const grid = document.getElementById('video-grid');
if (grid) grid.innerHTML = "";
App.virtualGrid.reset();
if (App.feed && typeof App.feed.reset === 'function') {
App.feed.reset();
}
@@ -510,8 +508,7 @@ App.videos = App.videos || {};
state.renderedVideoIds.clear();
state.loadedVideos = [];
state.groupCursors = null;
const grid = document.getElementById('video-grid');
if (grid) grid.innerHTML = "";
App.virtualGrid.reset();
if (App.feed && typeof App.feed.reset === 'function') {
App.feed.reset();
}
@@ -529,66 +526,206 @@ 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
// it. Reading getComputedStyle per card (per image load) was a needless
// layout read on the hot path.
let cachedGridMetrics = null;
const getGridMetrics = function() {
if (cachedGridMetrics) return cachedGridMetrics;
const grid = document.getElementById('video-grid');
if (!grid) return null;
const styles = window.getComputedStyle(grid);
if (styles.display !== 'grid') return null;
const rowHeight = parseInt(styles.getPropertyValue('grid-auto-rows'), 10);
const rowGap = parseInt(styles.getPropertyValue('row-gap') || styles.getPropertyValue('gap'), 10) || 0;
if (!rowHeight) return null;
cachedGridMetrics = { rowHeight, rowGap };
return cachedGridMetrics;
};
// ---------------------------------------------------------------------
// Virtualized masonry grid
//
// The full set of loaded videos lives in state.loadedVideos. Only the cards
// whose computed position falls within the viewport (plus an overscan
// buffer) are kept in the DOM; the rest are unmounted. The container is
// given an explicit pixel height and every card is absolutely positioned at
// a precomputed (top,left), so:
// * the scrollbar and scroll position are identical to a fully-rendered
// grid, and
// * mounting/unmounting a card never moves any other card -- positions are
// assigned once and never change -- so there is no scroll jank.
//
// Card heights are deterministic (single-line title, fixed 16:9 thumbnail,
// optional tag/uploader/duration rows), so we measure one card per distinct
// "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 spanFor = function(itemHeight, metrics) {
return Math.ceil((itemHeight + metrics.rowGap) / (metrics.rowHeight + metrics.rowGap));
};
const grid = () => document.getElementById('video-grid');
// Masonry placement for a single card. Each card's row span is independent
// of its siblings, so a newly loaded thumbnail only needs to re-measure its
// own card -- not relayout the entire (potentially huge) grid. This is the
// O(1) replacement for the old whole-grid pass that ran on every image load.
App.videos.layoutCard = function(card) {
if (!card) return;
const metrics = getGridMetrics();
if (!metrics) return;
const itemHeight = card.getBoundingClientRect().height;
if (!itemHeight) return;
card.style.gridRowEnd = `span ${spanFor(itemHeight, metrics)}`;
};
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;
};
// Full relayout, used only when the column width actually changes (resize /
// breakpoint), since that re-wraps titles and changes every card's height.
// Reads are batched ahead of writes so we don't thrash layout per card the
// way the old per-item read-then-write loop did.
let masonryRaf = null;
App.videos.applyMasonryLayout = function() {
const grid = document.getElementById('video-grid');
if (!grid) return;
cachedGridMetrics = null;
const metrics = getGridMetrics();
if (!metrics) return;
const cards = Array.from(grid.children);
const heights = cards.map((item) => item.getBoundingClientRect().height);
cards.forEach((item, i) => {
if (heights[i]) item.style.gridRowEnd = `span ${spanFor(heights[i], metrics)}`;
});
};
const signatureOf = function(v) {
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('|');
};
App.videos.scheduleMasonryLayout = function() {
if (masonryRaf) cancelAnimationFrame(masonryRaf);
masonryRaf = requestAnimationFrame(() => {
masonryRaf = null;
App.videos.applyMasonryLayout();
});
};
// Height of a card of `v`'s shape at the current column width. Measured
// once per shape via a hidden probe, then cached.
const heightOf = function(v) {
const sig = signatureOf(v);
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;
probeObserver.unobserve(card);
if (titleObserver) {
titleObserver.unobserve(card);
titleVisibility.delete(card);
}
cardVideo.delete(card);
card.remove();
mounted.delete(i);
};
// Mounts cards intersecting the viewport window, unmounts the rest.
const update = function() {
const el = grid();
if (!el || !state.loadedVideos.length) return;
const rectTop = el.getBoundingClientRect().top; // container top vs viewport
const vh = window.innerHeight || 800;
const viewTop = -rectTop; // viewport top in container space
const start = viewTop - vh * OVERSCAN;
const end = viewTop + vh * (1 + OVERSCAN);
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);
}
};
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);
});
};
const reset = function() {
mounted.forEach((card, i) => unmount(i));
mounted.clear();
layout.length = 0;
colBottoms = [];
const el = grid();
if (el) { el.innerHTML = ''; el.style.height = '0px'; }
};
return { ensureInit, packFrom, update: scheduleUpdate, relayout, reset };
})();
App.videos.updateLoadMoreState = function() {
const loadMoreBtn = document.getElementById('load-more-btn');