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);