Compare commits
No commits in common. "81a1f914b4ceba6965493fa876ea37a45684522f" and "9d57c03e74997ff2452fcbe69f9f34370eb439bf" have entirely different histories.
81a1f914b4
...
9d57c03e74
@ -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
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
114
src/main.ts
114
src/main.ts
@ -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();
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user