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

@@ -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 || []; }
}
if (_cachedPPTemplates.length === 0) {
const pr = await fetchWithAuth('/postprocessing-templates');
if (pr.ok) { const pd = await pr.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 || []; }
}
} catch (e) { console.warn('Could not pre-load templates for streams:', e); }
// 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 || [];
}
const response = await fetchWithAuth('/picture-streams');
if (!response.ok) {
throw new Error(`Failed to load streams: ${response.status}`);
if (ppResp.ok) {
const pd = await ppResp.json();
_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 || [];
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}">
<div class="templates-grid">
${tab.streams.map(renderCard).join('')}
${addCard(tab.key, tab.addLabelKey)}
</div>
</div>`
).join('');
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(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;
}
@@ -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;