diff --git a/frontend/css/style.css b/frontend/css/style.css index 0030c6a..bf121a9 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -781,7 +781,9 @@ body.theme-light .setting-item select option { @media (max-width: 768px) { .grid-container { - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + /* Exactly two larger cards per row on phones, rather than packing in + several small ones. */ + grid-template-columns: repeat(2, 1fr); grid-auto-rows: 10px; gap: 12px; padding: 16px; @@ -887,6 +889,14 @@ 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; } .video-card:hover { @@ -924,7 +934,7 @@ body.theme-light .setting-item select option { } .video-card.is-title-active .video-title-text { - animation: video-title-marquee 10s linear infinite; + animation: video-title-marquee var(--marquee-duration, 10s) linear infinite; will-change: transform; } diff --git a/frontend/js/storage.js b/frontend/js/storage.js index 7fcb679..1f71f93 100644 --- a/frontend/js/storage.js +++ b/frontend/js/storage.js @@ -195,20 +195,36 @@ App.session = App.session || {}; const config = JSON.parse(localStorage.getItem('config')); if (!config || !config.servers) return; + const fetchDirectStatus = async (server) => { + const directUrl = `${server.replace(/\/$/, '')}/api/status`; + const response = await fetch(directUrl); + if (!response.ok) throw new Error(`Direct status request failed: ${response.status}`); + return await response.json(); + }; + + const fetchProxiedStatus = async (server) => { + const response = await fetch(`/api/status`, { + method: "POST", + body: JSON.stringify({ + server: server + }), + headers: { + "Content-Type": "application/json" + }, + }); + if (!response.ok) throw new Error(`Proxied status request failed: ${response.status}`); + return await response.json(); + }; + const statusPromises = config.servers.map(async (serverObj) => { const server = Object.keys(serverObj)[0]; try { - const response = await fetch(`/api/status`, { - method: "POST", - body: JSON.stringify({ - server: server - }), - headers: { - "Content-Type": "application/json" - }, - }); - const status = await response.json(); - serverObj[server] = status; + // Try a direct request first, then fall back to the server-side proxy. + try { + serverObj[server] = await fetchDirectStatus(server); + } catch (directErr) { + serverObj[server] = await fetchProxiedStatus(server); + } } catch (err) { serverObj[server] = { online: false, diff --git a/frontend/js/videos.js b/frontend/js/videos.js index 3dc7834..c25c09b 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -71,10 +71,26 @@ App.videos = App.videos || {}; const overflow = titleText.scrollWidth - titleWrap.clientWidth; if (overflow > 4) { card.classList.add('has-marquee'); - titleText.style.setProperty('--marquee-distance', `${overflow + 12}px`); + const distance = overflow + 12; + titleText.style.setProperty('--marquee-distance', `${distance}px`); + // Drive the duration off the distance so every title scrolls at the + // same gentle speed (px/sec) instead of a fixed duration that made + // longer titles whip past. A floor keeps short titles from snapping. + const MARQUEE_SPEED = 28; // px per second + const MARQUEE_MIN_DURATION = 6; // seconds + const duration = Math.max(MARQUEE_MIN_DURATION, distance / MARQUEE_SPEED); + titleText.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`); + // Only marquee cards need the scroll-position observer that picks the + // centered card to animate; observing every card made scrolling a + // large grid needlessly expensive. + if (titleObserver) titleObserver.observe(card); } else { card.classList.remove('has-marquee', 'is-title-active'); titleText.style.removeProperty('--marquee-distance'); + if (titleObserver) { + titleObserver.unobserve(card); + titleVisibility.delete(card); + } } updateTitleActive(card); }; @@ -87,6 +103,9 @@ 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(); }); }; @@ -322,7 +341,9 @@ App.videos = App.videos || {}; const thumb = card.querySelector('img'); App.videos.attachNoReferrerRetry(thumb); if (thumb) { - thumb.addEventListener('load', App.videos.scheduleMasonryLayout); + // 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) { @@ -337,6 +358,7 @@ App.videos = App.videos || {}; if (titleWrap && titleText) { requestAnimationFrame(() => { measureTitle(card); + App.videos.layoutCard(card); }); card.addEventListener('focusin', () => { card.dataset.titleFocused = '1'; @@ -355,9 +377,9 @@ App.videos = App.videos || {}; card.dataset.titleHovered = '0'; updateTitleActive(card); }); - } else if (titleObserver) { - titleObserver.observe(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) { @@ -415,7 +437,8 @@ App.videos = App.videos || {}; state.renderedVideoIds.add(v.id); }); - App.videos.scheduleMasonryLayout(); + // 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(); } @@ -506,29 +529,64 @@ App.videos = App.videos || {}; } }; - let masonryRaf = null; - App.videos.scheduleMasonryLayout = function() { - if (masonryRaf) { - cancelAnimationFrame(masonryRaf); - } - masonryRaf = requestAnimationFrame(() => { - masonryRaf = null; - App.videos.applyMasonryLayout(); - }); + // 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; }; + const spanFor = function(itemHeight, metrics) { + return Math.ceil((itemHeight + metrics.rowGap) / (metrics.rowHeight + metrics.rowGap)); + }; + + // 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)}`; + }; + + // 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; - const styles = window.getComputedStyle(grid); - if (styles.display !== 'grid') return; - const rowHeight = parseInt(styles.getPropertyValue('grid-auto-rows'), 10); - const rowGap = parseInt(styles.getPropertyValue('row-gap') || styles.getPropertyValue('gap'), 10) || 0; - if (!rowHeight) return; - Array.from(grid.children).forEach((item) => { - const itemHeight = item.getBoundingClientRect().height; - const span = Math.ceil((itemHeight + rowGap) / (rowHeight + rowGap)); - item.style.gridRowEnd = `span ${span}`; + 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() { + if (masonryRaf) cancelAnimationFrame(masonryRaf); + masonryRaf = requestAnimationFrame(() => { + masonryRaf = null; + App.videos.applyMasonryLayout(); }); };