diff --git a/src/index.html b/src/index.html
index 334797b..b8668c6 100644
--- a/src/index.html
+++ b/src/index.html
@@ -39,6 +39,7 @@
+
diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts
index 803e520..c230a57 100644
--- a/src/renderer-locale-de.ts
+++ b/src/renderer-locale-de.ts
@@ -49,6 +49,8 @@ const UI_TEXT_DE = {
performanceModeBalanced: 'Ausgewogen',
performanceModeSpeed: 'Max Geschwindigkeit',
smartSchedulerLabel: 'Smart Queue Scheduler aktivieren',
+ smartSchedulerHint: 'Bevorzugt kuerzere VODs und aeltere Queue-Eintraege zuerst, damit der Durchsatz gleichmaessig bleibt. Deaktivieren = strikte Einfuegereihenfolge.',
+ streamerInvalid: 'Twitch-Username ungueltig (4-25 Zeichen, Buchstaben/Zahlen/Unterstrich).',
duplicatePreventionLabel: 'Duplikate in Queue verhindern',
persistQueueLabel: 'Queue zwischen App-Starts speichern',
metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)',
@@ -245,6 +247,7 @@ const UI_TEXT_DE = {
modalDismiss: 'Nein',
modalDownloadConfirm: 'Ja, herunterladen',
modalInstallConfirm: 'Ja, installieren',
+ modalSkipVersion: 'Diese Version ueberspringen',
changelogLabel: 'Changelog',
showChangelog: 'Changelog anzeigen',
hideChangelog: 'Changelog ausblenden',
diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts
index 9142555..11fc605 100644
--- a/src/renderer-locale-en.ts
+++ b/src/renderer-locale-en.ts
@@ -49,6 +49,8 @@ const UI_TEXT_EN = {
performanceModeBalanced: 'Balanced',
performanceModeSpeed: 'Max Speed',
smartSchedulerLabel: 'Enable smart queue scheduler',
+ smartSchedulerHint: 'Prefers shorter VODs and older queue entries first so the queue throughput stays steady. Disable to drain in strict insertion order.',
+ streamerInvalid: 'Invalid Twitch username (4-25 chars, letters/digits/underscore).',
duplicatePreventionLabel: 'Prevent duplicate queue entries',
persistQueueLabel: 'Keep queue between app restarts',
metadataCacheMinutesLabel: 'Metadata Cache (Minutes)',
@@ -245,6 +247,7 @@ const UI_TEXT_EN = {
modalDismiss: 'No',
modalDownloadConfirm: 'Yes, download',
modalInstallConfirm: 'Yes, install',
+ modalSkipVersion: 'Skip this version',
changelogLabel: 'Changelog',
showChangelog: 'Show changelog',
hideChangelog: 'Hide changelog',
diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts
index d1aa971..daf0bc0 100644
--- a/src/renderer-streamers.ts
+++ b/src/renderer-streamers.ts
@@ -210,7 +210,19 @@ function renderStreamers(): void {
async function addStreamer(): Promise {
const input = byId('newStreamer');
const name = input.value.trim().toLowerCase();
- if (!name || (config.streamers ?? []).includes(name)) {
+ 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;
}
diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts
index 09cb5f8..56fc126 100644
--- a/src/renderer-texts.ts
+++ b/src/renderer-texts.ts
@@ -91,6 +91,8 @@ function applyLanguageToStaticUI(): void {
setText('performanceModeBalanced', UI_TEXT.static.performanceModeBalanced);
setText('performanceModeSpeed', UI_TEXT.static.performanceModeSpeed);
setText('smartSchedulerLabel', UI_TEXT.static.smartSchedulerLabel);
+ setTitle('smartSchedulerLabel', UI_TEXT.static.smartSchedulerHint);
+ setTitle('smartSchedulerToggle', UI_TEXT.static.smartSchedulerHint);
setText('duplicatePreventionLabel', UI_TEXT.static.duplicatePreventionLabel);
setText('persistQueueLabel', UI_TEXT.static.persistQueueLabel);
setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel);
@@ -139,6 +141,7 @@ function applyLanguageToStaticUI(): void {
setText('updateModalTitle', UI_TEXT.updates.modalAvailableTitle);
setText('updateModalDismissBtn', UI_TEXT.updates.modalDismiss);
setText('updateModalConfirmBtn', UI_TEXT.updates.modalDownloadConfirm);
+ setText('updateModalSkipBtn', UI_TEXT.updates.modalSkipVersion);
setText('updateChangelogLabel', UI_TEXT.updates.changelogLabel);
setText('updateChangelogToggle', UI_TEXT.updates.showChangelog);
setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog);
diff --git a/src/renderer-updates.ts b/src/renderer-updates.ts
index f0ee708..b86a6e2 100644
--- a/src/renderer-updates.ts
+++ b/src/renderer-updates.ts
@@ -9,6 +9,20 @@ let updateBannerState: 'idle' | 'available' | 'downloading' | 'ready' = 'idle';
let updateChangelogExpanded = false;
let shouldOpenUpdateModalOnAvailable = false;
+const SKIPPED_UPDATE_VERSION_KEY = 'twitch-vod-manager:skipped-update-version';
+
+function getSkippedUpdateVersion(): string {
+ try { return localStorage.getItem(SKIPPED_UPDATE_VERSION_KEY) || ''; } catch { return ''; }
+}
+
+function persistSkippedUpdateVersion(version: string): void {
+ try { localStorage.setItem(SKIPPED_UPDATE_VERSION_KEY, version); } catch { /* localStorage may be unavailable */ }
+}
+
+function clearSkippedUpdateVersion(): void {
+ try { localStorage.removeItem(SKIPPED_UPDATE_VERSION_KEY); } catch { /* localStorage may be unavailable */ }
+}
+
function notifyUpdate(message: string, type: 'info' | 'warn' = 'info'): void {
const toastFn = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (typeof toastFn === 'function') {
@@ -278,6 +292,11 @@ function refreshUpdateModalTexts(): void {
byId('updateModalConfirmBtn').textContent = isReady
? UI_TEXT.updates.modalInstallConfirm
: UI_TEXT.updates.modalDownloadConfirm;
+ // Skip-version only makes sense before the download. Once the .exe is
+ // already on disk and ready to install, hide the button.
+ const skipBtn = byId('updateModalSkipBtn');
+ skipBtn.textContent = UI_TEXT.updates.modalSkipVersion;
+ skipBtn.style.display = isReady ? 'none' : '';
byId('updateChangelogLabel').textContent = UI_TEXT.updates.changelogLabel;
byId('updateChangelogEmpty').textContent = UI_TEXT.updates.noChangelog;
@@ -301,6 +320,19 @@ function dismissUpdateModal(): void {
byId('updateModal').classList.remove('show');
}
+function skipUpdateVersion(): void {
+ const v = (latestUpdateInfo?.version || latestUpdateVersion || '').trim();
+ if (v) {
+ persistSkippedUpdateVersion(v);
+ }
+ dismissUpdateModal();
+ hideUpdateBanner();
+ updateBannerState = 'idle';
+ // Note: latestUpdateInfo is intentionally kept so a manual "Check for
+ // updates" can still re-surface the same version if the user changes
+ // their mind (manual checks bypass the skip-version filter).
+}
+
function confirmUpdateModal(): void {
dismissUpdateModal();
@@ -495,11 +527,22 @@ window.api.onUpdateAvailable((info: UpdateInfo) => {
updateCheckInProgress = false;
updateReady = false;
updateDownloadInProgress = false;
+ const wasManual = manualUpdateCheckPending;
manualUpdateCheckPending = false;
manualUpdateOutcomeHandled = true;
latestDownloadProgress = null;
setCheckButtonCheckingState(false);
+ // If the user explicitly skipped this exact version, suppress the auto
+ // notification entirely — banner stays hidden, no modal popup. A manual
+ // "Check for updates" click overrides the skip so the user can change
+ // their mind.
+ const isSkipped = getSkippedUpdateVersion() === activeInfo.version;
+ if (isSkipped && !wasManual) {
+ shouldOpenUpdateModalOnAvailable = false;
+ return;
+ }
+
setUpdateBannerAvailableUi(activeInfo);
if (shouldOpenUpdateModalOnAvailable) {
@@ -509,6 +552,7 @@ window.api.onUpdateAvailable((info: UpdateInfo) => {
shouldOpenUpdateModalOnAvailable = false;
});
+
window.api.onUpdateNotAvailable(() => {
updateCheckInProgress = false;
setCheckButtonCheckingState(false);
@@ -540,6 +584,10 @@ window.api.onUpdateDownloadProgress((progress: UpdateDownloadProgress) => {
});
window.api.onUpdateDownloaded((info: UpdateInfo) => {
+ // Once a version is actually downloaded the user clearly stopped
+ // skipping it — clear the skip flag so future updates aren't masked
+ // by a stale entry.
+ clearSkippedUpdateVersion();
const activeInfo = rememberUpdateInfo(info);
setDownloadReadyUi(activeInfo);
openUpdateModal(activeInfo);