feat: in-app chat replay viewer — read .chat.json/.chat.jsonl without leaving the app

Up to now, the app saved chat data (4.6.2 VOD replay, 4.6.3 live
capture) but had no way to view it — users had to open the file in
Notepad or write a custom parser. New in-app modal closes that loop:
queue items with a sibling .chat.json or .chat.jsonl get a "View
chat" button next to Open file / Show in folder; click pops a modal
with a scrollable, filterable, formatted message list.

Server:
- New ipcMain.handle("read-chat-file") parses both formats. JSON
  Lines (.jsonl) is split per line, header row skipped, malformed
  lines silently dropped — that way a partial / killed live capture
  still renders. JSON object (.json) is the VOD replay shape with
  messages array. Hard-capped at 50k messages so a multi-day archive
  can't kill the renderer; truncation is reported via {truncated,
  total} in the result.

Renderer:
- New chatViewerModal in index.html — full-height list with a filter
  input + status line.
- openChatViewer(filePath, title) loads the file via IPC, normalises
  the message shape (supports both .chat.json and .chat.jsonl
  fields), renders in 500-message chunks via setTimeout(0) so the
  main thread stays responsive on a 30k-message archive.
- Each row: time marker (offset for replays, wall-clock for live),
  user (in their stored color), message text. Non-msg event types
  (subs, raids, clears) get a faint italic [type] tag.
- Filter substring-matches user OR text, case-insensitive, instant.
- Esc + outside-click + the close-x dismiss; Esc handler in
  closeTopmostOpenModal lists the chat viewer first so a user
  with multiple modals open closes the foreground one.

Queue UI:
- renderQueueItemFileActions detects sibling chat files (regex
  /\.chat\.json(l)?$/) in item.outputFiles and surfaces the View
  chat button. The button is shown for both 4.6.2-style replays
  and 4.6.3-style live captures because both formats parse.

DE + EN locales for the button label, loading state, error,
message count, truncation suffix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 21:42:41 +02:00
parent dc0b92d5a4
commit 3129c9b5be
8 changed files with 235 additions and 1 deletions

View File

@ -136,6 +136,19 @@
</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;">
<button class="modal-close" onclick="closeChatViewer()">x</button>
<h2 id="chatViewerTitle" style="margin-top:0;">Chat replay</h2>
<div class="form-row" style="margin-bottom:8px; gap:8px; flex-wrap:wrap; align-items:center;">
<input type="text" id="chatViewerFilter" placeholder="Filter..." oninput="onChatViewerFilterChange()" style="flex:1; min-width:160px; background: var(--bg-card); border:1px solid var(--border-soft); border-radius:6px; padding:6px 10px; color:var(--text); font-size:13px;">
<span id="chatViewerStatus" style="color:var(--text-secondary); font-size:12px;"></span>
</div>
<div id="chatViewerList" style="flex:1; overflow-y:auto; background: var(--bg-main); border:1px solid var(--border-soft); border-radius:6px; padding:8px; font-family: 'Consolas', monospace; font-size: 12px;"></div>
</div>
</div>
<!-- Template Guide Modal -->
<div class="modal-overlay" id="templateGuideModal">
<div class="modal template-guide-modal">

View File

@ -5693,6 +5693,58 @@ ipcMain.handle('run-storage-cleanup', (_, options?: { dryRun?: boolean }): Clean
return runStorageCleanup({ dryRun: options?.dryRun === true });
});
// Read a chat-replay (.chat.json) or live-chat (.chat.jsonl) file and
// return a normalized message list the renderer can display directly.
// Caps at 50k messages to stop a runaway file from killing the renderer.
ipcMain.handle('read-chat-file', (_, filePath: string): { success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; truncated?: boolean; total?: number } => {
if (typeof filePath !== 'string' || !filePath) return { success: false, error: 'No path' };
if (!fs.existsSync(filePath)) return { success: false, error: 'File not found' };
const MAX_MESSAGES = 50000;
try {
const raw = fs.readFileSync(filePath, 'utf-8');
if (filePath.toLowerCase().endsWith('.jsonl')) {
// JSON Lines (live chat): one object per line, first line may be header
const messages: Array<Record<string, unknown>> = [];
let truncated = false;
const lines = raw.split('\n');
let total = 0;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed);
if (obj && typeof obj === 'object' && obj.type !== 'header') {
total++;
if (messages.length < MAX_MESSAGES) messages.push(obj);
else truncated = true;
}
} catch { /* skip bad lines */ }
}
return { success: true, format: 'live', messages, truncated, total };
}
// .chat.json (VOD replay) — single object with messages array
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.messages)) {
return { success: false, error: 'Unsupported chat file format' };
}
const total = parsed.messages.length;
const messages = parsed.messages.length > MAX_MESSAGES
? parsed.messages.slice(0, MAX_MESSAGES)
: parsed.messages;
return {
success: true,
format: 'replay',
messages,
truncated: total > MAX_MESSAGES,
total
};
} catch (e) {
return { success: false, error: String(e) };
}
});
ipcMain.handle('check-folder-writable', (_, folderPath: string): boolean => {
if (typeof folderPath !== 'string' || !folderPath) return false;
return isDownloadPathWritable(folderPath);

View File

@ -91,6 +91,7 @@ contextBridge.exposeInMainWorld('api', {
checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path),
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath),
// Video Cutter
getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),

View File

@ -257,6 +257,7 @@ interface ApiBridge {
checkFolderWritable(path: string): Promise<boolean>;
getStorageStats(): Promise<StorageStatsResult>;
runStorageCleanup(options?: { dryRun?: boolean }): Promise<CleanupReport>;
readChatFile(filePath: string): Promise<{ success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; truncated?: boolean; total?: number }>;
getVideoInfo(filePath: string): Promise<VideoInfo | null>;
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;

View File

@ -232,6 +232,11 @@ const UI_TEXT_DE = {
openFileFailed: 'Datei konnte nicht geoeffnet werden (evtl. verschoben oder geloescht).',
outputFilesLabel: '{count} Ausgabedateien',
retryItem: 'Diesen Eintrag erneut versuchen',
viewChat: 'Chat ansehen',
viewChatLoading: 'Lade Chat...',
viewChatFailed: 'Chat-Datei konnte nicht gelesen werden',
viewChatCount: '{count} Nachrichten',
viewChatTruncatedSuffix: ' (gekuerzt)',
statusBarSummary: '{downloading} aktiv, {pending} wartet',
ctxMoveTop: 'Nach oben verschieben',
ctxMoveBottom: 'Nach unten verschieben',

View File

@ -232,6 +232,11 @@ const UI_TEXT_EN = {
openFileFailed: 'Could not open the file (it may have been moved or deleted).',
outputFilesLabel: '{count} output files',
retryItem: 'Retry this item',
viewChat: 'View chat',
viewChatLoading: 'Loading chat...',
viewChatFailed: 'Could not read chat file',
viewChatCount: '{count} messages',
viewChatTruncatedSuffix: ' (truncated)',
statusBarSummary: '{downloading} dl, {pending} queued',
ctxMoveTop: 'Move to top',
ctxMoveBottom: 'Move to bottom',

View File

@ -17,6 +17,14 @@ function renderQueueItemFileActions(item: QueueItem): string {
}
buttons.push(`<button class="queue-detail-btn" onclick="invokeShowInFolder('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.showInFolder)}</button>`);
// Surface a "View chat" button when a sibling chat file exists in the
// outputs list. Single click opens the in-app viewer modal.
const chatFile = item.outputFiles.find((f) => /\.chat\.json(l)?$/i.test(f));
if (chatFile) {
const safeChatAttr = chatFile.replace(/'/g, "\\'").replace(/"/g, '&quot;');
buttons.push(`<button class="queue-detail-btn" onclick="openChatViewer('${safeChatAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewChat)}</button>`);
}
const fileLabel = item.outputFiles.length === 1
? safeFirst
: `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`;

View File

@ -246,8 +246,157 @@ function openTwitchDevConsole(): void {
void window.api.openExternal('https://dev.twitch.tv/console/apps');
}
interface ChatViewerMessage {
t?: string;
type?: string;
u?: string;
user?: string;
login?: string;
color?: string;
msg?: string;
text?: string;
offset?: number;
badges?: string;
bits?: string;
msgId?: string;
systemMsg?: string;
}
let chatViewerMessages: ChatViewerMessage[] = [];
let chatViewerFormat: 'replay' | 'live' = 'replay';
async function openChatViewer(filePath: string, title: string): Promise<void> {
const modal = byId('chatViewerModal');
const list = byId('chatViewerList');
const status = byId('chatViewerStatus');
const filterInput = byId<HTMLInputElement>('chatViewerFilter');
byId('chatViewerTitle').textContent = title || UI_TEXT.queue.viewChat;
list.replaceChildren();
filterInput.value = '';
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;
}
chatViewerMessages = result.messages as ChatViewerMessage[];
chatViewerFormat = result.format === 'live' ? 'live' : 'replay';
status.textContent = UI_TEXT.queue.viewChatCount.replace('{count}', String(result.total ?? chatViewerMessages.length))
+ (result.truncated ? UI_TEXT.queue.viewChatTruncatedSuffix : '');
renderChatViewerList(chatViewerMessages);
}
function closeChatViewer(): void {
byId('chatViewerModal').classList.remove('show');
chatViewerMessages = [];
}
function onChatViewerFilterChange(): void {
const filter = byId<HTMLInputElement>('chatViewerFilter').value.trim().toLowerCase();
if (!filter) {
renderChatViewerList(chatViewerMessages);
return;
}
const filtered = chatViewerMessages.filter((m) => {
const u = (m.u || m.user || m.login || '').toLowerCase();
const text = (m.msg || m.text || '').toLowerCase();
return u.includes(filter) || text.includes(filter);
});
renderChatViewerList(filtered);
}
function formatChatTimeMarker(m: ChatViewerMessage): string {
if (chatViewerFormat === 'replay' && typeof m.offset === 'number') {
const total = Math.max(0, Math.floor(m.offset));
const h = Math.floor(total / 3600);
const min = Math.floor((total % 3600) / 60);
const sec = total % 60;
return `${h.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
}
if (m.t) {
try {
const d = new Date(m.t);
const h = d.getHours().toString().padStart(2, '0');
const min = d.getMinutes().toString().padStart(2, '0');
const sec = d.getSeconds().toString().padStart(2, '0');
return `${h}:${min}:${sec}`;
} catch { /* ignore */ }
}
return '';
}
function renderChatViewerList(messages: ChatViewerMessage[]): void {
const list = byId('chatViewerList');
list.replaceChildren();
// Render in chunks to keep main thread responsive on big files.
const CHUNK = 500;
let idx = 0;
const renderChunk = (): void => {
if (idx >= messages.length) return;
const fragment = document.createDocumentFragment();
const end = Math.min(idx + CHUNK, messages.length);
for (let i = idx; i < end; i++) {
const m = messages[i];
const row = document.createElement('div');
row.style.padding = '2px 0';
row.style.lineHeight = '1.5';
const time = formatChatTimeMarker(m);
if (time) {
const tSpan = document.createElement('span');
tSpan.style.color = 'var(--text-secondary)';
tSpan.style.marginRight = '6px';
tSpan.textContent = `[${time}]`;
row.appendChild(tSpan);
}
const user = m.u || m.user || m.login || '';
if (user) {
const uSpan = document.createElement('span');
uSpan.style.fontWeight = '600';
uSpan.style.color = m.color || 'var(--accent)';
uSpan.style.marginRight = '4px';
uSpan.textContent = `${user}:`;
row.appendChild(uSpan);
}
const msgSpan = document.createElement('span');
msgSpan.textContent = m.msg || m.text || '';
row.appendChild(msgSpan);
// System events (subs, raids, deletions) get a faint bracketed prefix
const isMessageType = m.type === 'msg' || !m.type;
if (!isMessageType) {
const tag = document.createElement('span');
tag.style.color = 'var(--text-secondary)';
tag.style.fontStyle = 'italic';
tag.style.marginRight = '4px';
tag.textContent = `[${m.type}]`;
row.insertBefore(tag, row.firstChild);
}
fragment.appendChild(row);
}
list.appendChild(fragment);
idx = end;
if (idx < messages.length) {
window.setTimeout(renderChunk, 0);
}
};
renderChunk();
}
function closeTopmostOpenModal(): boolean {
// Try each known modal in priority order: clip dialog, template guide, update modal
// Try each known modal in priority order
const chatViewerModal = document.getElementById('chatViewerModal');
if (chatViewerModal?.classList.contains('show')) {
closeChatViewer();
return true;
}
const clipModal = document.getElementById('clipModal');
if (clipModal?.classList.contains('show')) {
closeClipDialog();