diff --git a/frontend/css/style.css b/frontend/css/style.css index adcc8a4..3bfdc70 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -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; diff --git a/frontend/index.html b/frontend/index.html index 74b0944..8bd1e9b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -34,6 +34,10 @@

Favorites

+
+ + +
No favorites yet. Tap the heart on a video to save it here.
@@ -202,6 +206,7 @@ + diff --git a/frontend/js/favorites.js b/frontend/js/favorites.js index 4d97a40..21d883a 100644 --- a/frontend/js/favorites.js +++ b/frontend/js/favorites.js @@ -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'; - } - }; + } })(); diff --git a/frontend/js/favoritesView.js b/frontend/js/favoritesView.js new file mode 100644 index 0000000..a44d517 --- /dev/null +++ b/frontend/js/favoritesView.js @@ -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(); + } + }; +})(); diff --git a/frontend/js/hottubBackup.js b/frontend/js/hottubBackup.js index 5d83183..bdee05e 100644 --- a/frontend/js/hottubBackup.js +++ b/frontend/js/hottubBackup.js @@ -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) })); }; diff --git a/frontend/js/state.js b/frontend/js/state.js index 0b082b6..20019f0 100644 --- a/frontend/js/state.js +++ b/frontend/js/state.js @@ -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' }; diff --git a/frontend/js/ui.js b/frontend/js/ui.js index 21324ae..18c11f0 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -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; diff --git a/frontend/js/videos.js b/frontend/js/videos.js index 9dbf8e4..cff77f8 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -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(); };