// 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(); 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; 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 { // 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; } vodStoryboardClientCache.set(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(); }