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;