feat: live recording meta + events viewer modal

Two finishing touches on the live-recording stack.

1. Live recording meta line. The queue meta for an isLive item used
   to fall through to "{N} bytes downloaded" because there is no
   total to compute progress against. Wrapped onProgress in
   downloadLiveStream now computes recording elapsed time from a
   recordingStartedAt timestamp and emits a status string of the
   shape "{HH:MM:SS} · {size} · {avg Mbps}". Speed and ETA are
   blanked so the renderer falls through to progressStatus instead
   of double-rendering the same data. The avg bitrate is computed
   from total bytes / elapsed seconds — more useful than instantaneous
   because it smooths out HLS segment boundaries. Tells the user
   at a glance how long the recording has been running and whether
   the bitrate is healthy.

2. Events viewer modal. Companion to the chat viewer from 4.6.8.
   Queue items with a sibling .events.jsonl get a new "View events"
   button next to "View chat". Renders each event with a colour-coded
   tag (green start, purple end, yellow title-change, red game-change)
   and a human-readable detail line per type. Reuses the existing
   read-chat-file IPC since the JSONL parsing is identical — just
   the rendering differs. Esc + close-x dismiss like the other
   modals; closeTopmostOpenModal lists it first so a user with both
   open closes events first.

DE + EN locale strings for the new button + every event-type detail
line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 21:50:13 +02:00
parent 5098510d53
commit fab263ae4c
6 changed files with 165 additions and 1 deletions

View File

@ -136,6 +136,16 @@
</div>
</div>
<!-- Events Viewer Modal -->
<div class="modal-overlay" id="eventsViewerModal">
<div class="modal" style="max-width: 700px; max-height: 80vh; display:flex; flex-direction:column;">
<button class="modal-close" onclick="closeEventsViewer()">x</button>
<h2 id="eventsViewerTitle" style="margin-top:0;">Stream events</h2>
<div id="eventsViewerStatus" style="color:var(--text-secondary); font-size:12px; margin-bottom:8px;"></div>
<div id="eventsViewerList" style="flex:1; overflow-y:auto; background: var(--bg-main); border:1px solid var(--border-soft); border-radius:6px; padding:8px;"></div>
</div>
</div>
<!-- Chat Replay Viewer Modal -->
<div class="modal-overlay" id="chatViewerModal">
<div class="modal" style="max-width: 800px; height: 80vh; display:flex; flex-direction:column;">

View File

@ -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);

View File

@ -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',

View File

@ -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',

View File

@ -25,6 +25,13 @@ function renderQueueItemFileActions(item: QueueItem): string {
buttons.push(`<button class="queue-detail-btn" onclick="openChatViewer('${safeChatAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewChat)}</button>`);
}
// 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, '&quot;');
buttons.push(`<button class="queue-detail-btn" onclick="openEventsViewer('${safeEventsAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewEvents)}</button>`);
}
const fileLabel = item.outputFiles.length === 1
? safeFirst
: `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`;

View File

@ -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<void> {
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<string, string> = {
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();