Compare commits

..

3 Commits

Author SHA1 Message Date
xRangerDE
81a1f914b4 release: 4.5.10 clip hardening, VOD filter, editor proc decoupling
- download-clip: sanitize broadcaster name + title, ensure unique
  filename, post-download size + integrity check, track in
  activeClipProcesses so window-close cleans up
- VOD list: persistent filter input with Ctrl+F focus, Esc clear,
  match counter (DE + EN strings)
- currentProcess split into currentEditorProcess (cutter/merger/
  splitter only) so cancel-download no longer accidentally kills a
  separate video cut

See docs/IMPROVEMENT_LOG.md (Cycle 3, 2026-05-03) for the dated
rationale and regression run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:43:30 +02:00
xRangerDE
23d0dd5829 ui: VOD list filter with persistence + Ctrl+F focus + Esc clear
Filter row above the VOD grid lets the user search the loaded archive
by title. Concrete user pain: streamers commonly have hundreds of VODs
and the current UI only supported scrolling.

- vodFilterInput / vodFilterClearBtn / vodFilterCount in index.html
- localized placeholder + clear-button title (DE + EN)
- vodFilterQuery state persisted to localStorage as
  twitch-vod-manager:vod-filter so the search bar survives reloads
- renderVODs split: it now caches lastLoadedVods + lastLoadedStreamer
  and delegates to renderVodGridFromCurrentState which applies
  filterVodsByQuery on every input event (no re-fetch)
- empty-state DOM is now built with createElement + textContent (via
  setVodGridEmptyState) instead of an innerHTML template, even for
  locale-only strings — defence in depth
- keyboard: Ctrl/Cmd+F focuses the filter when the VODs tab is active
  (Electron has no native find bar, so the default is suppressed). Esc
  clears the filter when the input has focus and content. Esc still
  closes modals first if any are open.

docs/IMPROVEMENT_LOG.md: Cycle 3 dated section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:43:16 +02:00
xRangerDE
31e6671e65 harden: download-clip integrity + cancel tracking + decouple editor procs
Two server-side fixes for separate clip/queue/editor crosstalk paths.

1. download-clip IPC was unsafe in three ways:
   - reported success: true on exit code 0 even with empty files
     (Twitch sometimes returns a manifest with no segments)
   - passed clipInfo.broadcaster_name straight to path.join, so unicode
     / spaces / punctuation in display names produced odd directory
     layouts on Windows
   - the spawned streamlink process was tracked nowhere, so window
     close orphaned it
   Now: sanitize broadcaster_name + title, ensureUniqueFilename so
   re-downloads do not overwrite, post-download size + integrity check
   (16 KiB floor + ffprobe via validateDownloadedFileIntegrity), proc
   tracked in activeClipProcesses and killed on window-all-closed.

2. currentProcess (a single ChildProcess global) was shared between
   cutter/merger/splitter and downloadVODPart. The real bug: while a
   queue download was running and the user kicked off a video cut,
   pressing the queue's "Stop" button iterated activeDownloads (fine)
   AND called currentProcess.kill() — which by then pointed at the
   cutter ffmpeg, killing an unrelated cut.
   Renamed to currentEditorProcess, confined to the editor pipeline.
   downloadVODPart no longer touches it. The fallback kill calls in
   remove-from-queue / pause-download / cancel-download are gone — the
   activeDownloads loop above each was already authoritative.
   window-all-closed now also kills activeClipProcesses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:43:01 +02:00
10 changed files with 275 additions and 51 deletions

View File

@ -2,6 +2,37 @@
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.9",
"version": "4.5.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
"version": "4.5.9",
"version": "4.5.10",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",

View File

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

View File

@ -239,6 +239,11 @@
<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,7 +530,11 @@ let downloadQueue: QueueItem[] = loadQueue();
let queueIdCounter = 0;
let lastQueueBroadcastFingerprint = '';
let isDownloading = false;
let currentProcess: ChildProcess | null = null;
// 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 currentDownloadCancelled = false;
let pauseRequested = false;
let activeQueueItemId: string | null = null;
@ -2066,7 +2070,7 @@ async function cutVideo(
return await new Promise((resolve) => {
const proc = spawn(ffmpeg, args, { windowsHide: true });
currentProcess = proc;
currentEditorProcess = proc;
proc.stdout?.on('data', (data) => {
const line = data.toString();
@ -2079,7 +2083,7 @@ async function cutVideo(
});
proc.on('close', (code) => {
currentProcess = null;
currentEditorProcess = null;
if (code === 0 && fs.existsSync(outputFile)) {
const stats = fs.statSync(outputFile);
if (stats.size <= 256) {
@ -2094,7 +2098,7 @@ async function cutVideo(
});
proc.on('error', () => {
currentProcess = null;
currentEditorProcess = null;
resolve(false);
});
});
@ -2208,7 +2212,7 @@ async function mergeVideos(
return await new Promise((resolve) => {
const proc = spawn(ffmpeg, args, { windowsHide: true });
currentProcess = proc;
currentEditorProcess = proc;
proc.stdout?.on('data', (data) => {
const line = data.toString();
@ -2224,7 +2228,7 @@ async function mergeVideos(
});
proc.on('close', (code) => {
currentProcess = null;
currentEditorProcess = null;
const success = code === 0 && fs.existsSync(outputFile);
if (success) {
onProgress(100);
@ -2233,7 +2237,7 @@ async function mergeVideos(
});
proc.on('error', () => {
currentProcess = null;
currentEditorProcess = null;
resolve(false);
});
});
@ -2303,15 +2307,15 @@ async function splitMergedFile(
const success = await new Promise<boolean>((resolve) => {
const proc = spawn(ffmpeg, args, { windowsHide: true });
currentProcess = proc;
currentEditorProcess = proc;
proc.on('close', (code) => {
currentProcess = null;
currentEditorProcess = null;
resolve(code === 0 && fs.existsSync(outputFile));
});
proc.on('error', () => {
currentProcess = null;
currentEditorProcess = null;
resolve(false);
});
});
@ -2358,9 +2362,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);
@ -2451,7 +2455,6 @@ function downloadVODPart(
proc.on('close', async (code) => {
clearInterval(progressInterval);
currentProcess = null;
activeDownloads.delete(itemId);
if (currentDownloadCancelled || cancelledItemIds.has(itemId)) {
@ -2496,7 +2499,6 @@ 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')
@ -3582,17 +3584,12 @@ 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;
@ -3761,16 +3758,14 @@ ipcMain.handle('pause-download', () => {
pauseRequested = true;
currentDownloadCancelled = true;
// Kill all active download processes
// Kill queue downloads only — cutter/merger/splitter use currentEditorProcess
// and aren't affected by pause-download.
for (const [id, tracking] of activeDownloads) {
cancelledItemIds.add(id);
if (tracking.process) {
tracking.process.kill();
}
}
if (currentProcess) {
currentProcess.kill();
}
return true;
});
@ -3778,16 +3773,13 @@ ipcMain.handle('cancel-download', () => {
isDownloading = false;
pauseRequested = false;
currentDownloadCancelled = true;
// Kill all active download processes
// Kill queue downloads only — see pause-download note above.
for (const [id, tracking] of activeDownloads) {
cancelledItemIds.add(id);
if (tracking.process) {
tracking.process.kill();
}
}
if (currentProcess) {
currentProcess.kill();
}
return true;
});
@ -3858,6 +3850,11 @@ 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_-]+)/);
@ -3870,7 +3867,14 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
const clipInfo = await getClipInfo(clipId);
if (!clipInfo) return { success: false, error: 'Clip nicht gefunden' };
const folder = path.join(config.download_path, 'Clips', clipInfo.broadcaster_name);
// 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);
fs.mkdirSync(folder, { recursive: true });
const clipDiskCheck = ensureDiskSpace(folder, 128 * 1024 * 1024, 'Clip-Download');
@ -3878,10 +3882,14 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
return { success: false, error: clipDiskCheck.error || 'Zu wenig Speicherplatz.' };
}
const safeTitle = clipInfo.title.replace(/[^a-zA-Z0-9_\- ]/g, '').substring(0, 50);
const filename = path.join(folder, `${safeTitle}.mp4`);
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);
return new Promise((resolve) => {
return new Promise<{ success: boolean; error?: string; filename?: string }>((resolve) => {
const streamlinkCmd = getStreamlinkCommand();
const proc = spawn(streamlinkCmd.command, [
...streamlinkCmd.prefixArgs,
@ -3891,15 +3899,45 @@ 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) => {
if (code === 0 && fs.existsSync(filename)) {
resolve({ success: true, filename });
} else {
activeClipProcesses.delete(clipId);
releaseClaimedFilenamesForItem(clipId);
if (code !== 0 || !fs.existsSync(filename)) {
appendDebugLog('clip-download-failed', { clipId, code });
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' });
});
});
@ -4018,14 +4056,18 @@ app.on('window-all-closed', () => {
stopDebugLogFlushTimer(true);
stopAutoUpdatePolling();
// Kill all active download processes
// Kill all active children: queue downloads, standalone clip downloads,
// and any in-flight cutter/merger/splitter ffmpeg.
for (const [, tracking] of activeDownloads) {
if (tracking.process) {
tracking.process.kill();
}
}
if (currentProcess) {
currentProcess.kill();
for (const [, proc] of activeClipProcesses) {
try { proc.kill(); } catch { }
}
if (currentEditorProcess) {
currentEditorProcess.kill();
}
saveConfig(config);
flushQueueSave();

View File

@ -162,7 +162,12 @@ const UI_TEXT_DE = {
noResultsText: 'Dieser Streamer hat keine VODs.',
untitled: 'Unbenanntes VOD',
views: 'Aufrufe',
addQueue: '+ Warteschlange'
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'
},
clips: {
dialogTitle: 'Clip zuschneiden',

View File

@ -162,7 +162,12 @@ const UI_TEXT_EN = {
noResultsText: 'This streamer has no VODs.',
untitled: 'Untitled VOD',
views: 'views',
addQueue: '+ Queue'
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'
},
clips: {
dialogTitle: 'Trim clip',

View File

@ -2,6 +2,79 @@ 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);
@ -117,9 +190,48 @@ 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;
@ -128,26 +240,19 @@ function renderVODs(vods: VOD[] | null | undefined, streamer: string): 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 = vods.slice(startIndex, startIndex + VOD_RENDER_CHUNK_SIZE);
const chunk = filtered.slice(startIndex, startIndex + VOD_RENDER_CHUNK_SIZE);
if (!chunk.length) {
return;
}
grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, streamer)).join(''));
grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, lastLoadedStreamer || '')).join(''));
if (startIndex + chunk.length < vods.length) {
if (startIndex + chunk.length < filtered.length) {
scheduleNextChunk(startIndex + chunk.length);
}
};

View File

@ -143,6 +143,8 @@ 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,6 +45,13 @@ 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());
@ -136,6 +143,28 @@ 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