diff --git a/src/index.html b/src/index.html index b8668c6..5f102d4 100644 --- a/src/index.html +++ b/src/index.html @@ -258,6 +258,12 @@ +
diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index dcac842..c107838 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -180,7 +180,13 @@ const UI_TEXT_DE = { sortDateAsc: 'Aelteste zuerst', sortViewsDesc: 'Meiste Aufrufe', sortDurationDesc: 'Laengste zuerst', - sortDurationAsc: 'Kuerzeste zuerst' + sortDurationAsc: 'Kuerzeste zuerst', + bulkSelectedCount: '{count} ausgewaehlt', + bulkAddToQueue: '+ Warteschlange', + bulkAdding: 'Fuege hinzu...', + bulkClear: 'Loeschen', + bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefuegt.', + bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).' }, clips: { dialogTitle: 'VOD zuschneiden', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index aac8bcc..083d570 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -180,7 +180,13 @@ const UI_TEXT_EN = { sortDateAsc: 'Oldest first', sortViewsDesc: 'Most viewed', sortDurationDesc: 'Longest first', - sortDurationAsc: 'Shortest first' + sortDurationAsc: 'Shortest first', + bulkSelectedCount: '{count} selected', + bulkAddToQueue: '+ Queue', + bulkAdding: 'Adding...', + bulkClear: 'Clear', + bulkAddedToQueue: 'Added {count} VODs to the queue.', + bulkAddSkipped: 'No VODs were added (already in queue or invalid).' }, clips: { dialogTitle: 'Trim VOD', diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts index daf0bc0..4112e77 100644 --- a/src/renderer-streamers.ts +++ b/src/renderer-streamers.ts @@ -10,6 +10,12 @@ 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; + 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'; @@ -169,9 +175,12 @@ function buildVodCardHtml(vod: VOD, streamer: string): string { 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); return ` -
+
+
${safeDisplayTitle}
@@ -189,22 +198,94 @@ function buildVodCardHtml(vod: VOD, streamer: string): string { `; } +let streamerDragInitialized = false; +let draggedStreamerName: string | null = null; + function renderStreamers(): void { const list = byId('streamerList'); - list.innerHTML = ''; + list.replaceChildren(); (config.streamers ?? []).forEach((streamer: string) => { const item = document.createElement('div'); item.className = 'streamer-item' + (currentStreamer === streamer ? ' active' : ''); - item.innerHTML = ` - ${streamer} - x - `; - item.onclick = () => { + 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 { @@ -305,11 +386,104 @@ function setVodGridEmptyState(grid: HTMLElement, title: string, text: string): v } 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; diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts index 56fc126..3853b64 100644 --- a/src/renderer-texts.ts +++ b/src/renderer-texts.ts @@ -152,6 +152,12 @@ function applyLanguageToStaticUI(): void { if (typeof refreshVodSortSelectLabels === 'function') { refreshVodSortSelectLabels(); } + setText('vodBulkAddBtn', UI_TEXT.vods.bulkAddToQueue); + setText('vodBulkClearBtn', UI_TEXT.vods.bulkClear); + if (typeof updateVodBulkBar === 'function') { + // Repopulate the count text in the new locale + updateVodBulkBar(); + } const status = document.getElementById('statusText')?.textContent?.trim() || ''; if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) { diff --git a/src/styles.css b/src/styles.css index cfc951c..91cecf2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -576,6 +576,7 @@ body { overflow: hidden; transition: transform 0.2s, box-shadow 0.2s; cursor: pointer; + position: relative; } .vod-card:hover { @@ -583,6 +584,14 @@ body { box-shadow: 0 8px 25px rgba(0,0,0,0.3); } +.vod-card.selected { + box-shadow: 0 0 0 2px #9146FF, 0 8px 25px rgba(145, 70, 255, 0.25); +} + +.streamer-item.dragging { + opacity: 0.4; +} + .vod-thumbnail { width: 100%; aspect-ratio: 16/9;