diff --git a/server/src/wled_controller/core/capture_engines/camera_engine.py b/server/src/wled_controller/core/capture_engines/camera_engine.py index 56582ef..301d316 100644 --- a/server/src/wled_controller/core/capture_engines/camera_engine.py +++ b/server/src/wled_controller/core/capture_engines/camera_engine.py @@ -10,6 +10,7 @@ Prerequisites (optional dependency): import platform import sys +import time from typing import Any, Dict, List, Optional import numpy as np @@ -66,23 +67,43 @@ def _get_camera_friendly_names() -> Dict[int, str]: return {} +_camera_cache: Optional[List[Dict[str, Any]]] = None +_camera_cache_time: float = 0 +_CAMERA_CACHE_TTL = 30.0 # seconds + + def _enumerate_cameras(backend_name: str = "auto") -> List[Dict[str, Any]]: """Probe camera indices and return metadata for each available camera. + Results are cached for 30 seconds to avoid repeated slow probes. Returns a list of dicts: {cv2_index, name, width, height, fps}. """ + global _camera_cache, _camera_cache_time + + now = time.monotonic() + if _camera_cache is not None and (now - _camera_cache_time) < _CAMERA_CACHE_TTL: + return _camera_cache + try: import cv2 except ImportError: + _camera_cache = [] + _camera_cache_time = now return [] backend_id = _cv2_backend_id(backend_name) friendly_names = _get_camera_friendly_names() + # On Windows, WMI tells us how many cameras exist — skip probing + # indices beyond that count to avoid slow timeouts. + max_probe = _MAX_CAMERA_INDEX + if friendly_names: + max_probe = min(len(friendly_names) + 1, _MAX_CAMERA_INDEX) + cameras: List[Dict[str, Any]] = [] sequential_idx = 0 - for i in range(_MAX_CAMERA_INDEX): + for i in range(max_probe): if backend_id is not None: cap = cv2.VideoCapture(i, backend_id) else: @@ -108,6 +129,9 @@ def _enumerate_cameras(backend_name: str = "auto") -> List[Dict[str, Any]]: }) sequential_idx += 1 + _camera_cache = cameras + _camera_cache_time = now + logger.debug(f"Camera enumeration: found {len(cameras)} camera(s) (probed {max_probe} indices)") return cameras diff --git a/server/src/wled_controller/static/css/components.css b/server/src/wled_controller/static/css/components.css index 29f57c2..6c10ef0 100644 --- a/server/src/wled_controller/static/css/components.css +++ b/server/src/wled_controller/static/css/components.css @@ -246,6 +246,25 @@ input:-webkit-autofill:focus { to { transform: rotate(360deg); } } +/* Modal body loading overlay — hides form while data loads */ +.modal-body-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 20px; + gap: 12px; +} + +.modal-body-loading .loading-spinner { + padding: 0; +} + +.modal-body-loading-text { + color: var(--text-secondary); + font-size: 0.9rem; +} + /* Full-page overlay spinner */ .overlay-spinner { position: fixed; diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index 8e8e8fd..539e8d1 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -1494,6 +1494,10 @@ export async function showAddStreamModal(presetType, cloneData = null) { document.getElementById('stream-source').onchange = () => _autoGenerateStreamName(); document.getElementById('stream-pp-template').onchange = () => _autoGenerateStreamName(); + // Open modal instantly with loading indicator + _showStreamModalLoading(true); + streamModal.open(); + await populateStreamModalDropdowns(); // Pre-fill from clone data after dropdowns are populated @@ -1518,12 +1522,19 @@ export async function showAddStreamModal(presetType, cloneData = null) { } } - streamModal.open(); + _showStreamModalLoading(false); streamModal.snapshot(); } export async function editStream(streamId) { try { + // Open modal instantly with loading indicator + document.getElementById('stream-modal-title').innerHTML = t('streams.edit'); + document.getElementById('stream-form').reset(); + document.getElementById('stream-error').style.display = 'none'; + _showStreamModalLoading(true); + streamModal.open(); + const response = await fetchWithAuth(`/picture-sources/${streamId}`); if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`); const stream = await response.json(); @@ -1533,7 +1544,6 @@ export async function editStream(streamId) { document.getElementById('stream-id').value = streamId; document.getElementById('stream-name').value = stream.name; document.getElementById('stream-description').value = stream.description || ''; - document.getElementById('stream-error').style.display = 'none'; document.getElementById('stream-type').value = stream.stream_type; set_lastValidatedImageSource(''); @@ -1565,10 +1575,11 @@ export async function editStream(streamId) { if (stream.image_source) validateStaticImage(); } - streamModal.open(); + _showStreamModalLoading(false); streamModal.snapshot(); } catch (error) { console.error('Error loading stream:', error); + streamModal.forceClose(); showToast(t('streams.error.load') + ': ' + error.message, 'error'); } } @@ -1751,6 +1762,16 @@ export async function deleteStream(streamId) { } } +/** Toggle loading overlay in stream modal — hides form while data loads. */ +function _showStreamModalLoading(show) { + const loading = document.getElementById('stream-modal-loading'); + const form = document.getElementById('stream-form'); + const footer = document.querySelector('#stream-modal .modal-footer'); + if (loading) loading.style.display = show ? '' : 'none'; + if (form) form.style.display = show ? 'none' : ''; + if (footer) footer.style.visibility = show ? 'hidden' : ''; +} + export async function closeStreamModal() { await streamModal.close(); } diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 2e94620..4f14816 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -334,6 +334,7 @@ "streams.updated": "Source updated successfully", "streams.deleted": "Source deleted successfully", "streams.delete.confirm": "Are you sure you want to delete this source?", + "streams.modal.loading": "Loading...", "streams.error.load": "Failed to load sources", "streams.error.required": "Please fill in all required fields", "streams.error.delete": "Failed to delete source", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 9772950..45347b3 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -334,6 +334,7 @@ "streams.updated": "Источник успешно обновлён", "streams.deleted": "Источник успешно удалён", "streams.delete.confirm": "Вы уверены, что хотите удалить этот источник?", + "streams.modal.loading": "Загрузка...", "streams.error.load": "Не удалось загрузить источники", "streams.error.required": "Пожалуйста, заполните все обязательные поля", "streams.error.delete": "Не удалось удалить источник", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 272663c..620d94a 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -334,6 +334,7 @@ "streams.updated": "源更新成功", "streams.deleted": "源删除成功", "streams.delete.confirm": "确定要删除此源吗?", + "streams.modal.loading": "加载中...", "streams.error.load": "加载源失败", "streams.error.required": "请填写所有必填项", "streams.error.delete": "删除源失败", diff --git a/server/src/wled_controller/templates/modals/stream.html b/server/src/wled_controller/templates/modals/stream.html index 95c162d..d8c1e38 100644 --- a/server/src/wled_controller/templates/modals/stream.html +++ b/server/src/wled_controller/templates/modals/stream.html @@ -6,6 +6,11 @@