From e2632c962de1ec9e9d6e52cc5d70ca28b593326d Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 7 Sep 2026 11:50:45 +0000 Subject: [PATCH] some more features and fixes (title and show info) --- backend/main.py | 69 +++++++++++++++------- frontend/css/style.css | 43 +++++++++++++- frontend/index.html | 1 + frontend/js/favorites.js | 83 ++++++++++++++++++++++++-- frontend/js/feed.js | 21 +------ frontend/js/marquee.js | 51 ++++++++++++++++ frontend/js/player.js | 21 +------ frontend/js/ui.js | 122 +++++++++++++++++++++++++++++---------- frontend/js/videos.js | 61 +++++++++++++++----- 9 files changed, 363 insertions(+), 109 deletions(-) create mode 100644 frontend/js/marquee.js diff --git a/backend/main.py b/backend/main.py index 6633973..7519abe 100644 --- a/backend/main.py +++ b/backend/main.py @@ -134,10 +134,12 @@ def impersonate_get(url, **kwargs): _discard_session(sess) raise -# Stream params that have dedicated meaning and must never be treated as headers. +# Request params that have dedicated meaning and must never be treated as headers. # `referer` is mapped to a real Referer header by collect_passthrough_headers, but -# `live` is purely a playback hint and must not leak upstream as a header. -STREAM_RESERVED_PARAMS = {'url', 'live'} +# `live` is purely a playback hint and must not leak upstream as a header. `full` +# is /api/resolve's "give me everything" switch and is likewise ours, not the +# origin's. +STREAM_RESERVED_PARAMS = {'url', 'live', 'full'} # Headers that affect the transport layer rather than the resource itself; allowing # these to be forwarded could enable request smuggling or vhost-routing abuse. STREAM_DISALLOWED_HEADER_NAMES = {'host', 'content-length', 'transfer-encoding', 'connection', 'expect'} @@ -297,6 +299,24 @@ _RESOLVE_FORMAT_FIELDS = ('url', 'http_headers', 'height', 'width', 'tbr', 'fps', 'vcodec', 'acodec', 'ext', 'video_ext', 'quality', 'protocol', 'format_note') + +def _trim_resolve_info(info): + """The lean payload playback needs: the media URLs, the headers that make + them work, and just enough per-format detail to rank them. This is what + every hovered card asks for, so it stays small.""" + formats = [] + for fmt in ((info.get('formats') if info else None) or []): + if not fmt.get('url'): + continue + formats.append({k: fmt.get(k) for k in _RESOLVE_FORMAT_FIELDS if fmt.get(k) is not None}) + + return { + 'url': info.get('url') if info else None, + 'http_headers': (info.get('http_headers') if info else None) or {}, + 'isLive': bool(info.get('is_live')) if info else False, + 'formats': formats, + } + # Some channels surface pages that yt-dlp can't extract because the video is # embedded in a third-party JS player iframe (e.g. the xtremestream family used # by tube.perverzija.com). The player page declares its HLS playlist URL as @@ -354,7 +374,13 @@ def resolve_video(): """Resolve a page URL to its playable formats via yt-dlp and return them as JSON. The frontend calls this on demand (when a card is hovered or scrolled into view) to learn the real media URLs so it can background-probe them for - direct, proxy-free playability.""" + direct, proxy-free playability. + + `full=1` returns the extractor's whole info dict instead of the trimmed + playback payload -- everything it knows about the video (description, dates, + counts, tags, thumbnails, every format field), which is what the Show info + panel exists to display. Both views come from one extraction and one cache + entry, so asking for the full one costs no extra work upstream.""" if request.method == 'POST': source = request.json or {} video_url = source.get('url') @@ -365,11 +391,19 @@ def resolve_video(): if not video_url: return jsonify({"error": "No URL provided"}), 400 + want_full = str(source.get('full', '')).strip().lower() in ('1', 'true', 'yes', 'on') + + def view_of(info): + if not want_full: + return _trim_resolve_info(info) + # Nothing to show, but answer in the same shape rather than `null`. + return info if info else {} + now = time.time() with _resolve_cache_lock: cached = _resolve_cache.get(video_url) if cached and cached[0] > now: - return jsonify(cached[1]) + return jsonify(view_of(cached[1])) ydl_opts = { 'quiet': True, @@ -386,6 +420,10 @@ def resolve_video(): try: with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(video_url, download=False) + # The raw info dict holds objects that don't survive JSON (and + # internal `__`-prefixed bookkeeping). This is the same pass yt-dlp + # itself runs behind --dump-json. + info = ydl.sanitize_info(info, remove_private_keys=True) except Exception as e: # Many channels point at sites yt-dlp can't extract ("Unsupported URL"). # That's not fatal here -- the embed fallback below may still find a @@ -400,26 +438,17 @@ def resolve_video(): if embed: info = embed - formats = [] - for fmt in ((info.get('formats') if info else None) or []): - if not fmt.get('url'): - continue - formats.append({k: fmt.get(k) for k in _RESOLVE_FORMAT_FIELDS if fmt.get(k) is not None}) - - result = { - 'url': info.get('url') if info else None, - 'http_headers': (info.get('http_headers') if info else None) or {}, - 'isLive': bool(info.get('is_live')) if info else False, - 'formats': formats, - } - + # The extraction is cached whole, and each caller is served the view it + # asked for. A failed extraction (info is None) is cached the same way, so a + # video that can't be resolved is attempted once per TTL rather than on + # every hover. with _resolve_cache_lock: # Drop expired entries so the cache doesn't grow without bound. for key in [k for k, v in _resolve_cache.items() if v[0] <= now]: _resolve_cache.pop(key, None) - _resolve_cache[video_url] = (now + RESOLVE_CACHE_TTL, result) + _resolve_cache[video_url] = (now + RESOLVE_CACHE_TTL, info) - return jsonify(result) + return jsonify(view_of(info)) @app.route('/api/image', methods=['GET', 'HEAD']) def image_proxy(): diff --git a/frontend/css/style.css b/frontend/css/style.css index 3bfdc70..2ab8a55 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -857,6 +857,8 @@ body.favorites-view-open .favorites-empty { padding: 10px 12px 12px 12px; } +/* One line, always. A title too long for the card scrolls across it (see + App.marquee) rather than wrapping the card to an uneven height. */ .favorite-info h4 { font-size: 13px; font-weight: 600; @@ -864,6 +866,20 @@ body.favorites-view-open .favorites-empty { color: var(--text-primary); font-family: var(--font-display); line-height: 1.3; + white-space: nowrap; + overflow: hidden; + position: relative; +} + +.favorite-title-text { + display: inline-block; + padding-right: 24px; + transform: translateX(0); +} + +.favorite-card.is-title-active .favorite-title-text { + animation: video-title-marquee var(--marquee-duration, 10s) linear infinite; + will-change: transform; } .favorites-empty { @@ -1387,6 +1403,31 @@ body.theme-light .favorite-btn { gap: 10px; } +/* The panel shows every field the client has, which for a resolved video is the + extractor's whole payload -- dozens of rows. Give the list its own scroll so + the card stays inside the viewport and the close button stays put. */ +.info-list { + max-height: min(70vh, 620px); + overflow-y: auto; + padding-right: 4px; +} + +.info-section { + font-family: var(--font-display); + text-transform: uppercase; + letter-spacing: 0.8px; + font-size: 11px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border); + padding: 6px 0; +} + +.info-pending { + font-size: 12px; + color: var(--text-secondary); + padding-top: 4px; +} + .info-row { display: flex; justify-content: space-between; @@ -2162,7 +2203,7 @@ body.feed-mode-open .mode-toggle-btn .icon-svg { transform: translateX(0); } -.feed-title.is-marquee .feed-title-text { +.feed-title.has-marquee .feed-title-text { animation: video-title-marquee var(--marquee-duration, 10s) linear infinite; will-change: transform; } diff --git a/frontend/index.html b/frontend/index.html index 8bd1e9b..ac2e67f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -202,6 +202,7 @@ + diff --git a/frontend/js/favorites.js b/frontend/js/favorites.js index 2862614..2cc9b5d 100644 --- a/frontend/js/favorites.js +++ b/frontend/js/favorites.js @@ -274,6 +274,61 @@ App.favorites = App.favorites || {}; const BAR_PAGE_AHEAD_PX = 800; const barPage = { items: [], rendered: 0 }; + // Bar titles are one line that scrolls when it doesn't fit, the same as the + // grid card's. Which one scrolls follows the grid too: with a real pointer + // it's the card under it, on touch the card nearest the middle of the strip. + // Animating every overflowing title at once turns the bar into a wall of + // moving text. + const barTitleEnv = { + useHoverFocus: window.matchMedia('(hover: hover) and (pointer: fine)').matches + }; + + const setBarTitleActive = function(card, active) { + const title = card && card.querySelector('.favorite-title'); + if (!title) return; + card.classList.toggle('is-title-active', !!active && title.classList.contains('has-marquee')); + }; + + // Touch: the card nearest the centre of the visible strip is the one being + // read, so it is the one whose title scrolls. + const syncBarTitleActive = function(list) { + if (!list) return; + const listRect = list.getBoundingClientRect(); + const centre = listRect.left + listRect.width / 2; + let best = null; + let bestDistance = Infinity; + const cards = list.querySelectorAll('.favorite-card'); + cards.forEach((card) => { + const rect = card.getBoundingClientRect(); + if (rect.right <= listRect.left || rect.left >= listRect.right) return; + const distance = Math.abs((rect.left + rect.width / 2) - centre); + if (distance < bestDistance) { + bestDistance = distance; + best = card; + } + }); + cards.forEach((card) => setBarTitleActive(card, card === best)); + }; + + // Measures every card in the strip. Cheap enough to redo wholesale: a card's + // width is fixed, so this only really runs when cards are added. + const measureBarTitles = function(list) { + const target = list || document.getElementById('favorites-list'); + if (!target) return; + target.querySelectorAll('.favorite-card').forEach((card) => { + App.marquee.measure(card.querySelector('.favorite-title'), + card.querySelector('.favorite-title-text')); + }); + if (!barTitleEnv.useHoverFocus) syncBarTitleActive(target); + }; + + // The display font arrives after the first render, and it changes how wide + // every title is -- so whatever was measured against the fallback font has + // to be measured again once the real one is in. + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(() => measureBarTitles()).catch(() => {}); + } + App.favorites.renderBar = function() { const bar = document.getElementById('favorites-bar'); const list = document.getElementById('favorites-list'); @@ -296,7 +351,16 @@ App.favorites = App.favorites || {}; // Assignment rather than addEventListener: renderBar runs on every // favorite change, and this must not stack up handlers. + let scrollRaf = null; list.onscroll = () => { + // Which card is centred changes as the strip moves, but only once + // per frame is worth measuring. + if (!barTitleEnv.useHoverFocus && !scrollRaf) { + scrollRaf = requestAnimationFrame(() => { + scrollRaf = null; + syncBarTitleActive(list); + }); + } if (barPage.rendered >= barPage.items.length) return; const remaining = list.scrollWidth - (list.scrollLeft + list.clientWidth); if (remaining <= BAR_PAGE_AHEAD_PX) appendBarPage(list); @@ -336,7 +400,7 @@ App.favorites = App.favorites || {}; ${durationText ? `${durationText}` : ''}
-

${item.title}

+

${item.title}

`; const thumb = card.querySelector('img'); @@ -372,9 +436,10 @@ App.favorites = App.favorites || {}; showInfoBtn.onclick = (event) => { event.stopPropagation(); 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)); + // Favorites deliberately store no resolved metadata; the + // panel opens on what the entry holds and resolves the rest + // itself. + App.ui.openInfo(item); }; } if (downloadBtn) { @@ -394,7 +459,17 @@ App.favorites = App.favorites || {}; App.videos.handleSearch(uploader); }; } + if (barTitleEnv.useHoverFocus) { + card.addEventListener('pointerenter', () => setBarTitleActive(card, true)); + card.addEventListener('pointerleave', () => setBarTitleActive(card, false)); + } + // Keyboard: tabbing to a card's heart should reveal its whole title + // too, on touch devices as much as on desktop. + card.addEventListener('focusin', () => setBarTitleActive(card, true)); + card.addEventListener('focusout', () => setBarTitleActive(card, false)); list.appendChild(card); }); + // Widths only exist once the cards are laid out. + requestAnimationFrame(() => measureBarTitles(list)); } })(); diff --git a/frontend/js/feed.js b/frontend/js/feed.js index 4d836a8..978fb72 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -159,26 +159,11 @@ App.feed = App.feed || {}; slide.classList.remove('is-loaded'); }; - // Single-line feed title that scrolls horizontally when it overflows. - // Driven off the overflow distance so every title scrolls at the same - // gentle speed, matching the grid-card marquee (see App.videos.measureTitle). + // Single-line slide title that scrolls when it overflows. Only the active + // slide's title is measured, so like the player it always scrolls. const measureFeedTitle = function(slide) { if (!slide) return; - const wrap = slide.querySelector('.feed-title'); - const text = slide.querySelector('.feed-title-text'); - if (!wrap || !text) return; - const overflow = text.scrollWidth - wrap.clientWidth; - if (overflow > 4) { - const distance = overflow + 16; - const MARQUEE_SPEED = 28; // px per second - const duration = Math.max(6, distance / MARQUEE_SPEED); - text.style.setProperty('--marquee-distance', `${distance}px`); - text.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`); - wrap.classList.add('is-marquee'); - } else { - wrap.classList.remove('is-marquee'); - text.style.removeProperty('--marquee-distance'); - } + App.marquee.measure(slide.querySelector('.feed-title'), slide.querySelector('.feed-title-text')); }; const setTimelinePosition = function(slide, ratio) { diff --git a/frontend/js/marquee.js b/frontend/js/marquee.js new file mode 100644 index 0000000..2f97fdd --- /dev/null +++ b/frontend/js/marquee.js @@ -0,0 +1,51 @@ +window.App = window.App || {}; +App.marquee = App.marquee || {}; + +// A title is always one line. When it doesn't fit its box it scrolls sideways +// instead of wrapping or being silently cut off. +// +// Four surfaces show a title that way -- the grid card, the favorites bar card, +// the fullscreen player, the reels slide -- and they must scroll at the same +// speed to look like one app, so the measurement lives here rather than being +// written out again next to each of them. Whether a given title is *currently* +// scrolling is the caller's business (see the `is-title-active` handling in +// videos.js and favorites.js): a whole grid of marching text is unreadable, so +// most callers animate only the title the reader is actually looking at. +(function() { + // Drive the duration off the distance so every title scrolls at the same + // gentle rate rather than a fixed duration, which made longer titles whip + // past. The floor keeps short ones from snapping. + const SPEED_PX_PER_SEC = 28; + const MIN_DURATION_S = 6; + // Sub-pixel rounding isn't overflow worth animating. + const OVERFLOW_SLACK_PX = 4; + // Trailing space so the last word clears the edge before it wraps around. + const TAIL_GAP_PX = 12; + + // Measures `text` inside `wrap` and prepares the animation: sets + // --marquee-distance / --marquee-duration on `text`, and marks `wrap` with + // `has-marquee` so CSS can decide what to do about it. Returns whether the + // title overflows -- callers use that to skip the bookkeeping (scroll + // observers, hover handlers) that only scrolling titles need. + // + // Reads layout, so call it when the element is in the document and visible; + // a hidden element measures as zero-width and reports no overflow. + App.marquee.measure = function(wrap, text) { + if (!wrap || !text) return false; + + const overflow = text.scrollWidth - wrap.clientWidth; + if (overflow <= OVERFLOW_SLACK_PX) { + wrap.classList.remove('has-marquee'); + text.style.removeProperty('--marquee-distance'); + text.style.removeProperty('--marquee-duration'); + return false; + } + + const distance = overflow + TAIL_GAP_PX; + const duration = Math.max(MIN_DURATION_S, distance / SPEED_PX_PER_SEC); + text.style.setProperty('--marquee-distance', `${distance}px`); + text.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`); + wrap.classList.add('has-marquee'); + return true; + }; +})(); diff --git a/frontend/js/player.js b/frontend/js/player.js index d362967..f53ff29 100644 --- a/frontend/js/player.js +++ b/frontend/js/player.js @@ -111,26 +111,11 @@ App.player = App.player || {}; } // --------------------------------------------------------------------- - // Title marquee (mirrors App.videos' card marquee math, scoped locally - // since the player title isn't a `.video-card`). + // Title marquee. Only one title is on screen here, so unlike the grid it + // always scrolls when it overflows -- there's nothing to pick between. // --------------------------------------------------------------------- function measureTitle() { - const wrap = q('.cp-title'); - const text = q('.cp-title-text'); - if (!wrap || !text) return; - const overflow = text.scrollWidth - wrap.clientWidth; - if (overflow > 4) { - const distance = overflow + 12; - const MARQUEE_SPEED = 28; - const MARQUEE_MIN_DURATION = 6; - const duration = Math.max(MARQUEE_MIN_DURATION, distance / MARQUEE_SPEED); - text.style.setProperty('--marquee-distance', `${distance}px`); - text.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`); - wrap.classList.add('has-marquee'); - } else { - wrap.classList.remove('has-marquee'); - text.style.removeProperty('--marquee-distance'); - } + App.marquee.measure(q('.cp-title'), q('.cp-title-text')); } // --------------------------------------------------------------------- diff --git a/frontend/js/ui.js b/frontend/js/ui.js index 18c11f0..a123283 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -66,61 +66,119 @@ App.ui = App.ui || {}; }, 4000); }; - App.ui.showInfo = function(video) { + // Which video the panel is currently showing, so a slow resolve that lands + // after the user moved on doesn't redraw someone else's panel. + let infoVideo = null; + + const appendInfoHeading = function(list, label) { + const heading = document.createElement('div'); + heading.className = 'info-section'; + heading.textContent = label; + list.appendChild(heading); + }; + + // One row per field, whatever the field is. Objects and arrays are printed + // as JSON rather than summarised: the panel is the place to see exactly what + // the server said, so nothing is dropped or abbreviated here. + const appendInfoRows = function(list, data) { + let count = 0; + Object.entries(data || {}).forEach(([key, value]) => { + const row = document.createElement('div'); + row.className = 'info-row'; + + const label = document.createElement('span'); + label.className = 'info-label'; + label.textContent = key; + + let valueNode; + if (value && typeof value === 'object') { + valueNode = document.createElement('pre'); + valueNode.className = 'info-json'; + valueNode.textContent = JSON.stringify(value, null, 2); + } else { + valueNode = document.createElement('span'); + valueNode.className = 'info-value'; + valueNode.textContent = value === undefined || value === null || value === '' ? '—' : String(value); + } + + row.appendChild(label); + row.appendChild(valueNode); + list.appendChild(row); + count++; + }); + return count; + }; + + // Shows every field the client holds for a video: the listing item's own + // (id, title, uploader, duration, tags, ...) and then the extractor's, which + // arrive separately. It used to show `video.meta` *instead of* the item once + // one had been resolved, which silently hid everything the listing knew the + // moment a card had been hovered. + // `options.info` is the full extractor payload (App.videos.fetchFullInfo); + // `options.pending` notes that it's still on its way. + App.ui.showInfo = function(video, options) { const modal = document.getElementById('info-modal'); if (!modal) return; + const opts = options || {}; const title = document.getElementById('info-title'); const list = document.getElementById('info-list'); const empty = document.getElementById('info-empty'); - const data = video && video.meta ? video.meta : video; - const titleText = data && data.title ? data.title : 'Video Info'; - if (title) title.textContent = titleText; + const item = (video && typeof video === 'object') ? video : {}; + // `meta` is the trimmed playback payload; the full extractor info is a + // superset of it, so only one of the two is ever shown. + const resolved = opts.info || item.meta || null; + if (title) title.textContent = item.title || (resolved && resolved.title) || 'Video Info'; + + let rows = 0; if (list) { list.innerHTML = ""; - } + // `meta` gets its own section below rather than a row of JSON. + const own = Object.assign({}, item); + delete own.meta; + rows += appendInfoRows(list, own); - let hasRows = false; - if (data && typeof data === 'object') { - Object.entries(data).forEach(([key, value]) => { - if (!list) return; - const row = document.createElement('div'); - row.className = 'info-row'; + if (resolved && typeof resolved === 'object') { + appendInfoHeading(list, opts.info ? 'Extractor' : 'Resolved'); + rows += appendInfoRows(list, resolved); + } - const label = document.createElement('span'); - label.className = 'info-label'; - label.textContent = key; - - let valueNode; - if (value && typeof value === 'object') { - valueNode = document.createElement('pre'); - valueNode.className = 'info-json'; - valueNode.textContent = JSON.stringify(value, null, 2); - } else { - valueNode = document.createElement('span'); - valueNode.className = 'info-value'; - valueNode.textContent = value === undefined || value === null || value === '' ? '—' : String(value); - } - - row.appendChild(label); - row.appendChild(valueNode); - list.appendChild(row); - hasRows = true; - }); + if (opts.pending) { + const pending = document.createElement('div'); + pending.className = 'info-pending'; + pending.textContent = 'Resolving full metadata…'; + list.appendChild(pending); + } } if (empty) { - empty.style.display = hasRows ? 'none' : 'block'; + empty.style.display = rows ? 'none' : 'block'; } modal.classList.add('open'); modal.setAttribute('aria-hidden', 'false'); }; + // Opens the panel on what the client already has, then redraws it with the + // extractor's full payload once that lands. Resolution runs yt-dlp against + // the source site and can take seconds; there's no reason to stare at + // nothing (or at a spinner) while it does. + App.ui.openInfo = function(video) { + infoVideo = video; + const canResolve = !!(App.videos && typeof App.videos.fetchFullInfo === 'function'); + App.ui.showInfo(video, { pending: canResolve }); + if (!canResolve) return; + App.videos.fetchFullInfo(video).then((info) => { + if (infoVideo !== video) return; // the panel moved on, or closed + App.ui.showInfo(video, { info: info }); + }); + }; + App.ui.closeInfo = function() { const modal = document.getElementById('info-modal'); if (!modal) return; + infoVideo = null; modal.classList.remove('open'); modal.setAttribute('aria-hidden', 'true'); }; diff --git a/frontend/js/videos.js b/frontend/js/videos.js index cff77f8..0aaa947 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -52,7 +52,8 @@ App.videos = App.videos || {}; }; const updateTitleActive = function(card) { - if (!card || !card.classList.contains('has-marquee')) { + const titleWrap = card && card.querySelector('.video-title'); + if (!titleWrap || !titleWrap.classList.contains('has-marquee')) { if (card) card.classList.remove('is-title-active'); return; } @@ -68,25 +69,13 @@ App.videos = App.videos || {}; const titleWrap = card.querySelector('.video-title'); const titleText = card.querySelector('.video-title-text'); if (!titleWrap || !titleText) return; - const overflow = titleText.scrollWidth - titleWrap.clientWidth; - if (overflow > 4) { - card.classList.add('has-marquee'); - const distance = overflow + 12; - titleText.style.setProperty('--marquee-distance', `${distance}px`); - // Drive the duration off the distance so every title scrolls at the - // same gentle speed (px/sec) instead of a fixed duration that made - // longer titles whip past. A floor keeps short titles from snapping. - const MARQUEE_SPEED = 28; // px per second - const MARQUEE_MIN_DURATION = 6; // seconds - const duration = Math.max(MARQUEE_MIN_DURATION, distance / MARQUEE_SPEED); - titleText.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`); + if (App.marquee.measure(titleWrap, titleText)) { // Only marquee cards need the scroll-position observer that picks the // centered card to animate; observing every card made scrolling a // large grid needlessly expensive. if (titleObserver) titleObserver.observe(card); } else { - card.classList.remove('has-marquee', 'is-title-active'); - titleText.style.removeProperty('--marquee-distance'); + card.classList.remove('is-title-active'); if (titleObserver) { titleObserver.unobserve(card); titleVisibility.delete(card); @@ -548,7 +537,7 @@ App.videos = App.videos || {}; if (showInfoBtn) { showInfoBtn.onclick = (event) => { event.stopPropagation(); - App.ui.showInfo(v); + App.ui.openInfo(v); App.videos.closeAllMenus(); }; } @@ -1530,6 +1519,46 @@ App.videos = App.videos || {}; }); }; + // Everything the extractor knows about a video, for the Show info panel: + // description, dates, counts, categories, thumbnails, every field of every + // format. Deliberately not `ensureFormats`' payload and deliberately not + // attached to `video.meta` -- meta is fetched for every card that scrolls + // past and is kept small on purpose, and it names things playback's way + // (`isLive`) rather than the extractor's (`is_live`). Cached per video for + // the session, like meta, so reopening the panel is free. + const fullInfoCache = new Map(); + + App.videos.fetchFullInfo = function(video) { + if (!video || typeof video !== 'object' || !video.url) return Promise.resolve(null); + // Same reasoning as ensureFormats: a URL that already names a media file + // has nothing to extract, and re-fetching a signed one can burn a + // single-use link that playback still needs. + if (isDirectMediaUrl(video.url)) return Promise.resolve(null); + + const cacheKey = video.id || video.url; + let promise = fullInfoCache.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, full: '1' }) + }); + if (!response.ok) return null; + const data = await response.json(); + if (!data || typeof data !== 'object' || !Object.keys(data).length) return null; + return data; + } catch (err) { + // Best-effort: the panel still shows what the client holds. + return null; + } + })(); + fullInfoCache.set(cacheKey, promise); + } + return promise; + }; + const probeObserver = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return;