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;