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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
This commit is contained in:
Simon
2026-09-08 17:04:34 +00:00
parent 74b719b2ea
commit 1dbac33359
4 changed files with 106 additions and 11 deletions

View File

@@ -4,6 +4,12 @@ App.favorites = App.favorites || {};
(function() { (function() {
const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY } = App.constants; 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. // Favorites storage helpers.
App.favorites.getAll = function() { App.favorites.getAll = function() {
try { try {
@@ -87,6 +93,7 @@ App.favorites = App.favorites || {};
}; };
App.favorites.setAll = function(items) { App.favorites.setAll = function(items) {
identityCache = null;
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items)); localStorage.setItem(FAVORITES_KEY, JSON.stringify(items));
}; };
@@ -181,19 +188,45 @@ App.favorites = App.favorites || {};
return { added, skipped, total: favorites.length }; 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() { 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 // 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. // than by a server id, so a listing card can only recognise one this way.
App.favorites.getUrlSet = function() { App.favorites.getUrlSet = function() {
const urls = new Set(); return identities().urls;
App.favorites.getAll().forEach((item) => { };
const urlKey = item && App.favorites.urlKey(item.url);
if (urlKey) urls.add(urlKey); // 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
return urls; // 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 // 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); 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() { App.favorites.isVisible = function() {
return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false'; return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false';
}; };

View File

@@ -469,7 +469,7 @@ App.feed = App.feed || {};
const favBtn = slide.querySelector('.feed-fav-btn'); const favBtn = slide.querySelector('.feed-fav-btn');
if (favBtn && App.favorites) { 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) => { favBtn.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
App.favorites.toggle(v); App.favorites.toggle(v);

View File

@@ -295,7 +295,7 @@ App.player = App.player || {};
const key = App.favorites.getKey(videoData); const key = App.favorites.getKey(videoData);
if (!key) { btn.hidden = true; return; } if (!key) { btn.hidden = true; return; }
btn.dataset.favKey = key; btn.dataset.favKey = key;
App.favorites.setButtonState(btn, App.favorites.getSet().has(key)); App.favorites.setButtonState(btn, App.favorites.has(videoData));
const onClick = (event) => { const onClick = (event) => {
event.stopPropagation(); event.stopPropagation();
App.favorites.toggle(videoData); App.favorites.toggle(videoData);

View File

@@ -714,7 +714,6 @@ App.videos = App.videos || {};
// mounting so the virtualizer can create a card the moment it needs to be // 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. // on screen and throw it away once it scrolls out of the window.
App.videos.buildCard = function(v, options) { App.videos.buildCard = function(v, options) {
const favoritesSet = App.favorites.getSet();
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'video-card'; card.className = 'video-card';
card.dataset.videoId = v.id; card.dataset.videoId = v.id;
@@ -755,7 +754,9 @@ App.videos = App.videos || {};
} }
const favoriteBtn = card.querySelector('.favorite-btn'); const favoriteBtn = card.querySelector('.favorite-btn');
if (favoriteBtn && favoriteKey) { 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) => { favoriteBtn.onclick = (event) => {
event.stopPropagation(); event.stopPropagation();
App.favorites.toggle(v); App.favorites.toggle(v);
@@ -847,6 +848,15 @@ App.videos = App.videos || {};
App.virtualGrid.ensureInit(); App.virtualGrid.ensureInit();
const items = videos && Array.isArray(videos.items) ? videos.items : []; 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; const startLen = state.loadedVideos.length;
items.forEach((v) => { items.forEach((v) => {
if (state.renderedVideoIds.has(v.id)) return; if (state.renderedVideoIds.has(v.id)) return;