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:
Simon
2026-09-05 14:07:56 +00:00
parent b6b17b1f52
commit 25dad88ed9
4 changed files with 217 additions and 85 deletions

View File

@@ -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();
};
}

View File

@@ -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,

View File

@@ -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;
};
})();

View File

@@ -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 (!video || typeof video !== 'object') return Promise.resolve();
return App.videos.ensureFormats(video).then((meta) => {
if (meta) App.videos.probeVideoSources(video);
});
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;
};
// Builds a proxied stream URL. Extra params other than `url` are forwarded