From 05152a0f51af835ea31451e91a09137edcb30d58 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 17 Mar 2026 17:16:57 +0300 Subject: [PATCH] Settings tabs, log overlay, external URL, Sources tree restructure, audio fixes - Settings modal split into 3 tabs: General, Backup, MQTT - Log viewer moved to full-screen overlay with compact toolbar - External URL setting: API endpoints + UI for configuring server domain used in webhook/WS URLs instead of auto-detected local IP - Sources tab tree restructured: Picture Source (Screen Capture/Static/ Processed sub-groups), Color Strip, Audio, Utility - TreeNav extended to support nested groups (3-level tree) - Audio tab split into Sources and Templates sub-tabs - Fix audio template test: device picker now filters by engine type (was showing WASAPI indices for sounddevice templates) - Audio template test device picker disabled during active test - Rename "Input Source" to "Source" in CSS test preview (en/ru/zh) - Fix i18n: log filter/level items deferred to avoid stale t() calls Co-Authored-By: Claude Opus 4.6 (1M context) --- .../wled_controller/api/routes/automations.py | 7 +- .../src/wled_controller/api/routes/system.py | 59 +++++++++++++ .../src/wled_controller/api/schemas/system.py | 14 ++++ .../wled_controller/static/css/tree-nav.css | 11 +++ server/src/wled_controller/static/js/app.js | 6 ++ .../static/js/core/tree-nav.js | 70 +++++++++++----- .../static/js/features/automations.js | 3 +- .../static/js/features/color-strips.js | 11 ++- .../static/js/features/devices.js | 7 +- .../static/js/features/settings.js | 54 ++++++++++++ .../static/js/features/streams.js | 83 ++++++++++++------- .../wled_controller/static/locales/en.json | 20 ++++- .../wled_controller/static/locales/ru.json | 24 +++++- .../wled_controller/static/locales/zh.json | 20 ++++- .../templates/modals/css-editor.html | 2 +- .../templates/modals/settings.html | 13 +++ .../templates/modals/test-css-source.html | 2 +- 17 files changed, 335 insertions(+), 71 deletions(-) diff --git a/server/src/wled_controller/api/routes/automations.py b/server/src/wled_controller/api/routes/automations.py index 0b8652c..6552504 100644 --- a/server/src/wled_controller/api/routes/automations.py +++ b/server/src/wled_controller/api/routes/automations.py @@ -89,7 +89,12 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque webhook_url = None for c in automation.conditions: if isinstance(c, WebhookCondition) and c.token: - if request: + # Prefer configured external URL, fall back to request base URL + from wled_controller.api.routes.system import load_external_url + ext = load_external_url() + if ext: + webhook_url = ext + f"/api/v1/webhooks/{c.token}" + elif request: webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{c.token}" else: webhook_url = f"/api/v1/webhooks/{c.token}" diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py index f023ffe..bc7e8da 100644 --- a/server/src/wled_controller/api/routes/system.py +++ b/server/src/wled_controller/api/routes/system.py @@ -43,6 +43,8 @@ from wled_controller.api.schemas.system import ( BackupListResponse, DisplayInfo, DisplayListResponse, + ExternalUrlRequest, + ExternalUrlResponse, GpuInfo, HealthResponse, LogLevelRequest, @@ -763,6 +765,63 @@ async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest): ) +# --------------------------------------------------------------------------- +# External URL setting +# --------------------------------------------------------------------------- + +_EXTERNAL_URL_FILE: Path | None = None + + +def _get_external_url_path() -> Path: + global _EXTERNAL_URL_FILE + if _EXTERNAL_URL_FILE is None: + cfg = get_config() + data_dir = Path(cfg.storage.devices_file).parent + _EXTERNAL_URL_FILE = data_dir / "external_url.json" + return _EXTERNAL_URL_FILE + + +def load_external_url() -> str: + """Load the external URL setting. Returns empty string if not set.""" + path = _get_external_url_path() + if path.exists(): + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("external_url", "") + except Exception: + pass + return "" + + +def _save_external_url(url: str) -> None: + from wled_controller.utils import atomic_write_json + atomic_write_json(_get_external_url_path(), {"external_url": url}) + + +@router.get( + "/api/v1/system/external-url", + response_model=ExternalUrlResponse, + tags=["System"], +) +async def get_external_url(_: AuthRequired): + """Get the configured external base URL.""" + return ExternalUrlResponse(external_url=load_external_url()) + + +@router.put( + "/api/v1/system/external-url", + response_model=ExternalUrlResponse, + tags=["System"], +) +async def update_external_url(_: AuthRequired, body: ExternalUrlRequest): + """Set the external base URL used in webhook URLs and other user-visible URLs.""" + url = body.external_url.strip().rstrip("/") + _save_external_url(url) + logger.info("External URL updated: %s", url or "(cleared)") + return ExternalUrlResponse(external_url=url) + + # --------------------------------------------------------------------------- # Live log viewer WebSocket # --------------------------------------------------------------------------- diff --git a/server/src/wled_controller/api/schemas/system.py b/server/src/wled_controller/api/schemas/system.py index 219e038..819f137 100644 --- a/server/src/wled_controller/api/schemas/system.py +++ b/server/src/wled_controller/api/schemas/system.py @@ -143,6 +143,20 @@ class MQTTSettingsRequest(BaseModel): base_topic: str = Field(default="ledgrab", description="Base topic prefix") +# ─── External URL schema ─────────────────────────────────────── + +class ExternalUrlResponse(BaseModel): + """External URL setting response.""" + + external_url: str = Field(description="External base URL (e.g. https://myserver.example.com:8080). Empty = use auto-detected URL.") + + +class ExternalUrlRequest(BaseModel): + """External URL setting update request.""" + + external_url: str = Field(default="", description="External base URL. Empty string to clear.") + + # ─── Log level schemas ───────────────────────────────────────── class LogLevelResponse(BaseModel): diff --git a/server/src/wled_controller/static/css/tree-nav.css b/server/src/wled_controller/static/css/tree-nav.css index 09e3d3e..79864f4 100644 --- a/server/src/wled_controller/static/css/tree-nav.css +++ b/server/src/wled_controller/static/css/tree-nav.css @@ -99,6 +99,17 @@ text-align: center; } +/* ── Nested sub-group (group inside a group) ── */ + +.tree-group-nested > .tree-group-header { + font-size: 0.75rem; + text-transform: none; + letter-spacing: normal; + font-weight: 600; + margin-top: 2px; + padding: 4px 10px 4px 12px; +} + /* ── Children (leaves) ── */ .tree-children { diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 217300c..4222648 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -191,6 +191,7 @@ import { connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter, openLogOverlay, closeLogOverlay, loadLogLevel, setLogLevel, + saveExternalUrl, getBaseOrigin, loadExternalUrl, } from './features/settings.js'; // ─── Register all HTML onclick / onchange / onfocus globals ─── @@ -547,6 +548,8 @@ Object.assign(window, { closeLogOverlay, loadLogLevel, setLogLevel, + saveExternalUrl, + getBaseOrigin, }); // ─── Global keyboard shortcuts ─── @@ -613,6 +616,9 @@ document.addEventListener('DOMContentLoaded', async () => { // Initialize locale (dispatches languageChanged which may trigger API calls) await initLocale(); + // Load external URL setting early so getBaseOrigin() is available for card rendering + loadExternalUrl(); + // Restore active tab before showing content to avoid visible jump initTabs(); diff --git a/server/src/wled_controller/static/js/core/tree-nav.js b/server/src/wled_controller/static/js/core/tree-nav.js index 5ec7dcc..62a2aca 100644 --- a/server/src/wled_controller/static/js/core/tree-nav.js +++ b/server/src/wled_controller/static/js/core/tree-nav.js @@ -2,9 +2,12 @@ * TreeNav — hierarchical sidebar navigation for Targets and Sources tabs. * Replaces flat sub-tab bars with a collapsible tree that groups related items. * - * Config format: + * Config format (supports arbitrary nesting): * [ - * { key, titleKey, icon?, children: [{ key, titleKey, icon?, count, subTab?, sectionKey? }] }, + * { key, titleKey, icon?, children: [ + * { key, titleKey, icon?, children: [...] }, // nested group + * { key, titleKey, icon?, count } // leaf + * ] }, * { key, titleKey, icon?, count } // standalone leaf (no children) * ] */ @@ -25,6 +28,12 @@ function _saveCollapsed(key, collapsed) { localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); } +/** Recursively sum leaf counts in a tree node. */ +function _deepCount(node) { + if (!node.children) return node.count || 0; + return node.children.reduce((sum, c) => sum + _deepCount(c), 0); +} + export class TreeNav { /** * @param {string} containerId - ID of the nav element to render into @@ -71,15 +80,22 @@ export class TreeNav { const leaf = this._leafMap.get(key); if (leaf) leaf.count = count; } - // Update group counts - container.querySelectorAll('[data-tree-group]').forEach(groupEl => { + // Update group counts (bottom-up: deepest first) + const groups = [...container.querySelectorAll('[data-tree-group]')]; + groups.reverse(); + for (const groupEl of groups) { let total = 0; - groupEl.querySelectorAll('.tree-leaf .tree-count').forEach(cnt => { + // Sum direct leaf children + for (const cnt of groupEl.querySelectorAll(':scope > .tree-children > .tree-leaf .tree-count')) { total += parseInt(cnt.textContent, 10) || 0; - }); - const groupCount = groupEl.querySelector('.tree-group-count'); + } + // Sum nested sub-group counts + for (const cnt of groupEl.querySelectorAll(':scope > .tree-children > .tree-group > .tree-group-header > .tree-group-count')) { + total += parseInt(cnt.textContent, 10) || 0; + } + const groupCount = groupEl.querySelector(':scope > .tree-group-header > .tree-group-count'); if (groupCount) groupCount.textContent = total; - }); + } } /** Set extra HTML appended at the bottom (expand/collapse buttons, etc.) */ @@ -116,11 +132,13 @@ export class TreeNav { _buildLeafMap() { this._leafMap.clear(); - for (const item of this._items) { + this._collectLeaves(this._items); + } + + _collectLeaves(items) { + for (const item of items) { if (item.children) { - for (const child of item.children) { - this._leafMap.set(child.key, child); - } + this._collectLeaves(item.children); } else { this._leafMap.set(item.key, item); } @@ -135,7 +153,7 @@ export class TreeNav { const html = this._items.map(item => { if (item.children) { - return this._renderGroup(item, collapsed); + return this._renderGroup(item, collapsed, 0); } return this._renderStandalone(item); }).join(''); @@ -145,12 +163,24 @@ export class TreeNav { this._bindEvents(container); } - _renderGroup(group, collapsed) { + _renderGroup(group, collapsed, depth) { const isCollapsed = !!collapsed[group.key]; - const groupCount = group.children.reduce((sum, c) => sum + (c.count || 0), 0); + const groupCount = _deepCount(group); + + const childrenHtml = group.children.map(child => { + if (child.children) { + return this._renderGroup(child, collapsed, depth + 1); + } + return ` +
+ ${child.icon ? `${child.icon}` : ''} + ${t(child.titleKey)} + ${child.count ?? 0} +
`; + }).join(''); return ` -
+
${group.icon ? `${group.icon}` : ''} @@ -158,13 +188,7 @@ export class TreeNav { ${groupCount}
- ${group.children.map(leaf => ` -
- ${leaf.icon ? `${leaf.icon}` : ''} - ${t(leaf.titleKey)} - ${leaf.count ?? 0} -
- `).join('')} + ${childrenHtml}
`; } diff --git a/server/src/wled_controller/static/js/features/automations.js b/server/src/wled_controller/static/js/features/automations.js index 2bcb481..d61c240 100644 --- a/server/src/wled_controller/static/js/features/automations.js +++ b/server/src/wled_controller/static/js/features/automations.js @@ -13,6 +13,7 @@ import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICO import * as P from '../core/icon-paths.js'; import { wrapCard } from '../core/card-colors.js'; import { TagInput, renderTagChips } from '../core/tag-input.js'; +import { getBaseOrigin } from './settings.js'; import { IconSelect } from '../core/icon-select.js'; import { EntitySelect } from '../core/entity-palette.js'; import { attachProcessPicker } from '../core/process-picker.js'; @@ -546,7 +547,7 @@ function addAutomationConditionRow(condition) { } if (type === 'webhook') { if (data.token) { - const webhookUrl = window.location.origin + '/api/v1/webhooks/' + data.token; + const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token; container.innerHTML = `
${t('automations.condition.webhook.hint')} diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index b76f2c9..fca0b0e 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -22,6 +22,7 @@ import { TagInput, renderTagChips } from '../core/tag-input.js'; import { attachProcessPicker } from '../core/process-picker.js'; import { IconSelect, showTypePicker } from '../core/icon-select.js'; import { EntitySelect } from '../core/entity-palette.js'; +import { getBaseOrigin } from './settings.js'; import { rgbArrayToHex, hexToRgbArray, gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset, @@ -1449,7 +1450,7 @@ function _showNotificationEndpoint(cssId) { el.innerHTML = `${t('color_strip.notification.save_first')}`; return; } - const base = `${window.location.origin}/api/v1`; + const base = `${getBaseOrigin()}/api/v1`; const url = `${base}/color-strip-sources/${cssId}/notify`; el.innerHTML = ` POST @@ -2279,9 +2280,11 @@ function _showApiInputEndpoints(cssId) { el.innerHTML = `${t('color_strip.api_input.save_first')}`; return; } - const base = `${window.location.origin}/api/v1`; - const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsBase = `${wsProto}//${window.location.host}/api/v1`; + const origin = getBaseOrigin(); + const base = `${origin}/api/v1`; + const wsProto = origin.startsWith('https') ? 'wss:' : 'ws:'; + const hostPart = origin.replace(/^https?:\/\//, ''); + const wsBase = `${wsProto}//${hostPart}/api/v1`; const restUrl = `${base}/color-strip-sources/${cssId}/colors`; const apiKey = localStorage.getItem('wled_api_key') || ''; const wsUrl = `${wsBase}/color-strip-sources/${cssId}/ws?token=${encodeURIComponent(apiKey)}`; diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index dfa5f4f..4d8e07f 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -16,6 +16,7 @@ import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REF import { wrapCard } from '../core/card-colors.js'; import { TagInput, renderTagChips } from '../core/tag-input.js'; import { EntitySelect } from '../core/entity-palette.js'; +import { getBaseOrigin } from './settings.js'; let _deviceTagsInput = null; let _settingsCsptEntitySelect = null; @@ -366,9 +367,11 @@ export async function showSettings(deviceId) { const wsUrlGroup = document.getElementById('settings-ws-url-group'); if (wsUrlGroup) { if (isWs) { - const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const origin = getBaseOrigin(); + const wsProto = origin.startsWith('https') ? 'wss:' : 'ws:'; + const hostPart = origin.replace(/^https?:\/\//, ''); const apiKey = localStorage.getItem('wled_api_key') || ''; - const wsUrl = `${wsProto}//${location.host}/api/v1/devices/${device.id}/ws?token=${encodeURIComponent(apiKey)}`; + const wsUrl = `${wsProto}//${hostPart}/api/v1/devices/${device.id}/ws?token=${encodeURIComponent(apiKey)}`; document.getElementById('settings-ws-url').value = wsUrl; wsUrlGroup.style.display = ''; } else { diff --git a/server/src/wled_controller/static/js/features/settings.js b/server/src/wled_controller/static/js/features/settings.js index 2b05045..95b9127 100644 --- a/server/src/wled_controller/static/js/features/settings.js +++ b/server/src/wled_controller/static/js/features/settings.js @@ -10,6 +10,59 @@ import { t } from '../core/i18n.js'; import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js'; import { IconSelect } from '../core/icon-select.js'; +// ─── External URL (used by other modules for user-visible URLs) ── + +let _externalUrl = ''; + +/** Get the configured external base URL (empty string = not set). */ +export function getExternalUrl() { + return _externalUrl; +} + +/** + * Return the base origin for user-visible URLs (webhook, WS). + * If an external URL is configured, use that; otherwise fall back to window.location.origin. + */ +export function getBaseOrigin() { + return _externalUrl || window.location.origin; +} + +export async function loadExternalUrl() { + try { + const resp = await fetchWithAuth('/system/external-url'); + if (!resp.ok) return; + const data = await resp.json(); + _externalUrl = data.external_url || ''; + const input = document.getElementById('settings-external-url'); + if (input) input.value = _externalUrl; + } catch (err) { + console.error('Failed to load external URL:', err); + } +} + +export async function saveExternalUrl() { + const input = document.getElementById('settings-external-url'); + if (!input) return; + const url = input.value.trim().replace(/\/+$/, ''); + try { + const resp = await fetchWithAuth('/system/external-url', { + method: 'PUT', + body: JSON.stringify({ external_url: url }), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${resp.status}`); + } + const data = await resp.json(); + _externalUrl = data.external_url || ''; + input.value = _externalUrl; + showToast(t('settings.external_url.saved'), 'success'); + } catch (err) { + console.error('Failed to save external URL:', err); + showToast(t('settings.external_url.save_error') + ': ' + err.message, 'error'); + } +} + // ─── Settings-modal tab switching ─────────────────────────── export function switchSettingsTab(tabId) { @@ -221,6 +274,7 @@ export function openSettingsModal() { } loadApiKeysList(); + loadExternalUrl(); loadAutoBackupSettings(); loadBackupList(); loadMqttSettings(); diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index 1c9bc06..ad65bbe 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -1031,13 +1031,20 @@ const testAudioTemplateModal = new Modal('test-audio-template-modal', { backdrop export async function showTestAudioTemplateModal(templateId) { _currentTestAudioTemplateId = templateId; - // Load audio devices for picker + // Find template's engine type so we show the correct device list + const template = _cachedAudioTemplates.find(t => t.id === templateId); + const engineType = template ? template.engine_type : null; + + // Load audio devices for picker — filter by engine type const deviceSelect = document.getElementById('test-audio-template-device'); try { const resp = await fetchWithAuth('/audio-devices'); if (resp.ok) { const data = await resp.json(); - const devices = data.devices || []; + // Use engine-specific device list if available, fall back to flat list + const devices = (engineType && data.by_engine && data.by_engine[engineType]) + ? data.by_engine[engineType] + : (data.devices || []); deviceSelect.innerHTML = devices.map(d => { const label = d.name; const val = `${d.index}:${d.is_loopback ? '1' : '0'}`; @@ -1082,10 +1089,11 @@ export function startAudioTemplateTest() { const [devIdx, devLoop] = deviceVal.split(':'); localStorage.setItem('lastAudioTestDevice', deviceVal); - // Show canvas + stats, hide run button + // Show canvas + stats, hide run button, disable device picker document.getElementById('audio-template-test-canvas').style.display = ''; document.getElementById('audio-template-test-stats').style.display = ''; document.getElementById('test-audio-template-start-btn').style.display = 'none'; + document.getElementById('test-audio-template-device').disabled = true; const statusEl = document.getElementById('audio-template-test-status'); statusEl.textContent = t('audio_source.test.connecting'); @@ -1144,6 +1152,9 @@ function _tplCleanupTest() { _tplTestWs = null; } _tplTestLatest = null; + // Re-enable device picker + const devSel = document.getElementById('test-audio-template-device'); + if (devSel) devSel.disabled = false; } function _tplSizeCanvas(canvas) { @@ -1283,7 +1294,8 @@ const _streamSectionMap = { proc_templates: [csProcTemplates], css_processing: [csCSPTemplates], color_strip: [csColorStrips], - audio: [csAudioMulti, csAudioMono, csAudioTemplates], + audio: [csAudioMulti, csAudioMono], + audio_templates: [csAudioTemplates], value: [csValueSources], sync: [csSyncClocks], }; @@ -1490,6 +1502,7 @@ function renderPictureSourcesList(streams) { { key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length }, { key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip', count: colorStrips.length }, { key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', count: _cachedAudioSources.length }, + { key: 'audio_templates', icon: ICON_AUDIO_TEMPLATE, titleKey: 'streams.group.audio_templates', count: _cachedAudioTemplates.length }, { key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length }, { key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length }, ]; @@ -1497,37 +1510,44 @@ function renderPictureSourcesList(streams) { // Build tree navigation structure const treeGroups = [ { - key: 'capture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.capture', + key: 'picture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.picture', children: [ - { key: 'raw', titleKey: 'streams.group.raw', icon: getPictureSourceIcon('raw'), count: rawStreams.length }, - { key: 'raw_templates', titleKey: 'streams.group.raw_templates', icon: ICON_CAPTURE_TEMPLATE, count: _cachedCaptureTemplates.length }, - ] - }, - { - key: 'static_image', icon: getPictureSourceIcon('static_image'), titleKey: 'streams.group.static_image', - count: staticImageStreams.length, - }, - { - key: 'video', icon: getPictureSourceIcon('video'), titleKey: 'streams.group.video', - count: videoStreams.length, - }, - { - key: 'processing_group', icon: getPictureSourceIcon('processed'), titleKey: 'tree.group.processing', - children: [ - { key: 'processed', titleKey: 'streams.group.processed', icon: getPictureSourceIcon('processed'), count: processedStreams.length }, - { key: 'proc_templates', titleKey: 'streams.group.proc_templates', icon: ICON_PP_TEMPLATE, count: _cachedPPTemplates.length }, + { + key: 'capture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.capture', + children: [ + { key: 'raw', titleKey: 'tree.leaf.sources', icon: getPictureSourceIcon('raw'), count: rawStreams.length }, + { key: 'raw_templates', titleKey: 'tree.leaf.engine_templates', icon: ICON_CAPTURE_TEMPLATE, count: _cachedCaptureTemplates.length }, + ] + }, + { + key: 'static_group', icon: getPictureSourceIcon('static_image'), titleKey: 'tree.group.static', + children: [ + { key: 'static_image', titleKey: 'tree.leaf.images', icon: getPictureSourceIcon('static_image'), count: staticImageStreams.length }, + { key: 'video', titleKey: 'tree.leaf.video', icon: getPictureSourceIcon('video'), count: videoStreams.length }, + ] + }, + { + key: 'processing_group', icon: getPictureSourceIcon('processed'), titleKey: 'tree.group.processing', + children: [ + { key: 'processed', titleKey: 'tree.leaf.sources', icon: getPictureSourceIcon('processed'), count: processedStreams.length }, + { key: 'proc_templates', titleKey: 'tree.leaf.filter_templates', icon: ICON_PP_TEMPLATE, count: _cachedPPTemplates.length }, + ] + }, ] }, { key: 'strip_group', icon: getColorStripIcon('static'), titleKey: 'tree.group.strip', children: [ - { key: 'color_strip', titleKey: 'streams.group.color_strip', icon: getColorStripIcon('static'), count: colorStrips.length }, - { key: 'css_processing', titleKey: 'streams.group.css_processing', icon: ICON_CSPT, count: csptTemplates.length }, + { key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('static'), count: colorStrips.length }, + { key: 'css_processing', titleKey: 'tree.leaf.processing_templates', icon: ICON_CSPT, count: csptTemplates.length }, ] }, { - key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', - count: _cachedAudioSources.length + _cachedAudioTemplates.length, + key: 'audio_group', icon: getAudioSourceIcon('multichannel'), titleKey: 'tree.group.audio', + children: [ + { key: 'audio', titleKey: 'tree.leaf.sources', icon: getAudioSourceIcon('multichannel'), count: _cachedAudioSources.length }, + { key: 'audio_templates', titleKey: 'tree.leaf.templates', icon: ICON_AUDIO_TEMPLATE, count: _cachedAudioTemplates.length }, + ] }, { key: 'utility_group', icon: ICON_WRENCH, titleKey: 'tree.group.utility', @@ -1559,7 +1579,7 @@ function renderPictureSourcesList(streams) { const loopback = src.is_loopback !== false; const devLabel = loopback ? `${ICON_AUDIO_LOOPBACK} Loopback` : `${ICON_AUDIO_INPUT} Input`; const tpl = src.audio_template_id ? _cachedAudioTemplates.find(t => t.id === src.audio_template_id) : null; - const tplBadge = tpl ? `${ICON_AUDIO_TEMPLATE} ${escapeHtml(tpl.name)}` : ''; + const tplBadge = tpl ? `${ICON_AUDIO_TEMPLATE} ${escapeHtml(tpl.name)}` : ''; propsHtml = `${devLabel} #${devIdx}${tplBadge}`; } @@ -1651,7 +1671,8 @@ function renderPictureSourcesList(streams) { proc_templates: _cachedPPTemplates.length, css_processing: csptTemplates.length, color_strip: colorStrips.length, - audio: _cachedAudioSources.length + _cachedAudioTemplates.length, + audio: _cachedAudioSources.length, + audio_templates: _cachedAudioTemplates.length, value: _cachedValueSources.length, sync: _cachedSyncClocks.length, }); @@ -1678,7 +1699,8 @@ function renderPictureSourcesList(streams) { else if (tab.key === 'proc_templates') panelContent = csProcTemplates.render(procTemplateItems); else if (tab.key === 'css_processing') panelContent = csCSPTemplates.render(csptItems); else if (tab.key === 'color_strip') panelContent = csColorStrips.render(colorStripItems); - else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems) + csAudioTemplates.render(audioTemplateItems); + else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems); + else if (tab.key === 'audio_templates') panelContent = csAudioTemplates.render(audioTemplateItems); else if (tab.key === 'value') panelContent = csValueSources.render(valueItems); else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems); else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems); @@ -1699,7 +1721,8 @@ function renderPictureSourcesList(streams) { 'proc-streams': 'processed', 'proc-templates': 'proc_templates', 'css-proc-templates': 'css_processing', 'color-strips': 'color_strip', - 'audio-multi': 'audio', 'audio-mono': 'audio', 'audio-templates': 'audio', + 'audio-multi': 'audio', 'audio-mono': 'audio', + 'audio-templates': 'audio_templates', 'value-sources': 'value', 'sync-clocks': 'sync', }); diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 46d62a5..8448788 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -316,6 +316,12 @@ "settings.tab.backup": "Backup", "settings.tab.mqtt": "MQTT", "settings.logs.open_viewer": "Open Log Viewer", + "settings.external_url.label": "External URL", + "settings.external_url.hint": "If set, this base URL is used in webhook URLs and other user-visible links instead of the auto-detected local IP. Example: https://myserver.example.com:8080", + "settings.external_url.placeholder": "https://myserver.example.com:8080", + "settings.external_url.save": "Save", + "settings.external_url.saved": "External URL saved", + "settings.external_url.save_error": "Failed to save external URL", "settings.general.title": "General Settings", "settings.capture.title": "Capture Settings", "settings.capture.saved": "Capture settings updated", @@ -447,6 +453,7 @@ "streams.group.css_processing": "Processing Templates", "streams.group.color_strip": "Color Strips", "streams.group.audio": "Audio", + "streams.group.audio_templates": "Audio Templates", "streams.section.streams": "Sources", "streams.add": "Add Source", "streams.add.raw": "Add Screen Capture", @@ -1113,7 +1120,7 @@ "color_strip.type.processed": "Processed", "color_strip.type.processed.desc": "Apply a processing template to another source", "color_strip.type.processed.hint": "Wraps an existing color strip source and pipes its output through a filter chain.", - "color_strip.processed.input": "Input Source:", + "color_strip.processed.input": "Source:", "color_strip.processed.input.hint": "The color strip source whose output will be processed", "color_strip.processed.template": "Processing Template:", "color_strip.processed.template.hint": "Filter chain to apply to the input source output", @@ -1276,11 +1283,20 @@ "audio_template.error.delete": "Failed to delete audio template", "streams.group.value": "Value Sources", "streams.group.sync": "Sync Clocks", + "tree.group.picture": "Picture Source", "tree.group.capture": "Screen Capture", + "tree.group.static": "Static", "tree.group.processing": "Processed", - "tree.group.picture": "Picture", "tree.group.strip": "Color Strip", + "tree.group.audio": "Audio", "tree.group.utility": "Utility", + "tree.leaf.sources": "Sources", + "tree.leaf.engine_templates": "Engine Templates", + "tree.leaf.images": "Images", + "tree.leaf.video": "Video", + "tree.leaf.filter_templates": "Filter Templates", + "tree.leaf.processing_templates": "Processing Templates", + "tree.leaf.templates": "Templates", "value_source.group.title": "Value Sources", "value_source.select_type": "Select Value Source Type", "value_source.add": "Add Value Source", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 84f606a..41b180d 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -316,6 +316,12 @@ "settings.tab.backup": "Бэкап", "settings.tab.mqtt": "MQTT", "settings.logs.open_viewer": "Открыть логи", + "settings.external_url.label": "Внешний URL", + "settings.external_url.hint": "Если указан, этот базовый URL используется в URL-ах вебхуков и других пользовательских ссылках вместо автоопределённого локального IP. Пример: https://myserver.example.com:8080", + "settings.external_url.placeholder": "https://myserver.example.com:8080", + "settings.external_url.save": "Сохранить", + "settings.external_url.saved": "Внешний URL сохранён", + "settings.external_url.save_error": "Не удалось сохранить внешний URL", "settings.general.title": "Основные Настройки", "settings.capture.title": "Настройки Захвата", "settings.capture.saved": "Настройки захвата обновлены", @@ -447,6 +453,7 @@ "streams.group.css_processing": "Шаблоны Обработки", "streams.group.color_strip": "Цветовые Полосы", "streams.group.audio": "Аудио", + "streams.group.audio_templates": "Аудио шаблоны", "streams.section.streams": "Источники", "streams.add": "Добавить Источник", "streams.add.raw": "Добавить Захват Экрана", @@ -1113,7 +1120,7 @@ "color_strip.type.processed": "Обработанный", "color_strip.type.processed.desc": "Применить шаблон обработки к другому источнику", "color_strip.type.processed.hint": "Оборачивает существующий источник цветовой полосы и пропускает его вывод через цепочку фильтров.", - "color_strip.processed.input": "Входной источник:", + "color_strip.processed.input": "Источник:", "color_strip.processed.input.hint": "Источник цветовой полосы, вывод которого будет обработан", "color_strip.processed.template": "Шаблон обработки:", "color_strip.processed.template.hint": "Цепочка фильтров для применения к выводу входного источника", @@ -1276,11 +1283,20 @@ "audio_template.error.delete": "Не удалось удалить аудиошаблон", "streams.group.value": "Источники значений", "streams.group.sync": "Часы синхронизации", - "tree.group.capture": "Захват Экрана", + "tree.group.picture": "Источники изображений", + "tree.group.capture": "Захват экрана", + "tree.group.static": "Статичные", "tree.group.processing": "Обработанные", - "tree.group.picture": "Изображения", - "tree.group.strip": "Цветовые Полосы", + "tree.group.strip": "Цветовые полосы", + "tree.group.audio": "Аудио", "tree.group.utility": "Утилиты", + "tree.leaf.sources": "Источники", + "tree.leaf.engine_templates": "Шаблоны движка", + "tree.leaf.images": "Изображения", + "tree.leaf.video": "Видео", + "tree.leaf.filter_templates": "Шаблоны фильтров", + "tree.leaf.processing_templates": "Шаблоны обработки", + "tree.leaf.templates": "Шаблоны", "value_source.group.title": "Источники значений", "value_source.select_type": "Выберите тип источника значений", "value_source.add": "Добавить источник значений", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index cae6b3b..092b8ae 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -316,6 +316,12 @@ "settings.tab.backup": "备份", "settings.tab.mqtt": "MQTT", "settings.logs.open_viewer": "打开日志查看器", + "settings.external_url.label": "外部 URL", + "settings.external_url.hint": "设置后,此基础 URL 将用于 webhook 链接和其他用户可见的链接,代替自动检测的本地 IP。示例:https://myserver.example.com:8080", + "settings.external_url.placeholder": "https://myserver.example.com:8080", + "settings.external_url.save": "保存", + "settings.external_url.saved": "外部 URL 已保存", + "settings.external_url.save_error": "保存外部 URL 失败", "settings.general.title": "常规设置", "settings.capture.title": "采集设置", "settings.capture.saved": "采集设置已更新", @@ -447,6 +453,7 @@ "streams.group.css_processing": "处理模板", "streams.group.color_strip": "色带源", "streams.group.audio": "音频", + "streams.group.audio_templates": "音频模板", "streams.section.streams": "源", "streams.add": "添加源", "streams.add.raw": "添加屏幕采集", @@ -1113,7 +1120,7 @@ "color_strip.type.processed": "已处理", "color_strip.type.processed.desc": "将处理模板应用于另一个源", "color_strip.type.processed.hint": "包装现有色带源并通过滤镜链处理其输出。", - "color_strip.processed.input": "输入源:", + "color_strip.processed.input": "源:", "color_strip.processed.input.hint": "将被处理的色带源", "color_strip.processed.template": "处理模板:", "color_strip.processed.template.hint": "应用于输入源输出的滤镜链", @@ -1276,11 +1283,20 @@ "audio_template.error.delete": "删除音频模板失败", "streams.group.value": "值源", "streams.group.sync": "同步时钟", + "tree.group.picture": "图片源", "tree.group.capture": "屏幕采集", + "tree.group.static": "静态", "tree.group.processing": "已处理", - "tree.group.picture": "图片", "tree.group.strip": "色带", + "tree.group.audio": "音频", "tree.group.utility": "工具", + "tree.leaf.sources": "源", + "tree.leaf.engine_templates": "引擎模板", + "tree.leaf.images": "图片", + "tree.leaf.video": "视频", + "tree.leaf.filter_templates": "滤镜模板", + "tree.leaf.processing_templates": "处理模板", + "tree.leaf.templates": "模板", "value_source.group.title": "值源", "value_source.select_type": "选择值源类型", "value_source.add": "添加值源", diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index 81d2bd7..b65844f 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -567,7 +567,7 @@