Files
jacuzzi/frontend/js/enhance.js
Simon bc7c0047b7 Keep a whole search, and say what is new in it
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
2026-10-17 05:59:19 +00:00

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();
}
})();