feat: streamer profile header — modern channel-page card above VOD grid

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>
This commit is contained in:
xRangerDE 2026-05-11 00:38:38 +02:00
parent a43fc6689c
commit 9239eebf34
9 changed files with 748 additions and 0 deletions

View File

@ -280,6 +280,7 @@
<div class="content">
<!-- VODs Tab -->
<div class="tab-content active" id="vodsTab">
<div id="streamerProfileHeader" class="streamer-profile-header" style="display:none;"></div>
<div class="vod-filter-row" style="display:flex; align-items:center; gap:8px; margin-bottom:12px; flex-wrap:wrap;">
<input type="text" id="vodFilterInput" placeholder="Filter VODs..." oninput="onVodFilterInput()" style="flex:1; min-width:180px; background: var(--bg-card); border:1px solid var(--border-soft); border-radius:6px; padding:8px 12px; color: var(--text); font-size:13px;">
<button id="vodFilterClearBtn" onclick="clearVodFilter()" title="Clear filter" style="display:none; background:transparent; border:1px solid var(--border-soft); border-radius:6px; padding:8px 12px; color: var(--text-secondary); cursor:pointer;">x</button>
@ -840,6 +841,7 @@
<script src="../dist/renderer-updates.js"></script>
<script src="../dist/renderer-stats.js"></script>
<script src="../dist/renderer-archive.js"></script>
<script src="../dist/renderer-profile.js"></script>
<script src="../dist/renderer.js"></script>
</body>
</html>

View File

@ -2291,6 +2291,213 @@ async function getLiveStreamInfo(login: string): Promise<LiveStreamInfo | null>
};
}
// ==========================================
// STREAMER PROFILE — display-name, avatar, follower count, etc.
// ==========================================
// User-facing channel header data. Combines Helix /users (display name,
// avatar, bio, broadcaster type), public GQL (follower total — Helix
// requires moderator scope we don't have), the already-cached VOD list
// (vodCount + lastStreamAt come for free), and the live-status cache
// (isLive + currentTitle + currentGame). Cached for 30 min per login.
interface StreamerProfile {
login: string;
displayName: string;
avatarUrl: string;
description: string;
broadcasterType: '' | 'partner' | 'affiliate';
followerCount: number | null;
vodCount: number;
lastStreamAt: string | null;
isLive: boolean;
currentTitle: string | null;
currentGame: string | null;
twitchUrl: string;
fetchedAt: number;
}
const MAX_STREAMER_PROFILE_CACHE_ENTRIES = 512;
const streamerProfileCache = new Map<string, CacheEntry<StreamerProfile>>();
const inFlightProfileRequests = new Map<string, Promise<StreamerProfile | null>>();
interface HelixUser {
id: string;
login: string;
display_name: string;
description: string;
profile_image_url: string;
broadcaster_type: string;
}
async function fetchHelixUserInfo(login: string): Promise<HelixUser | null> {
if (!(await ensureTwitchAuth())) return null;
try {
const response = await axios.get('https://api.twitch.tv/helix/users', {
params: { login },
headers: {
'Client-ID': config.client_id,
'Authorization': `Bearer ${accessToken}`
},
timeout: API_TIMEOUT
});
const u = response.data?.data?.[0];
if (!u?.id) return null;
return u as HelixUser;
} catch (e) {
appendDebugLog('helix-user-info-failed', { login, error: String(e) });
return null;
}
}
interface PublicProfileQueryResult {
user: {
id: string;
login: string;
displayName: string;
description: string | null;
profileImageURL: string | null;
broadcasterType: string | null;
followers?: { totalCount: number } | null;
} | null;
}
async function fetchPublicStreamerProfile(login: string): Promise<{
displayName: string;
avatarUrl: string;
description: string;
broadcasterType: '' | 'partner' | 'affiliate';
followerCount: number | null;
} | null> {
const data = await fetchPublicTwitchGql<PublicProfileQueryResult>(
`query($login: String!) {
user(login: $login) {
id
login
displayName
description
profileImageURL(width: 150)
broadcasterType
followers { totalCount }
}
}`,
{ login }
);
if (!data?.user) return null;
const bt = (data.user.broadcasterType || '').toLowerCase();
return {
displayName: data.user.displayName || login,
avatarUrl: data.user.profileImageURL || '',
description: data.user.description || '',
broadcasterType: (bt === 'partner' || bt === 'affiliate') ? bt : '',
followerCount: typeof data.user.followers?.totalCount === 'number' ? data.user.followers.totalCount : null
};
}
async function fetchOnlyFollowerCount(login: string): Promise<number | null> {
const data = await fetchPublicTwitchGql<PublicProfileQueryResult>(
`query($login: String!) { user(login: $login) { followers { totalCount } } }`,
{ login }
);
const cnt = data?.user?.followers?.totalCount;
return typeof cnt === 'number' ? cnt : null;
}
async function getStreamerProfile(login: string, forceRefresh = false): Promise<StreamerProfile | null> {
const normalized = normalizeLogin(login);
if (!normalized) return null;
if (!forceRefresh) {
const cached = getCachedValue(streamerProfileCache, normalized);
if (cached !== undefined) {
runtimeMetrics.cacheHits += 1;
return cached;
}
}
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.
let displayName = normalized;
let avatarUrl = '';
let description = '';
let broadcasterType: '' | 'partner' | 'affiliate' = '';
const helixUser = await fetchHelixUserInfo(normalized);
if (helixUser) {
displayName = helixUser.display_name || normalized;
avatarUrl = helixUser.profile_image_url || '';
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;
}
}
// 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);
// Derive vod count + last stream from the already-cached VOD list
// when we have an id. No extra network hit.
let vodCount = 0;
let lastStreamAt: string | null = null;
const userId = await getUserId(normalized);
if (userId) {
try {
const vods = await getVODs(userId);
vodCount = vods.length;
// VOD list is sorted by Twitch newest-first; pick element 0.
const newest = vods[0];
if (newest?.created_at) lastStreamAt = newest.created_at;
} catch (e) {
appendDebugLog('profile-vod-derive-failed', { login: normalized, error: String(e) });
}
}
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 */ }
const profile: StreamerProfile = {
login: normalized,
displayName,
avatarUrl,
description,
broadcasterType,
followerCount,
vodCount,
lastStreamAt,
isLive,
currentTitle,
currentGame,
twitchUrl: `https://www.twitch.tv/${normalized}`,
fetchedAt: Date.now()
};
setCachedValue(streamerProfileCache, normalized, profile, MAX_STREAMER_PROFILE_CACHE_ENTRIES);
return profile;
});
}
async function getClipInfo(clipId: string): Promise<any | null> {
const cachedClip = getCachedValue(clipInfoCache, clipId);
if (cachedClip !== undefined) {
@ -6536,6 +6743,10 @@ ipcMain.handle('get-archive-stats', (): ArchiveStats => {
return computeArchiveStats();
});
ipcMain.handle('get-streamer-profile', async (_, login: string, forceRefresh?: boolean): Promise<StreamerProfile | null> => {
return await getStreamerProfile(login, forceRefresh === true);
});
ipcMain.handle('search-archive', (_, filter: Partial<ArchiveSearchFilter>): ArchiveSearchResult => {
const normalized: ArchiveSearchFilter = {
query: typeof filter?.query === 'string' ? filter.query.trim() : '',

View File

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

View File

@ -235,6 +235,22 @@ interface StorageStatsResult {
scannedAt: string;
}
interface StreamerProfile {
login: string;
displayName: string;
avatarUrl: string;
description: string;
broadcasterType: '' | 'partner' | 'affiliate';
followerCount: number | null;
vodCount: number;
lastStreamAt: string | null;
isLive: boolean;
currentTitle: string | null;
currentGame: string | null;
twitchUrl: string;
fetchedAt: number;
}
interface ArchiveSearchHit {
fullPath: string;
fileName: string;
@ -315,6 +331,7 @@ interface ApiBridge {
checkFolderWritable(path: string): Promise<boolean>;
getStorageStats(): Promise<StorageStatsResult>;
getArchiveStats(): Promise<ArchiveStats>;
getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>;
searchArchive(filter: {
query?: string;
type?: 'all' | 'live' | 'vod' | 'chat' | 'events';

View File

@ -316,6 +316,23 @@ const UI_TEXT_DE = {
},
eventRecordingResume: 'Aufnahme fortgesetzt - Part {part} startet'
},
profile: {
liveBadge: 'LIVE',
partner: 'Partner',
affiliate: 'Affiliate',
followers: 'Follower',
vods: 'VODs',
vodsTooltip: 'Ueber die Twitch-API sichtbare VODs dieses Kanals',
lastStream: 'Letzter Stream',
openTwitch: 'Auf Twitch oeffnen',
openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen',
refresh: 'Aktualisieren',
agoMinutes: 'vor {n} Min',
agoHours: 'vor {n} h',
agoDays: 'vor {n} Tagen',
agoMonths: 'vor {n} Monaten',
agoYears: 'vor {n} Jahren'
},
streamers: {
recordLiveTitle: 'Diesen Streamer live aufnehmen (laeuft bis der Stream endet)',
liveRecordingStarted: 'Live-Aufnahme fuer {streamer} gestartet.',

View File

@ -316,6 +316,23 @@ const UI_TEXT_EN = {
},
eventRecordingResume: 'Recording resumed — starting part {part}'
},
profile: {
liveBadge: 'LIVE',
partner: 'Partner',
affiliate: 'Affiliate',
followers: 'Followers',
vods: 'VODs',
vodsTooltip: 'VODs visible via Twitch API for this channel',
lastStream: 'Last stream',
openTwitch: 'Open on Twitch',
openTwitchTooltip: 'Open this channel on twitch.tv',
refresh: 'Refresh',
agoMinutes: '{n} min ago',
agoHours: '{n} h ago',
agoDays: '{n} d ago',
agoMonths: '{n} mo ago',
agoYears: '{n} y ago'
},
streamers: {
recordLiveTitle: 'Record this streamer live (captures until stream ends)',
liveRecordingStarted: 'Live recording started for {streamer}.',

195
src/renderer-profile.ts Normal file
View File

@ -0,0 +1,195 @@
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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;

View File

@ -494,6 +494,8 @@ async function bulkRemoveStreamers(): Promise<void> {
config = await window.api.saveConfig({ streamers: remaining });
if (currentStreamer && targets.includes(currentStreamer)) {
currentStreamer = null;
const hide = (window as unknown as { hideStreamerProfileHeader?: () => void }).hideStreamerProfileHeader;
if (typeof hide === 'function') hide();
}
streamerListFilterQuery = '';
const input = document.getElementById('streamerListFilter') as HTMLInputElement | null;
@ -592,6 +594,8 @@ async function removeStreamer(name: string): Promise<void> {
}
currentStreamer = null;
const hide = (window as unknown as { hideStreamerProfileHeader?: () => void }).hideStreamerProfileHeader;
if (typeof hide === 'function') hide();
byId('vodGrid').innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
@ -616,6 +620,14 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
renderStreamers();
byId('pageTitle').textContent = name;
// Kick off the profile header load in parallel with VOD fetching.
// It's a separate request stream and not strictly needed for the VOD
// grid, so we don't await it here — the skeleton appears immediately.
const profileLoader = (window as unknown as { loadStreamerProfile?: (login: string) => Promise<void> }).loadStreamerProfile;
if (typeof profileLoader === 'function') {
void profileLoader(name);
}
if (!isConnected) {
await connect();
if (isStaleRequest()) {

View File

@ -1940,3 +1940,279 @@ body.theme-light .modal {
.app-toast.warn {
border-color: rgba(255, 167, 38, 0.7);
}
/* ============================================
STREAMER PROFILE HEADER
============================================
Polished channel-page-style header that shows up above the VOD grid
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;
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;
}
@keyframes profile-fade-in {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.streamer-profile-header.is-live::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
border-radius: 12px;
box-shadow: inset 0 0 0 1px rgba(233, 25, 22, 0.4);
}
.streamer-profile-avatar-wrap {
position: relative;
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s;
}
.streamer-profile-avatar-wrap:hover {
transform: scale(1.04);
}
.streamer-profile-avatar {
width: 88px;
height: 88px;
border-radius: 50%;
object-fit: cover;
background: var(--bg-elevated);
border: 3px solid rgba(145, 70, 255, 0.6);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.30);
}
.streamer-profile-avatar.is-live {
border-color: #e91916;
animation: profile-live-ring 1.6s ease-in-out infinite;
}
@keyframes profile-live-ring {
0%, 100% { box-shadow: 0 0 0 0 rgba(233, 25, 22, 0.5), 0 4px 18px rgba(0, 0, 0, 0.30); }
50% { box-shadow: 0 0 0 8px rgba(233, 25, 22, 0), 0 4px 18px rgba(0, 0, 0, 0.30); }
}
.streamer-profile-avatar-fallback {
width: 88px;
height: 88px;
border-radius: 50%;
background: linear-gradient(135deg, #9146ff, #00c853);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: 700;
border: 3px solid rgba(145, 70, 255, 0.6);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.30);
}
.streamer-profile-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.streamer-profile-name-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.streamer-profile-display-name {
font-size: 22px;
font-weight: 700;
color: var(--text);
line-height: 1.1;
letter-spacing: -0.2px;
}
.streamer-profile-login {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.streamer-profile-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.streamer-profile-badge.partner {
background: rgba(145, 70, 255, 0.18);
color: #9146ff;
border: 1px solid rgba(145, 70, 255, 0.5);
}
.streamer-profile-badge.affiliate {
background: rgba(0, 200, 83, 0.15);
color: #00c853;
border: 1px solid rgba(0, 200, 83, 0.45);
}
.streamer-profile-badge.live {
background: #e91916;
color: #fff;
border: 1px solid #e91916;
animation: profile-live-blink 1.6s ease-in-out infinite;
}
.streamer-profile-badge.live::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: #fff;
}
@keyframes profile-live-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.75; }
}
.streamer-profile-bio {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-top: 2px;
}
.streamer-profile-live-info {
font-size: 13px;
color: var(--text);
background: rgba(233, 25, 22, 0.08);
border-left: 3px solid #e91916;
padding: 6px 10px;
border-radius: 0 4px 4px 0;
margin-top: 4px;
}
.streamer-profile-live-info strong {
color: #ff6b6b;
font-weight: 600;
}
.streamer-profile-stats {
display: flex;
gap: 18px;
flex-wrap: wrap;
margin-top: 6px;
}
.streamer-profile-stat {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.streamer-profile-stat strong {
color: var(--text);
font-weight: 600;
font-size: 13px;
}
.streamer-profile-stat svg {
width: 14px;
height: 14px;
opacity: 0.7;
}
.streamer-profile-actions {
display: flex;
flex-direction: column;
gap: 6px;
flex-shrink: 0;
}
.streamer-profile-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 14px;
background: var(--bg-elevated);
border: 1px solid var(--border-soft);
color: var(--text);
border-radius: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.18s;
text-decoration: none;
white-space: nowrap;
}
.streamer-profile-btn:hover {
background: rgba(145, 70, 255, 0.18);
border-color: rgba(145, 70, 255, 0.6);
color: var(--text);
transform: translateY(-1px);
}
.streamer-profile-btn.primary {
background: #9146ff;
border-color: #9146ff;
color: #fff;
}
.streamer-profile-btn.primary:hover {
background: #a970ff;
border-color: #a970ff;
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(145, 70, 255, 0.4);
}
/* Skeleton loading state */
.streamer-profile-skeleton .streamer-profile-skel-block {
background: linear-gradient(90deg, var(--bg-elevated) 0%, rgba(255,255,255,0.06) 50%, var(--bg-elevated) 100%);
background-size: 200% 100%;
animation: profile-skel-shimmer 1.4s linear infinite;
border-radius: 4px;
}
@keyframes profile-skel-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (max-width: 720px) {
.streamer-profile-header {
flex-direction: column;
align-items: flex-start;
}
.streamer-profile-actions {
flex-direction: row;
width: 100%;
}
}