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"> <div class="modal-actions update-modal-actions">
<button class="btn-secondary" id="updateModalDismissBtn" type="button" onclick="dismissUpdateModal()">Nein</button> <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> <button class="btn-primary" id="updateModalConfirmBtn" type="button" onclick="confirmUpdateModal()">Ja, herunterladen</button>
</div> </div>
</div> </div>

View File

@ -49,6 +49,8 @@ const UI_TEXT_DE = {
performanceModeBalanced: 'Ausgewogen', performanceModeBalanced: 'Ausgewogen',
performanceModeSpeed: 'Max Geschwindigkeit', performanceModeSpeed: 'Max Geschwindigkeit',
smartSchedulerLabel: 'Smart Queue Scheduler aktivieren', 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', duplicatePreventionLabel: 'Duplikate in Queue verhindern',
persistQueueLabel: 'Queue zwischen App-Starts speichern', persistQueueLabel: 'Queue zwischen App-Starts speichern',
metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)', metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)',
@ -245,6 +247,7 @@ const UI_TEXT_DE = {
modalDismiss: 'Nein', modalDismiss: 'Nein',
modalDownloadConfirm: 'Ja, herunterladen', modalDownloadConfirm: 'Ja, herunterladen',
modalInstallConfirm: 'Ja, installieren', modalInstallConfirm: 'Ja, installieren',
modalSkipVersion: 'Diese Version ueberspringen',
changelogLabel: 'Changelog', changelogLabel: 'Changelog',
showChangelog: 'Changelog anzeigen', showChangelog: 'Changelog anzeigen',
hideChangelog: 'Changelog ausblenden', hideChangelog: 'Changelog ausblenden',

View File

@ -49,6 +49,8 @@ const UI_TEXT_EN = {
performanceModeBalanced: 'Balanced', performanceModeBalanced: 'Balanced',
performanceModeSpeed: 'Max Speed', performanceModeSpeed: 'Max Speed',
smartSchedulerLabel: 'Enable smart queue scheduler', 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', duplicatePreventionLabel: 'Prevent duplicate queue entries',
persistQueueLabel: 'Keep queue between app restarts', persistQueueLabel: 'Keep queue between app restarts',
metadataCacheMinutesLabel: 'Metadata Cache (Minutes)', metadataCacheMinutesLabel: 'Metadata Cache (Minutes)',
@ -245,6 +247,7 @@ const UI_TEXT_EN = {
modalDismiss: 'No', modalDismiss: 'No',
modalDownloadConfirm: 'Yes, download', modalDownloadConfirm: 'Yes, download',
modalInstallConfirm: 'Yes, install', modalInstallConfirm: 'Yes, install',
modalSkipVersion: 'Skip this version',
changelogLabel: 'Changelog', changelogLabel: 'Changelog',
showChangelog: 'Show changelog', showChangelog: 'Show changelog',
hideChangelog: 'Hide changelog', hideChangelog: 'Hide changelog',

View File

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

View File

@ -91,6 +91,8 @@ function applyLanguageToStaticUI(): void {
setText('performanceModeBalanced', UI_TEXT.static.performanceModeBalanced); setText('performanceModeBalanced', UI_TEXT.static.performanceModeBalanced);
setText('performanceModeSpeed', UI_TEXT.static.performanceModeSpeed); setText('performanceModeSpeed', UI_TEXT.static.performanceModeSpeed);
setText('smartSchedulerLabel', UI_TEXT.static.smartSchedulerLabel); 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('duplicatePreventionLabel', UI_TEXT.static.duplicatePreventionLabel);
setText('persistQueueLabel', UI_TEXT.static.persistQueueLabel); setText('persistQueueLabel', UI_TEXT.static.persistQueueLabel);
setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel); setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel);
@ -139,6 +141,7 @@ function applyLanguageToStaticUI(): void {
setText('updateModalTitle', UI_TEXT.updates.modalAvailableTitle); setText('updateModalTitle', UI_TEXT.updates.modalAvailableTitle);
setText('updateModalDismissBtn', UI_TEXT.updates.modalDismiss); setText('updateModalDismissBtn', UI_TEXT.updates.modalDismiss);
setText('updateModalConfirmBtn', UI_TEXT.updates.modalDownloadConfirm); setText('updateModalConfirmBtn', UI_TEXT.updates.modalDownloadConfirm);
setText('updateModalSkipBtn', UI_TEXT.updates.modalSkipVersion);
setText('updateChangelogLabel', UI_TEXT.updates.changelogLabel); setText('updateChangelogLabel', UI_TEXT.updates.changelogLabel);
setText('updateChangelogToggle', UI_TEXT.updates.showChangelog); setText('updateChangelogToggle', UI_TEXT.updates.showChangelog);
setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog); setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog);

View File

@ -9,6 +9,20 @@ let updateBannerState: 'idle' | 'available' | 'downloading' | 'ready' = 'idle';
let updateChangelogExpanded = false; let updateChangelogExpanded = false;
let shouldOpenUpdateModalOnAvailable = 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 { function notifyUpdate(message: string, type: 'info' | 'warn' = 'info'): void {
const toastFn = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; const toastFn = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (typeof toastFn === 'function') { if (typeof toastFn === 'function') {
@ -278,6 +292,11 @@ function refreshUpdateModalTexts(): void {
byId('updateModalConfirmBtn').textContent = isReady byId('updateModalConfirmBtn').textContent = isReady
? UI_TEXT.updates.modalInstallConfirm ? UI_TEXT.updates.modalInstallConfirm
: UI_TEXT.updates.modalDownloadConfirm; : 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('updateChangelogLabel').textContent = UI_TEXT.updates.changelogLabel;
byId('updateChangelogEmpty').textContent = UI_TEXT.updates.noChangelog; byId('updateChangelogEmpty').textContent = UI_TEXT.updates.noChangelog;
@ -301,6 +320,19 @@ function dismissUpdateModal(): void {
byId('updateModal').classList.remove('show'); 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 { function confirmUpdateModal(): void {
dismissUpdateModal(); dismissUpdateModal();
@ -495,11 +527,22 @@ window.api.onUpdateAvailable((info: UpdateInfo) => {
updateCheckInProgress = false; updateCheckInProgress = false;
updateReady = false; updateReady = false;
updateDownloadInProgress = false; updateDownloadInProgress = false;
const wasManual = manualUpdateCheckPending;
manualUpdateCheckPending = false; manualUpdateCheckPending = false;
manualUpdateOutcomeHandled = true; manualUpdateOutcomeHandled = true;
latestDownloadProgress = null; latestDownloadProgress = null;
setCheckButtonCheckingState(false); 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); setUpdateBannerAvailableUi(activeInfo);
if (shouldOpenUpdateModalOnAvailable) { if (shouldOpenUpdateModalOnAvailable) {
@ -509,6 +552,7 @@ window.api.onUpdateAvailable((info: UpdateInfo) => {
shouldOpenUpdateModalOnAvailable = false; shouldOpenUpdateModalOnAvailable = false;
}); });
window.api.onUpdateNotAvailable(() => { window.api.onUpdateNotAvailable(() => {
updateCheckInProgress = false; updateCheckInProgress = false;
setCheckButtonCheckingState(false); setCheckButtonCheckingState(false);
@ -540,6 +584,10 @@ window.api.onUpdateDownloadProgress((progress: UpdateDownloadProgress) => {
}); });
window.api.onUpdateDownloaded((info: UpdateInfo) => { 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); const activeInfo = rememberUpdateInfo(info);
setDownloadReadyUi(activeInfo); setDownloadReadyUi(activeInfo);
openUpdateModal(activeInfo); openUpdateModal(activeInfo);