Compare commits

..

No commits in common. "81a1f914b4ceba6965493fa876ea37a45684522f" and "9d57c03e74997ff2452fcbe69f9f34370eb439bf" have entirely different histories.

10 changed files with 51 additions and 275 deletions

View File

@ -2,37 +2,6 @@
Dated entries from improvement cycles. Newest at top.
## 2026-05-03 — Cycle 3: clip hardening + VOD filter + cancel-cross-talk fix
Three independent improvements landed this cycle.
### 1. `download-clip` IPC: integrity, cancellation, sanitization (server defensive)
- **File**: `src/main.ts``download-clip` IPC handler, new `activeClipProcesses` map.
- **Problem**: The handler reported `success: true` on streamlink exit code 0 even when the resulting file was empty / a few hundred bytes (Twitch occasionally returns a manifest with no segments). The path passed `clipInfo.broadcaster_name` straight to `path.join` — Twitch returns the broadcaster's *display* name, which can carry unicode, spaces, or punctuation that produced surprising directory layouts on Windows. The spawned streamlink process was tracked nowhere, so `window-all-closed` left it orphaned.
- **Fix**: `safeBroadcaster` runs through `sanitizeFilenamePart`. `safeTitle` falls back to `clip` when the title sanitises to empty. The output filename now goes through `ensureUniqueFilename(path, clipId)` so retrying a clip with the same title doesn't overwrite the previous download. After streamlink exits, the file is rejected if smaller than 16 KiB or if `validateDownloadedFileIntegrity` fails (no video stream / unreadable). The proc is tracked in a new `activeClipProcesses` map and killed by `window-all-closed`.
### 2. VOD list filter / search (client feature: VM/state + UI + persistence + keyboard)
- **Files**: `src/renderer-streamers.ts`, `src/renderer.ts`, `src/renderer-texts.ts`, `src/index.html`, `src/renderer-locale-de.ts`, `src/renderer-locale-en.ts`.
- **Problem**: A streamer can have hundreds of VODs (the test fixture alone has 37 cards). There was no way to find a specific VOD by title — only scroll. With a long archive this is genuinely painful.
- **Fix**: Filter row above the VOD grid (`vodFilterInput`, clear button, match counter). State (`vodFilterQuery`) is persisted to `localStorage` via `loadPersistedVodFilter` / `persistVodFilter`, so the search bar survives an app restart. The render path was split: `renderVODs` now stores `lastLoadedVods` + `lastLoadedStreamer` and delegates to `renderVodGridFromCurrentState`, which applies `filterVodsByQuery` on every input event without re-fetching. Empty-state DOM is built via `setVodGridEmptyState` using `createElement` + `textContent` (no `innerHTML` for locale strings — defense-in-depth even though the strings are trusted). Keyboard: `Ctrl+F` / `Cmd+F` focuses the filter (only when the VODs tab is active and Electron's no-op default is suppressed); `Esc` clears the filter when the input has focus and content; `Esc` still closes modals first if any are open.
### 3. Decouple `currentProcess` from queue downloads (server cleanup + race fix)
- **File**: `src/main.ts` — global rename and assignment removal.
- **Problem**: A single `currentProcess: ChildProcess | null` was shared by `cutVideo`, `mergeVideos`, `splitMergedFile`, AND `downloadVODPart`. With parallel downloads the global was constantly overwritten between siblings, but the cross-talk that mattered was different: if a queue download was running and the user kicked off a video cut, the cutter ffmpeg ran into the same global. Pressing the queue's *cancel-download* button then iterated `activeDownloads` (correct) AND called `currentProcess.kill()` (incorrect — that was the cutter ffmpeg by then), killing the unrelated cut.
- **Fix**: `currentProcess` renamed to `currentEditorProcess` and confined to the editor pipeline (cutter / merger / splitter). `downloadVODPart` no longer assigns to it — `activeDownloads` is the sole source of truth for queue children. The fallback `if (currentProcess) currentProcess.kill()` was removed from `remove-from-queue`, `pause-download`, and `cancel-download`. `window-all-closed` still kills it (so a cutter ffmpeg gets cleaned up on app exit) and now also kills `activeClipProcesses` introduced by Pick 1.
### Regression
- `npm run build` — clean (TypeScript strict, 0 errors).
- `npm run test:e2e:update-logic` — passed.
- `npm run test:e2e` — passed (`issues: []`).
- `npm run test:e2e:guide` — passed (`failures: []`).
- `npm run test:merge-split` — passed.
- `npm run test:e2e:full` — passed (`failures: []`, `runtimeIssues: []`; flows: language switch, queue add, duplicate prevention, runtime metrics, clip queue, pause/resume, retry, reorder, media cut/merge, update check).
## 2026-05-03 — Cycle 2: release pipeline + defensive parsing
Three independent improvements landed this cycle.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "twitch-vod-manager",
"version": "4.5.10",
"version": "4.5.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
"version": "4.5.10",
"version": "4.5.9",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{
"name": "twitch-vod-manager",
"version": "4.5.10",
"version": "4.5.9",
"description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js",
"author": "xRangerDE",

View File

@ -239,11 +239,6 @@
<div class="content">
<!-- VODs Tab -->
<div class="tab-content active" id="vodsTab">
<div class="vod-filter-row" style="display:flex; align-items:center; gap:8px; margin-bottom:12px;">
<input type="text" id="vodFilterInput" placeholder="Filter VODs..." oninput="onVodFilterInput()" style="flex:1; background: var(--bg-secondary,#222); border:1px solid var(--border-color,#444); border-radius:6px; padding:8px 12px; color: var(--text-primary,#fff); font-size:13px;">
<button id="vodFilterClearBtn" onclick="clearVodFilter()" title="Clear filter" style="display:none; background:transparent; border:1px solid var(--border-color,#444); border-radius:6px; padding:8px 12px; color: var(--text-secondary,#888); cursor:pointer;">x</button>
<span id="vodFilterCount" style="color: var(--text-secondary,#888); font-size:12px; min-width:80px;"></span>
</div>
<div class="vod-grid" id="vodGrid">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>

View File

@ -530,11 +530,7 @@ let downloadQueue: QueueItem[] = loadQueue();
let queueIdCounter = 0;
let lastQueueBroadcastFingerprint = '';
let isDownloading = false;
// Process handle for the standalone video editor pipeline (cutter / merger /
// splitter). Queue downloads track their own children via activeDownloads,
// and clip downloads via activeClipProcesses. Keeping these separate
// prevents cancel-download from killing an unrelated cutter ffmpeg.
let currentEditorProcess: ChildProcess | null = null;
let currentProcess: ChildProcess | null = null;
let currentDownloadCancelled = false;
let pauseRequested = false;
let activeQueueItemId: string | null = null;
@ -2070,7 +2066,7 @@ async function cutVideo(
return await new Promise((resolve) => {
const proc = spawn(ffmpeg, args, { windowsHide: true });
currentEditorProcess = proc;
currentProcess = proc;
proc.stdout?.on('data', (data) => {
const line = data.toString();
@ -2083,7 +2079,7 @@ async function cutVideo(
});
proc.on('close', (code) => {
currentEditorProcess = null;
currentProcess = null;
if (code === 0 && fs.existsSync(outputFile)) {
const stats = fs.statSync(outputFile);
if (stats.size <= 256) {
@ -2098,7 +2094,7 @@ async function cutVideo(
});
proc.on('error', () => {
currentEditorProcess = null;
currentProcess = null;
resolve(false);
});
});
@ -2212,7 +2208,7 @@ async function mergeVideos(
return await new Promise((resolve) => {
const proc = spawn(ffmpeg, args, { windowsHide: true });
currentEditorProcess = proc;
currentProcess = proc;
proc.stdout?.on('data', (data) => {
const line = data.toString();
@ -2228,7 +2224,7 @@ async function mergeVideos(
});
proc.on('close', (code) => {
currentEditorProcess = null;
currentProcess = null;
const success = code === 0 && fs.existsSync(outputFile);
if (success) {
onProgress(100);
@ -2237,7 +2233,7 @@ async function mergeVideos(
});
proc.on('error', () => {
currentEditorProcess = null;
currentProcess = null;
resolve(false);
});
});
@ -2307,15 +2303,15 @@ async function splitMergedFile(
const success = await new Promise<boolean>((resolve) => {
const proc = spawn(ffmpeg, args, { windowsHide: true });
currentEditorProcess = proc;
currentProcess = proc;
proc.on('close', (code) => {
currentEditorProcess = null;
currentProcess = null;
resolve(code === 0 && fs.existsSync(outputFile));
});
proc.on('error', () => {
currentEditorProcess = null;
currentProcess = null;
resolve(false);
});
});
@ -2362,9 +2358,9 @@ function downloadVODPart(
appendDebugLog('download-part-start', { itemId, command: streamlinkCmd.command, filename, args });
const proc = spawn(streamlinkCmd.command, args, { windowsHide: true });
currentProcess = proc;
// Register in per-item tracking map for parallel downloads
// (no longer mirrored on a global — currentEditorProcess is editor-only)
const itemTracking = { process: proc, cancelled: false, startTime: Date.now(), bytes: 0 };
activeDownloads.set(itemId, itemTracking);
@ -2455,6 +2451,7 @@ function downloadVODPart(
proc.on('close', async (code) => {
clearInterval(progressInterval);
currentProcess = null;
activeDownloads.delete(itemId);
if (currentDownloadCancelled || cancelledItemIds.has(itemId)) {
@ -2499,6 +2496,7 @@ function downloadVODPart(
proc.on('error', (err) => {
clearInterval(progressInterval);
console.error('Process error:', err);
currentProcess = null;
activeDownloads.delete(itemId);
const rawError = String(err);
const errorMessage = rawError.includes('ENOENT')
@ -3584,12 +3582,17 @@ ipcMain.handle('remove-from-queue', (_, id: string) => {
const wasActiveItem = activeQueueItemId === id || activeDownloads.has(id);
if (wasActiveItem) {
// Cancel via per-item tracking
cancelledItemIds.add(id);
const tracking = activeDownloads.get(id);
if (tracking?.process) {
tracking.process.kill();
}
// Also set global for backwards compat
currentDownloadCancelled = true;
if (currentProcess) {
currentProcess.kill();
}
activeDownloads.delete(id);
activeQueueItemId = null;
runtimeMetrics.activeItemId = null;
@ -3758,14 +3761,16 @@ ipcMain.handle('pause-download', () => {
pauseRequested = true;
currentDownloadCancelled = true;
// Kill queue downloads only — cutter/merger/splitter use currentEditorProcess
// and aren't affected by pause-download.
// Kill all active download processes
for (const [id, tracking] of activeDownloads) {
cancelledItemIds.add(id);
if (tracking.process) {
tracking.process.kill();
}
}
if (currentProcess) {
currentProcess.kill();
}
return true;
});
@ -3773,13 +3778,16 @@ ipcMain.handle('cancel-download', () => {
isDownloading = false;
pauseRequested = false;
currentDownloadCancelled = true;
// Kill queue downloads only — see pause-download note above.
// Kill all active download processes
for (const [id, tracking] of activeDownloads) {
cancelledItemIds.add(id);
if (tracking.process) {
tracking.process.kill();
}
}
if (currentProcess) {
currentProcess.kill();
}
return true;
});
@ -3850,11 +3858,6 @@ ipcMain.handle('open-external', async (_, url: string) => {
await shell.openExternal(url);
});
// Tracks active standalone clip downloads so cancel-download / window-all-closed
// can kill them. Separate from activeDownloads (queue) because clip downloads
// don't go through the queue scheduler.
const activeClipProcesses = new Map<string, ChildProcess>();
ipcMain.handle('download-clip', async (_, clipUrl: string) => {
let clipId = '';
const match1 = clipUrl.match(/clips\.twitch\.tv\/([A-Za-z0-9_-]+)/);
@ -3867,14 +3870,7 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
const clipInfo = await getClipInfo(clipId);
if (!clipInfo) return { success: false, error: 'Clip nicht gefunden' };
// Sanitize broadcaster_name for path safety — Twitch returns the display
// name which can contain unicode, spaces, or punctuation that breaks
// path joining on some Windows configurations.
const safeBroadcaster = sanitizeFilenamePart(
typeof clipInfo.broadcaster_name === 'string' ? clipInfo.broadcaster_name : '',
'unknown'
);
const folder = path.join(config.download_path, 'Clips', safeBroadcaster);
const folder = path.join(config.download_path, 'Clips', clipInfo.broadcaster_name);
fs.mkdirSync(folder, { recursive: true });
const clipDiskCheck = ensureDiskSpace(folder, 128 * 1024 * 1024, 'Clip-Download');
@ -3882,14 +3878,10 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
return { success: false, error: clipDiskCheck.error || 'Zu wenig Speicherplatz.' };
}
const rawTitle = typeof clipInfo.title === 'string' ? clipInfo.title : '';
const safeTitle = (rawTitle.replace(/[^a-zA-Z0-9_\- ]/g, '').trim().substring(0, 50)) || 'clip';
// Use ensureUniqueFilename so retrying a clip with the same title doesn't
// overwrite the previous download. itemId is the clipId — if the user
// cancels via cancel-download, that's the handle.
const filename = ensureUniqueFilename(path.join(folder, `${safeTitle}.mp4`), clipId);
const safeTitle = clipInfo.title.replace(/[^a-zA-Z0-9_\- ]/g, '').substring(0, 50);
const filename = path.join(folder, `${safeTitle}.mp4`);
return new Promise<{ success: boolean; error?: string; filename?: string }>((resolve) => {
return new Promise((resolve) => {
const streamlinkCmd = getStreamlinkCommand();
const proc = spawn(streamlinkCmd.command, [
...streamlinkCmd.prefixArgs,
@ -3899,45 +3891,15 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
'--force'
], { windowsHide: true });
activeClipProcesses.set(clipId, proc);
appendDebugLog('clip-download-start', { clipId, broadcaster: safeBroadcaster, filename });
proc.on('close', (code) => {
activeClipProcesses.delete(clipId);
releaseClaimedFilenamesForItem(clipId);
if (code !== 0 || !fs.existsSync(filename)) {
appendDebugLog('clip-download-failed', { clipId, code });
if (code === 0 && fs.existsSync(filename)) {
resolve({ success: true, filename });
} else {
resolve({ success: false, error: `Download fehlgeschlagen (Exit-Code ${code ?? -1})` });
return;
}
// Integrity: clips are short but should still be at least a few KB
// and parse as a video stream via ffprobe. Empty/zero-byte files
// were previously reported as "success" because exit code was 0.
const stats = fs.statSync(filename);
if (stats.size < 16 * 1024) {
try { fs.unlinkSync(filename); } catch { }
appendDebugLog('clip-download-too-small', { clipId, bytes: stats.size });
resolve({ success: false, error: `Clip-Datei zu klein (${stats.size} Bytes) — Twitch hat den Stream evtl. nicht ausgeliefert.` });
return;
}
const integrity = validateDownloadedFileIntegrity(filename, null);
if (!integrity.success) {
try { fs.unlinkSync(filename); } catch { }
appendDebugLog('clip-download-integrity-failed', { clipId, error: integrity.error });
resolve({ success: false, error: integrity.error || 'Integritaetspruefung fehlgeschlagen.' });
return;
}
appendDebugLog('clip-download-success', { clipId, bytes: stats.size, filename });
resolve({ success: true, filename });
});
proc.on('error', () => {
activeClipProcesses.delete(clipId);
releaseClaimedFilenamesForItem(clipId);
resolve({ success: false, error: 'Streamlink nicht gefunden' });
});
});
@ -4056,18 +4018,14 @@ app.on('window-all-closed', () => {
stopDebugLogFlushTimer(true);
stopAutoUpdatePolling();
// Kill all active children: queue downloads, standalone clip downloads,
// and any in-flight cutter/merger/splitter ffmpeg.
// Kill all active download processes
for (const [, tracking] of activeDownloads) {
if (tracking.process) {
tracking.process.kill();
}
}
for (const [, proc] of activeClipProcesses) {
try { proc.kill(); } catch { }
}
if (currentEditorProcess) {
currentEditorProcess.kill();
if (currentProcess) {
currentProcess.kill();
}
saveConfig(config);
flushQueueSave();

View File

@ -162,12 +162,7 @@ const UI_TEXT_DE = {
noResultsText: 'Dieser Streamer hat keine VODs.',
untitled: 'Unbenanntes VOD',
views: 'Aufrufe',
addQueue: '+ Warteschlange',
filterPlaceholder: 'Nach Titel filtern... (Strg+F)',
filterClearTitle: 'Filter loeschen (Esc)',
filterNoMatchTitle: 'Keine Treffer',
filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.',
filterMatchCount: '{shown} von {total} VODs'
addQueue: '+ Warteschlange'
},
clips: {
dialogTitle: 'Clip zuschneiden',

View File

@ -162,12 +162,7 @@ const UI_TEXT_EN = {
noResultsText: 'This streamer has no VODs.',
untitled: 'Untitled VOD',
views: 'views',
addQueue: '+ Queue',
filterPlaceholder: 'Filter by title... (Ctrl+F)',
filterClearTitle: 'Clear filter (Esc)',
filterNoMatchTitle: 'No matches',
filterNoMatchText: 'No VODs match the current filter.',
filterMatchCount: '{shown} of {total} VODs'
addQueue: '+ Queue'
},
clips: {
dialogTitle: 'Trim clip',

View File

@ -2,79 +2,6 @@ let selectStreamerRequestId = 0;
let vodRenderTaskId = 0;
const VOD_RENDER_CHUNK_SIZE = 64;
// VOD filter state — persists across renderer reloads via localStorage so the
// user's search query survives an app restart. Cleared explicitly via Esc /
// the clear button. Shared across streamers (acts like a search bar).
let lastLoadedVods: VOD[] = [];
let lastLoadedStreamer: string | null = null;
let vodFilterQuery = '';
const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter';
function loadPersistedVodFilter(): string {
try {
return localStorage.getItem(VOD_FILTER_STORAGE_KEY) ?? '';
} catch {
return '';
}
}
function persistVodFilter(query: string): void {
try { localStorage.setItem(VOD_FILTER_STORAGE_KEY, query); } catch { /* localStorage may be unavailable */ }
}
function filterVodsByQuery(vods: VOD[], query: string): VOD[] {
const q = query.trim().toLowerCase();
if (!q) return vods;
return vods.filter((vod) => (vod.title || '').toLowerCase().includes(q));
}
function updateVodFilterCount(filteredCount: number, totalCount: number): void {
const node = document.getElementById('vodFilterCount');
if (!node) return;
if (!totalCount || !vodFilterQuery.trim()) {
node.textContent = '';
return;
}
node.textContent = UI_TEXT.vods.filterMatchCount
.replace('{shown}', String(filteredCount))
.replace('{total}', String(totalCount));
}
function syncVodFilterClearButton(): void {
const btn = document.getElementById('vodFilterClearBtn') as HTMLButtonElement | null;
if (!btn) return;
btn.style.display = vodFilterQuery.trim() ? '' : 'none';
}
function onVodFilterInput(): void {
const input = byId<HTMLInputElement>('vodFilterInput');
vodFilterQuery = input.value;
persistVodFilter(vodFilterQuery);
syncVodFilterClearButton();
if (lastLoadedStreamer) {
renderVodGridFromCurrentState();
}
}
function clearVodFilter(): void {
vodFilterQuery = '';
const input = byId<HTMLInputElement>('vodFilterInput');
if (input) input.value = '';
persistVodFilter('');
syncVodFilterClearButton();
if (lastLoadedStreamer) {
renderVodGridFromCurrentState();
}
}
function focusVodFilter(): void {
const input = document.getElementById('vodFilterInput') as HTMLInputElement | null;
if (input) {
input.focus();
input.select();
}
}
function buildVodCardHtml(vod: VOD, streamer: string): string {
const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180');
const date = formatUiDate(vod.created_at);
@ -190,48 +117,9 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
renderVODs(vods, name);
}
function setVodGridEmptyState(grid: HTMLElement, title: string, text: string): void {
// Build via DOM API so the (locale-only) strings can never escape into HTML.
const wrap = document.createElement('div');
wrap.className = 'empty-state';
const h3 = document.createElement('h3');
h3.textContent = title;
const p = document.createElement('p');
p.textContent = text;
wrap.appendChild(h3);
wrap.appendChild(p);
grid.replaceChildren(wrap);
}
function renderVODs(vods: VOD[] | null | undefined, streamer: string): void {
lastLoadedVods = Array.isArray(vods) ? vods : [];
lastLoadedStreamer = streamer;
renderVodGridFromCurrentState();
}
function renderVodGridFromCurrentState(): void {
if (!lastLoadedStreamer) return;
const grid = byId('vodGrid');
const renderTaskId = ++vodRenderTaskId;
const total = lastLoadedVods.length;
if (total === 0) {
setVodGridEmptyState(grid, UI_TEXT.vods.noResultsTitle, UI_TEXT.vods.noResultsText);
updateVodFilterCount(0, 0);
return;
}
const filtered = filterVodsByQuery(lastLoadedVods, vodFilterQuery);
if (filtered.length === 0 && vodFilterQuery.trim()) {
setVodGridEmptyState(grid, UI_TEXT.vods.filterNoMatchTitle, UI_TEXT.vods.filterNoMatchText);
updateVodFilterCount(0, total);
return;
}
grid.replaceChildren();
updateVodFilterCount(filtered.length, total);
const scheduleNextChunk = (nextStartIndex: number): void => {
const delayMs = document.hidden ? 16 : 0;
@ -240,19 +128,26 @@ function renderVodGridFromCurrentState(): void {
}, delayMs);
};
if (!vods || vods.length === 0) {
grid.innerHTML = `<div class="empty-state"><h3>${UI_TEXT.vods.noResultsTitle}</h3><p>${UI_TEXT.vods.noResultsText}</p></div>`;
return;
}
grid.innerHTML = '';
const renderChunk = (startIndex: number): void => {
if (renderTaskId !== vodRenderTaskId) {
return;
}
const chunk = filtered.slice(startIndex, startIndex + VOD_RENDER_CHUNK_SIZE);
const chunk = vods.slice(startIndex, startIndex + VOD_RENDER_CHUNK_SIZE);
if (!chunk.length) {
return;
}
grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, lastLoadedStreamer || '')).join(''));
grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, streamer)).join(''));
if (startIndex + chunk.length < filtered.length) {
if (startIndex + chunk.length < vods.length) {
scheduleNextChunk(startIndex + chunk.length);
}
};

View File

@ -143,8 +143,6 @@ function applyLanguageToStaticUI(): void {
setText('updateChangelogToggle', UI_TEXT.updates.showChangelog);
setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog);
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);
setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder);
setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
const status = document.getElementById('statusText')?.textContent?.trim() || '';
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) {

View File

@ -45,13 +45,6 @@ async function init(): Promise<void> {
initQueueDragDrop();
updateDownloadButtonState();
// Restore persisted VOD filter into the input — the filter itself only
// takes effect once VODs load (renderVODs reads vodFilterQuery).
vodFilterQuery = loadPersistedVodFilter();
const vodFilterInput = document.getElementById('vodFilterInput') as HTMLInputElement | null;
if (vodFilterInput) vodFilterInput.value = vodFilterQuery;
syncVodFilterClearButton();
// Restore last active tab from previous session (default 'vods')
showTab(loadPersistedActiveTab());
@ -143,28 +136,6 @@ async function init(): Promise<void> {
e.preventDefault();
return;
}
// No modal open: if the VOD filter has focus or content, clear it.
// Otherwise let Esc bubble (e.g. blur).
if (e.target instanceof HTMLInputElement && e.target.id === 'vodFilterInput') {
if (vodFilterQuery) {
clearVodFilter();
e.preventDefault();
}
return;
}
}
// Ctrl+F (or Cmd+F): focus the VOD filter — only when on the VODs tab.
// Browser's default Ctrl+F is suppressed because Electron's renderer
// doesn't have a native find bar anyway.
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'f' || e.key === 'F')) {
const onVodsTab = document.getElementById('vodsTab')?.classList.contains('active');
if (onVodsTab) {
e.preventDefault();
focusVodFilter();
return;
}
}
// Skip rest if user is typing in an input field