Files
jacuzzi/frontend/js/favorites.js
Simon 74b719b2ea Race the CDN and the proxy, for thumbnails and for playback
A thumbnail used to try the provider and only ask /api/image once that
had failed, so every hotlink-blocked host cost a wasted request per card
before anything appeared. Both routes now go out together for the first
thumbnail of a host, and the rest of the batch waits on that one answer
rather than each rediscovering it. Speed decides which image is shown;
capability decides what the host is remembered as, since the proxy tends
to win first contact merely for being same-origin -- pinning a host to it
over that would push a whole page of thumbnails through our own server.

Playback asks the same question, but per video and at play time: one
provider can spread its media over several CDNs, so there is nothing
useful to pre-compute, and the old per-card probe answered for whichever
card happened to scroll past. The direct route is now tested alongside
the proxied playback and takes over if it answers before a frame is
decoded. Whatever loses is cancelled -- the token guards stopped stale
callbacks but left their requests running, so the losing route kept
pulling bytes and the server kept an upstream connection open for them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-08 12:48:18 +00:00

476 lines
22 KiB
JavaScript

window.App = window.App || {};
App.favorites = App.favorites || {};
(function() {
const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY } = App.constants;
// 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) {
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 };
};
App.favorites.getSet = function() {
return new Set(App.favorites.getAll().map((item) => item.key));
};
// 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() {
const urls = new Set();
App.favorites.getAll().forEach((item) => {
const urlKey = item && App.favorites.urlKey(item.url);
if (urlKey) urls.add(urlKey);
});
return urls;
};
// 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);
};
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));
}
})();