A search here is more than the words in the box: it is the source, the channel, every filter that channel offers, and the query together. The bookmark in the search bar keeps all of it, the one in the header opens the list, and tapping a row puts the whole thing back. Each saved search remembers the ids of the newest videos it showed the last time it was opened. Once a day -- at startup, when the tab is looked at again, and on a slow heartbeat for tabs left open -- the client asks each one's first page again and counts how many of those videos it has never shown. That count is the badge, on the row and summed in the header, and it stops at 9+. Opening a saved search is viewing it, so the count goes out and the mark moves to what is on screen now. Nothing new leaves the device: the check is the same listing request the grid would have made, and everything else is localStorage. Also: handleSearch's "put this in the box without searching for it" half is now App.videos.setSearchBox, which is what applying a saved search needs. tests/smoke_saved_searches.py covers it end to end against a fixture listing whose ids the test shifts to mean "new uploads": 46 checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
315 lines
15 KiB
JavaScript
315 lines
15 KiB
JavaScript
window.App = window.App || {};
|
|
App.enhance = App.enhance || {};
|
|
|
|
// Progressive UI enhancements layered on top of the core app. Everything here
|
|
// is non-essential polish: cursor-tracking card spotlight, a scroll-progress
|
|
// bar, a back-to-top button, a ⌘K command palette, and hover video previews.
|
|
// None of it is required for the app to function, so each piece fails soft.
|
|
(function() {
|
|
const fineHover = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
|
const reduceMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
|
|
// ---- Cursor-tracking spotlight border on grid cards ----------------------
|
|
// One delegated listener keeps per-card vars (--mx/--my) updated; the brass
|
|
// border gradient that reads them lives in CSS (.video-card::after).
|
|
function initSpotlight() {
|
|
const grid = document.getElementById('video-grid');
|
|
if (!grid || !fineHover) return;
|
|
grid.addEventListener('pointermove', (e) => {
|
|
const card = e.target.closest('.video-card');
|
|
if (!card) return;
|
|
const r = card.getBoundingClientRect();
|
|
card.style.setProperty('--mx', (e.clientX - r.left) + 'px');
|
|
card.style.setProperty('--my', (e.clientY - r.top) + 'px');
|
|
}, { passive: true });
|
|
}
|
|
|
|
// ---- Scroll progress bar + back-to-top FAB -------------------------------
|
|
function initScrollAffordances() {
|
|
const bar = document.getElementById('scroll-progress');
|
|
const fab = document.getElementById('back-to-top');
|
|
let ticking = false;
|
|
const onScroll = () => {
|
|
if (ticking) return;
|
|
ticking = true;
|
|
requestAnimationFrame(() => {
|
|
ticking = false;
|
|
const doc = document.documentElement;
|
|
const max = doc.scrollHeight - window.innerHeight;
|
|
const pct = max > 0 ? (window.scrollY / max) * 100 : 0;
|
|
if (bar) bar.style.width = pct.toFixed(2) + '%';
|
|
if (fab) fab.classList.toggle('is-visible', window.scrollY > 700);
|
|
});
|
|
};
|
|
window.addEventListener('scroll', onScroll, { passive: true });
|
|
onScroll();
|
|
if (fab) {
|
|
fab.addEventListener('click', () => {
|
|
window.scrollTo({ top: 0, behavior: reduceMotion() ? 'auto' : 'smooth' });
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---- Hover video preview --------------------------------------------------
|
|
// After a short dwell over a card, play a muted inline clip in place of the
|
|
// poster — but only if the video's real formats are already resolved (the
|
|
// grid resolves them lazily on hover/scroll anyway), so we never block or
|
|
// hammer the backend just to preview.
|
|
function initHoverPreview() {
|
|
const grid = document.getElementById('video-grid');
|
|
if (!grid || !fineHover) return;
|
|
let dwellTimer = null;
|
|
let activeCard = null;
|
|
// The card element alone doesn't identify what is being previewed: the
|
|
// grid pools its cards, so the same element can come back showing a
|
|
// different video (via relayout or a new search, neither of which
|
|
// scrolls, so clearPreview never runs). Remembering the video too keeps
|
|
// the "already previewing this" check honest.
|
|
let activeVideo = null;
|
|
|
|
const isActive = (card) => card === activeCard &&
|
|
(!App.videos.getVideoForCard || App.videos.getVideoForCard(card) === activeVideo);
|
|
|
|
const clearPreview = () => {
|
|
if (dwellTimer) { clearTimeout(dwellTimer); dwellTimer = null; }
|
|
if (activeCard) {
|
|
const vid = activeCard.querySelector('.card-preview');
|
|
if (vid) { try { vid.pause(); } catch (e) {} vid.remove(); }
|
|
activeCard.classList.remove('is-previewing');
|
|
activeCard = null;
|
|
activeVideo = null;
|
|
}
|
|
};
|
|
|
|
const startPreview = (card) => {
|
|
if (!App.videos || typeof App.videos.getVideoForCard !== 'function') return;
|
|
const v = App.videos.getVideoForCard(card);
|
|
if (!v) return;
|
|
const meta = v.meta;
|
|
const ready = meta && Array.isArray(meta.formats) && meta.formats.length;
|
|
if (!ready) {
|
|
// Not resolved yet: kick it off so the *next* hover can preview.
|
|
if (typeof App.videos.ensureFormats === 'function') App.videos.ensureFormats(v);
|
|
return;
|
|
}
|
|
let url = '';
|
|
try { url = App.videos.buildStreamUrl(v); } catch (e) { return; }
|
|
if (!url) return;
|
|
const img = card.querySelector('img');
|
|
const vid = document.createElement('video');
|
|
vid.className = 'card-preview';
|
|
vid.muted = true;
|
|
vid.loop = true;
|
|
vid.playsInline = true;
|
|
vid.setAttribute('playsinline', '');
|
|
vid.setAttribute('webkit-playsinline', '');
|
|
vid.preload = 'auto';
|
|
if (img) vid.style.height = img.getBoundingClientRect().height + 'px';
|
|
vid.src = url;
|
|
vid.addEventListener('error', () => { if (vid.isConnected) vid.remove(); }, { once: true });
|
|
card.appendChild(vid);
|
|
card.classList.add('is-previewing');
|
|
const p = vid.play();
|
|
if (p && p.catch) p.catch(() => {});
|
|
};
|
|
|
|
grid.addEventListener('pointerover', (e) => {
|
|
const card = e.target.closest('.video-card');
|
|
if (!card || isActive(card)) return;
|
|
clearPreview();
|
|
activeCard = card;
|
|
activeVideo = App.videos.getVideoForCard ? App.videos.getVideoForCard(card) : null;
|
|
dwellTimer = setTimeout(() => {
|
|
dwellTimer = null;
|
|
if (isActive(card)) startPreview(card);
|
|
}, 600);
|
|
});
|
|
grid.addEventListener('pointerout', (e) => {
|
|
const card = e.target.closest('.video-card');
|
|
if (!card) return;
|
|
const to = e.relatedTarget;
|
|
if (to && card.contains(to)) return; // still inside the same card
|
|
if (card === activeCard) clearPreview();
|
|
});
|
|
window.addEventListener('scroll', clearPreview, { passive: true });
|
|
}
|
|
|
|
// ---- Command palette (⌘K / Ctrl+K) ---------------------------------------
|
|
function initCommandPalette() {
|
|
const palette = document.getElementById('command-palette');
|
|
const input = document.getElementById('cmdk-input');
|
|
const list = document.getElementById('cmdk-list');
|
|
if (!palette || !input || !list) return;
|
|
|
|
let actions = [];
|
|
let filtered = [];
|
|
let activeIndex = 0;
|
|
|
|
const fireChange = (el) => el && el.dispatchEvent(new Event('change'));
|
|
|
|
const buildActions = () => {
|
|
const out = [];
|
|
out.push({ label: 'Search videos', hint: 'Focus the search box', run: () => {
|
|
const s = document.getElementById('search-input'); if (s) { s.focus(); s.select(); }
|
|
}});
|
|
const theme = (localStorage.getItem('theme') || 'dark');
|
|
out.push({ label: `Switch to ${theme === 'light' ? 'dark' : 'light'} theme`, hint: 'Appearance', run: () => {
|
|
localStorage.setItem('theme', theme === 'light' ? 'dark' : 'light');
|
|
if (App.ui && App.ui.applyTheme) App.ui.applyTheme();
|
|
}});
|
|
const density = (App.storage && App.storage.getDensity) ? App.storage.getDensity() : 'comfortable';
|
|
out.push({ label: `Grid density: ${density === 'compact' ? 'comfortable' : 'compact'}`, hint: 'Layout', run: () => {
|
|
if (!App.storage) return;
|
|
App.storage.setDensity(density === 'compact' ? 'comfortable' : 'compact');
|
|
if (App.ui && App.ui.applyDensity) App.ui.applyDensity();
|
|
if (App.virtualGrid && App.virtualGrid.relayout) App.virtualGrid.relayout();
|
|
}});
|
|
out.push({ label: 'Toggle Reels view', hint: 'Playback', run: () => { if (App.feed) App.feed.toggle(); } });
|
|
|
|
// The favorites bar carries these controls, but it can be switched
|
|
// off in settings -- in which case the palette is the way in.
|
|
if (App.favoritesView && App.favorites) {
|
|
const browsing = App.favoritesView.isActive();
|
|
out.push({
|
|
label: browsing ? 'Back to videos' : 'Browse favorites',
|
|
hint: browsing ? 'Leave the favorites grid' : 'All favorites as a grid',
|
|
run: () => App.favoritesView.toggle()
|
|
});
|
|
const currentSort = App.favorites.getSort();
|
|
App.favorites.SORTS.forEach((sort) => {
|
|
if (sort.id === currentSort) return;
|
|
out.push({
|
|
label: `Sort favorites: ${sort.label}`,
|
|
hint: 'Favorites',
|
|
run: () => App.favoritesView.applySort(sort.id)
|
|
});
|
|
});
|
|
}
|
|
// Saved searches: the list itself, the one-press save, and every
|
|
// saved search as a row of its own so ⌘K can jump straight to one.
|
|
if (App.savedSearches && App.ui && App.ui.savedSearches) {
|
|
const saved = App.savedSearches.getAll();
|
|
out.push({ label: 'Saved searches', hint: saved.length ? `${saved.length} saved` : 'Nothing saved yet', run: () => App.ui.savedSearches.open() });
|
|
const match = App.savedSearches.findMatching();
|
|
out.push({
|
|
label: match ? `Remove saved search: ${match.name}` : 'Save the current search',
|
|
hint: 'Source · channel · filters · query',
|
|
run: () => App.ui.savedSearches.toggleCurrent()
|
|
});
|
|
saved.forEach((entry) => {
|
|
const label = App.savedSearches.badgeLabel(entry.newCount);
|
|
out.push({
|
|
label: entry.name,
|
|
hint: label ? `Saved search · ${label} new` : 'Saved search',
|
|
run: () => App.savedSearches.apply(entry.id)
|
|
});
|
|
});
|
|
}
|
|
out.push({ label: 'Reload channel', hint: 'Refresh the current feed', run: () => { if (App.videos) App.videos.resetAndReload(); } });
|
|
out.push({ label: 'Open Menu', hint: 'Source · channel · filters', run: () => { if (App.ui) App.ui.toggleDrawer('menu'); } });
|
|
out.push({ label: 'Open Settings', hint: 'Preferences', run: () => { if (App.ui) App.ui.toggleDrawer('settings'); } });
|
|
|
|
const sourceSelect = document.getElementById('source-select');
|
|
if (sourceSelect) {
|
|
Array.from(sourceSelect.options).forEach((opt) => {
|
|
if (opt.value === sourceSelect.value) return;
|
|
out.push({ label: opt.textContent, hint: 'Source', run: () => { sourceSelect.value = opt.value; fireChange(sourceSelect); } });
|
|
});
|
|
}
|
|
// The channel list is the picker's (App.ui.channels), not a
|
|
// <select>'s -- same entries, drawn as palette rows.
|
|
if (App.ui && App.ui.channels) {
|
|
const current = App.storage && App.storage.getSession ?
|
|
App.storage.getSession() : null;
|
|
const currentId = (current && current.channel) ? current.channel.id : '';
|
|
App.ui.channels.entries().forEach((entry) => {
|
|
if (entry.id === currentId) return;
|
|
out.push({
|
|
label: entry.label,
|
|
hint: entry.group || 'Channel',
|
|
run: () => App.ui.channels.choose(entry.id)
|
|
});
|
|
});
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const render = () => {
|
|
list.innerHTML = '';
|
|
filtered.forEach((a, i) => {
|
|
const li = document.createElement('button');
|
|
li.type = 'button';
|
|
li.className = 'cmdk-item' + (i === activeIndex ? ' is-active' : '');
|
|
li.innerHTML = `<span class="cmdk-label"></span><span class="cmdk-hint"></span>`;
|
|
li.querySelector('.cmdk-label').textContent = a.label;
|
|
li.querySelector('.cmdk-hint').textContent = a.hint || '';
|
|
li.addEventListener('click', () => choose(i));
|
|
li.addEventListener('pointermove', () => { if (activeIndex !== i) { activeIndex = i; render(); } });
|
|
list.appendChild(li);
|
|
});
|
|
};
|
|
|
|
const applyFilter = () => {
|
|
const q = input.value.trim().toLowerCase();
|
|
filtered = q
|
|
? actions.filter((a) => (a.label + ' ' + (a.hint || '')).toLowerCase().includes(q))
|
|
: actions.slice();
|
|
activeIndex = 0;
|
|
render();
|
|
};
|
|
|
|
const choose = (i) => {
|
|
const a = filtered[i];
|
|
close();
|
|
if (a && a.run) a.run();
|
|
};
|
|
|
|
const open = () => {
|
|
actions = buildActions();
|
|
input.value = '';
|
|
applyFilter();
|
|
palette.classList.add('open');
|
|
palette.setAttribute('aria-hidden', 'false');
|
|
requestAnimationFrame(() => input.focus());
|
|
};
|
|
const close = () => {
|
|
palette.classList.remove('open');
|
|
palette.setAttribute('aria-hidden', 'true');
|
|
};
|
|
App.enhance.openPalette = open;
|
|
|
|
input.addEventListener('input', applyFilter);
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); activeIndex = Math.min(activeIndex + 1, filtered.length - 1); render(); scrollActive(); }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); render(); scrollActive(); }
|
|
else if (e.key === 'Enter') { e.preventDefault(); choose(activeIndex); }
|
|
else if (e.key === 'Escape') { e.preventDefault(); close(); }
|
|
});
|
|
const scrollActive = () => {
|
|
const el = list.children[activeIndex];
|
|
if (el) el.scrollIntoView({ block: 'nearest' });
|
|
};
|
|
palette.addEventListener('click', (e) => { if (e.target === palette) close(); });
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
|
|
e.preventDefault();
|
|
if (palette.classList.contains('open')) close(); else open();
|
|
}
|
|
});
|
|
}
|
|
|
|
function init() {
|
|
initSpotlight();
|
|
initScrollAffordances();
|
|
initHoverPreview();
|
|
initCommandPalette();
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|