Twitch-VOD-Manager/src/renderer-vod-hover.ts
xRangerDE 976ca40963 perf: bound the renderer-side VOD storyboard cache (FIFO 100)
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>
2026-05-11 04:08:42 +02:00

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