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);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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) {
|
App.videos.renderVideos = function(videos) {
|
||||||
const grid = document.getElementById('video-grid');
|
const grid = document.getElementById('video-grid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
|
App.virtualGrid.ensureInit();
|
||||||
|
|
||||||
const items = videos && Array.isArray(videos.items) ? videos.items : [];
|
const items = videos && Array.isArray(videos.items) ? videos.items : [];
|
||||||
const favoritesSet = App.favorites.getSet();
|
const startLen = state.loadedVideos.length;
|
||||||
items.forEach(v => {
|
items.forEach((v) => {
|
||||||
if (state.renderedVideoIds.has(v.id)) return;
|
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.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,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
|
// 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 spanFor = function(itemHeight, metrics) {
|
const grid = () => document.getElementById('video-grid');
|
||||||
return Math.ceil((itemHeight + metrics.rowGap) / (metrics.rowHeight + metrics.rowGap));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Masonry placement for a single card. Each card's row span is independent
|
const measureMetrics = function() {
|
||||||
// of its siblings, so a newly loaded thumbnail only needs to re-measure its
|
const el = grid();
|
||||||
// own card -- not relayout the entire (potentially huge) grid. This is the
|
if (!el) return false;
|
||||||
// O(1) replacement for the old whole-grid pass that ran on every image load.
|
const mobile = window.matchMedia('(max-width: 768px)').matches;
|
||||||
App.videos.layoutCard = function(card) {
|
const large = window.matchMedia('(min-width: 1600px)').matches;
|
||||||
if (!card) return;
|
gap = mobile ? 12 : (large ? 24 : 16);
|
||||||
const metrics = getGridMetrics();
|
padX = mobile ? 16 : (large ? 48 : 24);
|
||||||
if (!metrics) return;
|
padY = mobile ? 16 : (large ? 32 : 24);
|
||||||
const itemHeight = card.getBoundingClientRect().height;
|
// We own positioning, so neutralize the CSS grid + padding and read
|
||||||
if (!itemHeight) return;
|
// the resulting content width (which still honors max-width:auto
|
||||||
card.style.gridRowEnd = `span ${spanFor(itemHeight, metrics)}`;
|
// 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 /
|
const signatureOf = function(v) {
|
||||||
// breakpoint), since that re-wraps titles and changes every card's height.
|
const hasTags = Array.isArray(v.tags) && v.tags.some((t) => t);
|
||||||
// Reads are batched ahead of writes so we don't thrash layout per card the
|
return [Math.round(colWidth), v.isLive ? 1 : 0, hasTags ? 1 : 0,
|
||||||
// way the old per-item read-then-write loop did.
|
v.uploader ? 1 : 0, (v.duration > 0) ? 1 : 0].join('|');
|
||||||
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)}`;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
App.videos.scheduleMasonryLayout = function() {
|
// Height of a card of `v`'s shape at the current column width. Measured
|
||||||
if (masonryRaf) cancelAnimationFrame(masonryRaf);
|
// once per shape via a hidden probe, then cached.
|
||||||
masonryRaf = requestAnimationFrame(() => {
|
const heightOf = function(v) {
|
||||||
masonryRaf = null;
|
const sig = signatureOf(v);
|
||||||
App.videos.applyMasonryLayout();
|
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() {
|
App.videos.updateLoadMoreState = function() {
|
||||||
const loadMoreBtn = document.getElementById('load-more-btn');
|
const loadMoreBtn = document.getElementById('load-more-btn');
|
||||||
|
|||||||
Reference in New Issue
Block a user