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