Twitch-VOD-Manager/src/renderer-streamers.ts
xRangerDE e2c0e3a2bf feat: hide-downloaded filter + reset list + config export/import
Three companion features around the 4.5.22 already-downloaded badge.

1. "Hide downloaded" toggle in the VOD filter row. Persisted to
   localStorage so power users who keep it on across sessions don't
   re-flip it on every launch. Filter applies before the title-search
   filter so the match counter stays consistent.

2. "Reset downloaded list" button in a new Backup & Maintenance
   settings card. Confirm-dialog before clearing, IPC returns the
   removed count for a "cleared N entries" toast. Renderer refreshes
   its config copy + re-renders the VOD grid so badges disappear
   immediately. No files are touched.

3. Config export / import via dialog.show*Dialog. Export strips
   client_secret (should never travel as plain text via cloud sync),
   tags the file with __exportVersion + __exportedAt. Import runs
   the JSON through normalizeConfigTemplates so out-of-range fields
   fall back to defaults; if the imported file lacks client_secret,
   the existing value is preserved. After import the renderer reloads
   config + relocalizes if language changed + re-renders streamers /
   settings form / VOD grid.

DE + EN locale strings for every label, button, toast, and confirm
dialog. New backupCardTitle / backupCardIntro section header in
Settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:46:21 +02:00

593 lines
21 KiB
TypeScript

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<string>();
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<HTMLInputElement>('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<VodSortKey> = ['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<HTMLSelectElement>('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<VodSortKey, string> = {
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<HTMLInputElement>('vodFilterInput');
vodFilterQuery = input.value;
persistVodFilter(vodFilterQuery);
syncVodFilterClearButton();
if (lastLoadedStreamer) {
renderVodGridFromCurrentState();
}
}
function clearVodFilter(): void {
vodFilterQuery = '';
const input = byId<HTMLInputElement>('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>): 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, '&quot;');
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
? `<div class="vod-downloaded-badge" title="${escapeHtml(UI_TEXT.vods.alreadyDownloaded)}">&#10003;</div>`
: '';
return `
<div class="vod-card${isChecked ? ' selected' : ''}${isAlreadyDownloaded ? ' already-downloaded' : ''}">
<input type="checkbox" class="vod-select-checkbox" data-vod-url="${safeUrlAttr}" ${isChecked ? 'checked' : ''} title="Select for bulk action" style="position:absolute; top:8px; left:8px; width:18px; height:18px; accent-color:#9146FF; cursor:pointer; z-index:2;">
${downloadedBadge}
<img class="vod-thumbnail" loading="lazy" decoding="async" src="${thumb}" alt="" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 180%22><rect fill=%22%23333%22 width=%22320%22 height=%22180%22/></svg>'">
<div class="vod-info">
<div class="vod-title">${safeDisplayTitle}</div>
<div class="vod-meta">
<span>${date}</span>
<span>${vod.duration}</span>
<span>${formatUiNumber(vod.view_count)} ${UI_TEXT.vods.views}</span>
</div>
</div>
<div class="vod-actions">
<button class="vod-btn secondary" onclick="openClipDialog('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">${UI_TEXT.vods.trimButton}</button>
<button class="vod-btn primary" onclick="addToQueue('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">${UI_TEXT.vods.addQueue}</button>
</div>
</div>
`;
}
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<void> {
const input = byId<HTMLInputElement>('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<void> {
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 = `
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
<h3>${UI_TEXT.vods.noneTitle}</h3>
<p>${UI_TEXT.vods.noneText}</p>
</div>
`;
}
async function selectStreamer(name: string, forceRefresh = false): Promise<void> {
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 = `<div class="empty-state"><p>${UI_TEXT.vods.loading}</p></div>`;
const userId = await window.api.getUserId(name);
if (isStaleRequest()) {
return;
}
if (!userId) {
byId('vodGrid').innerHTML = `<div class="empty-state"><h3>${UI_TEXT.vods.notFound}</h3></div>`;
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<void> {
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<void> {
if (!currentStreamer) {
return;
}
await selectStreamer(currentStreamer, true);
}