feat: asset-based image/video sources, notification sounds, UI improvements
Some checks failed
Lint & Test / test (push) Has been cancelled
Some checks failed
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:
475
server/src/wled_controller/static/js/features/assets.ts
Normal file
475
server/src/wled_controller/static/js/features/assets.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* Assets — file upload/download, CRUD, cards, modal.
|
||||
*/
|
||||
|
||||
import { _cachedAssets, assetsCache } from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml, API_BASE, getHeaders } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { ICON_CLONE, ICON_EDIT, ICON_DOWNLOAD, ICON_ASSET, ICON_TRASH, getAssetTypeIcon } from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { loadPictureSources } from './streams.ts';
|
||||
import type { Asset } from '../types.ts';
|
||||
|
||||
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
const ICON_PLAY_SOUND = _icon(P.play);
|
||||
const ICON_UPLOAD = _icon(P.fileUp);
|
||||
const ICON_RESTORE = _icon(P.rotateCcw);
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
let _dropzoneInitialized = false;
|
||||
|
||||
/** Initialise the drop-zone wiring for the upload modal (once). */
|
||||
function initUploadDropzone(): void {
|
||||
if (_dropzoneInitialized) return;
|
||||
_dropzoneInitialized = true;
|
||||
const dropzone = document.getElementById('asset-upload-dropzone')!;
|
||||
const fileInput = document.getElementById('asset-upload-file') as HTMLInputElement;
|
||||
const infoEl = document.getElementById('asset-upload-file-info')!;
|
||||
const nameEl = document.getElementById('asset-upload-file-name')!;
|
||||
const sizeEl = document.getElementById('asset-upload-file-size')!;
|
||||
const removeBtn = document.getElementById('asset-upload-file-remove')!;
|
||||
|
||||
const showFile = (file: File) => {
|
||||
nameEl.textContent = file.name;
|
||||
sizeEl.textContent = formatFileSize(file.size);
|
||||
infoEl.style.display = '';
|
||||
dropzone.classList.add('has-file');
|
||||
// Hide the prompt text when a file is selected
|
||||
const labelEl = dropzone.querySelector('.file-dropzone-label') as HTMLElement | null;
|
||||
if (labelEl) labelEl.style.display = 'none';
|
||||
const iconEl = dropzone.querySelector('.file-dropzone-icon') as HTMLElement | null;
|
||||
if (iconEl) iconEl.style.display = 'none';
|
||||
};
|
||||
|
||||
const clearFile = () => {
|
||||
fileInput.value = '';
|
||||
infoEl.style.display = 'none';
|
||||
dropzone.classList.remove('has-file');
|
||||
const labelEl = dropzone.querySelector('.file-dropzone-label') as HTMLElement | null;
|
||||
if (labelEl) labelEl.style.display = '';
|
||||
const iconEl = dropzone.querySelector('.file-dropzone-icon') as HTMLElement | null;
|
||||
if (iconEl) iconEl.style.display = '';
|
||||
};
|
||||
|
||||
// Click → open file picker
|
||||
dropzone.addEventListener('click', (e) => {
|
||||
if ((e.target as HTMLElement).closest('.file-dropzone-remove')) return;
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
// Keyboard: Enter/Space
|
||||
dropzone.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
// File input change
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files && fileInput.files.length > 0) {
|
||||
showFile(fileInput.files[0]);
|
||||
} else {
|
||||
clearFile();
|
||||
}
|
||||
});
|
||||
|
||||
// Drag events
|
||||
let dragCounter = 0;
|
||||
dropzone.addEventListener('dragenter', (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter++;
|
||||
dropzone.classList.add('dragover');
|
||||
});
|
||||
dropzone.addEventListener('dragleave', () => {
|
||||
dragCounter--;
|
||||
if (dragCounter <= 0) {
|
||||
dragCounter = 0;
|
||||
dropzone.classList.remove('dragover');
|
||||
}
|
||||
});
|
||||
dropzone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
dropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter = 0;
|
||||
dropzone.classList.remove('dragover');
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
fileInput.files = files;
|
||||
showFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove button
|
||||
removeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
clearFile();
|
||||
});
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function getAssetTypeLabel(assetType: string): string {
|
||||
const map: Record<string, string> = {
|
||||
sound: t('asset.type.sound'),
|
||||
image: t('asset.type.image'),
|
||||
video: t('asset.type.video'),
|
||||
other: t('asset.type.other'),
|
||||
};
|
||||
return map[assetType] || assetType;
|
||||
}
|
||||
|
||||
// ── Card builder ──
|
||||
|
||||
export function createAssetCard(asset: Asset): string {
|
||||
const icon = getAssetTypeIcon(asset.asset_type);
|
||||
const sizeStr = formatFileSize(asset.size_bytes);
|
||||
const prebuiltBadge = asset.prebuilt
|
||||
? `<span class="stream-card-prop" title="${escapeHtml(t('asset.prebuilt'))}">${_icon(P.star)} ${t('asset.prebuilt')}</span>`
|
||||
: '';
|
||||
|
||||
let playBtn = '';
|
||||
if (asset.asset_type === 'sound') {
|
||||
playBtn = `<button class="btn btn-icon btn-secondary" data-action="play" title="${escapeHtml(t('asset.play'))}">${ICON_PLAY_SOUND}</button>`;
|
||||
}
|
||||
|
||||
return wrapCard({
|
||||
dataAttr: 'data-id',
|
||||
id: asset.id,
|
||||
removeOnclick: `deleteAsset('${asset.id}')`,
|
||||
removeTitle: t('common.delete'),
|
||||
content: `
|
||||
<div class="card-header">
|
||||
<div class="card-title" title="${escapeHtml(asset.name)}">
|
||||
${icon} <span class="card-title-text">${escapeHtml(asset.name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop">${getAssetTypeIcon(asset.asset_type)} ${escapeHtml(getAssetTypeLabel(asset.asset_type))}</span>
|
||||
<span class="stream-card-prop">${_icon(P.fileText)} ${sizeStr}</span>
|
||||
${prebuiltBadge}
|
||||
</div>
|
||||
${renderTagChips(asset.tags)}`,
|
||||
actions: `
|
||||
${playBtn}
|
||||
<button class="btn btn-icon btn-secondary" data-action="download" title="${escapeHtml(t('asset.download'))}">${ICON_DOWNLOAD}</button>
|
||||
<button class="btn btn-icon btn-secondary" data-action="edit" title="${escapeHtml(t('common.edit'))}">${ICON_EDIT}</button>`,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sound playback ──
|
||||
|
||||
let _currentAudio: HTMLAudioElement | null = null;
|
||||
|
||||
async function _playAssetSound(assetId: string) {
|
||||
if (_currentAudio) {
|
||||
_currentAudio.pause();
|
||||
_currentAudio = null;
|
||||
}
|
||||
try {
|
||||
const res = await fetchWithAuth(`/assets/${assetId}/file`);
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const audio = new Audio(blobUrl);
|
||||
audio.addEventListener('ended', () => URL.revokeObjectURL(blobUrl));
|
||||
audio.play().catch(() => {});
|
||||
_currentAudio = audio;
|
||||
} catch { /* ignore playback errors */ }
|
||||
}
|
||||
|
||||
// ── Modal ──
|
||||
|
||||
let _assetTagsInput: TagInput | null = null;
|
||||
let _uploadTagsInput: TagInput | null = null;
|
||||
|
||||
class AssetEditorModal extends Modal {
|
||||
constructor() { super('asset-editor-modal'); }
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: (document.getElementById('asset-editor-name') as HTMLInputElement).value,
|
||||
description: (document.getElementById('asset-editor-description') as HTMLInputElement).value,
|
||||
tags: JSON.stringify(_assetTagsInput ? _assetTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_assetTagsInput) { _assetTagsInput.destroy(); _assetTagsInput = null; }
|
||||
}
|
||||
}
|
||||
const assetEditorModal = new AssetEditorModal();
|
||||
|
||||
class AssetUploadModal extends Modal {
|
||||
constructor() { super('asset-upload-modal'); }
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: (document.getElementById('asset-upload-name') as HTMLInputElement).value,
|
||||
file: (document.getElementById('asset-upload-file') as HTMLInputElement).value,
|
||||
tags: JSON.stringify(_uploadTagsInput ? _uploadTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
onForceClose() {
|
||||
if (_uploadTagsInput) { _uploadTagsInput.destroy(); _uploadTagsInput = null; }
|
||||
}
|
||||
}
|
||||
const assetUploadModal = new AssetUploadModal();
|
||||
|
||||
// ── CRUD: Upload ──
|
||||
|
||||
export async function showAssetUploadModal(): Promise<void> {
|
||||
const titleEl = document.getElementById('asset-upload-title')!;
|
||||
titleEl.innerHTML = `${ICON_UPLOAD} ${t('asset.upload')}`;
|
||||
|
||||
(document.getElementById('asset-upload-name') as HTMLInputElement).value = '';
|
||||
(document.getElementById('asset-upload-description') as HTMLInputElement).value = '';
|
||||
(document.getElementById('asset-upload-file') as HTMLInputElement).value = '';
|
||||
document.getElementById('asset-upload-error')!.style.display = 'none';
|
||||
|
||||
// Tags
|
||||
if (_uploadTagsInput) { _uploadTagsInput.destroy(); _uploadTagsInput = null; }
|
||||
const tagsContainer = document.getElementById('asset-upload-tags-container')!;
|
||||
_uploadTagsInput = new TagInput(tagsContainer, { entityType: 'asset' });
|
||||
|
||||
// Reset dropzone visual state
|
||||
const dropzone = document.getElementById('asset-upload-dropzone')!;
|
||||
dropzone.classList.remove('has-file', 'dragover');
|
||||
const dzLabel = dropzone.querySelector('.file-dropzone-label') as HTMLElement | null;
|
||||
if (dzLabel) dzLabel.style.display = '';
|
||||
const dzIcon = dropzone.querySelector('.file-dropzone-icon') as HTMLElement | null;
|
||||
if (dzIcon) dzIcon.style.display = '';
|
||||
document.getElementById('asset-upload-file-info')!.style.display = 'none';
|
||||
|
||||
initUploadDropzone();
|
||||
|
||||
assetUploadModal.open();
|
||||
assetUploadModal.snapshot();
|
||||
}
|
||||
|
||||
export async function uploadAsset(): Promise<void> {
|
||||
const fileInput = document.getElementById('asset-upload-file') as HTMLInputElement;
|
||||
const nameInput = document.getElementById('asset-upload-name') as HTMLInputElement;
|
||||
const descInput = document.getElementById('asset-upload-description') as HTMLInputElement;
|
||||
const errorEl = document.getElementById('asset-upload-error')!;
|
||||
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
errorEl.textContent = t('asset.error.no_file');
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fileInput.files[0];
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
let url = `${API_BASE}/assets`;
|
||||
const params = new URLSearchParams();
|
||||
const name = nameInput.value.trim();
|
||||
if (name) params.set('name', name);
|
||||
const desc = descInput.value.trim();
|
||||
if (desc) params.set('description', desc);
|
||||
if (params.toString()) url += `?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const headers = getHeaders();
|
||||
delete headers['Content-Type']; // Let browser set multipart boundary
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || 'Upload failed');
|
||||
}
|
||||
|
||||
// Set tags via metadata update if any were specified
|
||||
const tags = _uploadTagsInput ? _uploadTagsInput.getValue() : [];
|
||||
const result = await res.json();
|
||||
if (tags.length > 0 && result.id) {
|
||||
await fetchWithAuth(`/assets/${result.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ tags }),
|
||||
});
|
||||
}
|
||||
|
||||
showToast(t('asset.uploaded'), 'success');
|
||||
assetsCache.invalidate();
|
||||
assetUploadModal.forceClose();
|
||||
await loadPictureSources();
|
||||
} catch (e: any) {
|
||||
errorEl.textContent = e.message;
|
||||
errorEl.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
export function closeAssetUploadModal() {
|
||||
assetUploadModal.close();
|
||||
}
|
||||
|
||||
// ── CRUD: Edit metadata ──
|
||||
|
||||
export async function showAssetEditor(editId: string): Promise<void> {
|
||||
const titleEl = document.getElementById('asset-editor-title')!;
|
||||
const idInput = document.getElementById('asset-editor-id') as HTMLInputElement;
|
||||
const nameInput = document.getElementById('asset-editor-name') as HTMLInputElement;
|
||||
const descInput = document.getElementById('asset-editor-description') as HTMLInputElement;
|
||||
const errorEl = document.getElementById('asset-editor-error')!;
|
||||
|
||||
errorEl.style.display = 'none';
|
||||
const assets = await assetsCache.fetch();
|
||||
const asset = assets.find(a => a.id === editId);
|
||||
if (!asset) return;
|
||||
|
||||
titleEl.innerHTML = `${ICON_ASSET} ${t('asset.edit')}`;
|
||||
idInput.value = asset.id;
|
||||
nameInput.value = asset.name;
|
||||
descInput.value = asset.description || '';
|
||||
|
||||
// Tags
|
||||
if (_assetTagsInput) { _assetTagsInput.destroy(); _assetTagsInput = null; }
|
||||
const tagsContainer = document.getElementById('asset-editor-tags-container')!;
|
||||
_assetTagsInput = new TagInput(tagsContainer, { entityType: 'asset' });
|
||||
_assetTagsInput.setValue(asset.tags || []);
|
||||
|
||||
assetEditorModal.open();
|
||||
assetEditorModal.snapshot();
|
||||
}
|
||||
|
||||
export async function saveAssetMetadata(): Promise<void> {
|
||||
const id = (document.getElementById('asset-editor-id') as HTMLInputElement).value;
|
||||
const name = (document.getElementById('asset-editor-name') as HTMLInputElement).value.trim();
|
||||
const description = (document.getElementById('asset-editor-description') as HTMLInputElement).value.trim();
|
||||
const errorEl = document.getElementById('asset-editor-error')!;
|
||||
|
||||
if (!name) {
|
||||
errorEl.textContent = t('asset.error.name_required');
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = _assetTagsInput ? _assetTagsInput.getValue() : [];
|
||||
|
||||
try {
|
||||
const res = await fetchWithAuth(`/assets/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, description: description || null, tags }),
|
||||
});
|
||||
if (!res!.ok) {
|
||||
const err = await res!.json();
|
||||
throw new Error(err.detail);
|
||||
}
|
||||
|
||||
showToast(t('asset.updated'), 'success');
|
||||
assetsCache.invalidate();
|
||||
assetEditorModal.forceClose();
|
||||
await loadPictureSources();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
errorEl.textContent = e.message;
|
||||
errorEl.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
export function closeAssetEditorModal() {
|
||||
assetEditorModal.close();
|
||||
}
|
||||
|
||||
// ── CRUD: Delete ──
|
||||
|
||||
export async function deleteAsset(assetId: string): Promise<void> {
|
||||
const ok = await showConfirm(t('asset.confirm_delete'));
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await fetchWithAuth(`/assets/${assetId}`, { method: 'DELETE' });
|
||||
showToast(t('asset.deleted'), 'success');
|
||||
assetsCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message || t('asset.error.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Restore prebuilt ──
|
||||
|
||||
export async function restorePrebuiltAssets(): Promise<void> {
|
||||
try {
|
||||
const res = await fetchWithAuth('/assets/restore-prebuilt', { method: 'POST' });
|
||||
if (!res!.ok) {
|
||||
const err = await res!.json();
|
||||
throw new Error(err.detail);
|
||||
}
|
||||
const data = await res!.json();
|
||||
if (data.restored_count > 0) {
|
||||
showToast(t('asset.prebuilt_restored').replace('{count}', String(data.restored_count)), 'success');
|
||||
} else {
|
||||
showToast(t('asset.prebuilt_none_to_restore'), 'info');
|
||||
}
|
||||
assetsCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Download ──
|
||||
|
||||
async function _downloadAsset(assetId: string) {
|
||||
const asset = _cachedAssets.find(a => a.id === assetId);
|
||||
const filename = asset ? asset.filename : 'download';
|
||||
try {
|
||||
const res = await fetchWithAuth(`/assets/${assetId}/file`);
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Event delegation ──
|
||||
|
||||
export function initAssetDelegation(container: HTMLElement): void {
|
||||
container.addEventListener('click', (e: Event) => {
|
||||
const btn = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
|
||||
if (!btn) return;
|
||||
const card = btn.closest('[data-id]') as HTMLElement | null;
|
||||
if (!card || !card.closest('#stream-tab-assets')) return;
|
||||
|
||||
const action = btn.dataset.action;
|
||||
const id = card.getAttribute('data-id');
|
||||
if (!action || !id) return;
|
||||
|
||||
e.stopPropagation();
|
||||
if (action === 'edit') showAssetEditor(id);
|
||||
else if (action === 'delete') deleteAsset(id);
|
||||
else if (action === 'download') _downloadAsset(id);
|
||||
else if (action === 'play') _playAssetSound(id);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Expose to global scope for HTML template onclick handlers ──
|
||||
|
||||
window.showAssetUploadModal = showAssetUploadModal;
|
||||
window.closeAssetUploadModal = closeAssetUploadModal;
|
||||
window.uploadAsset = uploadAsset;
|
||||
window.showAssetEditor = showAssetEditor;
|
||||
window.closeAssetEditorModal = closeAssetEditorModal;
|
||||
window.saveAssetMetadata = saveAssetMetadata;
|
||||
window.deleteAsset = deleteAsset;
|
||||
window.restorePrebuiltAssets = restorePrebuiltAssets;
|
||||
@@ -9,7 +9,7 @@ import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { CardSection } from '../core/card-sections.ts';
|
||||
import { updateTabBadge, updateSubTabHash } from './tabs.ts';
|
||||
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB } from '../core/icons.ts';
|
||||
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH } from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
@@ -766,7 +766,7 @@ function addAutomationConditionRow(condition: any) {
|
||||
<div class="condition-field">
|
||||
<div class="condition-apps-header">
|
||||
<label>${t('automations.condition.application.apps')}</label>
|
||||
<button type="button" class="btn-browse-apps" title="${t('automations.condition.application.browse')}">${t('automations.condition.application.browse')}</button>
|
||||
<button type="button" class="btn btn-icon btn-secondary btn-browse-apps" title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
|
||||
</div>
|
||||
<textarea class="condition-apps" rows="3" placeholder="firefox.exe chrome.exe">${escapeHtml(appsValue)}</textarea>
|
||||
</div>
|
||||
|
||||
@@ -7,23 +7,33 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast } from '../core/ui.ts';
|
||||
import {
|
||||
ICON_SEARCH, ICON_CLONE,
|
||||
ICON_SEARCH, ICON_CLONE, getAssetTypeIcon,
|
||||
} from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts';
|
||||
import { _cachedAssets, assetsCache } from '../core/state.ts';
|
||||
import { getBaseOrigin } from './settings.ts';
|
||||
|
||||
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
/* ── Notification state ───────────────────────────────────────── */
|
||||
|
||||
let _notificationAppColors: any[] = []; // [{app: '', color: '#...'}]
|
||||
|
||||
/** Return current app colors array (for dirty-check snapshot). */
|
||||
export function notificationGetRawAppColors() {
|
||||
return _notificationAppColors;
|
||||
interface AppOverride {
|
||||
app: string;
|
||||
color: string;
|
||||
sound_asset_id: string | null;
|
||||
volume: number; // 0–100
|
||||
}
|
||||
|
||||
let _notificationAppOverrides: AppOverride[] = [];
|
||||
|
||||
/** Return current overrides array (for dirty-check snapshot). */
|
||||
export function notificationGetRawAppOverrides() {
|
||||
return _notificationAppOverrides;
|
||||
}
|
||||
|
||||
let _notificationEffectIconSelect: any = null;
|
||||
let _notificationFilterModeIconSelect: any = null;
|
||||
|
||||
@@ -58,50 +68,162 @@ export function onNotificationFilterModeChange() {
|
||||
(document.getElementById('css-editor-notification-filter-list-group') as HTMLElement).style.display = mode === 'off' ? 'none' : '';
|
||||
}
|
||||
|
||||
function _notificationAppColorsRenderList() {
|
||||
const list = document.getElementById('notification-app-colors-list') as HTMLElement | null;
|
||||
if (!list) return;
|
||||
list.innerHTML = _notificationAppColors.map((entry, i) => `
|
||||
<div class="notif-app-color-row">
|
||||
<input type="text" class="notif-app-name" data-idx="${i}" value="${escapeHtml(entry.app)}" placeholder="App name">
|
||||
<button type="button" class="notif-app-browse" data-idx="${i}"
|
||||
title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
|
||||
<input type="color" class="notif-app-color" data-idx="${i}" value="${entry.color}">
|
||||
<button type="button" class="notif-app-color-remove"
|
||||
onclick="notificationRemoveAppColor(${i})">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
/* ── Unified per-app overrides (color + sound) ────────────────── */
|
||||
|
||||
// Wire up browse buttons to open process palette
|
||||
list.querySelectorAll<HTMLButtonElement>('.notif-app-browse').forEach(btn => {
|
||||
let _overrideEntitySelects: EntitySelect[] = [];
|
||||
|
||||
function _getSoundAssetItems() {
|
||||
return _cachedAssets
|
||||
.filter(a => a.asset_type === 'sound')
|
||||
.map(a => ({ value: a.id, label: a.name, icon: getAssetTypeIcon('sound'), desc: a.filename }));
|
||||
}
|
||||
|
||||
function _overridesSyncFromDom() {
|
||||
const list = document.getElementById('notification-app-overrides-list') as HTMLElement | null;
|
||||
if (!list) return;
|
||||
const names = list.querySelectorAll<HTMLInputElement>('.notif-override-name');
|
||||
const colors = list.querySelectorAll<HTMLInputElement>('.notif-override-color');
|
||||
const sounds = list.querySelectorAll<HTMLSelectElement>('.notif-override-sound');
|
||||
const volumes = list.querySelectorAll<HTMLInputElement>('.notif-override-volume');
|
||||
if (names.length === _notificationAppOverrides.length) {
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
_notificationAppOverrides[i].app = names[i].value;
|
||||
_notificationAppOverrides[i].color = colors[i].value;
|
||||
_notificationAppOverrides[i].sound_asset_id = sounds[i].value || null;
|
||||
_notificationAppOverrides[i].volume = parseInt(volumes[i].value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _overridesRenderList() {
|
||||
const list = document.getElementById('notification-app-overrides-list') as HTMLElement | null;
|
||||
if (!list) return;
|
||||
|
||||
_overrideEntitySelects.forEach(es => es.destroy());
|
||||
_overrideEntitySelects = [];
|
||||
|
||||
const soundAssets = _cachedAssets.filter(a => a.asset_type === 'sound');
|
||||
|
||||
list.innerHTML = _notificationAppOverrides.map((entry, i) => {
|
||||
const soundOpts = `<option value="">${t('color_strip.notification.sound.none')}</option>` +
|
||||
soundAssets.map(a =>
|
||||
`<option value="${a.id}"${a.id === entry.sound_asset_id ? ' selected' : ''}>${escapeHtml(a.name)}</option>`
|
||||
).join('');
|
||||
const volPct = entry.volume ?? 100;
|
||||
return `
|
||||
<div class="notif-override-row">
|
||||
<input type="text" class="notif-override-name" data-idx="${i}" value="${escapeHtml(entry.app)}"
|
||||
placeholder="${t('color_strip.notification.app_overrides.app_placeholder') || 'App name'}">
|
||||
<button type="button" class="btn btn-icon btn-secondary notif-override-browse" data-idx="${i}"
|
||||
title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
|
||||
<input type="color" class="notif-override-color" data-idx="${i}" value="${entry.color}">
|
||||
<button type="button" class="btn btn-icon btn-secondary"
|
||||
onclick="notificationRemoveAppOverride(${i})">✕</button>
|
||||
<select class="notif-override-sound" data-idx="${i}">${soundOpts}</select>
|
||||
<input type="range" class="notif-override-volume" data-idx="${i}" min="0" max="100" step="5" value="${volPct}"
|
||||
title="${volPct}%"
|
||||
oninput="this.title = this.value + '%'">
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Wire browse buttons
|
||||
list.querySelectorAll<HTMLButtonElement>('.notif-override-browse').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const idx = parseInt(btn.dataset.idx!);
|
||||
const nameInput = list.querySelector<HTMLInputElement>(`.notif-app-name[data-idx="${idx}"]`);
|
||||
const nameInput = list.querySelector<HTMLInputElement>(`.notif-override-name[data-idx="${idx}"]`);
|
||||
if (!nameInput) return;
|
||||
const picked = await NotificationAppPalette.pick({
|
||||
current: nameInput.value,
|
||||
placeholder: t('color_strip.notification.search_apps') || 'Search notification apps...',
|
||||
placeholder: t('color_strip.notification.search_apps') || 'Search notification apps…',
|
||||
});
|
||||
if (picked !== undefined) {
|
||||
nameInput.value = picked;
|
||||
_notificationAppColorsSyncFromDom();
|
||||
_overridesSyncFromDom();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Wire EntitySelects for sound dropdowns
|
||||
list.querySelectorAll<HTMLSelectElement>('.notif-override-sound').forEach(sel => {
|
||||
const items = _getSoundAssetItems();
|
||||
if (items.length > 0) {
|
||||
const es = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => _getSoundAssetItems(),
|
||||
placeholder: t('color_strip.notification.sound.search') || 'Search sounds…',
|
||||
});
|
||||
_overrideEntitySelects.push(es);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function notificationAddAppColor() {
|
||||
_notificationAppColorsSyncFromDom();
|
||||
_notificationAppColors.push({ app: '', color: '#ffffff' });
|
||||
_notificationAppColorsRenderList();
|
||||
export function notificationAddAppOverride() {
|
||||
_overridesSyncFromDom();
|
||||
_notificationAppOverrides.push({ app: '', color: '#ffffff', sound_asset_id: null, volume: 100 });
|
||||
_overridesRenderList();
|
||||
}
|
||||
|
||||
export function notificationRemoveAppColor(i: number) {
|
||||
_notificationAppColorsSyncFromDom();
|
||||
_notificationAppColors.splice(i, 1);
|
||||
_notificationAppColorsRenderList();
|
||||
export function notificationRemoveAppOverride(i: number) {
|
||||
_overridesSyncFromDom();
|
||||
_notificationAppOverrides.splice(i, 1);
|
||||
_overridesRenderList();
|
||||
}
|
||||
|
||||
/** Split overrides into app_colors dict for the API. */
|
||||
export function notificationGetAppColorsDict() {
|
||||
_overridesSyncFromDom();
|
||||
const dict: Record<string, string> = {};
|
||||
for (const entry of _notificationAppOverrides) {
|
||||
if (entry.app.trim()) dict[entry.app.trim()] = entry.color;
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
/** Split overrides into app_sounds dict for the API. */
|
||||
export function notificationGetAppSoundsDict() {
|
||||
_overridesSyncFromDom();
|
||||
const dict: Record<string, any> = {};
|
||||
for (const entry of _notificationAppOverrides) {
|
||||
if (!entry.app.trim()) continue;
|
||||
if (entry.sound_asset_id || entry.volume !== 100) {
|
||||
dict[entry.app.trim()] = {
|
||||
sound_asset_id: entry.sound_asset_id || null,
|
||||
volume: (entry.volume ?? 100) / 100,
|
||||
};
|
||||
}
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
/* ── Notification sound — global EntitySelect ─────────────────── */
|
||||
|
||||
let _notifSoundEntitySelect: EntitySelect | null = null;
|
||||
|
||||
function _populateSoundOptions(sel: HTMLSelectElement, selectedId?: string | null) {
|
||||
const sounds = _cachedAssets.filter(a => a.asset_type === 'sound');
|
||||
sel.innerHTML = `<option value="">${t('color_strip.notification.sound.none')}</option>` +
|
||||
sounds.map(a =>
|
||||
`<option value="${a.id}"${a.id === selectedId ? ' selected' : ''}>${escapeHtml(a.name)}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
export function ensureNotifSoundEntitySelect() {
|
||||
const sel = document.getElementById('css-editor-notification-sound') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
_populateSoundOptions(sel);
|
||||
if (_notifSoundEntitySelect) _notifSoundEntitySelect.destroy();
|
||||
const items = _getSoundAssetItems();
|
||||
if (items.length > 0) {
|
||||
_notifSoundEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => _getSoundAssetItems(),
|
||||
placeholder: t('color_strip.notification.sound.search') || 'Search sounds…',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Test notification ────────────────────────────────────────── */
|
||||
|
||||
export async function testNotification(sourceId: string) {
|
||||
try {
|
||||
const resp = (await fetchWithAuth(`/color-strip-sources/${sourceId}/notify`, { method: 'POST' }))!;
|
||||
@@ -194,29 +316,24 @@ async function _loadNotificationHistory() {
|
||||
}
|
||||
}
|
||||
|
||||
function _notificationAppColorsSyncFromDom() {
|
||||
const list = document.getElementById('notification-app-colors-list') as HTMLElement | null;
|
||||
if (!list) return;
|
||||
const names = list.querySelectorAll<HTMLInputElement>('.notif-app-name');
|
||||
const colors = list.querySelectorAll<HTMLInputElement>('.notif-app-color');
|
||||
if (names.length === _notificationAppColors.length) {
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
_notificationAppColors[i].app = names[i].value;
|
||||
_notificationAppColors[i].color = colors[i].value;
|
||||
}
|
||||
}
|
||||
/* ── Load / Reset state ───────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Merge app_colors and app_sounds dicts into unified overrides list.
|
||||
* app_colors: {app: color}
|
||||
* app_sounds: {app: {sound_asset_id, volume}}
|
||||
*/
|
||||
function _mergeOverrides(appColors: Record<string, string>, appSounds: Record<string, any>): AppOverride[] {
|
||||
const appNames = new Set([...Object.keys(appColors), ...Object.keys(appSounds)]);
|
||||
return [...appNames].map(app => ({
|
||||
app,
|
||||
color: appColors[app] || '#ffffff',
|
||||
sound_asset_id: appSounds[app]?.sound_asset_id || null,
|
||||
volume: Math.round((appSounds[app]?.volume ?? 1.0) * 100),
|
||||
}));
|
||||
}
|
||||
|
||||
export function notificationGetAppColorsDict() {
|
||||
_notificationAppColorsSyncFromDom();
|
||||
const dict: Record<string, any> = {};
|
||||
for (const entry of _notificationAppColors) {
|
||||
if (entry.app.trim()) dict[entry.app.trim()] = entry.color;
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
export function loadNotificationState(css: any) {
|
||||
export async function loadNotificationState(css: any) {
|
||||
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = !!css.os_listener;
|
||||
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash';
|
||||
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
|
||||
@@ -230,15 +347,27 @@ export function loadNotificationState(css: any) {
|
||||
onNotificationFilterModeChange();
|
||||
_attachNotificationProcessPicker();
|
||||
|
||||
// App colors dict -> list
|
||||
const ac = css.app_colors || {};
|
||||
_notificationAppColors = Object.entries(ac).map(([app, color]) => ({ app, color }));
|
||||
_notificationAppColorsRenderList();
|
||||
// Ensure assets are loaded before populating sound dropdowns
|
||||
await assetsCache.fetch();
|
||||
|
||||
// Sound (global)
|
||||
const soundSel = document.getElementById('css-editor-notification-sound') as HTMLSelectElement;
|
||||
_populateSoundOptions(soundSel, css.sound_asset_id);
|
||||
if (soundSel) soundSel.value = css.sound_asset_id || '';
|
||||
ensureNotifSoundEntitySelect();
|
||||
if (_notifSoundEntitySelect && css.sound_asset_id) _notifSoundEntitySelect.setValue(css.sound_asset_id);
|
||||
const volPct = Math.round((css.sound_volume ?? 1.0) * 100);
|
||||
(document.getElementById('css-editor-notification-volume') as HTMLInputElement).value = volPct as any;
|
||||
(document.getElementById('css-editor-notification-volume-val') as HTMLElement).textContent = `${volPct}%`;
|
||||
|
||||
// Unified per-app overrides (merge app_colors + app_sounds)
|
||||
_notificationAppOverrides = _mergeOverrides(css.app_colors || {}, css.app_sounds || {});
|
||||
_overridesRenderList();
|
||||
|
||||
showNotificationEndpoint(css.id);
|
||||
}
|
||||
|
||||
export function resetNotificationState() {
|
||||
export async function resetNotificationState() {
|
||||
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = true;
|
||||
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash';
|
||||
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
|
||||
@@ -250,8 +379,19 @@ export function resetNotificationState() {
|
||||
(document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = '';
|
||||
onNotificationFilterModeChange();
|
||||
_attachNotificationProcessPicker();
|
||||
_notificationAppColors = [];
|
||||
_notificationAppColorsRenderList();
|
||||
|
||||
// Sound reset
|
||||
const soundSel = document.getElementById('css-editor-notification-sound') as HTMLSelectElement;
|
||||
_populateSoundOptions(soundSel);
|
||||
if (soundSel) soundSel.value = '';
|
||||
ensureNotifSoundEntitySelect();
|
||||
(document.getElementById('css-editor-notification-volume') as HTMLInputElement).value = 100 as any;
|
||||
(document.getElementById('css-editor-notification-volume-val') as HTMLElement).textContent = '100%';
|
||||
|
||||
// Clear overrides
|
||||
_notificationAppOverrides = [];
|
||||
_overridesRenderList();
|
||||
|
||||
showNotificationEndpoint(null);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
|
||||
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
|
||||
import { notificationGetAppColorsDict } from './color-strips-notification.ts';
|
||||
import { notificationGetAppColorsDict, notificationGetAppSoundsDict } from './color-strips-notification.ts';
|
||||
|
||||
/* ── Preview config builder ───────────────────────────────────── */
|
||||
|
||||
@@ -54,6 +54,9 @@ function _collectPreviewConfig() {
|
||||
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
||||
app_filter_list: filterList,
|
||||
app_colors: notificationGetAppColorsDict(),
|
||||
sound_asset_id: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value || null,
|
||||
sound_volume: parseInt((document.getElementById('css-editor-notification-volume') as HTMLInputElement).value) / 100,
|
||||
app_sounds: notificationGetAppSoundsDict(),
|
||||
};
|
||||
}
|
||||
const clockEl = document.getElementById('css-editor-clock') as HTMLSelectElement | null;
|
||||
|
||||
@@ -34,17 +34,19 @@ import {
|
||||
} from './color-strips-composite.ts';
|
||||
import {
|
||||
ensureNotificationEffectIconSelect, ensureNotificationFilterModeIconSelect,
|
||||
onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor,
|
||||
onNotificationFilterModeChange,
|
||||
notificationAddAppOverride, notificationRemoveAppOverride,
|
||||
notificationGetAppColorsDict, notificationGetAppSoundsDict, notificationGetRawAppOverrides,
|
||||
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||
notificationGetAppColorsDict, loadNotificationState, resetNotificationState, showNotificationEndpoint,
|
||||
notificationGetRawAppColors,
|
||||
loadNotificationState, resetNotificationState, showNotificationEndpoint,
|
||||
} from './color-strips-notification.ts';
|
||||
|
||||
// Re-export for app.js window global bindings
|
||||
export { gradientInit, gradientRenderAll, gradientAddStop };
|
||||
export { compositeAddLayer, compositeRemoveLayer };
|
||||
export {
|
||||
onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor,
|
||||
onNotificationFilterModeChange,
|
||||
notificationAddAppOverride, notificationRemoveAppOverride,
|
||||
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||
};
|
||||
export { _getAnimationPayload, _colorCycleGetColors };
|
||||
@@ -97,7 +99,7 @@ class CSSEditorModal extends Modal {
|
||||
notification_default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
|
||||
notification_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
||||
notification_filter_list: (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value,
|
||||
notification_app_colors: JSON.stringify(notificationGetRawAppColors()),
|
||||
notification_app_overrides: JSON.stringify(notificationGetRawAppOverrides()),
|
||||
clock_id: (document.getElementById('css-editor-clock') as HTMLInputElement).value,
|
||||
daylight_speed: (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value,
|
||||
daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
|
||||
@@ -1473,6 +1475,9 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
||||
app_filter_list: filterList,
|
||||
app_colors: notificationGetAppColorsDict(),
|
||||
sound_asset_id: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value || null,
|
||||
sound_volume: parseInt((document.getElementById('css-editor-notification-volume') as HTMLInputElement).value) / 100,
|
||||
app_sounds: notificationGetAppSoundsDict(),
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user