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:
parent
83647c264b
commit
d6e513d70d
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user