Twitch-VOD-Manager/src/renderer-settings.ts
xRangerDE 63aafae85d feat: add light theme with toggle in settings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:46:26 +01:00

588 lines
21 KiB
TypeScript

let lastRuntimeMetricsOutput = '';
let lastDebugLogOutput = '';
let settingsAutoSaveBound = false;
let settingsAutoSaveInFlight = false;
let pendingSettingsAutoSave = false;
let settingsAutoSaveTimer: number | null = null;
let pendingCredentialsReconnect = false;
let lastPersistedSettingsFingerprint = '';
function canRunSettingsAutoRefresh(): boolean {
if (document.hidden) {
return false;
}
return document.querySelector('.tab-content.active')?.id === 'settingsTab';
}
async function connect(): Promise<void> {
const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && (config.client_secret ?? '').toString().trim());
if (!hasCredentials) {
isConnected = false;
updateStatus(UI_TEXT.status.noLogin, false);
return;
}
updateStatus(UI_TEXT.status.connecting, false);
const success = await window.api.login();
isConnected = success;
updateStatus(success ? UI_TEXT.status.connected : UI_TEXT.status.connectFailedPublic, success);
}
function formatBytesForMetrics(bytes: number): string {
const value = Math.max(0, Number(bytes) || 0);
if (value < 1024) return `${value.toFixed(0)} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`;
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function validateFilenameTemplates(showAlert = false): boolean {
const templates = [
byId<HTMLInputElement>('vodFilenameTemplate').value.trim(),
byId<HTMLInputElement>('partsFilenameTemplate').value.trim(),
byId<HTMLInputElement>('defaultClipFilenameTemplate').value.trim()
];
const unknown = templates.flatMap((template) => collectUnknownTemplatePlaceholders(template));
const uniqueUnknown = Array.from(new Set(unknown));
const lintNode = byId('filenameTemplateLint');
if (!uniqueUnknown.length) {
lintNode.style.color = '#8bc34a';
lintNode.textContent = UI_TEXT.static.templateLintOk;
return true;
}
lintNode.style.color = '#ff8a80';
lintNode.textContent = `${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`;
if (showAlert) {
alert(`${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`);
}
return false;
}
function applyTemplatePreset(preset: string): void {
const presets: Record<string, { vod: string; parts: string; clip: string }> = {
default: {
vod: '{title}.mp4',
parts: '{date}_Part{part_padded}.mp4',
clip: '{date}_{part}.mp4'
},
archive: {
vod: '{channel}_{date_custom="yyyy-MM-dd"}_{title}.mp4',
parts: '{channel}_{date_custom="yyyy-MM-dd"}_Part{part_padded}.mp4',
clip: '{channel}_{date_custom="yyyy-MM-dd"}_{trim_start}_{part}.mp4'
},
clipper: {
vod: '{date_custom="yyyy-MM-dd"}_{title}.mp4',
parts: '{date_custom="yyyy-MM-dd"}_{part_padded}_{trim_start}.mp4',
clip: '{title}_{trim_start_custom="HH-mm-ss"}_{part}.mp4'
}
};
const selected = presets[preset] || presets.default;
byId<HTMLInputElement>('vodFilenameTemplate').value = selected.vod;
byId<HTMLInputElement>('partsFilenameTemplate').value = selected.parts;
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = selected.clip;
validateFilenameTemplates();
}
async function refreshRuntimeMetrics(showLoading = true): Promise<void> {
const output = byId('runtimeMetricsOutput');
if (showLoading) {
output.textContent = UI_TEXT.static.runtimeMetricsLoading;
}
try {
const metrics = await window.api.getRuntimeMetrics();
const lines = [
`${UI_TEXT.static.runtimeMetricQueue}: ${metrics.queue.total} total (${metrics.queue.pending} pending, ${metrics.queue.downloading} downloading, ${metrics.queue.error} failed)`,
`${UI_TEXT.static.runtimeMetricMode}: ${metrics.config.performanceMode} | smartScheduler=${metrics.config.smartScheduler} | dedupe=${metrics.config.duplicatePrevention}`,
`${UI_TEXT.static.runtimeMetricRetries}: ${metrics.retriesScheduled} scheduled, ${metrics.retriesExhausted} exhausted`,
`${UI_TEXT.static.runtimeMetricIntegrity}: ${metrics.integrityFailures}`,
`${UI_TEXT.static.runtimeMetricCache}: hits=${metrics.cacheHits}, misses=${metrics.cacheMisses}, vod=${metrics.caches.vodList}, users=${metrics.caches.loginToUserId}, clips=${metrics.caches.clipInfo}`,
`${UI_TEXT.static.runtimeMetricBandwidth}: current=${formatBytesForMetrics(metrics.lastSpeedBytesPerSec)}/s, avg=${formatBytesForMetrics(metrics.avgSpeedBytesPerSec)}/s`,
`${UI_TEXT.static.runtimeMetricDownloads}: started=${metrics.downloadsStarted}, done=${metrics.downloadsCompleted}, failed=${metrics.downloadsFailed}, bytes=${formatBytesForMetrics(metrics.downloadedBytesTotal)}`,
`${UI_TEXT.static.runtimeMetricActive}: ${metrics.activeItemTitle || '-'} (${metrics.activeItemId || '-'})`,
`${UI_TEXT.static.runtimeMetricLastError}: ${metrics.lastErrorClass || '-'}, retryDelay=${metrics.lastRetryDelaySeconds}s`,
`${UI_TEXT.static.runtimeMetricUpdated}: ${new Date(metrics.timestamp).toLocaleString(currentLanguage === 'en' ? 'en-US' : 'de-DE')}`
];
const nextOutput = lines.join('\n');
if (nextOutput !== lastRuntimeMetricsOutput) {
output.textContent = nextOutput;
lastRuntimeMetricsOutput = nextOutput;
}
} catch {
if (lastRuntimeMetricsOutput !== UI_TEXT.static.runtimeMetricsError) {
output.textContent = UI_TEXT.static.runtimeMetricsError;
lastRuntimeMetricsOutput = UI_TEXT.static.runtimeMetricsError;
}
}
}
async function exportRuntimeMetrics(): Promise<void> {
const result = await window.api.exportRuntimeMetrics();
const toast = (window as unknown as { showAppToast?: (message: string, type?: 'info' | 'warn') => void }).showAppToast;
const notify = (message: string, type: 'info' | 'warn' = 'info') => {
if (typeof toast === 'function') {
toast(message, type);
} else if (type === 'warn') {
alert(message);
}
};
if (result.success) {
notify(UI_TEXT.static.runtimeMetricsExportDone, 'info');
return;
}
if (result.cancelled) {
notify(UI_TEXT.static.runtimeMetricsExportCancelled, 'info');
return;
}
notify(`${UI_TEXT.static.runtimeMetricsExportFailed}${result.error ? `\n${result.error}` : ''}`, 'warn');
}
function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
if (runtimeMetricsAutoRefreshTimer) {
clearInterval(runtimeMetricsAutoRefreshTimer);
runtimeMetricsAutoRefreshTimer = null;
}
if (enabled) {
runtimeMetricsAutoRefreshTimer = window.setInterval(() => {
if (!canRunSettingsAutoRefresh()) {
return;
}
void refreshRuntimeMetrics(false);
}, 2000);
}
}
function updateStatus(text: string, connected: boolean): void {
byId('statusText').textContent = text;
const dot = byId('statusDot');
dot.classList.remove('connected', 'error');
dot.classList.add(connected ? 'connected' : 'error');
}
function changeLanguage(lang: string): void {
const normalized = setLanguage(lang);
byId<HTMLSelectElement>('languageSelect').value = normalized;
updateLanguagePicker(normalized);
config.language = normalized;
void window.api.saveConfig({ language: normalized });
const currentStatus = byId('statusText').textContent?.trim() || '';
updateStatus(localizeCurrentStatusText(currentStatus), isConnected);
renderQueue();
renderStreamers();
const activeTabId = document.querySelector('.tab-content.active')?.id || 'vodsTab';
const activeTab = activeTabId.replace('Tab', '');
if (activeTab === 'vods' && currentStreamer) {
byId('pageTitle').textContent = currentStreamer;
} else {
byId('pageTitle').textContent = (UI_TEXT.tabs as Record<string, string>)[activeTab] || UI_TEXT.appName;
}
void refreshRuntimeMetrics();
validateFilenameTemplates();
}
function updateLanguagePicker(lang: string): void {
const de = byId<HTMLButtonElement>('langOptionDe');
const en = byId<HTMLButtonElement>('langOptionEn');
const isDe = lang === 'de';
de.classList.toggle('active', isDe);
en.classList.toggle('active', !isDe);
de.setAttribute('aria-pressed', String(isDe));
en.setAttribute('aria-pressed', String(!isDe));
}
function selectLanguageOption(lang: string): void {
changeLanguage(lang);
}
function renderPreflightResult(result: PreflightResult): void {
const entries = [
[UI_TEXT.static.preflightInternet, result.checks.internet],
[UI_TEXT.static.preflightStreamlink, result.checks.streamlink],
[UI_TEXT.static.preflightFfmpeg, result.checks.ffmpeg],
[UI_TEXT.static.preflightFfprobe, result.checks.ffprobe],
[UI_TEXT.static.preflightPath, result.checks.downloadPathWritable]
];
const lines = entries.map(([name, ok]) => `${ok ? 'OK' : 'FAIL'} ${name}`).join('\n');
const extra = result.messages.length ? `\n\n${result.messages.join('\n')}` : `\n\n${UI_TEXT.static.preflightReady}`;
byId('preflightResult').textContent = `${lines}${extra}`;
const badge = byId('healthBadge');
badge.classList.remove('good', 'warn', 'bad', 'unknown');
if (result.ok) {
badge.classList.add('good');
badge.textContent = UI_TEXT.static.healthGood;
return;
}
const failCount = Object.values(result.checks).filter((ok) => !ok).length;
if (failCount <= 2) {
badge.classList.add('warn');
badge.textContent = UI_TEXT.static.healthWarn;
} else {
badge.classList.add('bad');
badge.textContent = UI_TEXT.static.healthBad;
}
}
async function runPreflight(autoFix = false): Promise<void> {
const btn = byId<HTMLButtonElement>(autoFix ? 'btnPreflightFix' : 'btnPreflightRun');
const old = btn.textContent || '';
btn.disabled = true;
btn.textContent = autoFix ? UI_TEXT.static.preflightFixing : UI_TEXT.static.preflightChecking;
try {
const result = await window.api.runPreflight(autoFix);
renderPreflightResult(result);
} finally {
btn.disabled = false;
btn.textContent = old;
}
}
async function refreshDebugLog(): Promise<void> {
const text = await window.api.getDebugLog(250);
const panel = byId('debugLogOutput');
const keepAtBottom = (panel.scrollHeight - panel.scrollTop - panel.clientHeight) < 20;
if (text !== lastDebugLogOutput) {
panel.textContent = text;
lastDebugLogOutput = text;
}
if (keepAtBottom) {
panel.scrollTop = panel.scrollHeight;
}
}
function toggleDebugAutoRefresh(enabled: boolean): void {
if (debugLogAutoRefreshTimer) {
clearInterval(debugLogAutoRefreshTimer);
debugLogAutoRefreshTimer = null;
}
if (enabled) {
debugLogAutoRefreshTimer = window.setInterval(() => {
if (!canRunSettingsAutoRefresh()) {
return;
}
void refreshDebugLog();
}, 2000);
}
}
function collectCredentialsPayload(): Partial<AppConfig> {
return {
client_id: byId<HTMLInputElement>('clientId').value.trim(),
client_secret: byId<HTMLInputElement>('clientSecret').value.trim()
};
}
function syncPartMinutesFieldState(): void {
const downloadMode = byId<HTMLSelectElement>('downloadMode').value;
const partMinutes = byId<HTMLInputElement>('partMinutes');
const label = byId<HTMLElement>('partMinutesLabel');
const isSplitMode = downloadMode === 'parts';
partMinutes.disabled = !isSplitMode;
partMinutes.setAttribute('aria-disabled', String(!isSplitMode));
label.classList.toggle('input-disabled', !isSplitMode);
}
function collectDownloadSettingsPayload(): Partial<AppConfig> {
return {
download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full',
part_minutes: parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120,
performance_mode: byId<HTMLSelectElement>('performanceMode').value as 'stability' | 'balanced' | 'speed',
smart_queue_scheduler: byId<HTMLInputElement>('smartSchedulerToggle').checked,
prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').checked,
persist_queue_on_restart: byId<HTMLInputElement>('persistQueueToggle').checked,
metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10
};
}
function collectFilenameTemplatePayload(showAlert = false): Partial<AppConfig> | null {
if (!validateFilenameTemplates(showAlert)) {
return null;
}
return {
filename_template_vod: byId<HTMLInputElement>('vodFilenameTemplate').value.trim() || '{title}.mp4',
filename_template_parts: byId<HTMLInputElement>('partsFilenameTemplate').value.trim() || '{date}_Part{part_padded}.mp4',
filename_template_clip: byId<HTMLInputElement>('defaultClipFilenameTemplate').value.trim() || '{date}_{part}.mp4'
};
}
function collectAutoSavePayload(): Partial<AppConfig> {
const payload: Partial<AppConfig> = {
...collectCredentialsPayload(),
...collectDownloadSettingsPayload()
};
const templatePayload = collectFilenameTemplatePayload(false);
if (templatePayload) {
Object.assign(payload, templatePayload);
}
return payload;
}
function getSettingsFingerprint(payload: Partial<AppConfig>): string {
const effective = { ...config, ...payload };
return JSON.stringify([
effective.client_id ?? '',
effective.client_secret ?? '',
effective.download_mode ?? 'full',
effective.part_minutes ?? 120,
effective.performance_mode ?? 'balanced',
effective.smart_queue_scheduler !== false,
effective.prevent_duplicate_downloads !== false,
effective.persist_queue_on_restart !== false,
effective.metadata_cache_minutes ?? 10,
effective.filename_template_vod ?? '{title}.mp4',
effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4',
effective.filename_template_clip ?? '{date}_{part}.mp4'
]);
}
function syncSettingsFormFromConfig(): void {
byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? '';
byId<HTMLSelectElement>('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full';
byId<HTMLInputElement>('partMinutes').value = String((config.part_minutes as number) || 120);
byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced';
byId<HTMLInputElement>('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false;
byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false;
byId<HTMLInputElement>('persistQueueToggle').checked = (config.persist_queue_on_restart as boolean) !== false;
byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4';
byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4';
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || '{date}_{part}.mp4';
syncPartMinutesFieldState();
validateFilenameTemplates();
lastPersistedSettingsFingerprint = getSettingsFingerprint({});
}
async function persistSettings(options: {
includeCredentials?: boolean;
includeTemplates?: boolean;
reconnectAfterSave?: boolean;
showTemplateAlert?: boolean;
} = {}): Promise<boolean> {
const payload: Partial<AppConfig> = {
...collectDownloadSettingsPayload()
};
if (options.includeCredentials) {
Object.assign(payload, collectCredentialsPayload());
}
if (options.includeTemplates !== false) {
const templatePayload = collectFilenameTemplatePayload(options.showTemplateAlert);
if (!templatePayload) {
return false;
}
Object.assign(payload, templatePayload);
}
config = await window.api.saveConfig(payload);
syncSettingsFormFromConfig();
pendingCredentialsReconnect = false;
if (options.reconnectAfterSave) {
await connect();
}
if (canRunSettingsAutoRefresh()) {
await refreshRuntimeMetrics(false);
}
return true;
}
async function flushSettingsAutoSave(reconnectAfterSave = false): Promise<void> {
if (settingsAutoSaveTimer) {
clearTimeout(settingsAutoSaveTimer);
settingsAutoSaveTimer = null;
}
const payload = collectAutoSavePayload();
const fingerprint = getSettingsFingerprint(payload);
if (fingerprint === lastPersistedSettingsFingerprint) {
if (reconnectAfterSave && pendingCredentialsReconnect) {
pendingCredentialsReconnect = false;
await connect();
}
return;
}
if (settingsAutoSaveInFlight) {
pendingSettingsAutoSave = true;
return;
}
settingsAutoSaveInFlight = true;
try {
config = await window.api.saveConfig(payload);
lastPersistedSettingsFingerprint = getSettingsFingerprint({});
if (reconnectAfterSave && pendingCredentialsReconnect) {
pendingCredentialsReconnect = false;
await connect();
}
} finally {
settingsAutoSaveInFlight = false;
if (pendingSettingsAutoSave) {
pendingSettingsAutoSave = false;
void flushSettingsAutoSave(pendingCredentialsReconnect);
}
}
}
function scheduleSettingsAutoSave(delayMs = 450): void {
if (settingsAutoSaveTimer) {
clearTimeout(settingsAutoSaveTimer);
}
settingsAutoSaveTimer = window.setTimeout(() => {
settingsAutoSaveTimer = null;
void flushSettingsAutoSave(false);
}, delayMs);
}
function initSettingsAutoSave(): void {
if (settingsAutoSaveBound) {
return;
}
settingsAutoSaveBound = true;
syncSettingsFormFromConfig();
const immediateSaveIds = [
'downloadMode',
'performanceMode',
'smartSchedulerToggle',
'duplicatePreventionToggle',
'persistQueueToggle'
] as const;
const debouncedSaveIds = [
'partMinutes',
'metadataCacheMinutes',
'vodFilenameTemplate',
'partsFilenameTemplate',
'defaultClipFilenameTemplate'
] as const;
const credentialIds = [
'clientId',
'clientSecret'
] as const;
const triggerImmediateSave = () => {
void flushSettingsAutoSave(false);
};
byId<HTMLSelectElement>('downloadMode').addEventListener('change', syncPartMinutesFieldState);
for (const id of immediateSaveIds) {
const element = byId<HTMLInputElement | HTMLSelectElement>(id);
element.addEventListener('change', triggerImmediateSave);
element.addEventListener('blur', triggerImmediateSave);
}
for (const id of debouncedSaveIds) {
const element = byId<HTMLInputElement>(id);
element.addEventListener('input', () => {
scheduleSettingsAutoSave();
});
element.addEventListener('blur', () => {
void flushSettingsAutoSave(false);
});
}
for (const id of credentialIds) {
const element = byId<HTMLInputElement>(id);
element.addEventListener('input', () => {
pendingCredentialsReconnect = true;
scheduleSettingsAutoSave();
});
element.addEventListener('blur', () => {
pendingCredentialsReconnect = true;
void flushSettingsAutoSave(true);
});
}
window.addEventListener('blur', () => {
if (settingsAutoSaveTimer || pendingCredentialsReconnect) {
void flushSettingsAutoSave(pendingCredentialsReconnect);
}
});
document.addEventListener('visibilitychange', () => {
if (document.hidden && (settingsAutoSaveTimer || pendingCredentialsReconnect)) {
void flushSettingsAutoSave(pendingCredentialsReconnect);
}
});
}
async function saveSettings(): Promise<void> {
const saved = await persistSettings({
includeCredentials: true,
includeTemplates: true,
reconnectAfterSave: true,
showTemplateAlert: true
});
if (!saved) {
return;
}
}
async function selectFolder(): Promise<void> {
const folder = await window.api.selectFolder();
if (!folder) {
return;
}
byId<HTMLInputElement>('downloadPath').value = folder;
config = await window.api.saveConfig({ download_path: folder });
}
function openFolder(): void {
const folder = config.download_path;
if (!folder || typeof folder !== 'string') {
return;
}
void window.api.openFolder(folder);
}
function changeTheme(theme: string): void {
document.body.className = `theme-${theme}`;
config.theme = theme;
void window.api.saveConfig({ theme });
}