some more features and fixes (title and show info)
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
</div>
|
||||
|
||||
<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/customPlayer.js"></script>
|
||||
<script src="static/js/player.js"></script>
|
||||
|
||||
@@ -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 ? `<span class="video-duration">${durationText}</span>` : ''}
|
||||
</div>
|
||||
<div class="favorite-info">
|
||||
<h4>${item.title}</h4>
|
||||
<h4 class="favorite-title"><span class="favorite-title-text">${item.title}</span></h4>
|
||||
</div>
|
||||
`;
|
||||
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));
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
51
frontend/js/marquee.js
Normal file
51
frontend/js/marquee.js
Normal 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;
|
||||
};
|
||||
})();
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -66,25 +66,23 @@ App.ui = App.ui || {};
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
App.ui.showInfo = function(video) {
|
||||
const modal = document.getElementById('info-modal');
|
||||
if (!modal) return;
|
||||
const title = document.getElementById('info-title');
|
||||
const list = document.getElementById('info-list');
|
||||
const empty = document.getElementById('info-empty');
|
||||
// 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 data = video && video.meta ? video.meta : video;
|
||||
const titleText = data && data.title ? data.title : 'Video Info';
|
||||
if (title) title.textContent = titleText;
|
||||
const appendInfoHeading = function(list, label) {
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'info-section';
|
||||
heading.textContent = label;
|
||||
list.appendChild(heading);
|
||||
};
|
||||
|
||||
if (list) {
|
||||
list.innerHTML = "";
|
||||
}
|
||||
|
||||
let hasRows = false;
|
||||
if (data && typeof data === 'object') {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (!list) return;
|
||||
// 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';
|
||||
|
||||
@@ -106,21 +104,81 @@ App.ui = App.ui || {};
|
||||
row.appendChild(label);
|
||||
row.appendChild(valueNode);
|
||||
list.appendChild(row);
|
||||
hasRows = true;
|
||||
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 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);
|
||||
|
||||
if (resolved && typeof resolved === 'object') {
|
||||
appendInfoHeading(list, opts.info ? 'Extractor' : 'Resolved');
|
||||
rows += appendInfoRows(list, resolved);
|
||||
}
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user