- ${Object.keys(template.engine_config).length > 0 ? `
+ ${configEntries.length > 0 ? `
๐งช
@@ -3087,50 +3087,46 @@ async function loadPictureStreams() {
}
}
+function toggleStreamGroup(groupKey) {
+ const stored = JSON.parse(localStorage.getItem('streamGroupState') || '{}');
+ stored[groupKey] = !stored[groupKey];
+ localStorage.setItem('streamGroupState', JSON.stringify(stored));
+ renderPictureStreamsList(_cachedStreams);
+}
+
+function isStreamGroupCollapsed(groupKey) {
+ const stored = JSON.parse(localStorage.getItem('streamGroupState') || '{}');
+ return !!stored[groupKey];
+}
+
+function renderStreamGroup(groupKey, icon, titleKey, count, addType, addLabelKey, cardsHtml) {
+ const collapsed = isStreamGroupCollapsed(groupKey);
+ const chevron = collapsed ? '▶' : '▼';
+ return `
+
+
+ ${cardsHtml}
+
+
+
+
${t(addLabelKey)}
+
+
+
`;
+}
+
function renderPictureStreamsList(streams) {
const container = document.getElementById('streams-list');
if (streams.length === 0) {
- container.innerHTML = `
-
-
-
-
-
+
-
${t('streams.add.raw')}
-
-
-
-
-
-
-
-
+
-
${t('streams.add.static_image')}
-
-
-
-
-
-
-
-
+
-
${t('streams.add.processed')}
-
-
-
`;
+ container.innerHTML =
+ renderStreamGroup('raw', '๐ฅ๏ธ', 'streams.group.raw', 0, 'raw', 'streams.add.raw', '') +
+ renderStreamGroup('static_image', '๐ผ๏ธ', 'streams.group.static_image', 0, 'static_image', 'streams.add.static_image', '') +
+ renderStreamGroup('processed', '๐จ', 'streams.group.processed', 0, 'processed', 'streams.add.processed', '');
return;
}
@@ -3146,49 +3142,33 @@ function renderPictureStreamsList(streams) {
let detailsHtml = '';
if (stream.stream_type === 'raw') {
- let captureTemplateHtml = '';
+ let capTmplName = '';
if (stream.capture_template_id) {
const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
- if (capTmpl) {
- captureTemplateHtml = `${t('streams.capture_template')} ${escapeHtml(capTmpl.name)}
`;
- }
+ if (capTmpl) capTmplName = escapeHtml(capTmpl.name);
}
- detailsHtml = `
-
- ${t('streams.display')} ${stream.display_index ?? 0}
-
-
- ${t('streams.target_fps')} ${stream.target_fps ?? 30}
-
- ${captureTemplateHtml}
- `;
+ detailsHtml = `
+ ๐ฅ๏ธ ${stream.display_index ?? 0}
+ โก ${stream.target_fps ?? 30}
+ ${capTmplName ? `${capTmplName} ` : ''}
+
`;
} else if (stream.stream_type === 'processed') {
- // Find source stream name and PP template name
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
- // Find PP template name
- let ppTemplateHtml = '';
+ let ppTmplName = '';
if (stream.postprocessing_template_id) {
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
- if (ppTmpl) {
- ppTemplateHtml = `${t('streams.pp_template')} ${escapeHtml(ppTmpl.name)}
`;
- }
+ if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name);
}
- detailsHtml = `
-
- ${t('streams.source')} ${sourceName}
-
- ${ppTemplateHtml}
- `;
+ detailsHtml = `
+ ๐บ ${sourceName}
+ ${ppTmplName ? `๐จ ${ppTmplName} ` : ''}
+
`;
} else if (stream.stream_type === 'static_image') {
const src = stream.image_source || '';
- const truncated = src.length > 50 ? src.substring(0, 47) + '...' : src;
- detailsHtml = `
-
- ${t('streams.image_source')}
-
- ${escapeHtml(truncated)}
- `;
+ detailsHtml = `
+ ๐ ${escapeHtml(src)}
+
`;
}
return `
@@ -3218,57 +3198,10 @@ function renderPictureStreamsList(streams) {
const processedStreams = streams.filter(s => s.stream_type === 'processed');
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
- let html = '';
-
- // Screen Capture streams section
- html += `
-
-
- ${rawStreams.map(renderCard).join('')}
-
-
+
-
${t('streams.add.raw')}
-
-
-
`;
-
- // Static Image streams section
- html += `
-
-
- ${staticImageStreams.map(renderCard).join('')}
-
-
+
-
${t('streams.add.static_image')}
-
-
-
`;
-
- // Processed streams section
- html += `
-
-
- ${processedStreams.map(renderCard).join('')}
-
-
+
-
${t('streams.add.processed')}
-
-
-
`;
-
- container.innerHTML = html;
+ container.innerHTML =
+ renderStreamGroup('raw', '๐ฅ๏ธ', 'streams.group.raw', rawStreams.length, 'raw', 'streams.add.raw', rawStreams.map(renderCard).join('')) +
+ renderStreamGroup('static_image', '๐ผ๏ธ', 'streams.group.static_image', staticImageStreams.length, 'static_image', 'streams.add.static_image', staticImageStreams.map(renderCard).join('')) +
+ renderStreamGroup('processed', '๐จ', 'streams.group.processed', processedStreams.length, 'processed', 'streams.add.processed', processedStreams.map(renderCard).join(''));
}
function onStreamTypeChange() {
@@ -3323,9 +3256,14 @@ async function editStream(streamId) {
// Set type (hidden input)
document.getElementById('stream-type').value = stream.stream_type;
- // Clear static image preview
+ // Clear static image preview and wire up auto-validation
+ _lastValidatedImageSource = '';
+ const imgSrcInput = document.getElementById('stream-image-source');
document.getElementById('stream-image-preview-container').style.display = 'none';
document.getElementById('stream-image-validation-status').style.display = 'none';
+ imgSrcInput.onblur = () => validateStaticImage();
+ imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
+ imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
onStreamTypeChange();
// Populate dropdowns before setting values
diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html
index 92982ea..2c0af33 100644
--- a/server/src/wled_controller/static/index.html
+++ b/server/src/wled_controller/static/index.html
@@ -42,14 +42,6 @@
-
- WLED Configuration: Configure your WLED device (effects, segments, color order, power limits, etc.) using the
- official WLED app
- or the built-in
- WLED Web UI
- (open your device's IP in a browser).
- This controller sends pixel color data and controls brightness per device.
-
@@ -57,33 +49,18 @@
-
-
- Picture streams define the capture pipeline. A raw stream captures from a display using a capture template. A processed stream applies postprocessing to another stream. Assign streams to devices.
-
-
-
-
- Capture templates define how the screen is captured. Each template uses a specific capture engine (MSS, DXcam, WGC) with custom settings. Assign templates to devices for optimal performance.
-
-
-
-
- Processing templates define image filters and color correction. Assign them to processed picture streams for consistent postprocessing across devices.
-
-
diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css
index 71ab557..2625b18 100644
--- a/server/src/wled_controller/static/style.css
+++ b/server/src/wled_controller/static/style.css
@@ -2192,6 +2192,30 @@ input:-webkit-autofill:focus {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid var(--border-color);
+ cursor: pointer;
+ user-select: none;
+ transition: opacity 0.2s;
+}
+
+.stream-group-header:hover {
+ opacity: 0.8;
+}
+
+.stream-group-chevron {
+ font-size: 10px;
+ color: var(--text-secondary);
+ flex-shrink: 0;
+ width: 12px;
+ transition: transform 0.2s;
+}
+
+.stream-group.collapsed .stream-group-header {
+ margin-bottom: 0;
+ border-bottom-color: transparent;
+}
+
+.stream-group.collapsed .templates-grid {
+ display: none;
}
.stream-group-icon {
@@ -2395,10 +2419,32 @@ input:-webkit-autofill:focus {
.validation-status.loading {
color: var(--text-muted);
}
-.stream-card-image-source {
- font-size: 0.7rem;
- color: var(--text-muted);
- word-break: break-all;
- margin-top: 4px;
+.stream-card-props {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 8px;
+}
+
+.stream-card-prop {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ background: var(--border-color);
+ padding: 2px 8px;
+ border-radius: 10px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 180px;
+}
+
+.stream-card-prop-full {
+ max-width: 100%;
+ word-break: break-all;
+ white-space: normal;
+ font-size: 0.7rem;
}