diff --git a/src/index.html b/src/index.html
index 10f8b57..9bced6e 100644
--- a/src/index.html
+++ b/src/index.html
@@ -136,6 +136,19 @@
+
+
diff --git a/src/main.ts b/src/main.ts
index a9635ef..3c132f8 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -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>; 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> = [];
+ 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);
diff --git a/src/preload.ts b/src/preload.ts
index 30fcfbc..bc43bb7 100644
--- a/src/preload.ts
+++ b/src/preload.ts
@@ -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 => ipcRenderer.invoke('get-video-info', filePath),
diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts
index ad99062..3afb5e6 100644
--- a/src/renderer-globals.d.ts
+++ b/src/renderer-globals.d.ts
@@ -257,6 +257,7 @@ interface ApiBridge {
checkFolderWritable(path: string): Promise;
getStorageStats(): Promise;
runStorageCleanup(options?: { dryRun?: boolean }): Promise;
+ readChatFile(filePath: string): Promise<{ success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array>; truncated?: boolean; total?: number }>;
getVideoInfo(filePath: string): Promise;
extractFrame(filePath: string, timeSeconds: number): Promise;
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;
diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts
index 269c46c..81199f4 100644
--- a/src/renderer-locale-de.ts
+++ b/src/renderer-locale-de.ts
@@ -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',
diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts
index c2c418b..02aeefc 100644
--- a/src/renderer-locale-en.ts
+++ b/src/renderer-locale-en.ts
@@ -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',
diff --git a/src/renderer-queue.ts b/src/renderer-queue.ts
index 400b03c..bac3494 100644
--- a/src/renderer-queue.ts
+++ b/src/renderer-queue.ts
@@ -17,6 +17,14 @@ function renderQueueItemFileActions(item: QueueItem): string {
}
buttons.push(``);
+ // 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, '"');
+ 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 91d929e..cf96d57 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -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 {
+ const modal = byId('chatViewerModal');
+ const list = byId('chatViewerList');
+ const status = byId('chatViewerStatus');
+ const filterInput = byId('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('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();