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

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

View File

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

View File

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

View File

@@ -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;