vodStoryboardClientCache was a plain Map<vodId, VodStoryboard | null> with no eviction. Every VOD ever hovered cached its first sprite data URL — about 50-200 KB each. Browsing a long-running streamer's 2000-VOD archive could leave the renderer holding 100-400 MB of hover-only sprite data permanently, with no signal to the user that it was happening. Wrapped writes in a rememberStoryboard helper that caps the cache at 100 entries with FIFO eviction (Map iterator is insertion-ordered so .keys().next().value is always the oldest). Cache hit / miss semantics unchanged for the live set — only the dropped-off-the- back entries get re-fetched if the user scrolls back to a VOD they hovered hundreds of cards ago, and that re-fetch is fast because the main-process side has its own metadata cache that survives the renderer-side eviction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
163 lines
7.1 KiB
TypeScript
163 lines
7.1 KiB
TypeScript
// VOD hover preview. When the user mouses over a VOD card, we lazy-fetch
|
|
// the channel's seek-preview storyboard sprite for that VOD and cycle
|
|
// through 4 evenly-spaced cells to produce a scrub-preview animation —
|
|
// the same UX twitch.tv ships on its VOD browsing pages.
|
|
//
|
|
// The storyboard fetch goes through the main process (axios via Node's
|
|
// http client) so the renderer never has to make its own HTTPS request
|
|
// to the Twitch CDN, sidestepping the same set of Electron renderer
|
|
// image-loading quirks the avatar code hit.
|
|
|
|
interface ActiveHover {
|
|
vodId: string;
|
|
intervalId: number;
|
|
overlay: HTMLElement;
|
|
}
|
|
|
|
const vodStoryboardClientCache = new Map<string, VodStoryboard | null>();
|
|
let activeHover: ActiveHover | null = null;
|
|
let pendingHoverVodId: string | null = null;
|
|
|
|
const HOVER_DEBOUNCE_MS = 220;
|
|
const FRAME_INTERVAL_MS = 600;
|
|
const FRAMES_TO_CYCLE = 4;
|
|
// Bounded cache — each storyboard data URL is ~50-200 KB, so an
|
|
// unbounded cache could balloon to hundreds of MB on a long browsing
|
|
// session through a streamer with thousands of VODs. FIFO eviction
|
|
// keeps the working set fresh without manual cleanup.
|
|
const MAX_CLIENT_STORYBOARD_CACHE = 100;
|
|
|
|
function rememberStoryboard(vodId: string, sb: VodStoryboard | null): void {
|
|
vodStoryboardClientCache.set(vodId, sb);
|
|
if (vodStoryboardClientCache.size > MAX_CLIENT_STORYBOARD_CACHE) {
|
|
// Map iterator is insertion-ordered — first key is the oldest.
|
|
const oldestKey = vodStoryboardClientCache.keys().next().value as string | undefined;
|
|
if (oldestKey !== undefined) vodStoryboardClientCache.delete(oldestKey);
|
|
}
|
|
}
|
|
|
|
function ensureVodHoverHandlersBound(): void {
|
|
const grid = document.getElementById('vodGrid');
|
|
if (!grid || grid.dataset.hoverBound === '1') return;
|
|
grid.dataset.hoverBound = '1';
|
|
|
|
// Delegated mouseover/mouseout on the grid — re-renders of the
|
|
// grid replace the card DOM but the grid root persists, so the
|
|
// listener stays bound across streamer switches.
|
|
grid.addEventListener('mouseover', (e) => {
|
|
const target = e.target as HTMLElement | null;
|
|
const card = target?.closest('.vod-card') as HTMLElement | null;
|
|
if (!card) return;
|
|
const vodId = card.dataset.vodId;
|
|
if (!vodId) return;
|
|
scheduleHoverPreview(card, vodId);
|
|
});
|
|
grid.addEventListener('mouseout', (e) => {
|
|
const target = e.target as HTMLElement | null;
|
|
const card = target?.closest('.vod-card') as HTMLElement | null;
|
|
if (!card) return;
|
|
// Only clear when leaving the card entirely (not just moving
|
|
// within it between child elements).
|
|
const related = e.relatedTarget as HTMLElement | null;
|
|
if (related && card.contains(related)) return;
|
|
clearHoverPreview();
|
|
});
|
|
}
|
|
|
|
function scheduleHoverPreview(card: HTMLElement, vodId: string): void {
|
|
if (pendingHoverVodId === vodId) return;
|
|
pendingHoverVodId = vodId;
|
|
// Debounce so rapid mouse passes (scrolling, dragging across cards)
|
|
// don't trigger a download for every card brushed.
|
|
window.setTimeout(() => {
|
|
if (pendingHoverVodId !== vodId) return;
|
|
void activateHoverPreview(card, vodId);
|
|
}, HOVER_DEBOUNCE_MS);
|
|
}
|
|
|
|
function clearHoverPreview(): void {
|
|
pendingHoverVodId = null;
|
|
if (!activeHover) return;
|
|
window.clearInterval(activeHover.intervalId);
|
|
const card = activeHover.overlay.parentElement;
|
|
if (card) card.classList.remove('preview-active');
|
|
// Brief opacity fade-out, then remove from DOM.
|
|
activeHover.overlay.style.opacity = '0';
|
|
const overlayToRemove = activeHover.overlay;
|
|
window.setTimeout(() => { try { overlayToRemove.remove(); } catch { /* gone */ } }, 220);
|
|
activeHover = null;
|
|
}
|
|
|
|
async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<void> {
|
|
// Stale-guard: user might have moved off the card in the debounce window.
|
|
if (pendingHoverVodId !== vodId) return;
|
|
|
|
let storyboard: VodStoryboard | null | undefined = vodStoryboardClientCache.get(vodId);
|
|
if (storyboard === undefined) {
|
|
try {
|
|
storyboard = await window.api.getVodStoryboard(vodId);
|
|
} catch (_) {
|
|
storyboard = null;
|
|
}
|
|
rememberStoryboard(vodId, storyboard);
|
|
}
|
|
|
|
// Cursor may have moved on while we awaited; re-check guard.
|
|
if (pendingHoverVodId !== vodId) return;
|
|
if (!storyboard) return;
|
|
|
|
clearHoverPreview();
|
|
|
|
// Pick FRAMES_TO_CYCLE evenly-spaced cells from the first sprite —
|
|
// distributes the chosen preview frames across the early/mid portion
|
|
// of the VOD. For very short VODs the first sprite is the only one,
|
|
// so this still gives a representative spread.
|
|
const totalCells = Math.min(storyboard.framesInSprite, storyboard.cols * storyboard.rows);
|
|
const stride = Math.max(1, Math.floor(totalCells / FRAMES_TO_CYCLE));
|
|
const cellsToShow: Array<{ col: number; row: number }> = [];
|
|
for (let i = 0; i < FRAMES_TO_CYCLE; i++) {
|
|
const idx = Math.min(totalCells - 1, i * stride);
|
|
const col = idx % storyboard.cols;
|
|
const row = Math.floor(idx / storyboard.cols);
|
|
cellsToShow.push({ col, row });
|
|
}
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'vod-storyboard-preview';
|
|
// Scale the sprite so a single cell exactly fills the card width.
|
|
// The thumbnail aspect-ratio (16:9) matches typical cell aspect
|
|
// (e.g. 220x124 ≈ 1.77) so width-stretch keeps proportions.
|
|
const cardWidth = card.getBoundingClientRect().width;
|
|
const cellAspect = storyboard.cellWidth / storyboard.cellHeight;
|
|
const scale = cardWidth / storyboard.cellWidth;
|
|
overlay.style.backgroundImage = `url("${storyboard.spriteDataUrl.replace(/"/g, '%22')}")`;
|
|
overlay.style.backgroundSize = `${storyboard.cols * storyboard.cellWidth * scale}px ${storyboard.rows * storyboard.cellHeight * scale}px`;
|
|
overlay.style.height = `${cardWidth / cellAspect}px`;
|
|
// Initial position = first chosen cell.
|
|
const first = cellsToShow[0];
|
|
overlay.style.backgroundPosition = `-${first.col * storyboard.cellWidth * scale}px -${first.row * storyboard.cellHeight * scale}px`;
|
|
|
|
card.appendChild(overlay);
|
|
// Trigger CSS transition to opacity:1 on the next frame.
|
|
requestAnimationFrame(() => { card.classList.add('preview-active'); });
|
|
|
|
let frameIdx = 1;
|
|
const intervalId = window.setInterval(() => {
|
|
const cell = cellsToShow[frameIdx % cellsToShow.length];
|
|
overlay.style.backgroundPosition = `-${cell.col * storyboard.cellWidth * scale}px -${cell.row * storyboard.cellHeight * scale}px`;
|
|
frameIdx++;
|
|
}, FRAME_INTERVAL_MS);
|
|
|
|
activeHover = { vodId, intervalId, overlay };
|
|
}
|
|
|
|
(window as unknown as { ensureVodHoverHandlersBound: typeof ensureVodHoverHandlersBound }).ensureVodHoverHandlersBound = ensureVodHoverHandlersBound;
|
|
|
|
// Bind once the grid exists. Tab switches don't re-create the grid, so
|
|
// one-time binding via DOMContentLoaded is enough.
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', () => { ensureVodHoverHandlersBound(); });
|
|
} else {
|
|
ensureVodHoverHandlersBound();
|
|
}
|