Add reusable DataCache class, unify frontend cache patterns

- Create DataCache class with fetch deduplication, invalidation, subscribers
- Instantiate 10 cache instances in state.js (streams, templates, sources, etc.)
- Replace inline fetch+parse+set patterns with cache.fetch() calls across modules
- Eliminate dual _scenesCache/_presetsCache sync via shared scenePresetsCache
- Remove 9 now-unused setter functions from state.js
- Clean up unused setter imports from audio-sources, value-sources, displays

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 19:35:20 +03:00
parent ff4e7f8adb
commit a34edf9650
11 changed files with 220 additions and 176 deletions

View File

@@ -10,7 +10,7 @@
* This module manages the editor modal and API operations.
*/
import { _cachedAudioSources, set_cachedAudioSources, _cachedAudioTemplates, apiKey } from '../core/state.js';
import { _cachedAudioSources, _cachedAudioTemplates, apiKey } from '../core/state.js';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js';

View File

@@ -2,7 +2,7 @@
* Automations — automation cards, editor, condition builder, process picker, scene selector.
*/
import { apiKey, _automationsCache, set_automationsCache, _automationsLoading, set_automationsLoading } from '../core/state.js';
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache } from '../core/state.js';
import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js';
@@ -10,10 +10,7 @@ import { Modal } from '../core/modal.js';
import { CardSection } from '../core/card-sections.js';
import { updateTabBadge } from './tabs.js';
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE } from '../core/icons.js';
import { csScenes, createSceneCard, updatePresetsCache } from './scene-presets.js';
// ===== Scene presets cache (shared by both selectors) =====
let _scenesCache = [];
import { csScenes, createSceneCard } from './scene-presets.js';
class AutomationEditorModal extends Modal {
constructor() { super('automation-editor-modal'); }
@@ -54,23 +51,15 @@ export async function loadAutomations() {
setTabRefreshing('automations-content', true);
try {
const [automationsResp, scenesResp] = await Promise.all([
fetchWithAuth('/automations'),
fetchWithAuth('/scene-presets'),
const [automations, scenes] = await Promise.all([
automationsCacheObj.fetch(),
scenePresetsCache.fetch(),
]);
if (!automationsResp.ok) throw new Error('Failed to load automations');
const data = await automationsResp.json();
const scenesData = scenesResp.ok ? await scenesResp.json() : { presets: [] };
_scenesCache = scenesData.presets || [];
updatePresetsCache(_scenesCache);
// Build scene name map for card rendering
const sceneMap = new Map(_scenesCache.map(s => [s.id, s]));
set_automationsCache(data.automations);
const activeCount = data.automations.filter(a => a.is_active).length;
const sceneMap = new Map(scenes.map(s => [s.id, s]));
const activeCount = automations.filter(a => a.is_active).length;
updateTabBadge('automations', activeCount);
renderAutomations(data.automations, sceneMap);
renderAutomations(automations, sceneMap);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to load automations:', error);
@@ -93,7 +82,7 @@ function renderAutomations(automations, sceneMap) {
const container = document.getElementById('automations-content');
const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) })));
const sceneItems = csScenes.applySortOrder(_scenesCache.map(s => ({ key: s.id, html: createSceneCard(s) })));
const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) })));
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllAutomationSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllAutomationSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
container.innerHTML = toolbar + csAutomations.render(autoItems) + csScenes.render(sceneItems);
@@ -205,12 +194,7 @@ export async function openAutomationEditor(automationId) {
// Fetch scenes for selector
try {
const resp = await fetchWithAuth('/scene-presets');
if (resp.ok) {
const data = await resp.json();
_scenesCache = data.presets || [];
updatePresetsCache(_scenesCache);
}
await scenePresetsCache.fetch();
} catch { /* use cached */ }
// Reset deactivation mode
@@ -288,7 +272,7 @@ function _initSceneSelector(prefix, selectedId) {
// Set initial display text
if (selectedId) {
const scene = _scenesCache.find(s => s.id === selectedId);
const scene = scenePresetsCache.data.find(s => s.id === selectedId);
searchInput.value = scene ? scene.name : '';
clearBtn.classList.toggle('visible', true);
} else {
@@ -299,7 +283,7 @@ function _initSceneSelector(prefix, selectedId) {
// Render dropdown items
function renderDropdown(filter) {
const query = (filter || '').toLowerCase();
const filtered = query ? _scenesCache.filter(s => s.name.toLowerCase().includes(query)) : _scenesCache;
const filtered = query ? scenePresetsCache.data.filter(s => s.name.toLowerCase().includes(query)) : scenePresetsCache.data;
if (filtered.length === 0) {
dropdown.innerHTML = `<div class="scene-selector-empty">${t('automations.scene.none_available')}</div>`;
@@ -314,7 +298,7 @@ function _initSceneSelector(prefix, selectedId) {
dropdown.querySelectorAll('.scene-selector-item').forEach(item => {
item.addEventListener('click', () => {
const id = item.dataset.sceneId;
const scene = _scenesCache.find(s => s.id === id);
const scene = scenePresetsCache.data.find(s => s.id === id);
hiddenInput.value = id;
searchInput.value = scene ? scene.name : '';
clearBtn.classList.toggle('visible', true);
@@ -333,7 +317,7 @@ function _initSceneSelector(prefix, selectedId) {
renderDropdown(searchInput.value);
dropdown.classList.add('open');
// If text doesn't match any scene, clear the hidden input
const exactMatch = _scenesCache.find(s => s.name.toLowerCase() === searchInput.value.toLowerCase());
const exactMatch = scenePresetsCache.data.find(s => s.name.toLowerCase() === searchInput.value.toLowerCase());
if (!exactMatch) {
hiddenInput.value = '';
clearBtn.classList.toggle('visible', !!searchInput.value);

View File

@@ -5,10 +5,9 @@
import {
_cachedDisplays, _displayPickerCallback, _displayPickerSelectedIndex,
set_displayPickerCallback, set_displayPickerSelectedIndex, set_cachedDisplays,
set_displayPickerCallback, set_displayPickerSelectedIndex, displaysCache,
} from '../core/state.js';
import { t } from '../core/i18n.js';
import { loadDisplays } from '../core/api.js';
import { fetchWithAuth } from '../core/api.js';
import { showToast } from '../core/ui.js';
@@ -32,14 +31,12 @@ export function openDisplayPicker(callback, selectedIndex, engineType = null) {
} else {
const canvas = document.getElementById('display-picker-canvas');
canvas.innerHTML = '<div class="loading-spinner"></div>';
loadDisplays().then(() => {
import('../core/state.js').then(({ _cachedDisplays: displays }) => {
if (displays && displays.length > 0) {
renderDisplayPickerLayout(displays);
} else {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
}
});
displaysCache.fetch().then(displays => {
if (displays && displays.length > 0) {
renderDisplayPickerLayout(displays);
} else {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
}
});
}
});
@@ -56,7 +53,7 @@ async function _fetchAndRenderEngineDisplays(engineType) {
const displays = data.displays || [];
// Store in cache so selectDisplay() can look them up
set_cachedDisplays(displays);
displaysCache.update(displays);
if (displays.length > 0) {
renderDisplayPickerLayout(displays, engineType);

View File

@@ -8,7 +8,7 @@ import {
_kcNameManuallyEdited, set_kcNameManuallyEdited,
kcWebSockets,
PATTERN_RECT_BORDERS,
_cachedValueSources, set_cachedValueSources,
_cachedValueSources, valueSourcesCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
@@ -413,15 +413,13 @@ function _populateKCBrightnessVsDropdown(selectedId = '') {
export async function showKCEditor(targetId = null, cloneData = null) {
try {
// Load sources, pattern templates, and value sources in parallel
const [sourcesResp, patResp, vsResp] = await Promise.all([
const [sourcesResp, patResp, valueSources] = await Promise.all([
fetchWithAuth('/picture-sources').catch(() => null),
fetchWithAuth('/pattern-templates').catch(() => null),
fetchWithAuth('/value-sources').catch(() => null),
valueSourcesCache.fetch(),
]);
const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
const patTemplates = (patResp && patResp.ok) ? (await patResp.json()).templates || [] : [];
const valueSources = (vsResp && vsResp.ok) ? (await vsResp.json()).sources || [] : [];
set_cachedValueSources(valueSources);
// Populate source select
const sourceSelect = document.getElementById('kc-editor-source');

View File

@@ -11,14 +11,11 @@ import { CardSection } from '../core/card-sections.js';
import {
ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET,
} from '../core/icons.js';
import { scenePresetsCache } from '../core/state.js';
let _presetsCache = [];
let _editingId = null;
let _allTargets = []; // fetched on capture open
/** Update the internal presets cache (called from automations tab after fetching). */
export function updatePresetsCache(presets) { _presetsCache = presets; }
class ScenePresetEditorModal extends Modal {
constructor() { super('scene-preset-editor-modal'); }
snapshotValues() {
@@ -74,15 +71,7 @@ export function createSceneCard(preset) {
// ===== Dashboard section (compact cards) =====
export async function loadScenePresets() {
try {
const resp = await fetchWithAuth('/scene-presets');
if (!resp.ok) return [];
const data = await resp.json();
_presetsCache = data.presets || [];
return _presetsCache;
} catch {
return [];
}
return scenePresetsCache.fetch();
}
export function renderScenePresetsSection(presets) {
@@ -153,7 +142,7 @@ export async function openScenePresetCapture() {
// ===== Edit metadata =====
export async function editScenePreset(presetId) {
const preset = _presetsCache.find(p => p.id === presetId);
const preset = scenePresetsCache.data.find(p => p.id === presetId);
if (!preset) return;
_editingId = presetId;
@@ -295,7 +284,7 @@ export async function activateScenePreset(presetId) {
// ===== Recapture =====
export async function recaptureScenePreset(presetId) {
const preset = _presetsCache.find(p => p.id === presetId);
const preset = scenePresetsCache.data.find(p => p.id === presetId);
const name = preset ? preset.name : presetId;
const confirmed = await showConfirm(t('scenes.recapture_confirm', { name }));
if (!confirmed) return;
@@ -319,7 +308,7 @@ export async function recaptureScenePreset(presetId) {
// ===== Delete =====
export async function deleteScenePreset(presetId) {
const preset = _presetsCache.find(p => p.id === presetId);
const preset = scenePresetsCache.data.find(p => p.id === presetId);
const name = preset ? preset.name : presetId;
const confirmed = await showConfirm(t('scenes.delete_confirm', { name }));
if (!confirmed) return;

View File

@@ -3,11 +3,12 @@
*/
import {
_cachedDisplays, set_cachedDisplays,
_cachedStreams, set_cachedStreams,
_cachedPPTemplates, set_cachedPPTemplates,
_cachedCaptureTemplates, set_cachedCaptureTemplates,
_availableFilters, set_availableFilters,
_cachedDisplays,
displaysCache,
_cachedStreams,
_cachedPPTemplates,
_cachedCaptureTemplates,
_availableFilters,
availableEngines, setAvailableEngines,
currentEditingTemplateId, setCurrentEditingTemplateId,
_templateNameManuallyEdited, set_templateNameManuallyEdited,
@@ -18,14 +19,16 @@ import {
_currentTestStreamId, set_currentTestStreamId,
_currentTestPPTemplateId, set_currentTestPPTemplateId,
_lastValidatedImageSource, set_lastValidatedImageSource,
_cachedAudioSources, set_cachedAudioSources,
_cachedValueSources, set_cachedValueSources,
_cachedAudioTemplates, set_cachedAudioTemplates,
_cachedAudioSources,
_cachedValueSources,
_cachedAudioTemplates,
availableAudioEngines, setAvailableAudioEngines,
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
_audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited,
_sourcesLoading, set_sourcesLoading,
apiKey,
streamsCache, ppTemplatesCache, captureTemplatesCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, filtersCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
@@ -153,10 +156,7 @@ const audioTemplateModal = new AudioTemplateModal();
async function loadCaptureTemplates() {
try {
const response = await fetchWithAuth('/capture-templates');
if (!response.ok) throw new Error(`Failed to load templates: ${response.status}`);
const data = await response.json();
set_cachedCaptureTemplates(data.templates || []);
await captureTemplatesCache.fetch();
renderPictureSourcesList(_cachedStreams);
} catch (error) {
if (error.isAuth) return;
@@ -397,7 +397,7 @@ async function loadDisplaysForTest() {
const response = await fetchWithAuth(url);
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
const displaysData = await response.json();
set_cachedDisplays(displaysData.displays || []);
displaysCache.update(displaysData.displays || []);
}
let selectedIndex = null;
@@ -745,10 +745,7 @@ function collectAudioEngineConfig() {
async function loadAudioTemplates() {
try {
const response = await fetchWithAuth('/audio-templates');
if (!response.ok) throw new Error(`Failed to load audio templates: ${response.status}`);
const data = await response.json();
set_cachedAudioTemplates(data.templates || []);
await audioTemplatesCache.fetch();
renderPictureSourcesList(_cachedStreams);
} catch (error) {
if (error.isAuth) return;
@@ -1096,44 +1093,16 @@ export async function loadPictureSources() {
set_sourcesLoading(true);
setTabRefreshing('streams-list', true);
try {
const [filtersResp, ppResp, captResp, streamsResp, audioResp, valueResp, audioTplResp] = await Promise.all([
_availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
fetchWithAuth('/postprocessing-templates'),
fetchWithAuth('/capture-templates'),
fetchWithAuth('/picture-sources'),
fetchWithAuth('/audio-sources'),
fetchWithAuth('/value-sources'),
fetchWithAuth('/audio-templates'),
const [streams] = await Promise.all([
streamsCache.fetch(),
ppTemplatesCache.fetch(),
captureTemplatesCache.fetch(),
audioSourcesCache.fetch(),
valueSourcesCache.fetch(),
audioTemplatesCache.fetch(),
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
]);
if (filtersResp && filtersResp.ok) {
const fd = await filtersResp.json();
set_availableFilters(fd.filters || []);
}
if (ppResp.ok) {
const pd = await ppResp.json();
set_cachedPPTemplates(pd.templates || []);
}
if (captResp.ok) {
const cd = await captResp.json();
set_cachedCaptureTemplates(cd.templates || []);
}
if (audioResp && audioResp.ok) {
const ad = await audioResp.json();
set_cachedAudioSources(ad.sources || []);
}
if (valueResp && valueResp.ok) {
const vd = await valueResp.json();
set_cachedValueSources(vd.sources || []);
}
if (audioTplResp && audioTplResp.ok) {
const atd = await audioTplResp.json();
set_cachedAudioTemplates(atd.templates || []);
}
if (!streamsResp.ok) throw new Error(`Failed to load streams: ${streamsResp.status}`);
const data = await streamsResp.json();
set_cachedStreams(data.streams || []);
renderPictureSourcesList(_cachedStreams);
renderPictureSourcesList(streams);
} catch (error) {
if (error.isAuth) return;
console.error('Error loading picture sources:', error);
@@ -1576,7 +1545,7 @@ async function populateStreamModalDropdowns() {
if (displaysRes.ok) {
const displaysData = await displaysRes.json();
set_cachedDisplays(displaysData.displays || []);
displaysCache.update(displaysData.displays || []);
}
_streamModalDisplaysEngine = null; // desktop displays loaded
@@ -1660,7 +1629,7 @@ async function _refreshStreamDisplaysForEngine(engineType) {
const resp = await fetchWithAuth(url);
if (resp.ok) {
const data = await resp.json();
set_cachedDisplays(data.displays || []);
displaysCache.update(data.displays || []);
}
} catch (error) {
console.error('Error refreshing displays for engine:', error);
@@ -1841,8 +1810,7 @@ export async function showTestPPTemplateModal(templateId) {
select.innerHTML = '';
if (_cachedStreams.length === 0) {
try {
const resp = await fetchWithAuth('/picture-sources');
if (resp.ok) { const d = await resp.json(); set_cachedStreams(d.streams || []); }
await streamsCache.fetch();
} catch (e) { console.warn('Could not load streams for PP test:', e); }
}
for (const s of _cachedStreams) {
@@ -1894,24 +1862,13 @@ export function runPPTemplateTest() {
// ===== PP Templates =====
async function loadAvailableFilters() {
try {
const response = await fetchWithAuth('/filters');
if (!response.ok) throw new Error(`Failed to load filters: ${response.status}`);
const data = await response.json();
set_availableFilters(data.filters || []);
} catch (error) {
console.error('Error loading available filters:', error);
set_availableFilters([]);
}
await filtersCache.fetch();
}
async function loadPPTemplates() {
try {
if (_availableFilters.length === 0) await loadAvailableFilters();
const response = await fetchWithAuth('/postprocessing-templates');
if (!response.ok) throw new Error(`Failed to load templates: ${response.status}`);
const data = await response.json();
set_cachedPPTemplates(data.templates || []);
if (_availableFilters.length === 0) await filtersCache.fetch();
await ppTemplatesCache.fetch();
renderPictureSourcesList(_cachedStreams);
} catch (error) {
console.error('Error loading PP templates:', error);

View File

@@ -8,7 +8,7 @@ import {
_deviceBrightnessCache,
kcWebSockets,
ledPreviewWebSockets,
_cachedValueSources, set_cachedValueSources,
_cachedValueSources, valueSourcesCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
@@ -241,18 +241,14 @@ function _populateBrightnessVsDropdown(selectedId = '') {
export async function showTargetEditor(targetId = null, cloneData = null) {
try {
// Load devices, CSS sources, and value sources for dropdowns
const [devicesResp, cssResp, vsResp] = await Promise.all([
const [devicesResp, cssResp] = await Promise.all([
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
fetchWithAuth('/color-strip-sources'),
fetchWithAuth('/value-sources'),
valueSourcesCache.fetch(),
]);
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : [];
if (vsResp.ok) {
const vsData = await vsResp.json();
set_cachedValueSources(vsData.sources || []);
}
set_targetEditorDevices(devices);
_editorCssSources = cssSources;
@@ -478,13 +474,13 @@ export async function loadTargetsTab() {
try {
// Fetch devices, targets, CSS sources, picture sources, pattern templates, and value sources in parallel
const [devicesResp, targetsResp, cssResp, psResp, patResp, vsResp, asResp] = await Promise.all([
const [devicesResp, targetsResp, cssResp, psResp, patResp, valueSrcArr, asResp] = await Promise.all([
fetchWithAuth('/devices'),
fetchWithAuth('/picture-targets'),
fetchWithAuth('/color-strip-sources').catch(() => null),
fetchWithAuth('/picture-sources').catch(() => null),
fetchWithAuth('/pattern-templates').catch(() => null),
fetchWithAuth('/value-sources').catch(() => null),
valueSourcesCache.fetch().catch(() => []),
fetchWithAuth('/audio-sources').catch(() => null),
]);
@@ -515,10 +511,7 @@ export async function loadTargetsTab() {
}
let valueSourceMap = {};
if (vsResp && vsResp.ok) {
const vsData = await vsResp.json();
(vsData.sources || []).forEach(s => { valueSourceMap[s.id] = s; });
}
valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; });
let audioSourceMap = {};
if (asResp && asResp.ok) {

View File

@@ -10,7 +10,7 @@
* This module manages the editor modal and API operations.
*/
import { _cachedValueSources, set_cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey } from '../core/state.js';
import { _cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey } from '../core/state.js';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';