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) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 12:14:13 +02:00
parent 83647c264b
commit d6e513d70d
6 changed files with 71 additions and 1 deletions

View File

@ -39,6 +39,7 @@
<div class="modal-actions update-modal-actions">
<button class="btn-secondary" id="updateModalDismissBtn" type="button" onclick="dismissUpdateModal()">Nein</button>
<button class="btn-secondary" id="updateModalSkipBtn" type="button" onclick="skipUpdateVersion()">Diese Version ueberspringen</button>
<button class="btn-primary" id="updateModalConfirmBtn" type="button" onclick="confirmUpdateModal()">Ja, herunterladen</button>
</div>
</div>

View File

@ -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',

View File

@ -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',

View File

@ -210,7 +210,19 @@ function renderStreamers(): void {
async function addStreamer(): Promise<void> {
const input = byId<HTMLInputElement>('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;
}

View File

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

View File

@ -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<HTMLButtonElement>('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);