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:
parent
fa8c2b2658
commit
11883889de
119
src/main.ts
119
src/main.ts
@ -3694,6 +3694,108 @@ async function runAutoVodPoll(): Promise<number> {
|
||||
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
|
||||
// ==========================================
|
||||
@ -6408,6 +6510,7 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
|
||||
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<Config>) => {
|
||||
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<VodStoryb
|
||||
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 => {
|
||||
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,
|
||||
|
||||
@ -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<string, boolean> }) => void) => {
|
||||
ipcRenderer.on('live-status-batch-update', (_, info) => callback(info));
|
||||
},
|
||||
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),
|
||||
|
||||
2
src/renderer-globals.d.ts
vendored
2
src/renderer-globals.d.ts
vendored
@ -346,6 +346,8 @@ interface ApiBridge {
|
||||
getArchiveStats(): Promise<ArchiveStats>;
|
||||
getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | 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: {
|
||||
query?: string;
|
||||
type?: 'all' | 'live' | 'vod' | 'chat' | 'events';
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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<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
|
||||
// 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
|
||||
|
||||
@ -42,6 +42,10 @@ async function init(): Promise<void> {
|
||||
changeTheme(config.theme ?? 'twitch');
|
||||
renderStreamers();
|
||||
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();
|
||||
updateDownloadButtonState();
|
||||
updateStatusBarQueueSummary();
|
||||
|
||||
@ -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 */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user