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:
@@ -32,14 +32,22 @@ App.favorites = App.favorites || {};
|
|||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
id: video.id || null,
|
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 || '',
|
title: video.title || '',
|
||||||
thumb: video.thumb || '',
|
thumb: video.thumb || '',
|
||||||
channel: video.channel || (meta && meta.channel) || '',
|
channel: video.channel || (meta && meta.channel) || '',
|
||||||
uploader: video.uploader || (meta && meta.uploader) || '',
|
uploader: video.uploader || (meta && meta.uploader) || '',
|
||||||
duration: video.duration || (meta && meta.duration) || 0,
|
duration: video.duration || (meta && meta.duration) || 0,
|
||||||
isLive: !!(video.isLive || (meta && meta.isLive)),
|
isLive: !!(video.isLive || (meta && meta.isLive))
|
||||||
meta: meta
|
// 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 = () => {
|
card.onclick = () => {
|
||||||
if (card.classList.contains('is-loading')) return;
|
if (card.classList.contains('is-loading')) return;
|
||||||
card.classList.add('is-loading');
|
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');
|
const favoriteBtn = card.querySelector('.favorite-btn');
|
||||||
if (favoriteBtn) {
|
if (favoriteBtn) {
|
||||||
@@ -167,14 +178,16 @@ App.favorites = App.favorites || {};
|
|||||||
if (showInfoBtn) {
|
if (showInfoBtn) {
|
||||||
showInfoBtn.onclick = (event) => {
|
showInfoBtn.onclick = (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
App.ui.showInfo(item.meta || item);
|
|
||||||
App.videos.closeAllMenus();
|
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) {
|
if (downloadBtn) {
|
||||||
downloadBtn.onclick = (event) => {
|
downloadBtn.onclick = (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
App.videos.downloadVideo(item.meta || item);
|
App.videos.downloadVideo(item);
|
||||||
App.videos.closeAllMenus();
|
App.videos.closeAllMenus();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ App.feed = App.feed || {};
|
|||||||
|
|
||||||
const formatBtn = slide.querySelector('.feed-format-btn');
|
const formatBtn = slide.querySelector('.feed-format-btn');
|
||||||
const formatMenu = slide.querySelector('.feed-format-menu');
|
const formatMenu = slide.querySelector('.feed-format-menu');
|
||||||
cleanups.push(App.customPlayer.bindFormatMenu(formatBtn, formatMenu, videoData, (fmt) => {
|
const onFormatPick = (fmt) => {
|
||||||
slide._formatOverride = fmt;
|
slide._formatOverride = fmt;
|
||||||
const t = video.currentTime;
|
const t = video.currentTime;
|
||||||
if (isFinite(t) && t > 0) resumeTimes.set(videoData.id, t);
|
if (isFinite(t) && t > 0) resumeTimes.set(videoData.id, t);
|
||||||
@@ -292,7 +292,23 @@ App.feed = App.feed || {};
|
|||||||
video.load();
|
video.load();
|
||||||
slide.classList.remove('is-loaded');
|
slide.classList.remove('is-loaded');
|
||||||
loadSlideSource(slide, videoData, true);
|
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, {
|
cleanups.push(App.customPlayer.attachGestures(slide, {
|
||||||
onSingleTap: wakeHud,
|
onSingleTap: wakeHud,
|
||||||
|
|||||||
@@ -806,11 +806,30 @@ App.player = App.player || {};
|
|||||||
bindFavorite(source);
|
bindFavorite(source);
|
||||||
bindTimeline(cp.video);
|
bindTimeline(cp.video);
|
||||||
bindTransport(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;
|
cp.formatOverride = fmt;
|
||||||
const resumeAt = cp.video.currentTime || 0;
|
const resumeAt = cp.video.currentTime || 0;
|
||||||
playSources(source, { resumeAt, originEl: cp.originEl });
|
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);
|
bindGestures(cp.video);
|
||||||
bindKeyboard(cp.video);
|
bindKeyboard(cp.video);
|
||||||
bindClose();
|
bindClose();
|
||||||
@@ -859,6 +878,7 @@ App.player = App.player || {};
|
|||||||
cp.originEl = null;
|
cp.originEl = null;
|
||||||
}
|
}
|
||||||
cp.data = null;
|
cp.data = null;
|
||||||
|
cp.source = null; // voids a still-pending format resolve for this open
|
||||||
cp.formatOverride = null;
|
cp.formatOverride = null;
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -553,11 +553,14 @@ App.videos = App.videos || {};
|
|||||||
const revealed = new Set(); // indices that have played their entrance once
|
const revealed = new Set(); // indices that have played their entrance once
|
||||||
const layout = []; // index -> { top, left, width, height, col, posInCol }
|
const layout = []; // index -> { top, left, width, height, col, posInCol }
|
||||||
const heightCache = new Map(); // shape signature -> estimated px height
|
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 colItems = []; // col -> ordered list of item indices in that column
|
||||||
let colBottoms = []; // running bottom y of each column
|
let colBottoms = []; // running bottom y of each column
|
||||||
let cols = 0, colWidth = 0, gap = 16, padX = 24, padY = 24;
|
let cols = 0, colWidth = 0, gap = 16, padX = 24, padY = 24;
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let rafPending = 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 OVERSCAN = 1.2; // viewports of cards kept mounted off-screen
|
||||||
|
|
||||||
const grid = () => document.getElementById('video-grid');
|
const grid = () => document.getElementById('video-grid');
|
||||||
@@ -597,11 +600,10 @@ App.videos = App.videos || {};
|
|||||||
v.uploader ? 1 : 0, (v.duration > 0) ? 1 : 0].join('|');
|
v.uploader ? 1 : 0, (v.duration > 0) ? 1 : 0].join('|');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Estimated height of a card of `v`'s shape at the current column width,
|
// Height of a card of `v`'s shape at the current column width, using the
|
||||||
// using the CSS 16:9 thumbnail placeholder (the image isn't loaded in the
|
// CSS 16:9 thumbnail placeholder (the image isn't loaded in the probe).
|
||||||
// probe). Measured once per shape, then cached. The real height replaces
|
// Measured once per shape, then cached.
|
||||||
// this per-card once the thumbnail loads (see correct()).
|
const shapeHeight = function(v) {
|
||||||
const heightOf = function(v) {
|
|
||||||
const sig = signatureOf(v);
|
const sig = signatureOf(v);
|
||||||
const cached = heightCache.get(sig);
|
const cached = heightCache.get(sig);
|
||||||
if (cached != null) return cached;
|
if (cached != null) return cached;
|
||||||
@@ -620,6 +622,22 @@ App.videos = App.videos || {};
|
|||||||
return h || 240;
|
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 setContainerHeight = function() {
|
||||||
const el = grid();
|
const el = grid();
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -709,6 +727,12 @@ App.videos = App.videos || {};
|
|||||||
const reveal = () => {
|
const reveal = () => {
|
||||||
img.style.aspectRatio = 'auto';
|
img.style.aspectRatio = 'auto';
|
||||||
img.classList.add('is-loaded');
|
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 (mounted.get(i) === card) correct(i);
|
||||||
};
|
};
|
||||||
if (img.complete && img.naturalHeight > 0) requestAnimationFrame(reveal);
|
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 viewTop = -rectTop; // viewport top in container space
|
||||||
const start = viewTop - vh * OVERSCAN;
|
const start = viewTop - vh * OVERSCAN;
|
||||||
const end = viewTop + vh * (1 + OVERSCAN);
|
const end = viewTop + vh * (1 + OVERSCAN);
|
||||||
|
let anchorIndex = -1;
|
||||||
|
let anchorTop = Infinity;
|
||||||
for (let i = 0; i < layout.length; i++) {
|
for (let i = 0; i < layout.length; i++) {
|
||||||
const l = layout[i];
|
const l = layout[i];
|
||||||
if (!l) continue;
|
if (!l) continue;
|
||||||
const visible = l.top < end && (l.top + l.height) > start;
|
const visible = l.top < end && (l.top + l.height) > start;
|
||||||
if (visible) mount(i);
|
if (visible) mount(i);
|
||||||
else if (mounted.has(i)) unmount(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.
|
// Full re-pack + remount, used when the column geometry changes.
|
||||||
const relayout = function() {
|
const relayout = function() {
|
||||||
if (!measureMetrics()) return;
|
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
|
heightCache.clear(); // colWidth changed -> heights differ
|
||||||
mounted.forEach((card, i) => unmount(i));
|
mounted.forEach((card, i) => unmount(i));
|
||||||
layout.length = 0;
|
layout.length = 0;
|
||||||
colBottoms = new Array(cols).fill(padY);
|
colBottoms = new Array(cols).fill(padY);
|
||||||
colItems = Array.from({ length: cols }, () => []);
|
colItems = Array.from({ length: cols }, () => []);
|
||||||
packFrom(0);
|
packFrom(0);
|
||||||
|
restoreAnchor(anchor);
|
||||||
update();
|
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
|
// 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.
|
// 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) {
|
const restoreAnchor = function(anchor) {
|
||||||
if (!anchor) return;
|
if (!anchor) return;
|
||||||
const el = grid();
|
const el = grid();
|
||||||
@@ -799,27 +825,70 @@ App.videos = App.videos || {};
|
|||||||
if (!el || !l) return;
|
if (!el || !l) return;
|
||||||
const gridTopDoc = el.getBoundingClientRect().top + window.scrollY;
|
const gridTopDoc = el.getBoundingClientRect().top + window.scrollY;
|
||||||
const target = gridTopDoc + l.top - anchor.offsetTop;
|
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() {
|
const ensureInit = function() {
|
||||||
if (!cols) measureMetrics();
|
if (!cols) measureMetrics();
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
window.addEventListener('scroll', scheduleUpdate, { passive: true });
|
window.addEventListener('scroll', scheduleUpdate, { passive: true });
|
||||||
window.addEventListener('resize', () => {
|
// orientationchange fires first (before the viewport metrics change),
|
||||||
// Capture before the re-pack (positions are still the old ones)
|
// which is exactly when the pre-rotation anchor is still valid.
|
||||||
// and keep the earliest anchor across a burst of resize events.
|
window.addEventListener('orientationchange', beginSettle);
|
||||||
if (!pendingAnchor) pendingAnchor = captureAnchor();
|
window.addEventListener('resize', beginSettle);
|
||||||
if (resizeRaf) cancelAnimationFrame(resizeRaf);
|
// Deliberately not on visualViewport: that also fires for pinch-zoom
|
||||||
resizeRaf = requestAnimationFrame(() => {
|
// and keyboard insets, where pinning the scroll position would fight
|
||||||
resizeRaf = null;
|
// the user. A rotation always fires the window events above.
|
||||||
relayout();
|
// Any real scroll gesture (including a pinch) ends the window early.
|
||||||
restoreAnchor(pendingAnchor);
|
['wheel', 'touchmove', 'keydown'].forEach((evt) => {
|
||||||
pendingAnchor = null;
|
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
|
// video, triggered lazily by hover/scroll so we don't resolve cards the
|
||||||
// user never looks at.
|
// user never looks at.
|
||||||
const cardVideo = new WeakMap();
|
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) => {
|
const probeObserver = new IntersectionObserver((entries) => {
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
@@ -1161,38 +1272,10 @@ App.videos = App.videos || {};
|
|||||||
}, { rootMargin: '200px' });
|
}, { rootMargin: '200px' });
|
||||||
|
|
||||||
App.videos.resolveAndProbe = function(video) {
|
App.videos.resolveAndProbe = function(video) {
|
||||||
if (!video || typeof video !== 'object' || !video.id) return Promise.resolve();
|
if (!video || typeof video !== 'object') return Promise.resolve();
|
||||||
// Already have formats (resolved earlier): just (re)probe the best one.
|
return App.videos.ensureFormats(video).then((meta) => {
|
||||||
if (video.meta && Array.isArray(video.meta.formats) && video.meta.formats.length) {
|
if (meta) App.videos.probeVideoSources(video);
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Builds a proxied stream URL. Extra params other than `url` are forwarded
|
// Builds a proxied stream URL. Extra params other than `url` are forwarded
|
||||||
|
|||||||
Reference in New Issue
Block a user