diff --git a/frontend/css/style.css b/frontend/css/style.css index 4737d80..adcc8a4 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -398,6 +398,15 @@ body.theme-light .sidebar { margin-bottom: 8px; } +/* Explanatory line under a control -- and where the import reports back. Not a + `label`, so it keeps sentence case and normal letter spacing. */ +.setting-note { + margin: 8px 0 0; + font-size: 12px; + line-height: 1.45; + color: var(--text-secondary); +} + .setting-label-row label { margin-bottom: 0; } diff --git a/frontend/index.html b/frontend/index.html index c6238fc..74b0944 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -133,6 +133,17 @@
+ @@ -191,6 +202,8 @@ + + diff --git a/frontend/js/favorites.js b/frontend/js/favorites.js index 312571b..4d97a40 100644 --- a/frontend/js/favorites.js +++ b/frontend/js/favorites.js @@ -63,10 +63,90 @@ App.favorites = App.favorites || {}; }; }; + // 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'; }; @@ -85,10 +165,12 @@ App.favorites = App.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; - if (!key) return; - App.favorites.setButtonState(button, favoritesSet.has(key)); + const urlKey = App.favorites.urlKey(button.dataset.favUrl); + if (!key && !urlKey) return; + App.favorites.setButtonState(button, (key && favoritesSet.has(key)) || (urlKey && favoriteUrls.has(urlKey))); }); }; @@ -96,7 +178,9 @@ App.favorites = App.favorites || {}; const key = App.favorites.getKey(video); if (!key) return; const favorites = App.favorites.getAll(); - const existingIndex = favorites.findIndex((item) => item.key === key); + // 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); @@ -140,7 +224,7 @@ App.favorites = App.favorites || {}; const liveBadge = item.isLive ? '● LIVE' : ''; card.innerHTML = ` ${liveBadge} - +