Improve device cards, stream/template UI, and add PP template testing
- Move WLED UI button into URL badge as clickable link on device cards - Remove version label from device cards - Show PP template name on processed stream cards - Display filter chain as pills on processing template cards - Add processing template test with source stream selector - Pre-load PP templates when viewing streams to fix race condition - Add ESC key handling for all modals - Add filter chain CSS styles Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,7 @@ from wled_controller.api.schemas import (
|
||||
PictureStreamResponse,
|
||||
PictureStreamListResponse,
|
||||
PictureStreamTestRequest,
|
||||
PPTemplateTestRequest,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings
|
||||
@@ -1255,6 +1256,172 @@ async def delete_pp_template(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/postprocessing-templates/{template_id}/test", response_model=TemplateTestResponse, tags=["Postprocessing Templates"])
|
||||
async def test_pp_template(
|
||||
template_id: str,
|
||||
test_request: PPTemplateTestRequest,
|
||||
_auth: AuthRequired,
|
||||
pp_store: PostprocessingTemplateStore = Depends(get_pp_template_store),
|
||||
stream_store: PictureStreamStore = Depends(get_picture_stream_store),
|
||||
template_store: TemplateStore = Depends(get_template_store),
|
||||
processor_manager: ProcessorManager = Depends(get_processor_manager),
|
||||
device_store: DeviceStore = Depends(get_device_store),
|
||||
):
|
||||
"""Test a postprocessing template by capturing from a source stream and applying filters."""
|
||||
engine = None
|
||||
try:
|
||||
# Get the PP template
|
||||
try:
|
||||
pp_template = pp_store.get_template(template_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
# Resolve source stream chain to get the raw stream
|
||||
try:
|
||||
chain = stream_store.resolve_stream_chain(test_request.source_stream_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
raw_stream = chain["raw_stream"]
|
||||
|
||||
# Get capture template from raw stream
|
||||
try:
|
||||
capture_template = template_store.get_template(raw_stream.capture_template_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Capture template not found: {raw_stream.capture_template_id}",
|
||||
)
|
||||
|
||||
display_index = raw_stream.display_index
|
||||
|
||||
# Validate engine
|
||||
if capture_template.engine_type not in EngineRegistry.get_available_engines():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Engine '{capture_template.engine_type}' is not available on this system",
|
||||
)
|
||||
|
||||
# Check display lock
|
||||
locked_device_id = processor_manager.get_display_lock_info(display_index)
|
||||
if locked_device_id:
|
||||
try:
|
||||
device = device_store.get_device(locked_device_id)
|
||||
device_name = device.name
|
||||
except Exception:
|
||||
device_name = locked_device_id
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Display {display_index} is currently being captured by device '{device_name}'. "
|
||||
f"Please stop the device processing before testing.",
|
||||
)
|
||||
|
||||
# Create engine and run test
|
||||
engine = EngineRegistry.create_engine(capture_template.engine_type, capture_template.engine_config)
|
||||
|
||||
logger.info(f"Starting {test_request.capture_duration}s PP template test for {template_id} using stream {test_request.source_stream_id}")
|
||||
|
||||
frame_count = 0
|
||||
total_capture_time = 0.0
|
||||
last_frame = None
|
||||
|
||||
start_time = time.perf_counter()
|
||||
end_time = start_time + test_request.capture_duration
|
||||
|
||||
while time.perf_counter() < end_time:
|
||||
capture_start = time.perf_counter()
|
||||
screen_capture = engine.capture_display(display_index)
|
||||
capture_elapsed = time.perf_counter() - capture_start
|
||||
|
||||
total_capture_time += capture_elapsed
|
||||
frame_count += 1
|
||||
last_frame = screen_capture
|
||||
|
||||
actual_duration = time.perf_counter() - start_time
|
||||
|
||||
if last_frame is None:
|
||||
raise RuntimeError("No frames captured during test")
|
||||
|
||||
# Convert to PIL Image
|
||||
if isinstance(last_frame.image, np.ndarray):
|
||||
pil_image = Image.fromarray(last_frame.image)
|
||||
else:
|
||||
raise ValueError("Unexpected image format from engine")
|
||||
|
||||
# Create thumbnail
|
||||
thumbnail_width = 640
|
||||
aspect_ratio = pil_image.height / pil_image.width
|
||||
thumbnail_height = int(thumbnail_width * aspect_ratio)
|
||||
thumbnail = pil_image.copy()
|
||||
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Apply postprocessing filters
|
||||
if pp_template.filters:
|
||||
pool = ImagePool()
|
||||
|
||||
def apply_filters(img):
|
||||
arr = np.array(img)
|
||||
for fi in pp_template.filters:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(arr, pool)
|
||||
if result is not None:
|
||||
arr = result
|
||||
return Image.fromarray(arr)
|
||||
|
||||
thumbnail = apply_filters(thumbnail)
|
||||
pil_image = apply_filters(pil_image)
|
||||
|
||||
# Encode thumbnail
|
||||
img_buffer = io.BytesIO()
|
||||
thumbnail.save(img_buffer, format='JPEG', quality=85)
|
||||
img_buffer.seek(0)
|
||||
thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
|
||||
|
||||
# Encode full-resolution image
|
||||
full_buffer = io.BytesIO()
|
||||
pil_image.save(full_buffer, format='JPEG', quality=90)
|
||||
full_buffer.seek(0)
|
||||
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
|
||||
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
|
||||
|
||||
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
||||
avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0
|
||||
width, height = pil_image.size
|
||||
|
||||
return TemplateTestResponse(
|
||||
full_capture=CaptureImage(
|
||||
image=thumbnail_data_uri,
|
||||
full_image=full_data_uri,
|
||||
width=width,
|
||||
height=height,
|
||||
thumbnail_width=thumbnail_width,
|
||||
thumbnail_height=thumbnail_height,
|
||||
),
|
||||
border_extraction=None,
|
||||
performance=PerformanceMetrics(
|
||||
capture_duration_s=actual_duration,
|
||||
frame_count=frame_count,
|
||||
actual_fps=actual_fps,
|
||||
avg_capture_time_ms=avg_capture_time_ms,
|
||||
),
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Postprocessing template test failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
if engine:
|
||||
try:
|
||||
engine.release()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ===== PICTURE STREAM ENDPOINTS =====
|
||||
|
||||
def _stream_to_response(s) -> PictureStreamResponse:
|
||||
|
||||
@@ -454,3 +454,10 @@ class PictureStreamTestRequest(BaseModel):
|
||||
|
||||
capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds")
|
||||
border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels for preview")
|
||||
|
||||
|
||||
class PPTemplateTestRequest(BaseModel):
|
||||
"""Request to test a postprocessing template against a source stream."""
|
||||
|
||||
source_stream_id: str = Field(description="ID of the source picture stream to capture from")
|
||||
capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds")
|
||||
|
||||
@@ -57,10 +57,33 @@ function closeLightbox(event) {
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
// Close in order: overlay lightboxes first, then modals
|
||||
if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
|
||||
closeDisplayPicker();
|
||||
} else if (document.getElementById('image-lightbox').classList.contains('active')) {
|
||||
closeLightbox();
|
||||
} else {
|
||||
// Close topmost visible modal
|
||||
const modals = [
|
||||
{ id: 'test-pp-template-modal', close: closeTestPPTemplateModal },
|
||||
{ id: 'test-stream-modal', close: closeTestStreamModal },
|
||||
{ id: 'test-template-modal', close: closeTestTemplateModal },
|
||||
{ id: 'stream-modal', close: closeStreamModal },
|
||||
{ id: 'pp-template-modal', close: closePPTemplateModal },
|
||||
{ id: 'template-modal', close: closeTemplateModal },
|
||||
{ id: 'device-settings-modal', close: forceCloseDeviceSettingsModal },
|
||||
{ id: 'capture-settings-modal', close: forceCloseCaptureSettingsModal },
|
||||
{ id: 'calibration-modal', close: forceCloseCalibrationModal },
|
||||
{ id: 'stream-selector-modal', close: forceCloseStreamSelectorModal },
|
||||
{ id: 'add-device-modal', close: closeAddDeviceModal },
|
||||
];
|
||||
for (const m of modals) {
|
||||
const el = document.getElementById(m.id);
|
||||
if (el && el.style.display === 'flex') {
|
||||
m.close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -648,13 +671,12 @@ function createDeviceCard(device) {
|
||||
<div class="card-title">
|
||||
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
||||
${device.name || device.id}
|
||||
${device.url ? `<span class="device-url-badge" title="${escapeHtml(device.url)}">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span>` : ''}
|
||||
${device.url ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">🌐</span></a>` : ''}
|
||||
${healthLabel}
|
||||
${isProcessing ? `<span class="badge processing">${t('device.status.processing')}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-subtitle">
|
||||
${wledVersion ? `<span class="card-meta">v${wledVersion}</span>` : ''}
|
||||
${ledCount ? `<span class="card-meta" title="${t('device.led_count')}">💡 ${ledCount}</span>` : ''}
|
||||
${state.wled_led_type ? `<span class="card-meta">🔌 ${state.wled_led_type.replace(/ RGBW$/, '')}</span>` : ''}
|
||||
<span class="card-meta" title="${state.wled_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.wled_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
|
||||
@@ -707,11 +729,6 @@ function createDeviceCard(device) {
|
||||
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
|
||||
📐
|
||||
</button>
|
||||
${device.url ? `
|
||||
<a class="btn btn-icon btn-secondary" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}">
|
||||
🌐
|
||||
</a>
|
||||
` : ''}
|
||||
</div>
|
||||
<button class="card-tutorial-btn" onclick="startDeviceTutorial('${device.id}')" title="${t('device.tutorial.start')}">?</button>
|
||||
</div>
|
||||
@@ -3033,6 +3050,17 @@ let _availableFilters = []; // Loaded from GET /filters
|
||||
|
||||
async function loadPictureStreams() {
|
||||
try {
|
||||
// Ensure PP templates are cached so processed stream cards can show filter info
|
||||
if (_cachedPPTemplates.length === 0) {
|
||||
try {
|
||||
if (_availableFilters.length === 0) {
|
||||
const fr = await fetchWithAuth('/filters');
|
||||
if (fr.ok) { const fd = await fr.json(); _availableFilters = fd.filters || []; }
|
||||
}
|
||||
const pr = await fetchWithAuth('/postprocessing-templates');
|
||||
if (pr.ok) { const pd = await pr.json(); _cachedPPTemplates = pd.templates || []; }
|
||||
} catch (e) { console.warn('Could not pre-load PP templates for streams:', e); }
|
||||
}
|
||||
const response = await fetchWithAuth('/picture-streams');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load streams: ${response.status}`);
|
||||
@@ -3102,10 +3130,19 @@ function renderPictureStreamsList(streams) {
|
||||
// Find source stream name and PP template name
|
||||
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
|
||||
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
|
||||
// Find PP template name
|
||||
let ppTemplateHtml = '';
|
||||
if (stream.postprocessing_template_id) {
|
||||
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
|
||||
if (ppTmpl) {
|
||||
ppTemplateHtml = `<div class="template-config"><strong>${t('streams.pp_template')}</strong> ${escapeHtml(ppTmpl.name)}</div>`;
|
||||
}
|
||||
}
|
||||
detailsHtml = `
|
||||
<div class="template-config">
|
||||
<strong>${t('streams.source')}</strong> ${sourceName}
|
||||
</div>
|
||||
${ppTemplateHtml}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -3456,6 +3493,95 @@ function displayStreamTestResults(result) {
|
||||
openLightbox(fullImageSrc, buildTestStatsHtml(result));
|
||||
}
|
||||
|
||||
// ===== PP Template Test =====
|
||||
|
||||
let _currentTestPPTemplateId = null;
|
||||
|
||||
async function showTestPPTemplateModal(templateId) {
|
||||
_currentTestPPTemplateId = templateId;
|
||||
restorePPTestDuration();
|
||||
|
||||
// Populate source stream selector
|
||||
const select = document.getElementById('test-pp-source-stream');
|
||||
select.innerHTML = '';
|
||||
// Ensure streams are cached
|
||||
if (_cachedStreams.length === 0) {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/picture-streams');
|
||||
if (resp.ok) { const d = await resp.json(); _cachedStreams = d.streams || []; }
|
||||
} catch (e) { console.warn('Could not load streams for PP test:', e); }
|
||||
}
|
||||
for (const s of _cachedStreams) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
opt.textContent = s.name;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
// Auto-select last used stream
|
||||
const lastStream = localStorage.getItem('lastPPTestStreamId');
|
||||
if (lastStream && _cachedStreams.find(s => s.id === lastStream)) {
|
||||
select.value = lastStream;
|
||||
}
|
||||
|
||||
const modal = document.getElementById('test-pp-template-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
modal.onclick = (e) => { if (e.target === modal) closeTestPPTemplateModal(); };
|
||||
}
|
||||
|
||||
function closeTestPPTemplateModal() {
|
||||
document.getElementById('test-pp-template-modal').style.display = 'none';
|
||||
unlockBody();
|
||||
_currentTestPPTemplateId = null;
|
||||
}
|
||||
|
||||
function updatePPTestDuration(value) {
|
||||
document.getElementById('test-pp-duration-value').textContent = value;
|
||||
localStorage.setItem('lastPPTestDuration', value);
|
||||
}
|
||||
|
||||
function restorePPTestDuration() {
|
||||
const saved = localStorage.getItem('lastPPTestDuration') || '5';
|
||||
document.getElementById('test-pp-duration').value = saved;
|
||||
document.getElementById('test-pp-duration-value').textContent = saved;
|
||||
}
|
||||
|
||||
async function runPPTemplateTest() {
|
||||
if (!_currentTestPPTemplateId) return;
|
||||
|
||||
const sourceStreamId = document.getElementById('test-pp-source-stream').value;
|
||||
if (!sourceStreamId) {
|
||||
showToast(t('postprocessing.test.error.no_stream'), 'error');
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('lastPPTestStreamId', sourceStreamId);
|
||||
|
||||
const captureDuration = parseFloat(document.getElementById('test-pp-duration').value);
|
||||
|
||||
showOverlaySpinner(t('postprocessing.test.running'), captureDuration);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/postprocessing-templates/${_currentTestPPTemplateId}/test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ source_stream_id: sourceStreamId, capture_duration: captureDuration })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Test failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
hideOverlaySpinner();
|
||||
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
|
||||
openLightbox(fullImageSrc, buildTestStatsHtml(result));
|
||||
} catch (error) {
|
||||
console.error('Error running PP template test:', error);
|
||||
hideOverlaySpinner();
|
||||
showToast(t('postprocessing.test.error.failed') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Processing Templates =====
|
||||
|
||||
async function loadAvailableFilters() {
|
||||
@@ -3514,12 +3640,12 @@ function renderPPTemplatesList(templates) {
|
||||
}
|
||||
|
||||
const renderCard = (tmpl) => {
|
||||
// Build config entries from filter list
|
||||
const filterRows = (tmpl.filters || []).map(fi => {
|
||||
const filterName = _getFilterName(fi.filter_id);
|
||||
const optStr = Object.entries(fi.options || {}).map(([k, v]) => `${v}`).join(', ');
|
||||
return `<tr><td class="config-key">${escapeHtml(filterName)}</td><td class="config-value">${escapeHtml(optStr)}</td></tr>`;
|
||||
}).join('');
|
||||
// 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}">
|
||||
@@ -3530,13 +3656,11 @@ function renderPPTemplatesList(templates) {
|
||||
</div>
|
||||
</div>
|
||||
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
|
||||
<details class="template-config-details">
|
||||
<summary>${t('postprocessing.config.show')}</summary>
|
||||
<table class="config-table">
|
||||
${filterRows}
|
||||
</table>
|
||||
</details>
|
||||
${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>
|
||||
|
||||
@@ -490,6 +490,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test PP Template Modal -->
|
||||
<div id="test-pp-template-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 data-i18n="postprocessing.test.title">Test Processing Template</h2>
|
||||
<button class="modal-close-btn" onclick="closeTestPPTemplateModal()" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label data-i18n="postprocessing.test.source_stream">Source Stream:</label>
|
||||
<select id="test-pp-source-stream"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="test-pp-duration">
|
||||
<span data-i18n="streams.test.duration">Capture Duration (s):</span>
|
||||
<span id="test-pp-duration-value">5</span>
|
||||
</label>
|
||||
<input type="range" id="test-pp-duration" min="1" max="10" step="1" value="5" oninput="updatePPTestDuration(this.value)" />
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-primary" onclick="runPPTemplateTest()" style="margin-top: 16px;">
|
||||
<span data-i18n="streams.test.run">🧪 Run Test</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Picture Stream Modal -->
|
||||
<div id="stream-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
|
||||
@@ -260,6 +260,11 @@
|
||||
"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.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:",
|
||||
|
||||
@@ -260,6 +260,11 @@
|
||||
"postprocessing.error.required": "Пожалуйста, заполните все обязательные поля",
|
||||
"postprocessing.error.delete": "Не удалось удалить шаблон обработки",
|
||||
"postprocessing.config.show": "Показать настройки",
|
||||
"postprocessing.test.title": "Тест шаблона обработки",
|
||||
"postprocessing.test.source_stream": "Источник потока:",
|
||||
"postprocessing.test.running": "Тестирование шаблона обработки...",
|
||||
"postprocessing.test.error.no_stream": "Пожалуйста, выберите источник потока",
|
||||
"postprocessing.test.error.failed": "Тест шаблона обработки не удался",
|
||||
"device.button.stream_selector": "Настройки потока",
|
||||
"device.stream_settings.title": "📺 Настройки потока",
|
||||
"device.stream_selector.label": "Видеопоток:",
|
||||
|
||||
@@ -243,6 +243,7 @@ section {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
|
||||
.card-remove-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
@@ -284,6 +285,9 @@ section {
|
||||
}
|
||||
|
||||
.device-url-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
@@ -292,6 +296,16 @@ section {
|
||||
border-radius: 10px;
|
||||
letter-spacing: 0.03em;
|
||||
font-family: monospace;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.device-url-badge:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.device-url-icon {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
@@ -1791,6 +1805,27 @@ input:-webkit-autofill:focus {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.filter-chain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.filter-chain-item {
|
||||
font-size: 0.7rem;
|
||||
background: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.filter-chain-arrow {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.template-config-details {
|
||||
margin: 12px 0;
|
||||
font-size: 13px;
|
||||
|
||||
Reference in New Issue
Block a user