Fix expiring favorites, empty quality menus, rotation scroll jumps
Favorites persisted the *resolved* stream metadata (resolveAndProbe mutates video.meta with yt-dlp's CDN format URLs), so a favorite opened the next day replayed a dead link. They now store only the page URL and identifying fields, and ignore any stale meta left in localStorage -- playback, download and info re-resolve through the backend, which resolves a page URL live in /api/stream. That left the quality switcher empty for anything not yet resolved (favorites, cards clicked before their hover-resolve landed, feed slides), so App.videos.ensureFormats now resolves formats once per session -- cached by video id rather than per object, so any object describing the same video gets them -- and both the player and the reels feed rebuild their format menu when they arrive. Playback isn't blocked: it already starts from the page URL via the proxy. Rotating a phone also jumped the grid to a completely different place: the anchor was read inside the resize handler (by which point the browser has already moved the scroll) and asserted once, and every re-pack discarded known card heights for the 16:9 placeholder estimate. The virtualizer now tracks the anchor on every scroll pass, re-asserts it across a short settling window (ending early on a real gesture), and remembers each thumbnail's true aspect ratio so a re-pack places cards at their real heights. Verified in headless Chrome across a portrait/landscape/portrait cycle: visible videos 37-39 -> 36-40 -> 36-38, against 37-39 -> 34-37 -> 29-30 before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user