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:
parent
5098510d53
commit
fab263ae4c
@ -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;">
|
||||
|
||||
23
src/main.ts
23
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);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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, '"');
|
||||
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)))}`;
|
||||
|
||||
112
src/renderer.ts
112
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<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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user