From 3c73efbad7cfc6c940f8112ece6de10c54fe71af Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Mon, 11 May 2026 00:55:17 +0200 Subject: [PATCH] feat: banner background + live preview card + VOD hover storyboard + sticky header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/index.html | 1 + src/main.ts | 262 ++++++++++++++++++++++++++++++++------ src/preload.ts | 1 + src/renderer-globals.d.ts | 14 ++ src/renderer-locale-de.ts | 2 + src/renderer-locale-en.ts | 2 + src/renderer-profile.ts | 86 +++++++++---- src/renderer-vod-hover.ts | 148 +++++++++++++++++++++ src/styles.css | 191 ++++++++++++++++++++++++++- 9 files changed, 636 insertions(+), 71 deletions(-) create mode 100644 src/renderer-vod-hover.ts diff --git a/src/index.html b/src/index.html index be44af0..9acaea4 100644 --- a/src/index.html +++ b/src/index.html @@ -842,6 +842,7 @@ + diff --git a/src/main.ts b/src/main.ts index 5bf85d2..5ebf254 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2303,6 +2303,7 @@ interface StreamerProfile { login: string; displayName: string; avatarUrl: string; + bannerUrl: string; description: string; broadcasterType: '' | 'partner' | 'affiliate'; followerCount: number | null; @@ -2311,6 +2312,8 @@ interface StreamerProfile { isLive: boolean; currentTitle: string | null; currentGame: string | null; + currentStreamPreviewUrl: string; + currentStreamViewers: number | null; twitchUrl: string; fetchedAt: number; } @@ -2393,25 +2396,31 @@ interface PublicProfileQueryResult { displayName: string; description: string | null; profileImageURL: string | null; + bannerImageURL: string | null; roles?: { isPartner: boolean; isAffiliate: boolean } | null; followers?: { totalCount: number } | null; + stream?: { + id: string; + type: string; + title: string | null; + viewersCount: number | null; + previewImageURL: string | null; + game: { name: string } | null; + } | null; } | null; } async function fetchPublicStreamerProfile(login: string): Promise<{ displayName: string; avatarUrl: string; + bannerUrl: string; description: string; broadcasterType: '' | 'partner' | 'affiliate'; followerCount: number | null; + stream: { previewUrl: string; viewers: number | null; title: string | null; game: string | null } | null; } | null> { - // The public (unauthenticated) GQL schema does NOT expose - // `broadcasterType` directly — querying it returns an errors[] response - // which the upstream helper treats as a complete failure (null data), - // which in turn left the avatar empty and the user's whole profile - // fell through to the fallback letter tile. Use `roles{isPartner - // isAffiliate}` instead, which the public schema does expose, and - // derive broadcasterType locally. + // Same query also pulls bannerImageURL and the current stream's + // preview + viewer count when live — saves a separate roundtrip. const data = await fetchPublicTwitchGql( `query($login: String!) { user(login: $login) { @@ -2420,8 +2429,17 @@ async function fetchPublicStreamerProfile(login: string): Promise<{ displayName description profileImageURL(width: 150) + bannerImageURL roles { isPartner isAffiliate } followers { totalCount } + stream { + id + type + title + viewersCount + previewImageURL(width: 640, height: 360) + game { name } + } } }`, { login } @@ -2431,12 +2449,21 @@ async function fetchPublicStreamerProfile(login: string): Promise<{ const broadcasterType: '' | 'partner' | 'affiliate' = roles?.isPartner ? 'partner' : (roles?.isAffiliate ? 'affiliate' : ''); + const s = data.user.stream; + const stream = (s && s.type === 'live') ? { + previewUrl: s.previewImageURL || '', + viewers: typeof s.viewersCount === 'number' ? s.viewersCount : null, + title: s.title || null, + game: s.game?.name || null + } : null; return { displayName: data.user.displayName || login, avatarUrl: data.user.profileImageURL || '', + bannerUrl: data.user.bannerImageURL || '', description: data.user.description || '', broadcasterType, - followerCount: typeof data.user.followers?.totalCount === 'number' ? data.user.followers.totalCount : null + followerCount: typeof data.user.followers?.totalCount === 'number' ? data.user.followers.totalCount : null, + stream }; } @@ -2464,37 +2491,43 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise< return await withInFlightDedup(inFlightProfileRequests, normalized, async () => { runtimeMetrics.cacheMisses += 1; - // Prefer Helix for the core fields when we have credentials (richer - // bio, faster, less likely to rate-limit), then fill in followers - // via public GQL since Helix /channels/followers needs a moderator - // token we don't have. Fall back fully to public GQL when not - // authenticated. + // Public GQL is now the SOURCE for everything except some of the + // core text fields when Helix is authenticated — because public + // GQL is the only route that gives us the banner image + current + // stream preview in one shot, and skipping it would mean two + // extra roundtrips. Helix takes precedence for displayName / + // description (those fields are sometimes richer there). let displayName = normalized; let avatarUrl = ''; + let bannerUrl = ''; let description = ''; let broadcasterType: '' | 'partner' | 'affiliate' = ''; + let streamFromPublic: Awaited> extends infer R ? (R extends null ? null : R extends { stream: infer S } ? S : null) : null = null; + let followerCountFromPublic: number | null = null; + + const publicProfile = await fetchPublicStreamerProfile(normalized); + if (publicProfile) { + displayName = publicProfile.displayName; + avatarUrl = publicProfile.avatarUrl; + bannerUrl = publicProfile.bannerUrl; + description = publicProfile.description; + broadcasterType = publicProfile.broadcasterType; + followerCountFromPublic = publicProfile.followerCount; + streamFromPublic = publicProfile.stream; + } const helixUser = await fetchHelixUserInfo(normalized); if (helixUser) { - displayName = helixUser.display_name || normalized; - avatarUrl = helixUser.profile_image_url || ''; - description = helixUser.description || ''; + displayName = helixUser.display_name || displayName; + if (helixUser.profile_image_url) avatarUrl = helixUser.profile_image_url; + if (helixUser.description) description = helixUser.description; const bt = (helixUser.broadcaster_type || '').toLowerCase(); - broadcasterType = (bt === 'partner' || bt === 'affiliate') ? bt : ''; - } else { - const publicProfile = await fetchPublicStreamerProfile(normalized); - if (publicProfile) { - displayName = publicProfile.displayName; - avatarUrl = publicProfile.avatarUrl; - description = publicProfile.description; - broadcasterType = publicProfile.broadcasterType; - } + if (bt === 'partner' || bt === 'affiliate') broadcasterType = bt; } - // Follower count always from public GQL — even when Helix - // succeeded for the rest. One extra ~50ms call, but it's the - // only reliable way without scoped tokens. - const followerCount = await fetchOnlyFollowerCount(normalized); + // followerCountFromPublic comes from the public profile query + // above — no separate follower roundtrip needed. + const followerCount = followerCountFromPublic; // Derive vod count + last stream from the already-cached VOD list // when we have an id. No extra network hit. @@ -2516,24 +2549,49 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise< let isLive = false; let currentTitle: string | null = null; let currentGame: string | null = null; - try { - const live = await getLiveStreamInfo(normalized); - if (live) { - isLive = live.isLive; - currentTitle = live.title || null; - currentGame = live.gameName || null; - } - } catch (_) { /* best-effort */ } + let currentStreamPreviewRemoteUrl = ''; + let currentStreamViewers: number | null = null; - // Embed the avatar bytes as a data URL so the renderer doesn't - // have to make its own HTTPS request. Bypasses any CSP, CORS, - // referrer-policy, or Electron renderer image-loading quirks. - const avatarDataUrl = avatarUrl ? await fetchAvatarAsDataUrl(avatarUrl) : ''; + if (streamFromPublic) { + // Public-GQL already told us this user is live and gave us a + // preview frame URL + viewer count + game/title. Don't double- + // call getLiveStreamInfo when we already have a fresh answer. + isLive = true; + currentTitle = streamFromPublic.title; + currentGame = streamFromPublic.game; + currentStreamPreviewRemoteUrl = streamFromPublic.previewUrl; + currentStreamViewers = streamFromPublic.viewers; + } else { + try { + const live = await getLiveStreamInfo(normalized); + if (live) { + isLive = live.isLive; + currentTitle = live.title || null; + currentGame = live.gameName || null; + } + } catch (_) { /* best-effort */ } + } + + // Embed the avatar AND banner bytes as data URLs in parallel. + // Renderer can't reliably fetch Twitch CDN images directly from + // an Electron renderer process, plus the data URL approach skips + // any CSP/referer/CORS quirks. Live preview also goes through + // this path — adds a cache-busting query string so a returning + // user gets a fresh frame each time the profile refreshes. + const livePreviewUrlForFetch = currentStreamPreviewRemoteUrl + ? `${currentStreamPreviewRemoteUrl}${currentStreamPreviewRemoteUrl.includes('?') ? '&' : '?'}_=${Date.now()}` + : ''; + const [avatarDataUrl, bannerDataUrl, livePreviewDataUrl] = await Promise.all([ + avatarUrl ? fetchAvatarAsDataUrl(avatarUrl) : Promise.resolve(''), + bannerUrl ? fetchAvatarAsDataUrl(bannerUrl) : Promise.resolve(''), + livePreviewUrlForFetch ? fetchAvatarAsDataUrl(livePreviewUrlForFetch) : Promise.resolve('') + ]); const profile: StreamerProfile = { login: normalized, displayName, avatarUrl: avatarDataUrl || avatarUrl, + bannerUrl: bannerDataUrl || bannerUrl, description, broadcasterType, followerCount, @@ -2542,6 +2600,8 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise< isLive, currentTitle, currentGame, + currentStreamPreviewUrl: livePreviewDataUrl || currentStreamPreviewRemoteUrl, + currentStreamViewers, twitchUrl: `https://www.twitch.tv/${normalized}`, fetchedAt: Date.now() }; @@ -2551,6 +2611,122 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise< }); } +// ========================================== +// VOD STORYBOARD — animated hover preview +// ========================================== +// Twitch publishes a "storyboard" JSON per VOD with sprite-sheet URLs +// containing N preview thumbnails covering the full length of the +// recording. We pull the JSON via public GQL (seekPreviewsURL), then +// hand the renderer the first high-quality sprite as a data URL plus +// the grid metadata. The renderer animates background-position across +// 4 cells to produce a scrub-preview effect on hover, twitch.tv-style. +interface VodStoryboard { + vodId: string; + spriteDataUrl: string; + cols: number; + rows: number; + cellWidth: number; + cellHeight: number; + framesInSprite: number; +} + +const MAX_VOD_STORYBOARD_CACHE_ENTRIES = 1024; +const vodStoryboardCache = new Map>(); +const inFlightStoryboardRequests = new Map>(); + +interface StoryboardManifestEntry { + count: number; + width: number; + height: number; + cols: number; + rows: number; + images: string[]; + quality: string; + interval: number; +} + +async function getVodStoryboard(vodId: string): Promise { + if (!vodId) return null; + + const cached = getCachedValue(vodStoryboardCache, vodId); + if (cached !== undefined) { + runtimeMetrics.cacheHits += 1; + return cached; + } + + return await withInFlightDedup(inFlightStoryboardRequests, vodId, async () => { + runtimeMetrics.cacheMisses += 1; + + // Step 1: GQL gives us the seekPreviewsURL pointing at a JSON + // manifest. The manifest lists sprite images at multiple quality + // levels; we pick the high-quality first sprite (covers the + // beginning of the VOD with the most detail). + const data = await fetchPublicTwitchGql<{ video: { seekPreviewsURL: string | null } | null }>( + `query($id: ID!) { video(id: $id) { seekPreviewsURL } }`, + { id: vodId } + ); + const manifestUrl = data?.video?.seekPreviewsURL; + if (!manifestUrl) { + // Cache the negative result so a VOD without a storyboard + // (private/unlisted/expired) doesn't get re-queried on every + // subsequent hover. + setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES); + return null; + } + + let manifest: StoryboardManifestEntry[] | null = null; + try { + const manifestResp = await axios.get(manifestUrl, { + timeout: 6000, + responseType: 'json', + headers: { 'User-Agent': 'TwitchVODManager/1.0' } + }); + manifest = manifestResp.data; + } catch (e) { + appendDebugLog('storyboard-manifest-failed', { vodId, error: String(e) }); + setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES); + return null; + } + + if (!Array.isArray(manifest) || manifest.length === 0) { + setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES); + return null; + } + + // Prefer the "high" quality entry — Twitch ships both "low" and + // "high" alongside each other. Falls back to whichever is present. + const entry = manifest.find((m) => m.quality === 'high') || manifest[0]; + if (!entry?.images?.length) { + setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES); + return null; + } + + // The manifest URL points at e.g. .../storyboards/2767872722-info.json + // and sprite filenames are relative (e.g. "2767872722-high-0.jpg"). + // Strip the JSON filename to get the base, then append the sprite. + const baseUrl = manifestUrl.replace(/\/[^/]+$/, '/'); + const firstSpriteUrl = baseUrl + entry.images[0]; + + const spriteDataUrl = await fetchAvatarAsDataUrl(firstSpriteUrl); + if (!spriteDataUrl) { + setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES); + return null; + } + + const storyboard: VodStoryboard = { + vodId, + spriteDataUrl, + cols: entry.cols, + rows: entry.rows, + cellWidth: entry.width, + cellHeight: entry.height, + framesInSprite: entry.cols * entry.rows + }; + setCachedValue(vodStoryboardCache, vodId, storyboard, MAX_VOD_STORYBOARD_CACHE_ENTRIES); + return storyboard; + }); +} + async function getClipInfo(clipId: string): Promise { const cachedClip = getCachedValue(clipInfoCache, clipId); if (cachedClip !== undefined) { @@ -6800,6 +6976,10 @@ ipcMain.handle('get-streamer-profile', async (_, login: string, forceRefresh?: b return await getStreamerProfile(login, forceRefresh === true); }); +ipcMain.handle('get-vod-storyboard', async (_, vodId: string): Promise => { + return await getVodStoryboard(vodId); +}); + ipcMain.handle('search-archive', (_, filter: Partial): ArchiveSearchResult => { const normalized: ArchiveSearchFilter = { query: typeof filter?.query === 'string' ? filter.query.trim() : '', diff --git a/src/preload.ts b/src/preload.ts index 4be39c8..be4131a 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -92,6 +92,7 @@ contextBridge.exposeInMainWorld('api', { getStorageStats: () => ipcRenderer.invoke('get-storage-stats'), getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'), getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh), + getVodStoryboard: (vodId: string) => ipcRenderer.invoke('get-vod-storyboard', vodId), searchArchive: (filter: Record) => ipcRenderer.invoke('search-archive', filter), runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options), readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath), diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index 3372f19..07164e8 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -239,6 +239,7 @@ interface StreamerProfile { login: string; displayName: string; avatarUrl: string; + bannerUrl: string; description: string; broadcasterType: '' | 'partner' | 'affiliate'; followerCount: number | null; @@ -247,10 +248,22 @@ interface StreamerProfile { isLive: boolean; currentTitle: string | null; currentGame: string | null; + currentStreamPreviewUrl: string; + currentStreamViewers: number | null; twitchUrl: string; fetchedAt: number; } +interface VodStoryboard { + vodId: string; + spriteDataUrl: string; + cols: number; + rows: number; + cellWidth: number; + cellHeight: number; + framesInSprite: number; +} + interface ArchiveSearchHit { fullPath: string; fileName: string; @@ -332,6 +345,7 @@ interface ApiBridge { getStorageStats(): Promise; getArchiveStats(): Promise; getStreamerProfile(login: string, forceRefresh?: boolean): Promise; + getVodStoryboard(vodId: string): Promise; searchArchive(filter: { query?: string; type?: 'all' | 'live' | 'vod' | 'chat' | 'events'; diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index 9e5eb1c..96cdf16 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -326,6 +326,8 @@ const UI_TEXT_DE = { lastStream: 'Letzter Stream', openTwitch: 'Auf Twitch oeffnen', openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen', + liveCardTooltip: 'Klick um sofort eine Live-Aufnahme zu starten', + recordNow: 'Jetzt aufnehmen', refresh: 'Aktualisieren', agoMinutes: 'vor {n} Min', agoHours: 'vor {n} h', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index 885ac50..80fdaa6 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -326,6 +326,8 @@ const UI_TEXT_EN = { lastStream: 'Last stream', openTwitch: 'Open on Twitch', openTwitchTooltip: 'Open this channel on twitch.tv', + liveCardTooltip: 'Click to start a live recording right now', + recordNow: 'Record now', refresh: 'Refresh', agoMinutes: '{n} min ago', agoHours: '{n} h ago', diff --git a/src/renderer-profile.ts b/src/renderer-profile.ts index 398488d..1beed83 100644 --- a/src/renderer-profile.ts +++ b/src/renderer-profile.ts @@ -77,7 +77,7 @@ function renderStreamerProfileCard(p: StreamerProfile): void { if (!el) return; el.classList.remove('streamer-profile-skeleton'); if (p.isLive) el.classList.add('is-live'); else el.classList.remove('is-live'); - el.style.display = 'flex'; + el.style.display = 'block'; const safeLogin = p.login.replace(/'/g, "\\'"); const safeUrl = p.twitchUrl.replace(/'/g, "\\'"); @@ -87,14 +87,9 @@ function renderStreamerProfileCard(p: StreamerProfile): void { : `
${escapeProfileHtml((p.displayName || p.login || '?').slice(0, 1).toUpperCase())}
`; const badges: string[] = []; - if (p.isLive) badges.push(`${escapeProfileHtml(UI_TEXT.profile.liveBadge)}`); if (p.broadcasterType === 'partner') badges.push(`${escapeProfileHtml(UI_TEXT.profile.partner)}`); if (p.broadcasterType === 'affiliate') badges.push(`${escapeProfileHtml(UI_TEXT.profile.affiliate)}`); - const liveInfo = (p.isLive && (p.currentTitle || p.currentGame)) - ? `
${p.currentTitle ? `${escapeProfileHtml(p.currentTitle)}` : ''}${p.currentTitle && p.currentGame ? ' · ' : ''}${p.currentGame ? escapeProfileHtml(p.currentGame) : ''}
` - : ''; - const bio = p.description ? `
${escapeProfileHtml(p.description)}
` : ''; @@ -115,31 +110,72 @@ function renderStreamerProfileCard(p: StreamerProfile): void { ${escapeProfileHtml(UI_TEXT.profile.lastStream)}: ${escapeProfileHtml(formatLastStreamAgo(p.lastStreamAt))} `; + // Banner-as-background — set inline so the URL stays per-streamer. + const bannerStyle = p.bannerUrl + ? `background-image: linear-gradient(135deg, rgba(14,14,16,0.78) 0%, rgba(14,14,16,0.92) 100%), url("${p.bannerUrl.replace(/"/g, '%22')}");` + : ''; + + // Live preview block — only when currently live. Big card with + // current preview frame + viewer count + title + game + record CTA. + const liveCard = p.isLive + ? ` +
+ ${p.currentStreamPreviewUrl + ? `Live preview` + : `
`} +
+
+ ${escapeProfileHtml(UI_TEXT.profile.liveBadge)} + ${typeof p.currentStreamViewers === 'number' ? ` ${escapeProfileHtml(formatProfileFollowers(p.currentStreamViewers))}` : ''} +
+ ${p.currentTitle ? `
${escapeProfileHtml(p.currentTitle)}
` : ''} + ${p.currentGame ? `
${escapeProfileHtml(p.currentGame)}
` : ''} + +
+
+ ` : ''; + applyProfileHtml(el, ` -
- ${avatarBlock} -
-
-
- ${escapeProfileHtml(p.displayName)} - - ${badges.join('')} + ${bannerStyle ? `
` : ''} +
+
+ ${avatarBlock}
- ${liveInfo} - ${bio} -
- ${followersStat} - ${vodsStat} - ${lastStreamStat} +
+
+ ${escapeProfileHtml(p.displayName)} + + ${badges.join('')} +
+ ${bio} +
+ ${followersStat} + ${vodsStat} + ${lastStreamStat} +
+
+
+ +
-
- - -
+ ${liveCard} `); } +function onProfileLivePreviewError(img: HTMLImageElement): void { + const parent = img.parentElement; + if (!parent) return; + const fallback = document.createElement('div'); + fallback.className = 'streamer-profile-live-thumb-fallback'; + parent.replaceChild(fallback, img); +} + +function triggerLiveRecordingFromProfile(login: string): void { + const fn = (window as unknown as { triggerLiveRecording?: (login: string) => Promise }).triggerLiveRecording; + if (typeof fn === 'function') void fn(login); +} + async function loadStreamerProfile(login: string, forceRefresh = false): Promise { if (!login) { hideStreamerProfileHeader(); @@ -193,3 +229,5 @@ function onProfileAvatarError(img: HTMLImageElement): void { (window as unknown as { hideStreamerProfileHeader: typeof hideStreamerProfileHeader }).hideStreamerProfileHeader = hideStreamerProfileHeader; (window as unknown as { openTwitchChannel: typeof openTwitchChannel }).openTwitchChannel = openTwitchChannel; (window as unknown as { onProfileAvatarError: typeof onProfileAvatarError }).onProfileAvatarError = onProfileAvatarError; +(window as unknown as { onProfileLivePreviewError: typeof onProfileLivePreviewError }).onProfileLivePreviewError = onProfileLivePreviewError; +(window as unknown as { triggerLiveRecordingFromProfile: typeof triggerLiveRecordingFromProfile }).triggerLiveRecordingFromProfile = triggerLiveRecordingFromProfile; diff --git a/src/renderer-vod-hover.ts b/src/renderer-vod-hover.ts new file mode 100644 index 0000000..a57793b --- /dev/null +++ b/src/renderer-vod-hover.ts @@ -0,0 +1,148 @@ +// 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(); +} diff --git a/src/styles.css b/src/styles.css index 3772805..4793276 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1948,17 +1948,40 @@ body.theme-light .modal { when a streamer is selected. Modeled on Twitch's own profile header for instant familiarity, but trimmed for the desktop-app context. */ .streamer-profile-header { - position: relative; - display: flex; - gap: 18px; - align-items: center; - padding: 18px 22px; + position: sticky; + top: 0; + z-index: 20; + display: block; + padding: 0; margin-bottom: 14px; background: linear-gradient(135deg, rgba(145, 70, 255, 0.10) 0%, rgba(0, 200, 83, 0.04) 100%), var(--bg-card); border: 1px solid var(--border-soft); border-radius: 12px; overflow: hidden; animation: profile-fade-in 0.32s ease-out; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +.streamer-profile-row { + position: relative; + z-index: 1; + display: flex; + gap: 18px; + align-items: center; + padding: 18px 22px; +} + +.streamer-profile-banner-bg { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + filter: blur(18px) saturate(1.2); + opacity: 0.55; + pointer-events: none; + z-index: 0; + transform: scale(1.1); /* avoid the blur edge bleed */ } @keyframes profile-fade-in { @@ -2207,7 +2230,7 @@ body.theme-light .modal { } @media (max-width: 720px) { - .streamer-profile-header { + .streamer-profile-row { flex-direction: column; align-items: flex-start; } @@ -2216,3 +2239,159 @@ body.theme-light .modal { width: 100%; } } + +/* ============================================ + LIVE PREVIEW CARD — inside the profile header + ============================================ */ +.streamer-profile-live-card { + position: relative; + z-index: 1; + display: flex; + gap: 14px; + margin: 0 14px 14px; + padding: 12px; + background: rgba(233, 25, 22, 0.10); + border: 1px solid rgba(233, 25, 22, 0.5); + border-radius: 10px; + cursor: pointer; + transition: transform 0.18s, box-shadow 0.18s, background 0.18s; + animation: profile-fade-in 0.4s ease-out; +} + +.streamer-profile-live-card:hover { + transform: translateY(-2px); + background: rgba(233, 25, 22, 0.16); + box-shadow: 0 6px 22px rgba(233, 25, 22, 0.20); +} + +.streamer-profile-live-thumb { + width: 240px; + height: 135px; + object-fit: cover; + border-radius: 6px; + flex-shrink: 0; + background: #000; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); +} + +.streamer-profile-live-thumb-fallback { + width: 240px; + height: 135px; + border-radius: 6px; + flex-shrink: 0; + background: linear-gradient(135deg, #2a0a0a, #1a0606); + display: flex; + align-items: center; + justify-content: center; + color: rgba(233, 25, 22, 0.5); +} + +.streamer-profile-live-thumb-fallback svg { + width: 48px; + height: 48px; +} + +.streamer-profile-live-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + justify-content: center; +} + +.streamer-profile-live-badge-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.streamer-profile-live-viewers { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--text); + font-weight: 600; +} + +.streamer-profile-live-viewers svg { + width: 14px; + height: 14px; + opacity: 0.85; +} + +.streamer-profile-live-title { + font-size: 16px; + font-weight: 600; + color: var(--text); + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.streamer-profile-live-game { + font-size: 13px; + color: var(--text-secondary); +} + +.streamer-profile-live-rec-btn { + margin-top: 6px; + align-self: flex-start; + background: #e91916 !important; + border-color: #e91916 !important; +} + +.streamer-profile-live-rec-btn:hover { + background: #ff3733 !important; + border-color: #ff3733 !important; + box-shadow: 0 4px 14px rgba(233, 25, 22, 0.4); +} + +@media (max-width: 720px) { + .streamer-profile-live-card { flex-direction: column; } + .streamer-profile-live-thumb, + .streamer-profile-live-thumb-fallback { width: 100%; height: 180px; } +} + +/* ============================================ + VOD HOVER PREVIEW — storyboard sprite cycling + ============================================ + Overlay sits as a direct child of .vod-card, positioned over the + thumbnail's bounding box. Width matches the card; aspect-ratio + 16/9 anchors the height to align with the thumbnail. */ +.vod-storyboard-preview { + position: absolute; + top: 0; + left: 0; + right: 0; + aspect-ratio: 16/9; + background-repeat: no-repeat; + opacity: 0; + transition: opacity 0.22s ease-out; + pointer-events: none; + z-index: 2; + border-radius: 8px 8px 0 0; + overflow: hidden; +} + +.vod-card.preview-active .vod-storyboard-preview { + opacity: 1; +} + +.vod-card.preview-active .vod-thumbnail { + filter: brightness(0.92); + transition: filter 0.3s; +} + +.vod-storyboard-preview::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 70%, rgba(0, 0, 0, 0.18) 100%); + pointer-events: none; +}