feat: streamer search/bulk-remove + cutter drag-drop + per-streamer scroll

Three Phase-10 wins.

1. Streamer-list filter + bulk-remove. Above 6 streamers (the magic
   number where the list starts to feel cluttered) a search input
   appears below the section title and a small bulk-remove "x"
   button next to it. Filter is title-substring, case-insensitive.
   Bulk-remove honours the active filter — when the input is empty
   it confirms removing the entire list, when filled it confirms
   removing only the matching subset. Used a confirm() dialog with
   the matching count interpolated into the locale string.

2. Cutter drag-and-drop. Dragging a video file from Explorer onto
   the cutter tab now loads it directly — no separate Browse click.
   Uses Electron's File.path extension on the dropped File object
   (works through contextIsolation:true). selectCutterVideo was
   refactored into loadCutterFromPath + a thin wrapper so the drop
   handler reuses the same loading logic. dragenter/dragleave count
   adds visual outline on #cutterPreview while a Files drag is over
   the tab. Falls back gracefully if the dropped file lacks .path.

3. Per-streamer VOD scroll position. Switching streamers used to
   reset scroll-to-top, painful when cycling between archives.
   vodScrollPositions Record<streamer, scrollY> persisted to
   localStorage, capped to 32 entries to bound storage. Save fires
   on a 250ms scroll-debounce timer + on every selectStreamer
   transition. Restore happens 80ms after renderVODs paints (lets
   the first chunk settle) so scrollTop has somewhere to land.

Plus: bounded the persistence table at 32 entries, locale strings
DE/EN, all wired through applyLanguageToStaticUI for live language
switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 16:03:47 +02:00
parent e5decfd851
commit b959a930af
7 changed files with 219 additions and 7 deletions

View File

@ -208,7 +208,11 @@
</div>
</nav>
<div class="section-title">Streamer</div>
<div class="section-title" id="streamerSectionTitle" style="display:flex; align-items:center; gap:6px; justify-content:space-between;">
<span id="streamerSectionTitleText">Streamer</span>
<button id="btnStreamerBulkRemove" type="button" onclick="bulkRemoveStreamers()" title="Bulk remove" style="display:none; background:transparent; border:1px solid var(--border-soft); border-radius:4px; padding:2px 8px; color:var(--text-secondary); font-size:11px; cursor:pointer;">x</button>
</div>
<input type="text" id="streamerListFilter" placeholder="Filter..." oninput="onStreamerListFilterChange()" style="display:none; width:calc(100% - 16px); margin:0 8px 8px; background:var(--bg-card); border:1px solid var(--border-soft); border-radius:4px; padding:4px 8px; color:var(--text); font-size:12px;">
<div class="streamers" id="streamerList"></div>
<div class="queue-section">

View File

@ -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',

View File

@ -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',

View File

@ -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<string, number> = {};
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<string, number> = {};
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<void> }).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<HTMLInputElement>('streamerListFilter');
streamerListFilterQuery = input.value;
renderStreamers();
}
async function bulkRemoveStreamers(): Promise<void> {
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<void> {
}
async function selectStreamer(name: string, forceRefresh = false): Promise<void> {
// 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 {

View File

@ -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);

View File

@ -64,6 +64,11 @@ async function init(): Promise<void> {
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<void> {
status.className = 'clip-status error';
}
async function selectCutterVideo(): Promise<void> {
const filePath = await window.api.selectVideoFile();
if (!filePath) {
return;
}
async function loadCutterFromPath(filePath: string): Promise<void> {
if (!filePath) return;
cutterFile = filePath;
byId<HTMLInputElement>('cutterFilePath').value = filePath;
@ -1114,6 +1116,12 @@ async function selectCutterVideo(): Promise<void> {
await updatePreview(0);
}
async function selectCutterVideo(): Promise<void> {
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);

View File

@ -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;
}