A <select> of ninety channels tells the reader almost nothing: a wall of bare names, nothing to say which site a name belongs to or what it carries, and no way to look for one. The server has been sending far more than the name all along -- a favicon, a description, tags, its own groupings, and whether a channel still says "work in progress" -- so the picker shows that. It opens as a dialog in the site's own style: a search field, then the server's groups as sections, each led by an "All <group>" row that browses the whole group. Search matches the name, the id, the description, the tags and the group, so "jav" finds Tokyo Motion (whose name never says it) and "leaks" finds the OnlyFans mirrors. Arrows and Enter walk the list, Escape closes it, and opening it with nothing typed scrolls to the channel already being read. On a phone it fills the screen and leaves the field unfocused -- the keyboard would cover the thing being chosen from. Favicons load through attachThumbnail, so they get the same route race, proxy fallback and retries as every other remote picture, with the channel's initial behind them for the ones that never arrive. The <select> was also where the command palette read its channel actions, so the list lives in App.ui.channels now and the palette asks for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
295 lines
14 KiB
JavaScript
295 lines
14 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); } });
|
|
});
|
|
}
|
|
// 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();
|
|
}
|
|
})();
|