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:
parent
a43fc6689c
commit
9239eebf34
@ -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>
|
||||
|
||||
211
src/main.ts
211
src/main.ts
@ -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() : '',
|
||||
|
||||
@ -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),
|
||||
|
||||
17
src/renderer-globals.d.ts
vendored
17
src/renderer-globals.d.ts
vendored
@ -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';
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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
195
src/renderer-profile.ts
Normal 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, '&')
|
||||
.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;
|
||||
@ -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()) {
|
||||
|
||||
276
src/styles.css
276
src/styles.css
@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user