diff --git a/frontend/js/favorites.js b/frontend/js/favorites.js index 56607d8..f01cf3d 100644 --- a/frontend/js/favorites.js +++ b/frontend/js/favorites.js @@ -32,14 +32,22 @@ App.favorites = App.favorites || {}; return { key, id: video.id || null, - url: video.url || '', + // The page/source URL (e.g. the YouTube watch URL), not a resolved + // CDN media URL -- those expire, so favorites must always re-resolve + // via the server at play time instead of caching a stream link. + url: video.url || (meta && meta.url) || '', title: video.title || '', thumb: video.thumb || '', channel: video.channel || (meta && meta.channel) || '', uploader: video.uploader || (meta && meta.uploader) || '', duration: video.duration || (meta && meta.duration) || 0, - isLive: !!(video.isLive || (meta && meta.isLive)), - meta: meta + isLive: !!(video.isLive || (meta && meta.isLive)) + // No `meta` field: persisting resolved formats would freeze their + // (expiring) CDN URLs into localStorage. Leaving it unset makes a + // favorite look like a fresh, unresolved listing item again, so + // playback/download/info all re-resolve through the backend from + // `url` -- see resolveStreamSources' no-formats fallback, which the + // backend resolves live via yt-dlp (main.py stream_video). }; }; @@ -145,7 +153,10 @@ App.favorites = App.favorites || {}; card.onclick = () => { if (card.classList.contains('is-loading')) return; card.classList.add('is-loading'); - App.player.open(item.meta || item, { originEl: card }); + // Ignore any stale `meta` a favorite saved before this fix may + // still carry in localStorage -- always re-resolve from `item` + // (id/url) so playback never reuses an expired stream URL. + App.player.open(item, { originEl: card }); }; const favoriteBtn = card.querySelector('.favorite-btn'); if (favoriteBtn) { @@ -167,14 +178,16 @@ App.favorites = App.favorites || {}; if (showInfoBtn) { showInfoBtn.onclick = (event) => { event.stopPropagation(); - App.ui.showInfo(item.meta || item); App.videos.closeAllMenus(); + // Favorites deliberately store no resolved metadata, so pull + // it fresh before showing the full info dump. + App.videos.ensureFormats(item).then(() => App.ui.showInfo(item)); }; } if (downloadBtn) { downloadBtn.onclick = (event) => { event.stopPropagation(); - App.videos.downloadVideo(item.meta || item); + App.videos.downloadVideo(item); App.videos.closeAllMenus(); }; } diff --git a/frontend/js/feed.js b/frontend/js/feed.js index 3aee673..8ff25ca 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -273,7 +273,7 @@ App.feed = App.feed || {}; const formatBtn = slide.querySelector('.feed-format-btn'); const formatMenu = slide.querySelector('.feed-format-menu'); - cleanups.push(App.customPlayer.bindFormatMenu(formatBtn, formatMenu, videoData, (fmt) => { + const onFormatPick = (fmt) => { slide._formatOverride = fmt; const t = video.currentTime; if (isFinite(t) && t > 0) resumeTimes.set(videoData.id, t); @@ -292,7 +292,23 @@ App.feed = App.feed || {}; video.load(); slide.classList.remove('is-loaded'); loadSlideSource(slide, videoData, true); - })); + }; + const bindFormats = () => App.customPlayer.bindFormatMenu(formatBtn, formatMenu, videoData, onFormatPick); + let destroyFormatMenu = bindFormats(); + cleanups.push(() => destroyFormatMenu()); + // A slide can go active before its formats have been resolved (feed items + // carry only a page URL until then), which would leave the quality menu + // empty. Playback already runs from that page URL through the proxy, so + // resolve in the background and rebuild the menu once the real qualities + // land -- same as the standalone player does. + if (App.videos && typeof App.videos.ensureFormats === 'function') { + App.videos.ensureFormats(videoData).then((meta) => { + // Bail if the slide was torn down (or rebound) in the meantime. + if (!meta || slide._sharedControlCleanups !== cleanups) return; + destroyFormatMenu(); + destroyFormatMenu = bindFormats(); + }); + } cleanups.push(App.customPlayer.attachGestures(slide, { onSingleTap: wakeHud, diff --git a/frontend/js/player.js b/frontend/js/player.js index 30bcb1b..ee91165 100644 --- a/frontend/js/player.js +++ b/frontend/js/player.js @@ -806,11 +806,30 @@ App.player = App.player || {}; bindFavorite(source); bindTimeline(cp.video); bindTransport(cp.video); - addCleanup(App.customPlayer.bindFormatMenu(q('.cp-format-btn'), q('.cp-format-menu'), source, (fmt) => { + + cp.source = source; + const bindFormats = () => App.customPlayer.bindFormatMenu(q('.cp-format-btn'), q('.cp-format-menu'), source, (fmt) => { cp.formatOverride = fmt; const resumeAt = cp.video.currentTime || 0; playSources(source, { resumeAt, originEl: cp.originEl }); - })); + }); + let destroyFormatMenu = bindFormats(); + addCleanup(() => destroyFormatMenu()); + // Items that arrive without formats -- a listing card clicked before its + // hover-resolve finished, or a favorite (which deliberately stores no + // resolved formats, since their URLs expire) -- would otherwise show an + // empty quality menu. Playback already starts from the page URL via the + // proxy, so resolve in the background and rebuild the menu with the real + // qualities as soon as they land. + if (App.videos && typeof App.videos.ensureFormats === 'function') { + App.videos.ensureFormats(source).then((meta) => { + // A later open() may have taken over in the meantime; its own + // bindFormats() owns the menu then. + if (!meta || cp.source !== source) return; + destroyFormatMenu(); + destroyFormatMenu = bindFormats(); + }); + } bindGestures(cp.video); bindKeyboard(cp.video); bindClose(); @@ -859,6 +878,7 @@ App.player = App.player || {}; cp.originEl = null; } cp.data = null; + cp.source = null; // voids a still-pending format resolve for this open cp.formatOverride = null; }; })(); diff --git a/frontend/js/videos.js b/frontend/js/videos.js index 40be1be..6c521b3 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -553,11 +553,14 @@ App.videos = App.videos || {}; const revealed = new Set(); // indices that have played their entrance once const layout = []; // index -> { top, left, width, height, col, posInCol } const heightCache = new Map(); // shape signature -> estimated px height + const aspectCache = new Map(); // video id -> thumbnail width/height, once loaded let colItems = []; // col -> ordered list of item indices in that column 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; + let lastAnchor = null; // reader's place, refreshed on every scroll pass + let heldAnchor = null; // anchor frozen while a resize settles const OVERSCAN = 1.2; // viewports of cards kept mounted off-screen const grid = () => document.getElementById('video-grid'); @@ -597,11 +600,10 @@ App.videos = App.videos || {}; v.uploader ? 1 : 0, (v.duration > 0) ? 1 : 0].join('|'); }; - // Estimated height of a card of `v`'s shape at the current column width, - // using the CSS 16:9 thumbnail placeholder (the image isn't loaded in the - // probe). Measured once per shape, then cached. The real height replaces - // this per-card once the thumbnail loads (see correct()). - const heightOf = function(v) { + // Height of a card of `v`'s shape at the current column width, using the + // CSS 16:9 thumbnail placeholder (the image isn't loaded in the probe). + // Measured once per shape, then cached. + const shapeHeight = function(v) { const sig = signatureOf(v); const cached = heightCache.get(sig); if (cached != null) return cached; @@ -620,6 +622,22 @@ App.videos = App.videos || {}; return h || 240; }; + // Estimated height of item `v`. Once a thumbnail has loaded we know its + // real aspect ratio (recorded in aspectCache by mount()), which is + // column-width independent -- so we swap the shape probe's 16:9 + // placeholder for the real thumbnail height instead of falling back to + // the 16:9 guess. That matters most on a re-pack (orientation change): + // without it every card above the viewport reverts to a guess, and the + // corrections trickling back in as images reload drag the page out from + // under the reader. The per-card correct() below still fixes whatever is + // left (title wrapping, tag rows). + const heightOf = function(v) { + const base = shapeHeight(v); + const aspect = (v && v.id != null) ? aspectCache.get(String(v.id)) : null; + if (!aspect) return base; + return Math.max(1, base - colWidth * 9 / 16 + colWidth / aspect); + }; + const setContainerHeight = function() { const el = grid(); if (!el) return; @@ -709,6 +727,12 @@ App.videos = App.videos || {}; const reveal = () => { img.style.aspectRatio = 'auto'; img.classList.add('is-loaded'); + // Remember the real aspect ratio so any later re-pack (see + // heightOf) can place this card at its true height straight + // away instead of re-guessing 16:9. + if (v.id != null && img.naturalWidth && img.naturalHeight) { + aspectCache.set(String(v.id), img.naturalWidth / img.naturalHeight); + } if (mounted.get(i) === card) correct(i); }; if (img.complete && img.naturalHeight > 0) requestAnimationFrame(reveal); @@ -741,12 +765,27 @@ App.videos = App.videos || {}; const viewTop = -rectTop; // viewport top in container space const start = viewTop - vh * OVERSCAN; const end = viewTop + vh * (1 + OVERSCAN); + let anchorIndex = -1; + let anchorTop = Infinity; 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); + // Topmost card still crossing the viewport top: the reader's + // place in the list, kept fresh on every scroll pass (see + // lastAnchor). + if (l.top + l.height > viewTop && l.top < anchorTop) { + anchorTop = l.top; + anchorIndex = i; + } + } + // Frozen while a resize settles: the browser moves the scroll + // position itself during a rotation, and tracking that would replace + // the reader's real place with wherever the browser landed. + if (!heldAnchor) { + lastAnchor = anchorIndex >= 0 ? { index: anchorIndex, offsetTop: anchorTop - viewTop } : null; } }; @@ -759,39 +798,26 @@ App.videos = App.videos || {}; // Full re-pack + remount, used when the column geometry changes. const relayout = function() { if (!measureMetrics()) return; + // Every card is about to move, so the raw scroll position stops + // meaning anything: hold the reader's place and put them back on it + // once the new positions exist. Applies to every re-pack, not just + // rotation -- changing card size or density moves the grid too. + const anchor = heldAnchor || lastAnchor; heightCache.clear(); // colWidth changed -> heights differ mounted.forEach((card, i) => unmount(i)); layout.length = 0; colBottoms = new Array(cols).fill(padY); colItems = Array.from({ length: cols }, () => []); packFrom(0); + restoreAnchor(anchor); update(); }; - // Records the topmost card crossing (or just below) the viewport top, - // plus how far its top sits from the viewport top. A column re-pack (on - // resize / orientation change) reassigns every card's position, so the - // raw scrollTop would otherwise point at a different video afterwards. - // Anchoring on the topmost visible card (rather than the centred one) - // is independent of the viewport height, which has *already* changed by - // the time a resize/orientation event fires -- so it stays correct even - // as portrait<->landscape swaps the height out from under us. - const captureAnchor = function() { - let anchor = null; - let bestTop = Infinity; - mounted.forEach((card, i) => { - const rect = card.getBoundingClientRect(); - if (rect.bottom <= 0) return; // fully scrolled past - if (rect.top < bestTop) { - bestTop = rect.top; - anchor = { index: i, offsetTop: rect.top }; - } - }); - return anchor; - }; - // Scrolls so the anchored card sits at the same viewport offset it had // before the re-pack, keeping the user's place across the layout change. + // The anchor is the topmost card crossing the viewport top (rather than + // the centred one), which is independent of the viewport height -- and + // that height has already changed by the time we hear about a rotation. const restoreAnchor = function(anchor) { if (!anchor) return; const el = grid(); @@ -799,27 +825,70 @@ App.videos = App.videos || {}; if (!el || !l) return; const gridTopDoc = el.getBoundingClientRect().top + window.scrollY; const target = gridTopDoc + l.top - anchor.offsetTop; - window.scrollTo(0, Math.max(0, target)); + const clamped = Math.max(0, Math.min(target, + Math.max(0, document.documentElement.scrollHeight - (window.innerHeight || 0)))); + if (Math.abs(clamped - window.scrollY) > 1) window.scrollTo(0, clamped); + }; + + // A rotation re-packs every column, so the raw scrollTop afterwards + // points at a different video. Restoring the anchor once, in the frame + // after `resize`, isn't enough on a phone: the browser fires `resize` + // partway through the rotation animation (with metrics that are still + // changing) and then adjusts the scroll position itself *after* our + // handler has run, overwriting our restore. So we hold the anchor taken + // *before* the change and re-assert it across a short settling window, + // re-packing on each tick only if the column geometry actually moved. + // The window ends early the moment the user scrolls, so we never fight + // a real gesture. + const SETTLE_TICKS = [0, 60, 150, 300, 500]; + let settleTimers = []; + + const endSettle = function() { + settleTimers.forEach(clearTimeout); + settleTimers = []; + heldAnchor = null; + }; + + const settleTick = function() { + const prevCols = cols; + const prevWidth = colWidth; + if (measureMetrics() && (cols !== prevCols || Math.abs(colWidth - prevWidth) > 0.5)) { + relayout(); // re-packs and restores the anchor + } + if (heldAnchor) restoreAnchor(heldAnchor); + }; + + // A soft keyboard opening also fires `resize` (and moves the scroll + // position deliberately, to reveal the focused field): leave that alone. + const isTypingTarget = function() { + const el = document.activeElement; + if (!el) return false; + return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable; + }; + + const beginSettle = function() { + if (isTypingTarget()) return; + if (!heldAnchor) heldAnchor = lastAnchor; + settleTimers.forEach(clearTimeout); + settleTimers = SETTLE_TICKS.map((ms) => setTimeout(settleTick, ms)); + settleTimers.push(setTimeout(endSettle, SETTLE_TICKS[SETTLE_TICKS.length - 1] + 100)); }; - let resizeRaf = null; - let pendingAnchor = null; const ensureInit = function() { if (!cols) measureMetrics(); if (initialized) return; initialized = true; window.addEventListener('scroll', scheduleUpdate, { passive: true }); - window.addEventListener('resize', () => { - // Capture before the re-pack (positions are still the old ones) - // and keep the earliest anchor across a burst of resize events. - if (!pendingAnchor) pendingAnchor = captureAnchor(); - if (resizeRaf) cancelAnimationFrame(resizeRaf); - resizeRaf = requestAnimationFrame(() => { - resizeRaf = null; - relayout(); - restoreAnchor(pendingAnchor); - pendingAnchor = null; - }); + // orientationchange fires first (before the viewport metrics change), + // which is exactly when the pre-rotation anchor is still valid. + window.addEventListener('orientationchange', beginSettle); + window.addEventListener('resize', beginSettle); + // Deliberately not on visualViewport: that also fires for pinch-zoom + // and keyboard insets, where pinning the scroll position would fight + // the user. A rotation always fires the window events above. + // Any real scroll gesture (including a pinch) ends the window early. + ['wheel', 'touchmove', 'keydown'].forEach((evt) => { + window.addEventListener(evt, endSettle, { passive: true }); }); }; @@ -1148,8 +1217,50 @@ App.videos = App.videos || {}; // video, triggered lazily by hover/scroll so we don't resolve cards the // user never looks at. const cardVideo = new WeakMap(); - const metaResolved = new Set(); - const metaPending = new Map(); + + // Session cache of `/api/resolve` results, keyed by video id (falling back + // to the page URL). Keyed rather than per-object so any *other* object + // describing the same video -- a favorites entry rebuilt from localStorage, + // a feed slide, a re-rendered card -- gets the formats attached too instead + // of silently skipping the fetch because some earlier object already ran it. + // Failures resolve to null and are cached the same way, so a broken video is + // attempted once per session. + const metaCache = new Map(); + + const hasFormats = (meta) => !!(meta && Array.isArray(meta.formats) && meta.formats.length); + + // Resolves a video's real media formats (once per session) and attaches them + // as `video.meta`. Returns the resolved meta, or null when it can't be had. + App.videos.ensureFormats = function(video) { + if (!video || typeof video !== 'object') return Promise.resolve(null); + if (hasFormats(video.meta)) return Promise.resolve(video.meta); + const cacheKey = video.id || video.url; + if (!cacheKey || !video.url) return Promise.resolve(null); + + let promise = metaCache.get(cacheKey); + if (!promise) { + promise = (async () => { + try { + const response = await fetch('/api/resolve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: video.url }) + }); + if (!response.ok) return null; + const data = await response.json(); + return hasFormats(data) ? data : null; + } catch (err) { + // Best-effort: playback still works through the proxy. + return null; + } + })(); + metaCache.set(cacheKey, promise); + } + return promise.then((data) => { + if (data) video.meta = data; + return data; + }); + }; const probeObserver = new IntersectionObserver((entries) => { entries.forEach((entry) => { @@ -1161,38 +1272,10 @@ App.videos = App.videos || {}; }, { rootMargin: '200px' }); App.videos.resolveAndProbe = function(video) { - if (!video || typeof video !== 'object' || !video.id) return Promise.resolve(); - // Already have formats (resolved earlier): just (re)probe the best one. - if (video.meta && Array.isArray(video.meta.formats) && video.meta.formats.length) { - App.videos.probeVideoSources(video); - return Promise.resolve(); - } - if (metaResolved.has(video.id)) return Promise.resolve(); - if (metaPending.has(video.id)) return metaPending.get(video.id); - if (!video.url) return Promise.resolve(); - - const promise = (async () => { - try { - const response = await fetch('/api/resolve', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: video.url }) - }); - if (!response.ok) return; - const data = await response.json(); - if (data && Array.isArray(data.formats) && data.formats.length) { - video.meta = data; - App.videos.probeVideoSources(video); - } - } catch (err) { - // Best-effort: playback still works through the proxy. - } finally { - metaResolved.add(video.id); - metaPending.delete(video.id); - } - })(); - metaPending.set(video.id, promise); - return promise; + if (!video || typeof video !== 'object') return Promise.resolve(); + return App.videos.ensureFormats(video).then((meta) => { + if (meta) App.videos.probeVideoSources(video); + }); }; // Builds a proxied stream URL. Extra params other than `url` are forwarded