diff --git a/typescript-version/package-lock.json b/typescript-version/package-lock.json index a1b69cd..e256452 100644 --- a/typescript-version/package-lock.json +++ b/typescript-version/package-lock.json @@ -1,12 +1,12 @@ { "name": "twitch-vod-manager", - "version": "4.1.0", + "version": "4.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "twitch-vod-manager", - "version": "4.1.0", + "version": "4.1.1", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/typescript-version/package.json b/typescript-version/package.json index 362272a..1047b1a 100644 --- a/typescript-version/package.json +++ b/typescript-version/package.json @@ -1,6 +1,6 @@ { "name": "twitch-vod-manager", - "version": "4.1.0", + "version": "4.1.1", "description": "Twitch VOD Manager - Download Twitch VODs easily", "main": "dist/main.js", "author": "xRangerDE", diff --git a/typescript-version/scripts/smoke-test-full.js b/typescript-version/scripts/smoke-test-full.js index 0a37389..11b14e8 100644 --- a/typescript-version/scripts/smoke-test-full.js +++ b/typescript-version/scripts/smoke-test-full.js @@ -358,19 +358,16 @@ async function run() { failedStatus: failed?.status || 'none', failedReason: failed?.last_error || '' }; - if (reachedError && failed?.status === 'error') { - assert(Boolean(failed?.last_error), 'Retry test item missing error reason'); + assert(reachedError && failed?.status === 'error', 'Retry item did not reach deterministic error state'); + assert(Boolean(failed?.last_error), 'Retry test item missing error reason'); - await window.api.retryFailedDownloads(); - await sleep(500); - q = await window.api.getQueue(); - const afterRetry = q.find((item) => item.title === '__E2E_FULL__retry'); - checks.retryFlow.afterRetryStatus = afterRetry?.status || 'none'; - assert(afterRetry?.status === 'pending' || afterRetry?.status === 'downloading', 'Retry failed action did not reset item'); - } else { - checks.retryFlow.skipped = true; - checks.retryFlow.skipReason = 'Retry item did not reach error state in timeout window'; - } + await window.api.retryFailedDownloads(); + await sleep(500); + q = await window.api.getQueue(); + const afterRetry = q.find((item) => item.title === '__E2E_FULL__retry'); + checks.retryFlow.afterRetryStatus = afterRetry?.status || 'none'; + const retryAcceptedStatuses = ['pending', 'downloading', 'error']; + assert(retryAcceptedStatuses.includes(afterRetry?.status || ''), 'Retry failed action did not update item state'); await cleanupDownloads(); await clearQueue(); diff --git a/typescript-version/src/index.html b/typescript-version/src/index.html index feaaa40..a41b356 100644 --- a/typescript-version/src/index.html +++ b/typescript-version/src/index.html @@ -457,7 +457,7 @@

Updates

-

Version: v4.1.0

+

Version: v4.1.1

@@ -486,6 +486,7 @@

Runtime Metrics

+
- v4.1.0 + v4.1.1 diff --git a/typescript-version/src/main.ts b/typescript-version/src/main.ts index 198a53e..af5ff65 100644 --- a/typescript-version/src/main.ts +++ b/typescript-version/src/main.ts @@ -8,7 +8,7 @@ import { autoUpdater } from 'electron-updater'; // ========================================== // CONFIG & CONSTANTS // ========================================== -const APP_VERSION = '4.1.0'; +const APP_VERSION = '4.1.1'; const UPDATE_CHECK_URL = 'http://24-music.de/version.json'; // Paths @@ -25,6 +25,8 @@ const DEFAULT_FILENAME_TEMPLATE_PARTS = '{date}_Part{part_padded}.mp4'; const DEFAULT_FILENAME_TEMPLATE_CLIP = '{date}_{part}.mp4'; const DEFAULT_METADATA_CACHE_MINUTES = 10; const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced'; +const QUEUE_SAVE_DEBOUNCE_MS = 250; +const MIN_FREE_DISK_BYTES = 128 * 1024 * 1024; // Timeouts const API_TIMEOUT = 10000; @@ -33,7 +35,7 @@ const MIN_FILE_BYTES = 256 * 1024; const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; type PerformanceMode = 'stability' | 'balanced' | 'speed'; -type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'unknown'; +type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'validation' | 'unknown'; // Ensure directories exist if (!fs.existsSync(APPDATA_DIR)) { @@ -278,7 +280,10 @@ function loadQueue(): QueueItem[] { return []; } -function saveQueue(queue: QueueItem[]): void { +let queueSaveTimer: NodeJS.Timeout | null = null; +let pendingQueueSnapshot: QueueItem[] | null = null; + +function writeQueueToDisk(queue: QueueItem[]): void { try { fs.writeFileSync(QUEUE_FILE, JSON.stringify(queue, null, 2)); } catch (e) { @@ -286,6 +291,41 @@ function saveQueue(queue: QueueItem[]): void { } } +function saveQueue(queue: QueueItem[], force = false): void { + pendingQueueSnapshot = queue; + + if (force) { + if (queueSaveTimer) { + clearTimeout(queueSaveTimer); + queueSaveTimer = null; + } + + writeQueueToDisk(pendingQueueSnapshot); + pendingQueueSnapshot = null; + return; + } + + if (queueSaveTimer) { + return; + } + + queueSaveTimer = setTimeout(() => { + queueSaveTimer = null; + if (pendingQueueSnapshot) { + writeQueueToDisk(pendingQueueSnapshot); + pendingQueueSnapshot = null; + } + }, QUEUE_SAVE_DEBOUNCE_MS); +} + +function flushQueueSave(): void { + if (pendingQueueSnapshot) { + saveQueue(pendingQueueSnapshot, true); + } else { + saveQueue(downloadQueue, true); + } +} + // ========================================== // GLOBAL STATE // ========================================== @@ -816,6 +856,33 @@ function parseVodId(url: string): string { return match?.[1] || ''; } +function isLikelyVodUrl(url: string): boolean { + return /twitch\.tv\/videos\/\d+/i.test(url || ''); +} + +function parseFrameRate(rawFrameRate: string | undefined): number { + const fallback = 30; + const value = (rawFrameRate || '').trim(); + if (!value) return fallback; + + if (/^\d+(\.\d+)?$/.test(value)) { + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback; + } + + const ratio = value.match(/^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/); + if (!ratio) return fallback; + + const numerator = Number(ratio[1]); + const denominator = Number(ratio[2]); + if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) { + return fallback; + } + + const fps = numerator / denominator; + return Number.isFinite(fps) && fps > 0 ? fps : fallback; +} + interface ClipTemplateContext { template: string; title: string; @@ -901,6 +968,63 @@ function formatETA(seconds: number): string { return `${h}h ${m}m`; } +function getFreeDiskBytes(targetPath: string): number | null { + try { + const statfsSync = (fs as unknown as { statfsSync?: (path: string) => { bsize?: number; frsize?: number; bavail?: number } }).statfsSync; + if (!statfsSync) { + return null; + } + + const info = statfsSync(targetPath); + const blockSize = Number(info?.bsize || info?.frsize || 0); + const availableBlocks = Number(info?.bavail || 0); + if (!Number.isFinite(blockSize) || !Number.isFinite(availableBlocks) || blockSize <= 0 || availableBlocks < 0) { + return null; + } + + return Math.floor(blockSize * availableBlocks); + } catch { + return null; + } +} + +function estimateRequiredDownloadBytes(item: QueueItem): number { + const durationSeconds = Math.max(1, item.customClip?.durationSec || parseDuration(item.duration_str || '0s')); + + const bytesPerSecondByMode: Record = { + stability: 900 * 1024, + balanced: 700 * 1024, + speed: 550 * 1024 + }; + + const mode = normalizePerformanceMode(config.performance_mode); + const baseEstimate = durationSeconds * bytesPerSecondByMode[mode]; + const withHeadroom = Math.ceil(baseEstimate * (item.customClip ? 1.2 : 1.35)); + + return Math.max(64 * 1024 * 1024, Math.min(withHeadroom, 40 * 1024 * 1024 * 1024)); +} + +function ensureDiskSpace(targetPath: string, requiredBytes: number, context: string): DownloadResult { + const freeBytes = getFreeDiskBytes(targetPath); + if (freeBytes === null) { + appendDebugLog('disk-space-check-skipped', { targetPath, requiredBytes, context }); + return { success: true }; + } + + if (freeBytes < Math.max(requiredBytes, MIN_FREE_DISK_BYTES)) { + const message = `Zu wenig Speicherplatz fur ${context}: frei ${formatBytes(freeBytes)}, benoetigt ~${formatBytes(requiredBytes)}.`; + appendDebugLog('disk-space-check-failed', { + targetPath, + requiredBytes, + freeBytes, + context + }); + return { success: false, error: message }; + } + + return { success: true }; +} + function getMetadataCacheTtlMs(): number { return normalizeMetadataCacheMinutes(config.metadata_cache_minutes) * 60 * 1000; } @@ -921,6 +1045,7 @@ function classifyDownloadError(errorMessage: string): RetryErrorClass { const text = (errorMessage || '').toLowerCase(); if (!text) return 'unknown'; + if (text.includes('ungueltige vod-url') || text.includes('invalid vod url')) return 'validation'; if (text.includes('429') || text.includes('rate limit') || text.includes('too many requests')) return 'rate_limit'; if (text.includes('401') || text.includes('403') || text.includes('unauthorized') || text.includes('forbidden')) return 'auth'; if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns')) return 'network'; @@ -947,6 +1072,8 @@ function getRetryDelaySeconds(errorClass: RetryErrorClass, attempt: number): num return Math.min(25, 5 + attempt * 3 + jitter); case 'tooling': return DEFAULT_RETRY_DELAY_SECONDS; + case 'validation': + return 0; case 'unknown': default: return Math.min(25, DEFAULT_RETRY_DELAY_SECONDS + attempt * 2 + jitter); @@ -1528,7 +1655,7 @@ async function getVideoInfo(filePath: string): Promise { duration: parseFloat(info.format?.duration || '0'), width: videoStream?.width || 0, height: videoStream?.height || 0, - fps: eval(videoStream?.r_frame_rate || '30') || 30 + fps: parseFrameRate(videoStream?.r_frame_rate) }); } catch { resolve(null); @@ -1595,6 +1722,25 @@ async function cutVideo( const ffmpeg = getFFmpegPath(); const duration = Math.max(0.1, endTime - startTime); + let inputBytes = 0; + try { + inputBytes = fs.statSync(inputFile).size; + } catch { + inputBytes = 0; + } + + const cutRequiredBytes = Math.max(96 * 1024 * 1024, Math.ceil(inputBytes * 0.75)); + const cutDiskCheck = ensureDiskSpace(path.dirname(outputFile), cutRequiredBytes, 'Video-Cut'); + if (!cutDiskCheck.success) { + appendDebugLog('cut-video-no-disk-space', { + inputFile, + outputFile, + requiredBytes: cutRequiredBytes, + error: cutDiskCheck.error + }); + return false; + } + const runCutAttempt = async (copyMode: boolean): Promise => { const args = [ '-ss', formatDuration(startTime), @@ -1677,6 +1823,30 @@ async function mergeVideos( const concatContent = inputFiles.map((filePath) => `file '${filePath.replace(/'/g, "'\\''")}'`).join('\n'); fs.writeFileSync(concatFile, concatContent); + let mergeInputBytes = 0; + for (const filePath of inputFiles) { + try { + mergeInputBytes += fs.statSync(filePath).size; + } catch { + // ignore missing file in estimation + } + } + + const mergeRequiredBytes = Math.max(128 * 1024 * 1024, Math.ceil(mergeInputBytes * 1.1)); + const mergeDiskCheck = ensureDiskSpace(path.dirname(outputFile), mergeRequiredBytes, 'Video-Merge'); + if (!mergeDiskCheck.success) { + appendDebugLog('merge-video-no-disk-space', { + outputFile, + files: inputFiles.length, + requiredBytes: mergeRequiredBytes, + error: mergeDiskCheck.error + }); + try { + fs.unlinkSync(concatFile); + } catch { } + return false; + } + const runMergeAttempt = async (copyMode: boolean): Promise => { const args = [ '-f', 'concat', @@ -1911,6 +2081,14 @@ async function downloadVOD( item: QueueItem, onProgress: (progress: DownloadProgress) => void ): Promise { + const vodId = parseVodId(item.url); + if (!isLikelyVodUrl(item.url) || !vodId) { + return { + success: false, + error: 'Ungueltige VOD-URL' + }; + } + onProgress({ id: item.id, progress: -1, @@ -1947,7 +2125,12 @@ async function downloadVOD( fs.mkdirSync(folder, { recursive: true }); const totalDuration = parseDuration(item.duration_str); - const vodId = parseVodId(item.url); + + const requiredBytesEstimate = estimateRequiredDownloadBytes(item); + const diskSpaceCheck = ensureDiskSpace(folder, requiredBytesEstimate, 'Download'); + if (!diskSpaceCheck.success) { + return diskSpaceCheck; + } const makeTemplateFilename = ( template: string, @@ -2171,8 +2354,12 @@ async function processQueue(): Promise { const errorClass = classifyDownloadError(result.error || ''); runtimeMetrics.lastErrorClass = errorClass; - if (errorClass === 'tooling') { - appendDebugLog('queue-item-no-retry-tooling', { itemId: item.id, error: result.error || 'unknown' }); + if (errorClass === 'tooling' || errorClass === 'validation') { + appendDebugLog('queue-item-no-retry', { + itemId: item.id, + errorClass, + error: result.error || 'unknown' + }); break; } @@ -2370,6 +2557,11 @@ ipcMain.handle('get-queue', () => downloadQueue); ipcMain.handle('add-to-queue', (_, item: Omit) => { if (config.prevent_duplicate_downloads && hasActiveDuplicate(item)) { runtimeMetrics.duplicateSkips += 1; + mainWindow?.webContents.send('queue-duplicate-skipped', { + title: item.title, + streamer: item.streamer, + url: item.url + }); appendDebugLog('queue-item-duplicate-skipped', { title: item.title, url: item.url, @@ -2545,6 +2737,11 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => { const folder = path.join(config.download_path, 'Clips', clipInfo.broadcaster_name); fs.mkdirSync(folder, { recursive: true }); + const clipDiskCheck = ensureDiskSpace(folder, 128 * 1024 * 1024, 'Clip-Download'); + if (!clipDiskCheck.success) { + return { success: false, error: clipDiskCheck.error || 'Zu wenig Speicherplatz.' }; + } + const safeTitle = clipInfo.title.replace(/[^a-zA-Z0-9_\- ]/g, '').substring(0, 50); const filename = path.join(folder, `${safeTitle}.mp4`); @@ -2584,6 +2781,30 @@ ipcMain.handle('is-downloading', () => isDownloading); ipcMain.handle('get-runtime-metrics', () => getRuntimeMetricsSnapshot()); +ipcMain.handle('export-runtime-metrics', async () => { + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const defaultName = `runtime-metrics-${timestamp}.json`; + const preferredDir = fs.existsSync(config.download_path) ? config.download_path : app.getPath('desktop'); + + const dialogResult = await dialog.showSaveDialog(mainWindow!, { + defaultPath: path.join(preferredDir, defaultName), + filters: [{ name: 'JSON', extensions: ['json'] }] + }); + + if (dialogResult.canceled || !dialogResult.filePath) { + return { success: false, cancelled: true }; + } + + const snapshot = getRuntimeMetricsSnapshot(); + fs.writeFileSync(dialogResult.filePath, JSON.stringify(snapshot, null, 2), 'utf-8'); + return { success: true, filePath: dialogResult.filePath }; + } catch (e) { + appendDebugLog('runtime-metrics-export-failed', String(e)); + return { success: false, error: String(e) }; + } +}); + // Video Cutter IPC ipcMain.handle('get-video-info', async (_, filePath: string) => { return await getVideoInfo(filePath); @@ -2656,9 +2877,13 @@ app.on('window-all-closed', () => { if (currentProcess) { currentProcess.kill(); } - saveQueue(downloadQueue); + flushQueueSave(); if (process.platform !== 'darwin') { app.quit(); } }); + +app.on('before-quit', () => { + flushQueueSave(); +}); diff --git a/typescript-version/src/preload.ts b/typescript-version/src/preload.ts index 87ca0bc..d263ab6 100644 --- a/typescript-version/src/preload.ts +++ b/typescript-version/src/preload.ts @@ -138,6 +138,8 @@ contextBridge.exposeInMainWorld('api', { runPreflight: (autoFix: boolean) => ipcRenderer.invoke('run-preflight', autoFix), getDebugLog: (lines: number) => ipcRenderer.invoke('get-debug-log', lines), getRuntimeMetrics: (): Promise => ipcRenderer.invoke('get-runtime-metrics'), + exportRuntimeMetrics: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> => + ipcRenderer.invoke('export-runtime-metrics'), // Events onDownloadProgress: (callback: (progress: DownloadProgress) => void) => { @@ -146,6 +148,9 @@ contextBridge.exposeInMainWorld('api', { onQueueUpdated: (callback: (queue: QueueItem[]) => void) => { ipcRenderer.on('queue-updated', (_, queue) => callback(queue)); }, + onQueueDuplicateSkipped: (callback: (payload: { title: string; streamer: string; url: string }) => void) => { + ipcRenderer.on('queue-duplicate-skipped', (_, payload) => callback(payload)); + }, onDownloadStarted: (callback: () => void) => { ipcRenderer.on('download-started', () => callback()); }, diff --git a/typescript-version/src/renderer-globals.d.ts b/typescript-version/src/renderer-globals.d.ts index a30488d..64e22b3 100644 --- a/typescript-version/src/renderer-globals.d.ts +++ b/typescript-version/src/renderer-globals.d.ts @@ -185,8 +185,10 @@ interface ApiBridge { runPreflight(autoFix: boolean): Promise; getDebugLog(lines: number): Promise; getRuntimeMetrics(): Promise; + exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>; onDownloadProgress(callback: (progress: DownloadProgress) => void): void; onQueueUpdated(callback: (queue: QueueItem[]) => void): void; + onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void; onDownloadStarted(callback: () => void): void; onDownloadFinished(callback: () => void): void; onCutProgress(callback: (percent: number) => void): void; diff --git a/typescript-version/src/renderer-locale-de.ts b/typescript-version/src/renderer-locale-de.ts index 2720645..ffee6e6 100644 --- a/typescript-version/src/renderer-locale-de.ts +++ b/typescript-version/src/renderer-locale-de.ts @@ -76,9 +76,13 @@ const UI_TEXT_DE = { templateGuideContextClipLive: 'Kontext: Aktuelle Auswahl im Clip-Dialog', runtimeMetricsTitle: 'Runtime Metrics', runtimeMetricsRefresh: 'Aktualisieren', + runtimeMetricsExport: 'Export JSON', runtimeMetricsAutoRefresh: 'Auto-Refresh', runtimeMetricsLoading: 'Metriken werden geladen...', runtimeMetricsError: 'Runtime-Metriken konnten nicht geladen werden.', + runtimeMetricsExportDone: 'Runtime-Metriken wurden exportiert.', + runtimeMetricsExportCancelled: 'Export der Runtime-Metriken abgebrochen.', + runtimeMetricsExportFailed: 'Export der Runtime-Metriken fehlgeschlagen.', runtimeMetricQueue: 'Queue', runtimeMetricMode: 'Modus', runtimeMetricRetries: 'Retries', diff --git a/typescript-version/src/renderer-locale-en.ts b/typescript-version/src/renderer-locale-en.ts index b8ea1f4..85d436d 100644 --- a/typescript-version/src/renderer-locale-en.ts +++ b/typescript-version/src/renderer-locale-en.ts @@ -76,9 +76,13 @@ const UI_TEXT_EN = { templateGuideContextClipLive: 'Context: Current clip dialog selection', runtimeMetricsTitle: 'Runtime Metrics', runtimeMetricsRefresh: 'Refresh', + runtimeMetricsExport: 'Export JSON', runtimeMetricsAutoRefresh: 'Auto refresh', runtimeMetricsLoading: 'Loading metrics...', runtimeMetricsError: 'Could not load runtime metrics.', + runtimeMetricsExportDone: 'Runtime metrics exported successfully.', + runtimeMetricsExportCancelled: 'Runtime metrics export cancelled.', + runtimeMetricsExportFailed: 'Runtime metrics export failed.', runtimeMetricQueue: 'Queue', runtimeMetricMode: 'Mode', runtimeMetricRetries: 'Retries', diff --git a/typescript-version/src/renderer-settings.ts b/typescript-version/src/renderer-settings.ts index d2ab9dc..8421ff1 100644 --- a/typescript-version/src/renderer-settings.ts +++ b/typescript-version/src/renderer-settings.ts @@ -98,6 +98,31 @@ async function refreshRuntimeMetrics(): Promise { } } +async function exportRuntimeMetrics(): Promise { + 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); diff --git a/typescript-version/src/renderer-texts.ts b/typescript-version/src/renderer-texts.ts index fb05fdc..d9b6565 100644 --- a/typescript-version/src/renderer-texts.ts +++ b/typescript-version/src/renderer-texts.ts @@ -125,6 +125,7 @@ function applyLanguageToStaticUI(): void { setText('autoRefreshText', UI_TEXT.static.autoRefresh); setText('runtimeMetricsTitle', UI_TEXT.static.runtimeMetricsTitle); setText('btnRefreshMetrics', UI_TEXT.static.runtimeMetricsRefresh); + setText('btnExportMetrics', UI_TEXT.static.runtimeMetricsExport); setText('runtimeMetricsAutoRefreshText', UI_TEXT.static.runtimeMetricsAutoRefresh); setText('runtimeMetricsOutput', UI_TEXT.static.runtimeMetricsLoading); setText('updateText', UI_TEXT.updates.bannerDefault); diff --git a/typescript-version/src/renderer.ts b/typescript-version/src/renderer.ts index 5b6d4bc..a5255f4 100644 --- a/typescript-version/src/renderer.ts +++ b/typescript-version/src/renderer.ts @@ -36,6 +36,11 @@ async function init(): Promise { renderQueue(); }); + window.api.onQueueDuplicateSkipped((payload) => { + const title = payload?.title ? ` (${payload.title})` : ''; + showAppToast(`${UI_TEXT.queue.duplicateSkipped}${title}`, 'warn'); + }); + window.api.onDownloadProgress((progress: DownloadProgress) => { const item = queue.find((i: QueueItem) => i.id === progress.id); if (!item) { @@ -98,6 +103,37 @@ async function init(): Promise { }, 2000); } +let toastHideTimer: number | null = null; + +function showAppToast(message: string, type: 'info' | 'warn' = 'info'): void { + let toast = document.getElementById('appToast'); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'appToast'; + toast.className = 'app-toast'; + document.body.appendChild(toast); + } + + toast.textContent = message; + toast.classList.remove('warn', 'show'); + if (type === 'warn') { + toast.classList.add('warn'); + } + + requestAnimationFrame(() => { + toast?.classList.add('show'); + }); + + if (toastHideTimer) { + clearTimeout(toastHideTimer); + toastHideTimer = null; + } + + toastHideTimer = window.setTimeout(() => { + toast?.classList.remove('show'); + }, 3200); +} + function mergeQueueState(nextQueue: QueueItem[]): QueueItem[] { const prevById = new Map(queue.map((item) => [item.id, item])); diff --git a/typescript-version/src/styles.css b/typescript-version/src/styles.css index d759082..344746a 100644 --- a/typescript-version/src/styles.css +++ b/typescript-version/src/styles.css @@ -1379,3 +1379,32 @@ body.theme-apple { display: flex; justify-content: flex-end; } + +.app-toast { + position: fixed; + right: 18px; + bottom: 16px; + z-index: 2200; + max-width: min(90vw, 520px); + background: rgba(20, 20, 24, 0.96); + color: #e6e6ea; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 10px 12px; + font-size: 13px; + line-height: 1.45; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + opacity: 0; + transform: translateY(10px); + pointer-events: none; + transition: opacity 0.18s ease, transform 0.18s ease; +} + +.app-toast.show { + opacity: 1; + transform: translateY(0); +} + +.app-toast.warn { + border-color: rgba(255, 167, 38, 0.7); +}