- color_cycle is now a top-level source type (alongside picture/static/gradient) with a configurable color list and cycle_speed; defaults to full rainbow spectrum - ColorCycleColorStripSource + ColorCycleColorStripStream: smooth 30 fps interpolation between user-defined colors, one full cycle every 20s at speed=1.0 - Removed color_cycle animation sub-type from StaticColorStripStream - Color cycle editor: compact horizontal swatch layout, proper module-scope fix (colorCycleAdd/Remove now exposed on window, DOM-synced before mutations) - Animation enabled + Frame interpolation checkboxes use toggle-switch style - Removed Potential FPS metric from targets and KC targets metric grids Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
623 lines
28 KiB
JavaScript
623 lines
28 KiB
JavaScript
/**
|
|
* Targets tab — combined view of devices, LED targets, KC targets, pattern templates.
|
|
*/
|
|
|
|
import {
|
|
apiKey,
|
|
_targetEditorDevices, set_targetEditorDevices,
|
|
_deviceBrightnessCache,
|
|
kcWebSockets,
|
|
} from '../core/state.js';
|
|
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
|
import { t } from '../core/i18n.js';
|
|
import { showToast, showConfirm } from '../core/ui.js';
|
|
import { Modal } from '../core/modal.js';
|
|
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness } from './devices.js';
|
|
import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
|
|
import { createColorStripCard } from './color-strips.js';
|
|
|
|
// createPatternTemplateCard is imported via window.* to avoid circular deps
|
|
// (pattern-templates.js calls window.loadTargetsTab)
|
|
|
|
// Re-render targets tab when language changes
|
|
document.addEventListener('languageChanged', () => { if (apiKey) loadTargetsTab(); });
|
|
|
|
class TargetEditorModal extends Modal {
|
|
constructor() {
|
|
super('target-editor-modal');
|
|
}
|
|
|
|
snapshotValues() {
|
|
return {
|
|
name: document.getElementById('target-editor-name').value,
|
|
device: document.getElementById('target-editor-device').value,
|
|
css: document.getElementById('target-editor-css').value,
|
|
standby_interval: document.getElementById('target-editor-standby-interval').value,
|
|
};
|
|
}
|
|
}
|
|
|
|
const targetEditorModal = new TargetEditorModal();
|
|
|
|
let _targetNameManuallyEdited = false;
|
|
|
|
function _autoGenerateTargetName() {
|
|
if (_targetNameManuallyEdited) return;
|
|
if (document.getElementById('target-editor-id').value) return;
|
|
const deviceSelect = document.getElementById('target-editor-device');
|
|
const cssSelect = document.getElementById('target-editor-css');
|
|
const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
|
|
const cssName = cssSelect.selectedOptions[0]?.dataset?.name || '';
|
|
if (!deviceName || !cssName) return;
|
|
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`;
|
|
}
|
|
|
|
function _updateStandbyVisibility() {
|
|
const deviceSelect = document.getElementById('target-editor-device');
|
|
const standbyGroup = document.getElementById('target-editor-standby-group');
|
|
const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
|
|
const caps = selectedDevice?.capabilities || [];
|
|
standbyGroup.style.display = caps.includes('standby_required') ? '' : 'none';
|
|
}
|
|
|
|
export async function showTargetEditor(targetId = null) {
|
|
try {
|
|
// Load devices and CSS sources for dropdowns
|
|
const [devicesResp, cssResp] = await Promise.all([
|
|
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
|
|
fetchWithAuth('/color-strip-sources'),
|
|
]);
|
|
|
|
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
|
|
const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : [];
|
|
set_targetEditorDevices(devices);
|
|
|
|
// Populate device select
|
|
const deviceSelect = document.getElementById('target-editor-device');
|
|
deviceSelect.innerHTML = '';
|
|
devices.forEach(d => {
|
|
const opt = document.createElement('option');
|
|
opt.value = d.id;
|
|
opt.dataset.name = d.name;
|
|
const shortUrl = d.url ? d.url.replace(/^https?:\/\//, '') : '';
|
|
const devType = (d.device_type || 'wled').toUpperCase();
|
|
opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
|
|
deviceSelect.appendChild(opt);
|
|
});
|
|
|
|
// Populate color strip source select
|
|
const cssSelect = document.getElementById('target-editor-css');
|
|
cssSelect.innerHTML = '';
|
|
cssSources.forEach(s => {
|
|
const opt = document.createElement('option');
|
|
opt.value = s.id;
|
|
opt.dataset.name = s.name;
|
|
opt.textContent = `🎞️ ${s.name}`;
|
|
cssSelect.appendChild(opt);
|
|
});
|
|
|
|
if (targetId) {
|
|
// Editing existing target
|
|
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
|
|
if (!resp.ok) throw new Error('Failed to load target');
|
|
const target = await resp.json();
|
|
|
|
document.getElementById('target-editor-id').value = target.id;
|
|
document.getElementById('target-editor-name').value = target.name;
|
|
deviceSelect.value = target.device_id || '';
|
|
cssSelect.value = target.color_strip_source_id || '';
|
|
document.getElementById('target-editor-standby-interval').value = target.standby_interval ?? 1.0;
|
|
document.getElementById('target-editor-standby-interval-value').textContent = target.standby_interval ?? 1.0;
|
|
document.getElementById('target-editor-title').textContent = t('targets.edit');
|
|
} else {
|
|
// Creating new target — first option is selected by default
|
|
document.getElementById('target-editor-id').value = '';
|
|
document.getElementById('target-editor-name').value = '';
|
|
document.getElementById('target-editor-standby-interval').value = 1.0;
|
|
document.getElementById('target-editor-standby-interval-value').textContent = '1.0';
|
|
document.getElementById('target-editor-title').textContent = t('targets.add');
|
|
}
|
|
|
|
// Auto-name generation
|
|
_targetNameManuallyEdited = !!targetId;
|
|
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
|
|
deviceSelect.onchange = () => { _updateStandbyVisibility(); _autoGenerateTargetName(); };
|
|
cssSelect.onchange = () => _autoGenerateTargetName();
|
|
if (!targetId) _autoGenerateTargetName();
|
|
|
|
// Show/hide standby interval based on selected device capabilities
|
|
_updateStandbyVisibility();
|
|
|
|
targetEditorModal.snapshot();
|
|
targetEditorModal.open();
|
|
|
|
document.getElementById('target-editor-error').style.display = 'none';
|
|
setTimeout(() => document.getElementById('target-editor-name').focus(), 100);
|
|
} catch (error) {
|
|
console.error('Failed to open target editor:', error);
|
|
showToast('Failed to open target editor', 'error');
|
|
}
|
|
}
|
|
|
|
export function isTargetEditorDirty() {
|
|
return targetEditorModal.isDirty();
|
|
}
|
|
|
|
export async function closeTargetEditorModal() {
|
|
await targetEditorModal.close();
|
|
}
|
|
|
|
export function forceCloseTargetEditorModal() {
|
|
targetEditorModal.forceClose();
|
|
}
|
|
|
|
export async function saveTargetEditor() {
|
|
const targetId = document.getElementById('target-editor-id').value;
|
|
const name = document.getElementById('target-editor-name').value.trim();
|
|
const deviceId = document.getElementById('target-editor-device').value;
|
|
const cssId = document.getElementById('target-editor-css').value;
|
|
const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value);
|
|
|
|
if (!name) {
|
|
targetEditorModal.showError(t('targets.error.name_required'));
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
name,
|
|
device_id: deviceId,
|
|
color_strip_source_id: cssId,
|
|
standby_interval: standbyInterval,
|
|
};
|
|
|
|
try {
|
|
let response;
|
|
if (targetId) {
|
|
response = await fetchWithAuth(`/picture-targets/${targetId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
} else {
|
|
payload.target_type = 'led';
|
|
response = await fetchWithAuth('/picture-targets', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const err = await response.json();
|
|
throw new Error(err.detail || 'Failed to save');
|
|
}
|
|
|
|
showToast(targetId ? t('targets.updated') : t('targets.created'), 'success');
|
|
targetEditorModal.forceClose();
|
|
await loadTargetsTab();
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
console.error('Error saving target:', error);
|
|
targetEditorModal.showError(error.message);
|
|
}
|
|
}
|
|
|
|
// ===== TARGETS TAB (WLED devices + targets combined) =====
|
|
|
|
export async function loadTargets() {
|
|
// Alias for backward compatibility
|
|
await loadTargetsTab();
|
|
}
|
|
|
|
export function switchTargetSubTab(tabKey) {
|
|
document.querySelectorAll('.target-sub-tab-btn').forEach(btn =>
|
|
btn.classList.toggle('active', btn.dataset.targetSubTab === tabKey)
|
|
);
|
|
document.querySelectorAll('.target-sub-tab-panel').forEach(panel =>
|
|
panel.classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`)
|
|
);
|
|
localStorage.setItem('activeTargetSubTab', tabKey);
|
|
}
|
|
|
|
export async function loadTargetsTab() {
|
|
const container = document.getElementById('targets-panel-content');
|
|
if (!container) return;
|
|
|
|
try {
|
|
// Fetch devices, targets, CSS sources, picture sources, and pattern templates in parallel
|
|
const [devicesResp, targetsResp, cssResp, psResp, patResp] = await Promise.all([
|
|
fetchWithAuth('/devices'),
|
|
fetchWithAuth('/picture-targets'),
|
|
fetchWithAuth('/color-strip-sources').catch(() => null),
|
|
fetchWithAuth('/picture-sources').catch(() => null),
|
|
fetchWithAuth('/pattern-templates').catch(() => null),
|
|
]);
|
|
|
|
const devicesData = await devicesResp.json();
|
|
const devices = devicesData.devices || [];
|
|
|
|
const targetsData = await targetsResp.json();
|
|
const targets = targetsData.targets || [];
|
|
|
|
let colorStripSourceMap = {};
|
|
if (cssResp && cssResp.ok) {
|
|
const cssData = await cssResp.json();
|
|
(cssData.sources || []).forEach(s => { colorStripSourceMap[s.id] = s; });
|
|
}
|
|
|
|
let pictureSourceMap = {};
|
|
if (psResp && psResp.ok) {
|
|
const psData = await psResp.json();
|
|
(psData.streams || []).forEach(s => { pictureSourceMap[s.id] = s; });
|
|
}
|
|
|
|
let patternTemplates = [];
|
|
let patternTemplateMap = {};
|
|
if (patResp && patResp.ok) {
|
|
const patData = await patResp.json();
|
|
patternTemplates = patData.templates || [];
|
|
patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
|
|
}
|
|
|
|
// Fetch state for each device
|
|
const devicesWithState = await Promise.all(
|
|
devices.map(async (device) => {
|
|
try {
|
|
const stateResp = await fetch(`${API_BASE}/devices/${device.id}/state`, { headers: getHeaders() });
|
|
const state = stateResp.ok ? await stateResp.json() : {};
|
|
return { ...device, state };
|
|
} catch {
|
|
return device;
|
|
}
|
|
})
|
|
);
|
|
|
|
// Fetch state + metrics for each target (+ colors for KC targets)
|
|
const targetsWithState = await Promise.all(
|
|
targets.map(async (target) => {
|
|
try {
|
|
const stateResp = await fetch(`${API_BASE}/picture-targets/${target.id}/state`, { headers: getHeaders() });
|
|
const state = stateResp.ok ? await stateResp.json() : {};
|
|
const metricsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() });
|
|
const metrics = metricsResp.ok ? await metricsResp.json() : {};
|
|
let latestColors = null;
|
|
if (target.target_type === 'key_colors' && state.processing) {
|
|
try {
|
|
const colorsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/colors`, { headers: getHeaders() });
|
|
if (colorsResp.ok) latestColors = await colorsResp.json();
|
|
} catch {}
|
|
}
|
|
return { ...target, state, metrics, latestColors };
|
|
} catch {
|
|
return target;
|
|
}
|
|
})
|
|
);
|
|
|
|
// Build device map for target name resolution
|
|
const deviceMap = {};
|
|
devicesWithState.forEach(d => { deviceMap[d.id] = d; });
|
|
|
|
// Group by type
|
|
const ledDevices = devicesWithState;
|
|
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
|
|
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
|
|
|
|
// Backward compat: map stored "wled" sub-tab to "led"
|
|
let activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
|
|
if (activeSubTab === 'wled') activeSubTab = 'led';
|
|
|
|
const subTabs = [
|
|
{ key: 'led', icon: '\uD83D\uDCA1', titleKey: 'targets.subtab.led', count: ledDevices.length + Object.keys(colorStripSourceMap).length + ledTargets.length },
|
|
{ key: 'key_colors', icon: '\uD83C\uDFA8', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
|
|
];
|
|
|
|
const tabBar = `<div class="stream-tab-bar">${subTabs.map(tab =>
|
|
`<button class="target-sub-tab-btn stream-tab-btn${tab.key === activeSubTab ? ' active' : ''}" data-target-sub-tab="${tab.key}" onclick="switchTargetSubTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>`
|
|
).join('')}</div>`;
|
|
|
|
// Use window.createPatternTemplateCard to avoid circular import
|
|
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
|
|
|
|
// LED panel: devices section + color strip sources section + targets section
|
|
const ledPanel = `
|
|
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
|
|
<div class="subtab-section">
|
|
<h3 class="subtab-section-header">${t('targets.section.devices')}</h3>
|
|
<div class="devices-grid">
|
|
${ledDevices.map(device => createDeviceCard(device)).join('')}
|
|
<div class="template-card add-template-card" onclick="showAddDevice()">
|
|
<div class="add-template-icon">+</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="subtab-section">
|
|
<h3 class="subtab-section-header">${t('targets.section.color_strips')}</h3>
|
|
<div class="devices-grid">
|
|
${Object.values(colorStripSourceMap).map(s => createColorStripCard(s, pictureSourceMap)).join('')}
|
|
<div class="template-card add-template-card" onclick="showCSSEditor()">
|
|
<div class="add-template-icon">+</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="subtab-section">
|
|
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
|
|
<div class="devices-grid">
|
|
${ledTargets.map(target => createTargetCard(target, deviceMap, colorStripSourceMap)).join('')}
|
|
<div class="template-card add-template-card" onclick="showTargetEditor()">
|
|
<div class="add-template-icon">+</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
// Key Colors panel
|
|
const kcPanel = `
|
|
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'key_colors' ? ' active' : ''}" id="target-sub-tab-key_colors">
|
|
<div class="subtab-section">
|
|
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
|
|
<div class="devices-grid">
|
|
${kcTargets.map(target => createKCTargetCard(target, pictureSourceMap, patternTemplateMap)).join('')}
|
|
<div class="template-card add-template-card" onclick="showKCEditor()">
|
|
<div class="add-template-icon">+</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="subtab-section">
|
|
<h3 class="subtab-section-header">${t('targets.section.pattern_templates')}</h3>
|
|
<div class="templates-grid">
|
|
${patternTemplates.map(pt => createPatternTemplateCard(pt)).join('')}
|
|
<div class="template-card add-template-card" onclick="showPatternTemplateEditor()">
|
|
<div class="add-template-icon">+</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
container.innerHTML = tabBar + ledPanel + kcPanel;
|
|
|
|
// Attach event listeners and fetch brightness for device cards
|
|
devicesWithState.forEach(device => {
|
|
attachDeviceListeners(device.id);
|
|
if ((device.capabilities || []).includes('brightness_control')) {
|
|
// Only fetch from device if we don't have a cached value yet
|
|
if (device.id in _deviceBrightnessCache) {
|
|
const bri = _deviceBrightnessCache[device.id];
|
|
const slider = document.querySelector(`[data-device-brightness="${device.id}"]`);
|
|
if (slider) {
|
|
slider.value = bri;
|
|
slider.title = Math.round(bri / 255 * 100) + '%';
|
|
slider.disabled = false;
|
|
}
|
|
const wrap = document.querySelector(`[data-brightness-wrap="${device.id}"]`);
|
|
if (wrap) wrap.classList.remove('brightness-loading');
|
|
} else {
|
|
fetchDeviceBrightness(device.id);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Manage KC WebSockets: connect for processing, disconnect for stopped
|
|
const processingKCIds = new Set();
|
|
kcTargets.forEach(target => {
|
|
if (target.state && target.state.processing) {
|
|
processingKCIds.add(target.id);
|
|
if (!kcWebSockets[target.id]) {
|
|
connectKCWebSocket(target.id);
|
|
}
|
|
}
|
|
});
|
|
// Disconnect WebSockets for targets no longer processing
|
|
Object.keys(kcWebSockets).forEach(id => {
|
|
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
|
|
});
|
|
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
console.error('Failed to load targets tab:', error);
|
|
container.innerHTML = `<div class="loading">${t('targets.failed')}</div>`;
|
|
}
|
|
}
|
|
|
|
export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
|
const state = target.state || {};
|
|
const metrics = target.metrics || {};
|
|
|
|
const isProcessing = state.processing || false;
|
|
|
|
const device = deviceMap[target.device_id];
|
|
const css = colorStripSourceMap[target.color_strip_source_id];
|
|
const deviceName = device ? device.name : (target.device_id || 'No device');
|
|
const cssName = css ? css.name : (target.color_strip_source_id || 'No strip source');
|
|
|
|
// Health info from target state (forwarded from device)
|
|
const devOnline = state.device_online || false;
|
|
let healthClass = 'health-unknown';
|
|
let healthTitle = '';
|
|
if (state.device_last_checked !== null && state.device_last_checked !== undefined) {
|
|
healthClass = devOnline ? 'health-online' : 'health-offline';
|
|
healthTitle = devOnline ? t('device.health.online') : t('device.health.offline');
|
|
}
|
|
|
|
return `
|
|
<div class="card" data-target-id="${target.id}">
|
|
<button class="card-remove-btn" onclick="deleteTarget('${target.id}')" title="${t('common.delete')}">✕</button>
|
|
<div class="card-header">
|
|
<div class="card-title">
|
|
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
|
${escapeHtml(target.name)}
|
|
${isProcessing ? `<span class="badge processing">${t('device.status.processing')}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="stream-card-props">
|
|
<span class="stream-card-prop" title="${t('targets.device')}">💡 ${escapeHtml(deviceName)}</span>
|
|
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.color_strip_source')}">🎞️ ${escapeHtml(cssName)}</span>
|
|
</div>
|
|
<div class="card-content">
|
|
${isProcessing ? `
|
|
<div class="metrics-grid">
|
|
<div class="metric">
|
|
<div class="metric-label">${t('device.metrics.actual_fps')}</div>
|
|
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="metric-label">${t('device.metrics.current_fps')}</div>
|
|
<div class="metric-value">${state.fps_current ?? '-'}</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="metric-label">${t('device.metrics.target_fps')}</div>
|
|
<div class="metric-value">${state.fps_target || 0}</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="metric-label">${t('device.metrics.frames')}</div>
|
|
<div class="metric-value">${metrics.frames_processed || 0}</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="metric-label">${t('device.metrics.keepalive')}</div>
|
|
<div class="metric-value">${state.frames_keepalive ?? '-'}</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="metric-label">${t('device.metrics.errors')}</div>
|
|
<div class="metric-value">${metrics.errors_count || 0}</div>
|
|
</div>
|
|
</div>
|
|
${state.timing_total_ms != null ? `
|
|
<div class="timing-breakdown">
|
|
<div class="timing-header">
|
|
<div class="metric-label">${t('device.metrics.timing')}</div>
|
|
<div class="timing-total"><strong>${state.timing_total_ms}ms</strong></div>
|
|
</div>
|
|
<div class="timing-bar">
|
|
<span class="timing-seg timing-extract" style="flex:${state.timing_extract_ms}" title="extract ${state.timing_extract_ms}ms"></span>
|
|
<span class="timing-seg timing-map" style="flex:${state.timing_map_leds_ms}" title="map ${state.timing_map_leds_ms}ms"></span>
|
|
<span class="timing-seg timing-smooth" style="flex:${state.timing_smooth_ms || 0.1}" title="smooth ${state.timing_smooth_ms}ms"></span>
|
|
<span class="timing-seg timing-send" style="flex:${state.timing_send_ms}" title="send ${state.timing_send_ms}ms"></span>
|
|
</div>
|
|
<div class="timing-legend">
|
|
<span class="timing-legend-item"><span class="timing-dot timing-extract"></span>extract ${state.timing_extract_ms}ms</span>
|
|
<span class="timing-legend-item"><span class="timing-dot timing-map"></span>map ${state.timing_map_leds_ms}ms</span>
|
|
<span class="timing-legend-item"><span class="timing-dot timing-smooth"></span>smooth ${state.timing_smooth_ms}ms</span>
|
|
<span class="timing-legend-item"><span class="timing-dot timing-send"></span>send ${state.timing_send_ms}ms</span>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
` : ''}
|
|
</div>
|
|
<div class="card-actions">
|
|
${isProcessing ? `
|
|
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('device.button.stop')}">
|
|
⏹️
|
|
</button>
|
|
` : `
|
|
<button class="btn btn-icon btn-primary" onclick="startTargetProcessing('${target.id}')" title="${t('device.button.start')}">
|
|
▶️
|
|
</button>
|
|
`}
|
|
<button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}">
|
|
✏️
|
|
</button>
|
|
${state.overlay_active ? `
|
|
<button class="btn btn-icon btn-warning" onclick="stopTargetOverlay('${target.id}')" title="${t('overlay.button.hide')}">
|
|
👁️
|
|
</button>
|
|
` : `
|
|
<button class="btn btn-icon btn-secondary" onclick="startTargetOverlay('${target.id}')" title="${t('overlay.button.show')}">
|
|
👁️
|
|
</button>
|
|
`}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
export async function startTargetProcessing(targetId) {
|
|
try {
|
|
const response = await fetchWithAuth(`/picture-targets/${targetId}/start`, {
|
|
method: 'POST',
|
|
});
|
|
if (response.ok) {
|
|
showToast(t('device.started'), 'success');
|
|
loadTargetsTab();
|
|
} else {
|
|
const error = await response.json();
|
|
showToast(`Failed to start: ${error.detail}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
showToast('Failed to start processing', 'error');
|
|
}
|
|
}
|
|
|
|
export async function stopTargetProcessing(targetId) {
|
|
try {
|
|
const response = await fetchWithAuth(`/picture-targets/${targetId}/stop`, {
|
|
method: 'POST',
|
|
});
|
|
if (response.ok) {
|
|
showToast(t('device.stopped'), 'success');
|
|
loadTargetsTab();
|
|
} else {
|
|
const error = await response.json();
|
|
showToast(`Failed to stop: ${error.detail}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
showToast('Failed to stop processing', 'error');
|
|
}
|
|
}
|
|
|
|
export async function startTargetOverlay(targetId) {
|
|
try {
|
|
const response = await fetchWithAuth(`/picture-targets/${targetId}/overlay/start`, {
|
|
method: 'POST',
|
|
});
|
|
if (response.ok) {
|
|
showToast(t('overlay.started'), 'success');
|
|
loadTargetsTab();
|
|
} else {
|
|
const error = await response.json();
|
|
showToast(t('overlay.error.start') + ': ' + error.detail, 'error');
|
|
}
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
showToast(t('overlay.error.start'), 'error');
|
|
}
|
|
}
|
|
|
|
export async function stopTargetOverlay(targetId) {
|
|
try {
|
|
const response = await fetchWithAuth(`/picture-targets/${targetId}/overlay/stop`, {
|
|
method: 'POST',
|
|
});
|
|
if (response.ok) {
|
|
showToast(t('overlay.stopped'), 'success');
|
|
loadTargetsTab();
|
|
} else {
|
|
const error = await response.json();
|
|
showToast(t('overlay.error.stop') + ': ' + error.detail, 'error');
|
|
}
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
showToast(t('overlay.error.stop'), 'error');
|
|
}
|
|
}
|
|
|
|
export async function deleteTarget(targetId) {
|
|
const confirmed = await showConfirm(t('targets.delete.confirm'));
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
const response = await fetchWithAuth(`/picture-targets/${targetId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
if (response.ok) {
|
|
showToast(t('targets.deleted'), 'success');
|
|
loadTargetsTab();
|
|
} else {
|
|
const error = await response.json();
|
|
showToast(`Failed to delete: ${error.detail}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
showToast('Failed to delete target', 'error');
|
|
}
|
|
}
|