// 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; 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 < 30) return UI_TEXT.profile.agoDays.replace('{n}', String(days)); const months = Math.floor(days / 30); if (months < 12) return UI_TEXT.profile.agoMonths.replace('{n}', String(months)); const years = Math.floor(days / 365); return UI_TEXT.profile.agoYears.replace('{n}', String(years)); } function hideStreamerProfileHeader(): void { const el = document.getElementById('streamerProfileHeader'); if (!el) return; el.style.display = 'none'; applyHtml(el, ''); } function renderStreamerProfileSkeleton(login: string): void { const el = document.getElementById('streamerProfileHeader'); if (!el) return; el.classList.remove('is-live'); el.classList.add('streamer-profile-skeleton'); el.style.display = 'flex'; applyHtml(el, `
`); } function renderStreamerProfileCard(p: StreamerProfile): void { const el = document.getElementById('streamerProfileHeader'); 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 = 'block'; 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 ? `Live preview` : `
`}
${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 reqId = ++activeProfileRequestId; renderStreamerProfileSkeleton(login); try { const profile = await window.api.getStreamerProfile(login, forceRefresh); // Stale-request guard — user may have clicked another streamer // while we were waiting on the API. if (reqId !== activeProfileRequestId) return; if (!profile) { hideStreamerProfileHeader(); return; } renderStreamerProfileCard(profile); } catch (_) { if (reqId === activeProfileRequestId) hideStreamerProfileHeader(); } } 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;