feat(accessibility): harden chat and keyboard interactions
Stream capability-authorized chat reads with cancellation, virtualize viewer rendering, centralize modal focus management, and add keyboard-accessible queue and VOD menus. Expose command palette combobox state and keep the document language synchronized with the selected locale.
This commit is contained in:
+11
-6
@@ -9,7 +9,7 @@
|
|||||||
<link rel="stylesheet" href="./workspace.css">
|
<link rel="stylesheet" href="./workspace.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="theme-twitch">
|
<body class="theme-twitch">
|
||||||
<div class="modal-overlay" id="updateModal" role="dialog" aria-modal="true" aria-labelledby="updateModalTitle" onclick="handleUpdateModalOverlayClick(event)">
|
<div class="modal-overlay" id="updateModal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="updateModalTitle" onclick="handleUpdateModalOverlayClick(event)">
|
||||||
<div class="modal update-modal">
|
<div class="modal update-modal">
|
||||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="dismissUpdateModal()">x</button>
|
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="dismissUpdateModal()">x</button>
|
||||||
<div class="update-modal-eyebrow" id="updateModalEyebrow">Updates</div>
|
<div class="update-modal-eyebrow" id="updateModalEyebrow">Updates</div>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Clip Dialog Modal -->
|
<!-- Clip Dialog Modal -->
|
||||||
<div class="modal-overlay" id="clipModal" role="dialog" aria-modal="true" aria-labelledby="clipDialogTitle">
|
<div class="modal-overlay" id="clipModal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="clipDialogTitle">
|
||||||
<div class="modal clip-modal">
|
<div class="modal clip-modal">
|
||||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeClipDialog()">x</button>
|
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeClipDialog()">x</button>
|
||||||
<h2 class="clip-modal-title" id="clipDialogTitle">VOD zuschneiden</h2>
|
<h2 class="clip-modal-title" id="clipDialogTitle">VOD zuschneiden</h2>
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Events Viewer Modal -->
|
<!-- Events Viewer Modal -->
|
||||||
<div class="modal-overlay" id="eventsViewerModal" role="dialog" aria-modal="true" aria-labelledby="eventsViewerTitle">
|
<div class="modal-overlay" id="eventsViewerModal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="eventsViewerTitle">
|
||||||
<div class="modal viewer-modal viewer-modal-events">
|
<div class="modal viewer-modal viewer-modal-events">
|
||||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeEventsViewer()">x</button>
|
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeEventsViewer()">x</button>
|
||||||
<h2 id="eventsViewerTitle" class="viewer-modal-title"></h2>
|
<h2 id="eventsViewerTitle" class="viewer-modal-title"></h2>
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chat Replay Viewer Modal -->
|
<!-- Chat Replay Viewer Modal -->
|
||||||
<div class="modal-overlay" id="chatViewerModal" role="dialog" aria-modal="true" aria-labelledby="chatViewerTitle">
|
<div class="modal-overlay" id="chatViewerModal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="chatViewerTitle">
|
||||||
<div class="modal viewer-modal viewer-modal-chat">
|
<div class="modal viewer-modal viewer-modal-chat">
|
||||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeChatViewer()">x</button>
|
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeChatViewer()">x</button>
|
||||||
<h2 id="chatViewerTitle" class="viewer-modal-title"></h2>
|
<h2 id="chatViewerTitle" class="viewer-modal-title"></h2>
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Template Guide Modal -->
|
<!-- Template Guide Modal -->
|
||||||
<div class="modal-overlay" id="templateGuideModal" role="dialog" aria-modal="true" aria-labelledby="templateGuideTitle">
|
<div class="modal-overlay" id="templateGuideModal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="templateGuideTitle">
|
||||||
<div class="modal template-guide-modal">
|
<div class="modal template-guide-modal">
|
||||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeTemplateGuide()">x</button>
|
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeTemplateGuide()">x</button>
|
||||||
<h2 id="templateGuideTitle">Template Guide</h2>
|
<h2 id="templateGuideTitle">Template Guide</h2>
|
||||||
@@ -1058,7 +1058,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="commandPaletteModal" role="dialog" aria-modal="true" aria-labelledby="commandPaletteTitle">
|
<div class="modal-overlay" id="commandPaletteModal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="commandPaletteTitle">
|
||||||
<div class="modal command-palette">
|
<div class="modal command-palette">
|
||||||
<h2 id="commandPaletteTitle" class="cp-title">Befehlspalette</h2>
|
<h2 id="commandPaletteTitle" class="cp-title">Befehlspalette</h2>
|
||||||
<input
|
<input
|
||||||
@@ -1069,6 +1069,10 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
aria-label="Befehlspalette"
|
aria-label="Befehlspalette"
|
||||||
|
role="combobox"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls="commandPaletteList"
|
||||||
|
aria-expanded="false"
|
||||||
/>
|
/>
|
||||||
<ul id="commandPaletteList" class="cp-list" role="listbox" aria-label="Befehlsergebnisse"></ul>
|
<ul id="commandPaletteList" class="cp-list" role="listbox" aria-label="Befehlsergebnisse"></ul>
|
||||||
<p class="cp-hint" id="commandPaletteHint">Up/Down zum Navigieren, Enter zum Ausführen, Esc zum Schließen</p>
|
<p class="cp-hint" id="commandPaletteHint">Up/Down zum Navigieren, Enter zum Ausführen, Esc zum Schließen</p>
|
||||||
@@ -1079,6 +1083,7 @@
|
|||||||
<script src="../dist/renderer-locale-en.js"></script>
|
<script src="../dist/renderer-locale-en.js"></script>
|
||||||
<script src="../dist/renderer-texts.js"></script>
|
<script src="../dist/renderer-texts.js"></script>
|
||||||
<script src="../dist/renderer-shared.js"></script>
|
<script src="../dist/renderer-shared.js"></script>
|
||||||
|
<script src="../dist/renderer-accessibility.js"></script>
|
||||||
<script src="../dist/renderer-settings.js"></script>
|
<script src="../dist/renderer-settings.js"></script>
|
||||||
<script src="../dist/renderer-streamers.js"></script>
|
<script src="../dist/renderer-streamers.js"></script>
|
||||||
<script src="../dist/renderer-queue.js"></script>
|
<script src="../dist/renderer-queue.js"></script>
|
||||||
|
|||||||
+23
-42
@@ -57,6 +57,7 @@ import { commitQueueMutation, persistStateChange } from './main/domain/persisten
|
|||||||
import { resolveSecretInputUpdate } from './main/domain/secret-input';
|
import { resolveSecretInputUpdate } from './main/domain/secret-input';
|
||||||
import { createSecretStore, type SecretStore } from './main/domain/secret-store';
|
import { createSecretStore, type SecretStore } from './main/domain/secret-store';
|
||||||
import { createElectronSecureStorage } from './main/infra/secure-storage';
|
import { createElectronSecureStorage } from './main/infra/secure-storage';
|
||||||
|
import { readChatFile } from './main/domain/chat-reader';
|
||||||
import {
|
import {
|
||||||
setDebugLogFn, initToolDirs,
|
setDebugLogFn, initToolDirs,
|
||||||
getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath,
|
getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath,
|
||||||
@@ -7668,6 +7669,7 @@ ipcMain.handle('cancel-download', async (event) => {
|
|||||||
const fileCapabilities = new FileCapabilityStore();
|
const fileCapabilities = new FileCapabilityStore();
|
||||||
const VIDEO_FILE_EXTENSIONS = ['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ts', 'avi'];
|
const VIDEO_FILE_EXTENSIONS = ['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ts', 'avi'];
|
||||||
const knownRendererPaths = new Map<FileCapabilityPurpose, Set<string>>();
|
const knownRendererPaths = new Map<FileCapabilityPurpose, Set<string>>();
|
||||||
|
const activeChatReadControllers = new Map<number, Map<string, AbortController>>();
|
||||||
|
|
||||||
function issueFileCapability(event: IpcMainInvokeEvent, purpose: FileCapabilityPurpose, filePath: string, kind: 'input-file' | 'output-file' | 'directory', extensions: string[] = [], ttlMs?: number): FileCapabilityReference {
|
function issueFileCapability(event: IpcMainInvokeEvent, purpose: FileCapabilityPurpose, filePath: string, kind: 'input-file' | 'output-file' | 'directory', extensions: string[] = [], ttlMs?: number): FileCapabilityReference {
|
||||||
return fileCapabilities.issue({ ownerId: event.sender.id, purpose, path: filePath, kind, extensions, ttlMs });
|
return fileCapabilities.issue({ ownerId: event.sender.id, purpose, path: filePath, kind, extensions, ttlMs });
|
||||||
@@ -8073,52 +8075,31 @@ ipcMain.handle('run-storage-cleanup', (event, options?: { dryRun?: boolean }): C
|
|||||||
// Read a chat-replay (.chat.json) or live-chat (.chat.jsonl) file and
|
// Read a chat-replay (.chat.json) or live-chat (.chat.jsonl) file and
|
||||||
// return a normalized message list the renderer can display directly.
|
// return a normalized message list the renderer can display directly.
|
||||||
// Caps at 50k messages to stop a runaway file from killing the renderer.
|
// Caps at 50k messages to stop a runaway file from killing the renderer.
|
||||||
ipcMain.handle('read-chat-file', (event, capability: string): { success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; truncated?: boolean; total?: number } => {
|
ipcMain.on('cancel-chat-read', (event, requestId: string) => {
|
||||||
|
if (!isTrustedRendererEvent(event) || typeof requestId !== 'string') return;
|
||||||
|
activeChatReadControllers.get(event.sender.id)?.get(requestId)?.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('read-chat-file', async (event, capability: string, requestId?: string) => {
|
||||||
const filePath = resolveFileCapability(event, capability, 'chat-input', true);
|
const filePath = resolveFileCapability(event, capability, 'chat-input', true);
|
||||||
if (!filePath) return { success: false, error: 'File access denied' };
|
if (!filePath) return { success: false, error: 'File access denied' };
|
||||||
|
const controller = new AbortController();
|
||||||
const MAX_MESSAGES = 50000;
|
const validRequestId = typeof requestId === 'string' && requestId.length > 0 && requestId.length <= 128
|
||||||
|
? requestId
|
||||||
|
: null;
|
||||||
|
if (validRequestId) {
|
||||||
|
const controllers = activeChatReadControllers.get(event.sender.id) ?? new Map<string, AbortController>();
|
||||||
|
controllers.set(validRequestId, controller);
|
||||||
|
activeChatReadControllers.set(event.sender.id, controllers);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
return await readChatFile(filePath, { signal: controller.signal });
|
||||||
if (filePath.toLowerCase().endsWith('.jsonl')) {
|
} finally {
|
||||||
// JSON Lines (live chat): one object per line, first line may be header
|
if (validRequestId) {
|
||||||
const messages: Array<Record<string, unknown>> = [];
|
const controllers = activeChatReadControllers.get(event.sender.id);
|
||||||
let truncated = false;
|
controllers?.delete(validRequestId);
|
||||||
const lines = raw.split('\n');
|
if (controllers?.size === 0) activeChatReadControllers.delete(event.sender.id);
|
||||||
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) };
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { readChatFile } from './chat-reader';
|
||||||
|
|
||||||
|
describe('chat reader', () => {
|
||||||
|
const directories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streams a large live chat without retaining more than the configured message limit', async () => {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'tvm-chat-reader-'));
|
||||||
|
directories.push(directory);
|
||||||
|
const filePath = join(directory, 'stream.chat.jsonl');
|
||||||
|
const lines = Array.from({ length: 4_000 }, (_, index) => JSON.stringify({ type: 'msg', u: `viewer-${index}`, msg: `message-${index}` }));
|
||||||
|
writeFileSync(filePath, lines.join('\n'), 'utf8');
|
||||||
|
|
||||||
|
let yielded = false;
|
||||||
|
const resultPromise = readChatFile(filePath, { maxMessages: 120, yieldEveryChunks: 1 });
|
||||||
|
void new Promise<void>((resolve) => setImmediate(() => {
|
||||||
|
yielded = true;
|
||||||
|
resolve();
|
||||||
|
}));
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
expect(yielded).toBe(true);
|
||||||
|
expect(result).toMatchObject({ success: true, format: 'live', total: 4_000, truncated: true });
|
||||||
|
if (!result.success) throw new Error(result.error || 'Chat reader failed');
|
||||||
|
expect(result.messages).toHaveLength(120);
|
||||||
|
expect(result.messages?.[0]).toMatchObject({ u: 'viewer-0', msg: 'message-0' });
|
||||||
|
expect(result.messages?.[119]).toMatchObject({ u: 'viewer-119', msg: 'message-119' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops an active stream when the caller cancels the read', async () => {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'tvm-chat-reader-'));
|
||||||
|
directories.push(directory);
|
||||||
|
const filePath = join(directory, 'stream.chat.jsonl');
|
||||||
|
writeFileSync(filePath, Array.from({ length: 30_000 }, (_, index) => JSON.stringify({ type: 'msg', msg: String(index) })).join('\n'), 'utf8');
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const reading = readChatFile(filePath, { signal: controller.signal, yieldEveryChunks: 1 });
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(reading).resolves.toEqual({ success: false, cancelled: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
|
||||||
|
export interface ChatReadSuccess {
|
||||||
|
success: true;
|
||||||
|
format: 'replay' | 'live';
|
||||||
|
messages: Array<Record<string, unknown>>;
|
||||||
|
truncated: boolean;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatReadFailure {
|
||||||
|
success: false;
|
||||||
|
error?: string;
|
||||||
|
cancelled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatReadResult = ChatReadSuccess | ChatReadFailure;
|
||||||
|
|
||||||
|
export interface ChatReadOptions {
|
||||||
|
maxMessages?: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
yieldEveryChunks?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MAX_MESSAGES = 50_000;
|
||||||
|
const MAX_BUFFERED_ENTRY_CHARS = 512 * 1024;
|
||||||
|
|
||||||
|
function normalizeMaxMessages(value: number | undefined): number {
|
||||||
|
if (!Number.isInteger(value) || value === undefined || value < 1) return DEFAULT_MAX_MESSAGES;
|
||||||
|
return Math.min(value, DEFAULT_MAX_MESSAGES);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeYieldEveryChunks(value: number | undefined): number {
|
||||||
|
if (!Number.isInteger(value) || value === undefined || value < 1) return 8;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yieldToEventLoop(): Promise<void> {
|
||||||
|
return new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessage(messages: Array<Record<string, unknown>>, candidate: unknown, maxMessages: number): void {
|
||||||
|
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return;
|
||||||
|
if ((candidate as { type?: unknown }).type === 'header') return;
|
||||||
|
if (messages.length < maxMessages) messages.push(candidate as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findJsonValueEnd(input: string): number | null {
|
||||||
|
const first = input[0];
|
||||||
|
if (!first) return null;
|
||||||
|
if (first === '"') {
|
||||||
|
let escaped = false;
|
||||||
|
for (let index = 1; index < input.length; index++) {
|
||||||
|
const char = input[index];
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '\\') {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '"') return index + 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (first !== '{' && first !== '[') {
|
||||||
|
const separator = input.search(/[\],]/);
|
||||||
|
return separator < 0 ? null : separator;
|
||||||
|
}
|
||||||
|
const closing = first === '{' ? '}' : ']';
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
for (let index = 0; index < input.length; index++) {
|
||||||
|
const char = input[index];
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (char === '\\') {
|
||||||
|
escaped = true;
|
||||||
|
} else if (char === '"') {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '"') {
|
||||||
|
inString = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === first) depth++;
|
||||||
|
if (char === closing) {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return index + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLiveChat(filePath: string, maxMessages: number, signal: AbortSignal | undefined, yieldEveryChunks: number): Promise<ChatReadResult> {
|
||||||
|
const messages: Array<Record<string, unknown>> = [];
|
||||||
|
const stream = createReadStream(filePath, { encoding: 'utf8', highWaterMark: 64 * 1024 });
|
||||||
|
const abort = () => stream.destroy();
|
||||||
|
signal?.addEventListener('abort', abort, { once: true });
|
||||||
|
let total = 0;
|
||||||
|
let chunkCount = 0;
|
||||||
|
let carry = '';
|
||||||
|
try {
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
if (signal?.aborted) return { success: false, cancelled: true };
|
||||||
|
carry += chunk;
|
||||||
|
if (carry.length > MAX_BUFFERED_ENTRY_CHARS && !carry.includes('\n')) throw new Error('Chat line exceeds read limit');
|
||||||
|
let newlineIndex = carry.indexOf('\n');
|
||||||
|
while (newlineIndex >= 0) {
|
||||||
|
const line = carry.slice(0, newlineIndex).trim();
|
||||||
|
carry = carry.slice(newlineIndex + 1);
|
||||||
|
if (line) {
|
||||||
|
try {
|
||||||
|
const candidate = JSON.parse(line);
|
||||||
|
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate) && (candidate as { type?: unknown }).type !== 'header') {
|
||||||
|
total++;
|
||||||
|
addMessage(messages, candidate, maxMessages);
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
newlineIndex = carry.indexOf('\n');
|
||||||
|
}
|
||||||
|
chunkCount++;
|
||||||
|
if (chunkCount % yieldEveryChunks === 0) await yieldToEventLoop();
|
||||||
|
}
|
||||||
|
if (signal?.aborted) return { success: false, cancelled: true };
|
||||||
|
const finalLine = carry.trim();
|
||||||
|
if (finalLine) {
|
||||||
|
try {
|
||||||
|
const candidate = JSON.parse(finalLine);
|
||||||
|
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate) && (candidate as { type?: unknown }).type !== 'header') {
|
||||||
|
total++;
|
||||||
|
addMessage(messages, candidate, maxMessages);
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
return { success: true, format: 'live', messages, truncated: total > messages.length, total };
|
||||||
|
} catch (error) {
|
||||||
|
if (signal?.aborted) return { success: false, cancelled: true };
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
} finally {
|
||||||
|
signal?.removeEventListener('abort', abort);
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readReplayChat(filePath: string, maxMessages: number, signal: AbortSignal | undefined, yieldEveryChunks: number): Promise<ChatReadResult> {
|
||||||
|
const messages: Array<Record<string, unknown>> = [];
|
||||||
|
const stream = createReadStream(filePath, { encoding: 'utf8', highWaterMark: 64 * 1024 });
|
||||||
|
const abort = () => stream.destroy();
|
||||||
|
signal?.addEventListener('abort', abort, { once: true });
|
||||||
|
let total = 0;
|
||||||
|
let chunkCount = 0;
|
||||||
|
let beforeMessages = '';
|
||||||
|
let entries = '';
|
||||||
|
let foundMessages = false;
|
||||||
|
let closed = false;
|
||||||
|
try {
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
if (signal?.aborted) return { success: false, cancelled: true };
|
||||||
|
if (!foundMessages) {
|
||||||
|
beforeMessages += chunk;
|
||||||
|
const match = /"messages"\s*:\s*\[/.exec(beforeMessages);
|
||||||
|
if (!match || match.index === undefined) {
|
||||||
|
beforeMessages = beforeMessages.slice(-128);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foundMessages = true;
|
||||||
|
entries = beforeMessages.slice(match.index + match[0].length);
|
||||||
|
beforeMessages = '';
|
||||||
|
} else {
|
||||||
|
entries += chunk;
|
||||||
|
}
|
||||||
|
if (entries.length > MAX_BUFFERED_ENTRY_CHARS) throw new Error('Chat entry exceeds read limit');
|
||||||
|
while (entries) {
|
||||||
|
const leading = /^\s*,?\s*/.exec(entries)?.[0] ?? '';
|
||||||
|
entries = entries.slice(leading.length);
|
||||||
|
if (!entries) break;
|
||||||
|
if (entries[0] === ']') {
|
||||||
|
closed = true;
|
||||||
|
entries = '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const valueEnd = findJsonValueEnd(entries);
|
||||||
|
if (valueEnd === null) break;
|
||||||
|
const value = entries.slice(0, valueEnd);
|
||||||
|
entries = entries.slice(valueEnd);
|
||||||
|
try {
|
||||||
|
const candidate = JSON.parse(value);
|
||||||
|
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
|
||||||
|
total++;
|
||||||
|
addMessage(messages, candidate, maxMessages);
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
chunkCount++;
|
||||||
|
if (chunkCount % yieldEveryChunks === 0) await yieldToEventLoop();
|
||||||
|
if (closed) break;
|
||||||
|
}
|
||||||
|
if (signal?.aborted) return { success: false, cancelled: true };
|
||||||
|
if (!foundMessages || !closed) return { success: false, error: 'Unsupported chat file format' };
|
||||||
|
return { success: true, format: 'replay', messages, truncated: total > messages.length, total };
|
||||||
|
} catch (error) {
|
||||||
|
if (signal?.aborted) return { success: false, cancelled: true };
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
} finally {
|
||||||
|
signal?.removeEventListener('abort', abort);
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readChatFile(filePath: string, options: ChatReadOptions = {}): Promise<ChatReadResult> {
|
||||||
|
if (options.signal?.aborted) return { success: false, cancelled: true };
|
||||||
|
const maxMessages = normalizeMaxMessages(options.maxMessages);
|
||||||
|
const yieldEveryChunks = normalizeYieldEveryChunks(options.yieldEveryChunks);
|
||||||
|
return filePath.toLowerCase().endsWith('.jsonl')
|
||||||
|
? readLiveChat(filePath, maxMessages, options.signal, yieldEveryChunks)
|
||||||
|
: readReplayChat(filePath, maxMessages, options.signal, yieldEveryChunks);
|
||||||
|
}
|
||||||
+13
-4
@@ -1,6 +1,8 @@
|
|||||||
import { contextBridge, ipcRenderer, webUtils } from 'electron';
|
import { contextBridge, ipcRenderer, webUtils } from 'electron';
|
||||||
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress } from './types';
|
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress } from './types';
|
||||||
|
|
||||||
|
let chatReadSequence = 0;
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
interface RuntimeMetricsSnapshot {
|
interface RuntimeMetricsSnapshot {
|
||||||
cacheHits: number;
|
cacheHits: number;
|
||||||
@@ -164,11 +166,18 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
},
|
},
|
||||||
searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter),
|
searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter),
|
||||||
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
|
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
|
||||||
readChatFile: async (filePath: string) => {
|
readChatFile: async (filePath: string, signal?: AbortSignal) => {
|
||||||
const capability = await ipcRenderer.invoke('authorize-managed-path', 'chat-input', filePath);
|
const capability = await ipcRenderer.invoke('authorize-managed-path', 'chat-input', filePath);
|
||||||
return capability
|
if (!capability) return { success: false, error: 'File access denied' };
|
||||||
? ipcRenderer.invoke('read-chat-file', capability.token)
|
if (signal?.aborted) return { success: false, cancelled: true };
|
||||||
: { success: false, error: 'File access denied' };
|
const requestId = `chat-${Date.now()}-${++chatReadSequence}`;
|
||||||
|
const cancel = () => ipcRenderer.send('cancel-chat-read', requestId);
|
||||||
|
signal?.addEventListener('abort', cancel, { once: true });
|
||||||
|
try {
|
||||||
|
return await ipcRenderer.invoke('read-chat-file', capability.token, requestId);
|
||||||
|
} finally {
|
||||||
|
signal?.removeEventListener('abort', cancel);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
getAutomationStatus: () => ipcRenderer.invoke('get-automation-status'),
|
getAutomationStatus: () => ipcRenderer.invoke('get-automation-status'),
|
||||||
triggerAutoVodScan: () => ipcRenderer.invoke('trigger-auto-vod-scan'),
|
triggerAutoVodScan: () => ipcRenderer.invoke('trigger-auto-vod-scan'),
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { runInNewContext } from 'node:vm';
|
||||||
|
import { transpileModule, ModuleKind, ScriptTarget } from 'typescript';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
interface RendererAccessibilityApi {
|
||||||
|
RenderGeneration: new () => { next(): number; isCurrent(generation: number): boolean; cancel(): void };
|
||||||
|
getVirtualRange(scrollTop: number, viewportHeight: number, itemCount: number, rowHeight: number, overscan: number): { start: number; end: number };
|
||||||
|
getNextFocusIndex(activeIndex: number, count: number, shiftKey: boolean): number;
|
||||||
|
getNextMenuIndex(activeIndex: number, count: number, key: string): number | null;
|
||||||
|
setDocumentLanguage(language: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAccessibility(documentElement: { lang: string } = { lang: '' }): RendererAccessibilityApi {
|
||||||
|
const source = readFileSync(join(__dirname, 'renderer-accessibility.ts'), 'utf8');
|
||||||
|
const context = {
|
||||||
|
document: { documentElement, addEventListener: () => undefined },
|
||||||
|
requestAnimationFrame: (callback: () => void) => callback(),
|
||||||
|
HTMLElement: class {},
|
||||||
|
Element: class {},
|
||||||
|
Node: class {},
|
||||||
|
};
|
||||||
|
const output = transpileModule(source, { compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 } }).outputText;
|
||||||
|
runInNewContext(output, context);
|
||||||
|
return (context as unknown as { RendererAccessibility: RendererAccessibilityApi }).RendererAccessibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('renderer accessibility helpers', () => {
|
||||||
|
it('replaces stale chat work after rapid filters and close cancellation', () => {
|
||||||
|
const { RenderGeneration } = loadAccessibility();
|
||||||
|
const generations = new RenderGeneration();
|
||||||
|
const initial = generations.next();
|
||||||
|
const rapidFilter = generations.next();
|
||||||
|
generations.cancel();
|
||||||
|
|
||||||
|
expect(generations.isCurrent(initial)).toBe(false);
|
||||||
|
expect(generations.isCurrent(rapidFilter)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a large chat render window bounded to the visible rows', () => {
|
||||||
|
const { getVirtualRange } = loadAccessibility();
|
||||||
|
expect(getVirtualRange(29_000, 580, 50_000, 29, 12)).toEqual({ start: 988, end: 1_032 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps dialog tab order at the first and last focusable controls', () => {
|
||||||
|
const { getNextFocusIndex } = loadAccessibility();
|
||||||
|
expect(getNextFocusIndex(2, 3, false)).toBe(0);
|
||||||
|
expect(getNextFocusIndex(0, 3, true)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves keyboard menus with arrows, Home, End and Escape semantics', () => {
|
||||||
|
const { getNextMenuIndex } = loadAccessibility();
|
||||||
|
expect(getNextMenuIndex(1, 4, 'ArrowDown')).toBe(2);
|
||||||
|
expect(getNextMenuIndex(0, 4, 'ArrowUp')).toBe(3);
|
||||||
|
expect(getNextMenuIndex(2, 4, 'Home')).toBe(0);
|
||||||
|
expect(getNextMenuIndex(1, 4, 'End')).toBe(3);
|
||||||
|
expect(getNextMenuIndex(1, 4, 'Escape')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the document language when the application language changes', () => {
|
||||||
|
const documentElement = { lang: 'en' };
|
||||||
|
const { setDocumentLanguage } = loadAccessibility(documentElement);
|
||||||
|
expect(setDocumentLanguage('de')).toBe('de');
|
||||||
|
expect(documentElement.lang).toBe('de');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
interface RendererDialogOptions {
|
||||||
|
initialFocus?: HTMLElement | string | null;
|
||||||
|
onEscape?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RendererOpenDialogState {
|
||||||
|
returnFocus: HTMLElement | null;
|
||||||
|
onEscape?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RendererAccessibility = (() => {
|
||||||
|
const dialogStack: string[] = [];
|
||||||
|
const dialogStates = new Map<string, RendererOpenDialogState>();
|
||||||
|
const focusSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
class RenderGeneration {
|
||||||
|
private value = 0;
|
||||||
|
|
||||||
|
next(): number {
|
||||||
|
this.value += 1;
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCurrent(generation: number): boolean {
|
||||||
|
return generation === this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
this.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVirtualRange(scrollTop: number, viewportHeight: number, itemCount: number, rowHeight: number, overscan: number): { start: number; end: number } {
|
||||||
|
if (itemCount <= 0 || rowHeight <= 0) return { start: 0, end: 0 };
|
||||||
|
const firstVisible = Math.max(0, Math.floor(scrollTop / rowHeight));
|
||||||
|
const visibleCount = Math.max(1, Math.ceil(viewportHeight / rowHeight));
|
||||||
|
return {
|
||||||
|
start: Math.max(0, firstVisible - Math.max(0, overscan)),
|
||||||
|
end: Math.min(itemCount, firstVisible + visibleCount + Math.max(0, overscan))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextFocusIndex(activeIndex: number, count: number, shiftKey: boolean): number {
|
||||||
|
if (count < 1) return -1;
|
||||||
|
if (shiftKey) return activeIndex <= 0 ? count - 1 : activeIndex - 1;
|
||||||
|
return activeIndex >= count - 1 ? 0 : activeIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextMenuIndex(activeIndex: number, count: number, key: string): number | null {
|
||||||
|
if (count < 1 || key === 'Escape') return null;
|
||||||
|
if (key === 'Home') return 0;
|
||||||
|
if (key === 'End') return count - 1;
|
||||||
|
if (key === 'ArrowDown') return activeIndex >= count - 1 ? 0 : activeIndex + 1;
|
||||||
|
if (key === 'ArrowUp') return activeIndex <= 0 ? count - 1 : activeIndex - 1;
|
||||||
|
return activeIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDocumentLanguage(language: string): string {
|
||||||
|
const normalized = language === 'en' ? 'en' : 'de';
|
||||||
|
document.documentElement.lang = normalized;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFocusable(dialog: HTMLElement): HTMLElement[] {
|
||||||
|
return Array.from(dialog.querySelectorAll<HTMLElement>(focusSelector)).filter((element) => element.getAttribute('aria-hidden') !== 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialFocus(dialog: HTMLElement, initialFocus: RendererDialogOptions['initialFocus']): HTMLElement | null {
|
||||||
|
if (typeof initialFocus === 'string') return dialog.querySelector<HTMLElement>(initialFocus);
|
||||||
|
if (initialFocus && dialog.contains(initialFocus)) return initialFocus;
|
||||||
|
return getFocusable(dialog)[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncBackgroundInertness(): void {
|
||||||
|
const shell = document.querySelector<HTMLElement>('.workspace-shell');
|
||||||
|
if (shell) shell.inert = dialogStack.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDialogOpen(id: string): boolean {
|
||||||
|
return dialogStack.includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDialog(id: string, options: RendererDialogOptions = {}): void {
|
||||||
|
const dialog = document.getElementById(id);
|
||||||
|
if (!(dialog instanceof HTMLElement)) return;
|
||||||
|
const existing = dialogStack.indexOf(id);
|
||||||
|
if (existing >= 0) {
|
||||||
|
dialogStack.splice(existing, 1);
|
||||||
|
} else {
|
||||||
|
const active = document.activeElement;
|
||||||
|
dialogStates.set(id, {
|
||||||
|
returnFocus: active instanceof HTMLElement && active !== document.body ? active : null,
|
||||||
|
onEscape: options.onEscape
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dialogStack.push(id);
|
||||||
|
dialog.classList.add('show');
|
||||||
|
dialog.setAttribute('aria-hidden', 'false');
|
||||||
|
syncBackgroundInertness();
|
||||||
|
requestAnimationFrame(() => getInitialFocus(dialog, options.initialFocus)?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog(id: string): void {
|
||||||
|
const dialog = document.getElementById(id);
|
||||||
|
if (!(dialog instanceof HTMLElement)) return;
|
||||||
|
const index = dialogStack.indexOf(id);
|
||||||
|
if (index < 0) return;
|
||||||
|
const wasTopmost = index === dialogStack.length - 1;
|
||||||
|
const state = dialogStates.get(id);
|
||||||
|
dialogStack.splice(index, 1);
|
||||||
|
dialogStates.delete(id);
|
||||||
|
dialog.classList.remove('show');
|
||||||
|
dialog.setAttribute('aria-hidden', 'true');
|
||||||
|
syncBackgroundInertness();
|
||||||
|
if (wasTopmost && state?.returnFocus?.isConnected) requestAnimationFrame(() => state.returnFocus?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTopmostDialog(): boolean {
|
||||||
|
const id = dialogStack.at(-1);
|
||||||
|
if (!id) return false;
|
||||||
|
const state = dialogStates.get(id);
|
||||||
|
if (state?.onEscape) state.onEscape();
|
||||||
|
else closeDialog(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installMenuKeyboardNavigation(menu: HTMLElement, close: () => void): void {
|
||||||
|
menu.addEventListener('keydown', (event) => {
|
||||||
|
const items = Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]')).filter((item) => item.getAttribute('aria-disabled') !== 'true' && !(item instanceof HTMLButtonElement && item.disabled));
|
||||||
|
const activeIndex = items.indexOf(document.activeElement as HTMLElement);
|
||||||
|
const nextIndex = getNextMenuIndex(activeIndex < 0 ? 0 : activeIndex, items.length, event.key);
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextIndex === activeIndex || nextIndex === null) return;
|
||||||
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') {
|
||||||
|
event.preventDefault();
|
||||||
|
items[nextIndex]?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusFirstMenuItem(menu: HTMLElement): void {
|
||||||
|
menu.querySelector<HTMLElement>('[role="menuitem"]:not([aria-disabled="true"]):not([disabled])')?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
const id = dialogStack.at(-1);
|
||||||
|
if (!id) return;
|
||||||
|
const dialog = document.getElementById(id);
|
||||||
|
if (!(dialog instanceof HTMLElement)) return;
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
closeTopmostDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== 'Tab') return;
|
||||||
|
const focusable = getFocusable(dialog);
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
dialog.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeIndex = focusable.indexOf(document.activeElement as HTMLElement);
|
||||||
|
const nextIndex = getNextFocusIndex(activeIndex, focusable.length, event.shiftKey);
|
||||||
|
if (activeIndex < 0 || nextIndex !== activeIndex + (event.shiftKey ? -1 : 1)) {
|
||||||
|
event.preventDefault();
|
||||||
|
focusable[nextIndex]?.focus();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
RenderGeneration,
|
||||||
|
getVirtualRange,
|
||||||
|
getNextFocusIndex,
|
||||||
|
getNextMenuIndex,
|
||||||
|
setDocumentLanguage,
|
||||||
|
isDialogOpen,
|
||||||
|
openDialog,
|
||||||
|
closeDialog,
|
||||||
|
closeTopmostDialog,
|
||||||
|
installMenuKeyboardNavigation,
|
||||||
|
focusFirstMenuItem,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
Object.assign(globalThis, { RendererAccessibility });
|
||||||
@@ -99,6 +99,7 @@ interface PaletteCommand {
|
|||||||
clearList(list);
|
clearList(list);
|
||||||
STORE.filtered.forEach((cmd, idx) => {
|
STORE.filtered.forEach((cmd, idx) => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
|
li.id = `commandPaletteOption-${idx}`;
|
||||||
li.className = 'cp-item' + (idx === STORE.activeIndex ? ' cp-active' : '');
|
li.className = 'cp-item' + (idx === STORE.activeIndex ? ' cp-active' : '');
|
||||||
li.dataset.cmdId = cmd.id;
|
li.dataset.cmdId = cmd.id;
|
||||||
li.setAttribute('role', 'option');
|
li.setAttribute('role', 'option');
|
||||||
@@ -124,6 +125,8 @@ interface PaletteCommand {
|
|||||||
|
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
});
|
});
|
||||||
|
const input = getInput();
|
||||||
|
if (input) input.setAttribute('aria-activedescendant', STORE.filtered[STORE.activeIndex] ? `commandPaletteOption-${STORE.activeIndex}` : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilter(query: string) {
|
function applyFilter(query: string) {
|
||||||
@@ -133,9 +136,7 @@ interface PaletteCommand {
|
|||||||
} else {
|
} else {
|
||||||
STORE.filtered = STORE.commands.filter(c => c.keywords.includes(q));
|
STORE.filtered = STORE.commands.filter(c => c.keywords.includes(q));
|
||||||
}
|
}
|
||||||
if (STORE.activeIndex >= STORE.filtered.length) {
|
STORE.activeIndex = 0;
|
||||||
STORE.activeIndex = STORE.filtered.length > 0 ? STORE.filtered.length - 1 : 0;
|
|
||||||
}
|
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,15 +159,17 @@ interface PaletteCommand {
|
|||||||
STORE.filtered = STORE.commands.slice();
|
STORE.filtered = STORE.commands.slice();
|
||||||
STORE.activeIndex = 0;
|
STORE.activeIndex = 0;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
modal.classList.add('show');
|
|
||||||
requestAnimationFrame(() => input.focus());
|
|
||||||
render();
|
render();
|
||||||
|
input.setAttribute('aria-expanded', 'true');
|
||||||
|
RendererAccessibility.openDialog('commandPaletteModal', { initialFocus: input, onEscape: close });
|
||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
const modal = getModal();
|
const modal = getModal();
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
modal.classList.remove('show');
|
getInput()?.setAttribute('aria-expanded', 'false');
|
||||||
|
getInput()?.removeAttribute('aria-activedescendant');
|
||||||
|
RendererAccessibility.closeDialog('commandPaletteModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
|||||||
+5
-18
@@ -61,7 +61,6 @@ let cutterScrubSeekInFlight = false;
|
|||||||
let cutterScrubResumePlayback = false;
|
let cutterScrubResumePlayback = false;
|
||||||
let cutterScrubGeneration = 0;
|
let cutterScrubGeneration = 0;
|
||||||
let cutterDiscardResolver: ((discard: boolean) => void) | null = null;
|
let cutterDiscardResolver: ((discard: boolean) => void) | null = null;
|
||||||
let cutterDiscardReturnFocus: HTMLElement | null = null;
|
|
||||||
const cutterMaximumCuts = 64;
|
const cutterMaximumCuts = 64;
|
||||||
const cutterFrameTolerance = 1e-8;
|
const cutterFrameTolerance = 1e-8;
|
||||||
|
|
||||||
@@ -905,17 +904,10 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveCutterDiscard(discard: boolean): void {
|
function resolveCutterDiscard(discard: boolean): void {
|
||||||
const modal = byId('cutterDiscardModal');
|
RendererAccessibility.closeDialog('cutterDiscardModal');
|
||||||
modal.classList.remove('show');
|
|
||||||
modal.setAttribute('aria-hidden', 'true');
|
|
||||||
const shell = document.querySelector<HTMLElement>('.workspace-shell');
|
|
||||||
if (shell) shell.inert = false;
|
|
||||||
const resolver = cutterDiscardResolver;
|
const resolver = cutterDiscardResolver;
|
||||||
const returnFocus = cutterDiscardReturnFocus;
|
|
||||||
cutterDiscardResolver = null;
|
cutterDiscardResolver = null;
|
||||||
cutterDiscardReturnFocus = null;
|
|
||||||
resolver?.(discard);
|
resolver?.(discard);
|
||||||
if (returnFocus?.isConnected) requestAnimationFrame(() => returnFocus.focus());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCutterDiscardOverlayClick(event: MouseEvent): void {
|
function handleCutterDiscardOverlayClick(event: MouseEvent): void {
|
||||||
@@ -938,15 +930,10 @@ function trapCutterDiscardFocus(event: KeyboardEvent): void {
|
|||||||
function confirmCutterReplacement(file: FileCapabilityReference): Promise<boolean> {
|
function confirmCutterReplacement(file: FileCapabilityReference): Promise<boolean> {
|
||||||
if (!cutterFile || !cutterEditorState || cutterFile.token === file.token) return Promise.resolve(true);
|
if (!cutterFile || !cutterEditorState || cutterFile.token === file.token) return Promise.resolve(true);
|
||||||
if (cutterDiscardResolver) resolveCutterDiscard(false);
|
if (cutterDiscardResolver) resolveCutterDiscard(false);
|
||||||
const modal = byId('cutterDiscardModal');
|
RendererAccessibility.openDialog('cutterDiscardModal', {
|
||||||
cutterDiscardReturnFocus = document.activeElement instanceof HTMLElement && document.activeElement !== document.body
|
initialFocus: byId<HTMLButtonElement>('cutterDiscardCancelBtn'),
|
||||||
? document.activeElement
|
onEscape: () => resolveCutterDiscard(false)
|
||||||
: null;
|
});
|
||||||
const shell = document.querySelector<HTMLElement>('.workspace-shell');
|
|
||||||
if (shell) shell.inert = true;
|
|
||||||
modal.classList.add('show');
|
|
||||||
modal.setAttribute('aria-hidden', 'false');
|
|
||||||
requestAnimationFrame(() => byId<HTMLButtonElement>('cutterDiscardCancelBtn').focus());
|
|
||||||
return new Promise((resolve) => { cutterDiscardResolver = resolve; });
|
return new Promise((resolve) => { cutterDiscardResolver = resolve; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-1
@@ -437,7 +437,7 @@ interface ApiBridge {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}): Promise<ArchiveSearchResult>;
|
}): Promise<ArchiveSearchResult>;
|
||||||
runStorageCleanup(options?: { dryRun?: boolean }): Promise<CleanupReport>;
|
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 }>;
|
readChatFile(filePath: string, signal?: AbortSignal): Promise<{ success: boolean; error?: string; cancelled?: boolean; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; truncated?: boolean; total?: number }>;
|
||||||
getAutomationStatus(): Promise<{
|
getAutomationStatus(): Promise<{
|
||||||
autoRecord: { watching: number; lastRunAt: number; nextRunAt: number; lastTriggeredCount: number; inFlight: boolean };
|
autoRecord: { watching: number; lastRunAt: number; nextRunAt: number; lastTriggeredCount: number; inFlight: boolean };
|
||||||
autoVod: { watching: number; lastRunAt: number; nextRunAt: number; lastQueuedCount: number; inFlight: boolean };
|
autoVod: { watching: number; lastRunAt: number; nextRunAt: number; lastQueuedCount: number; inFlight: boolean };
|
||||||
|
|||||||
+31
-13
@@ -157,11 +157,15 @@ async function retryQueueItem(id: string): Promise<void> {
|
|||||||
|
|
||||||
let queueContextMenuInitialized = false;
|
let queueContextMenuInitialized = false;
|
||||||
let activeQueueContextMenu: HTMLElement | null = null;
|
let activeQueueContextMenu: HTMLElement | null = null;
|
||||||
|
let activeQueueContextMenuInvoker: HTMLElement | null = null;
|
||||||
|
|
||||||
function closeQueueContextMenu(): void {
|
function closeQueueContextMenu(restoreFocus = false): void {
|
||||||
if (!activeQueueContextMenu) return;
|
if (!activeQueueContextMenu) return;
|
||||||
activeQueueContextMenu.remove();
|
activeQueueContextMenu.remove();
|
||||||
activeQueueContextMenu = null;
|
activeQueueContextMenu = null;
|
||||||
|
const invoker = activeQueueContextMenuInvoker;
|
||||||
|
activeQueueContextMenuInvoker = null;
|
||||||
|
if (restoreFocus && invoker?.isConnected) invoker.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initQueueContextMenu(): void {
|
function initQueueContextMenu(): void {
|
||||||
@@ -177,26 +181,42 @@ function initQueueContextMenu(): void {
|
|||||||
const item = queue.find((i) => i.id === id);
|
const item = queue.find((i) => i.id === id);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showQueueContextMenu(e.clientX, e.clientY, item);
|
showQueueContextMenu(e.clientX, e.clientY, item, e.target as HTMLElement);
|
||||||
|
});
|
||||||
|
list.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'ContextMenu' && !(e.shiftKey && e.key === 'F10')) return;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const itemEl = target.closest('.queue-item') as HTMLElement | null;
|
||||||
|
const id = itemEl?.dataset.id;
|
||||||
|
const item = id ? queue.find((candidate) => candidate.id === id) : null;
|
||||||
|
if (!item || !itemEl) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = itemEl.getBoundingClientRect();
|
||||||
|
showQueueContextMenu(rect.left + 12, rect.top + 12, item, target);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
|
function showQueueContextMenu(x: number, y: number, item: QueueItem, invoker: HTMLElement | null): void {
|
||||||
closeQueueContextMenu();
|
closeQueueContextMenu();
|
||||||
|
|
||||||
const menu = document.createElement('div');
|
const menu = document.createElement('div');
|
||||||
menu.className = 'context-menu';
|
menu.className = 'context-menu';
|
||||||
menu.setAttribute('role', 'menu');
|
menu.setAttribute('role', 'menu');
|
||||||
|
|
||||||
|
let cleanup = (restoreFocus = false): void => closeQueueContextMenu(restoreFocus);
|
||||||
const makeItem = (label: string, onClick: () => void, disabled = false): HTMLElement => {
|
const makeItem = (label: string, onClick: () => void, disabled = false): HTMLElement => {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('button');
|
||||||
|
el.type = 'button';
|
||||||
el.textContent = label;
|
el.textContent = label;
|
||||||
el.className = 'context-menu-item' + (disabled ? ' disabled' : '');
|
el.className = 'context-menu-item' + (disabled ? ' disabled' : '');
|
||||||
el.setAttribute('role', 'menuitem');
|
el.setAttribute('role', 'menuitem');
|
||||||
if (disabled) el.setAttribute('aria-disabled', 'true');
|
if (disabled) {
|
||||||
|
el.setAttribute('aria-disabled', 'true');
|
||||||
|
el.disabled = true;
|
||||||
|
}
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
try { onClick(); } finally { closeQueueContextMenu(); }
|
try { onClick(); } finally { cleanup(); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return el;
|
return el;
|
||||||
@@ -260,6 +280,7 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
|
|||||||
|
|
||||||
document.body.appendChild(menu);
|
document.body.appendChild(menu);
|
||||||
activeQueueContextMenu = menu;
|
activeQueueContextMenu = menu;
|
||||||
|
activeQueueContextMenuInvoker = invoker;
|
||||||
|
|
||||||
const rect = menu.getBoundingClientRect();
|
const rect = menu.getBoundingClientRect();
|
||||||
let left = x;
|
let left = x;
|
||||||
@@ -274,19 +295,16 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
|
|||||||
if (ev.target instanceof Node && activeQueueContextMenu.contains(ev.target)) return;
|
if (ev.target instanceof Node && activeQueueContextMenu.contains(ev.target)) return;
|
||||||
cleanup();
|
cleanup();
|
||||||
};
|
};
|
||||||
const dismissOnEscape = (ev: KeyboardEvent) => {
|
|
||||||
if (ev.key === 'Escape') cleanup();
|
|
||||||
};
|
|
||||||
const dismissOnScroll = () => cleanup();
|
const dismissOnScroll = () => cleanup();
|
||||||
const cleanup = (): void => {
|
cleanup = (restoreFocus = false): void => {
|
||||||
closeQueueContextMenu();
|
closeQueueContextMenu(restoreFocus);
|
||||||
document.removeEventListener('mousedown', dismissOnClick, true);
|
document.removeEventListener('mousedown', dismissOnClick, true);
|
||||||
document.removeEventListener('keydown', dismissOnEscape, true);
|
|
||||||
document.removeEventListener('scroll', dismissOnScroll, true);
|
document.removeEventListener('scroll', dismissOnScroll, true);
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', dismissOnClick, true);
|
document.addEventListener('mousedown', dismissOnClick, true);
|
||||||
document.addEventListener('keydown', dismissOnEscape, true);
|
|
||||||
document.addEventListener('scroll', dismissOnScroll, true);
|
document.addEventListener('scroll', dismissOnScroll, true);
|
||||||
|
RendererAccessibility.installMenuKeyboardNavigation(menu, () => cleanup(true));
|
||||||
|
RendererAccessibility.focusFirstMenuItem(menu);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moveQueueItemTo(id: string, where: 'top' | 'bottom'): Promise<void> {
|
async function moveQueueItemTo(id: string, where: 'top' | 'bottom'): Promise<void> {
|
||||||
|
|||||||
+27
-22
@@ -1064,15 +1064,23 @@ function initVodGridSelectionDelegation(): void {
|
|||||||
const ctx = readVodCardContext(card);
|
const ctx = readVodCardContext(card);
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showVodContextMenu(e.clientX, e.clientY, ctx);
|
showVodContextMenu(e.clientX, e.clientY, ctx, card);
|
||||||
});
|
});
|
||||||
|
|
||||||
grid.addEventListener('keydown', (e) => {
|
grid.addEventListener('keydown', (e) => {
|
||||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const card = target.closest('.vod-card') as HTMLElement | null;
|
const card = target.closest('.vod-card') as HTMLElement | null;
|
||||||
if (!card || card !== target) return;
|
if (!card || card !== target) return;
|
||||||
|
if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) {
|
||||||
|
const ctx = readVodCardContext(card);
|
||||||
|
if (!ctx) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = card.getBoundingClientRect();
|
||||||
|
showVodContextMenu(rect.left + 12, rect.top + 12, ctx, card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toggleVodCardSelection(card);
|
toggleVodCardSelection(card);
|
||||||
});
|
});
|
||||||
@@ -1096,14 +1104,18 @@ function toggleVodCardSelection(card: HTMLElement): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let activeVodContextMenu: HTMLElement | null = null;
|
let activeVodContextMenu: HTMLElement | null = null;
|
||||||
|
let activeVodContextMenuInvoker: HTMLElement | null = null;
|
||||||
|
|
||||||
function closeVodContextMenu(): void {
|
function closeVodContextMenu(restoreFocus = false): void {
|
||||||
if (!activeVodContextMenu) return;
|
if (!activeVodContextMenu) return;
|
||||||
activeVodContextMenu.remove();
|
activeVodContextMenu.remove();
|
||||||
activeVodContextMenu = null;
|
activeVodContextMenu = null;
|
||||||
|
const invoker = activeVodContextMenuInvoker;
|
||||||
|
activeVodContextMenuInvoker = null;
|
||||||
|
if (restoreFocus && invoker?.isConnected) invoker.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
|
function showVodContextMenu(x: number, y: number, ctx: VodCardContext, invoker: HTMLElement | null): void {
|
||||||
closeVodContextMenu();
|
closeVodContextMenu();
|
||||||
|
|
||||||
const menu = document.createElement('div');
|
const menu = document.createElement('div');
|
||||||
@@ -1117,13 +1129,15 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
|
|||||||
);
|
);
|
||||||
const isMarkedDownloaded = downloadedIds.has(ctx.id);
|
const isMarkedDownloaded = downloadedIds.has(ctx.id);
|
||||||
|
|
||||||
|
let cleanup = (restoreFocus = false): void => closeVodContextMenu(restoreFocus);
|
||||||
const makeItem = (label: string, onClick: () => void): HTMLElement => {
|
const makeItem = (label: string, onClick: () => void): HTMLElement => {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('button');
|
||||||
|
el.type = 'button';
|
||||||
el.textContent = label;
|
el.textContent = label;
|
||||||
el.className = 'context-menu-item';
|
el.className = 'context-menu-item';
|
||||||
el.setAttribute('role', 'menuitem');
|
el.setAttribute('role', 'menuitem');
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
try { onClick(); } finally { closeVodContextMenu(); }
|
try { onClick(); } finally { cleanup(); }
|
||||||
});
|
});
|
||||||
return el;
|
return el;
|
||||||
};
|
};
|
||||||
@@ -1151,6 +1165,7 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
|
|||||||
|
|
||||||
document.body.appendChild(menu);
|
document.body.appendChild(menu);
|
||||||
activeVodContextMenu = menu;
|
activeVodContextMenu = menu;
|
||||||
|
activeVodContextMenuInvoker = invoker;
|
||||||
|
|
||||||
// Reposition if it would clip off the viewport
|
// Reposition if it would clip off the viewport
|
||||||
const rect = menu.getBoundingClientRect();
|
const rect = menu.getBoundingClientRect();
|
||||||
@@ -1161,31 +1176,21 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
|
|||||||
menu.style.left = `${left}px`;
|
menu.style.left = `${left}px`;
|
||||||
menu.style.top = `${top}px`;
|
menu.style.top = `${top}px`;
|
||||||
|
|
||||||
// Close on click anywhere else / Escape / scroll
|
|
||||||
const dismissOnClick = (ev: MouseEvent) => {
|
const dismissOnClick = (ev: MouseEvent) => {
|
||||||
if (!activeVodContextMenu) return;
|
if (!activeVodContextMenu) return;
|
||||||
if (ev.target instanceof Node && activeVodContextMenu.contains(ev.target)) return;
|
if (ev.target instanceof Node && activeVodContextMenu.contains(ev.target)) return;
|
||||||
closeVodContextMenu();
|
cleanup();
|
||||||
document.removeEventListener('mousedown', dismissOnClick, true);
|
|
||||||
document.removeEventListener('keydown', dismissOnEscape, true);
|
|
||||||
document.removeEventListener('scroll', dismissOnScroll, true);
|
|
||||||
};
|
};
|
||||||
const dismissOnEscape = (ev: KeyboardEvent) => {
|
const dismissOnScroll = () => cleanup();
|
||||||
if (ev.key !== 'Escape') return;
|
cleanup = (restoreFocus = false): void => {
|
||||||
closeVodContextMenu();
|
closeVodContextMenu(restoreFocus);
|
||||||
document.removeEventListener('mousedown', dismissOnClick, true);
|
document.removeEventListener('mousedown', dismissOnClick, true);
|
||||||
document.removeEventListener('keydown', dismissOnEscape, true);
|
|
||||||
document.removeEventListener('scroll', dismissOnScroll, true);
|
|
||||||
};
|
|
||||||
const dismissOnScroll = () => {
|
|
||||||
closeVodContextMenu();
|
|
||||||
document.removeEventListener('mousedown', dismissOnClick, true);
|
|
||||||
document.removeEventListener('keydown', dismissOnEscape, true);
|
|
||||||
document.removeEventListener('scroll', dismissOnScroll, true);
|
document.removeEventListener('scroll', dismissOnScroll, true);
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', dismissOnClick, true);
|
document.addEventListener('mousedown', dismissOnClick, true);
|
||||||
document.addEventListener('keydown', dismissOnEscape, true);
|
|
||||||
document.addEventListener('scroll', dismissOnScroll, true);
|
document.addEventListener('scroll', dismissOnScroll, true);
|
||||||
|
RendererAccessibility.installMenuKeyboardNavigation(menu, () => cleanup(true));
|
||||||
|
RendererAccessibility.focusFirstMenuItem(menu);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleVodDownloadedMark(vodId: string, mark: boolean): Promise<void> {
|
async function toggleVodDownloadedMark(vodId: string, mark: boolean): Promise<void> {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ function setAriaLabel(id: string, value: string): void {
|
|||||||
|
|
||||||
function setLanguage(lang: string): LanguageCode {
|
function setLanguage(lang: string): LanguageCode {
|
||||||
currentLanguage = lang === 'en' ? 'en' : 'de';
|
currentLanguage = lang === 'en' ? 'en' : 'de';
|
||||||
|
RendererAccessibility.setDocumentLanguage(currentLanguage);
|
||||||
UI_TEXT = UI_TEXTS[currentLanguage];
|
UI_TEXT = UI_TEXTS[currentLanguage];
|
||||||
applyLanguageToStaticUI();
|
applyLanguageToStaticUI();
|
||||||
return currentLanguage;
|
return currentLanguage;
|
||||||
|
|||||||
@@ -420,12 +420,12 @@ function refreshUpdateModalTexts(): void {
|
|||||||
function openUpdateModal(info?: UpdateInfo): void {
|
function openUpdateModal(info?: UpdateInfo): void {
|
||||||
rememberUpdateInfo(info);
|
rememberUpdateInfo(info);
|
||||||
updateChangelogExpanded = false;
|
updateChangelogExpanded = false;
|
||||||
byId('updateModal').classList.add('show');
|
RendererAccessibility.openDialog('updateModal', { onEscape: dismissUpdateModal });
|
||||||
refreshUpdateModalTexts();
|
refreshUpdateModalTexts();
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismissUpdateModal(): void {
|
function dismissUpdateModal(): void {
|
||||||
byId('updateModal').classList.remove('show');
|
RendererAccessibility.closeDialog('updateModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
function skipUpdateVersion(): void {
|
function skipUpdateVersion(): void {
|
||||||
|
|||||||
+113
-73
@@ -309,13 +309,12 @@ interface EventLogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openEventsViewer(filePath: string, title: string): Promise<void> {
|
async function openEventsViewer(filePath: string, title: string): Promise<void> {
|
||||||
const modal = byId('eventsViewerModal');
|
|
||||||
const list = byId('eventsViewerList');
|
const list = byId('eventsViewerList');
|
||||||
const status = byId('eventsViewerStatus');
|
const status = byId('eventsViewerStatus');
|
||||||
byId('eventsViewerTitle').textContent = title || UI_TEXT.queue.viewEvents;
|
byId('eventsViewerTitle').textContent = title || UI_TEXT.queue.viewEvents;
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
status.textContent = UI_TEXT.queue.viewChatLoading;
|
status.textContent = UI_TEXT.queue.viewChatLoading;
|
||||||
modal.classList.add('show');
|
RendererAccessibility.openDialog('eventsViewerModal', { onEscape: closeEventsViewer });
|
||||||
|
|
||||||
const result = await window.api.readChatFile(filePath);
|
const result = await window.api.readChatFile(filePath);
|
||||||
if (!result.success || !Array.isArray(result.messages)) {
|
if (!result.success || !Array.isArray(result.messages)) {
|
||||||
@@ -328,7 +327,7 @@ async function openEventsViewer(filePath: string, title: string): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeEventsViewer(): void {
|
function closeEventsViewer(): void {
|
||||||
byId('eventsViewerModal').classList.remove('show');
|
RendererAccessibility.closeDialog('eventsViewerModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEventTime(iso?: string): string {
|
function formatEventTime(iso?: string): string {
|
||||||
@@ -411,8 +410,17 @@ interface ChatViewerMessage {
|
|||||||
|
|
||||||
let chatViewerMessages: ChatViewerMessage[] = [];
|
let chatViewerMessages: ChatViewerMessage[] = [];
|
||||||
let chatViewerFormat: 'replay' | 'live' = 'replay';
|
let chatViewerFormat: 'replay' | 'live' = 'replay';
|
||||||
|
let chatViewerSessionGeneration = 0;
|
||||||
|
let chatViewerReadAbort: AbortController | null = null;
|
||||||
|
const chatViewerRenderGeneration = new RendererAccessibility.RenderGeneration();
|
||||||
|
const CHAT_VIEWER_ROW_HEIGHT = 29;
|
||||||
|
const CHAT_VIEWER_OVERSCAN = 12;
|
||||||
|
|
||||||
async function openChatViewer(filePath: string, title: string): Promise<void> {
|
async function openChatViewer(filePath: string, title: string): Promise<void> {
|
||||||
|
const sessionGeneration = ++chatViewerSessionGeneration;
|
||||||
|
chatViewerReadAbort?.abort();
|
||||||
|
const abortController = new AbortController();
|
||||||
|
chatViewerReadAbort = abortController;
|
||||||
const modal = byId('chatViewerModal');
|
const modal = byId('chatViewerModal');
|
||||||
const list = byId('chatViewerList');
|
const list = byId('chatViewerList');
|
||||||
const status = byId('chatViewerStatus');
|
const status = byId('chatViewerStatus');
|
||||||
@@ -421,10 +429,12 @@ async function openChatViewer(filePath: string, title: string): Promise<void> {
|
|||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
filterInput.value = '';
|
filterInput.value = '';
|
||||||
status.textContent = UI_TEXT.queue.viewChatLoading;
|
status.textContent = UI_TEXT.queue.viewChatLoading;
|
||||||
modal.classList.add('show');
|
RendererAccessibility.openDialog('chatViewerModal', { initialFocus: filterInput, onEscape: closeChatViewer });
|
||||||
|
|
||||||
const result = await window.api.readChatFile(filePath);
|
const result = await window.api.readChatFile(filePath, abortController.signal);
|
||||||
|
if (sessionGeneration !== chatViewerSessionGeneration || abortController.signal.aborted || !modal.classList.contains('show')) return;
|
||||||
if (!result.success || !Array.isArray(result.messages)) {
|
if (!result.success || !Array.isArray(result.messages)) {
|
||||||
|
if (result.cancelled) return;
|
||||||
status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : '');
|
status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : '');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -433,26 +443,114 @@ async function openChatViewer(filePath: string, title: string): Promise<void> {
|
|||||||
chatViewerFormat = result.format === 'live' ? 'live' : 'replay';
|
chatViewerFormat = result.format === 'live' ? 'live' : 'replay';
|
||||||
status.textContent = UI_TEXT.queue.viewChatCount.replace('{count}', String(result.total ?? chatViewerMessages.length))
|
status.textContent = UI_TEXT.queue.viewChatCount.replace('{count}', String(result.total ?? chatViewerMessages.length))
|
||||||
+ (result.truncated ? UI_TEXT.queue.viewChatTruncatedSuffix : '');
|
+ (result.truncated ? UI_TEXT.queue.viewChatTruncatedSuffix : '');
|
||||||
renderChatViewerList(chatViewerMessages);
|
onChatViewerFilterChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeChatViewer(): void {
|
function closeChatViewer(): void {
|
||||||
byId('chatViewerModal').classList.remove('show');
|
chatViewerSessionGeneration++;
|
||||||
|
chatViewerReadAbort?.abort();
|
||||||
|
chatViewerReadAbort = null;
|
||||||
|
chatViewerRenderGeneration.cancel();
|
||||||
|
RendererAccessibility.closeDialog('chatViewerModal');
|
||||||
chatViewerMessages = [];
|
chatViewerMessages = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function onChatViewerFilterChange(): void {
|
function onChatViewerFilterChange(): void {
|
||||||
|
const generation = chatViewerRenderGeneration.next();
|
||||||
|
const list = byId('chatViewerList');
|
||||||
|
list.scrollTop = 0;
|
||||||
|
list.replaceChildren();
|
||||||
const filter = byId<HTMLInputElement>('chatViewerFilter').value.trim().toLowerCase();
|
const filter = byId<HTMLInputElement>('chatViewerFilter').value.trim().toLowerCase();
|
||||||
if (!filter) {
|
if (!filter) {
|
||||||
renderChatViewerList(chatViewerMessages);
|
renderChatViewerList(chatViewerMessages, generation);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const filtered = chatViewerMessages.filter((m) => {
|
list.setAttribute('aria-busy', 'true');
|
||||||
|
const filtered: ChatViewerMessage[] = [];
|
||||||
|
let index = 0;
|
||||||
|
const filterChunk = (): void => {
|
||||||
|
if (!chatViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('chatViewerModal')) return;
|
||||||
|
const deadline = performance.now() + 8;
|
||||||
|
while (index < chatViewerMessages.length && performance.now() < deadline) {
|
||||||
|
const m = chatViewerMessages[index++];
|
||||||
const u = (m.u || m.user || m.login || '').toLowerCase();
|
const u = (m.u || m.user || m.login || '').toLowerCase();
|
||||||
const text = (m.msg || m.text || '').toLowerCase();
|
const text = (m.msg || m.text || '').toLowerCase();
|
||||||
return u.includes(filter) || text.includes(filter);
|
if (u.includes(filter) || text.includes(filter)) filtered.push(m);
|
||||||
|
}
|
||||||
|
if (index < chatViewerMessages.length) {
|
||||||
|
window.setTimeout(filterChunk, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.removeAttribute('aria-busy');
|
||||||
|
renderChatViewerList(filtered, generation);
|
||||||
|
};
|
||||||
|
filterChunk();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChatViewerRow(m: ChatViewerMessage): HTMLElement {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
const isMessageType = m.type === 'msg' || !m.type;
|
||||||
|
row.className = 'chat-viewer-row' + (!isMessageType ? ' is-system' : '');
|
||||||
|
|
||||||
|
if (!isMessageType) {
|
||||||
|
const tag = document.createElement('span');
|
||||||
|
tag.className = 'chat-viewer-tag';
|
||||||
|
tag.textContent = m.type || 'event';
|
||||||
|
row.appendChild(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = formatChatTimeMarker(m);
|
||||||
|
if (time) {
|
||||||
|
const timeElement = document.createElement('span');
|
||||||
|
timeElement.className = 'chat-viewer-time';
|
||||||
|
timeElement.textContent = time;
|
||||||
|
row.appendChild(timeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = m.u || m.user || m.login || '';
|
||||||
|
if (user) {
|
||||||
|
const userElement = document.createElement('span');
|
||||||
|
userElement.className = 'chat-viewer-user';
|
||||||
|
if (m.color) userElement.style.color = m.color;
|
||||||
|
userElement.textContent = `${user}:`;
|
||||||
|
row.appendChild(userElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = document.createElement('span');
|
||||||
|
message.textContent = ' ' + (m.msg || m.text || '');
|
||||||
|
row.appendChild(message);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChatViewerList(messages: ChatViewerMessage[], generation: number): void {
|
||||||
|
if (!chatViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('chatViewerModal')) return;
|
||||||
|
const list = byId('chatViewerList');
|
||||||
|
list.replaceChildren();
|
||||||
|
const canvas = document.createElement('div');
|
||||||
|
canvas.className = 'chat-viewer-virtual-canvas';
|
||||||
|
canvas.style.height = `${messages.length * CHAT_VIEWER_ROW_HEIGHT}px`;
|
||||||
|
const rows = document.createElement('div');
|
||||||
|
rows.className = 'chat-viewer-virtual-rows';
|
||||||
|
canvas.appendChild(rows);
|
||||||
|
list.appendChild(canvas);
|
||||||
|
let scheduled = false;
|
||||||
|
const renderVisibleRows = (): void => {
|
||||||
|
if (!chatViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('chatViewerModal')) return;
|
||||||
|
const range = RendererAccessibility.getVirtualRange(list.scrollTop, list.clientHeight, messages.length, CHAT_VIEWER_ROW_HEIGHT, CHAT_VIEWER_OVERSCAN);
|
||||||
|
rows.style.transform = `translateY(${range.start * CHAT_VIEWER_ROW_HEIGHT}px)`;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let index = range.start; index < range.end; index++) fragment.appendChild(createChatViewerRow(messages[index]));
|
||||||
|
rows.replaceChildren(fragment);
|
||||||
|
};
|
||||||
|
list.addEventListener('scroll', () => {
|
||||||
|
if (scheduled) return;
|
||||||
|
scheduled = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scheduled = false;
|
||||||
|
renderVisibleRows();
|
||||||
});
|
});
|
||||||
renderChatViewerList(filtered);
|
}, { passive: true });
|
||||||
|
renderVisibleRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatChatTimeMarker(m: ChatViewerMessage): string {
|
function formatChatTimeMarker(m: ChatViewerMessage): string {
|
||||||
@@ -475,64 +573,6 @@ function formatChatTimeMarker(m: ChatViewerMessage): string {
|
|||||||
return '';
|
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 isMessageType = m.type === 'msg' || !m.type;
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'chat-viewer-row' + (!isMessageType ? ' is-system' : '');
|
|
||||||
|
|
||||||
// System events (subs, raids, deletions) lead with a faint tag.
|
|
||||||
if (!isMessageType) {
|
|
||||||
const tag = document.createElement('span');
|
|
||||||
tag.className = 'chat-viewer-tag';
|
|
||||||
tag.textContent = m.type || 'event';
|
|
||||||
row.appendChild(tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
const time = formatChatTimeMarker(m);
|
|
||||||
if (time) {
|
|
||||||
const tSpan = document.createElement('span');
|
|
||||||
tSpan.className = 'chat-viewer-time';
|
|
||||||
tSpan.textContent = time;
|
|
||||||
row.appendChild(tSpan);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = m.u || m.user || m.login || '';
|
|
||||||
if (user) {
|
|
||||||
const uSpan = document.createElement('span');
|
|
||||||
uSpan.className = 'chat-viewer-user';
|
|
||||||
// Per-user IRC color overrides the default accent colour
|
|
||||||
// supplied by .chat-viewer-user; the class also sets weight.
|
|
||||||
if (m.color) uSpan.style.color = m.color;
|
|
||||||
uSpan.textContent = `${user}:`;
|
|
||||||
row.appendChild(uSpan);
|
|
||||||
}
|
|
||||||
|
|
||||||
const msgSpan = document.createElement('span');
|
|
||||||
msgSpan.textContent = ' ' + (m.msg || m.text || '');
|
|
||||||
row.appendChild(msgSpan);
|
|
||||||
|
|
||||||
fragment.appendChild(row);
|
|
||||||
}
|
|
||||||
list.appendChild(fragment);
|
|
||||||
idx = end;
|
|
||||||
if (idx < messages.length) {
|
|
||||||
window.setTimeout(renderChunk, 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
renderChunk();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeTopmostOpenModal(): boolean {
|
function closeTopmostOpenModal(): boolean {
|
||||||
// Try each known modal in priority order
|
// Try each known modal in priority order
|
||||||
const cutterDiscardModal = document.getElementById('cutterDiscardModal');
|
const cutterDiscardModal = document.getElementById('cutterDiscardModal');
|
||||||
@@ -1336,13 +1376,13 @@ function refreshTemplateGuideTexts(): void {
|
|||||||
|
|
||||||
function openTemplateGuide(source: TemplateGuideSource = 'vod'): void {
|
function openTemplateGuide(source: TemplateGuideSource = 'vod'): void {
|
||||||
templateGuideSource = source;
|
templateGuideSource = source;
|
||||||
byId('templateGuideModal').classList.add('show');
|
RendererAccessibility.openDialog('templateGuideModal', { onEscape: closeTemplateGuide });
|
||||||
refreshTemplateGuideTexts();
|
refreshTemplateGuideTexts();
|
||||||
setTemplateGuidePreset(source);
|
setTemplateGuidePreset(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTemplateGuide(): void {
|
function closeTemplateGuide(): void {
|
||||||
byId('templateGuideModal').classList.remove('show');
|
RendererAccessibility.closeDialog('templateGuideModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTemplateGuidePreset(source: TemplateGuideSource): void {
|
function setTemplateGuidePreset(source: TemplateGuideSource): void {
|
||||||
@@ -1392,11 +1432,11 @@ function openClipDialog(url: string, title: string, date: string, streamer: stri
|
|||||||
|
|
||||||
updateClipDuration();
|
updateClipDuration();
|
||||||
updateFilenameExamples();
|
updateFilenameExamples();
|
||||||
byId('clipModal').classList.add('show');
|
RendererAccessibility.openDialog('clipModal', { onEscape: closeClipDialog });
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeClipDialog(): void {
|
function closeClipDialog(): void {
|
||||||
byId('clipModal').classList.remove('show');
|
RendererAccessibility.closeDialog('clipModal');
|
||||||
clipDialogData = null;
|
clipDialogData = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4914,6 +4914,7 @@ input[type="number"]::-webkit-outer-spin-button {
|
|||||||
per-message colour for the username (driven by Twitch's IRC color
|
per-message colour for the username (driven by Twitch's IRC color
|
||||||
metadata). */
|
metadata). */
|
||||||
.chat-viewer-row {
|
.chat-viewer-row {
|
||||||
|
min-height: 29px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -4922,6 +4923,17 @@ input[type="number"]::-webkit-outer-spin-button {
|
|||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-viewer-virtual-canvas {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-viewer-virtual-rows {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-viewer-row:hover {
|
.chat-viewer-row:hover {
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
@@ -5615,6 +5627,11 @@ input[type="number"]::-webkit-outer-spin-button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.context-menu-item {
|
.context-menu-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -5627,6 +5644,11 @@ input[type="number"]::-webkit-outer-spin-button {
|
|||||||
background: rgba(145, 70, 255, 0.15);
|
background: rgba(145, 70, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-menu-item:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
.context-menu-item.disabled {
|
.context-menu-item.disabled {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
|
|||||||
Reference in New Issue
Block a user