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

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