feat: asset-based image/video sources, notification sounds, UI improvements
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:
2026-03-26 20:40:25 +03:00
parent c0853ce184
commit e2e1107df7
100 changed files with 2935 additions and 992 deletions

View 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;

View File

@@ -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&#10;chrome.exe">${escapeHtml(appsValue)}</textarea>
</div>

View File

@@ -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; // 0100
}
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})">&#x2715;</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})">&#x2715;</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);
}

View File

@@ -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;

View File

@@ -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(),
};
},
},

View File

@@ -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) {