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 []; // Favorites saved by older versions carry a `meta` blob of resolved // formats whose URLs are signed and long expired. Drop it on the way // in so no code path can reach for one; everything re-resolves from // `url` at play time, and normalize() no longer stores it. return parsed.map((item) => { if (item && typeof item === 'object' && item.meta) { const clean = Object.assign({}, item); delete clean.meta; return clean; } return item; }); } catch (err) { return []; } }; 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)) // 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 }); }); } }; 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.getAll(); const visible = App.favorites.isVisible(); bar.style.display = visible ? 'block' : 'none'; list.innerHTML = ""; favorites.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}