Befehlspalette
Up/Down zum Navigieren, Enter zum Ausführen, Esc zum Schließen
@@ -1079,6 +1083,7 @@
+
diff --git a/src/main.ts b/src/main.ts
index 85d6beb..d8bfae3 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -57,6 +57,7 @@ import { commitQueueMutation, persistStateChange } from './main/domain/persisten
import { resolveSecretInputUpdate } from './main/domain/secret-input';
import { createSecretStore, type SecretStore } from './main/domain/secret-store';
import { createElectronSecureStorage } from './main/infra/secure-storage';
+import { readChatFile } from './main/domain/chat-reader';
import {
setDebugLogFn, initToolDirs,
getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath,
@@ -7668,6 +7669,7 @@ ipcMain.handle('cancel-download', async (event) => {
const fileCapabilities = new FileCapabilityStore();
const VIDEO_FILE_EXTENSIONS = ['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ts', 'avi'];
const knownRendererPaths = new Map
>();
+const activeChatReadControllers = new Map>();
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 });
@@ -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
// 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', (event, capability: string): { success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array>; 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);
if (!filePath) return { success: false, error: 'File access denied' };
-
- const MAX_MESSAGES = 50000;
+ const controller = new AbortController();
+ const validRequestId = typeof requestId === 'string' && requestId.length > 0 && requestId.length <= 128
+ ? requestId
+ : null;
+ if (validRequestId) {
+ const controllers = activeChatReadControllers.get(event.sender.id) ?? new Map();
+ controllers.set(validRequestId, controller);
+ activeChatReadControllers.set(event.sender.id, controllers);
+ }
try {
- const raw = fs.readFileSync(filePath, 'utf-8');
- if (filePath.toLowerCase().endsWith('.jsonl')) {
- // JSON Lines (live chat): one object per line, first line may be header
- const messages: Array> = [];
- let truncated = false;
- const lines = raw.split('\n');
- let total = 0;
- for (const line of lines) {
- const trimmed = line.trim();
- if (!trimmed) continue;
- try {
- const obj = JSON.parse(trimmed);
- if (obj && typeof obj === 'object' && obj.type !== 'header') {
- total++;
- if (messages.length < MAX_MESSAGES) messages.push(obj);
- else truncated = true;
- }
- } catch { /* skip bad lines */ }
- }
- return { success: true, format: 'live', messages, truncated, total };
+ return await readChatFile(filePath, { signal: controller.signal });
+ } finally {
+ if (validRequestId) {
+ const controllers = activeChatReadControllers.get(event.sender.id);
+ controllers?.delete(validRequestId);
+ if (controllers?.size === 0) activeChatReadControllers.delete(event.sender.id);
}
-
- // .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) };
}
});
diff --git a/src/main/domain/chat-reader.test.ts b/src/main/domain/chat-reader.test.ts
new file mode 100644
index 0000000..22836f9
--- /dev/null
+++ b/src/main/domain/chat-reader.test.ts
@@ -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((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 });
+ });
+});
diff --git a/src/main/domain/chat-reader.ts b/src/main/domain/chat-reader.ts
new file mode 100644
index 0000000..c9c6ba5
--- /dev/null
+++ b/src/main/domain/chat-reader.ts
@@ -0,0 +1,224 @@
+import { createReadStream } from 'node:fs';
+
+export interface ChatReadSuccess {
+ success: true;
+ format: 'replay' | 'live';
+ messages: Array>;
+ 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 {
+ return new Promise((resolve) => setImmediate(resolve));
+}
+
+function addMessage(messages: Array>, 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);
+}
+
+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 {
+ const messages: Array> = [];
+ 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 {
+ const messages: Array> = [];
+ 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 {
+ 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);
+}
diff --git a/src/preload.ts b/src/preload.ts
index a019eae..7b33274 100644
--- a/src/preload.ts
+++ b/src/preload.ts
@@ -1,6 +1,8 @@
import { contextBridge, ipcRenderer, webUtils } from 'electron';
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress } from './types';
+let chatReadSequence = 0;
+
// Types
interface RuntimeMetricsSnapshot {
cacheHits: number;
@@ -164,11 +166,18 @@ contextBridge.exposeInMainWorld('api', {
},
searchArchive: (filter: Record) => ipcRenderer.invoke('search-archive', filter),
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);
- return capability
- ? ipcRenderer.invoke('read-chat-file', capability.token)
- : { success: false, error: 'File access denied' };
+ if (!capability) return { success: false, error: 'File access denied' };
+ if (signal?.aborted) return { success: false, cancelled: true };
+ 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'),
triggerAutoVodScan: () => ipcRenderer.invoke('trigger-auto-vod-scan'),
diff --git a/src/renderer-accessibility.test.ts b/src/renderer-accessibility.test.ts
new file mode 100644
index 0000000..db36309
--- /dev/null
+++ b/src/renderer-accessibility.test.ts
@@ -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');
+ });
+});
diff --git a/src/renderer-accessibility.ts b/src/renderer-accessibility.ts
new file mode 100644
index 0000000..bd6e9e6
--- /dev/null
+++ b/src/renderer-accessibility.ts
@@ -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();
+ 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(focusSelector)).filter((element) => element.getAttribute('aria-hidden') !== 'true');
+ }
+
+ function getInitialFocus(dialog: HTMLElement, initialFocus: RendererDialogOptions['initialFocus']): HTMLElement | null {
+ if (typeof initialFocus === 'string') return dialog.querySelector(initialFocus);
+ if (initialFocus && dialog.contains(initialFocus)) return initialFocus;
+ return getFocusable(dialog)[0] ?? null;
+ }
+
+ function syncBackgroundInertness(): void {
+ const shell = document.querySelector('.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('[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('[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 });
diff --git a/src/renderer-command-palette.ts b/src/renderer-command-palette.ts
index 92895dc..3b0c898 100644
--- a/src/renderer-command-palette.ts
+++ b/src/renderer-command-palette.ts
@@ -99,6 +99,7 @@ interface PaletteCommand {
clearList(list);
STORE.filtered.forEach((cmd, idx) => {
const li = document.createElement('li');
+ li.id = `commandPaletteOption-${idx}`;
li.className = 'cp-item' + (idx === STORE.activeIndex ? ' cp-active' : '');
li.dataset.cmdId = cmd.id;
li.setAttribute('role', 'option');
@@ -124,6 +125,8 @@ interface PaletteCommand {
list.appendChild(li);
});
+ const input = getInput();
+ if (input) input.setAttribute('aria-activedescendant', STORE.filtered[STORE.activeIndex] ? `commandPaletteOption-${STORE.activeIndex}` : '');
}
function applyFilter(query: string) {
@@ -133,9 +136,7 @@ interface PaletteCommand {
} else {
STORE.filtered = STORE.commands.filter(c => c.keywords.includes(q));
}
- if (STORE.activeIndex >= STORE.filtered.length) {
- STORE.activeIndex = STORE.filtered.length > 0 ? STORE.filtered.length - 1 : 0;
- }
+ STORE.activeIndex = 0;
render();
}
@@ -158,15 +159,17 @@ interface PaletteCommand {
STORE.filtered = STORE.commands.slice();
STORE.activeIndex = 0;
input.value = '';
- modal.classList.add('show');
- requestAnimationFrame(() => input.focus());
render();
+ input.setAttribute('aria-expanded', 'true');
+ RendererAccessibility.openDialog('commandPaletteModal', { initialFocus: input, onEscape: close });
}
function close() {
const modal = getModal();
if (!modal) return;
- modal.classList.remove('show');
+ getInput()?.setAttribute('aria-expanded', 'false');
+ getInput()?.removeAttribute('aria-activedescendant');
+ RendererAccessibility.closeDialog('commandPaletteModal');
}
function onKeydown(e: KeyboardEvent) {
diff --git a/src/renderer-cutter.ts b/src/renderer-cutter.ts
index eb1a50e..bbac8c0 100644
--- a/src/renderer-cutter.ts
+++ b/src/renderer-cutter.ts
@@ -61,7 +61,6 @@ let cutterScrubSeekInFlight = false;
let cutterScrubResumePlayback = false;
let cutterScrubGeneration = 0;
let cutterDiscardResolver: ((discard: boolean) => void) | null = null;
-let cutterDiscardReturnFocus: HTMLElement | null = null;
const cutterMaximumCuts = 64;
const cutterFrameTolerance = 1e-8;
@@ -905,17 +904,10 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise
}
function resolveCutterDiscard(discard: boolean): void {
- const modal = byId('cutterDiscardModal');
- modal.classList.remove('show');
- modal.setAttribute('aria-hidden', 'true');
- const shell = document.querySelector('.workspace-shell');
- if (shell) shell.inert = false;
+ RendererAccessibility.closeDialog('cutterDiscardModal');
const resolver = cutterDiscardResolver;
- const returnFocus = cutterDiscardReturnFocus;
cutterDiscardResolver = null;
- cutterDiscardReturnFocus = null;
resolver?.(discard);
- if (returnFocus?.isConnected) requestAnimationFrame(() => returnFocus.focus());
}
function handleCutterDiscardOverlayClick(event: MouseEvent): void {
@@ -938,15 +930,10 @@ function trapCutterDiscardFocus(event: KeyboardEvent): void {
function confirmCutterReplacement(file: FileCapabilityReference): Promise {
if (!cutterFile || !cutterEditorState || cutterFile.token === file.token) return Promise.resolve(true);
if (cutterDiscardResolver) resolveCutterDiscard(false);
- const modal = byId('cutterDiscardModal');
- cutterDiscardReturnFocus = document.activeElement instanceof HTMLElement && document.activeElement !== document.body
- ? document.activeElement
- : null;
- const shell = document.querySelector('.workspace-shell');
- if (shell) shell.inert = true;
- modal.classList.add('show');
- modal.setAttribute('aria-hidden', 'false');
- requestAnimationFrame(() => byId('cutterDiscardCancelBtn').focus());
+ RendererAccessibility.openDialog('cutterDiscardModal', {
+ initialFocus: byId('cutterDiscardCancelBtn'),
+ onEscape: () => resolveCutterDiscard(false)
+ });
return new Promise((resolve) => { cutterDiscardResolver = resolve; });
}
diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts
index 887cce0..a275186 100644
--- a/src/renderer-globals.d.ts
+++ b/src/renderer-globals.d.ts
@@ -437,7 +437,7 @@ interface ApiBridge {
limit?: number;
}): Promise;
runStorageCleanup(options?: { dryRun?: boolean }): Promise;
- readChatFile(filePath: string): Promise<{ success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array>; truncated?: boolean; total?: number }>;
+ readChatFile(filePath: string, signal?: AbortSignal): Promise<{ success: boolean; error?: string; cancelled?: boolean; format?: 'replay' | 'live'; messages?: Array>; truncated?: boolean; total?: number }>;
getAutomationStatus(): Promise<{
autoRecord: { watching: number; lastRunAt: number; nextRunAt: number; lastTriggeredCount: number; inFlight: boolean };
autoVod: { watching: number; lastRunAt: number; nextRunAt: number; lastQueuedCount: number; inFlight: boolean };
diff --git a/src/renderer-queue.ts b/src/renderer-queue.ts
index 8342655..d28ea51 100644
--- a/src/renderer-queue.ts
+++ b/src/renderer-queue.ts
@@ -157,11 +157,15 @@ async function retryQueueItem(id: string): Promise {
let queueContextMenuInitialized = false;
let activeQueueContextMenu: HTMLElement | null = null;
+let activeQueueContextMenuInvoker: HTMLElement | null = null;
-function closeQueueContextMenu(): void {
+function closeQueueContextMenu(restoreFocus = false): void {
if (!activeQueueContextMenu) return;
activeQueueContextMenu.remove();
activeQueueContextMenu = null;
+ const invoker = activeQueueContextMenuInvoker;
+ activeQueueContextMenuInvoker = null;
+ if (restoreFocus && invoker?.isConnected) invoker.focus();
}
function initQueueContextMenu(): void {
@@ -177,26 +181,42 @@ function initQueueContextMenu(): void {
const item = queue.find((i) => i.id === id);
if (!item) return;
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();
const menu = document.createElement('div');
menu.className = 'context-menu';
menu.setAttribute('role', 'menu');
+ let cleanup = (restoreFocus = false): void => closeQueueContextMenu(restoreFocus);
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.className = 'context-menu-item' + (disabled ? ' disabled' : '');
el.setAttribute('role', 'menuitem');
- if (disabled) el.setAttribute('aria-disabled', 'true');
+ if (disabled) {
+ el.setAttribute('aria-disabled', 'true');
+ el.disabled = true;
+ }
if (!disabled) {
el.addEventListener('click', () => {
- try { onClick(); } finally { closeQueueContextMenu(); }
+ try { onClick(); } finally { cleanup(); }
});
}
return el;
@@ -260,6 +280,7 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
document.body.appendChild(menu);
activeQueueContextMenu = menu;
+ activeQueueContextMenuInvoker = invoker;
const rect = menu.getBoundingClientRect();
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;
cleanup();
};
- const dismissOnEscape = (ev: KeyboardEvent) => {
- if (ev.key === 'Escape') cleanup();
- };
const dismissOnScroll = () => cleanup();
- const cleanup = (): void => {
- closeQueueContextMenu();
+ cleanup = (restoreFocus = false): void => {
+ closeQueueContextMenu(restoreFocus);
document.removeEventListener('mousedown', dismissOnClick, true);
- document.removeEventListener('keydown', dismissOnEscape, true);
document.removeEventListener('scroll', dismissOnScroll, true);
};
document.addEventListener('mousedown', dismissOnClick, true);
- document.addEventListener('keydown', dismissOnEscape, true);
document.addEventListener('scroll', dismissOnScroll, true);
+ RendererAccessibility.installMenuKeyboardNavigation(menu, () => cleanup(true));
+ RendererAccessibility.focusFirstMenuItem(menu);
}
async function moveQueueItemTo(id: string, where: 'top' | 'bottom'): Promise {
diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts
index 0e7f0ac..4f77149 100644
--- a/src/renderer-streamers.ts
+++ b/src/renderer-streamers.ts
@@ -1064,15 +1064,23 @@ function initVodGridSelectionDelegation(): void {
const ctx = readVodCardContext(card);
if (!ctx) return;
e.preventDefault();
- showVodContextMenu(e.clientX, e.clientY, ctx);
+ showVodContextMenu(e.clientX, e.clientY, ctx, card);
});
grid.addEventListener('keydown', (e) => {
- if (e.key !== 'Enter' && e.key !== ' ') return;
const target = e.target as HTMLElement | null;
if (!target) return;
const card = target.closest('.vod-card') as HTMLElement | null;
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();
toggleVodCardSelection(card);
});
@@ -1096,14 +1104,18 @@ function toggleVodCardSelection(card: HTMLElement): void {
}
let activeVodContextMenu: HTMLElement | null = null;
+let activeVodContextMenuInvoker: HTMLElement | null = null;
-function closeVodContextMenu(): void {
+function closeVodContextMenu(restoreFocus = false): void {
if (!activeVodContextMenu) return;
activeVodContextMenu.remove();
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();
const menu = document.createElement('div');
@@ -1117,13 +1129,15 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
);
const isMarkedDownloaded = downloadedIds.has(ctx.id);
+ let cleanup = (restoreFocus = false): void => closeVodContextMenu(restoreFocus);
const makeItem = (label: string, onClick: () => void): HTMLElement => {
- const el = document.createElement('div');
+ const el = document.createElement('button');
+ el.type = 'button';
el.textContent = label;
el.className = 'context-menu-item';
el.setAttribute('role', 'menuitem');
el.addEventListener('click', () => {
- try { onClick(); } finally { closeVodContextMenu(); }
+ try { onClick(); } finally { cleanup(); }
});
return el;
};
@@ -1151,6 +1165,7 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
document.body.appendChild(menu);
activeVodContextMenu = menu;
+ activeVodContextMenuInvoker = invoker;
// Reposition if it would clip off the viewport
const rect = menu.getBoundingClientRect();
@@ -1161,31 +1176,21 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
- // Close on click anywhere else / Escape / scroll
const dismissOnClick = (ev: MouseEvent) => {
if (!activeVodContextMenu) return;
if (ev.target instanceof Node && activeVodContextMenu.contains(ev.target)) return;
- closeVodContextMenu();
- document.removeEventListener('mousedown', dismissOnClick, true);
- document.removeEventListener('keydown', dismissOnEscape, true);
- document.removeEventListener('scroll', dismissOnScroll, true);
+ cleanup();
};
- const dismissOnEscape = (ev: KeyboardEvent) => {
- if (ev.key !== 'Escape') return;
- closeVodContextMenu();
+ const dismissOnScroll = () => cleanup();
+ cleanup = (restoreFocus = false): void => {
+ closeVodContextMenu(restoreFocus);
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.addEventListener('mousedown', dismissOnClick, true);
- document.addEventListener('keydown', dismissOnEscape, true);
document.addEventListener('scroll', dismissOnScroll, true);
+ RendererAccessibility.installMenuKeyboardNavigation(menu, () => cleanup(true));
+ RendererAccessibility.focusFirstMenuItem(menu);
}
async function toggleVodDownloadedMark(vodId: string, mark: boolean): Promise {
diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts
index d2aa50d..8252b9b 100644
--- a/src/renderer-texts.ts
+++ b/src/renderer-texts.ts
@@ -67,6 +67,7 @@ function setAriaLabel(id: string, value: string): void {
function setLanguage(lang: string): LanguageCode {
currentLanguage = lang === 'en' ? 'en' : 'de';
+ RendererAccessibility.setDocumentLanguage(currentLanguage);
UI_TEXT = UI_TEXTS[currentLanguage];
applyLanguageToStaticUI();
return currentLanguage;
diff --git a/src/renderer-updates.ts b/src/renderer-updates.ts
index 5b80c4a..1ca889b 100644
--- a/src/renderer-updates.ts
+++ b/src/renderer-updates.ts
@@ -420,12 +420,12 @@ function refreshUpdateModalTexts(): void {
function openUpdateModal(info?: UpdateInfo): void {
rememberUpdateInfo(info);
updateChangelogExpanded = false;
- byId('updateModal').classList.add('show');
+ RendererAccessibility.openDialog('updateModal', { onEscape: dismissUpdateModal });
refreshUpdateModalTexts();
}
function dismissUpdateModal(): void {
- byId('updateModal').classList.remove('show');
+ RendererAccessibility.closeDialog('updateModal');
}
function skipUpdateVersion(): void {
diff --git a/src/renderer.ts b/src/renderer.ts
index 9a85677..59462a4 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -309,13 +309,12 @@ interface EventLogEntry {
}
async function openEventsViewer(filePath: string, title: string): Promise {
- const modal = byId('eventsViewerModal');
const list = byId('eventsViewerList');
const status = byId('eventsViewerStatus');
byId('eventsViewerTitle').textContent = title || UI_TEXT.queue.viewEvents;
list.replaceChildren();
status.textContent = UI_TEXT.queue.viewChatLoading;
- modal.classList.add('show');
+ RendererAccessibility.openDialog('eventsViewerModal', { onEscape: closeEventsViewer });
const result = await window.api.readChatFile(filePath);
if (!result.success || !Array.isArray(result.messages)) {
@@ -328,7 +327,7 @@ async function openEventsViewer(filePath: string, title: string): Promise
}
function closeEventsViewer(): void {
- byId('eventsViewerModal').classList.remove('show');
+ RendererAccessibility.closeDialog('eventsViewerModal');
}
function formatEventTime(iso?: string): string {
@@ -411,8 +410,17 @@ interface ChatViewerMessage {
let chatViewerMessages: ChatViewerMessage[] = [];
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 {
+ const sessionGeneration = ++chatViewerSessionGeneration;
+ chatViewerReadAbort?.abort();
+ const abortController = new AbortController();
+ chatViewerReadAbort = abortController;
const modal = byId('chatViewerModal');
const list = byId('chatViewerList');
const status = byId('chatViewerStatus');
@@ -421,10 +429,12 @@ async function openChatViewer(filePath: string, title: string): Promise {
list.replaceChildren();
filterInput.value = '';
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.cancelled) return;
status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : '');
return;
}
@@ -433,26 +443,114 @@ async function openChatViewer(filePath: string, title: string): Promise {
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);
+ onChatViewerFilterChange();
}
function closeChatViewer(): void {
- byId('chatViewerModal').classList.remove('show');
+ chatViewerSessionGeneration++;
+ chatViewerReadAbort?.abort();
+ chatViewerReadAbort = null;
+ chatViewerRenderGeneration.cancel();
+ RendererAccessibility.closeDialog('chatViewerModal');
chatViewerMessages = [];
}
function onChatViewerFilterChange(): void {
+ const generation = chatViewerRenderGeneration.next();
+ const list = byId('chatViewerList');
+ list.scrollTop = 0;
+ list.replaceChildren();
const filter = byId('chatViewerFilter').value.trim().toLowerCase();
if (!filter) {
- renderChatViewerList(chatViewerMessages);
+ renderChatViewerList(chatViewerMessages, generation);
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);
+ 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 text = (m.msg || m.text || '').toLowerCase();
+ 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();
+ });
+ }, { passive: true });
+ renderVisibleRows();
}
function formatChatTimeMarker(m: ChatViewerMessage): string {
@@ -475,64 +573,6 @@ function formatChatTimeMarker(m: ChatViewerMessage): string {
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 {
// Try each known modal in priority order
const cutterDiscardModal = document.getElementById('cutterDiscardModal');
@@ -1336,13 +1376,13 @@ function refreshTemplateGuideTexts(): void {
function openTemplateGuide(source: TemplateGuideSource = 'vod'): void {
templateGuideSource = source;
- byId('templateGuideModal').classList.add('show');
+ RendererAccessibility.openDialog('templateGuideModal', { onEscape: closeTemplateGuide });
refreshTemplateGuideTexts();
setTemplateGuidePreset(source);
}
function closeTemplateGuide(): void {
- byId('templateGuideModal').classList.remove('show');
+ RendererAccessibility.closeDialog('templateGuideModal');
}
function setTemplateGuidePreset(source: TemplateGuideSource): void {
@@ -1392,11 +1432,11 @@ function openClipDialog(url: string, title: string, date: string, streamer: stri
updateClipDuration();
updateFilenameExamples();
- byId('clipModal').classList.add('show');
+ RendererAccessibility.openDialog('clipModal', { onEscape: closeClipDialog });
}
function closeClipDialog(): void {
- byId('clipModal').classList.remove('show');
+ RendererAccessibility.closeDialog('clipModal');
clipDialogData = null;
}
diff --git a/src/styles.css b/src/styles.css
index 715838c..a3cda60 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -4914,6 +4914,7 @@ input[type="number"]::-webkit-outer-spin-button {
per-message colour for the username (driven by Twitch's IRC color
metadata). */
.chat-viewer-row {
+ min-height: 29px;
padding: 4px 8px;
line-height: 1.55;
border-radius: 4px;
@@ -4922,6 +4923,17 @@ input[type="number"]::-webkit-outer-spin-button {
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 {
background: rgba(255, 255, 255, 0.04);
}
@@ -5615,6 +5627,11 @@ input[type="number"]::-webkit-outer-spin-button {
}
.context-menu-item {
+ display: block;
+ width: 100%;
+ border: 0;
+ background: transparent;
+ text-align: left;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
@@ -5627,6 +5644,11 @@ input[type="number"]::-webkit-outer-spin-button {
background: rgba(145, 70, 255, 0.15);
}
+.context-menu-item:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+
.context-menu-item.disabled {
color: var(--text-secondary);
opacity: 0.55;