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'; 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): 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); return `
${safeDisplayTitle}
${date} ${vod.duration} ${formatUiNumber(vod.view_count)} ${UI_TEXT.vods.views}
`; } function renderStreamers(): void { const list = byId('streamerList'); list.innerHTML = ''; (config.streamers ?? []).forEach((streamer: string) => { const item = document.createElement('div'); item.className = 'streamer-item' + (currentStreamer === streamer ? ' active' : ''); item.innerHTML = ` ${streamer} x `; item.onclick = () => { void selectStreamer(streamer); }; list.appendChild(item); }); } async function addStreamer(): Promise { const input = byId('newStreamer'); const name = input.value.trim().toLowerCase(); if (!name || (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 { 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 sorted = sortVods(lastLoadedVods, vodSortKey); const filtered = filterVodsByQuery(sorted, 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; 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 || '')).join('')); if (startIndex + chunk.length < filtered.length) { scheduleNextChunk(startIndex + chunk.length); } }; renderChunk(0); } async function refreshVODs(): Promise { if (!currentStreamer) { return; } await selectStreamer(currentStreamer, true); }