// Profile-header renderer. Owns the streamerProfileHeader div above the // VOD grid: hidden when no streamer is selected, skeleton while loading, // full card once profile data is back. Smooth fade-in is in CSS. let activeProfileRequestId = 0; let activeProfileLogin = ''; const streamerProfileCache = new Map(); const streamerProfileLoads = new Map>(); function formatProfileFollowers(count: number | null): string { if (count == null) return '–'; if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}M`; if (count >= 1_000) return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}K`; return String(count); } function formatLastStreamAgo(iso: string | null): string { if (!iso) return '–'; const ms = Date.now() - new Date(iso).getTime(); if (!Number.isFinite(ms) || ms < 0) return '–'; const minutes = Math.floor(ms / 60_000); if (minutes < 60) return UI_TEXT.profile.agoMinutes.replace('{n}', String(minutes)); const hours = Math.floor(minutes / 60); if (hours < 24) return UI_TEXT.profile.agoHours.replace('{n}', String(hours)); const days = Math.floor(hours / 24); if (days === 1) return UI_TEXT.profile.agoDay; if (days < 30) return UI_TEXT.profile.agoDays.replace('{n}', String(days)); const months = Math.floor(days / 30); if (months === 1) return UI_TEXT.profile.agoMonth; if (months < 12) return UI_TEXT.profile.agoMonths.replace('{n}', String(months)); const years = Math.floor(days / 365); if (years === 1) return UI_TEXT.profile.agoYear; return UI_TEXT.profile.agoYears.replace('{n}', String(years)); } function streamerProfilesMatch(left: StreamerProfile, right: StreamerProfile): boolean { return left.login === right.login && left.displayName === right.displayName && left.avatarUrl === right.avatarUrl && left.bannerUrl === right.bannerUrl && left.description === right.description && left.broadcasterType === right.broadcasterType && left.followerCount === right.followerCount && left.vodCount === right.vodCount && left.lastStreamAt === right.lastStreamAt && left.isLive === right.isLive && left.currentTitle === right.currentTitle && left.currentGame === right.currentGame && left.currentStreamPreviewUrl === right.currentStreamPreviewUrl && left.currentStreamViewers === right.currentStreamViewers && left.twitchUrl === right.twitchUrl; } function hideStreamerProfileHeader(): void { activeProfileRequestId += 1; activeProfileLogin = ''; const el = document.getElementById('streamerProfileHeader'); if (!el) return; el.classList.add('is-hidden'); applyHtml(el, ''); } function renderStreamerProfileSkeleton(login: string): void { const el = document.getElementById('streamerProfileHeader'); if (!el) return; el.classList.remove('is-live', 'is-hidden'); el.classList.add('streamer-profile-skeleton'); applyHtml(el, `
`); } function renderStreamerProfileCard(p: StreamerProfile): void { const el = document.getElementById('streamerProfileHeader'); if (!el) return; el.classList.remove('streamer-profile-skeleton', 'is-hidden'); if (p.isLive) el.classList.add('is-live'); else el.classList.remove('is-live'); const safeLogin = p.login.replace(/'/g, "\\'"); const safeUrl = p.twitchUrl.replace(/'/g, "\\'"); const avatarBlock = p.avatarUrl ? `${escapeHtml(p.displayName)}` : `
${escapeHtml((p.displayName || p.login || '?').slice(0, 1).toUpperCase())}
`; const badges: string[] = []; if (p.broadcasterType === 'partner') badges.push(`${escapeHtml(UI_TEXT.profile.partner)}`); if (p.broadcasterType === 'affiliate') badges.push(`${escapeHtml(UI_TEXT.profile.affiliate)}`); const bio = p.description ? `
${escapeHtml(p.description)}
` : ''; const followersStat = `
${escapeHtml(formatProfileFollowers(p.followerCount))} ${escapeHtml(UI_TEXT.profile.followers)}
`; const vodsStat = `
${p.vodCount} ${escapeHtml(UI_TEXT.profile.vods)}
`; const lastStreamStat = `
${escapeHtml(UI_TEXT.profile.lastStream)}: ${escapeHtml(formatLastStreamAgo(p.lastStreamAt))}
`; // Banner-as-background — set inline so the URL stays per-streamer. // The darkening gradient is handled by the .streamer-profile-header::before // pseudo so the banner itself stays bright and unfiltered here. const bannerStyle = p.bannerUrl ? `background-image: 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 ? `${escapeHtml(UI_TEXT.profile.liveThumbAlt)}` : `
`}
${escapeHtml(UI_TEXT.profile.liveBadge)} ${typeof p.currentStreamViewers === 'number' ? ` ${escapeHtml(formatProfileFollowers(p.currentStreamViewers))}` : ''}
${p.currentTitle ? `
${escapeHtml(p.currentTitle)}
` : ''} ${p.currentGame ? `
${escapeHtml(p.currentGame)}
` : ''}
` : ''; applyHtml(el, ` ${bannerStyle ? `
` : ''}
${avatarBlock}
${escapeHtml(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(); return; } const key = login.trim().toLowerCase(); activeProfileLogin = key; const reqId = ++activeProfileRequestId; const cached = streamerProfileCache.get(key); if (cached) renderStreamerProfileCard(cached); else renderStreamerProfileSkeleton(login); try { const profile = await fetchStreamerProfile(login, forceRefresh); // Stale-request guard — user may have clicked another streamer // while we were waiting on the API. if (reqId !== activeProfileRequestId) return; if (!profile) { if (!cached) hideStreamerProfileHeader(); return; } const rememberDisplayName = (window as unknown as { rememberStreamerDisplayName?: (login: string, displayName: string) => void }).rememberStreamerDisplayName; if (typeof rememberDisplayName === 'function') rememberDisplayName(profile.login, profile.displayName); if (!cached || !streamerProfilesMatch(cached, profile)) renderStreamerProfileCard(profile); } catch (_) { if (reqId === activeProfileRequestId && !cached) hideStreamerProfileHeader(); } } async function fetchStreamerProfile(login: string, forceRefresh = false): Promise { const key = login.trim().toLowerCase(); const cached = streamerProfileCache.get(key); if (!forceRefresh && cached) return cached; const pending = streamerProfileLoads.get(key); if (pending) return pending; const task = window.api.getStreamerProfile(login, forceRefresh).then((profile) => { if (profile) streamerProfileCache.set(key, profile); return profile; }); streamerProfileLoads.set(key, task); try { return await task; } finally { if (streamerProfileLoads.get(key) === task) streamerProfileLoads.delete(key); } } async function preloadStreamerProfiles(logins: string[]): Promise { await Promise.allSettled(logins.map((login) => fetchStreamerProfile(login))); } async function refreshStreamerProfilesInBackground(logins: string[]): Promise { await Promise.allSettled(logins.map(async (login) => { const previous = streamerProfileCache.get(login.trim().toLowerCase()); const profile = await fetchStreamerProfile(login, true); if (profile && activeProfileLogin === login.trim().toLowerCase() && (!previous || !streamerProfilesMatch(previous, profile))) renderStreamerProfileCard(profile); })); } function refreshStreamerProfile(login: string): void { void loadStreamerProfile(login, true); } function openTwitchChannel(url: string): void { void window.api.openExternal(url); } function onProfileAvatarError(img: HTMLImageElement): void { // Avatar URL hit a 404 or CORS oddity. Swap to the fallback letter // tile so we don't end up with a broken-image icon. const parent = img.parentElement; if (!parent) return; const fallback = document.createElement('div'); fallback.className = 'streamer-profile-avatar-fallback'; const alt = img.getAttribute('alt') || ''; fallback.textContent = (alt || '?').slice(0, 1).toUpperCase(); parent.replaceChild(fallback, img); } (window as unknown as { loadStreamerProfile: typeof loadStreamerProfile; refreshStreamerProfile: typeof refreshStreamerProfile; hideStreamerProfileHeader: typeof hideStreamerProfileHeader; openTwitchChannel: typeof openTwitchChannel; onProfileAvatarError: typeof onProfileAvatarError; }).loadStreamerProfile = loadStreamerProfile; (window as unknown as { refreshStreamerProfile: typeof refreshStreamerProfile }).refreshStreamerProfile = refreshStreamerProfile; (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; (window as unknown as { preloadStreamerProfiles: typeof preloadStreamerProfiles }).preloadStreamerProfiles = preloadStreamerProfiles; (window as unknown as { refreshStreamerProfilesInBackground: typeof refreshStreamerProfilesInBackground }).refreshStreamerProfilesInBackground = refreshStreamerProfilesInBackground;