The favorites bar carries the "Browse all" button and the sort control, so switching the bar off in settings hid the way in to both. The command palette now offers "Browse favorites" (or "Back to videos") and each sort order, and opening the grid re-renders the bar so its header -- the way back out -- is mounted even when settings say hidden. Tests (scratchpad): a new palette test that starts with the bar switched off, opens the grid through the palette, re-sorts through it, and returns to the listing. It caught the second half of this: opening from the palette did not re-render the bar, leaving no visible way out. Also fixes the favorites playback test, which had been wedging headless Chrome all session. It was reloading by navigating to the URL already loaded; the first evaluate after that is answered by the outgoing execution context and every one after it hangs forever. It now does its second visit in a fresh tab -- localStorage is shared per origin, so it models "next day" the same way -- and asserts what it had only been printing. It also runs hermetically now (no favorites left over from another test, CDN icons and fonts blocked) and no longer runs its whole body on import, which is what made it hijack a debugging session earlier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
401 lines
18 KiB
JavaScript
401 lines
18 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 };
|
|
|
|
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.
|
|
list.onscroll = () => {
|
|
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 src="${item.thumb}" 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>${item.title}</h4>
|
|
</div>
|
|
`;
|
|
const thumb = card.querySelector('img');
|
|
if (App.videos && typeof App.videos.attachNoReferrerRetry === 'function') {
|
|
App.videos.attachNoReferrerRetry(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, so pull
|
|
// it fresh before showing the full info dump.
|
|
App.videos.ensureFormats(item).then(() => App.ui.showInfo(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);
|
|
};
|
|
}
|
|
list.appendChild(card);
|
|
});
|
|
}
|
|
})();
|