From 7d0b6f2583f876f05f4c71194b28e7fe37803720 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 11 Feb 2026 20:28:30 +0300 Subject: [PATCH] Replace collapsible stream groups with sub-tabs navigation - Replace expandable/collapsible groups with tab bar (Screen Capture, Static Image, Processed) - Persist active stream tab in localStorage - Shorten tab labels by removing "Streams" suffix - Remove type badge from cards (redundant with tab separation) - Add count badge on each tab with active highlight Co-Authored-By: Claude Opus 4.6 --- server/src/wled_controller/static/app.js | 84 ++++++++---------- .../wled_controller/static/locales/en.json | 6 +- .../wled_controller/static/locales/ru.json | 6 +- server/src/wled_controller/static/style.css | 85 +++++++++---------- 4 files changed, 79 insertions(+), 102 deletions(-) diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 79e1f3d..1d7b535 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -3087,58 +3087,23 @@ 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 `
-
- ${chevron} - ${icon} - ${t(titleKey)} - ${count} -
-
- ${cardsHtml} -
-
+
-
${t(addLabelKey)}
-
-
-
`; +function switchStreamTab(tabKey) { + document.querySelectorAll('.stream-tab-btn').forEach(btn => + btn.classList.toggle('active', btn.dataset.streamTab === tabKey) + ); + document.querySelectorAll('.stream-tab-panel').forEach(panel => + panel.classList.toggle('active', panel.id === `stream-tab-${tabKey}`) + ); + localStorage.setItem('activeStreamTab', tabKey); } function renderPictureStreamsList(streams) { const container = document.getElementById('streams-list'); - - if (streams.length === 0) { - 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; - } + const activeTab = localStorage.getItem('activeStreamTab') || 'raw'; const renderCard = (stream) => { const typeIcons = { raw: '🖥️', processed: '🎨', static_image: '🖼️' }; const typeIcon = typeIcons[stream.stream_type] || '📺'; - const typeBadges = { - raw: `${t('streams.type.raw')}`, - processed: `${t('streams.type.processed')}`, - static_image: `${t('streams.type.static_image')}`, - }; - const typeBadge = typeBadges[stream.stream_type] || ''; let detailsHtml = ''; if (stream.stream_type === 'raw') { @@ -3178,7 +3143,6 @@ function renderPictureStreamsList(streams) {
${typeIcon} ${escapeHtml(stream.name)}
- ${typeBadge} ${detailsHtml} ${stream.description ? `
${escapeHtml(stream.description)}
` : ''} @@ -3198,10 +3162,32 @@ function renderPictureStreamsList(streams) { const processedStreams = streams.filter(s => s.stream_type === 'processed'); const staticImageStreams = streams.filter(s => s.stream_type === 'static_image'); - 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('')); + const addCard = (type, labelKey) => ` +
+
+
+
${t(labelKey)}
+
`; + + const tabs = [ + { key: 'raw', icon: '🖥️', titleKey: 'streams.group.raw', streams: rawStreams, addLabelKey: 'streams.add.raw' }, + { key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', streams: staticImageStreams, addLabelKey: 'streams.add.static_image' }, + { key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', streams: processedStreams, addLabelKey: 'streams.add.processed' }, + ]; + + const tabBar = `
${tabs.map(tab => + `` + ).join('')}
`; + + const panels = tabs.map(tab => + `
+
+ ${tab.streams.map(renderCard).join('')} + ${addCard(tab.key, tab.addLabelKey)} +
+
` + ).join(''); + + container.innerHTML = tabBar + panels; } function onStreamTypeChange() { diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index ca53714..1a30ef0 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -200,8 +200,8 @@ "common.edit": "Edit", "streams.title": "\uD83D\uDCFA Picture Streams", "streams.description": "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.", - "streams.group.raw": "Screen Capture Streams", - "streams.group.processed": "Processed Streams", + "streams.group.raw": "Screen Capture", + "streams.group.processed": "Processed", "streams.add": "Add Picture Stream", "streams.add.raw": "Add Screen Capture", "streams.add.processed": "Add Processed Stream", @@ -281,7 +281,7 @@ "device.stream_settings.smoothing": "Smoothing:", "device.stream_settings.smoothing_hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.", "device.tip.stream_selector": "Configure picture stream and LED projection settings for this device", - "streams.group.static_image": "Static Image Streams", + "streams.group.static_image": "Static Image", "streams.add.static_image": "Add Static Image", "streams.edit.static_image": "Edit Static Image", "streams.type.static_image": "Static Image", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index c20e559..1cdd16a 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -200,8 +200,8 @@ "common.edit": "Редактировать", "streams.title": "\uD83D\uDCFA Видеопотоки", "streams.description": "Видеопотоки определяют конвейер захвата. Сырой поток захватывает экран с помощью шаблона захвата. Обработанный поток применяет постобработку к другому потоку. Назначайте потоки устройствам.", - "streams.group.raw": "Потоки Захвата Экрана", - "streams.group.processed": "Обработанные Потоки", + "streams.group.raw": "Захват Экрана", + "streams.group.processed": "Обработанные", "streams.add": "Добавить Видеопоток", "streams.add.raw": "Добавить Захват Экрана", "streams.add.processed": "Добавить Обработанный", @@ -281,7 +281,7 @@ "device.stream_settings.smoothing": "Сглаживание:", "device.stream_settings.smoothing_hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.", "device.tip.stream_selector": "Настройки видеопотока и проекции LED для этого устройства", - "streams.group.static_image": "Статические изображения", + "streams.group.static_image": "Статические", "streams.add.static_image": "Добавить статическое изображение", "streams.edit.static_image": "Редактировать статическое изображение", "streams.type.static_image": "Статическое изображение", diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css index 2625b18..02c40a5 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -2176,67 +2176,58 @@ input:-webkit-autofill:focus { margin-right: 6px; } -/* Stream group sections */ -.stream-group { - margin-bottom: 24px; -} - -.stream-group:last-child { - margin-bottom: 0; -} - -.stream-group-header { +/* Stream sub-tabs */ +.stream-tab-bar { display: flex; align-items: center; - gap: 8px; - margin-bottom: 12px; - padding-bottom: 8px; + gap: 4px; border-bottom: 2px solid var(--border-color); - cursor: pointer; - user-select: none; - transition: opacity 0.2s; + margin-bottom: 16px; } -.stream-group-header:hover { - opacity: 0.8; -} - -.stream-group-chevron { - font-size: 10px; +.stream-tab-btn { + background: none; + border: none; + padding: 8px 14px; + font-size: 0.9rem; + font-weight: 500; color: var(--text-secondary); - flex-shrink: 0; - width: 12px; - transition: transform 0.2s; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + transition: color 0.2s, border-color 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 { - font-size: 1.2rem; -} - -.stream-group-title { - font-size: 1rem; - font-weight: 600; +.stream-tab-btn:hover { color: var(--text-color); } -.stream-group-count { +.stream-tab-btn.active { + color: var(--primary-color); + border-bottom-color: var(--primary-color); +} + +.stream-tab-count { background: var(--border-color); color: var(--text-secondary); - font-size: 0.75rem; + font-size: 0.7rem; font-weight: 600; - padding: 2px 8px; - border-radius: 10px; - min-width: 20px; - text-align: center; + padding: 1px 6px; + border-radius: 8px; + margin-left: 4px; +} + +.stream-tab-btn.active .stream-tab-count { + background: var(--primary-color); + color: #fff; +} + +.stream-tab-panel { + display: none; +} + +.stream-tab-panel.active { + display: block; } /* Image Lightbox */