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 {
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user