The favorites bar carries the "Browse all" button and the sort control, so switching the bar off in settings hid the way in to both. The command palette now offers "Browse favorites" (or "Back to videos") and each sort order, and opening the grid re-renders the bar so its header -- the way back out -- is mounted even when settings say hidden. Tests (scratchpad): a new palette test that starts with the bar switched off, opens the grid through the palette, re-sorts through it, and returns to the listing. It caught the second half of this: opening from the palette did not re-render the bar, leaving no visible way out. Also fixes the favorites playback test, which had been wedging headless Chrome all session. It was reloading by navigating to the URL already loaded; the first evaluate after that is answered by the outgoing execution context and every one after it hangs forever. It now does its second visit in a fresh tab -- localStorage is shared per origin, so it models "next day" the same way -- and asserts what it had only been printing. It also runs hermetically now (no favorites left over from another test, CDN icons and fonts blocked) and no longer runs its whole body on import, which is what made it hijack a debugging session earlier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
273 lines
13 KiB
JavaScript
273 lines
13 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;
|
|
|
|
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;
|
|
}
|
|
};
|
|
|
|
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.resolveAndProbe === 'function') App.videos.resolveAndProbe(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 || card === activeCard) return;
|
|
clearPreview();
|
|
activeCard = card;
|
|
dwellTimer = setTimeout(() => { dwellTimer = null; if (activeCard === 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)
|
|
});
|
|
});
|
|
}
|
|
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); } });
|
|
});
|
|
}
|
|
const channelSelect = document.getElementById('channel-select');
|
|
if (channelSelect) {
|
|
Array.from(channelSelect.options).forEach((opt) => {
|
|
if (opt.value === channelSelect.value) return;
|
|
out.push({ label: opt.textContent, hint: 'Channel', run: () => { channelSelect.value = opt.value; fireChange(channelSelect); } });
|
|
});
|
|
}
|
|
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();
|
|
}
|
|
})();
|