+
@@ -2978,3 +2962,773 @@ async function deleteTemplate(templateId) {
showToast(t('templates.error.delete') + ': ' + error.message, 'error');
}
}
+
+// ===== Picture Streams =====
+
+let _cachedStreams = [];
+let _cachedPPTemplates = [];
+
+async function loadPictureStreams() {
+ try {
+ const response = await fetchWithAuth('/picture-streams');
+ if (!response.ok) {
+ throw new Error(`Failed to load streams: ${response.status}`);
+ }
+ const data = await response.json();
+ _cachedStreams = data.streams || [];
+ renderPictureStreamsList(_cachedStreams);
+ } catch (error) {
+ console.error('Error loading picture streams:', error);
+ document.getElementById('streams-list').innerHTML = `
+
${t('streams.error.load')}: ${error.message}
+ `;
+ }
+}
+
+function renderPictureStreamsList(streams) {
+ const container = document.getElementById('streams-list');
+
+ if (streams.length === 0) {
+ container.innerHTML = `
+
+
+
${t('streams.add')}
+
`;
+ return;
+ }
+
+ const renderCard = (stream) => {
+ const typeIcon = stream.stream_type === 'raw' ? '📷' : '🎨';
+ const typeBadge = stream.stream_type === 'raw'
+ ? `
${t('streams.type.raw')}`
+ : `
${t('streams.type.processed')}`;
+
+ let detailsHtml = '';
+ if (stream.stream_type === 'raw') {
+ detailsHtml = `
+
+ ${t('streams.display')} ${stream.display_index ?? 0}
+
+
+ ${t('streams.target_fps')} ${stream.target_fps ?? 30}
+
+ `;
+ } else {
+ // Find source stream name and PP template name
+ const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
+ const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
+ detailsHtml = `
+
+ ${t('streams.source')} ${sourceName}
+
+ `;
+ }
+
+ return `
+
+
+
+ ${detailsHtml}
+ ${stream.description ? `
${escapeHtml(stream.description)}
` : ''}
+
+
+
+
+
+ `;
+ };
+
+ let html = streams.map(renderCard).join('');
+ html += `
+
+
+
${t('streams.add')}
+
`;
+
+ container.innerHTML = html;
+}
+
+function onStreamTypeChange() {
+ const streamType = document.getElementById('stream-type').value;
+ document.getElementById('stream-raw-fields').style.display = streamType === 'raw' ? '' : 'none';
+ document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none';
+}
+
+async function showAddStreamModal() {
+ document.getElementById('stream-modal-title').textContent = t('streams.add');
+ document.getElementById('stream-form').reset();
+ document.getElementById('stream-id').value = '';
+ document.getElementById('stream-error').style.display = 'none';
+ document.getElementById('stream-type').disabled = false;
+
+ // Reset to raw type
+ document.getElementById('stream-type').value = 'raw';
+ onStreamTypeChange();
+
+ // Populate dropdowns
+ await populateStreamModalDropdowns();
+
+ const modal = document.getElementById('stream-modal');
+ modal.style.display = 'flex';
+ lockBody();
+ modal.onclick = (e) => { if (e.target === modal) closeStreamModal(); };
+}
+
+async function editStream(streamId) {
+ try {
+ const response = await fetchWithAuth(`/picture-streams/${streamId}`);
+ if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
+ const stream = await response.json();
+
+ document.getElementById('stream-modal-title').textContent = t('streams.edit');
+ document.getElementById('stream-id').value = streamId;
+ document.getElementById('stream-name').value = stream.name;
+ document.getElementById('stream-description').value = stream.description || '';
+ document.getElementById('stream-error').style.display = 'none';
+
+ // Set type and disable changing it for existing streams
+ document.getElementById('stream-type').value = stream.stream_type;
+ document.getElementById('stream-type').disabled = true;
+ onStreamTypeChange();
+
+ // Populate dropdowns before setting values
+ await populateStreamModalDropdowns();
+
+ if (stream.stream_type === 'raw') {
+ document.getElementById('stream-display-index').value = String(stream.display_index ?? 0);
+ document.getElementById('stream-capture-template').value = stream.capture_template_id || '';
+ const fps = stream.target_fps ?? 30;
+ document.getElementById('stream-target-fps').value = fps;
+ document.getElementById('stream-target-fps-value').textContent = fps;
+ } else {
+ document.getElementById('stream-source').value = stream.source_stream_id || '';
+ document.getElementById('stream-pp-template').value = stream.postprocessing_template_id || '';
+ }
+
+ const modal = document.getElementById('stream-modal');
+ modal.style.display = 'flex';
+ lockBody();
+ modal.onclick = (e) => { if (e.target === modal) closeStreamModal(); };
+ } catch (error) {
+ console.error('Error loading stream:', error);
+ showToast(t('streams.error.load') + ': ' + error.message, 'error');
+ }
+}
+
+async function populateStreamModalDropdowns() {
+ // Load displays, capture templates, streams, and PP templates in parallel
+ const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([
+ fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
+ fetchWithAuth('/capture-templates'),
+ fetchWithAuth('/picture-streams'),
+ fetchWithAuth('/postprocessing-templates'),
+ ]);
+
+ // Displays
+ const displaySelect = document.getElementById('stream-display-index');
+ displaySelect.innerHTML = '';
+ if (displaysRes.ok) {
+ const displaysData = await displaysRes.json();
+ (displaysData.displays || []).forEach(d => {
+ const opt = document.createElement('option');
+ opt.value = d.index;
+ opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`;
+ displaySelect.appendChild(opt);
+ });
+ }
+ if (displaySelect.options.length === 0) {
+ const opt = document.createElement('option');
+ opt.value = '0';
+ opt.textContent = '0';
+ displaySelect.appendChild(opt);
+ }
+
+ // Capture templates
+ const templateSelect = document.getElementById('stream-capture-template');
+ templateSelect.innerHTML = '';
+ if (captureTemplatesRes.ok) {
+ const data = await captureTemplatesRes.json();
+ (data.templates || []).forEach(tmpl => {
+ const opt = document.createElement('option');
+ opt.value = tmpl.id;
+ opt.textContent = `${getEngineIcon(tmpl.engine_type)} ${tmpl.name}`;
+ templateSelect.appendChild(opt);
+ });
+ }
+
+ // Source streams (all existing streams)
+ const sourceSelect = document.getElementById('stream-source');
+ sourceSelect.innerHTML = '';
+ if (streamsRes.ok) {
+ const data = await streamsRes.json();
+ const editingId = document.getElementById('stream-id').value;
+ (data.streams || []).forEach(s => {
+ // Don't show the current stream as a possible source
+ if (s.id === editingId) return;
+ const opt = document.createElement('option');
+ opt.value = s.id;
+ const typeLabel = s.stream_type === 'raw' ? '📷' : '🎨';
+ opt.textContent = `${typeLabel} ${s.name}`;
+ sourceSelect.appendChild(opt);
+ });
+ }
+
+ // PP templates
+ const ppSelect = document.getElementById('stream-pp-template');
+ ppSelect.innerHTML = '';
+ if (ppTemplatesRes.ok) {
+ const data = await ppTemplatesRes.json();
+ (data.templates || []).forEach(tmpl => {
+ const opt = document.createElement('option');
+ opt.value = tmpl.id;
+ opt.textContent = tmpl.name;
+ ppSelect.appendChild(opt);
+ });
+ }
+}
+
+async function saveStream() {
+ const streamId = document.getElementById('stream-id').value;
+ const name = document.getElementById('stream-name').value.trim();
+ const streamType = document.getElementById('stream-type').value;
+ const description = document.getElementById('stream-description').value.trim();
+ const errorEl = document.getElementById('stream-error');
+
+ if (!name) {
+ showToast(t('streams.error.required'), 'error');
+ return;
+ }
+
+ const payload = { name, description: description || null };
+
+ if (!streamId) {
+ // Creating - include stream_type
+ payload.stream_type = streamType;
+ }
+
+ if (streamType === 'raw') {
+ payload.display_index = parseInt(document.getElementById('stream-display-index').value) || 0;
+ payload.capture_template_id = document.getElementById('stream-capture-template').value;
+ payload.target_fps = parseInt(document.getElementById('stream-target-fps').value) || 30;
+ } else {
+ payload.source_stream_id = document.getElementById('stream-source').value;
+ payload.postprocessing_template_id = document.getElementById('stream-pp-template').value;
+ }
+
+ try {
+ let response;
+ if (streamId) {
+ response = await fetchWithAuth(`/picture-streams/${streamId}`, {
+ method: 'PUT',
+ body: JSON.stringify(payload)
+ });
+ } else {
+ response = await fetchWithAuth('/picture-streams', {
+ method: 'POST',
+ body: JSON.stringify(payload)
+ });
+ }
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || error.message || 'Failed to save stream');
+ }
+
+ showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
+ closeStreamModal();
+ await loadPictureStreams();
+ } catch (error) {
+ console.error('Error saving stream:', error);
+ errorEl.textContent = error.message;
+ errorEl.style.display = 'block';
+ }
+}
+
+async function deleteStream(streamId) {
+ const confirmed = await showConfirm(t('streams.delete.confirm'));
+ if (!confirmed) return;
+
+ try {
+ const response = await fetchWithAuth(`/picture-streams/${streamId}`, {
+ method: 'DELETE'
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || error.message || 'Failed to delete stream');
+ }
+
+ showToast(t('streams.deleted'), 'success');
+ await loadPictureStreams();
+ } catch (error) {
+ console.error('Error deleting stream:', error);
+ showToast(t('streams.error.delete') + ': ' + error.message, 'error');
+ }
+}
+
+function closeStreamModal() {
+ document.getElementById('stream-modal').style.display = 'none';
+ document.getElementById('stream-type').disabled = false;
+ unlockBody();
+}
+
+// ===== Picture Stream Test =====
+
+let _currentTestStreamId = null;
+
+async function showTestStreamModal(streamId) {
+ _currentTestStreamId = streamId;
+ restoreStreamTestDuration();
+ document.getElementById('test-stream-results').style.display = 'none';
+
+ const modal = document.getElementById('test-stream-modal');
+ modal.style.display = 'flex';
+ lockBody();
+ modal.onclick = (e) => { if (e.target === modal) closeTestStreamModal(); };
+}
+
+function closeTestStreamModal() {
+ document.getElementById('test-stream-modal').style.display = 'none';
+ unlockBody();
+ _currentTestStreamId = null;
+}
+
+function updateStreamTestDuration(value) {
+ document.getElementById('test-stream-duration-value').textContent = value;
+ localStorage.setItem('lastStreamTestDuration', value);
+}
+
+function restoreStreamTestDuration() {
+ const saved = localStorage.getItem('lastStreamTestDuration') || '5';
+ document.getElementById('test-stream-duration').value = saved;
+ document.getElementById('test-stream-duration-value').textContent = saved;
+}
+
+async function runStreamTest() {
+ if (!_currentTestStreamId) return;
+
+ const captureDuration = parseFloat(document.getElementById('test-stream-duration').value);
+
+ showOverlaySpinner(t('streams.test.running'), captureDuration);
+
+ try {
+ const response = await fetchWithAuth(`/picture-streams/${_currentTestStreamId}/test`, {
+ method: 'POST',
+ body: JSON.stringify({ capture_duration: captureDuration })
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || error.message || 'Test failed');
+ }
+
+ const result = await response.json();
+ displayStreamTestResults(result);
+ } catch (error) {
+ console.error('Error running stream test:', error);
+ hideOverlaySpinner();
+ showToast(t('streams.test.error.failed') + ': ' + error.message, 'error');
+ }
+}
+
+function displayStreamTestResults(result) {
+ hideOverlaySpinner();
+
+ const previewImg = document.getElementById('test-stream-preview-image');
+ previewImg.innerHTML = `

`;
+
+ document.getElementById('test-stream-actual-duration').textContent = `${result.performance.capture_duration_s.toFixed(2)}s`;
+ document.getElementById('test-stream-frame-count').textContent = result.performance.frame_count;
+ document.getElementById('test-stream-actual-fps').textContent = `${result.performance.actual_fps.toFixed(1)} FPS`;
+ document.getElementById('test-stream-avg-capture-time').textContent = `${result.performance.avg_capture_time_ms.toFixed(1)}ms`;
+
+ document.getElementById('test-stream-results').style.display = 'block';
+}
+
+// ===== Processing Templates =====
+
+async function loadPPTemplates() {
+ try {
+ const response = await fetchWithAuth('/postprocessing-templates');
+ if (!response.ok) {
+ throw new Error(`Failed to load templates: ${response.status}`);
+ }
+ const data = await response.json();
+ _cachedPPTemplates = data.templates || [];
+ renderPPTemplatesList(_cachedPPTemplates);
+ } catch (error) {
+ console.error('Error loading PP templates:', error);
+ document.getElementById('pp-templates-list').innerHTML = `
+
${t('postprocessing.error.load')}: ${error.message}
+ `;
+ }
+}
+
+function renderPPTemplatesList(templates) {
+ const container = document.getElementById('pp-templates-list');
+
+ if (templates.length === 0) {
+ container.innerHTML = `
+
+
+
${t('postprocessing.add')}
+
`;
+ return;
+ }
+
+ const renderCard = (tmpl) => {
+ const configEntries = {
+ [t('postprocessing.gamma')]: tmpl.gamma,
+ [t('postprocessing.saturation')]: tmpl.saturation,
+ [t('postprocessing.brightness')]: tmpl.brightness,
+ [t('postprocessing.smoothing')]: tmpl.smoothing,
+ };
+
+ return `
+
+
+
+ ${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''}
+
+ ${t('postprocessing.config.show')}
+
+ ${Object.entries(configEntries).map(([key, val]) => `
+
+ | ${escapeHtml(key)} |
+ ${escapeHtml(String(val))} |
+
+ `).join('')}
+
+
+
+
+
+
+ `;
+ };
+
+ let html = templates.map(renderCard).join('');
+ html += `
+
+
+
${t('postprocessing.add')}
+
`;
+
+ container.innerHTML = html;
+}
+
+async function showAddPPTemplateModal() {
+ document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add');
+ document.getElementById('pp-template-form').reset();
+ document.getElementById('pp-template-id').value = '';
+ document.getElementById('pp-template-error').style.display = 'none';
+
+ // Reset slider displays to defaults
+ document.getElementById('pp-template-gamma').value = '2.2';
+ document.getElementById('pp-template-gamma-value').textContent = '2.2';
+ document.getElementById('pp-template-saturation').value = '1.0';
+ document.getElementById('pp-template-saturation-value').textContent = '1.0';
+ document.getElementById('pp-template-brightness').value = '1.0';
+ document.getElementById('pp-template-brightness-value').textContent = '1.0';
+ document.getElementById('pp-template-smoothing').value = '0.3';
+ document.getElementById('pp-template-smoothing-value').textContent = '0.3';
+
+ const modal = document.getElementById('pp-template-modal');
+ modal.style.display = 'flex';
+ lockBody();
+ modal.onclick = (e) => { if (e.target === modal) closePPTemplateModal(); };
+}
+
+async function editPPTemplate(templateId) {
+ try {
+ const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
+ if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
+ const tmpl = await response.json();
+
+ document.getElementById('pp-template-modal-title').textContent = t('postprocessing.edit');
+ document.getElementById('pp-template-id').value = templateId;
+ document.getElementById('pp-template-name').value = tmpl.name;
+ document.getElementById('pp-template-description').value = tmpl.description || '';
+ document.getElementById('pp-template-error').style.display = 'none';
+
+ // Set sliders
+ document.getElementById('pp-template-gamma').value = tmpl.gamma;
+ document.getElementById('pp-template-gamma-value').textContent = tmpl.gamma;
+ document.getElementById('pp-template-saturation').value = tmpl.saturation;
+ document.getElementById('pp-template-saturation-value').textContent = tmpl.saturation;
+ document.getElementById('pp-template-brightness').value = tmpl.brightness;
+ document.getElementById('pp-template-brightness-value').textContent = tmpl.brightness;
+ document.getElementById('pp-template-smoothing').value = tmpl.smoothing;
+ document.getElementById('pp-template-smoothing-value').textContent = tmpl.smoothing;
+
+ const modal = document.getElementById('pp-template-modal');
+ modal.style.display = 'flex';
+ lockBody();
+ modal.onclick = (e) => { if (e.target === modal) closePPTemplateModal(); };
+ } catch (error) {
+ console.error('Error loading PP template:', error);
+ showToast(t('postprocessing.error.load') + ': ' + error.message, 'error');
+ }
+}
+
+async function savePPTemplate() {
+ const templateId = document.getElementById('pp-template-id').value;
+ const name = document.getElementById('pp-template-name').value.trim();
+ const description = document.getElementById('pp-template-description').value.trim();
+ const errorEl = document.getElementById('pp-template-error');
+
+ if (!name) {
+ showToast(t('postprocessing.error.required'), 'error');
+ return;
+ }
+
+ const payload = {
+ name,
+ gamma: parseFloat(document.getElementById('pp-template-gamma').value),
+ saturation: parseFloat(document.getElementById('pp-template-saturation').value),
+ brightness: parseFloat(document.getElementById('pp-template-brightness').value),
+ smoothing: parseFloat(document.getElementById('pp-template-smoothing').value),
+ description: description || null,
+ };
+
+ try {
+ let response;
+ if (templateId) {
+ response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, {
+ method: 'PUT',
+ body: JSON.stringify(payload)
+ });
+ } else {
+ response = await fetchWithAuth('/postprocessing-templates', {
+ method: 'POST',
+ body: JSON.stringify(payload)
+ });
+ }
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || error.message || 'Failed to save template');
+ }
+
+ showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
+ closePPTemplateModal();
+ await loadPPTemplates();
+ } catch (error) {
+ console.error('Error saving PP template:', error);
+ errorEl.textContent = error.message;
+ errorEl.style.display = 'block';
+ }
+}
+
+async function deletePPTemplate(templateId) {
+ const confirmed = await showConfirm(t('postprocessing.delete.confirm'));
+ if (!confirmed) return;
+
+ try {
+ const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, {
+ method: 'DELETE'
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || error.message || 'Failed to delete template');
+ }
+
+ showToast(t('postprocessing.deleted'), 'success');
+ await loadPPTemplates();
+ } catch (error) {
+ console.error('Error deleting PP template:', error);
+ showToast(t('postprocessing.error.delete') + ': ' + error.message, 'error');
+ }
+}
+
+function closePPTemplateModal() {
+ document.getElementById('pp-template-modal').style.display = 'none';
+ unlockBody();
+}
+
+// ===== Device Stream Selector =====
+
+let streamSelectorInitialValues = {};
+
+async function showStreamSelector(deviceId) {
+ try {
+ const [deviceResponse, streamsResponse, settingsResponse] = await Promise.all([
+ fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
+ fetchWithAuth('/picture-streams'),
+ fetch(`${API_BASE}/devices/${deviceId}/settings`, { headers: getHeaders() }),
+ ]);
+
+ if (deviceResponse.status === 401) {
+ handle401Error();
+ return;
+ }
+
+ if (!deviceResponse.ok) {
+ showToast('Failed to load device', 'error');
+ return;
+ }
+
+ const device = await deviceResponse.json();
+ const settings = settingsResponse.ok ? await settingsResponse.json() : {};
+
+ // Populate stream select
+ const streamSelect = document.getElementById('stream-selector-stream');
+ streamSelect.innerHTML = '';
+
+ if (streamsResponse.ok) {
+ const data = await streamsResponse.json();
+ (data.streams || []).forEach(s => {
+ const opt = document.createElement('option');
+ opt.value = s.id;
+ const typeIcon = s.stream_type === 'raw' ? '📷' : '🎨';
+ opt.textContent = `${typeIcon} ${s.name}`;
+ streamSelect.appendChild(opt);
+ });
+ }
+
+ const currentStreamId = device.picture_stream_id || '';
+ streamSelect.value = currentStreamId;
+
+ // Populate LED projection fields
+ const borderWidth = settings.border_width ?? device.settings?.border_width ?? 10;
+ document.getElementById('stream-selector-border-width').value = borderWidth;
+ document.getElementById('stream-selector-interpolation').value = device.settings?.interpolation_mode || 'average';
+
+ streamSelectorInitialValues = {
+ stream: currentStreamId,
+ border_width: String(borderWidth),
+ interpolation: device.settings?.interpolation_mode || 'average',
+ };
+
+ document.getElementById('stream-selector-device-id').value = deviceId;
+ document.getElementById('stream-selector-error').style.display = 'none';
+
+ // Show info about selected stream
+ updateStreamSelectorInfo(streamSelect.value);
+ streamSelect.onchange = () => updateStreamSelectorInfo(streamSelect.value);
+
+ const modal = document.getElementById('stream-selector-modal');
+ modal.style.display = 'flex';
+ lockBody();
+ modal.onclick = (e) => { if (e.target === modal) closeStreamSelectorModal(); };
+ } catch (error) {
+ console.error('Failed to load stream settings:', error);
+ showToast('Failed to load stream settings', 'error');
+ }
+}
+
+async function updateStreamSelectorInfo(streamId) {
+ const infoPanel = document.getElementById('stream-selector-info');
+ if (!streamId) {
+ infoPanel.style.display = 'none';
+ return;
+ }
+
+ try {
+ const response = await fetchWithAuth(`/picture-streams/${streamId}`);
+ if (!response.ok) {
+ infoPanel.style.display = 'none';
+ return;
+ }
+ const stream = await response.json();
+
+ let infoHtml = `
${t('streams.type')} ${stream.stream_type === 'raw' ? t('streams.type.raw') : t('streams.type.processed')}
`;
+
+ if (stream.stream_type === 'raw') {
+ infoHtml += `
${t('streams.display')} ${stream.display_index ?? 0}
`;
+ infoHtml += `
${t('streams.target_fps')} ${stream.target_fps ?? 30}
`;
+ } else {
+ const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
+ infoHtml += `
${t('streams.source')} ${sourceStream ? escapeHtml(sourceStream.name) : stream.source_stream_id}
`;
+ }
+
+ infoPanel.innerHTML = infoHtml;
+ infoPanel.style.display = '';
+ } catch {
+ infoPanel.style.display = 'none';
+ }
+}
+
+async function saveStreamSelector() {
+ const deviceId = document.getElementById('stream-selector-device-id').value;
+ const pictureStreamId = document.getElementById('stream-selector-stream').value;
+ const borderWidth = parseInt(document.getElementById('stream-selector-border-width').value) || 10;
+ const interpolation = document.getElementById('stream-selector-interpolation').value;
+ const errorEl = document.getElementById('stream-selector-error');
+
+ try {
+ // Save picture stream assignment
+ const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
+ method: 'PUT',
+ headers: getHeaders(),
+ body: JSON.stringify({ picture_stream_id: pictureStreamId })
+ });
+
+ if (response.status === 401) {
+ handle401Error();
+ return;
+ }
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || error.message || 'Failed to save');
+ }
+
+ // Save LED projection settings — merge with existing to avoid overwriting other fields
+ const currentSettingsRes = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { headers: getHeaders() });
+ const currentSettings = currentSettingsRes.ok ? await currentSettingsRes.json() : {};
+ const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
+ method: 'PUT',
+ headers: getHeaders(),
+ body: JSON.stringify({ ...currentSettings, border_width: borderWidth, interpolation_mode: interpolation })
+ });
+
+ if (!settingsResponse.ok) {
+ const error = await settingsResponse.json();
+ throw new Error(error.detail || error.message || 'Failed to save settings');
+ }
+
+ showToast(t('device.stream_selector.saved'), 'success');
+ forceCloseStreamSelectorModal();
+ await loadDevices();
+ } catch (error) {
+ console.error('Error saving stream settings:', error);
+ errorEl.textContent = error.message;
+ errorEl.style.display = 'block';
+ }
+}
+
+function isStreamSettingsDirty() {
+ return (
+ document.getElementById('stream-selector-stream').value !== streamSelectorInitialValues.stream ||
+ document.getElementById('stream-selector-border-width').value !== streamSelectorInitialValues.border_width ||
+ document.getElementById('stream-selector-interpolation').value !== streamSelectorInitialValues.interpolation
+ );
+}
+
+async function closeStreamSelectorModal() {
+ if (isStreamSettingsDirty()) {
+ const confirmed = await showConfirm(t('modal.discard_changes'));
+ if (!confirmed) return;
+ }
+ forceCloseStreamSelectorModal();
+}
+
+function forceCloseStreamSelectorModal() {
+ document.getElementById('stream-selector-modal').style.display = 'none';
+ document.getElementById('stream-selector-error').style.display = 'none';
+ unlockBody();
+ streamSelectorInitialValues = {};
+}
diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html
index 1b10473..2ad89f2 100644
--- a/server/src/wled_controller/static/index.html
+++ b/server/src/wled_controller/static/index.html
@@ -36,7 +36,9 @@
+
+
+
+
+
+ Picture streams define the capture pipeline. A raw stream captures from a display using a capture template. A processed stream applies postprocessing to another stream. Assign streams to devices.
+
+
+
+
+
+
+