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:
parent
31e6671e65
commit
23d0dd5829
@ -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.
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user