feat: asset-based image/video sources, notification sounds, UI improvements
Lint & Test / test (push) Has been cancelled

- Replace URL-based image_source/url fields with image_asset_id/video_asset_id
  on StaticImagePictureSource and VideoCaptureSource (clean break, no migration)
- Resolve asset IDs to file paths at runtime via AssetStore.get_file_path()
- Add EntitySelect asset pickers for image/video in stream editor modal
- Add notification sound configuration (global sound + per-app overrides)
- Unify per-app color and sound overrides into single "Per-App Overrides" section
- Persist notification history between server restarts
- Add asset management system (upload, edit, delete, soft-delete)
- Replace emoji buttons with SVG icons throughout UI
- Various backend improvements: SQLite stores, auth, backup, MQTT, webhooks
This commit is contained in:
2026-03-26 20:40:25 +03:00
parent c0853ce184
commit e2e1107df7
100 changed files with 2935 additions and 992 deletions
@@ -19,7 +19,6 @@ import {
currentTestingTemplate, setCurrentTestingTemplate,
_currentTestStreamId, set_currentTestStreamId,
_currentTestPPTemplateId, set_currentTestPPTemplateId,
_lastValidatedImageSource, set_lastValidatedImageSource,
_cachedAudioSources,
_cachedValueSources,
_cachedSyncClocks,
@@ -35,7 +34,7 @@ import {
_sourcesLoading, set_sourcesLoading,
apiKey,
streamsCache, ppTemplatesCache, captureTemplatesCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, filtersCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, assetsCache, _cachedAssets, filtersCache,
colorStripSourcesCache,
csptCache, stripFiltersCache,
gradientsCache, GradientEntity,
@@ -51,6 +50,7 @@ import { updateSubTabHash } from './tabs.ts';
import { createValueSourceCard } from './value-sources.ts';
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
import { createAssetCard, initAssetDelegation } from './assets.ts';
import { createColorStripCard } from './color-strips.ts';
import { initAudioSourceDelegation } from './audio-sources.ts';
import {
@@ -58,7 +58,8 @@ import {
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO, ICON_ACTIVITY,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE, ICON_ASSET,
getAssetTypeIcon,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
@@ -97,8 +98,61 @@ const _colorStripDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon:
const _valueSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('value-sources', valueSourcesCache, 'value_source.deleted') }];
const _syncClockDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('sync-clocks', syncClocksCache, 'sync_clock.deleted') }];
const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }];
const _assetDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('assets', assetsCache, 'asset.deleted') }];
const _csptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-processing-templates', csptCache, 'templates.deleted') }];
/** Resolve an asset ID to its display name. */
function _getAssetName(assetId?: string | null): string {
if (!assetId) return '—';
const asset = _cachedAssets.find((a: any) => a.id === assetId);
return asset ? asset.name : assetId;
}
/** Get EntitySelect items for a given asset type (image/video). */
function _getAssetItems(assetType: string) {
return _cachedAssets
.filter((a: any) => a.asset_type === assetType)
.map((a: any) => ({ value: a.id, label: a.name, icon: getAssetTypeIcon(assetType), desc: a.filename }));
}
let _imageAssetEntitySelect: EntitySelect | null = null;
let _videoAssetEntitySelect: EntitySelect | null = null;
function _ensureImageAssetEntitySelect() {
const sel = document.getElementById('stream-image-asset') as HTMLSelectElement | null;
if (!sel) return;
if (_imageAssetEntitySelect) _imageAssetEntitySelect.destroy();
_imageAssetEntitySelect = null;
const items = _getAssetItems('image');
sel.innerHTML = `<option value="">${t('streams.image_asset.select')}</option>` +
items.map(a => `<option value="${a.value}">${escapeHtml(a.label)}</option>`).join('');
_imageAssetEntitySelect = new EntitySelect({
target: sel,
getItems: () => _getAssetItems('image'),
placeholder: t('streams.image_asset.search') || 'Search image assets…',
});
}
function _ensureVideoAssetEntitySelect() {
const sel = document.getElementById('stream-video-asset') as HTMLSelectElement | null;
if (!sel) return;
if (_videoAssetEntitySelect) _videoAssetEntitySelect.destroy();
_videoAssetEntitySelect = null;
const items = _getAssetItems('video');
sel.innerHTML = `<option value="">${t('streams.video_asset.select')}</option>` +
items.map(a => `<option value="${a.value}">${escapeHtml(a.label)}</option>`).join('');
_videoAssetEntitySelect = new EntitySelect({
target: sel,
getItems: () => _getAssetItems('video'),
placeholder: t('streams.video_asset.search') || 'Search video assets…',
});
}
function _destroyAssetEntitySelects() {
if (_imageAssetEntitySelect) { _imageAssetEntitySelect.destroy(); _imageAssetEntitySelect = null; }
if (_videoAssetEntitySelect) { _videoAssetEntitySelect.destroy(); _videoAssetEntitySelect = null; }
}
// ── Card section instances ──
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction });
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id', emptyKey: 'section.empty.capture_templates', bulkActions: _captureTemplateDeleteAction });
@@ -114,6 +168,7 @@ const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.secti
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources', bulkActions: _valueSourceDeleteAction });
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction });
const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction });
const csAssets = new CardSection('assets', { titleKey: 'asset.group.title', gridClass: 'templates-grid', addCardOnclick: "showAssetUploadModal()", keyAttr: 'data-id', emptyKey: 'section.empty.assets', bulkActions: _assetDeleteAction });
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction });
const _gradientDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('gradients', gradientsCache, 'gradient.deleted') }];
const csGradients = new CardSection('gradients', { titleKey: 'gradient.group.title', gridClass: 'templates-grid', addCardOnclick: "showGradientModal()", keyAttr: 'data-id', emptyKey: 'section.empty.gradients', bulkActions: _gradientDeleteAction });
@@ -137,13 +192,14 @@ class StreamEditorModal extends Modal {
targetFps: (document.getElementById('stream-target-fps') as HTMLInputElement).value,
source: (document.getElementById('stream-source') as HTMLSelectElement).value,
ppTemplate: (document.getElementById('stream-pp-template') as HTMLSelectElement).value,
imageSource: (document.getElementById('stream-image-source') as HTMLInputElement).value,
imageAsset: (document.getElementById('stream-image-asset') as HTMLSelectElement).value,
tags: JSON.stringify(_streamTagsInput ? _streamTagsInput.getValue() : []),
};
}
onForceClose() {
if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; }
_destroyAssetEntitySelects();
(document.getElementById('stream-type') as HTMLSelectElement).disabled = false;
set_streamNameManuallyEdited(false);
}
@@ -226,6 +282,7 @@ export async function loadPictureSources() {
valueSourcesCache.fetch(),
syncClocksCache.fetch(),
weatherSourcesCache.fetch(),
assetsCache.fetch(),
audioTemplatesCache.fetch(),
colorStripSourcesCache.fetch(),
csptCache.fetch(),
@@ -316,16 +373,15 @@ const PICTURE_SOURCE_CARD_RENDERERS: Record<string, StreamCardRenderer> = {
</div>`;
},
static_image: (stream) => {
const src = stream.image_source || '';
const assetName = _getAssetName(stream.image_asset_id);
return `<div class="stream-card-props">
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(src)}">${ICON_WEB} ${escapeHtml(src)}</span>
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(assetName)}">${ICON_ASSET} ${escapeHtml(assetName)}</span>
</div>`;
},
video: (stream) => {
const url = stream.url || '';
const shortUrl = url.length > 40 ? url.slice(0, 37) + '...' : url;
const assetName = _getAssetName(stream.video_asset_id);
return `<div class="stream-card-props">
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(url)}">${ICON_WEB} ${escapeHtml(shortUrl)}</span>
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(assetName)}">${ICON_ASSET} ${escapeHtml(assetName)}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
${stream.loop !== false ? `<span class="stream-card-prop">↻</span>` : ''}
${stream.playback_speed && stream.playback_speed !== 1.0 ? `<span class="stream-card-prop">${stream.playback_speed}×</span>` : ''}
@@ -510,6 +566,7 @@ function renderPictureSourcesList(streams: any) {
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
{ key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
{ key: 'assets', icon: ICON_ASSET, titleKey: 'streams.group.assets', count: _cachedAssets.length },
];
// Build tree navigation structure
@@ -563,6 +620,7 @@ function renderPictureSourcesList(streams: any) {
{ key: 'value', titleKey: 'streams.group.value', icon: ICON_VALUE_SOURCE, count: _cachedValueSources.length },
{ key: 'sync', titleKey: 'streams.group.sync', icon: ICON_CLOCK, count: _cachedSyncClocks.length },
{ key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
{ key: 'assets', titleKey: 'streams.group.assets', icon: ICON_ASSET, count: _cachedAssets.length },
]
}
];
@@ -723,6 +781,7 @@ function renderPictureSourcesList(streams: any) {
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) })));
const assetItems = csAssets.applySortOrder(_cachedAssets.map(a => ({ key: a.id, html: createAssetCard(a) })));
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
if (csRawStreams.isMounted()) {
@@ -742,6 +801,7 @@ function renderPictureSourcesList(streams: any) {
value: _cachedValueSources.length,
sync: _cachedSyncClocks.length,
weather: _cachedWeatherSources.length,
assets: _cachedAssets.length,
});
csRawStreams.reconcile(rawStreamItems);
csRawTemplates.reconcile(rawTemplateItems);
@@ -759,6 +819,7 @@ function renderPictureSourcesList(streams: any) {
csValueSources.reconcile(valueItems);
csSyncClocks.reconcile(syncClockItems);
csWeatherSources.reconcile(weatherSourceItems);
csAssets.reconcile(assetItems);
} else {
// First render: build full HTML
const panels = tabs.map(tab => {
@@ -777,18 +838,20 @@ function renderPictureSourcesList(streams: any) {
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
else if (tab.key === 'assets') panelContent = csAssets.render(assetItems);
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
else panelContent = csStaticStreams.render(staticItems);
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).join('');
container.innerHTML = panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources]);
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csAssets]);
// Event delegation for card actions (replaces inline onclick handlers)
initSyncClockDelegation(container);
initWeatherSourceDelegation(container);
initAudioSourceDelegation(container);
initAssetDelegation(container);
// Render tree sidebar with expand/collapse buttons
_streamsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
@@ -806,6 +869,7 @@ function renderPictureSourcesList(streams: any) {
'value-sources': 'value',
'sync-clocks': 'sync',
'weather-sources': 'weather',
'assets': 'assets',
});
}
}
@@ -867,14 +931,8 @@ export async function showAddStreamModal(presetType: any, cloneData: any = null)
document.getElementById('stream-display-picker-label')!.textContent = t('displays.picker.select');
document.getElementById('stream-error')!.style.display = 'none';
(document.getElementById('stream-type') as HTMLSelectElement).value = streamType;
set_lastValidatedImageSource('');
const imgSrcInput = document.getElementById('stream-image-source') as HTMLInputElement;
imgSrcInput.value = '';
document.getElementById('stream-image-preview-container')!.style.display = 'none';
document.getElementById('stream-image-validation-status')!.style.display = 'none';
imgSrcInput.onblur = () => validateStaticImage();
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
_ensureImageAssetEntitySelect();
_ensureVideoAssetEntitySelect();
onStreamTypeChange();
set_streamNameManuallyEdited(!!cloneData);
@@ -906,10 +964,15 @@ export async function showAddStreamModal(presetType: any, cloneData: any = null)
(document.getElementById('stream-source') as HTMLSelectElement).value = cloneData.source_stream_id || '';
(document.getElementById('stream-pp-template') as HTMLSelectElement).value = cloneData.postprocessing_template_id || '';
} else if (streamType === 'static_image') {
(document.getElementById('stream-image-source') as HTMLInputElement).value = cloneData.image_source || '';
if (cloneData.image_source) validateStaticImage();
if (cloneData.image_asset_id) {
(document.getElementById('stream-image-asset') as HTMLSelectElement).value = cloneData.image_asset_id;
if (_imageAssetEntitySelect) _imageAssetEntitySelect.setValue(cloneData.image_asset_id);
}
} else if (streamType === 'video') {
(document.getElementById('stream-video-url') as HTMLInputElement).value = cloneData.url || '';
if (cloneData.video_asset_id) {
(document.getElementById('stream-video-asset') as HTMLSelectElement).value = cloneData.video_asset_id;
if (_videoAssetEntitySelect) _videoAssetEntitySelect.setValue(cloneData.video_asset_id);
}
(document.getElementById('stream-video-loop') as HTMLInputElement).checked = cloneData.loop !== false;
(document.getElementById('stream-video-speed') as HTMLInputElement).value = cloneData.playback_speed || 1.0;
const cloneSpeedLabel = document.getElementById('stream-video-speed-value');
@@ -951,13 +1014,8 @@ export async function editStream(streamId: any) {
(document.getElementById('stream-description') as HTMLInputElement).value = stream.description || '';
(document.getElementById('stream-type') as HTMLSelectElement).value = stream.stream_type;
set_lastValidatedImageSource('');
const imgSrcInput = document.getElementById('stream-image-source') as HTMLInputElement;
document.getElementById('stream-image-preview-container')!.style.display = 'none';
document.getElementById('stream-image-validation-status')!.style.display = 'none';
imgSrcInput.onblur = () => validateStaticImage();
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
_ensureImageAssetEntitySelect();
_ensureVideoAssetEntitySelect();
onStreamTypeChange();
await populateStreamModalDropdowns();
@@ -976,10 +1034,15 @@ export async function editStream(streamId: any) {
(document.getElementById('stream-source') as HTMLSelectElement).value = stream.source_stream_id || '';
(document.getElementById('stream-pp-template') as HTMLSelectElement).value = stream.postprocessing_template_id || '';
} else if (stream.stream_type === 'static_image') {
(document.getElementById('stream-image-source') as HTMLInputElement).value = stream.image_source || '';
if (stream.image_source) validateStaticImage();
if (stream.image_asset_id) {
(document.getElementById('stream-image-asset') as HTMLSelectElement).value = stream.image_asset_id;
if (_imageAssetEntitySelect) _imageAssetEntitySelect.setValue(stream.image_asset_id);
}
} else if (stream.stream_type === 'video') {
(document.getElementById('stream-video-url') as HTMLInputElement).value = stream.url || '';
if (stream.video_asset_id) {
(document.getElementById('stream-video-asset') as HTMLSelectElement).value = stream.video_asset_id;
if (_videoAssetEntitySelect) _videoAssetEntitySelect.setValue(stream.video_asset_id);
}
(document.getElementById('stream-video-loop') as HTMLInputElement).checked = stream.loop !== false;
(document.getElementById('stream-video-speed') as HTMLInputElement).value = stream.playback_speed || 1.0;
const speedLabel = document.getElementById('stream-video-speed-value');
@@ -1164,13 +1227,13 @@ export async function saveStream() {
payload.source_stream_id = (document.getElementById('stream-source') as HTMLSelectElement).value;
payload.postprocessing_template_id = (document.getElementById('stream-pp-template') as HTMLSelectElement).value;
} else if (streamType === 'static_image') {
const imageSource = (document.getElementById('stream-image-source') as HTMLInputElement).value.trim();
if (!imageSource) { showToast(t('streams.error.required'), 'error'); return; }
payload.image_source = imageSource;
const imageAssetId = (document.getElementById('stream-image-asset') as HTMLSelectElement).value;
if (!imageAssetId) { showToast(t('streams.error.required'), 'error'); return; }
payload.image_asset_id = imageAssetId;
} else if (streamType === 'video') {
const url = (document.getElementById('stream-video-url') as HTMLInputElement).value.trim();
if (!url) { showToast(t('streams.error.required'), 'error'); return; }
payload.url = url;
const videoAssetId = (document.getElementById('stream-video-asset') as HTMLSelectElement).value;
if (!videoAssetId) { showToast(t('streams.error.required'), 'error'); return; }
payload.video_asset_id = videoAssetId;
payload.loop = (document.getElementById('stream-video-loop') as HTMLInputElement).checked;
payload.playback_speed = parseFloat((document.getElementById('stream-video-speed') as HTMLInputElement).value) || 1.0;
payload.target_fps = parseInt((document.getElementById('stream-video-fps') as HTMLInputElement).value) || 30;
@@ -1239,55 +1302,6 @@ export async function closeStreamModal() {
await streamModal.close();
}
async function validateStaticImage() {
const source = (document.getElementById('stream-image-source') as HTMLInputElement).value.trim();
const previewContainer = document.getElementById('stream-image-preview-container')!;
const previewImg = document.getElementById('stream-image-preview') as HTMLImageElement;
const infoEl = document.getElementById('stream-image-info')!;
const statusEl = document.getElementById('stream-image-validation-status')!;
if (!source) {
set_lastValidatedImageSource('');
previewContainer.style.display = 'none';
statusEl.style.display = 'none';
return;
}
if (source === _lastValidatedImageSource) return;
statusEl.textContent = t('streams.validate_image.validating');
statusEl.className = 'validation-status loading';
statusEl.style.display = 'block';
previewContainer.style.display = 'none';
try {
const response = await fetchWithAuth('/picture-sources/validate-image', {
method: 'POST',
body: JSON.stringify({ image_source: source }),
});
const data = await response.json();
set_lastValidatedImageSource(source);
if (data.valid) {
previewImg.src = data.preview;
previewImg.style.cursor = 'pointer';
previewImg.onclick = () => openFullImageLightbox(source);
infoEl.textContent = `${data.width} × ${data.height} px`;
previewContainer.style.display = '';
statusEl.textContent = t('streams.validate_image.valid');
statusEl.className = 'validation-status success';
} else {
previewContainer.style.display = 'none';
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${data.error}`;
statusEl.className = 'validation-status error';
}
} catch (err) {
previewContainer.style.display = 'none';
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${err.message}`;
statusEl.className = 'validation-status error';
}
}
// ===== Picture Source Test =====
export async function showTestStreamModal(streamId: any) {