Fix settings persistence, streaming stability, and UI polish

- Fix device settings partial update using model_fields_set for true merge
- Add missing interpolation_mode and smoothing to all API responses
- Fix send_pixels race condition when wled_client is None during stop
- Allow LED segments to exceed edge pixel count (float stepping)
- Fix modal scroll lock using position:fixed to prevent layout shift
- Show loading state for brightness slider until real value is fetched
- Remove stream description from stream selector dialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 02:59:34 +03:00
parent aa02a5f372
commit 66eecdb3c9
5 changed files with 58 additions and 28 deletions

View File

@@ -304,6 +304,7 @@ async def create_device(
border_width=device.settings.border_width, border_width=device.settings.border_width,
interpolation_mode=device.settings.interpolation_mode, interpolation_mode=device.settings.interpolation_mode,
brightness=device.settings.brightness, brightness=device.settings.brightness,
smoothing=device.settings.smoothing,
state_check_interval=device.settings.state_check_interval, state_check_interval=device.settings.state_check_interval,
), ),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
@@ -339,7 +340,9 @@ async def list_devices(
display_index=device.settings.display_index, display_index=device.settings.display_index,
fps=device.settings.fps, fps=device.settings.fps,
border_width=device.settings.border_width, border_width=device.settings.border_width,
interpolation_mode=device.settings.interpolation_mode,
brightness=device.settings.brightness, brightness=device.settings.brightness,
smoothing=device.settings.smoothing,
state_check_interval=device.settings.state_check_interval, state_check_interval=device.settings.state_check_interval,
), ),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
@@ -384,7 +387,9 @@ async def get_device(
display_index=device.settings.display_index, display_index=device.settings.display_index,
fps=device.settings.fps, fps=device.settings.fps,
border_width=device.settings.border_width, border_width=device.settings.border_width,
interpolation_mode=device.settings.interpolation_mode,
brightness=device.settings.brightness, brightness=device.settings.brightness,
smoothing=device.settings.smoothing,
state_check_interval=device.settings.state_check_interval, state_check_interval=device.settings.state_check_interval,
), ),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
@@ -474,6 +479,7 @@ async def update_device(
border_width=device.settings.border_width, border_width=device.settings.border_width,
interpolation_mode=device.settings.interpolation_mode, interpolation_mode=device.settings.interpolation_mode,
brightness=device.settings.brightness, brightness=device.settings.brightness,
smoothing=device.settings.smoothing,
state_check_interval=device.settings.state_check_interval, state_check_interval=device.settings.state_check_interval,
), ),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
@@ -619,6 +625,7 @@ async def update_settings(
"""Update processing settings for a device. """Update processing settings for a device.
Merges with existing settings so callers can send partial updates. Merges with existing settings so callers can send partial updates.
Only fields explicitly included in the request body are applied.
""" """
try: try:
# Get existing device to merge settings # Get existing device to merge settings
@@ -627,20 +634,31 @@ async def update_settings(
raise HTTPException(status_code=404, detail=f"Device {device_id} not found") raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
existing = device.settings existing = device.settings
sent = settings.model_fields_set # fields the client actually sent
# Merge: use new values where provided, keep existing otherwise # Merge: only override fields the client explicitly provided
new_settings = ProcessingSettings( new_settings = ProcessingSettings(
display_index=settings.display_index, display_index=settings.display_index if 'display_index' in sent else existing.display_index,
fps=settings.fps, fps=settings.fps if 'fps' in sent else existing.fps,
border_width=settings.border_width, border_width=settings.border_width if 'border_width' in sent else existing.border_width,
interpolation_mode=settings.interpolation_mode, interpolation_mode=settings.interpolation_mode if 'interpolation_mode' in sent else existing.interpolation_mode,
brightness=settings.color_correction.brightness if settings.color_correction else existing.brightness, brightness=settings.brightness if 'brightness' in sent else existing.brightness,
gamma=settings.color_correction.gamma if settings.color_correction else existing.gamma, gamma=existing.gamma,
saturation=settings.color_correction.saturation if settings.color_correction else existing.saturation, saturation=existing.saturation,
smoothing=settings.smoothing, smoothing=settings.smoothing if 'smoothing' in sent else existing.smoothing,
state_check_interval=settings.state_check_interval, state_check_interval=settings.state_check_interval if 'state_check_interval' in sent else existing.state_check_interval,
) )
# Apply color_correction fields if explicitly sent
if 'color_correction' in sent and settings.color_correction:
cc_sent = settings.color_correction.model_fields_set
if 'brightness' in cc_sent:
new_settings.brightness = settings.color_correction.brightness
if 'gamma' in cc_sent:
new_settings.gamma = settings.color_correction.gamma
if 'saturation' in cc_sent:
new_settings.saturation = settings.color_correction.saturation
# Update in storage # Update in storage
device = store.update_device(device_id, settings=new_settings) device = store.update_device(device_id, settings=new_settings)
@@ -657,6 +675,7 @@ async def update_settings(
border_width=device.settings.border_width, border_width=device.settings.border_width,
interpolation_mode=device.settings.interpolation_mode, interpolation_mode=device.settings.interpolation_mode,
brightness=device.settings.brightness, brightness=device.settings.brightness,
smoothing=device.settings.smoothing,
state_check_interval=device.settings.state_check_interval, state_check_interval=device.settings.state_check_interval,
) )

View File

@@ -613,6 +613,8 @@ class ProcessorManager:
) )
# Send to WLED with device brightness # Send to WLED with device brightness
if not state.is_running or state.wled_client is None:
break
brightness_value = int(wled_brightness * 255) brightness_value = int(wled_brightness * 255)
await state.wled_client.send_pixels(led_colors, brightness=brightness_value) await state.wled_client.send_pixels(led_colors, brightness=brightness_value)

View File

@@ -229,18 +229,16 @@ def get_edge_segments(
divide_axis = 0 # Height divide_axis = 0 # Height
edge_length = edge_pixels.shape[0] edge_length = edge_pixels.shape[0]
if segment_count > edge_length: # Use float stepping so multiple LEDs can share pixels when
raise ValueError( # segment_count > edge_length (e.g. after downscaling).
f"segment_count {segment_count} is larger than edge length {edge_length}" step = edge_length / segment_count
)
# Calculate segment size
segment_size = edge_length // segment_count
segments = [] segments = []
for i in range(segment_count): for i in range(segment_count):
start = i * segment_size start = int(i * step)
end = start + segment_size if i < segment_count - 1 else edge_length end = max(start + 1, int((i + 1) * step))
# Clamp to edge bounds
end = min(end, edge_length)
if divide_axis == 1: if divide_axis == 1:
segment = edge_pixels[:, start:end, :] segment = edge_pixels[:, start:end, :]

View File

@@ -47,16 +47,18 @@ const EDGE_TEST_COLORS = {
left: [255, 255, 0] left: [255, 255, 0]
}; };
// Modal body lock helpers - prevent layout jump when scrollbar disappears // Modal body lock helpers — uses position:fixed to freeze scroll without removing scrollbar
function lockBody() { function lockBody() {
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; const scrollY = window.scrollY;
document.body.style.paddingRight = scrollbarWidth + 'px'; document.body.style.top = `-${scrollY}px`;
document.body.classList.add('modal-open'); document.body.classList.add('modal-open');
} }
function unlockBody() { function unlockBody() {
const scrollY = parseInt(document.body.style.top || '0', 10) * -1;
document.body.classList.remove('modal-open'); document.body.classList.remove('modal-open');
document.body.style.paddingRight = ''; document.body.style.top = '';
window.scrollTo(0, scrollY);
} }
// Image lightbox // Image lightbox
@@ -764,12 +766,13 @@ function createDeviceCard(device) {
</div> </div>
` : ''} ` : ''}
</div> </div>
<div class="brightness-control"> <div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
<input type="range" class="brightness-slider" min="0" max="255" <input type="range" class="brightness-slider" min="0" max="255"
value="${_deviceBrightnessCache[device.id] ?? 128}" data-device-brightness="${device.id}" value="${_deviceBrightnessCache[device.id] ?? 0}" data-device-brightness="${device.id}"
oninput="updateBrightnessLabel('${device.id}', this.value)" oninput="updateBrightnessLabel('${device.id}', this.value)"
onchange="saveCardBrightness('${device.id}', this.value)" onchange="saveCardBrightness('${device.id}', this.value)"
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}"> title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}"
${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}>
</div> </div>
<div class="card-actions"> <div class="card-actions">
${isProcessing ? ` ${isProcessing ? `
@@ -1231,7 +1234,10 @@ async function fetchDeviceBrightness(deviceId) {
if (slider) { if (slider) {
slider.value = data.brightness; slider.value = data.brightness;
slider.title = Math.round(data.brightness / 255 * 100) + '%'; slider.title = Math.round(data.brightness / 255 * 100) + '%';
slider.disabled = false;
} }
const wrap = document.querySelector(`[data-brightness-wrap="${deviceId}"]`);
if (wrap) wrap.classList.remove('brightness-loading');
} catch (err) { } catch (err) {
// Silently fail — device may be offline // Silently fail — device may be offline
} }
@@ -4279,7 +4285,6 @@ async function updateStreamSelectorInfo(streamId) {
<span class="stream-card-prop" title="${t('streams.type')}">${typeIcon} ${typeName}</span> <span class="stream-card-prop" title="${t('streams.type')}">${typeIcon} ${typeName}</span>
${propsHtml} ${propsHtml}
</div> </div>
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}
`; `;
infoPanel.style.display = ''; infoPanel.style.display = '';
} catch { } catch {

View File

@@ -48,7 +48,8 @@ body {
} }
body.modal-open { body.modal-open {
overflow: hidden; position: fixed;
width: 100%;
} }
.container { .container {
@@ -595,6 +596,11 @@ section {
width: 100%; width: 100%;
} }
.brightness-loading .brightness-slider {
opacity: 0.3;
pointer-events: none;
}
.section-header { .section-header {
display: flex; display: flex;
align-items: center; align-items: center;