Add WLED brightness control, auto-name dialogs, and UI fixes

- Brightness slider now reads/writes actual WLED device brightness via
  new GET/PUT /devices/{id}/brightness endpoints (0-255 range)
- Cache last-known brightness to prevent slider jumping on card refresh
- Auto-generate names for stream, engine template, and PP template dialogs
- Fix sub-tabs not re-rendering on language change
- Add capture template icon (camera) to stream card pills
- Fix last calibration tutorial popup position

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 02:20:35 +03:00
parent 136f6fd120
commit aa02a5f372
2 changed files with 169 additions and 15 deletions

View File

@@ -667,6 +667,60 @@ async def update_settings(
raise HTTPException(status_code=500, detail=str(e))
# ===== WLED BRIGHTNESS ENDPOINT =====
@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
async def get_device_brightness(
device_id: str,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
):
"""Get current brightness from the WLED device."""
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
try:
async with httpx.AsyncClient(timeout=5.0) as http_client:
resp = await http_client.get(f"{device.url}/json/state")
resp.raise_for_status()
state = resp.json()
bri = state.get("bri", 255)
return {"brightness": bri}
except Exception as e:
logger.error(f"Failed to get WLED brightness for {device_id}: {e}")
raise HTTPException(status_code=502, detail=f"Failed to reach WLED device: {e}")
@router.put("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
async def set_device_brightness(
device_id: str,
body: dict,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
):
"""Set brightness on the WLED device directly."""
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
bri = body.get("brightness")
if bri is None or not isinstance(bri, int) or not 0 <= bri <= 255:
raise HTTPException(status_code=400, detail="brightness must be an integer 0-255")
try:
async with httpx.AsyncClient(timeout=5.0) as http_client:
resp = await http_client.post(
f"{device.url}/json/state",
json={"bri": bri},
)
resp.raise_for_status()
return {"brightness": bri}
except Exception as e:
logger.error(f"Failed to set WLED brightness for {device_id}: {e}")
raise HTTPException(status_code=502, detail=f"Failed to reach WLED device: {e}")
# ===== CALIBRATION ENDPOINTS =====
@router.get("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])

View File

@@ -219,9 +219,40 @@ function formatDisplayLabel(displayIndex, display) {
return `Display ${displayIndex}`;
}
let _streamNameManuallyEdited = false;
function onStreamDisplaySelected(displayIndex, display) {
document.getElementById('stream-display-index').value = displayIndex;
document.getElementById('stream-display-picker-label').textContent = formatDisplayLabel(displayIndex, display);
_autoGenerateStreamName();
}
let _streamModalPPTemplates = [];
function _autoGenerateStreamName() {
if (_streamNameManuallyEdited) return;
if (document.getElementById('stream-id').value) return; // editing, not creating
const streamType = document.getElementById('stream-type').value;
const nameInput = document.getElementById('stream-name');
if (streamType === 'raw') {
const displayIndex = document.getElementById('stream-display-index').value;
const templateSelect = document.getElementById('stream-capture-template');
const templateName = templateSelect.selectedOptions[0]?.dataset?.name || '';
if (displayIndex === '' || !templateName) return;
nameInput.value = `D${displayIndex}_${templateName}`;
} else if (streamType === 'processed') {
const sourceSelect = document.getElementById('stream-source');
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
const ppTemplateId = document.getElementById('stream-pp-template').value;
const ppTemplate = _streamModalPPTemplates.find(t => t.id === ppTemplateId);
if (!sourceName) return;
if (ppTemplate && ppTemplate.name) {
nameInput.value = `${sourceName} (${ppTemplate.name})`;
} else {
nameInput.value = sourceName;
}
}
}
function onTestDisplaySelected(displayIndex, display) {
@@ -352,6 +383,7 @@ function updateAllText() {
if (apiKey) {
loadDisplays();
loadDevices();
loadPictureStreams();
}
}
@@ -644,9 +676,10 @@ async function loadDevices() {
webuiLink.rel = 'noopener';
}
// Attach event listeners
// Attach event listeners and fetch real WLED brightness
devicesWithState.forEach(device => {
attachDeviceListeners(device.id);
fetchDeviceBrightness(device.id);
});
} catch (error) {
console.error('Failed to load devices:', error);
@@ -661,7 +694,6 @@ function createDeviceCard(device) {
const settings = device.settings || {};
const isProcessing = state.processing || false;
const brightnessPercent = Math.round((settings.brightness !== undefined ? settings.brightness : 1.0) * 100);
const statusKey = isProcessing ? 'device.status.processing' : 'device.status.idle';
const status = isProcessing ? 'processing' : 'idle';
@@ -733,11 +765,11 @@ function createDeviceCard(device) {
` : ''}
</div>
<div class="brightness-control">
<input type="range" class="brightness-slider" min="0" max="100"
value="${brightnessPercent}"
<input type="range" class="brightness-slider" min="0" max="255"
value="${_deviceBrightnessCache[device.id] ?? 128}" data-device-brightness="${device.id}"
oninput="updateBrightnessLabel('${device.id}', this.value)"
onchange="saveCardBrightness('${device.id}', this.value)"
title="${brightnessPercent}%">
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}">
</div>
<div class="card-actions">
${isProcessing ? `
@@ -1163,19 +1195,23 @@ async function saveCaptureSettings() {
}
}
// Card brightness controls
// Brightness cache: stores last known WLED brightness per device (0-255)
const _deviceBrightnessCache = {};
// Card brightness controls — talks directly to WLED device
function updateBrightnessLabel(deviceId, value) {
const slider = document.querySelector(`[data-device-id="${deviceId}"] .brightness-slider`);
if (slider) slider.title = value + '%';
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`);
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
}
async function saveCardBrightness(deviceId, value) {
const brightness = parseInt(value) / 100.0;
const bri = parseInt(value);
_deviceBrightnessCache[deviceId] = bri;
try {
await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
await fetch(`${API_BASE}/devices/${deviceId}/brightness`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ brightness })
body: JSON.stringify({ brightness: bri })
});
} catch (err) {
console.error('Failed to update brightness:', err);
@@ -1183,6 +1219,24 @@ async function saveCardBrightness(deviceId, value) {
}
}
async function fetchDeviceBrightness(deviceId) {
try {
const resp = await fetch(`${API_BASE}/devices/${deviceId}/brightness`, {
headers: getHeaders()
});
if (!resp.ok) return;
const data = await resp.json();
_deviceBrightnessCache[deviceId] = data.brightness;
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`);
if (slider) {
slider.value = data.brightness;
slider.title = Math.round(data.brightness / 255 * 100) + '%';
}
} catch (err) {
// Silently fail — device may be offline
}
}
// Add device modal
function showAddDevice() {
const modal = document.getElementById('add-device-modal');
@@ -2225,7 +2279,7 @@ const calibrationTutorialSteps = [
{ selector: '.offset-control', textKey: 'calibration.tip.offset', position: 'bottom' },
{ selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' },
{ selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' },
{ selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'bottom' }
{ selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' }
];
const deviceTutorialSteps = [
@@ -2467,6 +2521,8 @@ function getEngineIcon(engineType) {
}
// Show add template modal
let _templateNameManuallyEdited = false;
async function showAddTemplateModal() {
currentEditingTemplateId = null;
document.getElementById('template-modal-title').textContent = t('templates.add');
@@ -2475,6 +2531,10 @@ async function showAddTemplateModal() {
document.getElementById('engine-config-section').style.display = 'none';
document.getElementById('template-error').style.display = 'none';
// Auto-name: reset flag and wire listener
_templateNameManuallyEdited = false;
document.getElementById('template-name').oninput = () => { _templateNameManuallyEdited = true; };
// Load available engines
await loadAvailableEngines();
@@ -2738,6 +2798,11 @@ async function onEngineChange() {
return;
}
// Auto-name: set template name to engine name if user hasn't edited
if (!_templateNameManuallyEdited && !document.getElementById('template-id').value) {
document.getElementById('template-name').value = engine.name || engineType;
}
// Show availability hint
const hint = document.getElementById('engine-availability-hint');
if (!engine.available) {
@@ -3069,7 +3134,7 @@ function renderPictureStreamsList(streams) {
detailsHtml = `<div class="stream-card-props">
<span class="stream-card-prop" title="${t('streams.display')}">🖥️ ${stream.display_index ?? 0}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">⚡ ${stream.target_fps ?? 30}</span>
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">${capTmplName}</span>` : ''}
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">📷 ${capTmplName}</span>` : ''}
</div>`;
} else if (stream.stream_type === 'processed') {
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
@@ -3284,6 +3349,13 @@ async function showAddStreamModal(presetType) {
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
onStreamTypeChange();
// Auto-name: reset flag and wire listeners
_streamNameManuallyEdited = false;
document.getElementById('stream-name').oninput = () => { _streamNameManuallyEdited = true; };
document.getElementById('stream-capture-template').onchange = () => _autoGenerateStreamName();
document.getElementById('stream-source').onchange = () => _autoGenerateStreamName();
document.getElementById('stream-pp-template').onchange = () => _autoGenerateStreamName();
// Populate dropdowns
await populateStreamModalDropdowns();
@@ -3379,6 +3451,7 @@ async function populateStreamModalDropdowns() {
(data.templates || []).forEach(tmpl => {
const opt = document.createElement('option');
opt.value = tmpl.id;
opt.dataset.name = tmpl.name;
opt.textContent = `${getEngineIcon(tmpl.engine_type)} ${tmpl.name}`;
templateSelect.appendChild(opt);
});
@@ -3395,6 +3468,7 @@ async function populateStreamModalDropdowns() {
if (s.id === editingId) return;
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
const typeLabels = { raw: '🖥️', processed: '🎨', static_image: '🖼️' };
const typeLabel = typeLabels[s.stream_type] || '📺';
opt.textContent = `${typeLabel} ${s.name}`;
@@ -3403,17 +3477,22 @@ async function populateStreamModalDropdowns() {
}
// PP templates
_streamModalPPTemplates = [];
const ppSelect = document.getElementById('stream-pp-template');
ppSelect.innerHTML = '';
if (ppTemplatesRes.ok) {
const data = await ppTemplatesRes.json();
(data.templates || []).forEach(tmpl => {
_streamModalPPTemplates = data.templates || [];
_streamModalPPTemplates.forEach(tmpl => {
const opt = document.createElement('option');
opt.value = tmpl.id;
opt.textContent = tmpl.name;
ppSelect.appendChild(opt);
});
}
// Trigger auto-name after dropdowns are populated
_autoGenerateStreamName();
}
async function saveStream() {
@@ -3854,6 +3933,7 @@ function addFilterFromSelect() {
_modalFilters.push({ filter_id: filterId, options, _expanded: true });
select.value = '';
renderModalFilterList();
_autoGeneratePPTemplateName();
}
function toggleFilterExpand(index) {
@@ -3866,6 +3946,7 @@ function toggleFilterExpand(index) {
function removeFilter(index) {
_modalFilters.splice(index, 1);
renderModalFilterList();
_autoGeneratePPTemplateName();
}
function moveFilter(index, direction) {
@@ -3875,6 +3956,7 @@ function moveFilter(index, direction) {
_modalFilters[index] = _modalFilters[newIndex];
_modalFilters[newIndex] = tmp;
renderModalFilterList();
_autoGeneratePPTemplateName();
}
function updateFilterOption(filterIndex, optionKey, value) {
@@ -3904,6 +3986,20 @@ function collectFilters() {
}));
}
let _ppTemplateNameManuallyEdited = false;
function _autoGeneratePPTemplateName() {
if (_ppTemplateNameManuallyEdited) return;
if (document.getElementById('pp-template-id').value) return; // editing, not creating
const nameInput = document.getElementById('pp-template-name');
if (_modalFilters.length > 0) {
const filterNames = _modalFilters.map(f => _getFilterName(f.filter_id)).join(' + ');
nameInput.value = filterNames;
} else {
nameInput.value = '';
}
}
async function showAddPPTemplateModal() {
if (_availableFilters.length === 0) await loadAvailableFilters();
@@ -3914,6 +4010,10 @@ async function showAddPPTemplateModal() {
_modalFilters = [];
// Auto-name: reset flag and wire listener
_ppTemplateNameManuallyEdited = false;
document.getElementById('pp-template-name').oninput = () => { _ppTemplateNameManuallyEdited = true; };
_populateFilterSelect();
renderModalFilterList();
@@ -4141,7 +4241,7 @@ async function updateStreamSelectorInfo(streamId) {
propsHtml = `
<span class="stream-card-prop" title="${t('streams.display')}">🖥️ ${stream.display_index ?? 0}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">⚡ ${stream.target_fps ?? 30}</span>
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">${capTmplName}</span>` : ''}
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">📷 ${capTmplName}</span>` : ''}
`;
} else if (stream.stream_type === 'processed') {
if ((!_cachedStreams || _cachedStreams.length === 0) && stream.source_stream_id) {