Twitch-VOD-Manager/src/renderer-vod-hover.ts
xRangerDE 3c73efbad7 feat: banner background + live preview card + VOD hover storyboard + sticky header
Four interlocking visual upgrades that push the profile area from
"works" to "looks like a real Twitch app". Single release because
all four share data plumbing and need to land coherently.

1) Banner background — getStreamerProfile now also pulls
   bannerImageURL via public GQL, fetches the bytes server-side as a
   data URL (same path as the avatar fix in 4.6.18-4.6.19), and the
   renderer puts it behind the header content with blur(18px) +
   saturate(1.2) + a 0.55 opacity overlay. Result: per-streamer
   colour identity at a glance, like twitch.tv's channel page.

2) Live preview card — when isLive, the public-GQL stream block also
   carries previewImageURL(640x360), viewersCount, title, game{name}.
   A second card slides in below the main profile row showing the
   current frame at 240×135, eye-icon viewer count, big bold title,
   game, and a red "Jetzt aufnehmen" CTA. Click anywhere on the card
   OR on the button triggers triggerLiveRecording — same path as
   the sidebar REC dot, so the recording reaches the queue with
   identical settings.

3) VOD hover storyboard — Twitch ships a seekPreviewsURL per VOD
   pointing at a JSON manifest of sprite-sheet images, each a grid
   of preview thumbnails spanning the recording. New IPC
   get-vod-storyboard fetches the manifest, picks the high-quality
   first sprite, fetches its bytes as a data URL, and returns the
   grid metadata. Renderer (new renderer-vod-hover.ts) hooks
   delegated mouseover on #vodGrid: 220ms debounce, then on
   activation overlays a div positioned over the thumbnail with
   background-image=sprite + a setInterval cycling
   background-position through 4 evenly-spaced cells at 600ms each.
   Per-VOD result cached client-side so repeated hovers don't
   re-fetch. Negative results (private VODs, expired) are also
   cached so we don't re-query a known-empty manifest.

4) Sticky header — position:sticky;top:0;z-index:20 plus a
   backdrop-filter:blur(6px) so the VOD grid scrolling underneath
   reads through the banner subtly. Header stays anchored to the top
   of .content as the user scrolls hundreds of VODs.

GQL refresher: the public schema rejects `broadcasterType` but
accepts `roles{isPartner isAffiliate}`, plus the same query now
includes bannerImageURL and stream{previewImageURL viewersCount
title game{name}}. One single roundtrip pulls everything we need
for the header AND the live card. The old separate-follower-count
roundtrip (fetchOnlyFollowerCount) is now redundant but kept around
for back-compat in case other call sites grow into it.

Also: profile layout switched from one big flex row to a relative
container with two children (.streamer-profile-row for the meta,
.streamer-profile-live-card for the live block). The .live-card
only renders when isLive — offline streamers get the same compact
header they had before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:55:17 +02:00

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