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