virtual video item cards
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user