`;
}
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 @@