Improve stream UI: grouped sections, full-size preview lightbox, and test redesign

- Separate Screen Capture and Processed streams into grouped sections with headers
- Remove redundant Type dropdown from stream modal (type inferred from add button)
- Add full-resolution image to test endpoints alongside thumbnails
- Add image lightbox with clickable preview for full-size viewing
- Move test results from modal into lightbox overlay with capture stats
- Apply postprocessing to both thumbnail and full image for processed streams
- Rename "Assigned Picture Stream" to "Picture Stream" in device settings
- Fix null reference errors from removed test result HTML elements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 00:35:06 +03:00
parent 493f14fba9
commit e8cbc73161
7 changed files with 308 additions and 138 deletions

View File

@@ -1052,12 +1052,19 @@ async def test_template(
thumbnail = pil_image.copy() thumbnail = pil_image.copy()
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS) thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
# Encode full capture thumbnail as JPEG # Encode thumbnail as JPEG
img_buffer = io.BytesIO() img_buffer = io.BytesIO()
thumbnail.save(img_buffer, format='JPEG', quality=85) thumbnail.save(img_buffer, format='JPEG', quality=85)
img_buffer.seek(0) img_buffer.seek(0)
full_capture_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
full_capture_data_uri = f"data:image/jpeg;base64,{full_capture_b64}" thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
# Encode full-resolution image as JPEG
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}"
# Calculate metrics # Calculate metrics
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0 actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
@@ -1067,7 +1074,8 @@ async def test_template(
return TemplateTestResponse( return TemplateTestResponse(
full_capture=CaptureImage( full_capture=CaptureImage(
image=full_capture_data_uri, image=thumbnail_data_uri,
full_image=full_data_uri,
width=width, width=width,
height=height, height=height,
thumbnail_width=thumbnail_width, thumbnail_width=thumbnail_width,
@@ -1469,34 +1477,41 @@ async def test_picture_stream(
thumbnail = pil_image.copy() thumbnail = pil_image.copy()
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS) thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
# Apply postprocessing to preview if this is a processed stream # Apply postprocessing if this is a processed stream
pp_template_ids = chain["postprocessing_template_ids"] pp_template_ids = chain["postprocessing_template_ids"]
if pp_template_ids: if pp_template_ids:
try: try:
pp = pp_store.get_template(pp_template_ids[0]) pp = pp_store.get_template(pp_template_ids[0])
img_array = np.array(thumbnail, dtype=np.float32) / 255.0
def apply_pp(img):
arr = np.array(img, dtype=np.float32) / 255.0
if pp.brightness != 1.0: if pp.brightness != 1.0:
img_array *= pp.brightness arr *= pp.brightness
if pp.saturation != 1.0: if pp.saturation != 1.0:
luminance = np.dot(img_array[..., :3], [0.299, 0.587, 0.114]) lum = np.dot(arr[..., :3], [0.299, 0.587, 0.114])[..., np.newaxis]
luminance = luminance[..., np.newaxis] arr[..., :3] = lum + (arr[..., :3] - lum) * pp.saturation
img_array[..., :3] = luminance + (img_array[..., :3] - luminance) * pp.saturation
if pp.gamma != 1.0: if pp.gamma != 1.0:
img_array = np.power(np.clip(img_array, 0, 1), 1.0 / pp.gamma) arr = np.power(np.clip(arr, 0, 1), 1.0 / pp.gamma)
return Image.fromarray(np.clip(arr * 255.0, 0, 255).astype(np.uint8))
img_array = np.clip(img_array * 255.0, 0, 255).astype(np.uint8) thumbnail = apply_pp(thumbnail)
thumbnail = Image.fromarray(img_array) pil_image = apply_pp(pil_image)
except ValueError: except ValueError:
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview") logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
# Encode thumbnail
img_buffer = io.BytesIO() img_buffer = io.BytesIO()
thumbnail.save(img_buffer, format='JPEG', quality=85) thumbnail.save(img_buffer, format='JPEG', quality=85)
img_buffer.seek(0) img_buffer.seek(0)
full_capture_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
full_capture_data_uri = f"data:image/jpeg;base64,{full_capture_b64}" 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 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 avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0
@@ -1504,7 +1519,8 @@ async def test_picture_stream(
return TemplateTestResponse( return TemplateTestResponse(
full_capture=CaptureImage( full_capture=CaptureImage(
image=full_capture_data_uri, image=thumbnail_data_uri,
full_image=full_data_uri,
width=width, width=width,
height=height, height=height,
thumbnail_width=thumbnail_width, thumbnail_width=thumbnail_width,

View File

@@ -292,9 +292,10 @@ class TemplateTestRequest(BaseModel):
class CaptureImage(BaseModel): class CaptureImage(BaseModel):
"""Captured image with metadata.""" """Captured image with metadata."""
image: str = Field(description="Base64-encoded image data") image: str = Field(description="Base64-encoded thumbnail image data")
width: int = Field(description="Image width in pixels") full_image: Optional[str] = Field(None, description="Base64-encoded full-resolution image data")
height: int = Field(description="Image height in pixels") width: int = Field(description="Original image width in pixels")
height: int = Field(description="Original image height in pixels")
thumbnail_width: Optional[int] = Field(None, description="Thumbnail width (if resized)") thumbnail_width: Optional[int] = Field(None, description="Thumbnail width (if resized)")
thumbnail_height: Optional[int] = Field(None, description="Thumbnail height (if resized)") thumbnail_height: Optional[int] = Field(None, description="Thumbnail height (if resized)")

View File

@@ -30,6 +30,37 @@ function unlockBody() {
document.body.style.paddingRight = ''; document.body.style.paddingRight = '';
} }
// Image lightbox
function openLightbox(imageSrc, statsHtml) {
const lightbox = document.getElementById('image-lightbox');
const img = document.getElementById('lightbox-image');
const statsEl = document.getElementById('lightbox-stats');
img.src = imageSrc;
if (statsHtml) {
statsEl.innerHTML = statsHtml;
statsEl.style.display = '';
} else {
statsEl.style.display = 'none';
}
lightbox.classList.add('active');
lockBody();
}
function closeLightbox(event) {
if (event && event.target && event.target.closest('.lightbox-content')) return;
const lightbox = document.getElementById('image-lightbox');
lightbox.classList.remove('active');
document.getElementById('lightbox-image').src = '';
document.getElementById('lightbox-stats').style.display = 'none';
unlockBody();
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && document.getElementById('image-lightbox').classList.contains('active')) {
closeLightbox();
}
});
// Locale management // Locale management
let currentLocale = 'en'; let currentLocale = 'en';
let translations = {}; let translations = {};
@@ -2638,9 +2669,6 @@ async function showTestTemplateModal(templateId) {
// Restore last used capture duration // Restore last used capture duration
restoreCaptureDuration(); restoreCaptureDuration();
// Reset results
document.getElementById('test-template-results').style.display = 'none';
// Show modal // Show modal
const modal = document.getElementById('test-template-modal'); const modal = document.getElementById('test-template-modal');
modal.style.display = 'flex'; modal.style.display = 'flex';
@@ -2836,7 +2864,6 @@ async function runTemplateTest() {
} }
const template = window.currentTestingTemplate; const template = window.currentTestingTemplate;
const resultsDiv = document.getElementById('test-template-results');
// Show full-page overlay spinner with progress // Show full-page overlay spinner with progress
showOverlaySpinner(t('templates.test.running'), captureDuration); showOverlaySpinner(t('templates.test.running'), captureDuration);
@@ -2869,25 +2896,23 @@ async function runTemplateTest() {
} }
} }
// Display test results function buildTestStatsHtml(result) {
const p = result.performance;
const res = `${result.full_capture.width}x${result.full_capture.height}`;
return `
<div class="stat-item"><span>${t('templates.test.results.duration')}:</span> <strong>${p.capture_duration_s.toFixed(2)}s</strong></div>
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${p.frame_count}</strong></div>
<div class="stat-item"><span>${t('templates.test.results.actual_fps')}:</span> <strong>${p.actual_fps.toFixed(1)}</strong></div>
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${p.avg_capture_time_ms.toFixed(1)}ms</strong></div>
<div class="stat-item"><span>Resolution:</span> <strong>${res}</strong></div>
`;
}
// Display test results — opens lightbox with stats overlay
function displayTestResults(result) { function displayTestResults(result) {
const resultsDiv = document.getElementById('test-template-results');
// Hide overlay spinner
hideOverlaySpinner(); hideOverlaySpinner();
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
// Full capture preview openLightbox(fullImageSrc, buildTestStatsHtml(result));
const previewImg = document.getElementById('test-template-preview-image');
previewImg.innerHTML = `<img src="${result.full_capture.image}" alt="Capture preview" style="max-width: 100%; border-radius: 4px;">`;
// Performance stats
document.getElementById('test-template-actual-duration').textContent = `${result.performance.capture_duration_s.toFixed(2)}s`;
document.getElementById('test-template-frame-count').textContent = result.performance.frame_count;
document.getElementById('test-template-actual-fps').textContent = `${result.performance.actual_fps.toFixed(1)} FPS`;
document.getElementById('test-template-avg-capture-time').textContent = `${result.performance.avg_capture_time_ms.toFixed(1)}ms`;
// Show results
resultsDiv.style.display = 'block';
} }
// Save template // Save template
@@ -2989,9 +3014,32 @@ function renderPictureStreamsList(streams) {
const container = document.getElementById('streams-list'); const container = document.getElementById('streams-list');
if (streams.length === 0) { if (streams.length === 0) {
container.innerHTML = `<div class="template-card add-template-card" onclick="showAddStreamModal()"> container.innerHTML = `
<div class="stream-group">
<div class="stream-group-header">
<span class="stream-group-icon">📷</span>
<span class="stream-group-title">${t('streams.group.raw')}</span>
<span class="stream-group-count">0</span>
</div>
<div class="templates-grid">
<div class="template-card add-template-card" onclick="showAddStreamModal('raw')">
<div class="add-template-icon">+</div> <div class="add-template-icon">+</div>
<div class="add-template-label">${t('streams.add')}</div> <div class="add-template-label">${t('streams.add.raw')}</div>
</div>
</div>
</div>
<div class="stream-group">
<div class="stream-group-header">
<span class="stream-group-icon">🎨</span>
<span class="stream-group-title">${t('streams.group.processed')}</span>
<span class="stream-group-count">0</span>
</div>
<div class="templates-grid">
<div class="template-card add-template-card" onclick="showAddStreamModal('processed')">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('streams.add.processed')}</div>
</div>
</div>
</div>`; </div>`;
return; return;
} }
@@ -3046,10 +3094,41 @@ function renderPictureStreamsList(streams) {
`; `;
}; };
let html = streams.map(renderCard).join(''); const rawStreams = streams.filter(s => s.stream_type === 'raw');
html += `<div class="template-card add-template-card" onclick="showAddStreamModal()"> const processedStreams = streams.filter(s => s.stream_type === 'processed');
let html = '';
// Screen Capture streams section
html += `<div class="stream-group">
<div class="stream-group-header">
<span class="stream-group-icon">📷</span>
<span class="stream-group-title">${t('streams.group.raw')}</span>
<span class="stream-group-count">${rawStreams.length}</span>
</div>
<div class="templates-grid">
${rawStreams.map(renderCard).join('')}
<div class="template-card add-template-card" onclick="showAddStreamModal('raw')">
<div class="add-template-icon">+</div> <div class="add-template-icon">+</div>
<div class="add-template-label">${t('streams.add')}</div> <div class="add-template-label">${t('streams.add.raw')}</div>
</div>
</div>
</div>`;
// Processed streams section
html += `<div class="stream-group">
<div class="stream-group-header">
<span class="stream-group-icon">🎨</span>
<span class="stream-group-title">${t('streams.group.processed')}</span>
<span class="stream-group-count">${processedStreams.length}</span>
</div>
<div class="templates-grid">
${processedStreams.map(renderCard).join('')}
<div class="template-card add-template-card" onclick="showAddStreamModal('processed')">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('streams.add.processed')}</div>
</div>
</div>
</div>`; </div>`;
container.innerHTML = html; container.innerHTML = html;
@@ -3061,15 +3140,14 @@ function onStreamTypeChange() {
document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none'; document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none';
} }
async function showAddStreamModal() { async function showAddStreamModal(presetType) {
document.getElementById('stream-modal-title').textContent = t('streams.add'); const streamType = presetType || 'raw';
const titleKey = streamType === 'raw' ? 'streams.add.raw' : 'streams.add.processed';
document.getElementById('stream-modal-title').textContent = t(titleKey);
document.getElementById('stream-form').reset(); document.getElementById('stream-form').reset();
document.getElementById('stream-id').value = ''; document.getElementById('stream-id').value = '';
document.getElementById('stream-error').style.display = 'none'; document.getElementById('stream-error').style.display = 'none';
document.getElementById('stream-type').disabled = false; document.getElementById('stream-type').value = streamType;
// Reset to raw type
document.getElementById('stream-type').value = 'raw';
onStreamTypeChange(); onStreamTypeChange();
// Populate dropdowns // Populate dropdowns
@@ -3087,15 +3165,15 @@ async function editStream(streamId) {
if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`); if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
const stream = await response.json(); const stream = await response.json();
document.getElementById('stream-modal-title').textContent = t('streams.edit'); const editTitleKey = stream.stream_type === 'raw' ? 'streams.edit.raw' : 'streams.edit.processed';
document.getElementById('stream-modal-title').textContent = t(editTitleKey);
document.getElementById('stream-id').value = streamId; document.getElementById('stream-id').value = streamId;
document.getElementById('stream-name').value = stream.name; document.getElementById('stream-name').value = stream.name;
document.getElementById('stream-description').value = stream.description || ''; document.getElementById('stream-description').value = stream.description || '';
document.getElementById('stream-error').style.display = 'none'; document.getElementById('stream-error').style.display = 'none';
// Set type and disable changing it for existing streams // Set type (hidden input)
document.getElementById('stream-type').value = stream.stream_type; document.getElementById('stream-type').value = stream.stream_type;
document.getElementById('stream-type').disabled = true;
onStreamTypeChange(); onStreamTypeChange();
// Populate dropdowns before setting values // Populate dropdowns before setting values
@@ -3286,7 +3364,6 @@ let _currentTestStreamId = null;
async function showTestStreamModal(streamId) { async function showTestStreamModal(streamId) {
_currentTestStreamId = streamId; _currentTestStreamId = streamId;
restoreStreamTestDuration(); restoreStreamTestDuration();
document.getElementById('test-stream-results').style.display = 'none';
const modal = document.getElementById('test-stream-modal'); const modal = document.getElementById('test-stream-modal');
modal.style.display = 'flex'; modal.style.display = 'flex';
@@ -3340,16 +3417,8 @@ async function runStreamTest() {
function displayStreamTestResults(result) { function displayStreamTestResults(result) {
hideOverlaySpinner(); hideOverlaySpinner();
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
const previewImg = document.getElementById('test-stream-preview-image'); openLightbox(fullImageSrc, buildTestStatsHtml(result));
previewImg.innerHTML = `<img src="${result.full_capture.image}" alt="Stream preview" style="max-width: 100%; border-radius: 4px;">`;
document.getElementById('test-stream-actual-duration').textContent = `${result.performance.capture_duration_s.toFixed(2)}s`;
document.getElementById('test-stream-frame-count').textContent = result.performance.frame_count;
document.getElementById('test-stream-actual-fps').textContent = `${result.performance.actual_fps.toFixed(1)} FPS`;
document.getElementById('test-stream-avg-capture-time').textContent = `${result.performance.avg_capture_time_ms.toFixed(1)}ms`;
document.getElementById('test-stream-results').style.display = 'block';
} }
// ===== Processing Templates ===== // ===== Processing Templates =====

View File

@@ -70,7 +70,7 @@
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. 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.
</span> </span>
</p> </p>
<div id="streams-list" class="templates-grid"> <div id="streams-list">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
</div> </div>
</div> </div>
@@ -460,34 +460,6 @@
<span data-i18n="templates.test.run">🧪 Run Test</span> <span data-i18n="templates.test.run">🧪 Run Test</span>
</button> </button>
<div id="test-template-results" style="display: none; margin-top: 16px;">
<div class="test-results-container">
<div class="test-preview-section">
<div id="test-template-preview-image" class="test-preview-image"></div>
</div>
<div class="test-performance-section">
<div class="test-performance-stats">
<div class="stat-item">
<span data-i18n="templates.test.results.duration">Duration:</span>
<strong id="test-template-actual-duration">-</strong>
</div>
<div class="stat-item">
<span data-i18n="templates.test.results.frame_count">Frames:</span>
<strong id="test-template-frame-count">-</strong>
</div>
<div class="stat-item">
<span data-i18n="templates.test.results.actual_fps">Actual FPS:</span>
<strong id="test-template-actual-fps">-</strong>
</div>
<div class="stat-item">
<span data-i18n="templates.test.results.avg_capture_time">Avg Capture:</span>
<strong id="test-template-avg-capture-time">-</strong>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -512,34 +484,6 @@
<span data-i18n="streams.test.run">🧪 Run Test</span> <span data-i18n="streams.test.run">🧪 Run Test</span>
</button> </button>
<div id="test-stream-results" style="display: none; margin-top: 16px;">
<div class="test-results-container">
<div class="test-preview-section">
<div id="test-stream-preview-image" class="test-preview-image"></div>
</div>
<div class="test-performance-section">
<div class="test-performance-stats">
<div class="stat-item">
<span data-i18n="templates.test.results.duration">Duration:</span>
<strong id="test-stream-actual-duration">-</strong>
</div>
<div class="stat-item">
<span data-i18n="templates.test.results.frame_count">Frames:</span>
<strong id="test-stream-frame-count">-</strong>
</div>
<div class="stat-item">
<span data-i18n="templates.test.results.actual_fps">Actual FPS:</span>
<strong id="test-stream-actual-fps">-</strong>
</div>
<div class="stat-item">
<span data-i18n="templates.test.results.avg_capture_time">Avg Capture:</span>
<strong id="test-stream-avg-capture-time">-</strong>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -559,13 +503,7 @@
<input type="text" id="stream-name" data-i18n-placeholder="streams.name.placeholder" placeholder="My Stream" required> <input type="text" id="stream-name" data-i18n-placeholder="streams.name.placeholder" placeholder="My Stream" required>
</div> </div>
<div class="form-group"> <input type="hidden" id="stream-type" value="raw">
<label for="stream-type" data-i18n="streams.type">Stream Type:</label>
<select id="stream-type" onchange="onStreamTypeChange()">
<option value="raw" data-i18n="streams.type.raw">Screen Capture</option>
<option value="processed" data-i18n="streams.type.processed">Processed</option>
</select>
</div>
<!-- Raw stream fields --> <!-- Raw stream fields -->
<div id="stream-raw-fields"> <div id="stream-raw-fields">
@@ -692,6 +630,15 @@
</div> </div>
</div> </div>
<!-- Image Lightbox -->
<div id="image-lightbox" class="lightbox" onclick="closeLightbox(event)">
<button class="lightbox-close" onclick="closeLightbox()" title="Close">&#x2715;</button>
<div class="lightbox-content">
<img id="lightbox-image" src="" alt="Full size preview">
<div id="lightbox-stats" class="lightbox-stats" style="display: none;"></div>
</div>
</div>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
<script> <script>
// Initialize theme // Initialize theme

View File

@@ -197,8 +197,14 @@
"common.edit": "Edit", "common.edit": "Edit",
"streams.title": "\uD83D\uDCFA Picture Streams", "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.description": "Picture streams define the capture pipeline. A raw stream captures from a display using a capture template. A processed stream applies postprocessing to another stream. Assign streams to devices.",
"streams.group.raw": "Screen Capture Streams",
"streams.group.processed": "Processed Streams",
"streams.add": "Add Picture Stream", "streams.add": "Add Picture Stream",
"streams.add.raw": "Add Screen Capture",
"streams.add.processed": "Add Processed Stream",
"streams.edit": "Edit Picture Stream", "streams.edit": "Edit Picture Stream",
"streams.edit.raw": "Edit Screen Capture",
"streams.edit.processed": "Edit Processed Stream",
"streams.name": "Stream Name:", "streams.name": "Stream Name:",
"streams.name.placeholder": "My Stream", "streams.name.placeholder": "My Stream",
"streams.type": "Type:", "streams.type": "Type:",
@@ -245,7 +251,7 @@
"postprocessing.config.show": "Show settings", "postprocessing.config.show": "Show settings",
"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": "Assigned Picture Stream:", "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.hint": "Select a picture 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",

View File

@@ -197,8 +197,14 @@
"common.edit": "Редактировать", "common.edit": "Редактировать",
"streams.title": "\uD83D\uDCFA Видеопотоки", "streams.title": "\uD83D\uDCFA Видеопотоки",
"streams.description": "Видеопотоки определяют конвейер захвата. Сырой поток захватывает экран с помощью шаблона захвата. Обработанный поток применяет постобработку к другому потоку. Назначайте потоки устройствам.", "streams.description": "Видеопотоки определяют конвейер захвата. Сырой поток захватывает экран с помощью шаблона захвата. Обработанный поток применяет постобработку к другому потоку. Назначайте потоки устройствам.",
"streams.group.raw": "Потоки Захвата Экрана",
"streams.group.processed": "Обработанные Потоки",
"streams.add": "Добавить Видеопоток", "streams.add": "Добавить Видеопоток",
"streams.add.raw": "Добавить Захват Экрана",
"streams.add.processed": "Добавить Обработанный",
"streams.edit": "Редактировать Видеопоток", "streams.edit": "Редактировать Видеопоток",
"streams.edit.raw": "Редактировать Захват Экрана",
"streams.edit.processed": "Редактировать Обработанный Поток",
"streams.name": "Имя Потока:", "streams.name": "Имя Потока:",
"streams.name.placeholder": "Мой Поток", "streams.name.placeholder": "Мой Поток",
"streams.type": "Тип:", "streams.type": "Тип:",
@@ -245,7 +251,7 @@
"postprocessing.config.show": "Показать настройки", "postprocessing.config.show": "Показать настройки",
"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": "Настройки потока обновлены",

View File

@@ -2045,6 +2045,131 @@ input:-webkit-autofill:focus {
margin-right: 6px; margin-right: 6px;
} }
/* Stream group sections */
.stream-group {
margin-bottom: 24px;
}
.stream-group:last-child {
margin-bottom: 0;
}
.stream-group-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid var(--border-color);
}
.stream-group-icon {
font-size: 1.2rem;
}
.stream-group-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
}
.stream-group-count {
background: var(--border-color);
color: var(--text-secondary);
font-size: 0.75rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
min-width: 20px;
text-align: center;
}
/* Image Lightbox */
.lightbox {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.92);
z-index: 10000;
justify-content: center;
align-items: center;
cursor: zoom-out;
}
.lightbox.active {
display: flex;
}
.lightbox-content {
position: relative;
max-width: 95%;
max-height: 95%;
cursor: default;
}
.lightbox-content img {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
border-radius: 4px;
display: block;
}
.lightbox-close {
position: absolute;
top: 16px;
right: 16px;
background: rgba(255, 255, 255, 0.15);
border: none;
color: white;
font-size: 1.5rem;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
z-index: 1;
}
.lightbox-close:hover {
background: rgba(255, 255, 255, 0.3);
}
.lightbox-stats {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
color: white;
padding: 8px 14px;
border-radius: 6px;
font-size: 0.8rem;
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.lightbox-stats .stat-item {
display: flex;
gap: 4px;
align-items: center;
}
.lightbox-stats .stat-item span {
opacity: 0.7;
}
.lightbox-stats .stat-item strong {
font-weight: 600;
}
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.templates-grid { .templates-grid {