Scrolling the grid did nothing but destroy cards and build near-identical ones back: a template string parsed as innerHTML, ten querySelectors, and a listener per interactive element, every time a card entered the window. The virtualizer now keeps a pool and rebinds a card it already has -- 18us against 136us to build one, and 74-83% of mounts are served from it. Two things had to change first. Nothing on a card may close over the video it is showing, because the card outlives the video, so every interaction moved to one delegated listener per event type on the grid. And every card now has the same shape whatever it shows: the optional parts are always present and hidden when unused, so any pooled card fits any video. That needed a global [hidden] rule, since .live-badge and .video-tags carry their own display. The rest is the release path, which is where this design lives or dies. A thumbnail carries a generation, so a race or a proxy fallback settling after the card moved on cannot paint over the video now showing. The player stamps the card it was opened from, so a recycled element stops answering for it. The reveal handler, the entrance-animation listener and the hover preview are all taken back off. Anything missed here surfaces as one video's title, thumbnail or heart on another video's card, which is what the smoke suite scrolls back and forth to catch. Two incidental fixes found while measuring: bindCard no longer writes a data-tag per tag button (dataset is a proxy, and that alone cost more than the rest of a rebind put together -- the handler reads the label off the button), and favorites.has no longer parses a URL for every card that isn't a favorite by key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
287 lines
13 KiB
JavaScript
287 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;
|
|
// 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)
|
|
});
|
|
});
|
|
}
|
|
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();
|
|
}
|
|
})();
|