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:
@@ -775,6 +775,35 @@ body.theme-light .setting-item select option {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.favorites-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.favorites-sort {
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.favorites-actions .btn-secondary {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Browsing favorites as a grid: the bar above it would be the same list twice,
|
||||
so it collapses to its header (which carries the way back out). */
|
||||
body.favorites-view-open .favorites-list,
|
||||
body.favorites-view-open .favorites-empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.favorites-list {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -34,6 +34,10 @@
|
||||
<section id="favorites-bar" class="favorites-bar" aria-label="Favorites">
|
||||
<div class="favorites-header">
|
||||
<h3>Favorites</h3>
|
||||
<div class="favorites-actions">
|
||||
<select id="favorites-sort" class="favorites-sort" aria-label="Sort favorites"></select>
|
||||
<button id="favorites-browse-btn" class="btn-secondary" type="button" aria-pressed="false">Browse all</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="favorites-list" class="favorites-list"></div>
|
||||
<div id="favorites-empty" class="favorites-empty">No favorites yet. Tap the heart on a video to save it here.</div>
|
||||
@@ -202,6 +206,7 @@
|
||||
<script src="static/js/customPlayer.js"></script>
|
||||
<script src="static/js/player.js"></script>
|
||||
<script src="static/js/favorites.js"></script>
|
||||
<script src="static/js/favoritesView.js"></script>
|
||||
<script src="static/js/sqlite.js"></script>
|
||||
<script src="static/js/hottubBackup.js"></script>
|
||||
<script src="static/js/videos.js"></script>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
104
frontend/js/favoritesView.js
Normal file
104
frontend/js/favoritesView.js
Normal file
@@ -0,0 +1,104 @@
|
||||
window.App = window.App || {};
|
||||
App.favoritesView = App.favoritesView || {};
|
||||
|
||||
// Browsing favorites as a full grid, the same way the channel listing is
|
||||
// browsed: same cards, same virtualized masonry, same infinite scroll, same
|
||||
// reels mode. The only difference is where the pages come from -- localStorage
|
||||
// instead of the server -- so App.videos.loadVideos routes here while this view
|
||||
// is active and every page hands its slice to App.videos.renderVideos.
|
||||
(function() {
|
||||
const state = App.state;
|
||||
|
||||
// A page of a local list can be bigger than a page from the server: there's
|
||||
// no request behind it, only the cost of building cards, which the
|
||||
// virtualizer already keeps to what's on screen.
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
const view = {
|
||||
active: false,
|
||||
queue: [], // the sorted favorites still to be handed to the grid
|
||||
offset: 0
|
||||
};
|
||||
|
||||
// A favorite as the grid expects a video: `id` has to be unique per card
|
||||
// (the virtualizer and renderedVideoIds key on it), and an imported
|
||||
// favorite has no server id -- its key, which is the URL, stands in.
|
||||
const toVideo = function(entry) {
|
||||
return Object.assign({}, entry, { id: entry.key, tags: [] });
|
||||
};
|
||||
|
||||
App.favoritesView.isActive = function() {
|
||||
return view.active;
|
||||
};
|
||||
|
||||
App.favoritesView.loadNext = function() {
|
||||
if (!view.active) return false;
|
||||
const slice = view.queue.slice(view.offset, view.offset + PAGE_SIZE);
|
||||
view.offset += slice.length;
|
||||
state.hasNextPage = view.offset < view.queue.length;
|
||||
if (!slice.length) {
|
||||
App.videos.updateLoadMoreState();
|
||||
return false;
|
||||
}
|
||||
App.videos.renderVideos({ items: slice.map(toVideo) });
|
||||
App.videos.updateLoadMoreState();
|
||||
return true;
|
||||
};
|
||||
|
||||
// Starts (or restarts, after a sort change) the favorites grid.
|
||||
App.favoritesView.open = function(options) {
|
||||
const sort = (options && options.sort) || App.favorites.getSort();
|
||||
const favorites = App.favorites.sorted(sort);
|
||||
if (!favorites.length) {
|
||||
App.ui.showError('No favorites yet. Tap the heart on a video to save one.');
|
||||
return false;
|
||||
}
|
||||
App.videos.resetGrid();
|
||||
view.active = true;
|
||||
view.queue = favorites;
|
||||
view.offset = 0;
|
||||
state.hasNextPage = true;
|
||||
document.body.classList.add('favorites-view-open');
|
||||
App.favoritesView.syncControls();
|
||||
App.favoritesView.loadNext();
|
||||
window.scrollTo({ top: 0, behavior: 'auto' });
|
||||
return true;
|
||||
};
|
||||
|
||||
App.favoritesView.close = function(options) {
|
||||
if (!view.active) return;
|
||||
view.active = false;
|
||||
view.queue = [];
|
||||
view.offset = 0;
|
||||
document.body.classList.remove('favorites-view-open');
|
||||
App.favoritesView.syncControls();
|
||||
// Back to the channel listing, unless the caller is about to load
|
||||
// something itself (a search, a channel switch).
|
||||
if (!(options && options.silent)) App.videos.resetAndReload();
|
||||
};
|
||||
|
||||
App.favoritesView.toggle = function() {
|
||||
if (view.active) App.favoritesView.close();
|
||||
else App.favoritesView.open();
|
||||
};
|
||||
|
||||
// Re-pages the grid under a new order, and re-renders the bar so both show
|
||||
// favorites the same way round.
|
||||
App.favoritesView.applySort = function(sort) {
|
||||
App.favorites.setSort(sort);
|
||||
App.favorites.renderBar();
|
||||
if (view.active) App.favoritesView.open({ sort });
|
||||
};
|
||||
|
||||
App.favoritesView.syncControls = function() {
|
||||
const button = document.getElementById('favorites-browse-btn');
|
||||
if (button) {
|
||||
button.textContent = view.active ? 'Back to videos' : 'Browse all';
|
||||
button.setAttribute('aria-pressed', view.active ? 'true' : 'false');
|
||||
}
|
||||
const select = document.getElementById('favorites-sort');
|
||||
if (select && select.value !== App.favorites.getSort()) {
|
||||
select.value = App.favorites.getSort();
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -20,6 +20,15 @@ App.hottubBackup = App.hottubBackup || {};
|
||||
// (see App.favorites.normalize).
|
||||
const COLUMNS = ['url', 'title', 'thumb', 'duration', 'uploader', 'flags', 'favoriteDate'];
|
||||
|
||||
// The app writes a local-time stamp with no zone ("2025-04-25T20:26:02.144").
|
||||
// Read it as local time (which is what it was) and keep it as an instant, so
|
||||
// imported favorites sort against ones saved here. Unparseable or missing
|
||||
// dates fall back to now rather than to 1970, which would bury them.
|
||||
const toIsoDate = function(value) {
|
||||
const parsed = Date.parse(value || '');
|
||||
return isNaN(parsed) ? new Date().toISOString() : new Date(parsed).toISOString();
|
||||
};
|
||||
|
||||
const hasFavoriteFlag = function(flags) {
|
||||
if (!flags) return false;
|
||||
return String(flags).split(',').some((flag) => flag.trim().toLowerCase() === FAVORITE_FLAG);
|
||||
@@ -50,7 +59,8 @@ App.hottubBackup = App.hottubBackup || {};
|
||||
channel: '',
|
||||
uploader: row.uploader || '',
|
||||
duration: Number(row.duration) || 0,
|
||||
isLive: false
|
||||
isLive: false,
|
||||
favoriteDate: toIsoDate(row.favoriteDate)
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ App.state = {
|
||||
App.constants = {
|
||||
FAVORITES_KEY: 'favorites',
|
||||
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
|
||||
FAVORITES_SORT_KEY: 'favoritesSort',
|
||||
PREFERRED_QUALITY_KEY: 'preferredQuality',
|
||||
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior'
|
||||
};
|
||||
|
||||
@@ -658,8 +658,32 @@ App.ui = App.ui || {};
|
||||
});
|
||||
};
|
||||
|
||||
// Favorites bar header: the sort order (which applies to the bar and the
|
||||
// favorites grid alike) and the toggle into that grid.
|
||||
App.ui.bindFavoritesControls = function() {
|
||||
const select = document.getElementById('favorites-sort');
|
||||
const button = document.getElementById('favorites-browse-btn');
|
||||
|
||||
if (select && !select.options.length) {
|
||||
App.favorites.SORTS.forEach((sort) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = sort.id;
|
||||
option.textContent = sort.label;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.value = App.favorites.getSort();
|
||||
select.addEventListener('change', () => App.favoritesView.applySort(select.value));
|
||||
}
|
||||
|
||||
if (button) {
|
||||
button.addEventListener('click', () => App.favoritesView.toggle());
|
||||
}
|
||||
App.favoritesView.syncControls();
|
||||
};
|
||||
|
||||
App.ui.bindGlobalHandlers = function() {
|
||||
App.ui.bindBackupImport();
|
||||
App.ui.bindFavoritesControls();
|
||||
|
||||
window.toggleDrawer = App.ui.toggleDrawer;
|
||||
window.closeDrawers = App.ui.closeDrawers;
|
||||
|
||||
@@ -403,6 +403,12 @@ App.videos = App.videos || {};
|
||||
// button being pressed.
|
||||
App.videos.loadVideos = async function(opts) {
|
||||
const force = !!(opts && opts.force);
|
||||
// The favorites grid pages out of localStorage, not the server, but
|
||||
// rides the same sentinel and load-more button to get there.
|
||||
if (App.favoritesView && App.favoritesView.isActive()) {
|
||||
App.favoritesView.loadNext();
|
||||
return;
|
||||
}
|
||||
const session = App.storage.getSession();
|
||||
if (!session || !session.channel) return;
|
||||
if (loadRunning || state.isLoading) return;
|
||||
@@ -630,6 +636,17 @@ App.videos = App.videos || {};
|
||||
clearBtn.disabled = !hasValue;
|
||||
}
|
||||
}
|
||||
// A search is a new result set, so it leaves the favorites grid.
|
||||
if (App.favoritesView && App.favoritesView.isActive()) {
|
||||
App.favoritesView.close({ silent: true });
|
||||
}
|
||||
App.videos.resetGrid();
|
||||
App.videos.loadVideos();
|
||||
};
|
||||
|
||||
// Empties the grid back to "nothing loaded yet", without deciding what
|
||||
// fills it next -- the caller does that.
|
||||
App.videos.resetGrid = function() {
|
||||
// The held/in-flight page belongs to the old result set.
|
||||
App.videos.resetPrefetch();
|
||||
state.currentPage = 1;
|
||||
@@ -642,7 +659,6 @@ App.videos = App.videos || {};
|
||||
App.feed.reset();
|
||||
}
|
||||
App.videos.updateLoadMoreState();
|
||||
App.videos.loadVideos();
|
||||
};
|
||||
|
||||
App.videos.resetAndReload = function() {
|
||||
@@ -651,18 +667,11 @@ App.videos = App.videos || {};
|
||||
state.currentLoadController = null;
|
||||
state.isLoading = false;
|
||||
}
|
||||
// The held/in-flight page belongs to the old result set.
|
||||
App.videos.resetPrefetch();
|
||||
state.currentPage = 1;
|
||||
state.hasNextPage = true;
|
||||
state.renderedVideoIds.clear();
|
||||
state.loadedVideos = [];
|
||||
state.groupCursors = null;
|
||||
App.virtualGrid.reset();
|
||||
if (App.feed && typeof App.feed.reset === 'function') {
|
||||
App.feed.reset();
|
||||
// Switching source/channel/filters means leaving the favorites grid.
|
||||
if (App.favoritesView && App.favoritesView.isActive()) {
|
||||
App.favoritesView.close({ silent: true });
|
||||
}
|
||||
App.videos.updateLoadMoreState();
|
||||
App.videos.resetGrid();
|
||||
App.videos.loadVideos();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user