diff --git a/src/index.html b/src/index.html
index 9bced6e..ba2fcb6 100644
--- a/src/index.html
+++ b/src/index.html
@@ -136,6 +136,16 @@
+
+
diff --git a/src/main.ts b/src/main.ts
index 3c132f8..b5165cf 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -3904,10 +3904,31 @@ async function downloadLiveStream(
const recordingStartedAt = Date.now();
+ // Wrap onProgress so live recordings get a useful meta line. Without
+ // this the queue meta only shows raw bytes ("4.7 GB heruntergeladen")
+ // which doesn't tell the user how long the recording has been running
+ // or whether the bitrate is healthy. Substitutes:
+ // "{HH:MM:SS} · {size} · {avg Mbps}"
+ // and clears speed/eta so the renderer doesn't double-up on data.
+ const wrappedProgress = (p: DownloadProgress): void => {
+ const elapsed = Math.max(1, Math.floor((Date.now() - recordingStartedAt) / 1000));
+ const bytes = Number(p.downloadedBytes) || 0;
+ const avgBitrateMbps = (bytes * 8) / elapsed / 1_000_000;
+ const parts: string[] = [formatDuration(elapsed)];
+ if (bytes > 0) parts.push(formatBytes(bytes));
+ if (avgBitrateMbps > 0) parts.push(`${avgBitrateMbps.toFixed(1)} Mbps`);
+ onProgress({
+ ...p,
+ speed: '',
+ eta: '',
+ status: parts.join(' · ')
+ });
+ };
+
// No start/end times for live streams — streamlink records until the
// stream actually ends or we kill it. downloadVODPart already handles
// null start/end correctly.
- const result = await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1);
+ const result = await downloadVODPart(item.url, filename, null, null, wrappedProgress, item.id, 1, 1);
if (chatSession) {
stopLiveChatCapture(chatSession);
diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts
index 81199f4..64186db 100644
--- a/src/renderer-locale-de.ts
+++ b/src/renderer-locale-de.ts
@@ -237,6 +237,13 @@ const UI_TEXT_DE = {
viewChatFailed: 'Chat-Datei konnte nicht gelesen werden',
viewChatCount: '{count} Nachrichten',
viewChatTruncatedSuffix: ' (gekuerzt)',
+ viewEvents: 'Events ansehen',
+ viewEventsCount: '{count} Events',
+ viewEventsEmpty: 'Keine Events aufgezeichnet.',
+ eventStartedAs: 'Gestartet als',
+ eventEndedAfter: 'Beendet nach',
+ eventTitleFromTo: 'Titel: {from} -> {to}',
+ eventGameFromTo: 'Game: {from} -> {to}',
statusBarSummary: '{downloading} aktiv, {pending} wartet',
ctxMoveTop: 'Nach oben verschieben',
ctxMoveBottom: 'Nach unten verschieben',
diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts
index 02aeefc..fe29929 100644
--- a/src/renderer-locale-en.ts
+++ b/src/renderer-locale-en.ts
@@ -237,6 +237,13 @@ const UI_TEXT_EN = {
viewChatFailed: 'Could not read chat file',
viewChatCount: '{count} messages',
viewChatTruncatedSuffix: ' (truncated)',
+ viewEvents: 'View events',
+ viewEventsCount: '{count} events',
+ viewEventsEmpty: 'No events recorded.',
+ eventStartedAs: 'Started as',
+ eventEndedAfter: 'Ended after',
+ eventTitleFromTo: 'Title: {from} -> {to}',
+ eventGameFromTo: 'Game: {from} -> {to}',
statusBarSummary: '{downloading} dl, {pending} queued',
ctxMoveTop: 'Move to top',
ctxMoveBottom: 'Move to bottom',
diff --git a/src/renderer-queue.ts b/src/renderer-queue.ts
index bac3494..ecd51ab 100644
--- a/src/renderer-queue.ts
+++ b/src/renderer-queue.ts
@@ -25,6 +25,13 @@ function renderQueueItemFileActions(item: QueueItem): string {
buttons.push(``);
}
+ // Same pattern for the .events.jsonl sidecar — title/game change timeline.
+ const eventsFile = item.outputFiles.find((f) => /\.events\.jsonl$/i.test(f));
+ if (eventsFile) {
+ const safeEventsAttr = eventsFile.replace(/'/g, "\\'").replace(/"/g, '"');
+ buttons.push(``);
+ }
+
const fileLabel = item.outputFiles.length === 1
? safeFirst
: `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`;
diff --git a/src/renderer.ts b/src/renderer.ts
index cf96d57..8343e52 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -246,6 +246,112 @@ function openTwitchDevConsole(): void {
void window.api.openExternal('https://dev.twitch.tv/console/apps');
}
+interface EventLogEntry {
+ t?: string;
+ type?: string;
+ title?: string;
+ game?: string;
+ from?: string;
+ to?: string;
+ streamer?: string;
+ durationSeconds?: number;
+ success?: boolean;
+ error?: string;
+}
+
+async function openEventsViewer(filePath: string, title: string): Promise {
+ const modal = byId('eventsViewerModal');
+ const list = byId('eventsViewerList');
+ const status = byId('eventsViewerStatus');
+ byId('eventsViewerTitle').textContent = title || UI_TEXT.queue.viewEvents;
+ list.replaceChildren();
+ status.textContent = UI_TEXT.queue.viewChatLoading;
+ modal.classList.add('show');
+
+ const result = await window.api.readChatFile(filePath);
+ if (!result.success || !Array.isArray(result.messages)) {
+ status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : '');
+ return;
+ }
+ const events = result.messages as EventLogEntry[];
+ status.textContent = UI_TEXT.queue.viewEventsCount.replace('{count}', String(events.length));
+ renderEventsList(events);
+}
+
+function closeEventsViewer(): void {
+ byId('eventsViewerModal').classList.remove('show');
+}
+
+function formatEventTime(iso?: string): string {
+ if (!iso) return '';
+ try {
+ const d = new Date(iso);
+ return d.toLocaleString(currentLanguage === 'en' ? 'en-US' : 'de-DE');
+ } catch { return iso; }
+}
+
+function renderEventsList(events: EventLogEntry[]): void {
+ const list = byId('eventsViewerList');
+ list.replaceChildren();
+ if (events.length === 0) {
+ const empty = document.createElement('div');
+ empty.style.color = 'var(--text-secondary)';
+ empty.style.padding = '12px';
+ empty.style.textAlign = 'center';
+ empty.textContent = UI_TEXT.queue.viewEventsEmpty;
+ list.appendChild(empty);
+ return;
+ }
+
+ for (const ev of events) {
+ const row = document.createElement('div');
+ row.style.padding = '8px 10px';
+ row.style.borderBottom = '1px solid var(--border-soft)';
+ row.style.fontSize = '12px';
+
+ const time = document.createElement('span');
+ time.style.color = 'var(--text-secondary)';
+ time.style.marginRight = '8px';
+ time.textContent = formatEventTime(ev.t);
+ row.appendChild(time);
+
+ const tag = document.createElement('span');
+ tag.style.fontWeight = '600';
+ tag.style.marginRight = '8px';
+ const tagColors: Record = {
+ recording_start: '#00c853',
+ recording_end: '#9146ff',
+ title_change: '#ffab00',
+ game_change: '#ff4444'
+ };
+ tag.style.color = tagColors[ev.type || ''] || 'var(--accent)';
+ tag.textContent = ev.type || 'event';
+ row.appendChild(tag);
+
+ const detail = document.createElement('div');
+ detail.style.marginTop = '4px';
+ detail.style.color = 'var(--text)';
+
+ if (ev.type === 'recording_start') {
+ detail.textContent = `${UI_TEXT.queue.eventStartedAs}: "${ev.title || '-'}" — ${ev.game || '-'}`;
+ } else if (ev.type === 'recording_end') {
+ const dur = typeof ev.durationSeconds === 'number'
+ ? `${Math.floor(ev.durationSeconds / 3600)}h ${Math.floor((ev.durationSeconds % 3600) / 60)}m ${ev.durationSeconds % 60}s`
+ : '?';
+ const ok = ev.success ? '✓' : '✗';
+ detail.textContent = `${ok} ${UI_TEXT.queue.eventEndedAfter}: ${dur}${ev.error ? ` — ${ev.error}` : ''}`;
+ } else if (ev.type === 'title_change') {
+ detail.textContent = `${UI_TEXT.queue.eventTitleFromTo.replace('{from}', `"${ev.from || '-'}"`).replace('{to}', `"${ev.to || '-'}"`)}`;
+ } else if (ev.type === 'game_change') {
+ detail.textContent = `${UI_TEXT.queue.eventGameFromTo.replace('{from}', ev.from || '-').replace('{to}', ev.to || '-')}`;
+ } else {
+ detail.textContent = JSON.stringify(ev);
+ }
+ row.appendChild(detail);
+ list.appendChild(row);
+ }
+}
+
interface ChatViewerMessage {
t?: string;
type?: string;
@@ -391,6 +497,12 @@ function renderChatViewerList(messages: ChatViewerMessage[]): void {
function closeTopmostOpenModal(): boolean {
// Try each known modal in priority order
+ const eventsViewerModal = document.getElementById('eventsViewerModal');
+ if (eventsViewerModal?.classList.contains('show')) {
+ closeEventsViewer();
+ return true;
+ }
+
const chatViewerModal = document.getElementById('chatViewerModal');
if (chatViewerModal?.classList.contains('show')) {
closeChatViewer();