Merge templates into Streams tab, rename app to WLED Grab

- Merge Capture Templates and Processing Templates main tabs into Picture
  Streams sub-tabs (Screen Capture shows streams + engine templates,
  Processed shows streams + filter templates)
- Rename "Capture Templates" to "Engine Templates" and "Processing
  Templates" to "Filter Templates" across all locale strings
- Rename "Picture Streams" tab to "Streams" throughout UI and locales
- Rename "WLED Screen Controller" to "WLED Grab" across all files
- Add subtab section headers and styling for merged template views
- Remove add card labels, keeping only plus icon for cleaner UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 20:53:03 +03:00
parent 7d0b6f2583
commit 9ae93497a6
9 changed files with 241 additions and 239 deletions

View File

@@ -1,4 +1,4 @@
"""WLED Screen Controller - Ambient lighting based on screen content.""" """WLED Grab - Ambient lighting based on screen content."""
__version__ = "0.1.0" __version__ = "0.1.0"
__author__ = "Alexei Dolgolyov" __author__ = "Alexei Dolgolyov"

View File

@@ -1,4 +1,4 @@
"""Configuration management for WLED Screen Controller.""" """Configuration management for WLED Grab."""
import os import os
from pathlib import Path from pathlib import Path

View File

@@ -35,7 +35,7 @@ class CaptureEngine(ABC):
"""Abstract base class for screen capture engines. """Abstract base class for screen capture engines.
All screen capture engines must implement this interface to be All screen capture engines must implement this interface to be
compatible with the WLED Screen Controller system. compatible with the WLED Grab system.
""" """
ENGINE_TYPE: str = "base" # Override in subclasses ENGINE_TYPE: str = "base" # Override in subclasses

View File

@@ -98,7 +98,7 @@ async def lifespan(app: FastAPI):
Handles startup and shutdown events. Handles startup and shutdown events.
""" """
# Startup # Startup
logger.info(f"Starting WLED Screen Controller v{__version__}") logger.info(f"Starting WLED Grab v{__version__}")
logger.info(f"Python version: {sys.version}") logger.info(f"Python version: {sys.version}")
logger.info(f"Server listening on {config.server.host}:{config.server.port}") logger.info(f"Server listening on {config.server.host}:{config.server.port}")
@@ -152,7 +152,7 @@ async def lifespan(app: FastAPI):
yield yield
# Shutdown # Shutdown
logger.info("Shutting down WLED Screen Controller") logger.info("Shutting down WLED Grab")
# Stop all processing # Stop all processing
try: try:
@@ -163,7 +163,7 @@ async def lifespan(app: FastAPI):
# Create FastAPI application # Create FastAPI application
app = FastAPI( app = FastAPI(
title="WLED Screen Controller", title="WLED Grab",
description="Control WLED devices based on screen content for ambient lighting", description="Control WLED devices based on screen content for ambient lighting",
version=__version__, version=__version__,
lifespan=lifespan, lifespan=lifespan,
@@ -217,7 +217,7 @@ async def root():
# Fallback to JSON if static files not found # Fallback to JSON if static files not found
return { return {
"name": "WLED Screen Controller", "name": "WLED Grab",
"version": __version__, "version": __version__,
"docs": "/docs", "docs": "/docs",
"health": "/health", "health": "/health",

View File

@@ -208,7 +208,7 @@ const supportedLocales = {
// Minimal inline fallback for critical UI elements // Minimal inline fallback for critical UI elements
const fallbackTranslations = { const fallbackTranslations = {
'app.title': 'WLED Screen Controller', 'app.title': 'WLED Grab',
'auth.placeholder': 'Enter your API key...', 'auth.placeholder': 'Enter your API key...',
'auth.button.login': 'Login' 'auth.button.login': 'Login'
}; };
@@ -494,18 +494,16 @@ async function loadDisplays() {
let _cachedDisplays = null; let _cachedDisplays = null;
function switchTab(name) { function switchTab(name) {
// Migrate legacy tab values from localStorage
if (name === 'templates' || name === 'pp-templates') {
name = 'streams';
}
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name)); document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name));
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`)); document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
localStorage.setItem('activeTab', name); localStorage.setItem('activeTab', name);
if (name === 'templates') {
loadCaptureTemplates();
}
if (name === 'streams') { if (name === 'streams') {
loadPictureStreams(); loadPictureStreams();
} }
if (name === 'pp-templates') {
loadPPTemplates();
}
} }
function initTabs() { function initTabs() {
@@ -2423,80 +2421,15 @@ async function loadCaptureTemplates() {
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load templates: ${response.status}`); throw new Error(`Failed to load templates: ${response.status}`);
} }
const data = await response.json(); const data = await response.json();
renderTemplatesList(data.templates || []); _cachedCaptureTemplates = data.templates || [];
// Re-render the streams tab which now contains template sections
renderPictureStreamsList(_cachedStreams);
} catch (error) { } catch (error) {
console.error('Error loading capture templates:', error); console.error('Error loading capture templates:', error);
document.getElementById('templates-list').innerHTML = `
<div class="error-message">${t('templates.error.load')}: ${error.message}</div>
`;
} }
} }
// Render templates list
function renderTemplatesList(templates) {
const container = document.getElementById('templates-list');
if (templates.length === 0) {
container.innerHTML = `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('templates.add')}</div>
</div>`;
return;
}
const renderCard = (template) => {
const engineIcon = getEngineIcon(template.engine_type);
const configEntries = Object.entries(template.engine_config);
return `
<div class="template-card" data-template-id="${template.id}">
<button class="card-remove-btn" onclick="deleteTemplate('${template.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">
${engineIcon} ${escapeHtml(template.name)}
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('templates.engine')}">⚙️ ${template.engine_type.toUpperCase()}</span>
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">🔧 ${configEntries.length}</span>` : ''}
</div>
${configEntries.length > 0 ? `
<details class="template-config-details">
<summary>${t('templates.config.show')}</summary>
<table class="config-table">
${configEntries.map(([key, val]) => `
<tr>
<td class="config-key">${escapeHtml(key)}</td>
<td class="config-value">${escapeHtml(String(val))}</td>
</tr>
`).join('')}
</table>
</details>
` : ''}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">
🧪
</button>
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
};
let html = templates.map(renderCard).join('');
html += `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('templates.add')}</div>
</div>`;
container.innerHTML = html;
}
// Get engine icon // Get engine icon
function getEngineIcon(engineType) { function getEngineIcon(engineType) {
return '🖥️'; return '🖥️';
@@ -3055,28 +2988,31 @@ let _availableFilters = []; // Loaded from GET /filters
async function loadPictureStreams() { async function loadPictureStreams() {
try { try {
// Ensure PP templates and capture templates are cached for stream card display // Always fetch templates, filters, and streams in parallel
if (_cachedPPTemplates.length === 0 || _cachedCaptureTemplates.length === 0) { // since templates are now rendered inside stream sub-tabs
try { const [filtersResp, ppResp, captResp, streamsResp] = await Promise.all([
if (_availableFilters.length === 0) { _availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
const fr = await fetchWithAuth('/filters'); fetchWithAuth('/postprocessing-templates'),
if (fr.ok) { const fd = await fr.json(); _availableFilters = fd.filters || []; } fetchWithAuth('/capture-templates'),
} fetchWithAuth('/picture-streams')
if (_cachedPPTemplates.length === 0) { ]);
const pr = await fetchWithAuth('/postprocessing-templates');
if (pr.ok) { const pd = await pr.json(); _cachedPPTemplates = pd.templates || []; } if (filtersResp && filtersResp.ok) {
} const fd = await filtersResp.json();
if (_cachedCaptureTemplates.length === 0) { _availableFilters = fd.filters || [];
const cr = await fetchWithAuth('/capture-templates');
if (cr.ok) { const cd = await cr.json(); _cachedCaptureTemplates = cd.templates || []; }
}
} catch (e) { console.warn('Could not pre-load templates for streams:', e); }
} }
const response = await fetchWithAuth('/picture-streams'); if (ppResp.ok) {
if (!response.ok) { const pd = await ppResp.json();
throw new Error(`Failed to load streams: ${response.status}`); _cachedPPTemplates = pd.templates || [];
} }
const data = await response.json(); if (captResp.ok) {
const cd = await captResp.json();
_cachedCaptureTemplates = cd.templates || [];
}
if (!streamsResp.ok) {
throw new Error(`Failed to load streams: ${streamsResp.status}`);
}
const data = await streamsResp.json();
_cachedStreams = data.streams || []; _cachedStreams = data.streams || [];
renderPictureStreamsList(_cachedStreams); renderPictureStreamsList(_cachedStreams);
} catch (error) { } catch (error) {
@@ -3101,7 +3037,7 @@ function renderPictureStreamsList(streams) {
const container = document.getElementById('streams-list'); const container = document.getElementById('streams-list');
const activeTab = localStorage.getItem('activeStreamTab') || 'raw'; const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
const renderCard = (stream) => { const renderStreamCard = (stream) => {
const typeIcons = { raw: '🖥️', processed: '🎨', static_image: '🖼️' }; const typeIcons = { raw: '🖥️', processed: '🎨', static_image: '🖼️' };
const typeIcon = typeIcons[stream.stream_type] || '📺'; const typeIcon = typeIcons[stream.stream_type] || '📺';
@@ -3158,34 +3094,145 @@ function renderPictureStreamsList(streams) {
`; `;
}; };
const renderCaptureTemplateCard = (template) => {
const engineIcon = getEngineIcon(template.engine_type);
const configEntries = Object.entries(template.engine_config);
return `
<div class="template-card" data-template-id="${template.id}">
<button class="card-remove-btn" onclick="deleteTemplate('${template.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">
${engineIcon} ${escapeHtml(template.name)}
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('templates.engine')}">⚙️ ${template.engine_type.toUpperCase()}</span>
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">🔧 ${configEntries.length}</span>` : ''}
</div>
${configEntries.length > 0 ? `
<details class="template-config-details">
<summary>${t('templates.config.show')}</summary>
<table class="config-table">
${configEntries.map(([key, val]) => `
<tr>
<td class="config-key">${escapeHtml(key)}</td>
<td class="config-value">${escapeHtml(String(val))}</td>
</tr>
`).join('')}
</table>
</details>
` : ''}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">
🧪
</button>
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
};
const renderPPTemplateCard = (tmpl) => {
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map(fi => `<span class="filter-chain-item">${escapeHtml(_getFilterName(fi.filter_id))}</span>`);
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">→</span>')}</div>`;
}
return `
<div class="template-card" data-pp-template-id="${tmpl.id}">
<button class="card-remove-btn" onclick="deletePPTemplate('${tmpl.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">
🎨 ${escapeHtml(tmpl.name)}
</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
${filterChainHtml}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestPPTemplateModal('${tmpl.id}')" title="${t('postprocessing.test.title')}">
🧪
</button>
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
};
const rawStreams = streams.filter(s => s.stream_type === 'raw'); const rawStreams = streams.filter(s => s.stream_type === 'raw');
const processedStreams = streams.filter(s => s.stream_type === 'processed'); const processedStreams = streams.filter(s => s.stream_type === 'processed');
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image'); const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
const addCard = (type, labelKey) => ` const addStreamCard = (type) => `
<div class="template-card add-template-card" onclick="showAddStreamModal('${type}')"> <div class="template-card add-template-card" onclick="showAddStreamModal('${type}')">
<div class="add-template-icon">+</div> <div class="add-template-icon">+</div>
<div class="add-template-label">${t(labelKey)}</div>
</div>`; </div>`;
const tabs = [ const tabs = [
{ key: 'raw', icon: '🖥️', titleKey: 'streams.group.raw', streams: rawStreams, addLabelKey: 'streams.add.raw' }, { key: 'raw', icon: '🖥️', titleKey: 'streams.group.raw', streams: rawStreams },
{ key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', streams: staticImageStreams, addLabelKey: 'streams.add.static_image' }, { key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', streams: staticImageStreams },
{ key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', streams: processedStreams, addLabelKey: 'streams.add.processed' }, { key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', streams: processedStreams },
]; ];
const tabBar = `<div class="stream-tab-bar">${tabs.map(tab => const tabBar = `<div class="stream-tab-bar">${tabs.map(tab =>
`<button class="stream-tab-btn${tab.key === activeTab ? ' active' : ''}" data-stream-tab="${tab.key}" onclick="switchStreamTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.streams.length}</span></button>` `<button class="stream-tab-btn${tab.key === activeTab ? ' active' : ''}" data-stream-tab="${tab.key}" onclick="switchStreamTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.streams.length}</span></button>`
).join('')}</div>`; ).join('')}</div>`;
const panels = tabs.map(tab => const panels = tabs.map(tab => {
`<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}"> let panelContent = '';
<div class="templates-grid">
${tab.streams.map(renderCard).join('')} if (tab.key === 'raw') {
${addCard(tab.key, tab.addLabelKey)} // Screen Capture: streams section + capture templates section
</div> panelContent = `
</div>` <div class="subtab-section">
).join(''); <h3 class="subtab-section-header">${t('streams.section.streams')}</h3>
<div class="templates-grid">
${tab.streams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)}
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('templates.title')}</h3>
<div class="templates-grid">
${_cachedCaptureTemplates.map(renderCaptureTemplateCard).join('')}
<div class="template-card add-template-card" onclick="showAddTemplateModal()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>`;
} else if (tab.key === 'processed') {
// Processed: streams section + PP templates section
panelContent = `
<div class="subtab-section">
<h3 class="subtab-section-header">${t('streams.section.streams')}</h3>
<div class="templates-grid">
${tab.streams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)}
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('postprocessing.title')}</h3>
<div class="templates-grid">
${_cachedPPTemplates.map(renderPPTemplateCard).join('')}
<div class="template-card add-template-card" onclick="showAddPPTemplateModal()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>`;
} else {
// Static Image: just the stream cards, no section headers
panelContent = `
<div class="templates-grid">
${tab.streams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)}
</div>`;
}
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).join('');
container.innerHTML = tabBar + panels; container.innerHTML = tabBar + panels;
} }
@@ -3671,12 +3718,10 @@ async function loadPPTemplates() {
} }
const data = await response.json(); const data = await response.json();
_cachedPPTemplates = data.templates || []; _cachedPPTemplates = data.templates || [];
renderPPTemplatesList(_cachedPPTemplates); // Re-render the streams tab which now contains template sections
renderPictureStreamsList(_cachedStreams);
} catch (error) { } catch (error) {
console.error('Error loading PP templates:', error); console.error('Error loading PP templates:', error);
document.getElementById('pp-templates-list').innerHTML = `
<div class="error-message">${t('postprocessing.error.load')}: ${error.message}</div>
`;
} }
} }
@@ -3691,56 +3736,6 @@ function _getFilterName(filterId) {
return translated; return translated;
} }
function renderPPTemplatesList(templates) {
const container = document.getElementById('pp-templates-list');
if (templates.length === 0) {
container.innerHTML = `<div class="template-card add-template-card" onclick="showAddPPTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('postprocessing.add')}</div>
</div>`;
return;
}
const renderCard = (tmpl) => {
// Build filter chain pills
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map(fi => `<span class="filter-chain-item">${escapeHtml(_getFilterName(fi.filter_id))}</span>`);
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">→</span>')}</div>`;
}
return `
<div class="template-card" data-pp-template-id="${tmpl.id}">
<button class="card-remove-btn" onclick="deletePPTemplate('${tmpl.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">
🎨 ${escapeHtml(tmpl.name)}
</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
${filterChainHtml}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestPPTemplateModal('${tmpl.id}')" title="${t('postprocessing.test.title')}">
🧪
</button>
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
};
let html = templates.map(renderCard).join('');
html += `<div class="template-card add-template-card" onclick="showAddPPTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('postprocessing.add')}</div>
</div>`;
container.innerHTML = html;
}
// --- Filter list management in PP template modal --- // --- Filter list management in PP template modal ---
let _modalFilters = []; // Current filter list being edited in modal let _modalFilters = []; // Current filter list being edited in modal

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WLED Screen Controller</title> <title>WLED Grab</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>💡</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>💡</text></svg>">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
@@ -12,7 +12,7 @@
<header> <header>
<div class="header-title"> <div class="header-title">
<span id="server-status" class="status-badge"></span> <span id="server-status" class="status-badge"></span>
<h1 data-i18n="app.title">WLED Screen Controller</h1> <h1 data-i18n="app.title">WLED Grab</h1>
<span id="server-version"><span id="version-number"></span></span> <span id="server-version"><span id="version-number"></span></span>
</div> </div>
<div class="server-info"> <div class="server-info">
@@ -36,9 +36,7 @@
<div class="tab-bar"> <div class="tab-bar">
<button class="tab-btn active" data-tab="devices" onclick="switchTab('devices')"><span data-i18n="devices.title">💡 Devices</span></button> <button class="tab-btn active" data-tab="devices" onclick="switchTab('devices')"><span data-i18n="devices.title">💡 Devices</span></button>
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Picture Streams</span></button> <button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Streams</span></button>
<button class="tab-btn" data-tab="templates" onclick="switchTab('templates')"><span data-i18n="templates.title">🎯 Capture Templates</span></button>
<button class="tab-btn" data-tab="pp-templates" onclick="switchTab('pp-templates')"><span data-i18n="postprocessing.title">🎨 Processing Templates</span></button>
</div> </div>
<div class="tab-panel active" id="tab-devices"> <div class="tab-panel active" id="tab-devices">
@@ -54,17 +52,6 @@
</div> </div>
</div> </div>
<div class="tab-panel" id="tab-templates">
<div id="templates-list" class="templates-grid">
<div class="loading-spinner"></div>
</div>
</div>
<div class="tab-panel" id="tab-pp-templates">
<div id="pp-templates-list" class="templates-grid">
<div class="loading-spinner"></div>
</div>
</div>
</div> </div>
<footer class="app-footer"> <footer class="app-footer">
@@ -249,9 +236,9 @@
<input type="hidden" id="stream-selector-device-id"> <input type="hidden" id="stream-selector-device-id">
<div class="form-group"> <div class="form-group">
<label for="stream-selector-stream" data-i18n="device.stream_selector.label">Assigned Picture Stream:</label> <label for="stream-selector-stream" data-i18n="device.stream_selector.label">Stream:</label>
<select id="stream-selector-stream"></select> <select id="stream-selector-stream"></select>
<small class="input-hint" data-i18n="device.stream_selector.hint">Select a picture stream that defines what this device captures and processes</small> <small class="input-hint" data-i18n="device.stream_selector.hint">Select a stream that defines what this device captures and processes</small>
</div> </div>
<div id="stream-selector-info" class="stream-info-panel" style="display: none;"></div> <div id="stream-selector-info" class="stream-info-panel" style="display: none;"></div>
@@ -301,7 +288,7 @@
<form id="api-key-form" onsubmit="submitApiKey(event)"> <form id="api-key-form" onsubmit="submitApiKey(event)">
<div class="modal-body"> <div class="modal-body">
<p class="modal-description" data-i18n="auth.message"> <p class="modal-description" data-i18n="auth.message">
Please enter your API key to authenticate and access the WLED Screen Controller. Please enter your API key to authenticate and access the WLED Grab.
</p> </p>
<div class="form-group"> <div class="form-group">
<label for="api-key-input" data-i18n="auth.label">API Key:</label> <label for="api-key-input" data-i18n="auth.label">API Key:</label>
@@ -447,7 +434,7 @@
<div id="test-stream-modal" class="modal"> <div id="test-stream-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 data-i18n="streams.test.title">Test Picture Stream</h2> <h2 data-i18n="streams.test.title">Test Stream</h2>
<button class="modal-close-btn" onclick="closeTestStreamModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" onclick="closeTestStreamModal()" title="Close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -495,11 +482,11 @@
</div> </div>
</div> </div>
<!-- Picture Stream Modal --> <!-- Stream Modal -->
<div id="stream-modal" class="modal"> <div id="stream-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="stream-modal-title" data-i18n="streams.add">Add Picture Stream</h2> <h2 id="stream-modal-title" data-i18n="streams.add">Add Stream</h2>
<button class="modal-close-btn" onclick="closeStreamModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" onclick="closeStreamModal()" title="Close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">

View File

@@ -1,5 +1,5 @@
{ {
"app.title": "WLED Screen Controller", "app.title": "WLED Grab",
"app.version": "Version:", "app.version": "Version:",
"theme.toggle": "Toggle theme", "theme.toggle": "Toggle theme",
"locale.change": "Change language", "locale.change": "Change language",
@@ -7,7 +7,7 @@
"auth.logout": "Logout", "auth.logout": "Logout",
"auth.authenticated": "● Authenticated", "auth.authenticated": "● Authenticated",
"auth.title": "Login to WLED Controller", "auth.title": "Login to WLED Controller",
"auth.message": "Please enter your API key to authenticate and access the WLED Screen Controller.", "auth.message": "Please enter your API key to authenticate and access the WLED Grab.",
"auth.label": "API Key:", "auth.label": "API Key:",
"auth.placeholder": "Enter your API key...", "auth.placeholder": "Enter your API key...",
"auth.hint": "Your API key will be stored securely in your browser's local storage.", "auth.hint": "Your API key will be stored securely in your browser's local storage.",
@@ -35,12 +35,12 @@
"displays.picker.title": "Select a Display", "displays.picker.title": "Select a Display",
"displays.picker.select": "Select display...", "displays.picker.select": "Select display...",
"displays.picker.click_to_select": "Click to select this display", "displays.picker.click_to_select": "Click to select this display",
"templates.title": "\uD83C\uDFAF Capture Templates", "templates.title": "\uD83D\uDCC4 Engine Templates",
"templates.description": "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.", "templates.description": "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.",
"templates.loading": "Loading templates...", "templates.loading": "Loading templates...",
"templates.empty": "No capture templates configured", "templates.empty": "No capture templates configured",
"templates.add": "Add Capture Template", "templates.add": "Add Engine Template",
"templates.edit": "Edit Capture Template", "templates.edit": "Edit Engine Template",
"templates.name": "Template Name:", "templates.name": "Template Name:",
"templates.name.placeholder": "My Custom Template", "templates.name.placeholder": "My Custom Template",
"templates.description.label": "Description (optional):", "templates.description.label": "Description (optional):",
@@ -154,7 +154,7 @@
"settings.display_index.hint": "Which screen to capture for this device", "settings.display_index.hint": "Which screen to capture for this device",
"settings.fps": "Target FPS:", "settings.fps": "Target FPS:",
"settings.fps.hint": "Target frames per second (10-90)", "settings.fps.hint": "Target frames per second (10-90)",
"settings.capture_template": "Capture Template:", "settings.capture_template": "Engine Template:",
"settings.capture_template.hint": "Screen capture engine and configuration for this device", "settings.capture_template.hint": "Screen capture engine and configuration for this device",
"settings.button.cancel": "Cancel", "settings.button.cancel": "Cancel",
"settings.health_interval": "Health Check Interval (s):", "settings.health_interval": "Health Check Interval (s):",
@@ -198,14 +198,15 @@
"confirm.no": "No", "confirm.no": "No",
"common.delete": "Delete", "common.delete": "Delete",
"common.edit": "Edit", "common.edit": "Edit",
"streams.title": "\uD83D\uDCFA Picture Streams", "streams.title": "\uD83D\uDCFA 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.description": "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.group.raw": "Screen Capture",
"streams.group.processed": "Processed", "streams.group.processed": "Processed",
"streams.add": "Add Picture Stream", "streams.section.streams": "\uD83D\uDCFA Streams",
"streams.add": "Add Stream",
"streams.add.raw": "Add Screen Capture", "streams.add.raw": "Add Screen Capture",
"streams.add.processed": "Add Processed Stream", "streams.add.processed": "Add Processed Stream",
"streams.edit": "Edit Picture Stream", "streams.edit": "Edit Stream",
"streams.edit.raw": "Edit Screen Capture", "streams.edit.raw": "Edit Screen Capture",
"streams.edit.processed": "Edit Processed Stream", "streams.edit.processed": "Edit Processed Stream",
"streams.name": "Stream Name:", "streams.name": "Stream Name:",
@@ -214,10 +215,10 @@
"streams.type.raw": "Screen Capture", "streams.type.raw": "Screen Capture",
"streams.type.processed": "Processed", "streams.type.processed": "Processed",
"streams.display": "Display:", "streams.display": "Display:",
"streams.capture_template": "Capture Template:", "streams.capture_template": "Engine Template:",
"streams.target_fps": "Target FPS:", "streams.target_fps": "Target FPS:",
"streams.source": "Source Stream:", "streams.source": "Source Stream:",
"streams.pp_template": "Processing Template:", "streams.pp_template": "Filter Template:",
"streams.description_label": "Description (optional):", "streams.description_label": "Description (optional):",
"streams.description_placeholder": "Describe this stream...", "streams.description_placeholder": "Describe this stream...",
"streams.created": "Stream created successfully", "streams.created": "Stream created successfully",
@@ -227,17 +228,17 @@
"streams.error.load": "Failed to load streams", "streams.error.load": "Failed to load streams",
"streams.error.required": "Please fill in all required fields", "streams.error.required": "Please fill in all required fields",
"streams.error.delete": "Failed to delete stream", "streams.error.delete": "Failed to delete stream",
"streams.test.title": "Test Picture Stream", "streams.test.title": "Test Stream",
"streams.test.run": "🧪 Run Test", "streams.test.run": "🧪 Run Test",
"streams.test.running": "Testing stream...", "streams.test.running": "Testing stream...",
"streams.test.duration": "Capture Duration (s):", "streams.test.duration": "Capture Duration (s):",
"streams.test.error.failed": "Stream test failed", "streams.test.error.failed": "Stream test failed",
"postprocessing.title": "\uD83C\uDFA8 Processing Templates", "postprocessing.title": "\uD83D\uDCC4 Filter Templates",
"postprocessing.description": "Processing templates define image filters and color correction. Assign them to processed picture streams for consistent postprocessing across devices.", "postprocessing.description": "Processing templates define image filters and color correction. Assign them to processed picture streams for consistent postprocessing across devices.",
"postprocessing.add": "Add Processing Template", "postprocessing.add": "Add Filter Template",
"postprocessing.edit": "Edit Processing Template", "postprocessing.edit": "Edit Filter Template",
"postprocessing.name": "Template Name:", "postprocessing.name": "Template Name:",
"postprocessing.name.placeholder": "My Processing Template", "postprocessing.name.placeholder": "My Filter Template",
"filters.select_type": "Select filter type...", "filters.select_type": "Select filter type...",
"filters.add": "Add Filter", "filters.add": "Add Filter",
"filters.remove": "Remove", "filters.remove": "Remove",
@@ -255,20 +256,20 @@
"postprocessing.created": "Template created successfully", "postprocessing.created": "Template created successfully",
"postprocessing.updated": "Template updated successfully", "postprocessing.updated": "Template updated successfully",
"postprocessing.deleted": "Template deleted successfully", "postprocessing.deleted": "Template deleted successfully",
"postprocessing.delete.confirm": "Are you sure you want to delete this processing template?", "postprocessing.delete.confirm": "Are you sure you want to delete this filter template?",
"postprocessing.error.load": "Failed to load processing templates", "postprocessing.error.load": "Failed to load processing templates",
"postprocessing.error.required": "Please fill in all required fields", "postprocessing.error.required": "Please fill in all required fields",
"postprocessing.error.delete": "Failed to delete processing template", "postprocessing.error.delete": "Failed to delete processing template",
"postprocessing.config.show": "Show settings", "postprocessing.config.show": "Show settings",
"postprocessing.test.title": "Test Processing Template", "postprocessing.test.title": "Test Filter Template",
"postprocessing.test.source_stream": "Source Stream:", "postprocessing.test.source_stream": "Source Stream:",
"postprocessing.test.running": "Testing processing template...", "postprocessing.test.running": "Testing processing template...",
"postprocessing.test.error.no_stream": "Please select a source stream", "postprocessing.test.error.no_stream": "Please select a source stream",
"postprocessing.test.error.failed": "Processing template test failed", "postprocessing.test.error.failed": "Processing template test failed",
"device.button.stream_selector": "Stream Settings", "device.button.stream_selector": "Stream Settings",
"device.stream_settings.title": "📺 Stream Settings", "device.stream_settings.title": "📺 Stream Settings",
"device.stream_selector.label": "Picture Stream:", "device.stream_selector.label": "Stream:",
"device.stream_selector.hint": "Select a picture stream that defines what this device captures and processes", "device.stream_selector.hint": "Select a stream that defines what this device captures and processes",
"device.stream_selector.none": "-- No stream assigned --", "device.stream_selector.none": "-- No stream assigned --",
"device.stream_selector.saved": "Stream settings updated", "device.stream_selector.saved": "Stream settings updated",
"device.stream_settings.border_width": "Border Width (px):", "device.stream_settings.border_width": "Border Width (px):",

View File

@@ -1,13 +1,13 @@
{ {
"app.title": "WLED Контроллер Экрана", "app.title": "WLED Grab",
"app.version": "Версия:", "app.version": "Версия:",
"theme.toggle": "Переключить тему", "theme.toggle": "Переключить тему",
"locale.change": "Изменить язык", "locale.change": "Изменить язык",
"auth.login": "Войти", "auth.login": "Войти",
"auth.logout": "Выйти", "auth.logout": "Выйти",
"auth.authenticated": "● Авторизован", "auth.authenticated": "● Авторизован",
"auth.title": "Вход в WLED Контроллер", "auth.title": "Вход в WLED Grab",
"auth.message": "Пожалуйста, введите ваш API ключ для аутентификации и доступа к WLED Контроллеру Экрана.", "auth.message": "Пожалуйста, введите ваш API ключ для аутентификации и доступа к WLED Grab.",
"auth.label": "API Ключ:", "auth.label": "API Ключ:",
"auth.placeholder": "Введите ваш API ключ...", "auth.placeholder": "Введите ваш API ключ...",
"auth.hint": "Ваш API ключ будет безопасно сохранен в локальном хранилище браузера.", "auth.hint": "Ваш API ключ будет безопасно сохранен в локальном хранилище браузера.",
@@ -35,12 +35,12 @@
"displays.picker.title": "Выберите Дисплей", "displays.picker.title": "Выберите Дисплей",
"displays.picker.select": "Выберите дисплей...", "displays.picker.select": "Выберите дисплей...",
"displays.picker.click_to_select": "Нажмите, чтобы выбрать этот дисплей", "displays.picker.click_to_select": "Нажмите, чтобы выбрать этот дисплей",
"templates.title": "\uD83C\uDFAF Шаблоны Захвата", "templates.title": "\uD83D\uDCC4 Шаблоны Движков",
"templates.description": "Шаблоны захвата определяют, как захватывается экран. Каждый шаблон использует определённый движок захвата (MSS, DXcam, WGC) с настраиваемыми параметрами. Назначайте шаблоны устройствам для оптимальной производительности.", "templates.description": "Шаблоны захвата определяют, как захватывается экран. Каждый шаблон использует определённый движок захвата (MSS, DXcam, WGC) с настраиваемыми параметрами. Назначайте шаблоны устройствам для оптимальной производительности.",
"templates.loading": "Загрузка шаблонов...", "templates.loading": "Загрузка шаблонов...",
"templates.empty": "Шаблоны захвата не настроены", "templates.empty": "Шаблоны захвата не настроены",
"templates.add": "Добавить Шаблон Захвата", "templates.add": "Добавить Шаблон Движка",
"templates.edit": "Редактировать Шаблон Захвата", "templates.edit": "Редактировать Шаблон Движка",
"templates.name": "Имя Шаблона:", "templates.name": "Имя Шаблона:",
"templates.name.placeholder": "Мой Пользовательский Шаблон", "templates.name.placeholder": "Мой Пользовательский Шаблон",
"templates.description.label": "Описание (необязательно):", "templates.description.label": "Описание (необязательно):",
@@ -154,7 +154,7 @@
"settings.display_index.hint": "Какой экран захватывать для этого устройства", "settings.display_index.hint": "Какой экран захватывать для этого устройства",
"settings.fps": "Целевой FPS:", "settings.fps": "Целевой FPS:",
"settings.fps.hint": "Целевая частота кадров (10-90)", "settings.fps.hint": "Целевая частота кадров (10-90)",
"settings.capture_template": "Шаблон Захвата:", "settings.capture_template": "Шаблон Движка:",
"settings.capture_template.hint": "Движок захвата экрана и конфигурация для этого устройства", "settings.capture_template.hint": "Движок захвата экрана и конфигурация для этого устройства",
"settings.button.cancel": "Отмена", "settings.button.cancel": "Отмена",
"settings.health_interval": "Интервал Проверки (с):", "settings.health_interval": "Интервал Проверки (с):",
@@ -198,14 +198,15 @@
"confirm.no": "Нет", "confirm.no": "Нет",
"common.delete": "Удалить", "common.delete": "Удалить",
"common.edit": "Редактировать", "common.edit": "Редактировать",
"streams.title": "\uD83D\uDCFA Видеопотоки", "streams.title": "\uD83D\uDCFA Потоки",
"streams.description": "Видеопотоки определяют конвейер захвата. Сырой поток захватывает экран с помощью шаблона захвата. Обработанный поток применяет постобработку к другому потоку. Назначайте потоки устройствам.", "streams.description": "Потоки определяют конвейер захвата. Сырой поток захватывает экран с помощью шаблона захвата. Обработанный поток применяет постобработку к другому потоку. Назначайте потоки устройствам.",
"streams.group.raw": "Захват Экрана", "streams.group.raw": "Захват Экрана",
"streams.group.processed": "Обработанные", "streams.group.processed": "Обработанные",
"streams.add": "Добавить Видеопоток", "streams.section.streams": "\uD83D\uDCFA Потоки",
"streams.add": "Добавить Поток",
"streams.add.raw": "Добавить Захват Экрана", "streams.add.raw": "Добавить Захват Экрана",
"streams.add.processed": "Добавить Обработанный", "streams.add.processed": "Добавить Обработанный",
"streams.edit": "Редактировать Видеопоток", "streams.edit": "Редактировать Поток",
"streams.edit.raw": "Редактировать Захват Экрана", "streams.edit.raw": "Редактировать Захват Экрана",
"streams.edit.processed": "Редактировать Обработанный Поток", "streams.edit.processed": "Редактировать Обработанный Поток",
"streams.name": "Имя Потока:", "streams.name": "Имя Потока:",
@@ -214,10 +215,10 @@
"streams.type.raw": "Захват экрана", "streams.type.raw": "Захват экрана",
"streams.type.processed": "Обработанный", "streams.type.processed": "Обработанный",
"streams.display": "Дисплей:", "streams.display": "Дисплей:",
"streams.capture_template": "Шаблон Захвата:", "streams.capture_template": "Шаблон Движка:",
"streams.target_fps": "Целевой FPS:", "streams.target_fps": "Целевой FPS:",
"streams.source": "Исходный Поток:", "streams.source": "Исходный Поток:",
"streams.pp_template": "Шаблон Обработки:", "streams.pp_template": "Шаблон Фильтра:",
"streams.description_label": "Описание (необязательно):", "streams.description_label": "Описание (необязательно):",
"streams.description_placeholder": "Опишите этот поток...", "streams.description_placeholder": "Опишите этот поток...",
"streams.created": "Поток успешно создан", "streams.created": "Поток успешно создан",
@@ -227,17 +228,17 @@
"streams.error.load": "Не удалось загрузить потоки", "streams.error.load": "Не удалось загрузить потоки",
"streams.error.required": "Пожалуйста, заполните все обязательные поля", "streams.error.required": "Пожалуйста, заполните все обязательные поля",
"streams.error.delete": "Не удалось удалить поток", "streams.error.delete": "Не удалось удалить поток",
"streams.test.title": "Тест Видеопотока", "streams.test.title": "Тест Потока",
"streams.test.run": "🧪 Запустить Тест", "streams.test.run": "🧪 Запустить Тест",
"streams.test.running": "Тестирование потока...", "streams.test.running": "Тестирование потока...",
"streams.test.duration": "Длительность Захвата (с):", "streams.test.duration": "Длительность Захвата (с):",
"streams.test.error.failed": "Тест потока не удался", "streams.test.error.failed": "Тест потока не удался",
"postprocessing.title": "\uD83C\uDFA8 Шаблоны Обработки", "postprocessing.title": "\uD83D\uDCC4 Шаблоны Фильтров",
"postprocessing.description": "Шаблоны обработки определяют фильтры изображений и цветокоррекцию. Назначайте их обработанным видеопотокам для единообразной постобработки на всех устройствах.", "postprocessing.description": "Шаблоны обработки определяют фильтры изображений и цветокоррекцию. Назначайте их обработанным видеопотокам для единообразной постобработки на всех устройствах.",
"postprocessing.add": "Добавить Шаблон Обработки", "postprocessing.add": "Добавить Шаблон Фильтра",
"postprocessing.edit": "Редактировать Шаблон Обработки", "postprocessing.edit": "Редактировать Шаблон Фильтра",
"postprocessing.name": "Имя Шаблона:", "postprocessing.name": "Имя Шаблона:",
"postprocessing.name.placeholder": "Мой Шаблон Обработки", "postprocessing.name.placeholder": "Мой Шаблон Фильтра",
"filters.select_type": "Выберите тип фильтра...", "filters.select_type": "Выберите тип фильтра...",
"filters.add": "Добавить фильтр", "filters.add": "Добавить фильтр",
"filters.remove": "Удалить", "filters.remove": "Удалить",
@@ -255,20 +256,20 @@
"postprocessing.created": "Шаблон успешно создан", "postprocessing.created": "Шаблон успешно создан",
"postprocessing.updated": "Шаблон успешно обновлён", "postprocessing.updated": "Шаблон успешно обновлён",
"postprocessing.deleted": "Шаблон успешно удалён", "postprocessing.deleted": "Шаблон успешно удалён",
"postprocessing.delete.confirm": "Вы уверены, что хотите удалить этот шаблон обработки?", "postprocessing.delete.confirm": "Вы уверены, что хотите удалить этот шаблон фильтра?",
"postprocessing.error.load": "Не удалось загрузить шаблоны обработки", "postprocessing.error.load": "Не удалось загрузить шаблоны фильтров",
"postprocessing.error.required": "Пожалуйста, заполните все обязательные поля", "postprocessing.error.required": "Пожалуйста, заполните все обязательные поля",
"postprocessing.error.delete": "Не удалось удалить шаблон обработки", "postprocessing.error.delete": "Не удалось удалить шаблон фильтра",
"postprocessing.config.show": "Показать настройки", "postprocessing.config.show": "Показать настройки",
"postprocessing.test.title": "Тест шаблона обработки", "postprocessing.test.title": "Тест шаблона фильтра",
"postprocessing.test.source_stream": "Источник потока:", "postprocessing.test.source_stream": "Источник потока:",
"postprocessing.test.running": "Тестирование шаблона обработки...", "postprocessing.test.running": "Тестирование шаблона фильтра...",
"postprocessing.test.error.no_stream": "Пожалуйста, выберите источник потока", "postprocessing.test.error.no_stream": "Пожалуйста, выберите источник потока",
"postprocessing.test.error.failed": "Тест шаблона обработки не удался", "postprocessing.test.error.failed": "Тест шаблона фильтра не удался",
"device.button.stream_selector": "Настройки потока", "device.button.stream_selector": "Настройки потока",
"device.stream_settings.title": "📺 Настройки потока", "device.stream_settings.title": "📺 Настройки потока",
"device.stream_selector.label": "Видеопоток:", "device.stream_selector.label": "Поток:",
"device.stream_selector.hint": "Выберите видеопоток, определяющий что это устройство захватывает и обрабатывает", "device.stream_selector.hint": "Выберите поток, определяющий что это устройство захватывает и обрабатывает",
"device.stream_selector.none": "-- Поток не назначен --", "device.stream_selector.none": "-- Поток не назначен --",
"device.stream_selector.saved": "Настройки потока обновлены", "device.stream_selector.saved": "Настройки потока обновлены",
"device.stream_settings.border_width": "Ширина границы (px):", "device.stream_settings.border_width": "Ширина границы (px):",

View File

@@ -2230,6 +2230,24 @@ input:-webkit-autofill:focus {
display: block; display: block;
} }
/* Sub-tab content sections */
.subtab-section {
margin-bottom: 24px;
}
.subtab-section:last-child {
margin-bottom: 0;
}
.subtab-section-header {
font-size: 1rem;
font-weight: 600;
color: var(--text-secondary);
margin: 0 0 12px 0;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color);
}
/* Image Lightbox */ /* Image Lightbox */
.lightbox { .lightbox {
display: none; display: none;