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;