The same video reaches this client under two identities: saved from a card it carries the server's id, imported from a Hot Tub backup it carries only its URL. indexOfEntry already matched on either, but the grid card and the player asked only whether the key was known -- so an imported favorite left its own listing card, and the player, showing an empty heart. All three surfaces now ask one question. Once matched, the listing's copy is the better one: it has the id the cards key on, and a thumbnail URL that hasn't been sitting in localStorage since whenever the backup was taken. So a page of listing videos rewrites the favorites it matches, keeping only the date each was first saved -- the one fact the listing doesn't know, and the one the sort depends on. Nothing is written when nothing differs. Both identity sets now come from one cache dropped on write, since this is read once per card built and per layout probe, and each read was re-parsing the whole favorites list out of localStorage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
561 lines
26 KiB
JavaScript
561 lines
26 KiB
JavaScript
window.App = window.App || {};
|
|
App.favorites = App.favorites || {};
|
|
|
|
(function() {
|
|
const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY } = App.constants;
|
|
|
|
// Both identities of every favorite, in one pass, held until the list
|
|
// changes (see setAll). This is read once per card built, and re-parsing the
|
|
// whole favorites list out of localStorage that often is what makes a long
|
|
// list felt on a scrolling grid.
|
|
let identityCache = null;
|
|
|
|
// Favorites storage helpers.
|
|
App.favorites.getAll = function() {
|
|
try {
|
|
const raw = localStorage.getItem(FAVORITES_KEY);
|
|
const parsed = raw ? JSON.parse(raw) : [];
|
|
if (!Array.isArray(parsed)) return [];
|
|
// Two things are repaired on the way in, and written back once if
|
|
// anything changed, so the fix happens exactly one time:
|
|
//
|
|
// `meta`, from older versions, is a blob of resolved formats whose
|
|
// URLs are signed and long expired -- dropped so no code path can
|
|
// reach for one; everything re-resolves from `url` at play time.
|
|
//
|
|
// `favoriteDate` didn't exist before sorting needed it. There's no
|
|
// way to recover when an old favorite was actually saved, so it
|
|
// gets now: they sort together, as one batch, at the point the
|
|
// client learned to keep dates.
|
|
let repaired = false;
|
|
const now = new Date().toISOString();
|
|
const items = parsed.map((item) => {
|
|
if (!item || typeof item !== 'object') return item;
|
|
if (!item.meta && item.favoriteDate) return item;
|
|
const clean = Object.assign({}, item);
|
|
delete clean.meta;
|
|
if (!clean.favoriteDate) clean.favoriteDate = now;
|
|
repaired = true;
|
|
return clean;
|
|
});
|
|
if (repaired) App.favorites.setAll(items);
|
|
return items;
|
|
} catch (err) {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
// Sort orders offered for the favorites bar and the favorites grid.
|
|
// `random` reshuffles on every read by design -- it's for rediscovering a
|
|
// long list, so landing somewhere different each time is the point.
|
|
App.favorites.SORTS = [
|
|
{ id: 'recent', label: 'Recently added' },
|
|
{ id: 'oldest', label: 'Oldest first' },
|
|
{ id: 'title', label: 'Title A-Z' },
|
|
{ id: 'longest', label: 'Longest' },
|
|
{ id: 'shortest', label: 'Shortest' },
|
|
{ id: 'random', label: 'Shuffle' }
|
|
];
|
|
App.favorites.DEFAULT_SORT = 'recent';
|
|
|
|
App.favorites.getSort = function() {
|
|
const stored = localStorage.getItem(App.constants.FAVORITES_SORT_KEY);
|
|
return App.favorites.SORTS.some((sort) => sort.id === stored) ? stored : App.favorites.DEFAULT_SORT;
|
|
};
|
|
|
|
App.favorites.setSort = function(sort) {
|
|
localStorage.setItem(App.constants.FAVORITES_SORT_KEY, sort);
|
|
};
|
|
|
|
const dateValue = function(item) {
|
|
const parsed = Date.parse((item && item.favoriteDate) || '');
|
|
return isNaN(parsed) ? 0 : parsed;
|
|
};
|
|
|
|
App.favorites.sorted = function(sort) {
|
|
const items = App.favorites.getAll();
|
|
const mode = sort || App.favorites.getSort();
|
|
if (mode === 'random') {
|
|
for (let i = items.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[items[i], items[j]] = [items[j], items[i]];
|
|
}
|
|
return items;
|
|
}
|
|
const comparators = {
|
|
recent: (a, b) => dateValue(b) - dateValue(a),
|
|
oldest: (a, b) => dateValue(a) - dateValue(b),
|
|
title: (a, b) => String(a.title || '').localeCompare(String(b.title || ''), undefined, { sensitivity: 'base' }),
|
|
longest: (a, b) => (Number(b.duration) || 0) - (Number(a.duration) || 0),
|
|
shortest: (a, b) => (Number(a.duration) || 0) - (Number(b.duration) || 0)
|
|
};
|
|
return items.sort(comparators[mode] || comparators.recent);
|
|
};
|
|
|
|
App.favorites.setAll = function(items) {
|
|
identityCache = null;
|
|
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items));
|
|
};
|
|
|
|
App.favorites.getKey = function(video) {
|
|
if (!video) return null;
|
|
const meta = video.meta || video;
|
|
return video.key || meta.key || video.id || meta.id || video.url || meta.url || null;
|
|
};
|
|
|
|
App.favorites.normalize = function(video) {
|
|
const key = App.favorites.getKey(video);
|
|
if (!key) return null;
|
|
const meta = video && video.meta ? video.meta : video;
|
|
return {
|
|
key,
|
|
id: video.id || null,
|
|
// The page/source URL (e.g. the YouTube watch URL), not a resolved
|
|
// CDN media URL -- those expire, so favorites must always re-resolve
|
|
// via the server at play time instead of caching a stream link.
|
|
url: video.url || (meta && meta.url) || '',
|
|
title: video.title || '',
|
|
thumb: video.thumb || '',
|
|
channel: video.channel || (meta && meta.channel) || '',
|
|
uploader: video.uploader || (meta && meta.uploader) || '',
|
|
duration: video.duration || (meta && meta.duration) || 0,
|
|
isLive: !!(video.isLive || (meta && meta.isLive)),
|
|
// When it was saved. An import carries the date the other client
|
|
// recorded; anything saved here is saved now.
|
|
favoriteDate: video.favoriteDate || new Date().toISOString()
|
|
// No `meta` field: persisting resolved formats would freeze their
|
|
// (expiring) CDN URLs into localStorage. Leaving it unset makes a
|
|
// favorite look like a fresh, unresolved listing item again, so
|
|
// playback/download/info all re-resolve through the backend from
|
|
// `url` -- see resolveStreamSources' no-formats fallback, which the
|
|
// backend resolves live via yt-dlp (main.py stream_video).
|
|
};
|
|
};
|
|
|
|
// Identity across sources. Favorites added here are keyed by the server's
|
|
// id; ones imported from a Hot Tub backup can only be keyed by URL (the app
|
|
// keys videos by a hash of its own). Comparing normalized URLs is what
|
|
// stops the same video being listed twice under two different keys.
|
|
App.favorites.urlKey = function(url) {
|
|
const raw = String(url || '').trim();
|
|
if (!raw) return '';
|
|
try {
|
|
const parsed = new URL(raw, window.location.href);
|
|
const host = parsed.host.replace(/^www\./i, '').toLowerCase();
|
|
const path = parsed.pathname.replace(/\/+$/, '');
|
|
return `${host}${path}${parsed.search}`;
|
|
} catch (err) {
|
|
return raw.toLowerCase();
|
|
}
|
|
};
|
|
|
|
// Adds favorites from an import, skipping any this client already has.
|
|
// Existing entries are left exactly as they are -- they carry the server id
|
|
// that makes a listing card's heart light up, which an imported entry has
|
|
// no way to know -- and new ones are appended after them.
|
|
App.favorites.mergeImported = function(entries) {
|
|
const incoming = Array.isArray(entries) ? entries : [];
|
|
const favorites = App.favorites.getAll();
|
|
const keys = new Set();
|
|
const urls = new Set();
|
|
favorites.forEach((item) => {
|
|
if (!item) return;
|
|
if (item.key) keys.add(item.key);
|
|
const urlKey = App.favorites.urlKey(item.url);
|
|
if (urlKey) urls.add(urlKey);
|
|
});
|
|
|
|
let added = 0;
|
|
let skipped = 0;
|
|
incoming.forEach((entry) => {
|
|
if (!entry || !entry.key) return;
|
|
const urlKey = App.favorites.urlKey(entry.url);
|
|
if (keys.has(entry.key) || (urlKey && urls.has(urlKey))) {
|
|
skipped++;
|
|
return;
|
|
}
|
|
keys.add(entry.key);
|
|
if (urlKey) urls.add(urlKey);
|
|
favorites.push(entry);
|
|
added++;
|
|
});
|
|
|
|
if (added) {
|
|
App.favorites.setAll(favorites);
|
|
App.favorites.renderBar();
|
|
App.favorites.syncButtons();
|
|
}
|
|
return { added, skipped, total: favorites.length };
|
|
};
|
|
|
|
const identities = function() {
|
|
if (!identityCache) {
|
|
const keys = new Set();
|
|
const urls = new Set();
|
|
// getAll may rewrite the list (the repair pass), which clears the
|
|
// cache -- so build it only after that has run.
|
|
const items = App.favorites.getAll();
|
|
items.forEach((item) => {
|
|
if (!item) return;
|
|
if (item.key) keys.add(item.key);
|
|
const urlKey = App.favorites.urlKey(item.url);
|
|
if (urlKey) urls.add(urlKey);
|
|
});
|
|
identityCache = { keys: keys, urls: urls };
|
|
}
|
|
return identityCache;
|
|
};
|
|
|
|
App.favorites.getSet = function() {
|
|
return identities().keys;
|
|
};
|
|
|
|
// Same set, addressed by URL. Imported favorites are keyed by URL rather
|
|
// than by a server id, so a listing card can only recognise one this way.
|
|
App.favorites.getUrlSet = function() {
|
|
return identities().urls;
|
|
};
|
|
|
|
// Is this video a favorite, under either identity? A video reaches us from
|
|
// the listing keyed by the server's id and from a Hot Tub backup keyed by
|
|
// its URL, and the same video must light up its heart whichever way the
|
|
// copy on disk got there.
|
|
App.favorites.has = function(video) {
|
|
const index = identities();
|
|
const key = App.favorites.getKey(video);
|
|
if (key && index.keys.has(key)) return true;
|
|
const meta = (video && video.meta) || video || {};
|
|
const urlKey = App.favorites.urlKey(video && (video.url || meta.url));
|
|
return !!(urlKey && index.urls.has(urlKey));
|
|
};
|
|
|
|
// Is this video already a favorite, whichever way it got saved? Checked by
|
|
// key first, then by URL, so a card and an imported entry for the same
|
|
// video are recognised as one thing.
|
|
App.favorites.indexOfEntry = function(favorites, video) {
|
|
const key = App.favorites.getKey(video);
|
|
const byKey = key ? favorites.findIndex((item) => item && item.key === key) : -1;
|
|
if (byKey >= 0) return byKey;
|
|
const meta = (video && video.meta) || video || {};
|
|
const urlKey = App.favorites.urlKey(video && (video.url || meta.url));
|
|
if (!urlKey) return -1;
|
|
return favorites.findIndex((item) => item && App.favorites.urlKey(item.url) === urlKey);
|
|
};
|
|
|
|
// Everything an entry carries, so a change can be told from a no-op.
|
|
const ENTRY_FIELDS = ['key', 'id', 'url', 'title', 'thumb', 'channel',
|
|
'uploader', 'duration', 'isLive', 'favoriteDate'];
|
|
|
|
const sameEntry = function(a, b) {
|
|
return ENTRY_FIELDS.every((field) =>
|
|
String(a[field] === undefined || a[field] === null ? '' : a[field]) ===
|
|
String(b[field] === undefined || b[field] === null ? '' : b[field]));
|
|
};
|
|
|
|
// Brings stored favorites up to date from a page of listing videos.
|
|
//
|
|
// The same video reaches this client under two identities: saved from a
|
|
// card it carries the server's id, imported from a Hot Tub backup it
|
|
// carries only its URL. Matching on the URL is what recognises them as one
|
|
// thing -- and once they are matched, the listing's copy is the better one.
|
|
// It has the id every card keys on, and a thumbnail URL that hasn't been
|
|
// sitting in localStorage since whenever the backup was taken. So the
|
|
// stored entry is replaced by it, keeping only the date it was first saved:
|
|
// that is the one fact the listing doesn't know and the favorites sort
|
|
// depends on.
|
|
//
|
|
// Writes only when something actually differs, so the steady state of
|
|
// scrolling a listing full of favorites is no writes at all.
|
|
App.favorites.reconcile = function(videos) {
|
|
const items = Array.isArray(videos) ? videos : [];
|
|
if (!items.length) return 0;
|
|
const favorites = App.favorites.getAll();
|
|
if (!favorites.length) return 0;
|
|
|
|
let changed = 0;
|
|
items.forEach((video) => {
|
|
if (!video || !video.url) return;
|
|
const index = App.favorites.indexOfEntry(favorites, video);
|
|
if (index < 0) return;
|
|
const existing = favorites[index];
|
|
const upgraded = App.favorites.normalize(Object.assign({}, video, {
|
|
favoriteDate: existing.favoriteDate
|
|
}));
|
|
if (!upgraded || sameEntry(existing, upgraded)) return;
|
|
favorites[index] = upgraded;
|
|
changed++;
|
|
});
|
|
|
|
if (changed) {
|
|
App.favorites.setAll(favorites);
|
|
App.favorites.renderBar();
|
|
App.favorites.syncButtons();
|
|
}
|
|
return changed;
|
|
};
|
|
|
|
App.favorites.isVisible = function() {
|
|
return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false';
|
|
};
|
|
|
|
App.favorites.setVisible = function(isVisible) {
|
|
localStorage.setItem(FAVORITES_VISIBILITY_KEY, isVisible ? 'true' : 'false');
|
|
};
|
|
|
|
// UI helpers for rendering and syncing heart states.
|
|
App.favorites.setButtonState = function(button, isFavorite) {
|
|
button.classList.toggle('is-favorite', isFavorite);
|
|
button.textContent = isFavorite ? '♥' : '♡';
|
|
button.setAttribute('aria-pressed', isFavorite ? 'true' : 'false');
|
|
button.setAttribute('aria-label', isFavorite ? 'Remove from favorites' : 'Add to favorites');
|
|
};
|
|
|
|
App.favorites.syncButtons = function() {
|
|
const favoritesSet = App.favorites.getSet();
|
|
const favoriteUrls = App.favorites.getUrlSet();
|
|
document.querySelectorAll('.favorite-btn[data-fav-key]').forEach((button) => {
|
|
const key = button.dataset.favKey;
|
|
const urlKey = App.favorites.urlKey(button.dataset.favUrl);
|
|
if (!key && !urlKey) return;
|
|
App.favorites.setButtonState(button, (key && favoritesSet.has(key)) || (urlKey && favoriteUrls.has(urlKey)));
|
|
});
|
|
};
|
|
|
|
App.favorites.toggle = function(video) {
|
|
const key = App.favorites.getKey(video);
|
|
if (!key) return;
|
|
const favorites = App.favorites.getAll();
|
|
// By key or by URL: unfavoriting a card whose video came in from a
|
|
// backup must remove that entry, not add a second one beside it.
|
|
const existingIndex = App.favorites.indexOfEntry(favorites, video);
|
|
const becameFavorite = existingIndex < 0;
|
|
if (existingIndex >= 0) {
|
|
favorites.splice(existingIndex, 1);
|
|
} else {
|
|
const entry = App.favorites.normalize(video);
|
|
if (entry) favorites.unshift(entry);
|
|
}
|
|
App.favorites.setAll(favorites);
|
|
App.favorites.renderBar();
|
|
App.favorites.syncButtons();
|
|
// Celebrate an add with a brass pop + ring on every button for this key.
|
|
if (becameFavorite) {
|
|
document.querySelectorAll(`.favorite-btn[data-fav-key="${(window.CSS && CSS.escape) ? CSS.escape(key) : key}"]`).forEach((btn) => {
|
|
btn.classList.remove('just-favorited');
|
|
void btn.offsetWidth; // restart the animation
|
|
btn.classList.add('just-favorited');
|
|
btn.addEventListener('animationend', () => btn.classList.remove('just-favorited'), { once: true });
|
|
});
|
|
}
|
|
};
|
|
|
|
// The bar is a horizontal strip, and a long favorites list is hundreds of
|
|
// cards. Only a screenful or so is built up front; the rest arrives as the
|
|
// strip is scrolled, which keeps opening the app cheap no matter how many
|
|
// favorites are saved (an import can add hundreds at once).
|
|
const BAR_PAGE_SIZE = 24;
|
|
// How close to the right end the strip has to get before the next page is
|
|
// appended -- roughly a screen's worth of cards ahead of the reader.
|
|
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');
|
|
const empty = document.getElementById('favorites-empty');
|
|
if (!bar || !list) return;
|
|
|
|
const favorites = App.favorites.sorted();
|
|
// While the favorites grid is open the bar is kept mounted even if it's
|
|
// switched off in settings: its header is what leads back out.
|
|
const browsing = !!(App.favoritesView && App.favoritesView.isActive());
|
|
bar.style.display = (App.favorites.isVisible() || browsing) ? 'block' : 'none';
|
|
|
|
list.innerHTML = "";
|
|
barPage.items = favorites;
|
|
barPage.rendered = 0;
|
|
// While the favorites grid is open the strip is hidden -- the grid is
|
|
// the same list, larger -- so don't build cards nobody can see. The
|
|
// header stays, because it carries the way back out.
|
|
if (!browsing) appendBarPage(list);
|
|
|
|
// 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);
|
|
};
|
|
|
|
if (empty) {
|
|
empty.style.display = favorites.length > 0 ? 'none' : 'block';
|
|
}
|
|
};
|
|
|
|
function appendBarPage(list) {
|
|
const slice = barPage.items.slice(barPage.rendered, barPage.rendered + BAR_PAGE_SIZE);
|
|
barPage.rendered += slice.length;
|
|
slice.forEach((item) => {
|
|
const card = document.createElement('div');
|
|
card.className = 'favorite-card';
|
|
card.dataset.favKey = item.key;
|
|
const uploaderText = item.uploader || '';
|
|
const durationText = (!item.isLive && App.videos && typeof App.videos.formatDuration === 'function')
|
|
? App.videos.formatDuration(item.duration)
|
|
: '';
|
|
const liveBadge = item.isLive ? '<span class="live-badge">● LIVE</span>' : '';
|
|
card.innerHTML = `
|
|
${liveBadge}
|
|
<button class="favorite-btn is-favorite" type="button" aria-pressed="true" aria-label="Remove from favorites" data-fav-key="${item.key}" data-fav-url="${item.url || ''}">♥</button>
|
|
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
|
|
<div class="video-menu" role="menu">
|
|
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
|
|
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
|
</div>
|
|
<div class="video-thumb">
|
|
<img alt="${item.title}" loading="lazy" decoding="async">
|
|
<div class="video-loading" aria-hidden="true">
|
|
<div class="video-loading-spinner"></div>
|
|
</div>
|
|
${uploaderText ? `<button class="video-uploader uploader-link" type="button" data-uploader="${uploaderText}">${uploaderText}</button>` : ''}
|
|
${durationText ? `<span class="video-duration">${durationText}</span>` : ''}
|
|
</div>
|
|
<div class="favorite-info">
|
|
<h4 class="favorite-title"><span class="favorite-title-text">${item.title}</span></h4>
|
|
</div>
|
|
`;
|
|
const thumb = card.querySelector('img');
|
|
if (App.videos && typeof App.videos.attachThumbnail === 'function') {
|
|
App.videos.attachThumbnail(thumb, item.thumb);
|
|
}
|
|
card.onclick = () => {
|
|
if (card.classList.contains('is-loading')) return;
|
|
card.classList.add('is-loading');
|
|
// Ignore any stale `meta` a favorite saved before this fix may
|
|
// still carry in localStorage -- always re-resolve from `item`
|
|
// (id/url) so playback never reuses an expired stream URL.
|
|
App.player.open(item, { originEl: card });
|
|
};
|
|
const favoriteBtn = card.querySelector('.favorite-btn');
|
|
if (favoriteBtn) {
|
|
favoriteBtn.onclick = (event) => {
|
|
event.stopPropagation();
|
|
App.favorites.toggle(item);
|
|
};
|
|
}
|
|
const menuBtn = card.querySelector('.video-menu-btn');
|
|
const menu = card.querySelector('.video-menu');
|
|
const showInfoBtn = card.querySelector('.video-menu-item[data-action="info"]');
|
|
const downloadBtn = card.querySelector('.video-menu-item[data-action="download"]');
|
|
if (menuBtn && menu) {
|
|
menuBtn.onclick = (event) => {
|
|
event.stopPropagation();
|
|
App.videos.toggleMenu(menu, menuBtn);
|
|
};
|
|
}
|
|
if (showInfoBtn) {
|
|
showInfoBtn.onclick = (event) => {
|
|
event.stopPropagation();
|
|
App.videos.closeAllMenus();
|
|
// 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) {
|
|
downloadBtn.onclick = (event) => {
|
|
event.stopPropagation();
|
|
App.videos.closeAllMenus();
|
|
// Same as playback: resolve a real media URL first rather
|
|
// than pointing the download at the page URL.
|
|
App.videos.ensureFormats(item).then(() => App.videos.downloadVideo(item));
|
|
};
|
|
}
|
|
const uploaderBtn = card.querySelector('.uploader-link');
|
|
if (uploaderBtn) {
|
|
uploaderBtn.onclick = (event) => {
|
|
event.stopPropagation();
|
|
const uploader = uploaderBtn.dataset.uploader || uploaderBtn.textContent || '';
|
|
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));
|
|
}
|
|
})();
|