Add Picture Streams architecture with postprocessing templates and stream test UI

Introduce Picture Stream abstraction that separates the capture pipeline into
composable layers: raw streams (display + capture engine + FPS) and processed
streams (source stream + postprocessing template). Devices reference a picture
stream instead of managing individual capture settings.

- Add PictureStream and PostprocessingTemplate data models and stores
- Add CRUD API endpoints for picture streams and postprocessing templates
- Add stream chain resolution in ProcessorManager for start_processing
- Add picture stream test endpoint with postprocessing preview support
- Add Stream Settings modal with border_width and interpolation_mode controls
- Add stream test modal with capture preview and performance metrics
- Add full frontend: Picture Streams tab, Processing Templates tab, stream
  selector on device cards, test buttons on stream cards
- Add localization keys for all new features (en, ru)
- Migrate existing devices to picture streams on startup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 00:00:30 +03:00
parent 3db7ba4b0e
commit 493f14fba9
23 changed files with 2773 additions and 200 deletions

View File

@@ -347,6 +347,12 @@ function switchTab(name) {
if (name === 'templates') {
loadCaptureTemplates();
}
if (name === 'streams') {
loadPictureStreams();
}
if (name === 'pp-templates') {
loadPPTemplates();
}
}
function initTabs() {
@@ -625,8 +631,8 @@ function createDeviceCard(device) {
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
⚙️
</button>
<button class="btn btn-icon btn-secondary" onclick="showCaptureSettings('${device.id}')" title="${t('device.button.capture_settings')}">
🎬
<button class="btn btn-icon btn-secondary" onclick="showStreamSelector('${device.id}')" title="${t('device.button.stream_selector')}">
📺
</button>
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
📐
@@ -930,20 +936,14 @@ async function showCaptureSettings(deviceId) {
templateSelect.appendChild(opt);
});
}
if (templateSelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = 'tpl_mss_default';
opt.textContent = 'MSS (Default)';
templateSelect.appendChild(opt);
}
templateSelect.value = device.capture_template_id || 'tpl_mss_default';
templateSelect.value = device.capture_template_id || '';
// Store device ID, current settings snapshot, and initial values for dirty check
document.getElementById('capture-settings-device-id').value = device.id;
captureSettingsInitialValues = {
display_index: String(device.settings.display_index ?? 0),
fps: String(currentSettings.fps ?? 30),
capture_template_id: device.capture_template_id || 'tpl_mss_default',
capture_template_id: device.capture_template_id || '',
_currentSettings: currentSettings,
};
@@ -2359,25 +2359,16 @@ function renderTemplatesList(templates) {
return;
}
const defaultTemplates = templates.filter(t => t.is_default);
const customTemplates = templates.filter(t => !t.is_default);
const renderCard = (template) => {
const engineIcon = getEngineIcon(template.engine_type);
const defaultBadge = template.is_default
? `<span class="badge badge-default">${t('templates.default')}</span>`
: '';
return `
<div class="template-card" data-template-id="${template.id}">
${!template.is_default ? `
<button class="card-remove-btn" onclick="deleteTemplate('${template.id}')" title="${t('common.delete')}">&#x2715;</button>
` : ''}
<button class="card-remove-btn" onclick="deleteTemplate('${template.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">
${engineIcon} ${escapeHtml(template.name)}
</div>
${defaultBadge}
</div>
<div class="template-config">
<strong>${t('templates.engine')}</strong> ${template.engine_type.toUpperCase()}
@@ -2401,22 +2392,15 @@ function renderTemplatesList(templates) {
<button class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">
🧪
</button>
${!template.is_default ? `
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">
✏️
</button>
` : ''}
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
};
let html = defaultTemplates.map(renderCard).join('');
if (customTemplates.length > 0) {
html += `<div class="templates-separator"><span>${t('templates.custom')}</span></div>`;
html += customTemplates.map(renderCard).join('');
}
let html = templates.map(renderCard).join('');
html += `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
<div class="add-template-icon">+</div>
@@ -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 = `
<div class="error-message">${t('streams.error.load')}: ${error.message}</div>
`;
}
}
function renderPictureStreamsList(streams) {
const container = document.getElementById('streams-list');
if (streams.length === 0) {
container.innerHTML = `<div class="template-card add-template-card" onclick="showAddStreamModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('streams.add')}</div>
</div>`;
return;
}
const renderCard = (stream) => {
const typeIcon = stream.stream_type === 'raw' ? '📷' : '🎨';
const typeBadge = stream.stream_type === 'raw'
? `<span class="badge badge-raw">${t('streams.type.raw')}</span>`
: `<span class="badge badge-processed">${t('streams.type.processed')}</span>`;
let detailsHtml = '';
if (stream.stream_type === 'raw') {
detailsHtml = `
<div class="template-config">
<strong>${t('streams.display')}</strong> ${stream.display_index ?? 0}
</div>
<div class="template-config">
<strong>${t('streams.target_fps')}</strong> ${stream.target_fps ?? 30}
</div>
`;
} 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 = `
<div class="template-config">
<strong>${t('streams.source')}</strong> ${sourceName}
</div>
`;
}
return `
<div class="template-card" data-stream-id="${stream.id}">
<button class="card-remove-btn" onclick="deleteStream('${stream.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">
${typeIcon} ${escapeHtml(stream.name)}
</div>
${typeBadge}
</div>
${detailsHtml}
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestStreamModal('${stream.id}')" title="${t('streams.test.title')}">
🧪
</button>
<button class="btn btn-icon btn-secondary" onclick="editStream('${stream.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
};
let html = streams.map(renderCard).join('');
html += `<div class="template-card add-template-card" onclick="showAddStreamModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('streams.add')}</div>
</div>`;
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 = `<img src="${result.full_capture.image}" alt="Stream preview" style="max-width: 100%; border-radius: 4px;">`;
document.getElementById('test-stream-actual-duration').textContent = `${result.performance.capture_duration_s.toFixed(2)}s`;
document.getElementById('test-stream-frame-count').textContent = result.performance.frame_count;
document.getElementById('test-stream-actual-fps').textContent = `${result.performance.actual_fps.toFixed(1)} FPS`;
document.getElementById('test-stream-avg-capture-time').textContent = `${result.performance.avg_capture_time_ms.toFixed(1)}ms`;
document.getElementById('test-stream-results').style.display = 'block';
}
// ===== Processing Templates =====
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 = `
<div class="error-message">${t('postprocessing.error.load')}: ${error.message}</div>
`;
}
}
function renderPPTemplatesList(templates) {
const container = document.getElementById('pp-templates-list');
if (templates.length === 0) {
container.innerHTML = `<div class="template-card add-template-card" onclick="showAddPPTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('postprocessing.add')}</div>
</div>`;
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 `
<div class="template-card" data-pp-template-id="${tmpl.id}">
<button class="card-remove-btn" onclick="deletePPTemplate('${tmpl.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">
🎨 ${escapeHtml(tmpl.name)}
</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
<details class="template-config-details">
<summary>${t('postprocessing.config.show')}</summary>
<table class="config-table">
${Object.entries(configEntries).map(([key, val]) => `
<tr>
<td class="config-key">${escapeHtml(key)}</td>
<td class="config-value">${escapeHtml(String(val))}</td>
</tr>
`).join('')}
</table>
</details>
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
};
let html = templates.map(renderCard).join('');
html += `<div class="template-card add-template-card" onclick="showAddPPTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('postprocessing.add')}</div>
</div>`;
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 = `<div class="stream-info-type"><strong>${t('streams.type')}</strong> ${stream.stream_type === 'raw' ? t('streams.type.raw') : t('streams.type.processed')}</div>`;
if (stream.stream_type === 'raw') {
infoHtml += `<div><strong>${t('streams.display')}</strong> ${stream.display_index ?? 0}</div>`;
infoHtml += `<div><strong>${t('streams.target_fps')}</strong> ${stream.target_fps ?? 30}</div>`;
} else {
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
infoHtml += `<div><strong>${t('streams.source')}</strong> ${sourceStream ? escapeHtml(sourceStream.name) : stream.source_stream_id}</div>`;
}
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 = {};
}