feat: sidebar live indicators + polished hover + empty-state animation

The killer-feature of this pass is the live indicator: red pulsing
dot next to every streamer in the sidebar that is currently
broadcasting on Twitch. Suddenly the sidebar conveys real-time
state at a glance — you know who to click before clicking.

How it works:
- New live-status batch poller (main.ts) fires every 60s, packs
  every streamer in the user's watch list into a single GQL query
  using aliased user lookups (`u0:user(login:$l0){stream{type}} ...`),
  chunked at 50 logins per request. One roundtrip for the whole
  list — far cheaper than per-streamer polling.
- Updates a liveStatusByLogin Map on the main side, emits an IPC
  `live-status-batch-update` event with only the entries that
  flipped (plus a full snapshot for the renderer to keep in sync).
- Renderer subscribes once at boot via initLiveStatusSubscription,
  keeps a parallel Map, and re-renders the streamer list on
  change. Stamps a .streamer-live-dot before the name. Bold name
  for live streamers so they pop in scannability.
- Restart triggers: app boot, streamer-list change (added/removed
  via save-config) so a freshly added streamer gets their dot in
  seconds without waiting for the next 60s tick.

Polish bundled in the same release:

- VOD card hover gets a more substantial lift: 12px shadow + faint
  purple border-glow on hover. Subtle but enough to feel
  "tactile". Border-color transitions alongside the shadow.

- Empty states get a floating animation and a bigger SVG icon
  with accent-colored tint. "No VODs / select a streamer" now
  feels intentional instead of an oversight.

- Streamer-name span dedicated class (.streamer-name +
  .streamer-name.is-live) so a live streamer's name itself bolds,
  not just gets a dot beside it.

Locale strings: liveNowTooltip ("Currently live on Twitch" / "Aktuell
live auf Twitch") for the dot's tooltip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-11 01:11:26 +02:00
parent fa8c2b2658
commit 11883889de
8 changed files with 224 additions and 9 deletions

View File

@ -3694,6 +3694,108 @@ async function runAutoVodPoll(): Promise<number> {
return queuedCount; 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<string, boolean>();
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<Map<string, boolean>> {
const result = new Map<string, boolean>();
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<string, string> = {};
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<Record<string, { login: string; stream: { type: string } | null } | null>>(
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<void> {
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<string, boolean> = {};
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 // CHAT REPLAY DOWNLOAD
// ========================================== // ==========================================
@ -6408,6 +6510,7 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
const previousAutoRecordSeconds = config.auto_record_poll_seconds; const previousAutoRecordSeconds = config.auto_record_poll_seconds;
const previousAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []); const previousAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []);
const previousAutoVodMinutes = config.auto_vod_download_poll_minutes; const previousAutoVodMinutes = config.auto_vod_download_poll_minutes;
const previousStreamerList = JSON.stringify(config.streamers || []);
config = normalizeConfigTemplates({ ...config, ...newConfig }); config = normalizeConfigTemplates({ ...config, ...newConfig });
@ -6457,6 +6560,14 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
restartAutoVodPoller(); 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 // Restart cleanup timer when the toggle flips; harmless to call when
// unchanged because restartAutoCleanupTimer just resets the interval. // unchanged because restartAutoCleanupTimer just resets the interval.
restartAutoCleanupTimer(); restartAutoCleanupTimer();
@ -6980,6 +7091,12 @@ ipcMain.handle('get-vod-storyboard', async (_, vodId: string): Promise<VodStoryb
return await getVodStoryboard(vodId); return await getVodStoryboard(vodId);
}); });
ipcMain.handle('get-live-status-snapshot', (): Record<string, boolean> => {
const snap: Record<string, boolean> = {};
for (const [k, v] of liveStatusByLogin.entries()) snap[k] = v;
return snap;
});
ipcMain.handle('search-archive', (_, filter: Partial<ArchiveSearchFilter>): ArchiveSearchResult => { ipcMain.handle('search-archive', (_, filter: Partial<ArchiveSearchFilter>): ArchiveSearchResult => {
const normalized: ArchiveSearchFilter = { const normalized: ArchiveSearchFilter = {
query: typeof filter?.query === 'string' ? filter.query.trim() : '', query: typeof filter?.query === 'string' ? filter.query.trim() : '',
@ -7250,6 +7367,7 @@ app.whenReady().then(() => {
startDebugLogFlushTimer(); startDebugLogFlushTimer();
restartAutoRecordPoller(); restartAutoRecordPoller();
restartAutoVodPoller(); restartAutoVodPoller();
restartLiveStatusPoller();
restartAutoCleanupTimer(); restartAutoCleanupTimer();
createWindow(); createWindow();
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use'); appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
@ -7279,6 +7397,7 @@ function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
stopAutoUpdatePolling(); stopAutoUpdatePolling();
stopAutoRecordPoller(); stopAutoRecordPoller();
stopAutoVodPoller(); stopAutoVodPoller();
stopLiveStatusPoller();
stopAutoCleanupTimer(); stopAutoCleanupTimer();
// Kill all active children: queue downloads, standalone clip downloads, // Kill all active children: queue downloads, standalone clip downloads,

View File

@ -93,6 +93,10 @@ contextBridge.exposeInMainWorld('api', {
getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'), getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'),
getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh), getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh),
getVodStoryboard: (vodId: string) => ipcRenderer.invoke('get-vod-storyboard', vodId), 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<string, boolean> }) => void) => {
ipcRenderer.on('live-status-batch-update', (_, info) => callback(info));
},
searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter), searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter),
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options), runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath), readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath),

View File

@ -346,6 +346,8 @@ interface ApiBridge {
getArchiveStats(): Promise<ArchiveStats>; getArchiveStats(): Promise<ArchiveStats>;
getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>; getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>;
getVodStoryboard(vodId: string): Promise<VodStoryboard | null>; getVodStoryboard(vodId: string): Promise<VodStoryboard | null>;
getLiveStatusSnapshot(): Promise<Record<string, boolean>>;
onLiveStatusBatchUpdate(callback: (info: { changes: Array<{ login: string; isLive: boolean }>; snapshot: Record<string, boolean> }) => void): void;
searchArchive(filter: { searchArchive(filter: {
query?: string; query?: string;
type?: 'all' | 'live' | 'vod' | 'chat' | 'events'; type?: 'all' | 'live' | 'vod' | 'chat' | 'events';

View File

@ -350,7 +350,8 @@ const UI_TEXT_DE = {
autoVodScanQueued: '{count} neue VOD(s) automatisch eingereiht.', autoVodScanQueued: '{count} neue VOD(s) automatisch eingereiht.',
autoVodScanEmpty: 'Keine neuen VODs gefunden.', autoVodScanEmpty: 'Keine neuen VODs gefunden.',
autoRecordScanTriggered: 'Manueller Scan: {count} Live-Aufnahme(n) gestartet.', 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: { vods: {
noneTitle: 'Keine VODs', noneTitle: 'Keine VODs',

View File

@ -350,7 +350,8 @@ const UI_TEXT_EN = {
autoVodScanQueued: '{count} new VOD(s) auto-queued.', autoVodScanQueued: '{count} new VOD(s) auto-queued.',
autoVodScanEmpty: 'No new VODs found.', autoVodScanEmpty: 'No new VODs found.',
autoRecordScanTriggered: 'Manual scan: {count} live recording(s) started.', 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: { vods: {
noneTitle: 'No VODs', noneTitle: 'No VODs',

View File

@ -2,6 +2,36 @@ let selectStreamerRequestId = 0;
let vodRenderTaskId = 0; let vodRenderTaskId = 0;
const VOD_RENDER_CHUNK_SIZE = 64; 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<string, boolean>();
async function initLiveStatusSubscription(): Promise<void> {
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 // VOD filter state — persists across renderer reloads via localStorage so the
// user's search query survives an app restart. Cleared explicitly via Esc / // user's search query survives an app restart. Cleared explicitly via Esc /
// the clear button. Shared across streamers (acts like a search bar). // the clear button. Shared across streamers (acts like a search bar).
@ -404,7 +434,20 @@ function renderStreamers(): void {
item.setAttribute('draggable', 'true'); item.setAttribute('draggable', 'true');
item.dataset.streamerName = streamer; 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'); const nameSpan = document.createElement('span');
nameSpan.className = 'streamer-name' + (isLive ? ' is-live' : '');
nameSpan.textContent = streamer; nameSpan.textContent = streamer;
// AUTO toggle — when enabled, the main-process auto-record poller // AUTO toggle — when enabled, the main-process auto-record poller

View File

@ -42,6 +42,10 @@ async function init(): Promise<void> {
changeTheme(config.theme ?? 'twitch'); changeTheme(config.theme ?? 'twitch');
renderStreamers(); renderStreamers();
renderQueue(); renderQueue();
// Kick off live-status subscription so the sidebar dots populate.
const liveStatusInit = (window as unknown as { initLiveStatusSubscription?: () => Promise<void> }).initLiveStatusSubscription;
if (typeof liveStatusInit === 'function') void liveStatusInit();
initQueueDragDrop(); initQueueDragDrop();
updateDownloadButtonState(); updateDownloadButtonState();
updateStatusBarQueueSummary(); updateStatusBarQueueSummary();

View File

@ -142,6 +142,30 @@ body {
opacity: 1; 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 { .add-streamer {
padding: 10px; padding: 10px;
display: flex; display: flex;
@ -578,14 +602,16 @@ body {
background: var(--bg-card); background: var(--bg-card);
border-radius: 8px; border-radius: 8px;
overflow: hidden; 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; cursor: pointer;
position: relative; position: relative;
border: 1px solid transparent;
} }
.vod-card:hover { .vod-card:hover {
transform: translateY(-4px); 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 { .vod-card.selected {
@ -1156,15 +1182,30 @@ body {
} }
.empty-state svg { .empty-state svg {
width: 64px; width: 80px;
height: 64px; height: 80px;
margin-bottom: 15px; margin-bottom: 18px;
opacity: 0.5; 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 { .empty-state h3 {
margin-bottom: 8px; margin-bottom: 10px;
color: var(--text); color: var(--text);
font-size: 18px;
font-weight: 600;
}
.empty-state p {
max-width: 380px;
line-height: 1.5;
font-size: 13px;
} }
/* Status Bar */ /* Status Bar */