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) <noreply@anthropic.com>
This commit is contained in:
@@ -89,7 +89,12 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque
|
|||||||
webhook_url = None
|
webhook_url = None
|
||||||
for c in automation.conditions:
|
for c in automation.conditions:
|
||||||
if isinstance(c, WebhookCondition) and c.token:
|
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}"
|
webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{c.token}"
|
||||||
else:
|
else:
|
||||||
webhook_url = f"/api/v1/webhooks/{c.token}"
|
webhook_url = f"/api/v1/webhooks/{c.token}"
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ from wled_controller.api.schemas.system import (
|
|||||||
BackupListResponse,
|
BackupListResponse,
|
||||||
DisplayInfo,
|
DisplayInfo,
|
||||||
DisplayListResponse,
|
DisplayListResponse,
|
||||||
|
ExternalUrlRequest,
|
||||||
|
ExternalUrlResponse,
|
||||||
GpuInfo,
|
GpuInfo,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
LogLevelRequest,
|
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
|
# Live log viewer WebSocket
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -143,6 +143,20 @@ class MQTTSettingsRequest(BaseModel):
|
|||||||
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
|
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 ─────────────────────────────────────────
|
# ─── Log level schemas ─────────────────────────────────────────
|
||||||
|
|
||||||
class LogLevelResponse(BaseModel):
|
class LogLevelResponse(BaseModel):
|
||||||
|
|||||||
@@ -99,6 +99,17 @@
|
|||||||
text-align: center;
|
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) ── */
|
/* ── Children (leaves) ── */
|
||||||
|
|
||||||
.tree-children {
|
.tree-children {
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ import {
|
|||||||
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
|
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
|
||||||
openLogOverlay, closeLogOverlay,
|
openLogOverlay, closeLogOverlay,
|
||||||
loadLogLevel, setLogLevel,
|
loadLogLevel, setLogLevel,
|
||||||
|
saveExternalUrl, getBaseOrigin, loadExternalUrl,
|
||||||
} from './features/settings.js';
|
} from './features/settings.js';
|
||||||
|
|
||||||
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
||||||
@@ -547,6 +548,8 @@ Object.assign(window, {
|
|||||||
closeLogOverlay,
|
closeLogOverlay,
|
||||||
loadLogLevel,
|
loadLogLevel,
|
||||||
setLogLevel,
|
setLogLevel,
|
||||||
|
saveExternalUrl,
|
||||||
|
getBaseOrigin,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Global keyboard shortcuts ───
|
// ─── Global keyboard shortcuts ───
|
||||||
@@ -613,6 +616,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// Initialize locale (dispatches languageChanged which may trigger API calls)
|
// Initialize locale (dispatches languageChanged which may trigger API calls)
|
||||||
await initLocale();
|
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
|
// Restore active tab before showing content to avoid visible jump
|
||||||
initTabs();
|
initTabs();
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
* TreeNav — hierarchical sidebar navigation for Targets and Sources tabs.
|
* TreeNav — hierarchical sidebar navigation for Targets and Sources tabs.
|
||||||
* Replaces flat sub-tab bars with a collapsible tree that groups related items.
|
* 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)
|
* { key, titleKey, icon?, count } // standalone leaf (no children)
|
||||||
* ]
|
* ]
|
||||||
*/
|
*/
|
||||||
@@ -25,6 +28,12 @@ function _saveCollapsed(key, collapsed) {
|
|||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
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 {
|
export class TreeNav {
|
||||||
/**
|
/**
|
||||||
* @param {string} containerId - ID of the nav element to render into
|
* @param {string} containerId - ID of the nav element to render into
|
||||||
@@ -71,15 +80,22 @@ export class TreeNav {
|
|||||||
const leaf = this._leafMap.get(key);
|
const leaf = this._leafMap.get(key);
|
||||||
if (leaf) leaf.count = count;
|
if (leaf) leaf.count = count;
|
||||||
}
|
}
|
||||||
// Update group counts
|
// Update group counts (bottom-up: deepest first)
|
||||||
container.querySelectorAll('[data-tree-group]').forEach(groupEl => {
|
const groups = [...container.querySelectorAll('[data-tree-group]')];
|
||||||
|
groups.reverse();
|
||||||
|
for (const groupEl of groups) {
|
||||||
let total = 0;
|
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;
|
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;
|
if (groupCount) groupCount.textContent = total;
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set extra HTML appended at the bottom (expand/collapse buttons, etc.) */
|
/** Set extra HTML appended at the bottom (expand/collapse buttons, etc.) */
|
||||||
@@ -116,11 +132,13 @@ export class TreeNav {
|
|||||||
|
|
||||||
_buildLeafMap() {
|
_buildLeafMap() {
|
||||||
this._leafMap.clear();
|
this._leafMap.clear();
|
||||||
for (const item of this._items) {
|
this._collectLeaves(this._items);
|
||||||
|
}
|
||||||
|
|
||||||
|
_collectLeaves(items) {
|
||||||
|
for (const item of items) {
|
||||||
if (item.children) {
|
if (item.children) {
|
||||||
for (const child of item.children) {
|
this._collectLeaves(item.children);
|
||||||
this._leafMap.set(child.key, child);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this._leafMap.set(item.key, item);
|
this._leafMap.set(item.key, item);
|
||||||
}
|
}
|
||||||
@@ -135,7 +153,7 @@ export class TreeNav {
|
|||||||
|
|
||||||
const html = this._items.map(item => {
|
const html = this._items.map(item => {
|
||||||
if (item.children) {
|
if (item.children) {
|
||||||
return this._renderGroup(item, collapsed);
|
return this._renderGroup(item, collapsed, 0);
|
||||||
}
|
}
|
||||||
return this._renderStandalone(item);
|
return this._renderStandalone(item);
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -145,12 +163,24 @@ export class TreeNav {
|
|||||||
this._bindEvents(container);
|
this._bindEvents(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderGroup(group, collapsed) {
|
_renderGroup(group, collapsed, depth) {
|
||||||
const isCollapsed = !!collapsed[group.key];
|
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 `
|
||||||
|
<div class="tree-leaf${child.key === this._activeLeaf ? ' active' : ''}" data-tree-leaf="${child.key}">
|
||||||
|
${child.icon ? `<span class="tree-node-icon">${child.icon}</span>` : ''}
|
||||||
|
<span class="tree-node-title" data-i18n="${child.titleKey}">${t(child.titleKey)}</span>
|
||||||
|
<span class="tree-count">${child.count ?? 0}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="tree-group" data-tree-group="${group.key}">
|
<div class="tree-group${depth > 0 ? ' tree-group-nested' : ''}" data-tree-group="${group.key}">
|
||||||
<div class="tree-group-header" data-tree-group-toggle="${group.key}">
|
<div class="tree-group-header" data-tree-group-toggle="${group.key}">
|
||||||
<span class="tree-chevron${isCollapsed ? '' : ' open'}">▶</span>
|
<span class="tree-chevron${isCollapsed ? '' : ' open'}">▶</span>
|
||||||
${group.icon ? `<span class="tree-node-icon">${group.icon}</span>` : ''}
|
${group.icon ? `<span class="tree-node-icon">${group.icon}</span>` : ''}
|
||||||
@@ -158,13 +188,7 @@ export class TreeNav {
|
|||||||
<span class="tree-group-count">${groupCount}</span>
|
<span class="tree-group-count">${groupCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tree-children${isCollapsed ? ' collapsed' : ''}">
|
<div class="tree-children${isCollapsed ? ' collapsed' : ''}">
|
||||||
${group.children.map(leaf => `
|
${childrenHtml}
|
||||||
<div class="tree-leaf${leaf.key === this._activeLeaf ? ' active' : ''}" data-tree-leaf="${leaf.key}">
|
|
||||||
${leaf.icon ? `<span class="tree-node-icon">${leaf.icon}</span>` : ''}
|
|
||||||
<span class="tree-node-title" data-i18n="${leaf.titleKey}">${t(leaf.titleKey)}</span>
|
|
||||||
<span class="tree-count">${leaf.count ?? 0}</span>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 * as P from '../core/icon-paths.js';
|
||||||
import { wrapCard } from '../core/card-colors.js';
|
import { wrapCard } from '../core/card-colors.js';
|
||||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||||
|
import { getBaseOrigin } from './settings.js';
|
||||||
import { IconSelect } from '../core/icon-select.js';
|
import { IconSelect } from '../core/icon-select.js';
|
||||||
import { EntitySelect } from '../core/entity-palette.js';
|
import { EntitySelect } from '../core/entity-palette.js';
|
||||||
import { attachProcessPicker } from '../core/process-picker.js';
|
import { attachProcessPicker } from '../core/process-picker.js';
|
||||||
@@ -546,7 +547,7 @@ function addAutomationConditionRow(condition) {
|
|||||||
}
|
}
|
||||||
if (type === 'webhook') {
|
if (type === 'webhook') {
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
const webhookUrl = window.location.origin + '/api/v1/webhooks/' + data.token;
|
const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="condition-fields">
|
<div class="condition-fields">
|
||||||
<small class="condition-always-desc">${t('automations.condition.webhook.hint')}</small>
|
<small class="condition-always-desc">${t('automations.condition.webhook.hint')}</small>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { TagInput, renderTagChips } from '../core/tag-input.js';
|
|||||||
import { attachProcessPicker } from '../core/process-picker.js';
|
import { attachProcessPicker } from '../core/process-picker.js';
|
||||||
import { IconSelect, showTypePicker } from '../core/icon-select.js';
|
import { IconSelect, showTypePicker } from '../core/icon-select.js';
|
||||||
import { EntitySelect } from '../core/entity-palette.js';
|
import { EntitySelect } from '../core/entity-palette.js';
|
||||||
|
import { getBaseOrigin } from './settings.js';
|
||||||
import {
|
import {
|
||||||
rgbArrayToHex, hexToRgbArray,
|
rgbArrayToHex, hexToRgbArray,
|
||||||
gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset,
|
gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset,
|
||||||
@@ -1449,7 +1450,7 @@ function _showNotificationEndpoint(cssId) {
|
|||||||
el.innerHTML = `<em data-i18n="color_strip.notification.save_first">${t('color_strip.notification.save_first')}</em>`;
|
el.innerHTML = `<em data-i18n="color_strip.notification.save_first">${t('color_strip.notification.save_first')}</em>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const base = `${window.location.origin}/api/v1`;
|
const base = `${getBaseOrigin()}/api/v1`;
|
||||||
const url = `${base}/color-strip-sources/${cssId}/notify`;
|
const url = `${base}/color-strip-sources/${cssId}/notify`;
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<small class="endpoint-label">POST</small>
|
<small class="endpoint-label">POST</small>
|
||||||
@@ -2279,9 +2280,11 @@ function _showApiInputEndpoints(cssId) {
|
|||||||
el.innerHTML = `<em data-i18n="color_strip.api_input.save_first">${t('color_strip.api_input.save_first')}</em>`;
|
el.innerHTML = `<em data-i18n="color_strip.api_input.save_first">${t('color_strip.api_input.save_first')}</em>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const base = `${window.location.origin}/api/v1`;
|
const origin = getBaseOrigin();
|
||||||
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const base = `${origin}/api/v1`;
|
||||||
const wsBase = `${wsProto}//${window.location.host}/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 restUrl = `${base}/color-strip-sources/${cssId}/colors`;
|
||||||
const apiKey = localStorage.getItem('wled_api_key') || '';
|
const apiKey = localStorage.getItem('wled_api_key') || '';
|
||||||
const wsUrl = `${wsBase}/color-strip-sources/${cssId}/ws?token=${encodeURIComponent(apiKey)}`;
|
const wsUrl = `${wsBase}/color-strip-sources/${cssId}/ws?token=${encodeURIComponent(apiKey)}`;
|
||||||
|
|||||||
@@ -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 { wrapCard } from '../core/card-colors.js';
|
||||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||||
import { EntitySelect } from '../core/entity-palette.js';
|
import { EntitySelect } from '../core/entity-palette.js';
|
||||||
|
import { getBaseOrigin } from './settings.js';
|
||||||
|
|
||||||
let _deviceTagsInput = null;
|
let _deviceTagsInput = null;
|
||||||
let _settingsCsptEntitySelect = null;
|
let _settingsCsptEntitySelect = null;
|
||||||
@@ -366,9 +367,11 @@ export async function showSettings(deviceId) {
|
|||||||
const wsUrlGroup = document.getElementById('settings-ws-url-group');
|
const wsUrlGroup = document.getElementById('settings-ws-url-group');
|
||||||
if (wsUrlGroup) {
|
if (wsUrlGroup) {
|
||||||
if (isWs) {
|
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 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;
|
document.getElementById('settings-ws-url').value = wsUrl;
|
||||||
wsUrlGroup.style.display = '';
|
wsUrlGroup.style.display = '';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,6 +10,59 @@ import { t } from '../core/i18n.js';
|
|||||||
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js';
|
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js';
|
||||||
import { IconSelect } from '../core/icon-select.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 ───────────────────────────
|
// ─── Settings-modal tab switching ───────────────────────────
|
||||||
|
|
||||||
export function switchSettingsTab(tabId) {
|
export function switchSettingsTab(tabId) {
|
||||||
@@ -221,6 +274,7 @@ export function openSettingsModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadApiKeysList();
|
loadApiKeysList();
|
||||||
|
loadExternalUrl();
|
||||||
loadAutoBackupSettings();
|
loadAutoBackupSettings();
|
||||||
loadBackupList();
|
loadBackupList();
|
||||||
loadMqttSettings();
|
loadMqttSettings();
|
||||||
|
|||||||
@@ -1031,13 +1031,20 @@ const testAudioTemplateModal = new Modal('test-audio-template-modal', { backdrop
|
|||||||
export async function showTestAudioTemplateModal(templateId) {
|
export async function showTestAudioTemplateModal(templateId) {
|
||||||
_currentTestAudioTemplateId = 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');
|
const deviceSelect = document.getElementById('test-audio-template-device');
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth('/audio-devices');
|
const resp = await fetchWithAuth('/audio-devices');
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const data = await resp.json();
|
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 => {
|
deviceSelect.innerHTML = devices.map(d => {
|
||||||
const label = d.name;
|
const label = d.name;
|
||||||
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
|
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
|
||||||
@@ -1082,10 +1089,11 @@ export function startAudioTemplateTest() {
|
|||||||
const [devIdx, devLoop] = deviceVal.split(':');
|
const [devIdx, devLoop] = deviceVal.split(':');
|
||||||
localStorage.setItem('lastAudioTestDevice', deviceVal);
|
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-canvas').style.display = '';
|
||||||
document.getElementById('audio-template-test-stats').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-start-btn').style.display = 'none';
|
||||||
|
document.getElementById('test-audio-template-device').disabled = true;
|
||||||
|
|
||||||
const statusEl = document.getElementById('audio-template-test-status');
|
const statusEl = document.getElementById('audio-template-test-status');
|
||||||
statusEl.textContent = t('audio_source.test.connecting');
|
statusEl.textContent = t('audio_source.test.connecting');
|
||||||
@@ -1144,6 +1152,9 @@ function _tplCleanupTest() {
|
|||||||
_tplTestWs = null;
|
_tplTestWs = null;
|
||||||
}
|
}
|
||||||
_tplTestLatest = null;
|
_tplTestLatest = null;
|
||||||
|
// Re-enable device picker
|
||||||
|
const devSel = document.getElementById('test-audio-template-device');
|
||||||
|
if (devSel) devSel.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _tplSizeCanvas(canvas) {
|
function _tplSizeCanvas(canvas) {
|
||||||
@@ -1283,7 +1294,8 @@ const _streamSectionMap = {
|
|||||||
proc_templates: [csProcTemplates],
|
proc_templates: [csProcTemplates],
|
||||||
css_processing: [csCSPTemplates],
|
css_processing: [csCSPTemplates],
|
||||||
color_strip: [csColorStrips],
|
color_strip: [csColorStrips],
|
||||||
audio: [csAudioMulti, csAudioMono, csAudioTemplates],
|
audio: [csAudioMulti, csAudioMono],
|
||||||
|
audio_templates: [csAudioTemplates],
|
||||||
value: [csValueSources],
|
value: [csValueSources],
|
||||||
sync: [csSyncClocks],
|
sync: [csSyncClocks],
|
||||||
};
|
};
|
||||||
@@ -1490,6 +1502,7 @@ function renderPictureSourcesList(streams) {
|
|||||||
{ key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length },
|
{ 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: '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', 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: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
|
||||||
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
|
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
|
||||||
];
|
];
|
||||||
@@ -1497,37 +1510,44 @@ function renderPictureSourcesList(streams) {
|
|||||||
// Build tree navigation structure
|
// Build tree navigation structure
|
||||||
const treeGroups = [
|
const treeGroups = [
|
||||||
{
|
{
|
||||||
key: 'capture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.capture',
|
key: 'picture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.picture',
|
||||||
children: [
|
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: '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_image', icon: getPictureSourceIcon('static_image'), titleKey: 'streams.group.static_image',
|
]
|
||||||
count: staticImageStreams.length,
|
},
|
||||||
},
|
{
|
||||||
{
|
key: 'static_group', icon: getPictureSourceIcon('static_image'), titleKey: 'tree.group.static',
|
||||||
key: 'video', icon: getPictureSourceIcon('video'), titleKey: 'streams.group.video',
|
children: [
|
||||||
count: videoStreams.length,
|
{ 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: 'streams.group.processed', icon: getPictureSourceIcon('processed'), count: processedStreams.length },
|
key: 'processing_group', icon: getPictureSourceIcon('processed'), titleKey: 'tree.group.processing',
|
||||||
{ key: 'proc_templates', titleKey: 'streams.group.proc_templates', icon: ICON_PP_TEMPLATE, count: _cachedPPTemplates.length },
|
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',
|
key: 'strip_group', icon: getColorStripIcon('static'), titleKey: 'tree.group.strip',
|
||||||
children: [
|
children: [
|
||||||
{ key: 'color_strip', titleKey: 'streams.group.color_strip', icon: getColorStripIcon('static'), count: colorStrips.length },
|
{ key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('static'), count: colorStrips.length },
|
||||||
{ key: 'css_processing', titleKey: 'streams.group.css_processing', icon: ICON_CSPT, count: csptTemplates.length },
|
{ key: 'css_processing', titleKey: 'tree.leaf.processing_templates', icon: ICON_CSPT, count: csptTemplates.length },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio',
|
key: 'audio_group', icon: getAudioSourceIcon('multichannel'), titleKey: 'tree.group.audio',
|
||||||
count: _cachedAudioSources.length + _cachedAudioTemplates.length,
|
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',
|
key: 'utility_group', icon: ICON_WRENCH, titleKey: 'tree.group.utility',
|
||||||
@@ -1559,7 +1579,7 @@ function renderPictureSourcesList(streams) {
|
|||||||
const loopback = src.is_loopback !== false;
|
const loopback = src.is_loopback !== false;
|
||||||
const devLabel = loopback ? `${ICON_AUDIO_LOOPBACK} Loopback` : `${ICON_AUDIO_INPUT} Input`;
|
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 tpl = src.audio_template_id ? _cachedAudioTemplates.find(t => t.id === src.audio_template_id) : null;
|
||||||
const tplBadge = tpl ? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.audio_template'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio','audio-templates','data-audio-template-id','${src.audio_template_id}')">${ICON_AUDIO_TEMPLATE} ${escapeHtml(tpl.name)}</span>` : '';
|
const tplBadge = tpl ? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('audio_source.audio_template'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio_templates','audio-templates','data-audio-template-id','${src.audio_template_id}')">${ICON_AUDIO_TEMPLATE} ${escapeHtml(tpl.name)}</span>` : '';
|
||||||
propsHtml = `<span class="stream-card-prop">${devLabel} #${devIdx}</span>${tplBadge}`;
|
propsHtml = `<span class="stream-card-prop">${devLabel} #${devIdx}</span>${tplBadge}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1651,7 +1671,8 @@ function renderPictureSourcesList(streams) {
|
|||||||
proc_templates: _cachedPPTemplates.length,
|
proc_templates: _cachedPPTemplates.length,
|
||||||
css_processing: csptTemplates.length,
|
css_processing: csptTemplates.length,
|
||||||
color_strip: colorStrips.length,
|
color_strip: colorStrips.length,
|
||||||
audio: _cachedAudioSources.length + _cachedAudioTemplates.length,
|
audio: _cachedAudioSources.length,
|
||||||
|
audio_templates: _cachedAudioTemplates.length,
|
||||||
value: _cachedValueSources.length,
|
value: _cachedValueSources.length,
|
||||||
sync: _cachedSyncClocks.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 === 'proc_templates') panelContent = csProcTemplates.render(procTemplateItems);
|
||||||
else if (tab.key === 'css_processing') panelContent = csCSPTemplates.render(csptItems);
|
else if (tab.key === 'css_processing') panelContent = csCSPTemplates.render(csptItems);
|
||||||
else if (tab.key === 'color_strip') panelContent = csColorStrips.render(colorStripItems);
|
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 === 'value') panelContent = csValueSources.render(valueItems);
|
||||||
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
|
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
|
||||||
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
|
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
|
||||||
@@ -1699,7 +1721,8 @@ function renderPictureSourcesList(streams) {
|
|||||||
'proc-streams': 'processed', 'proc-templates': 'proc_templates',
|
'proc-streams': 'processed', 'proc-templates': 'proc_templates',
|
||||||
'css-proc-templates': 'css_processing',
|
'css-proc-templates': 'css_processing',
|
||||||
'color-strips': 'color_strip',
|
'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',
|
'value-sources': 'value',
|
||||||
'sync-clocks': 'sync',
|
'sync-clocks': 'sync',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -316,6 +316,12 @@
|
|||||||
"settings.tab.backup": "Backup",
|
"settings.tab.backup": "Backup",
|
||||||
"settings.tab.mqtt": "MQTT",
|
"settings.tab.mqtt": "MQTT",
|
||||||
"settings.logs.open_viewer": "Open Log Viewer",
|
"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.general.title": "General Settings",
|
||||||
"settings.capture.title": "Capture Settings",
|
"settings.capture.title": "Capture Settings",
|
||||||
"settings.capture.saved": "Capture settings updated",
|
"settings.capture.saved": "Capture settings updated",
|
||||||
@@ -447,6 +453,7 @@
|
|||||||
"streams.group.css_processing": "Processing Templates",
|
"streams.group.css_processing": "Processing Templates",
|
||||||
"streams.group.color_strip": "Color Strips",
|
"streams.group.color_strip": "Color Strips",
|
||||||
"streams.group.audio": "Audio",
|
"streams.group.audio": "Audio",
|
||||||
|
"streams.group.audio_templates": "Audio Templates",
|
||||||
"streams.section.streams": "Sources",
|
"streams.section.streams": "Sources",
|
||||||
"streams.add": "Add Source",
|
"streams.add": "Add Source",
|
||||||
"streams.add.raw": "Add Screen Capture",
|
"streams.add.raw": "Add Screen Capture",
|
||||||
@@ -1113,7 +1120,7 @@
|
|||||||
"color_strip.type.processed": "Processed",
|
"color_strip.type.processed": "Processed",
|
||||||
"color_strip.type.processed.desc": "Apply a processing template to another source",
|
"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.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.input.hint": "The color strip source whose output will be processed",
|
||||||
"color_strip.processed.template": "Processing Template:",
|
"color_strip.processed.template": "Processing Template:",
|
||||||
"color_strip.processed.template.hint": "Filter chain to apply to the input source output",
|
"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",
|
"audio_template.error.delete": "Failed to delete audio template",
|
||||||
"streams.group.value": "Value Sources",
|
"streams.group.value": "Value Sources",
|
||||||
"streams.group.sync": "Sync Clocks",
|
"streams.group.sync": "Sync Clocks",
|
||||||
|
"tree.group.picture": "Picture Source",
|
||||||
"tree.group.capture": "Screen Capture",
|
"tree.group.capture": "Screen Capture",
|
||||||
|
"tree.group.static": "Static",
|
||||||
"tree.group.processing": "Processed",
|
"tree.group.processing": "Processed",
|
||||||
"tree.group.picture": "Picture",
|
|
||||||
"tree.group.strip": "Color Strip",
|
"tree.group.strip": "Color Strip",
|
||||||
|
"tree.group.audio": "Audio",
|
||||||
"tree.group.utility": "Utility",
|
"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.group.title": "Value Sources",
|
||||||
"value_source.select_type": "Select Value Source Type",
|
"value_source.select_type": "Select Value Source Type",
|
||||||
"value_source.add": "Add Value Source",
|
"value_source.add": "Add Value Source",
|
||||||
|
|||||||
@@ -316,6 +316,12 @@
|
|||||||
"settings.tab.backup": "Бэкап",
|
"settings.tab.backup": "Бэкап",
|
||||||
"settings.tab.mqtt": "MQTT",
|
"settings.tab.mqtt": "MQTT",
|
||||||
"settings.logs.open_viewer": "Открыть логи",
|
"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.general.title": "Основные Настройки",
|
||||||
"settings.capture.title": "Настройки Захвата",
|
"settings.capture.title": "Настройки Захвата",
|
||||||
"settings.capture.saved": "Настройки захвата обновлены",
|
"settings.capture.saved": "Настройки захвата обновлены",
|
||||||
@@ -447,6 +453,7 @@
|
|||||||
"streams.group.css_processing": "Шаблоны Обработки",
|
"streams.group.css_processing": "Шаблоны Обработки",
|
||||||
"streams.group.color_strip": "Цветовые Полосы",
|
"streams.group.color_strip": "Цветовые Полосы",
|
||||||
"streams.group.audio": "Аудио",
|
"streams.group.audio": "Аудио",
|
||||||
|
"streams.group.audio_templates": "Аудио шаблоны",
|
||||||
"streams.section.streams": "Источники",
|
"streams.section.streams": "Источники",
|
||||||
"streams.add": "Добавить Источник",
|
"streams.add": "Добавить Источник",
|
||||||
"streams.add.raw": "Добавить Захват Экрана",
|
"streams.add.raw": "Добавить Захват Экрана",
|
||||||
@@ -1113,7 +1120,7 @@
|
|||||||
"color_strip.type.processed": "Обработанный",
|
"color_strip.type.processed": "Обработанный",
|
||||||
"color_strip.type.processed.desc": "Применить шаблон обработки к другому источнику",
|
"color_strip.type.processed.desc": "Применить шаблон обработки к другому источнику",
|
||||||
"color_strip.type.processed.hint": "Оборачивает существующий источник цветовой полосы и пропускает его вывод через цепочку фильтров.",
|
"color_strip.type.processed.hint": "Оборачивает существующий источник цветовой полосы и пропускает его вывод через цепочку фильтров.",
|
||||||
"color_strip.processed.input": "Входной источник:",
|
"color_strip.processed.input": "Источник:",
|
||||||
"color_strip.processed.input.hint": "Источник цветовой полосы, вывод которого будет обработан",
|
"color_strip.processed.input.hint": "Источник цветовой полосы, вывод которого будет обработан",
|
||||||
"color_strip.processed.template": "Шаблон обработки:",
|
"color_strip.processed.template": "Шаблон обработки:",
|
||||||
"color_strip.processed.template.hint": "Цепочка фильтров для применения к выводу входного источника",
|
"color_strip.processed.template.hint": "Цепочка фильтров для применения к выводу входного источника",
|
||||||
@@ -1276,11 +1283,20 @@
|
|||||||
"audio_template.error.delete": "Не удалось удалить аудиошаблон",
|
"audio_template.error.delete": "Не удалось удалить аудиошаблон",
|
||||||
"streams.group.value": "Источники значений",
|
"streams.group.value": "Источники значений",
|
||||||
"streams.group.sync": "Часы синхронизации",
|
"streams.group.sync": "Часы синхронизации",
|
||||||
"tree.group.capture": "Захват Экрана",
|
"tree.group.picture": "Источники изображений",
|
||||||
|
"tree.group.capture": "Захват экрана",
|
||||||
|
"tree.group.static": "Статичные",
|
||||||
"tree.group.processing": "Обработанные",
|
"tree.group.processing": "Обработанные",
|
||||||
"tree.group.picture": "Изображения",
|
"tree.group.strip": "Цветовые полосы",
|
||||||
"tree.group.strip": "Цветовые Полосы",
|
"tree.group.audio": "Аудио",
|
||||||
"tree.group.utility": "Утилиты",
|
"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.group.title": "Источники значений",
|
||||||
"value_source.select_type": "Выберите тип источника значений",
|
"value_source.select_type": "Выберите тип источника значений",
|
||||||
"value_source.add": "Добавить источник значений",
|
"value_source.add": "Добавить источник значений",
|
||||||
|
|||||||
@@ -316,6 +316,12 @@
|
|||||||
"settings.tab.backup": "备份",
|
"settings.tab.backup": "备份",
|
||||||
"settings.tab.mqtt": "MQTT",
|
"settings.tab.mqtt": "MQTT",
|
||||||
"settings.logs.open_viewer": "打开日志查看器",
|
"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.general.title": "常规设置",
|
||||||
"settings.capture.title": "采集设置",
|
"settings.capture.title": "采集设置",
|
||||||
"settings.capture.saved": "采集设置已更新",
|
"settings.capture.saved": "采集设置已更新",
|
||||||
@@ -447,6 +453,7 @@
|
|||||||
"streams.group.css_processing": "处理模板",
|
"streams.group.css_processing": "处理模板",
|
||||||
"streams.group.color_strip": "色带源",
|
"streams.group.color_strip": "色带源",
|
||||||
"streams.group.audio": "音频",
|
"streams.group.audio": "音频",
|
||||||
|
"streams.group.audio_templates": "音频模板",
|
||||||
"streams.section.streams": "源",
|
"streams.section.streams": "源",
|
||||||
"streams.add": "添加源",
|
"streams.add": "添加源",
|
||||||
"streams.add.raw": "添加屏幕采集",
|
"streams.add.raw": "添加屏幕采集",
|
||||||
@@ -1113,7 +1120,7 @@
|
|||||||
"color_strip.type.processed": "已处理",
|
"color_strip.type.processed": "已处理",
|
||||||
"color_strip.type.processed.desc": "将处理模板应用于另一个源",
|
"color_strip.type.processed.desc": "将处理模板应用于另一个源",
|
||||||
"color_strip.type.processed.hint": "包装现有色带源并通过滤镜链处理其输出。",
|
"color_strip.type.processed.hint": "包装现有色带源并通过滤镜链处理其输出。",
|
||||||
"color_strip.processed.input": "输入源:",
|
"color_strip.processed.input": "源:",
|
||||||
"color_strip.processed.input.hint": "将被处理的色带源",
|
"color_strip.processed.input.hint": "将被处理的色带源",
|
||||||
"color_strip.processed.template": "处理模板:",
|
"color_strip.processed.template": "处理模板:",
|
||||||
"color_strip.processed.template.hint": "应用于输入源输出的滤镜链",
|
"color_strip.processed.template.hint": "应用于输入源输出的滤镜链",
|
||||||
@@ -1276,11 +1283,20 @@
|
|||||||
"audio_template.error.delete": "删除音频模板失败",
|
"audio_template.error.delete": "删除音频模板失败",
|
||||||
"streams.group.value": "值源",
|
"streams.group.value": "值源",
|
||||||
"streams.group.sync": "同步时钟",
|
"streams.group.sync": "同步时钟",
|
||||||
|
"tree.group.picture": "图片源",
|
||||||
"tree.group.capture": "屏幕采集",
|
"tree.group.capture": "屏幕采集",
|
||||||
|
"tree.group.static": "静态",
|
||||||
"tree.group.processing": "已处理",
|
"tree.group.processing": "已处理",
|
||||||
"tree.group.picture": "图片",
|
|
||||||
"tree.group.strip": "色带",
|
"tree.group.strip": "色带",
|
||||||
|
"tree.group.audio": "音频",
|
||||||
"tree.group.utility": "工具",
|
"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.group.title": "值源",
|
||||||
"value_source.select_type": "选择值源类型",
|
"value_source.select_type": "选择值源类型",
|
||||||
"value_source.add": "添加值源",
|
"value_source.add": "添加值源",
|
||||||
|
|||||||
@@ -567,7 +567,7 @@
|
|||||||
<div id="css-editor-processed-section" style="display:none">
|
<div id="css-editor-processed-section" style="display:none">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-processed-input" data-i18n="color_strip.processed.input">Input Source:</label>
|
<label for="css-editor-processed-input" data-i18n="color_strip.processed.input">Source:</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.processed.input.hint">The color strip source whose output will be processed</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.processed.input.hint">The color strip source whose output will be processed</small>
|
||||||
|
|||||||
@@ -26,6 +26,19 @@
|
|||||||
<div id="settings-api-keys-list" style="font-size:0.85rem;"></div>
|
<div id="settings-api-keys-list" style="font-size:0.85rem;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- External URL -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label data-i18n="settings.external_url.label">External URL</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="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</small>
|
||||||
|
<div style="display:flex;gap:0.5rem;">
|
||||||
|
<input type="text" id="settings-external-url" placeholder="https://myserver.example.com:8080" style="flex:1" data-i18n-placeholder="settings.external_url.placeholder">
|
||||||
|
<button class="btn btn-primary" onclick="saveExternalUrl()" data-i18n="settings.external_url.save">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Log Level section -->
|
<!-- Log Level section -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
<!-- CSPT test: input source selector (hidden by default) -->
|
<!-- CSPT test: input source selector (hidden by default) -->
|
||||||
<div id="css-test-cspt-input-group" style="display:none" class="css-test-led-control">
|
<div id="css-test-cspt-input-group" style="display:none" class="css-test-led-control">
|
||||||
<label for="css-test-cspt-input-select" data-i18n="color_strip.processed.input">Input Source:</label>
|
<label for="css-test-cspt-input-select" data-i18n="color_strip.processed.input">Source:</label>
|
||||||
<select id="css-test-cspt-input-select" class="css-test-cspt-select" onchange="applyCssTestSettings()"></select>
|
<select id="css-test-cspt-input-select" class="css-test-cspt-select" onchange="applyCssTestSettings()"></select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user