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:
parent
dc0b92d5a4
commit
3129c9b5be
@ -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">
|
||||
|
||||
52
src/main.ts
52
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<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);
|
||||
|
||||
@ -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),
|
||||
|
||||
1
src/renderer-globals.d.ts
vendored
1
src/renderer-globals.d.ts
vendored
@ -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 }>;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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, '"');
|
||||
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)))}`;
|
||||
|
||||
151
src/renderer.ts
151
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<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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user