From 1dbac3335942cc90b7936c06d54fa2ab789f843b Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 8 Sep 2026 17:04:34 +0000 Subject: [PATCH] Recognise a favorite by its URL, and take the listing's copy of it The same video reaches this client under two identities: saved from a card it carries the server's id, imported from a Hot Tub backup it carries only its URL. indexOfEntry already matched on either, but the grid card and the player asked only whether the key was known -- so an imported favorite left its own listing card, and the player, showing an empty heart. All three surfaces now ask one question. Once matched, the listing's copy is the better one: it has the id the cards key on, and a thumbnail URL that hasn't been sitting in localStorage since whenever the backup was taken. So a page of listing videos rewrites the favorites it matches, keeping only the date each was first saved -- the one fact the listing doesn't know, and the one the sort depends on. Nothing is written when nothing differs. Both identity sets now come from one cache dropped on write, since this is read once per card built and per layout probe, and each read was re-parsing the whole favorites list out of localStorage. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd --- frontend/js/favorites.js | 99 +++++++++++++++++++++++++++++++++++++--- frontend/js/feed.js | 2 +- frontend/js/player.js | 2 +- frontend/js/videos.js | 14 +++++- 4 files changed, 106 insertions(+), 11 deletions(-) diff --git a/frontend/js/favorites.js b/frontend/js/favorites.js index b27e066..447824b 100644 --- a/frontend/js/favorites.js +++ b/frontend/js/favorites.js @@ -4,6 +4,12 @@ App.favorites = App.favorites || {}; (function() { const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY } = App.constants; + // Both identities of every favorite, in one pass, held until the list + // changes (see setAll). This is read once per card built, and re-parsing the + // whole favorites list out of localStorage that often is what makes a long + // list felt on a scrolling grid. + let identityCache = null; + // Favorites storage helpers. App.favorites.getAll = function() { try { @@ -87,6 +93,7 @@ App.favorites = App.favorites || {}; }; App.favorites.setAll = function(items) { + identityCache = null; localStorage.setItem(FAVORITES_KEY, JSON.stringify(items)); }; @@ -181,19 +188,45 @@ App.favorites = App.favorites || {}; return { added, skipped, total: favorites.length }; }; + const identities = function() { + if (!identityCache) { + const keys = new Set(); + const urls = new Set(); + // getAll may rewrite the list (the repair pass), which clears the + // cache -- so build it only after that has run. + const items = App.favorites.getAll(); + items.forEach((item) => { + if (!item) return; + if (item.key) keys.add(item.key); + const urlKey = App.favorites.urlKey(item.url); + if (urlKey) urls.add(urlKey); + }); + identityCache = { keys: keys, urls: urls }; + } + return identityCache; + }; + App.favorites.getSet = function() { - return new Set(App.favorites.getAll().map((item) => item.key)); + return identities().keys; }; // 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; + return identities().urls; + }; + + // Is this video a favorite, under either identity? A video reaches us from + // the listing keyed by the server's id and from a Hot Tub backup keyed by + // its URL, and the same video must light up its heart whichever way the + // copy on disk got there. + App.favorites.has = function(video) { + const index = identities(); + const key = App.favorites.getKey(video); + if (key && index.keys.has(key)) return true; + const meta = (video && video.meta) || video || {}; + const urlKey = App.favorites.urlKey(video && (video.url || meta.url)); + return !!(urlKey && index.urls.has(urlKey)); }; // Is this video already a favorite, whichever way it got saved? Checked by @@ -209,6 +242,58 @@ App.favorites = App.favorites || {}; return favorites.findIndex((item) => item && App.favorites.urlKey(item.url) === urlKey); }; + // Everything an entry carries, so a change can be told from a no-op. + const ENTRY_FIELDS = ['key', 'id', 'url', 'title', 'thumb', 'channel', + 'uploader', 'duration', 'isLive', 'favoriteDate']; + + const sameEntry = function(a, b) { + return ENTRY_FIELDS.every((field) => + String(a[field] === undefined || a[field] === null ? '' : a[field]) === + String(b[field] === undefined || b[field] === null ? '' : b[field])); + }; + + // Brings stored favorites up to date from a page of listing videos. + // + // The same video reaches this client under two identities: saved from a + // card it carries the server's id, imported from a Hot Tub backup it + // carries only its URL. Matching on the URL is what recognises them as one + // thing -- and once they are matched, the listing's copy is the better one. + // It has the id every card keys on, and a thumbnail URL that hasn't been + // sitting in localStorage since whenever the backup was taken. So the + // stored entry is replaced by it, keeping only the date it was first saved: + // that is the one fact the listing doesn't know and the favorites sort + // depends on. + // + // Writes only when something actually differs, so the steady state of + // scrolling a listing full of favorites is no writes at all. + App.favorites.reconcile = function(videos) { + const items = Array.isArray(videos) ? videos : []; + if (!items.length) return 0; + const favorites = App.favorites.getAll(); + if (!favorites.length) return 0; + + let changed = 0; + items.forEach((video) => { + if (!video || !video.url) return; + const index = App.favorites.indexOfEntry(favorites, video); + if (index < 0) return; + const existing = favorites[index]; + const upgraded = App.favorites.normalize(Object.assign({}, video, { + favoriteDate: existing.favoriteDate + })); + if (!upgraded || sameEntry(existing, upgraded)) return; + favorites[index] = upgraded; + changed++; + }); + + if (changed) { + App.favorites.setAll(favorites); + App.favorites.renderBar(); + App.favorites.syncButtons(); + } + return changed; + }; + App.favorites.isVisible = function() { return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false'; }; diff --git a/frontend/js/feed.js b/frontend/js/feed.js index 2799c6b..799a729 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -469,7 +469,7 @@ App.feed = App.feed || {}; const favBtn = slide.querySelector('.feed-fav-btn'); if (favBtn && App.favorites) { - App.favorites.setButtonState(favBtn, App.favorites.indexOfEntry(App.favorites.getAll(), v) >= 0); + App.favorites.setButtonState(favBtn, App.favorites.has(v)); favBtn.addEventListener('click', (event) => { event.stopPropagation(); App.favorites.toggle(v); diff --git a/frontend/js/player.js b/frontend/js/player.js index fd6cf0d..e2fb464 100644 --- a/frontend/js/player.js +++ b/frontend/js/player.js @@ -295,7 +295,7 @@ App.player = App.player || {}; const key = App.favorites.getKey(videoData); if (!key) { btn.hidden = true; return; } btn.dataset.favKey = key; - App.favorites.setButtonState(btn, App.favorites.getSet().has(key)); + App.favorites.setButtonState(btn, App.favorites.has(videoData)); const onClick = (event) => { event.stopPropagation(); App.favorites.toggle(videoData); diff --git a/frontend/js/videos.js b/frontend/js/videos.js index 5c7dde6..e825500 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -714,7 +714,6 @@ App.videos = App.videos || {}; // mounting so the virtualizer can create a card the moment it needs to be // on screen and throw it away once it scrolls out of the window. App.videos.buildCard = function(v, options) { - const favoritesSet = App.favorites.getSet(); const card = document.createElement('div'); card.className = 'video-card'; card.dataset.videoId = v.id; @@ -755,7 +754,9 @@ App.videos = App.videos || {}; } const favoriteBtn = card.querySelector('.favorite-btn'); if (favoriteBtn && favoriteKey) { - App.favorites.setButtonState(favoriteBtn, favoritesSet.has(favoriteKey)); + // By either identity: a favorite imported from a backup is keyed + // by its URL, not by the id this card carries. + App.favorites.setButtonState(favoriteBtn, App.favorites.has(v)); favoriteBtn.onclick = (event) => { event.stopPropagation(); App.favorites.toggle(v); @@ -847,6 +848,15 @@ App.videos = App.videos || {}; App.virtualGrid.ensureInit(); const items = videos && Array.isArray(videos.items) ? videos.items : []; + // The server's copy of a video it already has saved is the better one, + // so bring the favorites up to date from it. Skipped while the favorites + // grid is the thing being rendered -- those items *are* the favorites, + // reshaped for the grid, and reconciling them against themselves would + // only write back what they came from. + if (App.favorites && typeof App.favorites.reconcile === 'function' && + !(App.favoritesView && App.favoritesView.isActive())) { + App.favorites.reconcile(items); + } const startLen = state.loadedVideos.length; items.forEach((v) => { if (state.renderedVideoIds.has(v.id)) return;