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"
__author__ = "Alexei Dolgolyov"

View File

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

View File

@@ -35,7 +35,7 @@ class CaptureEngine(ABC):
"""Abstract base class for screen capture engines.
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

View File

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

View File

@@ -208,7 +208,7 @@ const supportedLocales = {
// Minimal inline fallback for critical UI elements
const fallbackTranslations = {
'app.title': 'WLED Screen Controller',
'app.title': 'WLED Grab',
'auth.placeholder': 'Enter your API key...',
'auth.button.login': 'Login'
};
@@ -494,18 +494,16 @@ async function loadDisplays() {
let _cachedDisplays = null;
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-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
localStorage.setItem('activeTab', name);
if (name === 'templates') {
loadCaptureTemplates();
}
if (name === 'streams') {
loadPictureStreams();
}
if (name === 'pp-templates') {
loadPPTemplates();
}
}
function initTabs() {
@@ -2423,80 +2421,15 @@ async function loadCaptureTemplates() {
if (!response.ok) {
throw new Error(`Failed to load templates: ${response.status}`);
}
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) {
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
function getEngineIcon(engineType) {
return '🖥️';
@@ -3055,28 +2988,31 @@ let _availableFilters = []; // Loaded from GET /filters
async function loadPictureStreams() {
try {
// Ensure PP templates and capture templates are cached for stream card display
if (_cachedPPTemplates.length === 0 || _cachedCaptureTemplates.length === 0) {
try {
if (_availableFilters.length === 0) {
const fr = await fetchWithAuth('/filters');
if (fr.ok) { const fd = await fr.json(); _availableFilters = fd.filters || []; }
// Always fetch templates, filters, and streams in parallel
// since templates are now rendered inside stream sub-tabs
const [filtersResp, ppResp, captResp, streamsResp] = await Promise.all([
_availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
fetchWithAuth('/postprocessing-templates'),
fetchWithAuth('/capture-templates'),
fetchWithAuth('/picture-streams')
]);
if (filtersResp && filtersResp.ok) {
const fd = await filtersResp.json();
_availableFilters = fd.filters || [];
}
if (_cachedPPTemplates.length === 0) {
const pr = await fetchWithAuth('/postprocessing-templates');
if (pr.ok) { const pd = await pr.json(); _cachedPPTemplates = pd.templates || []; }
if (ppResp.ok) {
const pd = await ppResp.json();
_cachedPPTemplates = pd.templates || [];
}
if (_cachedCaptureTemplates.length === 0) {
const cr = await fetchWithAuth('/capture-templates');
if (cr.ok) { const cd = await cr.json(); _cachedCaptureTemplates = cd.templates || []; }
if (captResp.ok) {
const cd = await captResp.json();
_cachedCaptureTemplates = cd.templates || [];
}
} catch (e) { console.warn('Could not pre-load templates for streams:', e); }
if (!streamsResp.ok) {
throw new Error(`Failed to load streams: ${streamsResp.status}`);
}
const response = await fetchWithAuth('/picture-streams');
if (!response.ok) {
throw new Error(`Failed to load streams: ${response.status}`);
}
const data = await response.json();
const data = await streamsResp.json();
_cachedStreams = data.streams || [];
renderPictureStreamsList(_cachedStreams);
} catch (error) {
@@ -3101,7 +3037,7 @@ function renderPictureStreamsList(streams) {
const container = document.getElementById('streams-list');
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
const renderCard = (stream) => {
const renderStreamCard = (stream) => {
const typeIcons = { raw: '🖥️', processed: '🎨', static_image: '🖼️' };
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 processedStreams = streams.filter(s => s.stream_type === 'processed');
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="add-template-icon">+</div>
<div class="add-template-label">${t(labelKey)}</div>
</div>`;
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' },
{ key: 'raw', icon: '🖥️', titleKey: 'streams.group.raw', streams: rawStreams },
{ key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', streams: staticImageStreams },
{ key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', streams: processedStreams },
];
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>`
).join('')}</div>`;
const panels = tabs.map(tab =>
`<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">
const panels = tabs.map(tab => {
let panelContent = '';
if (tab.key === 'raw') {
// Screen Capture: streams section + capture templates section
panelContent = `
<div class="subtab-section">
<h3 class="subtab-section-header">${t('streams.section.streams')}</h3>
<div class="templates-grid">
${tab.streams.map(renderCard).join('')}
${addCard(tab.key, tab.addLabelKey)}
${tab.streams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)}
</div>
</div>`
).join('');
</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;
}
@@ -3671,12 +3718,10 @@ async function loadPPTemplates() {
}
const data = await response.json();
_cachedPPTemplates = data.templates || [];
renderPPTemplatesList(_cachedPPTemplates);
// Re-render the streams tab which now contains template sections
renderPictureStreamsList(_cachedStreams);
} catch (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;
}
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 ---
let _modalFilters = []; // Current filter list being edited in modal

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<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="stylesheet" href="/static/style.css">
</head>
@@ -12,7 +12,7 @@
<header>
<div class="header-title">
<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>
</div>
<div class="server-info">
@@ -36,9 +36,7 @@
<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" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Picture 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>
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Streams</span></button>
</div>
<div class="tab-panel active" id="tab-devices">
@@ -54,17 +52,6 @@
</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>
<footer class="app-footer">
@@ -249,9 +236,9 @@
<input type="hidden" id="stream-selector-device-id">
<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>
<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 id="stream-selector-info" class="stream-info-panel" style="display: none;"></div>
@@ -301,7 +288,7 @@
<form id="api-key-form" onsubmit="submitApiKey(event)">
<div class="modal-body">
<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>
<div class="form-group">
<label for="api-key-input" data-i18n="auth.label">API Key:</label>
@@ -447,7 +434,7 @@
<div id="test-stream-modal" class="modal">
<div class="modal-content">
<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>
</div>
<div class="modal-body">
@@ -495,11 +482,11 @@
</div>
</div>
<!-- Picture Stream Modal -->
<!-- Stream Modal -->
<div id="stream-modal" class="modal">
<div class="modal-content">
<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>
</div>
<div class="modal-body">

View File

@@ -1,5 +1,5 @@
{
"app.title": "WLED Screen Controller",
"app.title": "WLED Grab",
"app.version": "Version:",
"theme.toggle": "Toggle theme",
"locale.change": "Change language",
@@ -7,7 +7,7 @@
"auth.logout": "Logout",
"auth.authenticated": "● Authenticated",
"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.placeholder": "Enter your API key...",
"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.select": "Select 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.loading": "Loading templates...",
"templates.empty": "No capture templates configured",
"templates.add": "Add Capture Template",
"templates.edit": "Edit Capture Template",
"templates.add": "Add Engine Template",
"templates.edit": "Edit Engine Template",
"templates.name": "Template Name:",
"templates.name.placeholder": "My Custom Template",
"templates.description.label": "Description (optional):",
@@ -154,7 +154,7 @@
"settings.display_index.hint": "Which screen to capture for this device",
"settings.fps": "Target FPS:",
"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.button.cancel": "Cancel",
"settings.health_interval": "Health Check Interval (s):",
@@ -198,14 +198,15 @@
"confirm.no": "No",
"common.delete": "Delete",
"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.title": "\uD83D\uDCFA Streams",
"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.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.processed": "Add Processed Stream",
"streams.edit": "Edit Picture Stream",
"streams.edit": "Edit Stream",
"streams.edit.raw": "Edit Screen Capture",
"streams.edit.processed": "Edit Processed Stream",
"streams.name": "Stream Name:",
@@ -214,10 +215,10 @@
"streams.type.raw": "Screen Capture",
"streams.type.processed": "Processed",
"streams.display": "Display:",
"streams.capture_template": "Capture Template:",
"streams.capture_template": "Engine Template:",
"streams.target_fps": "Target FPS:",
"streams.source": "Source Stream:",
"streams.pp_template": "Processing Template:",
"streams.pp_template": "Filter Template:",
"streams.description_label": "Description (optional):",
"streams.description_placeholder": "Describe this stream...",
"streams.created": "Stream created successfully",
@@ -227,17 +228,17 @@
"streams.error.load": "Failed to load streams",
"streams.error.required": "Please fill in all required fields",
"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.running": "Testing stream...",
"streams.test.duration": "Capture Duration (s):",
"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.add": "Add Processing Template",
"postprocessing.edit": "Edit Processing Template",
"postprocessing.add": "Add Filter Template",
"postprocessing.edit": "Edit Filter Template",
"postprocessing.name": "Template Name:",
"postprocessing.name.placeholder": "My Processing Template",
"postprocessing.name.placeholder": "My Filter Template",
"filters.select_type": "Select filter type...",
"filters.add": "Add Filter",
"filters.remove": "Remove",
@@ -255,20 +256,20 @@
"postprocessing.created": "Template created successfully",
"postprocessing.updated": "Template updated 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.required": "Please fill in all required fields",
"postprocessing.error.delete": "Failed to delete processing template",
"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.running": "Testing processing template...",
"postprocessing.test.error.no_stream": "Please select a source stream",
"postprocessing.test.error.failed": "Processing template test failed",
"device.button.stream_selector": "Stream Settings",
"device.stream_settings.title": "📺 Stream Settings",
"device.stream_selector.label": "Picture Stream:",
"device.stream_selector.hint": "Select a picture stream that defines what this device captures and processes",
"device.stream_selector.label": "Stream:",
"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.saved": "Stream settings updated",
"device.stream_settings.border_width": "Border Width (px):",

View File

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

View File

@@ -2230,6 +2230,24 @@ input:-webkit-autofill:focus {
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 */
.lightbox {
display: none;