From d6e513d70d3851cf04590c1e38aa1ce830ad2d7c Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Sun, 10 May 2026 12:14:13 +0200 Subject: [PATCH] feat: skip-version + addStreamer validation + smart-scheduler tooltip Three small UX wins. 1. Auto-update: "Skip this version" button on the update modal. Stores the dismissed version in localStorage; subsequent automatic update-available events for the same version are silenced (banner hidden, modal not opened). Manual "Check for updates" overrides the skip so the user can change their mind. The flag is cleared once the version is actually downloaded so a stale entry never masks a future update. Skip button is hidden in the "ready to install" state where it would not make sense. 2. addStreamer now validates against Twitch username rules (4-25 chars, [a-zA-Z0-9_]). Previously bad input fell through to the API and the user saw a silent "streamer not found" message instead of being told the input was invalid. 3. Smart Queue Scheduler checkbox got a hover tooltip that explains what enabling it actually does ("prefers shorter VODs and older queue entries first"). Users were disabling it without knowing what they were turning off. DE + EN locale strings added for all three. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.html | 1 + src/renderer-locale-de.ts | 3 +++ src/renderer-locale-en.ts | 3 +++ src/renderer-streamers.ts | 14 +++++++++++- src/renderer-texts.ts | 3 +++ src/renderer-updates.ts | 48 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 71 insertions(+), 1 deletion(-) 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);