diff --git a/src/main.ts b/src/main.ts index 5ebf254..7ebaf6e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3694,6 +3694,108 @@ async function runAutoVodPoll(): Promise { return queuedCount; } +// ========================================== +// LIVE STATUS BATCH POLLER — for the sidebar live indicators +// ========================================== +// Background poller that asks "which of these streamers are live right +// now?" for every streamer in the user's list, in a single GQL roundtrip +// (per chunk of 50). Results are stamped into liveStatusByLogin and +// pushed to the renderer so the sidebar gets a red pulsing dot next to +// anyone currently broadcasting. Independent from the auto-record +// poller — that one only watches a small subset and needs title/game, +// this one just needs the boolean and covers everyone. +const liveStatusByLogin = new Map(); +let liveStatusPollTimer: NodeJS.Timeout | null = null; +let liveStatusPollInFlight = false; +const LIVE_STATUS_POLL_INTERVAL_MS = 60_000; +const LIVE_STATUS_BATCH_CHUNK_SIZE = 50; + +async function fetchLiveStatusBatch(logins: string[]): Promise> { + const result = new Map(); + if (logins.length === 0) return result; + + for (let i = 0; i < logins.length; i += LIVE_STATUS_BATCH_CHUNK_SIZE) { + const chunk = logins.slice(i, i + LIVE_STATUS_BATCH_CHUNK_SIZE); + const vars: Record = {}; + const varDecls: string[] = []; + const aliases: string[] = []; + chunk.forEach((login, idx) => { + const varName = `l${idx}`; + vars[varName] = login; + varDecls.push(`$${varName}:String!`); + aliases.push(`u${idx}:user(login:$${varName}){login stream{type}}`); + }); + const query = `query(${varDecls.join(',')}){${aliases.join(' ')}}`; + try { + const data = await fetchPublicTwitchGql>( + query, vars + ); + if (!data) continue; + for (const key of Object.keys(data)) { + const user = data[key]; + if (!user || !user.login) continue; + result.set(normalizeLogin(user.login), user.stream?.type === 'live'); + } + } catch (e) { + appendDebugLog('live-status-batch-failed', { chunkStart: i, error: String(e) }); + } + } + return result; +} + +async function runLiveStatusBatchPoll(): Promise { + if (liveStatusPollInFlight) return; + liveStatusPollInFlight = true; + try { + const logins = ((config.streamers as string[]) || []) + .map((s) => normalizeLogin(s)) + .filter((s): s is string => Boolean(s)); + if (logins.length === 0) return; + + const fresh = await fetchLiveStatusBatch(logins); + const changes: Array<{ login: string; isLive: boolean }> = []; + const seen = new Set(fresh.keys()); + for (const [login, isLive] of fresh.entries()) { + const prev = liveStatusByLogin.get(login); + if (prev !== isLive) changes.push({ login, isLive }); + liveStatusByLogin.set(login, isLive); + } + // Streamers that vanished from the watch list (or that GQL didn't + // return for) get evicted so a removed streamer doesn't leave a + // ghost-live dot behind. + for (const oldLogin of Array.from(liveStatusByLogin.keys())) { + if (!seen.has(oldLogin)) { + liveStatusByLogin.delete(oldLogin); + changes.push({ login: oldLogin, isLive: false }); + } + } + + if (mainWindow) { + const snapshot: Record = {}; + for (const [k, v] of liveStatusByLogin.entries()) snapshot[k] = v; + mainWindow.webContents.send('live-status-batch-update', { changes, snapshot }); + } + } catch (e) { + appendDebugLog('live-status-poll-failed', String(e)); + } finally { + liveStatusPollInFlight = false; + } +} + +function stopLiveStatusPoller(): void { + if (liveStatusPollTimer) { + clearInterval(liveStatusPollTimer); + liveStatusPollTimer = null; + } +} + +function restartLiveStatusPoller(): void { + stopLiveStatusPoller(); + liveStatusPollTimer = setInterval(() => { void runLiveStatusBatchPoll(); }, LIVE_STATUS_POLL_INTERVAL_MS); + liveStatusPollTimer.unref?.(); + setTimeout(() => { void runLiveStatusBatchPoll(); }, 1500); +} + // ========================================== // CHAT REPLAY DOWNLOAD // ========================================== @@ -6408,6 +6510,7 @@ ipcMain.handle('save-config', (_, newConfig: Partial) => { const previousAutoRecordSeconds = config.auto_record_poll_seconds; const previousAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []); const previousAutoVodMinutes = config.auto_vod_download_poll_minutes; + const previousStreamerList = JSON.stringify(config.streamers || []); config = normalizeConfigTemplates({ ...config, ...newConfig }); @@ -6457,6 +6560,14 @@ ipcMain.handle('save-config', (_, newConfig: Partial) => { restartAutoVodPoller(); } + // Live-status batch poller — fire an immediate refresh when the + // streamer list itself changes (added/removed) so the sidebar dots + // update instantly instead of waiting for the next 60s tick. + const newStreamerList = JSON.stringify(config.streamers || []); + if (newStreamerList !== previousStreamerList) { + restartLiveStatusPoller(); + } + // Restart cleanup timer when the toggle flips; harmless to call when // unchanged because restartAutoCleanupTimer just resets the interval. restartAutoCleanupTimer(); @@ -6980,6 +7091,12 @@ ipcMain.handle('get-vod-storyboard', async (_, vodId: string): Promise => { + const snap: Record = {}; + for (const [k, v] of liveStatusByLogin.entries()) snap[k] = v; + return snap; +}); + ipcMain.handle('search-archive', (_, filter: Partial): ArchiveSearchResult => { const normalized: ArchiveSearchFilter = { query: typeof filter?.query === 'string' ? filter.query.trim() : '', @@ -7250,6 +7367,7 @@ app.whenReady().then(() => { startDebugLogFlushTimer(); restartAutoRecordPoller(); restartAutoVodPoller(); + restartLiveStatusPoller(); restartAutoCleanupTimer(); createWindow(); appendDebugLog('startup-tools-check-skipped', 'Deferred to first use'); @@ -7279,6 +7397,7 @@ function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void { stopAutoUpdatePolling(); stopAutoRecordPoller(); stopAutoVodPoller(); + stopLiveStatusPoller(); stopAutoCleanupTimer(); // Kill all active children: queue downloads, standalone clip downloads, diff --git a/src/preload.ts b/src/preload.ts index be4131a..1d28b59 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -93,6 +93,10 @@ contextBridge.exposeInMainWorld('api', { getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'), getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh), getVodStoryboard: (vodId: string) => ipcRenderer.invoke('get-vod-storyboard', vodId), + getLiveStatusSnapshot: () => ipcRenderer.invoke('get-live-status-snapshot'), + onLiveStatusBatchUpdate: (callback: (info: { changes: Array<{ login: string; isLive: boolean }>; snapshot: Record }) => void) => { + ipcRenderer.on('live-status-batch-update', (_, info) => callback(info)); + }, 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 07164e8..d412943 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -346,6 +346,8 @@ interface ApiBridge { getArchiveStats(): Promise; getStreamerProfile(login: string, forceRefresh?: boolean): Promise; getVodStoryboard(vodId: string): Promise; + getLiveStatusSnapshot(): Promise>; + onLiveStatusBatchUpdate(callback: (info: { changes: Array<{ login: string; isLive: boolean }>; snapshot: Record }) => void): void; 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 96cdf16..f9bbc5c 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -350,7 +350,8 @@ const UI_TEXT_DE = { autoVodScanQueued: '{count} neue VOD(s) automatisch eingereiht.', autoVodScanEmpty: 'Keine neuen VODs gefunden.', autoRecordScanTriggered: 'Manueller Scan: {count} Live-Aufnahme(n) gestartet.', - autoRecordScanEmpty: 'Manueller Scan: kein Streamer ist gerade live.' + autoRecordScanEmpty: 'Manueller Scan: kein Streamer ist gerade live.', + liveNowTooltip: 'Aktuell live auf Twitch' }, vods: { noneTitle: 'Keine VODs', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index 80fdaa6..34d3643 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -350,7 +350,8 @@ const UI_TEXT_EN = { autoVodScanQueued: '{count} new VOD(s) auto-queued.', autoVodScanEmpty: 'No new VODs found.', autoRecordScanTriggered: 'Manual scan: {count} live recording(s) started.', - autoRecordScanEmpty: 'Manual scan: no streamers currently live.' + autoRecordScanEmpty: 'Manual scan: no streamers currently live.', + liveNowTooltip: 'Currently live on Twitch' }, vods: { noneTitle: 'No VODs', diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts index e952583..6b76c4a 100644 --- a/src/renderer-streamers.ts +++ b/src/renderer-streamers.ts @@ -2,6 +2,36 @@ let selectStreamerRequestId = 0; let vodRenderTaskId = 0; const VOD_RENDER_CHUNK_SIZE = 64; +// Live status snapshot — updated by the main process via the +// 'live-status-batch-update' IPC event. Keys are lowercase logins so +// the lookup is case-insensitive regardless of how the streamer's +// name was added (display-cased vs login-cased). +const liveStatusByLogin = new Map(); + +async function initLiveStatusSubscription(): Promise { + try { + const initial = await window.api.getLiveStatusSnapshot(); + for (const [k, v] of Object.entries(initial)) { + liveStatusByLogin.set(k.toLowerCase(), v === true); + } + renderStreamers(); + } catch (_) { /* poller may not have fired yet — silent */ } + + window.api.onLiveStatusBatchUpdate(({ changes }) => { + let touched = false; + for (const change of changes) { + const key = change.login.toLowerCase(); + const prev = liveStatusByLogin.get(key); + if (prev !== change.isLive) { + liveStatusByLogin.set(key, change.isLive); + touched = true; + } + } + if (touched) renderStreamers(); + }); +} +(window as unknown as { initLiveStatusSubscription: typeof initLiveStatusSubscription }).initLiveStatusSubscription = initLiveStatusSubscription; + // VOD filter state — persists across renderer reloads via localStorage so the // user's search query survives an app restart. Cleared explicitly via Esc / // the clear button. Shared across streamers (acts like a search bar). @@ -404,7 +434,20 @@ function renderStreamers(): void { item.setAttribute('draggable', 'true'); item.dataset.streamerName = streamer; + // Live-dot — red pulsing dot when this streamer is currently + // broadcasting on Twitch. Populated from the live-status batch + // poller's snapshot. Renders before the name so the streamer + // identity stays primary visually. + const isLive = liveStatusByLogin.get(streamer.toLowerCase()) === true; + if (isLive) { + const dot = document.createElement('span'); + dot.className = 'streamer-live-dot'; + dot.title = UI_TEXT.streamers.liveNowTooltip || 'Live now'; + item.appendChild(dot); + } + const nameSpan = document.createElement('span'); + nameSpan.className = 'streamer-name' + (isLive ? ' is-live' : ''); nameSpan.textContent = streamer; // AUTO toggle — when enabled, the main-process auto-record poller diff --git a/src/renderer.ts b/src/renderer.ts index 767672b..5ab3438 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -42,6 +42,10 @@ async function init(): Promise { changeTheme(config.theme ?? 'twitch'); renderStreamers(); renderQueue(); + + // Kick off live-status subscription so the sidebar dots populate. + const liveStatusInit = (window as unknown as { initLiveStatusSubscription?: () => Promise }).initLiveStatusSubscription; + if (typeof liveStatusInit === 'function') void liveStatusInit(); initQueueDragDrop(); updateDownloadButtonState(); updateStatusBarQueueSummary(); diff --git a/src/styles.css b/src/styles.css index 8ca9172..ced23b9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -142,6 +142,30 @@ body { opacity: 1; } +/* Live-dot — red pulsing indicator shown next to a streamer's name in + the sidebar when they are currently broadcasting on Twitch. */ +.streamer-live-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: #e91916; + flex-shrink: 0; + animation: streamer-live-pulse 1.6s ease-in-out infinite; + box-shadow: 0 0 0 0 rgba(233, 25, 22, 0.55); +} + +@keyframes streamer-live-pulse { + 0% { box-shadow: 0 0 0 0 rgba(233, 25, 22, 0.55); } + 70% { box-shadow: 0 0 0 6px rgba(233, 25, 22, 0); } + 100% { box-shadow: 0 0 0 0 rgba(233, 25, 22, 0); } +} + +.streamer-name.is-live { + color: var(--text); + font-weight: 600; +} + .add-streamer { padding: 10px; display: flex; @@ -578,14 +602,16 @@ body { background: var(--bg-card); border-radius: 8px; overflow: hidden; - transition: transform 0.2s, box-shadow 0.2s; + transition: transform 0.22s ease-out, box-shadow 0.22s ease-out, border-color 0.22s; cursor: pointer; position: relative; + border: 1px solid transparent; } .vod-card:hover { transform: translateY(-4px); - box-shadow: 0 8px 25px rgba(0,0,0,0.3); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(145, 70, 255, 0.35); + border-color: rgba(145, 70, 255, 0.35); } .vod-card.selected { @@ -1156,15 +1182,30 @@ body { } .empty-state svg { - width: 64px; - height: 64px; - margin-bottom: 15px; - opacity: 0.5; + width: 80px; + height: 80px; + margin-bottom: 18px; + opacity: 0.45; + color: var(--accent); + animation: empty-state-float 4s ease-in-out infinite; +} + +@keyframes empty-state-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6px); } } .empty-state h3 { - margin-bottom: 8px; + margin-bottom: 10px; color: var(--text); + font-size: 18px; + font-weight: 600; +} + +.empty-state p { + max-width: 380px; + line-height: 1.5; + font-size: 13px; } /* Status Bar */