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>
This commit is contained in:
xRangerDE 2026-05-11 00:55:17 +02:00
parent 1b87a2611e
commit 3c73efbad7
9 changed files with 636 additions and 71 deletions

View File

@ -842,6 +842,7 @@
<script src="../dist/renderer-stats.js"></script> <script src="../dist/renderer-stats.js"></script>
<script src="../dist/renderer-archive.js"></script> <script src="../dist/renderer-archive.js"></script>
<script src="../dist/renderer-profile.js"></script> <script src="../dist/renderer-profile.js"></script>
<script src="../dist/renderer-vod-hover.js"></script>
<script src="../dist/renderer.js"></script> <script src="../dist/renderer.js"></script>
</body> </body>
</html> </html>

View File

@ -2303,6 +2303,7 @@ interface StreamerProfile {
login: string; login: string;
displayName: string; displayName: string;
avatarUrl: string; avatarUrl: string;
bannerUrl: string;
description: string; description: string;
broadcasterType: '' | 'partner' | 'affiliate'; broadcasterType: '' | 'partner' | 'affiliate';
followerCount: number | null; followerCount: number | null;
@ -2311,6 +2312,8 @@ interface StreamerProfile {
isLive: boolean; isLive: boolean;
currentTitle: string | null; currentTitle: string | null;
currentGame: string | null; currentGame: string | null;
currentStreamPreviewUrl: string;
currentStreamViewers: number | null;
twitchUrl: string; twitchUrl: string;
fetchedAt: number; fetchedAt: number;
} }
@ -2393,25 +2396,31 @@ interface PublicProfileQueryResult {
displayName: string; displayName: string;
description: string | null; description: string | null;
profileImageURL: string | null; profileImageURL: string | null;
bannerImageURL: string | null;
roles?: { isPartner: boolean; isAffiliate: boolean } | null; roles?: { isPartner: boolean; isAffiliate: boolean } | null;
followers?: { totalCount: number } | 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; } | null;
} }
async function fetchPublicStreamerProfile(login: string): Promise<{ async function fetchPublicStreamerProfile(login: string): Promise<{
displayName: string; displayName: string;
avatarUrl: string; avatarUrl: string;
bannerUrl: string;
description: string; description: string;
broadcasterType: '' | 'partner' | 'affiliate'; broadcasterType: '' | 'partner' | 'affiliate';
followerCount: number | null; followerCount: number | null;
stream: { previewUrl: string; viewers: number | null; title: string | null; game: string | null } | null;
} | null> { } | null> {
// The public (unauthenticated) GQL schema does NOT expose // Same query also pulls bannerImageURL and the current stream's
// `broadcasterType` directly — querying it returns an errors[] response // preview + viewer count when live — saves a separate roundtrip.
// 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.
const data = await fetchPublicTwitchGql<PublicProfileQueryResult>( const data = await fetchPublicTwitchGql<PublicProfileQueryResult>(
`query($login: String!) { `query($login: String!) {
user(login: $login) { user(login: $login) {
@ -2420,8 +2429,17 @@ async function fetchPublicStreamerProfile(login: string): Promise<{
displayName displayName
description description
profileImageURL(width: 150) profileImageURL(width: 150)
bannerImageURL
roles { isPartner isAffiliate } roles { isPartner isAffiliate }
followers { totalCount } followers { totalCount }
stream {
id
type
title
viewersCount
previewImageURL(width: 640, height: 360)
game { name }
}
} }
}`, }`,
{ login } { login }
@ -2431,12 +2449,21 @@ async function fetchPublicStreamerProfile(login: string): Promise<{
const broadcasterType: '' | 'partner' | 'affiliate' = roles?.isPartner const broadcasterType: '' | 'partner' | 'affiliate' = roles?.isPartner
? 'partner' ? 'partner'
: (roles?.isAffiliate ? 'affiliate' : ''); : (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 { return {
displayName: data.user.displayName || login, displayName: data.user.displayName || login,
avatarUrl: data.user.profileImageURL || '', avatarUrl: data.user.profileImageURL || '',
bannerUrl: data.user.bannerImageURL || '',
description: data.user.description || '', description: data.user.description || '',
broadcasterType, 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 () => { return await withInFlightDedup(inFlightProfileRequests, normalized, async () => {
runtimeMetrics.cacheMisses += 1; runtimeMetrics.cacheMisses += 1;
// Prefer Helix for the core fields when we have credentials (richer // Public GQL is now the SOURCE for everything except some of the
// bio, faster, less likely to rate-limit), then fill in followers // core text fields when Helix is authenticated — because public
// via public GQL since Helix /channels/followers needs a moderator // GQL is the only route that gives us the banner image + current
// token we don't have. Fall back fully to public GQL when not // stream preview in one shot, and skipping it would mean two
// authenticated. // extra roundtrips. Helix takes precedence for displayName /
// description (those fields are sometimes richer there).
let displayName = normalized; let displayName = normalized;
let avatarUrl = ''; let avatarUrl = '';
let bannerUrl = '';
let description = ''; let description = '';
let broadcasterType: '' | 'partner' | 'affiliate' = ''; let broadcasterType: '' | 'partner' | 'affiliate' = '';
let streamFromPublic: Awaited<ReturnType<typeof fetchPublicStreamerProfile>> 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); const helixUser = await fetchHelixUserInfo(normalized);
if (helixUser) { if (helixUser) {
displayName = helixUser.display_name || normalized; displayName = helixUser.display_name || displayName;
avatarUrl = helixUser.profile_image_url || ''; if (helixUser.profile_image_url) avatarUrl = helixUser.profile_image_url;
description = helixUser.description || ''; if (helixUser.description) description = helixUser.description;
const bt = (helixUser.broadcaster_type || '').toLowerCase(); const bt = (helixUser.broadcaster_type || '').toLowerCase();
broadcasterType = (bt === 'partner' || bt === 'affiliate') ? bt : ''; if (bt === 'partner' || bt === 'affiliate') broadcasterType = bt;
} else {
const publicProfile = await fetchPublicStreamerProfile(normalized);
if (publicProfile) {
displayName = publicProfile.displayName;
avatarUrl = publicProfile.avatarUrl;
description = publicProfile.description;
broadcasterType = publicProfile.broadcasterType;
}
} }
// Follower count always from public GQL — even when Helix // followerCountFromPublic comes from the public profile query
// succeeded for the rest. One extra ~50ms call, but it's the // above — no separate follower roundtrip needed.
// only reliable way without scoped tokens. const followerCount = followerCountFromPublic;
const followerCount = await fetchOnlyFollowerCount(normalized);
// Derive vod count + last stream from the already-cached VOD list // Derive vod count + last stream from the already-cached VOD list
// when we have an id. No extra network hit. // 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 isLive = false;
let currentTitle: string | null = null; let currentTitle: string | null = null;
let currentGame: string | null = null; let currentGame: string | null = null;
try { let currentStreamPreviewRemoteUrl = '';
const live = await getLiveStreamInfo(normalized); let currentStreamViewers: number | null = null;
if (live) {
isLive = live.isLive;
currentTitle = live.title || null;
currentGame = live.gameName || null;
}
} catch (_) { /* best-effort */ }
// Embed the avatar bytes as a data URL so the renderer doesn't if (streamFromPublic) {
// have to make its own HTTPS request. Bypasses any CSP, CORS, // Public-GQL already told us this user is live and gave us a
// referrer-policy, or Electron renderer image-loading quirks. // preview frame URL + viewer count + game/title. Don't double-
const avatarDataUrl = avatarUrl ? await fetchAvatarAsDataUrl(avatarUrl) : ''; // 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 = { const profile: StreamerProfile = {
login: normalized, login: normalized,
displayName, displayName,
avatarUrl: avatarDataUrl || avatarUrl, avatarUrl: avatarDataUrl || avatarUrl,
bannerUrl: bannerDataUrl || bannerUrl,
description, description,
broadcasterType, broadcasterType,
followerCount, followerCount,
@ -2542,6 +2600,8 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise<
isLive, isLive,
currentTitle, currentTitle,
currentGame, currentGame,
currentStreamPreviewUrl: livePreviewDataUrl || currentStreamPreviewRemoteUrl,
currentStreamViewers,
twitchUrl: `https://www.twitch.tv/${normalized}`, twitchUrl: `https://www.twitch.tv/${normalized}`,
fetchedAt: Date.now() 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<string, CacheEntry<VodStoryboard | null>>();
const inFlightStoryboardRequests = new Map<string, Promise<VodStoryboard | null>>();
interface StoryboardManifestEntry {
count: number;
width: number;
height: number;
cols: number;
rows: number;
images: string[];
quality: string;
interval: number;
}
async function getVodStoryboard(vodId: string): Promise<VodStoryboard | null> {
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<StoryboardManifestEntry[]>(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<any | null> { async function getClipInfo(clipId: string): Promise<any | null> {
const cachedClip = getCachedValue(clipInfoCache, clipId); const cachedClip = getCachedValue(clipInfoCache, clipId);
if (cachedClip !== undefined) { if (cachedClip !== undefined) {
@ -6800,6 +6976,10 @@ ipcMain.handle('get-streamer-profile', async (_, login: string, forceRefresh?: b
return await getStreamerProfile(login, forceRefresh === true); return await getStreamerProfile(login, forceRefresh === true);
}); });
ipcMain.handle('get-vod-storyboard', async (_, vodId: string): Promise<VodStoryboard | null> => {
return await getVodStoryboard(vodId);
});
ipcMain.handle('search-archive', (_, filter: Partial<ArchiveSearchFilter>): ArchiveSearchResult => { ipcMain.handle('search-archive', (_, filter: Partial<ArchiveSearchFilter>): ArchiveSearchResult => {
const normalized: ArchiveSearchFilter = { const normalized: ArchiveSearchFilter = {
query: typeof filter?.query === 'string' ? filter.query.trim() : '', query: typeof filter?.query === 'string' ? filter.query.trim() : '',

View File

@ -92,6 +92,7 @@ contextBridge.exposeInMainWorld('api', {
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'), getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'), getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'),
getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh), getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh),
getVodStoryboard: (vodId: string) => ipcRenderer.invoke('get-vod-storyboard', vodId),
searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter), searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter),
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options), runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath), readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath),

View File

@ -239,6 +239,7 @@ interface StreamerProfile {
login: string; login: string;
displayName: string; displayName: string;
avatarUrl: string; avatarUrl: string;
bannerUrl: string;
description: string; description: string;
broadcasterType: '' | 'partner' | 'affiliate'; broadcasterType: '' | 'partner' | 'affiliate';
followerCount: number | null; followerCount: number | null;
@ -247,10 +248,22 @@ interface StreamerProfile {
isLive: boolean; isLive: boolean;
currentTitle: string | null; currentTitle: string | null;
currentGame: string | null; currentGame: string | null;
currentStreamPreviewUrl: string;
currentStreamViewers: number | null;
twitchUrl: string; twitchUrl: string;
fetchedAt: number; fetchedAt: number;
} }
interface VodStoryboard {
vodId: string;
spriteDataUrl: string;
cols: number;
rows: number;
cellWidth: number;
cellHeight: number;
framesInSprite: number;
}
interface ArchiveSearchHit { interface ArchiveSearchHit {
fullPath: string; fullPath: string;
fileName: string; fileName: string;
@ -332,6 +345,7 @@ interface ApiBridge {
getStorageStats(): Promise<StorageStatsResult>; getStorageStats(): Promise<StorageStatsResult>;
getArchiveStats(): Promise<ArchiveStats>; getArchiveStats(): Promise<ArchiveStats>;
getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>; getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>;
getVodStoryboard(vodId: string): Promise<VodStoryboard | null>;
searchArchive(filter: { searchArchive(filter: {
query?: string; query?: string;
type?: 'all' | 'live' | 'vod' | 'chat' | 'events'; type?: 'all' | 'live' | 'vod' | 'chat' | 'events';

View File

@ -326,6 +326,8 @@ const UI_TEXT_DE = {
lastStream: 'Letzter Stream', lastStream: 'Letzter Stream',
openTwitch: 'Auf Twitch oeffnen', openTwitch: 'Auf Twitch oeffnen',
openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen', openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen',
liveCardTooltip: 'Klick um sofort eine Live-Aufnahme zu starten',
recordNow: 'Jetzt aufnehmen',
refresh: 'Aktualisieren', refresh: 'Aktualisieren',
agoMinutes: 'vor {n} Min', agoMinutes: 'vor {n} Min',
agoHours: 'vor {n} h', agoHours: 'vor {n} h',

View File

@ -326,6 +326,8 @@ const UI_TEXT_EN = {
lastStream: 'Last stream', lastStream: 'Last stream',
openTwitch: 'Open on Twitch', openTwitch: 'Open on Twitch',
openTwitchTooltip: 'Open this channel on twitch.tv', openTwitchTooltip: 'Open this channel on twitch.tv',
liveCardTooltip: 'Click to start a live recording right now',
recordNow: 'Record now',
refresh: 'Refresh', refresh: 'Refresh',
agoMinutes: '{n} min ago', agoMinutes: '{n} min ago',
agoHours: '{n} h ago', agoHours: '{n} h ago',

View File

@ -77,7 +77,7 @@ function renderStreamerProfileCard(p: StreamerProfile): void {
if (!el) return; if (!el) return;
el.classList.remove('streamer-profile-skeleton'); el.classList.remove('streamer-profile-skeleton');
if (p.isLive) el.classList.add('is-live'); else el.classList.remove('is-live'); 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 safeLogin = p.login.replace(/'/g, "\\'");
const safeUrl = p.twitchUrl.replace(/'/g, "\\'"); const safeUrl = p.twitchUrl.replace(/'/g, "\\'");
@ -87,14 +87,9 @@ function renderStreamerProfileCard(p: StreamerProfile): void {
: `<div class="streamer-profile-avatar-fallback">${escapeProfileHtml((p.displayName || p.login || '?').slice(0, 1).toUpperCase())}</div>`; : `<div class="streamer-profile-avatar-fallback">${escapeProfileHtml((p.displayName || p.login || '?').slice(0, 1).toUpperCase())}</div>`;
const badges: string[] = []; const badges: string[] = [];
if (p.isLive) badges.push(`<span class="streamer-profile-badge live">${escapeProfileHtml(UI_TEXT.profile.liveBadge)}</span>`);
if (p.broadcasterType === 'partner') badges.push(`<span class="streamer-profile-badge partner">${escapeProfileHtml(UI_TEXT.profile.partner)}</span>`); if (p.broadcasterType === 'partner') badges.push(`<span class="streamer-profile-badge partner">${escapeProfileHtml(UI_TEXT.profile.partner)}</span>`);
if (p.broadcasterType === 'affiliate') badges.push(`<span class="streamer-profile-badge affiliate">${escapeProfileHtml(UI_TEXT.profile.affiliate)}</span>`); if (p.broadcasterType === 'affiliate') badges.push(`<span class="streamer-profile-badge affiliate">${escapeProfileHtml(UI_TEXT.profile.affiliate)}</span>`);
const liveInfo = (p.isLive && (p.currentTitle || p.currentGame))
? `<div class="streamer-profile-live-info">${p.currentTitle ? `<strong>${escapeProfileHtml(p.currentTitle)}</strong>` : ''}${p.currentTitle && p.currentGame ? ' · ' : ''}${p.currentGame ? escapeProfileHtml(p.currentGame) : ''}</div>`
: '';
const bio = p.description const bio = p.description
? `<div class="streamer-profile-bio" title="${escapeProfileHtml(p.description)}">${escapeProfileHtml(p.description)}</div>` ? `<div class="streamer-profile-bio" title="${escapeProfileHtml(p.description)}">${escapeProfileHtml(p.description)}</div>`
: ''; : '';
@ -115,31 +110,72 @@ function renderStreamerProfileCard(p: StreamerProfile): void {
${escapeProfileHtml(UI_TEXT.profile.lastStream)}: <strong>${escapeProfileHtml(formatLastStreamAgo(p.lastStreamAt))}</strong> ${escapeProfileHtml(UI_TEXT.profile.lastStream)}: <strong>${escapeProfileHtml(formatLastStreamAgo(p.lastStreamAt))}</strong>
</div>`; </div>`;
// 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
? `
<div class="streamer-profile-live-card" onclick="triggerLiveRecordingFromProfile('${safeLogin}')" title="${escapeProfileHtml(UI_TEXT.profile.liveCardTooltip)}">
${p.currentStreamPreviewUrl
? `<img class="streamer-profile-live-thumb" src="${escapeProfileHtml(p.currentStreamPreviewUrl)}" alt="Live preview" onerror="onProfileLivePreviewError(this)">`
: `<div class="streamer-profile-live-thumb-fallback"></div>`}
<div class="streamer-profile-live-body">
<div class="streamer-profile-live-badge-row">
<span class="streamer-profile-badge live">${escapeProfileHtml(UI_TEXT.profile.liveBadge)}</span>
${typeof p.currentStreamViewers === 'number' ? `<span class="streamer-profile-live-viewers"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg> ${escapeProfileHtml(formatProfileFollowers(p.currentStreamViewers))}</span>` : ''}
</div>
${p.currentTitle ? `<div class="streamer-profile-live-title">${escapeProfileHtml(p.currentTitle)}</div>` : ''}
${p.currentGame ? `<div class="streamer-profile-live-game">${escapeProfileHtml(p.currentGame)}</div>` : ''}
<button class="streamer-profile-btn primary streamer-profile-live-rec-btn" onclick="event.stopPropagation(); triggerLiveRecordingFromProfile('${safeLogin}')">${escapeProfileHtml(UI_TEXT.profile.recordNow)}</button>
</div>
</div>
` : '';
applyProfileHtml(el, ` applyProfileHtml(el, `
<div class="streamer-profile-avatar-wrap" onclick="openTwitchChannel('${safeUrl}')" title="${escapeProfileHtml(UI_TEXT.profile.openTwitchTooltip)}"> ${bannerStyle ? `<div class="streamer-profile-banner-bg" style="${bannerStyle}"></div>` : ''}
${avatarBlock} <div class="streamer-profile-row">
</div> <div class="streamer-profile-avatar-wrap" onclick="openTwitchChannel('${safeUrl}')" title="${escapeProfileHtml(UI_TEXT.profile.openTwitchTooltip)}">
<div class="streamer-profile-body"> ${avatarBlock}
<div class="streamer-profile-name-row">
<span class="streamer-profile-display-name">${escapeProfileHtml(p.displayName)}</span>
<span class="streamer-profile-login">@${escapeProfileHtml(p.login)}</span>
${badges.join('')}
</div> </div>
${liveInfo} <div class="streamer-profile-body">
${bio} <div class="streamer-profile-name-row">
<div class="streamer-profile-stats"> <span class="streamer-profile-display-name">${escapeProfileHtml(p.displayName)}</span>
${followersStat} <span class="streamer-profile-login">@${escapeProfileHtml(p.login)}</span>
${vodsStat} ${badges.join('')}
${lastStreamStat} </div>
${bio}
<div class="streamer-profile-stats">
${followersStat}
${vodsStat}
${lastStreamStat}
</div>
</div>
<div class="streamer-profile-actions">
<button class="streamer-profile-btn primary" onclick="openTwitchChannel('${safeUrl}')">${escapeProfileHtml(UI_TEXT.profile.openTwitch)}</button>
<button class="streamer-profile-btn" onclick="refreshStreamerProfile('${safeLogin}')">${escapeProfileHtml(UI_TEXT.profile.refresh)}</button>
</div> </div>
</div> </div>
<div class="streamer-profile-actions"> ${liveCard}
<button class="streamer-profile-btn primary" onclick="openTwitchChannel('${safeUrl}')">${escapeProfileHtml(UI_TEXT.profile.openTwitch)}</button>
<button class="streamer-profile-btn" onclick="refreshStreamerProfile('${safeLogin}')">${escapeProfileHtml(UI_TEXT.profile.refresh)}</button>
</div>
`); `);
} }
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<void> }).triggerLiveRecording;
if (typeof fn === 'function') void fn(login);
}
async function loadStreamerProfile(login: string, forceRefresh = false): Promise<void> { async function loadStreamerProfile(login: string, forceRefresh = false): Promise<void> {
if (!login) { if (!login) {
hideStreamerProfileHeader(); hideStreamerProfileHeader();
@ -193,3 +229,5 @@ function onProfileAvatarError(img: HTMLImageElement): void {
(window as unknown as { hideStreamerProfileHeader: typeof hideStreamerProfileHeader }).hideStreamerProfileHeader = hideStreamerProfileHeader; (window as unknown as { hideStreamerProfileHeader: typeof hideStreamerProfileHeader }).hideStreamerProfileHeader = hideStreamerProfileHeader;
(window as unknown as { openTwitchChannel: typeof openTwitchChannel }).openTwitchChannel = openTwitchChannel; (window as unknown as { openTwitchChannel: typeof openTwitchChannel }).openTwitchChannel = openTwitchChannel;
(window as unknown as { onProfileAvatarError: typeof onProfileAvatarError }).onProfileAvatarError = onProfileAvatarError; (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;

148
src/renderer-vod-hover.ts Normal file
View File

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

View File

@ -1948,17 +1948,40 @@ body.theme-light .modal {
when a streamer is selected. Modeled on Twitch's own profile header when a streamer is selected. Modeled on Twitch's own profile header
for instant familiarity, but trimmed for the desktop-app context. */ for instant familiarity, but trimmed for the desktop-app context. */
.streamer-profile-header { .streamer-profile-header {
position: relative; position: sticky;
display: flex; top: 0;
gap: 18px; z-index: 20;
align-items: center; display: block;
padding: 18px 22px; padding: 0;
margin-bottom: 14px; margin-bottom: 14px;
background: linear-gradient(135deg, rgba(145, 70, 255, 0.10) 0%, rgba(0, 200, 83, 0.04) 100%), var(--bg-card); 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: 1px solid var(--border-soft);
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
animation: profile-fade-in 0.32s ease-out; 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 { @keyframes profile-fade-in {
@ -2207,7 +2230,7 @@ body.theme-light .modal {
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.streamer-profile-header { .streamer-profile-row {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
@ -2216,3 +2239,159 @@ body.theme-light .modal {
width: 100%; 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;
}