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>
This commit is contained in:
xRangerDE 2026-05-03 15:43:16 +02:00
parent 31e6671e65
commit 23d0dd5829
7 changed files with 194 additions and 12 deletions

View File

@ -2,6 +2,37 @@
Dated entries from improvement cycles. Newest at top. 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 ## 2026-05-03 — Cycle 2: release pipeline + defensive parsing
Three independent improvements landed this cycle. Three independent improvements landed this cycle.

View File

@ -239,6 +239,11 @@
<div class="content"> <div class="content">
<!-- VODs Tab --> <!-- VODs Tab -->
<div class="tab-content active" id="vodsTab"> <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="vod-grid" id="vodGrid">
<div class="empty-state"> <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> <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

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

View File

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

View File

@ -2,6 +2,79 @@ let selectStreamerRequestId = 0;
let vodRenderTaskId = 0; let vodRenderTaskId = 0;
const VOD_RENDER_CHUNK_SIZE = 64; 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 { function buildVodCardHtml(vod: VOD, streamer: string): string {
const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180'); const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180');
const date = formatUiDate(vod.created_at); const date = formatUiDate(vod.created_at);
@ -117,9 +190,48 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
renderVODs(vods, name); 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 { 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 grid = byId('vodGrid');
const renderTaskId = ++vodRenderTaskId; 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 scheduleNextChunk = (nextStartIndex: number): void => {
const delayMs = document.hidden ? 16 : 0; const delayMs = document.hidden ? 16 : 0;
@ -128,26 +240,19 @@ function renderVODs(vods: VOD[] | null | undefined, streamer: string): void {
}, delayMs); }, 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 => { const renderChunk = (startIndex: number): void => {
if (renderTaskId !== vodRenderTaskId) { if (renderTaskId !== vodRenderTaskId) {
return; return;
} }
const chunk = vods.slice(startIndex, startIndex + VOD_RENDER_CHUNK_SIZE); const chunk = filtered.slice(startIndex, startIndex + VOD_RENDER_CHUNK_SIZE);
if (!chunk.length) { if (!chunk.length) {
return; 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); scheduleNextChunk(startIndex + chunk.length);
} }
}; };

View File

@ -143,6 +143,8 @@ function applyLanguageToStaticUI(): void {
setText('updateChangelogToggle', UI_TEXT.updates.showChangelog); setText('updateChangelogToggle', UI_TEXT.updates.showChangelog);
setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog); setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog);
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder); 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() || ''; const status = document.getElementById('statusText')?.textContent?.trim() || '';
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) { 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(); initQueueDragDrop();
updateDownloadButtonState(); 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') // Restore last active tab from previous session (default 'vods')
showTab(loadPersistedActiveTab()); showTab(loadPersistedActiveTab());
@ -136,6 +143,28 @@ async function init(): Promise<void> {
e.preventDefault(); e.preventDefault();
return; 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 // Skip rest if user is typing in an input field