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 ? '● LIVE' : ''; card.innerHTML = ` ${liveBadge}