diff --git a/src/index.html b/src/index.html
index 56e0bc7..1a46749 100644
--- a/src/index.html
+++ b/src/index.html
@@ -208,7 +208,11 @@
-
Streamer
+
+ Streamer
+
+
+
diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts
index a110e70..504c346 100644
--- a/src/renderer-locale-de.ts
+++ b/src/renderer-locale-de.ts
@@ -69,6 +69,12 @@ const UI_TEXT_DE = {
persistQueueLabel: 'Queue zwischen App-Starts speichern',
autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen',
autoResumeQueueHint: 'Wenn aktiv und die gespeicherte Queue noch ausstehende Eintraege hat, starten Downloads ~5 Sekunden nach dem Fensteroeffnen. Deaktivieren = Start-Klick noetig.',
+ streamerSectionTitle: 'Streamer',
+ streamerListFilterPlaceholder: 'Filtern...',
+ streamerBulkRemoveTitle: 'Alle entfernen (oder gefilterte)',
+ streamerBulkRemoveAll: 'Alle {count} Streamer aus der Liste entfernen?',
+ streamerBulkRemoveFiltered: 'Die {count} passenden Streamer aus der Liste entfernen?',
+ cutterDropHint: 'Video-Datei hierher ziehen zum Laden.',
metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)',
filenameTemplatesTitle: 'Dateinamen-Templates',
vodTemplateLabel: 'VOD-Template',
diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts
index 1e9ee0b..ddae00c 100644
--- a/src/renderer-locale-en.ts
+++ b/src/renderer-locale-en.ts
@@ -69,6 +69,12 @@ const UI_TEXT_EN = {
persistQueueLabel: 'Keep queue between app restarts',
autoResumeQueueLabel: 'Auto-resume the queue on startup',
autoResumeQueueHint: 'When enabled and the persisted queue has pending entries, downloads kick off ~5 seconds after the window opens. Disable to require an explicit Start click.',
+ streamerSectionTitle: 'Streamer',
+ streamerListFilterPlaceholder: 'Filter...',
+ streamerBulkRemoveTitle: 'Remove all (or filtered)',
+ streamerBulkRemoveAll: 'Remove all {count} streamers from the list?',
+ streamerBulkRemoveFiltered: 'Remove the {count} matching streamer(s) from the list?',
+ cutterDropHint: 'Drop a video file here to load it.',
metadataCacheMinutesLabel: 'Metadata Cache (Minutes)',
filenameTemplatesTitle: 'Filename Templates',
vodTemplateLabel: 'VOD Template',
diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts
index beaff1f..97e416b 100644
--- a/src/renderer-streamers.ts
+++ b/src/renderer-streamers.ts
@@ -269,11 +269,136 @@ function readVodCardContext(card: HTMLElement | null): VodCardContext | null {
let streamerDragInitialized = false;
let draggedStreamerName: string | null = null;
+// Streamer list filter — only kicks in once the user has more than a handful
+// of streamers. The input stays display:none below the threshold to avoid
+// visual clutter for normal users with 1-3 streamers.
+const STREAMER_FILTER_THRESHOLD = 6;
+let streamerListFilterQuery = '';
+
+// Per-streamer VOD scroll position. When the user clicks back to a streamer
+// they've already viewed, restore where they were instead of jumping to top.
+// Lives in localStorage so it survives reloads.
+const VOD_SCROLL_POSITIONS_KEY = 'twitch-vod-manager:vod-scroll-positions';
+let vodScrollPositions: Record = {};
+let pendingScrollRestore: { streamer: string; y: number } | null = null;
+
+function loadVodScrollPositions(): void {
+ try {
+ const raw = localStorage.getItem(VOD_SCROLL_POSITIONS_KEY);
+ if (!raw) return;
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ const cleaned: Record = {};
+ for (const [k, v] of Object.entries(parsed)) {
+ if (typeof v === 'number' && Number.isFinite(v) && v >= 0) cleaned[k] = v;
+ }
+ vodScrollPositions = cleaned;
+ }
+ } catch { /* localStorage unavailable */ }
+}
+
+function persistVodScrollPositions(): void {
+ try {
+ // Cap to last 32 entries to bound storage size.
+ const entries = Object.entries(vodScrollPositions);
+ if (entries.length > 32) {
+ vodScrollPositions = Object.fromEntries(entries.slice(entries.length - 32));
+ }
+ localStorage.setItem(VOD_SCROLL_POSITIONS_KEY, JSON.stringify(vodScrollPositions));
+ } catch { /* ignore */ }
+}
+
+function rememberCurrentVodScroll(): void {
+ if (!lastLoadedStreamer) return;
+ const grid = document.getElementById('vodGrid');
+ if (!grid) return;
+ // Find the scroll container — vodGrid sits inside a scrollable .content
+ const scrollable = (grid.closest('.content') as HTMLElement | null) || grid;
+ const y = scrollable.scrollTop;
+ if (Number.isFinite(y) && y >= 0) {
+ vodScrollPositions[lastLoadedStreamer] = y;
+ persistVodScrollPositions();
+ }
+}
+
+let vodScrollSaveTimer: number | null = null;
+
+function initVodScrollTracking(): void {
+ const grid = document.getElementById('vodGrid');
+ if (!grid) return;
+ const scrollable = (grid.closest('.content') as HTMLElement | null) || grid;
+ scrollable.addEventListener('scroll', () => {
+ if (vodScrollSaveTimer) window.clearTimeout(vodScrollSaveTimer);
+ vodScrollSaveTimer = window.setTimeout(() => {
+ vodScrollSaveTimer = null;
+ rememberCurrentVodScroll();
+ }, 250);
+ }, { passive: true });
+}
+
+function initCutterDragDrop(): void {
+ const tab = document.getElementById('cutterTab');
+ if (!tab) return;
+
+ let dragOverCount = 0;
+ const setDragVisual = (active: boolean): void => {
+ const preview = document.getElementById('cutterPreview');
+ if (preview) preview.classList.toggle('drag-over', active);
+ };
+
+ tab.addEventListener('dragenter', (e) => {
+ if (!e.dataTransfer || !Array.from(e.dataTransfer.types).includes('Files')) return;
+ e.preventDefault();
+ dragOverCount++;
+ setDragVisual(true);
+ });
+ tab.addEventListener('dragover', (e) => {
+ if (!e.dataTransfer || !Array.from(e.dataTransfer.types).includes('Files')) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'copy';
+ });
+ tab.addEventListener('dragleave', () => {
+ dragOverCount = Math.max(0, dragOverCount - 1);
+ if (dragOverCount === 0) setDragVisual(false);
+ });
+ tab.addEventListener('drop', async (e) => {
+ if (!e.dataTransfer) return;
+ e.preventDefault();
+ dragOverCount = 0;
+ setDragVisual(false);
+
+ const files = Array.from(e.dataTransfer.files || []);
+ if (files.length === 0) return;
+ // First video-ish file wins
+ const allowed = /\.(mp4|mkv|ts|mov|avi)$/i;
+ const file = files.find((f) => allowed.test(f.name)) || files[0];
+ // Electron extends File with .path even with contextIsolation:true
+ const filePath = (file as unknown as { path?: string }).path || '';
+ if (!filePath) return;
+
+ const loader = (window as unknown as { loadCutterFromPath?: (p: string) => Promise }).loadCutterFromPath;
+ if (typeof loader === 'function') {
+ await loader(filePath);
+ }
+ });
+}
+
function renderStreamers(): void {
const list = byId('streamerList');
list.replaceChildren();
- (config.streamers ?? []).forEach((streamer: string) => {
+ const all = (config.streamers ?? []) as string[];
+ const filterInput = document.getElementById('streamerListFilter') as HTMLInputElement | null;
+ const sectionTitle = document.getElementById('streamerSectionTitle');
+ const showFilter = all.length >= STREAMER_FILTER_THRESHOLD;
+ if (filterInput) filterInput.style.display = showFilter ? '' : 'none';
+ // Compact title margin when filter is shown — avoids double gap.
+ if (sectionTitle) sectionTitle.style.marginBottom = showFilter ? '4px' : '';
+
+ const q = (streamerListFilterQuery || '').trim().toLowerCase();
+ const visible = q ? all.filter((s) => s.toLowerCase().includes(q)) : all;
+
+ visible.forEach((streamer: string) => {
const item = document.createElement('div');
item.className = 'streamer-item' + (currentStreamer === streamer ? ' active' : '');
item.setAttribute('draggable', 'true');
@@ -298,9 +423,43 @@ function renderStreamers(): void {
list.appendChild(item);
});
+ // Reveal bulk-remove button only above the filter threshold.
+ const bulkBtn = document.getElementById('btnStreamerBulkRemove') as HTMLButtonElement | null;
+ if (bulkBtn) bulkBtn.style.display = all.length >= STREAMER_FILTER_THRESHOLD ? '' : 'none';
+
initStreamerDragDrop();
}
+function onStreamerListFilterChange(): void {
+ const input = byId('streamerListFilter');
+ streamerListFilterQuery = input.value;
+ renderStreamers();
+}
+
+async function bulkRemoveStreamers(): Promise {
+ const all = (config.streamers ?? []) as string[];
+ if (all.length === 0) return;
+ const q = (streamerListFilterQuery || '').trim().toLowerCase();
+ // If a filter is active, target only the matching streamers; else
+ // require explicit confirmation to clear the entire list.
+ const targets = q ? all.filter((s) => s.toLowerCase().includes(q)) : all;
+ if (targets.length === 0) return;
+
+ const messageTemplate = q ? UI_TEXT.static.streamerBulkRemoveFiltered : UI_TEXT.static.streamerBulkRemoveAll;
+ if (!confirm(messageTemplate.replace('{count}', String(targets.length)))) return;
+
+ const remaining = all.filter((s) => !targets.includes(s));
+ config.streamers = remaining;
+ config = await window.api.saveConfig({ streamers: remaining });
+ if (currentStreamer && targets.includes(currentStreamer)) {
+ currentStreamer = null;
+ }
+ streamerListFilterQuery = '';
+ const input = document.getElementById('streamerListFilter') as HTMLInputElement | null;
+ if (input) input.value = '';
+ renderStreamers();
+}
+
function initStreamerDragDrop(): void {
if (streamerDragInitialized) return;
streamerDragInitialized = true;
@@ -402,10 +561,17 @@ async function removeStreamer(name: string): Promise {
}
async function selectStreamer(name: string, forceRefresh = false): Promise {
+ // Save where we were on the OLD streamer before navigating away.
+ rememberCurrentVodScroll();
+
const requestId = ++selectStreamerRequestId;
const isStaleRequest = () => requestId !== selectStreamerRequestId || currentStreamer !== name;
currentStreamer = name;
+ // Schedule a scroll-restore once the VOD grid renders. The actual
+ // restore runs after renderVODs replaces the grid.
+ const savedY = vodScrollPositions[name];
+ pendingScrollRestore = (typeof savedY === 'number' && savedY > 0) ? { streamer: name, y: savedY } : null;
renderStreamers();
byId('pageTitle').textContent = name;
@@ -463,6 +629,19 @@ function renderVODs(vods: VOD[] | null | undefined, streamer: string): void {
lastLoadedStreamer = streamer;
initVodGridSelectionDelegation();
renderVodGridFromCurrentState();
+
+ // After the first chunk lands the grid has size, so scroll-restore can
+ // succeed. Use a small delay to let chunked rendering paint.
+ if (pendingScrollRestore && pendingScrollRestore.streamer === streamer) {
+ const target = pendingScrollRestore;
+ pendingScrollRestore = null;
+ window.setTimeout(() => {
+ const grid = document.getElementById('vodGrid');
+ if (!grid) return;
+ const scrollable = (grid.closest('.content') as HTMLElement | null) || grid;
+ scrollable.scrollTop = target.y;
+ }, 80);
+ }
}
function initVodGridSelectionDelegation(): void {
diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts
index 5d50db8..f65d90d 100644
--- a/src/renderer-texts.ts
+++ b/src/renderer-texts.ts
@@ -117,6 +117,9 @@ function applyLanguageToStaticUI(): void {
setText('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueLabel);
setTitle('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueHint);
setTitle('autoResumeQueueToggle', UI_TEXT.static.autoResumeQueueHint);
+ setText('streamerSectionTitleText', UI_TEXT.static.streamerSectionTitle);
+ setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder);
+ setTitle('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel);
setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle);
setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel);
diff --git a/src/renderer.ts b/src/renderer.ts
index 59cba9a..786eb76 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -64,6 +64,11 @@ async function init(): Promise {
vodHideDownloaded = loadPersistedHideDownloaded();
syncVodHideDownloadedToggle();
+ // Restore per-streamer VOD scroll positions from prior sessions.
+ loadVodScrollPositions();
+ initVodScrollTracking();
+ initCutterDragDrop();
+
// Restore last active tab from previous session (default 'vods')
showTab(loadPersistedActiveTab());
@@ -1079,11 +1084,8 @@ async function downloadClip(): Promise {
status.className = 'clip-status error';
}
-async function selectCutterVideo(): Promise {
- const filePath = await window.api.selectVideoFile();
- if (!filePath) {
- return;
- }
+async function loadCutterFromPath(filePath: string): Promise {
+ if (!filePath) return;
cutterFile = filePath;
byId('cutterFilePath').value = filePath;
@@ -1114,6 +1116,12 @@ async function selectCutterVideo(): Promise {
await updatePreview(0);
}
+async function selectCutterVideo(): Promise {
+ const filePath = await window.api.selectVideoFile();
+ if (!filePath) return;
+ await loadCutterFromPath(filePath);
+}
+
function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
diff --git a/src/styles.css b/src/styles.css
index b8a4093..38f61ce 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -615,6 +615,12 @@ body {
opacity: 0.6;
}
+#cutterPreview.drag-over {
+ outline: 2px dashed var(--accent);
+ outline-offset: -8px;
+ background: rgba(145, 70, 255, 0.08);
+}
+
.streamer-item.dragging {
opacity: 0.4;
}