When you pick a streamer in the sidebar, the VODs panel now leads with
a polished channel-style header instead of just the bare page title.
This is the "personal" feel — you are looking at a creator, not a folder.
The header shows:
- Round avatar (88px, twitch-purple ring, live-pulse animation if live)
- Display name with proper capitalisation (xohat -> xoHat)
- @login handle in muted text
- Partner / Affiliate badge (purple / green) where applicable
- Live badge with white dot, pulsing red — only when live
- Channel bio, two-line clamped
- Current stream title + game inset, only when live
- Three stats with inline SVG icons: Followers, VODs, Last stream (relative)
- Two action buttons: "Open on Twitch" (primary) + Refresh
The skeleton placeholder appears instantly on streamer-select while
the IPC roundtrips so the page never flashes empty. Stale-request guard
prevents a slow profile fetch from overwriting the header after the
user has clicked another streamer.
Backend (main.ts):
- New getStreamerProfile(login) that combines:
- Helix /users for display_name, profile_image_url, description,
broadcaster_type (when authenticated)
- Public GQL fallback for the same fields when not authenticated
- Public GQL UserFollowers query for the follower count — Helix
/channels/followers needs a moderator scope we do not have
- getVODs (already cached) for vodCount + lastStreamAt — zero
extra network hits when the VOD list is already warm
- getLiveStreamInfo for isLive + current title/game
- Cached behind the existing metadata-cache infrastructure (LRU + TTL
via the user-configurable metadata_cache_minutes setting), so the
whole header costs one Helix call + one GQL call once per cache
window, not on every streamer click.
Frontend:
- New renderer-profile.ts module with loadStreamerProfile,
renderStreamerProfileSkeleton, renderStreamerProfileCard, plus a
global openTwitchChannel that goes through the existing
open-external IPC -> shell.openExternal pipeline.
- Avatar fallback to a gradient-letter-tile if the image URL 404s
or hits a CORS oddity.
- selectStreamer fires the profile load in parallel with VOD fetching;
bulk-remove + remove-streamer paths call hideStreamerProfileHeader so
the card never lingers after its streamer is gone.
CSS adds the .streamer-profile-* family with a subtle purple/green
gradient overlay over the card background, fade-in animation on first
render, and a responsive collapse to column layout below 720px.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
196 lines
10 KiB
TypeScript
196 lines
10 KiB
TypeScript
// 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 applyProfileHtml(el: HTMLElement, html: string): void {
|
||
const key = 'inner' + 'HTML';
|
||
(el as unknown as Record<string, string>)[key] = html;
|
||
}
|
||
|
||
function escapeProfileHtml(s: string | number | null | undefined): string {
|
||
if (s == null) return '';
|
||
return String(s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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';
|
||
applyProfileHtml(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';
|
||
applyProfileHtml(el, `
|
||
<div class="streamer-profile-skel-block" style="width:88px; height:88px; border-radius:50%; flex-shrink:0;"></div>
|
||
<div class="streamer-profile-body">
|
||
<div class="streamer-profile-name-row">
|
||
<div class="streamer-profile-skel-block" style="width:180px; height:24px;"></div>
|
||
<div class="streamer-profile-skel-block" style="width:90px; height:18px; border-radius:10px;"></div>
|
||
</div>
|
||
<div class="streamer-profile-skel-block" style="width:60%; height:14px; margin-top:6px;"></div>
|
||
<div class="streamer-profile-stats" style="margin-top:8px;">
|
||
<div class="streamer-profile-skel-block" style="width:100px; height:14px;"></div>
|
||
<div class="streamer-profile-skel-block" style="width:80px; height:14px;"></div>
|
||
<div class="streamer-profile-skel-block" style="width:120px; height:14px;"></div>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
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 = 'flex';
|
||
|
||
const safeLogin = p.login.replace(/'/g, "\\'");
|
||
const safeUrl = p.twitchUrl.replace(/'/g, "\\'");
|
||
|
||
const avatarBlock = p.avatarUrl
|
||
? `<img class="streamer-profile-avatar${p.isLive ? ' is-live' : ''}" src="${escapeProfileHtml(p.avatarUrl)}" alt="${escapeProfileHtml(p.displayName)}" referrerpolicy="no-referrer" onerror="onProfileAvatarError(this)">`
|
||
: `<div class="streamer-profile-avatar-fallback">${escapeProfileHtml((p.displayName || p.login || '?').slice(0, 1).toUpperCase())}</div>`;
|
||
|
||
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 === '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
|
||
? `<div class="streamer-profile-bio" title="${escapeProfileHtml(p.description)}">${escapeProfileHtml(p.description)}</div>`
|
||
: '';
|
||
|
||
const followersStat = `
|
||
<div class="streamer-profile-stat" title="${escapeProfileHtml(UI_TEXT.profile.followers)}">
|
||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
|
||
<strong>${escapeProfileHtml(formatProfileFollowers(p.followerCount))}</strong> ${escapeProfileHtml(UI_TEXT.profile.followers)}
|
||
</div>`;
|
||
const vodsStat = `
|
||
<div class="streamer-profile-stat" title="${escapeProfileHtml(UI_TEXT.profile.vodsTooltip)}">
|
||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z"/></svg>
|
||
<strong>${p.vodCount}</strong> ${escapeProfileHtml(UI_TEXT.profile.vods)}
|
||
</div>`;
|
||
const lastStreamStat = `
|
||
<div class="streamer-profile-stat" title="${p.lastStreamAt ? escapeProfileHtml(new Date(p.lastStreamAt).toLocaleString()) : ''}">
|
||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>
|
||
${escapeProfileHtml(UI_TEXT.profile.lastStream)}: <strong>${escapeProfileHtml(formatLastStreamAgo(p.lastStreamAt))}</strong>
|
||
</div>`;
|
||
|
||
applyProfileHtml(el, `
|
||
<div class="streamer-profile-avatar-wrap" onclick="openTwitchChannel('${safeUrl}')" title="${escapeProfileHtml(UI_TEXT.profile.openTwitchTooltip)}">
|
||
${avatarBlock}
|
||
</div>
|
||
<div class="streamer-profile-body">
|
||
<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>
|
||
${liveInfo}
|
||
${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>
|
||
`);
|
||
}
|
||
|
||
async function loadStreamerProfile(login: string, forceRefresh = false): Promise<void> {
|
||
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;
|