diff --git a/server/src/wled_controller/static/js/core/ui.js b/server/src/wled_controller/static/js/core/ui.js index 9ff5bf2..88b6222 100644 --- a/server/src/wled_controller/static/js/core/ui.js +++ b/server/src/wled_controller/static/js/core/ui.js @@ -163,10 +163,28 @@ export function showOverlaySpinner(text, duration = 0) { existing.remove(); } + // AbortController for cancelling in-flight fetch + window._overlayAbortController = new AbortController(); + const overlay = document.createElement('div'); overlay.id = 'overlay-spinner'; overlay.className = 'overlay-spinner'; + // Close button + const closeBtn = document.createElement('button'); + closeBtn.className = 'overlay-spinner-close'; + closeBtn.innerHTML = '×'; + closeBtn.title = 'Cancel'; + closeBtn.onclick = () => hideOverlaySpinner(); + overlay.appendChild(closeBtn); + + // ESC key handler + function onEsc(e) { + if (e.key === 'Escape') hideOverlaySpinner(); + } + window._overlayEscHandler = onEsc; + document.addEventListener('keydown', onEsc); + const progressContainer = document.createElement('div'); progressContainer.className = 'progress-container'; @@ -235,6 +253,14 @@ export function hideOverlaySpinner() { clearInterval(window.overlaySpinnerTimer); window.overlaySpinnerTimer = null; } + if (window._overlayEscHandler) { + document.removeEventListener('keydown', window._overlayEscHandler); + window._overlayEscHandler = null; + } + if (window._overlayAbortController) { + window._overlayAbortController.abort(); + window._overlayAbortController = null; + } const overlay = document.getElementById('overlay-spinner'); if (overlay) overlay.remove(); } diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index 5ef57da..4bc4269 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -298,6 +298,7 @@ export async function runTemplateTest() { const template = window.currentTestingTemplate; showOverlaySpinner(t('templates.test.running'), captureDuration); + const signal = window._overlayAbortController?.signal; try { const response = await fetchWithAuth('/capture-templates/test', { @@ -307,7 +308,8 @@ export async function runTemplateTest() { engine_config: template.engine_config, display_index: parseInt(displayIndex), capture_duration: captureDuration - }) + }), + signal }); if (!response.ok) { @@ -319,6 +321,7 @@ export async function runTemplateTest() { localStorage.setItem('lastTestDisplayIndex', displayIndex); displayTestResults(result); } catch (error) { + if (error.name === 'AbortError') return; console.error('Error running test:', error); hideOverlaySpinner(); showToast(t('templates.test.error.failed'), 'error'); @@ -980,11 +983,13 @@ export async function runStreamTest() { if (!_currentTestStreamId) return; const captureDuration = parseFloat(document.getElementById('test-stream-duration').value); showOverlaySpinner(t('streams.test.running'), captureDuration); + const signal = window._overlayAbortController?.signal; try { const response = await fetchWithAuth(`/picture-sources/${_currentTestStreamId}/test`, { method: 'POST', - body: JSON.stringify({ capture_duration: captureDuration }) + body: JSON.stringify({ capture_duration: captureDuration }), + signal }); if (!response.ok) { const error = await response.json(); @@ -995,6 +1000,7 @@ export async function runStreamTest() { const fullImageSrc = result.full_capture.full_image || result.full_capture.image; openLightbox(fullImageSrc, buildTestStatsHtml(result)); } catch (error) { + if (error.name === 'AbortError') return; console.error('Error running stream test:', error); hideOverlaySpinner(); showToast(t('streams.test.error.failed') + ': ' + error.message, 'error'); @@ -1057,11 +1063,13 @@ export async function runPPTemplateTest() { const captureDuration = parseFloat(document.getElementById('test-pp-duration').value); showOverlaySpinner(t('postprocessing.test.running'), captureDuration); + const signal = window._overlayAbortController?.signal; try { const response = await fetchWithAuth(`/postprocessing-templates/${_currentTestPPTemplateId}/test`, { method: 'POST', - body: JSON.stringify({ source_stream_id: sourceStreamId, capture_duration: captureDuration }) + body: JSON.stringify({ source_stream_id: sourceStreamId, capture_duration: captureDuration }), + signal }); if (!response.ok) { const error = await response.json(); @@ -1072,6 +1080,7 @@ export async function runPPTemplateTest() { const fullImageSrc = result.full_capture.full_image || result.full_capture.image; openLightbox(fullImageSrc, buildTestStatsHtml(result)); } catch (error) { + if (error.name === 'AbortError') return; console.error('Error running PP template test:', error); hideOverlaySpinner(); showToast(t('postprocessing.test.error.failed') + ': ' + error.message, 'error'); diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index defefb9..65b886b 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -237,6 +237,7 @@ "confirm.title": "Confirm Action", "confirm.yes": "Yes", "confirm.no": "No", + "common.loading": "Loading...", "common.delete": "Delete", "common.edit": "Edit", "streams.title": "\uD83D\uDCFA Sources", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 5f53c64..e568873 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -237,6 +237,7 @@ "confirm.title": "Подтверждение Действия", "confirm.yes": "Да", "confirm.no": "Нет", + "common.loading": "Загрузка...", "common.delete": "Удалить", "common.edit": "Редактировать", "streams.title": "\uD83D\uDCFA Источники", diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css index 15e41a7..4c41f44 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -1006,6 +1006,24 @@ input:-webkit-autofill:focus { font-weight: 500; } +.overlay-spinner-close { + position: absolute; + top: 16px; + right: 16px; + background: none; + border: none; + color: rgba(255, 255, 255, 0.6); + font-size: 32px; + cursor: pointer; + line-height: 1; + padding: 4px 8px; + transition: color 0.15s; +} + +.overlay-spinner-close:hover { + color: white; +} + @keyframes spin { to { transform: rotate(360deg); } }