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:
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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, :]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user