diff --git a/frontend/css/style.css b/frontend/css/style.css index bf121a9..ed280e6 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -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); } diff --git a/frontend/js/videos.js b/frontend/js/videos.js index c25c09b..cd1fdcc 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -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 + ? `
` + : ''; + const liveBadge = v.isLive ? '● LIVE' : ''; + card.innerHTML = ` + ${liveBadge} + + + +${durationText}
` : ''} + `; + 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 - ? `` - : ''; - const liveBadge = v.isLive ? '● LIVE' : ''; - card.innerHTML = ` - ${liveBadge} - - - -${durationText}
` : ''} - `; - 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');