Browse favorites like a listing, sorted, and page them as you scroll
Favorites now carry `favoriteDate`. Ones saved before this had no way of knowing when they were saved, so they are all stamped with the moment the client first reads them -- they sort together as one batch, at the point favorites learned to keep dates. An import brings the date the other client recorded instead, so a restored library keeps its history. The bar used to build a card per favorite, which an import of several hundred made an expensive way to open the app. It now renders a screenful and appends more as the strip is scrolled. "Browse all" turns the whole grid into favorites: the same cards, the same virtualized masonry, the same infinite scroll and reels mode as a channel listing -- App.videos.loadVideos simply pages out of localStorage instead of the server while that view is open. Sort applies to the bar and the grid together: recently added (default), oldest, title, longest, shortest, and a shuffle for rediscovering a long list. Tests (scratchpad): dates backfilled onto undated favorites, the bar paging as it scrolls rather than building every card, the grid paging to the full list with zero server calls, each sort order reordering it, and the way back to the channel listing. Also measured: 515 imported favorites load with the page responsive and 24 bar cards built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
This commit is contained in:
@@ -10,23 +10,82 @@ App.favorites = App.favorites || {};
|
||||
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;
|
||||
// Two things are repaired on the way in, and written back once if
|
||||
// anything changed, so the fix happens exactly one time:
|
||||
//
|
||||
// `meta`, from older versions, is a blob of resolved formats whose
|
||||
// URLs are signed and long expired -- dropped so no code path can
|
||||
// reach for one; everything re-resolves from `url` at play time.
|
||||
//
|
||||
// `favoriteDate` didn't exist before sorting needed it. There's no
|
||||
// way to recover when an old favorite was actually saved, so it
|
||||
// gets now: they sort together, as one batch, at the point the
|
||||
// client learned to keep dates.
|
||||
let repaired = false;
|
||||
const now = new Date().toISOString();
|
||||
const items = parsed.map((item) => {
|
||||
if (!item || typeof item !== 'object') return item;
|
||||
if (!item.meta && item.favoriteDate) return item;
|
||||
const clean = Object.assign({}, item);
|
||||
delete clean.meta;
|
||||
if (!clean.favoriteDate) clean.favoriteDate = now;
|
||||
repaired = true;
|
||||
return clean;
|
||||
});
|
||||
if (repaired) App.favorites.setAll(items);
|
||||
return items;
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Sort orders offered for the favorites bar and the favorites grid.
|
||||
// `random` reshuffles on every read by design -- it's for rediscovering a
|
||||
// long list, so landing somewhere different each time is the point.
|
||||
App.favorites.SORTS = [
|
||||
{ id: 'recent', label: 'Recently added' },
|
||||
{ id: 'oldest', label: 'Oldest first' },
|
||||
{ id: 'title', label: 'Title A-Z' },
|
||||
{ id: 'longest', label: 'Longest' },
|
||||
{ id: 'shortest', label: 'Shortest' },
|
||||
{ id: 'random', label: 'Shuffle' }
|
||||
];
|
||||
App.favorites.DEFAULT_SORT = 'recent';
|
||||
|
||||
App.favorites.getSort = function() {
|
||||
const stored = localStorage.getItem(App.constants.FAVORITES_SORT_KEY);
|
||||
return App.favorites.SORTS.some((sort) => sort.id === stored) ? stored : App.favorites.DEFAULT_SORT;
|
||||
};
|
||||
|
||||
App.favorites.setSort = function(sort) {
|
||||
localStorage.setItem(App.constants.FAVORITES_SORT_KEY, sort);
|
||||
};
|
||||
|
||||
const dateValue = function(item) {
|
||||
const parsed = Date.parse((item && item.favoriteDate) || '');
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
|
||||
App.favorites.sorted = function(sort) {
|
||||
const items = App.favorites.getAll();
|
||||
const mode = sort || App.favorites.getSort();
|
||||
if (mode === 'random') {
|
||||
for (let i = items.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[items[i], items[j]] = [items[j], items[i]];
|
||||
}
|
||||
return items;
|
||||
}
|
||||
const comparators = {
|
||||
recent: (a, b) => dateValue(b) - dateValue(a),
|
||||
oldest: (a, b) => dateValue(a) - dateValue(b),
|
||||
title: (a, b) => String(a.title || '').localeCompare(String(b.title || ''), undefined, { sensitivity: 'base' }),
|
||||
longest: (a, b) => (Number(b.duration) || 0) - (Number(a.duration) || 0),
|
||||
shortest: (a, b) => (Number(a.duration) || 0) - (Number(b.duration) || 0)
|
||||
};
|
||||
return items.sort(comparators[mode] || comparators.recent);
|
||||
};
|
||||
|
||||
App.favorites.setAll = function(items) {
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items));
|
||||
};
|
||||
@@ -53,7 +112,10 @@ App.favorites = App.favorites || {};
|
||||
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))
|
||||
isLive: !!(video.isLive || (meta && meta.isLive)),
|
||||
// When it was saved. An import carries the date the other client
|
||||
// recorded; anything saved here is saved now.
|
||||
favoriteDate: video.favoriteDate || new Date().toISOString()
|
||||
// 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
|
||||
@@ -202,18 +264,50 @@ App.favorites = App.favorites || {};
|
||||
}
|
||||
};
|
||||
|
||||
// The bar is a horizontal strip, and a long favorites list is hundreds of
|
||||
// cards. Only a screenful or so is built up front; the rest arrives as the
|
||||
// strip is scrolled, which keeps opening the app cheap no matter how many
|
||||
// favorites are saved (an import can add hundreds at once).
|
||||
const BAR_PAGE_SIZE = 24;
|
||||
// How close to the right end the strip has to get before the next page is
|
||||
// appended -- roughly a screen's worth of cards ahead of the reader.
|
||||
const BAR_PAGE_AHEAD_PX = 800;
|
||||
const barPage = { items: [], rendered: 0 };
|
||||
|
||||
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';
|
||||
const favorites = App.favorites.sorted();
|
||||
// While the favorites grid is open the bar is kept mounted even if it's
|
||||
// switched off in settings: its header is what leads back out.
|
||||
const browsing = !!(App.favoritesView && App.favoritesView.isActive());
|
||||
bar.style.display = (App.favorites.isVisible() || browsing) ? 'block' : 'none';
|
||||
|
||||
list.innerHTML = "";
|
||||
favorites.forEach((item) => {
|
||||
barPage.items = favorites;
|
||||
barPage.rendered = 0;
|
||||
appendBarPage(list);
|
||||
|
||||
// Assignment rather than addEventListener: renderBar runs on every
|
||||
// favorite change, and this must not stack up handlers.
|
||||
list.onscroll = () => {
|
||||
if (barPage.rendered >= barPage.items.length) return;
|
||||
const remaining = list.scrollWidth - (list.scrollLeft + list.clientWidth);
|
||||
if (remaining <= BAR_PAGE_AHEAD_PX) appendBarPage(list);
|
||||
};
|
||||
|
||||
if (empty) {
|
||||
empty.style.display = favorites.length > 0 ? 'none' : 'block';
|
||||
}
|
||||
};
|
||||
|
||||
function appendBarPage(list) {
|
||||
const slice = barPage.items.slice(barPage.rendered, barPage.rendered + BAR_PAGE_SIZE);
|
||||
barPage.rendered += slice.length;
|
||||
slice.forEach((item) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'favorite-card';
|
||||
card.dataset.favKey = item.key;
|
||||
@@ -299,9 +393,5 @@ App.favorites = App.favorites || {};
|
||||
}
|
||||
list.appendChild(card);
|
||||
});
|
||||
|
||||
if (empty) {
|
||||
empty.style.display = favorites.length > 0 ? 'none' : 'block';
|
||||
}
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user