diff --git a/src/index.html b/src/index.html index c5af5b1..be44af0 100644 --- a/src/index.html +++ b/src/index.html @@ -280,6 +280,7 @@
+
@@ -840,6 +841,7 @@ + diff --git a/src/main.ts b/src/main.ts index 40fd21c..e77864e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2291,6 +2291,213 @@ async function getLiveStreamInfo(login: string): Promise }; } +// ========================================== +// 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>(); +const inFlightProfileRequests = new Map>(); + +interface HelixUser { + id: string; + login: string; + display_name: string; + description: string; + profile_image_url: string; + broadcaster_type: string; +} + +async function fetchHelixUserInfo(login: string): Promise { + 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( + `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 { + const data = await fetchPublicTwitchGql( + `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 { + 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 { 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 => { + return await getStreamerProfile(login, forceRefresh === true); +}); + ipcMain.handle('search-archive', (_, filter: Partial): ArchiveSearchResult => { const normalized: ArchiveSearchFilter = { query: typeof filter?.query === 'string' ? filter.query.trim() : '', diff --git a/src/preload.ts b/src/preload.ts index d172b33..4be39c8 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -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) => ipcRenderer.invoke('search-archive', filter), runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options), readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath), diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index fe7b92c..3372f19 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -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; getStorageStats(): Promise; getArchiveStats(): Promise; + getStreamerProfile(login: string, forceRefresh?: boolean): Promise; searchArchive(filter: { query?: string; type?: 'all' | 'live' | 'vod' | 'chat' | 'events'; diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index 3bc7e60..9e5eb1c 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -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.', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index 15cea9c..885ac50 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -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}.', diff --git a/src/renderer-profile.ts b/src/renderer-profile.ts new file mode 100644 index 0000000..398488d --- /dev/null +++ b/src/renderer-profile.ts @@ -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)[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, '''); +} + +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, ` +
+
+
+
+
+
+
+
+
+
+
+
+
+ `); +} + +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 + ? `${escapeProfileHtml(p.displayName)}` + : `
${escapeProfileHtml((p.displayName || p.login || '?').slice(0, 1).toUpperCase())}
`; + + const badges: string[] = []; + if (p.isLive) badges.push(`${escapeProfileHtml(UI_TEXT.profile.liveBadge)}`); + if (p.broadcasterType === 'partner') badges.push(`${escapeProfileHtml(UI_TEXT.profile.partner)}`); + if (p.broadcasterType === 'affiliate') badges.push(`${escapeProfileHtml(UI_TEXT.profile.affiliate)}`); + + const liveInfo = (p.isLive && (p.currentTitle || p.currentGame)) + ? `
${p.currentTitle ? `${escapeProfileHtml(p.currentTitle)}` : ''}${p.currentTitle && p.currentGame ? ' · ' : ''}${p.currentGame ? escapeProfileHtml(p.currentGame) : ''}
` + : ''; + + const bio = p.description + ? `
${escapeProfileHtml(p.description)}
` + : ''; + + const followersStat = ` +
+ + ${escapeProfileHtml(formatProfileFollowers(p.followerCount))} ${escapeProfileHtml(UI_TEXT.profile.followers)} +
`; + const vodsStat = ` +
+ + ${p.vodCount} ${escapeProfileHtml(UI_TEXT.profile.vods)} +
`; + const lastStreamStat = ` +
+ + ${escapeProfileHtml(UI_TEXT.profile.lastStream)}: ${escapeProfileHtml(formatLastStreamAgo(p.lastStreamAt))} +
`; + + applyProfileHtml(el, ` +
+ ${avatarBlock} +
+
+
+ ${escapeProfileHtml(p.displayName)} + + ${badges.join('')} +
+ ${liveInfo} + ${bio} +
+ ${followersStat} + ${vodsStat} + ${lastStreamStat} +
+
+
+ + +
+ `); +} + +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; diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts index 74d8454..e952583 100644 --- a/src/renderer-streamers.ts +++ b/src/renderer-streamers.ts @@ -494,6 +494,8 @@ async function bulkRemoveStreamers(): Promise { 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 { } currentStreamer = null; + const hide = (window as unknown as { hideStreamerProfileHeader?: () => void }).hideStreamerProfileHeader; + if (typeof hide === 'function') hide(); byId('vodGrid').innerHTML = `
@@ -616,6 +620,14 @@ async function selectStreamer(name: string, forceRefresh = false): Promise 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 }).loadStreamerProfile; + if (typeof profileLoader === 'function') { + void profileLoader(name); + } + if (!isConnected) { await connect(); if (isStaleRequest()) { diff --git a/src/styles.css b/src/styles.css index 4947fcd..3772805 100644 --- a/src/styles.css +++ b/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%; + } +}