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'; // Bulk-select state — keyed by VOD URL since URL is unique per VOD. Cleared // on streamer switch (selection is per-streamer mental model). NOT persisted // because a stale selection across reloads is more confusing than helpful. const selectedVodUrls = new Set(); let vodGridDelegationInitialized = false; // Hide-downloaded toggle: when enabled, the VOD grid skips entries whose // vod.id is in config.downloaded_vod_ids. Persisted to localStorage so a // power user who keeps it enabled doesn't have to re-flip it every launch. const VOD_HIDE_DOWNLOADED_STORAGE_KEY = 'twitch-vod-manager:vod-hide-downloaded'; let vodHideDownloaded = false; function loadPersistedHideDownloaded(): boolean { try { return localStorage.getItem(VOD_HIDE_DOWNLOADED_STORAGE_KEY) === '1'; } catch { return false; } } function persistHideDownloaded(value: boolean): void { try { localStorage.setItem(VOD_HIDE_DOWNLOADED_STORAGE_KEY, value ? '1' : '0'); } catch { /* ignore */ } } function onVodHideDownloadedChange(): void { const cb = byId('vodHideDownloadedToggle'); vodHideDownloaded = cb.checked; persistHideDownloaded(vodHideDownloaded); if (lastLoadedStreamer) renderVodGridFromCurrentState(); } function syncVodHideDownloadedToggle(): void { const cb = document.getElementById('vodHideDownloadedToggle') as HTMLInputElement | null; if (cb) cb.checked = vodHideDownloaded; } type VodSortKey = 'date_desc' | 'date_asc' | 'views_desc' | 'duration_desc' | 'duration_asc'; const VALID_VOD_SORTS: ReadonlyArray = ['date_desc', 'date_asc', 'views_desc', 'duration_desc', 'duration_asc']; const VOD_SORT_STORAGE_KEY = 'twitch-vod-manager:vod-sort'; let vodSortKey: VodSortKey = 'date_desc'; function loadPersistedVodSort(): VodSortKey { try { const stored = localStorage.getItem(VOD_SORT_STORAGE_KEY); if (stored && (VALID_VOD_SORTS as readonly string[]).includes(stored)) { return stored as VodSortKey; } } catch { /* localStorage may be unavailable */ } return 'date_desc'; } function persistVodSort(key: VodSortKey): void { try { localStorage.setItem(VOD_SORT_STORAGE_KEY, key); } catch { /* localStorage may be unavailable */ } } function vodDurationToSeconds(durationStr: string): number { let total = 0; const h = durationStr.match(/(\d+)h/); const m = durationStr.match(/(\d+)m/); const s = durationStr.match(/(\d+)s/); if (h) total += parseInt(h[1], 10) * 3600; if (m) total += parseInt(m[1], 10) * 60; if (s) total += parseInt(s[1], 10); return total; } function sortVods(vods: VOD[], key: VodSortKey): VOD[] { const sorted = [...vods]; const ts = (s: string): number => { const n = new Date(s).getTime(); return Number.isFinite(n) ? n : 0; }; switch (key) { case 'date_desc': sorted.sort((a, b) => ts(b.created_at) - ts(a.created_at)); break; case 'date_asc': sorted.sort((a, b) => ts(a.created_at) - ts(b.created_at)); break; case 'views_desc': sorted.sort((a, b) => (b.view_count || 0) - (a.view_count || 0)); break; case 'duration_desc': sorted.sort((a, b) => vodDurationToSeconds(b.duration) - vodDurationToSeconds(a.duration)); break; case 'duration_asc': sorted.sort((a, b) => vodDurationToSeconds(a.duration) - vodDurationToSeconds(b.duration)); break; } return sorted; } function onVodSortChange(): void { const select = byId('vodSortSelect'); const value = select.value; if ((VALID_VOD_SORTS as readonly string[]).includes(value)) { vodSortKey = value as VodSortKey; persistVodSort(vodSortKey); if (lastLoadedStreamer) { renderVodGridFromCurrentState(); } } } function syncVodSortSelect(): void { const select = document.getElementById('vodSortSelect') as HTMLSelectElement | null; if (select) select.value = vodSortKey; } function refreshVodSortSelectLabels(): void { const select = document.getElementById('vodSortSelect') as HTMLSelectElement | null; if (!select) return; const labels: Record = { date_desc: UI_TEXT.vods.sortDateDesc, date_asc: UI_TEXT.vods.sortDateAsc, views_desc: UI_TEXT.vods.sortViewsDesc, duration_desc: UI_TEXT.vods.sortDurationDesc, duration_asc: UI_TEXT.vods.sortDurationAsc }; for (const opt of Array.from(select.options)) { const k = opt.value as VodSortKey; if (labels[k]) opt.textContent = labels[k]; } } 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('vodFilterInput'); vodFilterQuery = input.value; persistVodFilter(vodFilterQuery); syncVodFilterClearButton(); if (lastLoadedStreamer) { renderVodGridFromCurrentState(); } } function clearVodFilter(): void { vodFilterQuery = ''; const input = byId('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, downloadedIds?: Set): string { const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180'); const date = formatUiDate(vod.created_at); const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '"'); const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled); const safeUrlAttr = escapeHtml(vod.url); const isChecked = selectedVodUrls.has(vod.url); const isAlreadyDownloaded = downloadedIds ? downloadedIds.has(vod.id) : false; const downloadedBadge = isAlreadyDownloaded ? `
` : ''; return `
${downloadedBadge}
${safeDisplayTitle}
${date} ${vod.duration} ${formatUiNumber(vod.view_count)} ${UI_TEXT.vods.views}
`; } let streamerDragInitialized = false; let draggedStreamerName: string | null = null; function renderStreamers(): void { const list = byId('streamerList'); list.replaceChildren(); (config.streamers ?? []).forEach((streamer: string) => { const item = document.createElement('div'); item.className = 'streamer-item' + (currentStreamer === streamer ? ' active' : ''); item.setAttribute('draggable', 'true'); item.dataset.streamerName = streamer; const nameSpan = document.createElement('span'); nameSpan.textContent = streamer; const removeSpan = document.createElement('span'); removeSpan.className = 'remove'; removeSpan.textContent = 'x'; removeSpan.addEventListener('click', (e) => { e.stopPropagation(); void removeStreamer(streamer); }); item.append(nameSpan, removeSpan); item.addEventListener('click', () => { // Skip click if drag was just released — drop fires after dragend if (draggedStreamerName === streamer) return; void selectStreamer(streamer); }); list.appendChild(item); }); initStreamerDragDrop(); } function initStreamerDragDrop(): void { if (streamerDragInitialized) return; streamerDragInitialized = true; const list = byId('streamerList'); list.addEventListener('dragstart', (e: DragEvent) => { const target = e.target as HTMLElement; const item = target.closest('.streamer-item') as HTMLElement | null; if (!item || !item.dataset.streamerName) return; draggedStreamerName = item.dataset.streamerName; item.classList.add('dragging'); if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; // Some browsers refuse the drag without setData e.dataTransfer.setData('text/plain', draggedStreamerName); } }); list.addEventListener('dragover', (e: DragEvent) => { if (!draggedStreamerName) return; e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; }); list.addEventListener('drop', async (e: DragEvent) => { e.preventDefault(); const target = (e.target as HTMLElement).closest('.streamer-item') as HTMLElement | null; if (!target || !draggedStreamerName) return; const targetName = target.dataset.streamerName; if (!targetName || targetName === draggedStreamerName) return; const streamers = [...(config.streamers ?? [])]; const fromIdx = streamers.indexOf(draggedStreamerName); const toIdx = streamers.indexOf(targetName); if (fromIdx < 0 || toIdx < 0) return; const [moved] = streamers.splice(fromIdx, 1); streamers.splice(toIdx, 0, moved); config.streamers = streamers; renderStreamers(); config = await window.api.saveConfig({ streamers }); }); list.addEventListener('dragend', () => { document.querySelectorAll('.streamer-item.dragging').forEach((el) => el.classList.remove('dragging')); // Defer clearing draggedStreamerName so the click handler that fires // after dragend can suppress the spurious select. const wasDragging = draggedStreamerName; window.setTimeout(() => { if (draggedStreamerName === wasDragging) draggedStreamerName = null; }, 50); }); } async function addStreamer(): Promise { const input = byId('newStreamer'); const name = input.value.trim().toLowerCase(); if (!name) { return; } // Twitch usernames: 4-25 characters, alphanumeric + underscore. // Catch typos / invalid input before it hits the API and silently // returns "streamer not found". if (!/^[a-zA-Z0-9_]{4,25}$/.test(name)) { showAppToast(UI_TEXT.static.streamerInvalid, 'warn'); return; } if ((config.streamers ?? []).includes(name)) { return; } config.streamers = [...(config.streamers ?? []), name]; config = await window.api.saveConfig({ streamers: config.streamers }); input.value = ''; renderStreamers(); await selectStreamer(name); } async function removeStreamer(name: string): Promise { config.streamers = (config.streamers ?? []).filter((s: string) => s !== name); config = await window.api.saveConfig({ streamers: config.streamers }); renderStreamers(); if (currentStreamer !== name) { return; } currentStreamer = null; byId('vodGrid').innerHTML = `

${UI_TEXT.vods.noneTitle}

${UI_TEXT.vods.noneText}

`; } async function selectStreamer(name: string, forceRefresh = false): Promise { const requestId = ++selectStreamerRequestId; const isStaleRequest = () => requestId !== selectStreamerRequestId || currentStreamer !== name; currentStreamer = name; renderStreamers(); byId('pageTitle').textContent = name; if (!isConnected) { await connect(); if (isStaleRequest()) { return; } } if (!isConnected) { updateStatus(UI_TEXT.status.noLogin, false); } byId('vodGrid').innerHTML = `

${UI_TEXT.vods.loading}

`; const userId = await window.api.getUserId(name); if (isStaleRequest()) { return; } if (!userId) { byId('vodGrid').innerHTML = `

${UI_TEXT.vods.notFound}

`; return; } const vods = await window.api.getVODs(userId, forceRefresh); if (isStaleRequest()) { return; } 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 { // Clear bulk-selection on streamer switch — selection is per-streamer if (lastLoadedStreamer && lastLoadedStreamer !== streamer && selectedVodUrls.size > 0) { selectedVodUrls.clear(); updateVodBulkBar(); } lastLoadedVods = Array.isArray(vods) ? vods : []; lastLoadedStreamer = streamer; initVodGridSelectionDelegation(); renderVodGridFromCurrentState(); } function initVodGridSelectionDelegation(): void { if (vodGridDelegationInitialized) return; vodGridDelegationInitialized = true; const grid = document.getElementById('vodGrid'); if (!grid) return; grid.addEventListener('click', (e) => { const target = e.target as HTMLElement; if (!(target instanceof HTMLInputElement)) return; if (!target.classList.contains('vod-select-checkbox')) return; const url = target.dataset.vodUrl || ''; if (!url) return; if (target.checked) selectedVodUrls.add(url); else selectedVodUrls.delete(url); // Keep card visual + bar in sync without a full re-render of all cards const card = target.closest('.vod-card') as HTMLElement | null; if (card) card.classList.toggle('selected', target.checked); updateVodBulkBar(); }); } function updateVodBulkBar(): void { const bar = document.getElementById('vodBulkBar'); if (!bar) return; const count = selectedVodUrls.size; bar.style.display = count > 0 ? 'flex' : 'none'; const countEl = document.getElementById('vodBulkCount'); if (countEl) { countEl.textContent = UI_TEXT.vods.bulkSelectedCount.replace('{count}', String(count)); } } function clearVodSelection(): void { if (selectedVodUrls.size === 0) return; selectedVodUrls.clear(); updateVodBulkBar(); if (lastLoadedStreamer) renderVodGridFromCurrentState(); } async function bulkAddSelectedVodsToQueue(): Promise { const urls = Array.from(selectedVodUrls); if (urls.length === 0 || !lastLoadedStreamer) return; const streamer = lastLoadedStreamer; const btn = document.getElementById('vodBulkAddBtn') as HTMLButtonElement | null; const originalText = btn?.textContent || ''; if (btn) { btn.disabled = true; btn.textContent = UI_TEXT.vods.bulkAdding; } let added = 0; let skipped = 0; for (const url of urls) { const vod = lastLoadedVods.find((v) => v.url === url); if (!vod) { skipped++; continue; } try { queue = await window.api.addToQueue({ url: vod.url, title: vod.title, date: vod.created_at, streamer, duration_str: vod.duration }); added++; } catch { skipped++; } } selectedVodUrls.clear(); if (btn) { btn.disabled = false; btn.textContent = originalText; } updateVodBulkBar(); renderQueue(); renderVodGridFromCurrentState(); const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; if (toast && added > 0) { toast(UI_TEXT.vods.bulkAddedToQueue.replace('{count}', String(added)), 'info'); } else if (toast && skipped > 0) { toast(UI_TEXT.vods.bulkAddSkipped, 'warn'); } } 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 sorted = sortVods(lastLoadedVods, vodSortKey); const downloadedIdsForFilter = new Set( Array.isArray(config.downloaded_vod_ids) ? (config.downloaded_vod_ids as string[]).filter((id) => typeof id === 'string') : [] ); const sortedAndHidden = vodHideDownloaded ? sorted.filter((vod) => !downloadedIdsForFilter.has(vod.id)) : sorted; const filtered = filterVodsByQuery(sortedAndHidden, 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); // Build the downloaded-ids lookup once per render — Set.has is O(1) vs // Array.includes which would be O(n*m) across all cards. const downloadedIds = new Set( Array.isArray(config.downloaded_vod_ids) ? (config.downloaded_vod_ids as string[]).filter((id) => typeof id === 'string') : [] ); const scheduleNextChunk = (nextStartIndex: number): void => { const delayMs = document.hidden ? 16 : 0; window.setTimeout(() => { renderChunk(nextStartIndex); }, delayMs); }; const renderChunk = (startIndex: number): void => { if (renderTaskId !== vodRenderTaskId) { return; } const chunk = filtered.slice(startIndex, startIndex + VOD_RENDER_CHUNK_SIZE); if (!chunk.length) { return; } grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, lastLoadedStreamer || '', downloadedIds)).join('')); if (startIndex + chunk.length < filtered.length) { scheduleNextChunk(startIndex + chunk.length); } }; renderChunk(0); } async function refreshVODs(): Promise { if (!currentStreamer) { return; } await selectStreamer(currentStreamer, true); }