some more features and fixes (title and show info)

This commit is contained in:
Simon
2026-09-07 11:50:45 +00:00
parent 0009574b77
commit e2632c962d
9 changed files with 363 additions and 109 deletions

View File

@@ -134,10 +134,12 @@ def impersonate_get(url, **kwargs):
_discard_session(sess) _discard_session(sess)
raise 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 # `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. # `live` is purely a playback hint and must not leak upstream as a header. `full`
STREAM_RESERVED_PARAMS = {'url', 'live'} # 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 # Headers that affect the transport layer rather than the resource itself; allowing
# these to be forwarded could enable request smuggling or vhost-routing abuse. # these to be forwarded could enable request smuggling or vhost-routing abuse.
STREAM_DISALLOWED_HEADER_NAMES = {'host', 'content-length', 'transfer-encoding', 'connection', 'expect'} 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', 'fps', 'vcodec', 'acodec', 'ext', 'video_ext', 'quality',
'protocol', 'format_note') '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 # 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 # 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 # 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 """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 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 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': if request.method == 'POST':
source = request.json or {} source = request.json or {}
video_url = source.get('url') video_url = source.get('url')
@@ -365,11 +391,19 @@ def resolve_video():
if not video_url: if not video_url:
return jsonify({"error": "No URL provided"}), 400 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() now = time.time()
with _resolve_cache_lock: with _resolve_cache_lock:
cached = _resolve_cache.get(video_url) cached = _resolve_cache.get(video_url)
if cached and cached[0] > now: if cached and cached[0] > now:
return jsonify(cached[1]) return jsonify(view_of(cached[1]))
ydl_opts = { ydl_opts = {
'quiet': True, 'quiet': True,
@@ -386,6 +420,10 @@ def resolve_video():
try: try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=False) 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: except Exception as e:
# Many channels point at sites yt-dlp can't extract ("Unsupported URL"). # 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 # That's not fatal here -- the embed fallback below may still find a
@@ -400,26 +438,17 @@ def resolve_video():
if embed: if embed:
info = embed info = embed
formats = [] # The extraction is cached whole, and each caller is served the view it
for fmt in ((info.get('formats') if info else None) or []): # asked for. A failed extraction (info is None) is cached the same way, so a
if not fmt.get('url'): # video that can't be resolved is attempted once per TTL rather than on
continue # every hover.
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,
}
with _resolve_cache_lock: with _resolve_cache_lock:
# Drop expired entries so the cache doesn't grow without bound. # 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]: for key in [k for k, v in _resolve_cache.items() if v[0] <= now]:
_resolve_cache.pop(key, None) _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']) @app.route('/api/image', methods=['GET', 'HEAD'])
def image_proxy(): def image_proxy():

View File

@@ -857,6 +857,8 @@ body.favorites-view-open .favorites-empty {
padding: 10px 12px 12px 12px; 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 { .favorite-info h4 {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
@@ -864,6 +866,20 @@ body.favorites-view-open .favorites-empty {
color: var(--text-primary); color: var(--text-primary);
font-family: var(--font-display); font-family: var(--font-display);
line-height: 1.3; 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 { .favorites-empty {
@@ -1387,6 +1403,31 @@ body.theme-light .favorite-btn {
gap: 10px; 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 { .info-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -2162,7 +2203,7 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
transform: translateX(0); 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; animation: video-title-marquee var(--marquee-duration, 10s) linear infinite;
will-change: transform; will-change: transform;
} }

View File

@@ -202,6 +202,7 @@
</div> </div>
<script src="static/js/state.js"></script> <script src="static/js/state.js"></script>
<script src="static/js/marquee.js"></script>
<script src="static/js/storage.js"></script> <script src="static/js/storage.js"></script>
<script src="static/js/customPlayer.js"></script> <script src="static/js/customPlayer.js"></script>
<script src="static/js/player.js"></script> <script src="static/js/player.js"></script>

View File

@@ -274,6 +274,61 @@ App.favorites = App.favorites || {};
const BAR_PAGE_AHEAD_PX = 800; const BAR_PAGE_AHEAD_PX = 800;
const barPage = { items: [], rendered: 0 }; 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() { App.favorites.renderBar = function() {
const bar = document.getElementById('favorites-bar'); const bar = document.getElementById('favorites-bar');
const list = document.getElementById('favorites-list'); const list = document.getElementById('favorites-list');
@@ -296,7 +351,16 @@ App.favorites = App.favorites || {};
// Assignment rather than addEventListener: renderBar runs on every // Assignment rather than addEventListener: renderBar runs on every
// favorite change, and this must not stack up handlers. // favorite change, and this must not stack up handlers.
let scrollRaf = null;
list.onscroll = () => { 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; if (barPage.rendered >= barPage.items.length) return;
const remaining = list.scrollWidth - (list.scrollLeft + list.clientWidth); const remaining = list.scrollWidth - (list.scrollLeft + list.clientWidth);
if (remaining <= BAR_PAGE_AHEAD_PX) appendBarPage(list); if (remaining <= BAR_PAGE_AHEAD_PX) appendBarPage(list);
@@ -336,7 +400,7 @@ App.favorites = App.favorites || {};
${durationText ? `<span class="video-duration">${durationText}</span>` : ''} ${durationText ? `<span class="video-duration">${durationText}</span>` : ''}
</div> </div>
<div class="favorite-info"> <div class="favorite-info">
<h4>${item.title}</h4> <h4 class="favorite-title"><span class="favorite-title-text">${item.title}</span></h4>
</div> </div>
`; `;
const thumb = card.querySelector('img'); const thumb = card.querySelector('img');
@@ -372,9 +436,10 @@ App.favorites = App.favorites || {};
showInfoBtn.onclick = (event) => { showInfoBtn.onclick = (event) => {
event.stopPropagation(); event.stopPropagation();
App.videos.closeAllMenus(); App.videos.closeAllMenus();
// Favorites deliberately store no resolved metadata, so pull // Favorites deliberately store no resolved metadata; the
// it fresh before showing the full info dump. // panel opens on what the entry holds and resolves the rest
App.videos.ensureFormats(item).then(() => App.ui.showInfo(item)); // itself.
App.ui.openInfo(item);
}; };
} }
if (downloadBtn) { if (downloadBtn) {
@@ -394,7 +459,17 @@ App.favorites = App.favorites || {};
App.videos.handleSearch(uploader); 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); list.appendChild(card);
}); });
// Widths only exist once the cards are laid out.
requestAnimationFrame(() => measureBarTitles(list));
} }
})(); })();

View File

@@ -159,26 +159,11 @@ App.feed = App.feed || {};
slide.classList.remove('is-loaded'); slide.classList.remove('is-loaded');
}; };
// Single-line feed title that scrolls horizontally when it overflows. // Single-line slide title that scrolls when it overflows. Only the active
// Driven off the overflow distance so every title scrolls at the same // slide's title is measured, so like the player it always scrolls.
// gentle speed, matching the grid-card marquee (see App.videos.measureTitle).
const measureFeedTitle = function(slide) { const measureFeedTitle = function(slide) {
if (!slide) return; if (!slide) return;
const wrap = slide.querySelector('.feed-title'); App.marquee.measure(slide.querySelector('.feed-title'), slide.querySelector('.feed-title-text'));
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');
}
}; };
const setTimelinePosition = function(slide, ratio) { const setTimelinePosition = function(slide, ratio) {

51
frontend/js/marquee.js Normal file
View File

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

View File

@@ -111,26 +111,11 @@ App.player = App.player || {};
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Title marquee (mirrors App.videos' card marquee math, scoped locally // Title marquee. Only one title is on screen here, so unlike the grid it
// since the player title isn't a `.video-card`). // always scrolls when it overflows -- there's nothing to pick between.
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
function measureTitle() { function measureTitle() {
const wrap = q('.cp-title'); App.marquee.measure(q('.cp-title'), q('.cp-title-text'));
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');
}
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------

View File

@@ -66,61 +66,119 @@ App.ui = App.ui || {};
}, 4000); }, 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'); const modal = document.getElementById('info-modal');
if (!modal) return; if (!modal) return;
const opts = options || {};
const title = document.getElementById('info-title'); const title = document.getElementById('info-title');
const list = document.getElementById('info-list'); const list = document.getElementById('info-list');
const empty = document.getElementById('info-empty'); const empty = document.getElementById('info-empty');
const data = video && video.meta ? video.meta : video; const item = (video && typeof video === 'object') ? video : {};
const titleText = data && data.title ? data.title : 'Video Info'; // `meta` is the trimmed playback payload; the full extractor info is a
if (title) title.textContent = titleText; // 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) { if (list) {
list.innerHTML = ""; 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 (resolved && typeof resolved === 'object') {
if (data && typeof data === 'object') { appendInfoHeading(list, opts.info ? 'Extractor' : 'Resolved');
Object.entries(data).forEach(([key, value]) => { rows += appendInfoRows(list, resolved);
if (!list) return; }
const row = document.createElement('div');
row.className = 'info-row';
const label = document.createElement('span'); if (opts.pending) {
label.className = 'info-label'; const pending = document.createElement('div');
label.textContent = key; pending.className = 'info-pending';
pending.textContent = 'Resolving full metadata…';
let valueNode; list.appendChild(pending);
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 (empty) { if (empty) {
empty.style.display = hasRows ? 'none' : 'block'; empty.style.display = rows ? 'none' : 'block';
} }
modal.classList.add('open'); modal.classList.add('open');
modal.setAttribute('aria-hidden', 'false'); 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() { App.ui.closeInfo = function() {
const modal = document.getElementById('info-modal'); const modal = document.getElementById('info-modal');
if (!modal) return; if (!modal) return;
infoVideo = null;
modal.classList.remove('open'); modal.classList.remove('open');
modal.setAttribute('aria-hidden', 'true'); modal.setAttribute('aria-hidden', 'true');
}; };

View File

@@ -52,7 +52,8 @@ App.videos = App.videos || {};
}; };
const updateTitleActive = function(card) { 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'); if (card) card.classList.remove('is-title-active');
return; return;
} }
@@ -68,25 +69,13 @@ App.videos = App.videos || {};
const titleWrap = card.querySelector('.video-title'); const titleWrap = card.querySelector('.video-title');
const titleText = card.querySelector('.video-title-text'); const titleText = card.querySelector('.video-title-text');
if (!titleWrap || !titleText) return; if (!titleWrap || !titleText) return;
const overflow = titleText.scrollWidth - titleWrap.clientWidth; if (App.marquee.measure(titleWrap, titleText)) {
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`);
// Only marquee cards need the scroll-position observer that picks the // Only marquee cards need the scroll-position observer that picks the
// centered card to animate; observing every card made scrolling a // centered card to animate; observing every card made scrolling a
// large grid needlessly expensive. // large grid needlessly expensive.
if (titleObserver) titleObserver.observe(card); if (titleObserver) titleObserver.observe(card);
} else { } else {
card.classList.remove('has-marquee', 'is-title-active'); card.classList.remove('is-title-active');
titleText.style.removeProperty('--marquee-distance');
if (titleObserver) { if (titleObserver) {
titleObserver.unobserve(card); titleObserver.unobserve(card);
titleVisibility.delete(card); titleVisibility.delete(card);
@@ -548,7 +537,7 @@ App.videos = App.videos || {};
if (showInfoBtn) { if (showInfoBtn) {
showInfoBtn.onclick = (event) => { showInfoBtn.onclick = (event) => {
event.stopPropagation(); event.stopPropagation();
App.ui.showInfo(v); App.ui.openInfo(v);
App.videos.closeAllMenus(); 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) => { const probeObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (!entry.isIntersecting) return; if (!entry.isIntersecting) return;