Eliminate tab reload animation after saving card properties

- CardSection._animateEntrance: skip after first render to prevent
  card fade-in replaying on every data refresh
- automations: use reconcile() on subsequent renders instead of full
  innerHTML replacement that destroyed and recreated all cards
- streams: same reconcile() approach for all 9 CardSections
- targets/dashboard/streams: only show setTabRefreshing loading bar
  on first render when the tab is empty

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 00:07:18 +03:00
parent cb779e10d3
commit ec58282c19
5 changed files with 58 additions and 37 deletions

View File

@@ -355,6 +355,8 @@ export class CardSection {
// ── private ── // ── private ──
_animateEntrance(content) { _animateEntrance(content) {
if (this._animated) return;
this._animated = true;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const selector = this.keyAttr ? `[${this.keyAttr}]` : '.card, .template-card:not(.add-template-card)'; const selector = this.keyAttr ? `[${this.keyAttr}]` : '.card, .template-card:not(.add-template-card)';
const cards = content.querySelectorAll(selector); const cards = content.querySelectorAll(selector);

View File

@@ -49,7 +49,7 @@ export async function loadAutomations() {
set_automationsLoading(true); set_automationsLoading(true);
const container = document.getElementById('automations-content'); const container = document.getElementById('automations-content');
if (!container) { set_automationsLoading(false); return; } if (!container) { set_automationsLoading(false); return; }
setTabRefreshing('automations-content', true); if (!csAutomations.isMounted()) setTabRefreshing('automations-content', true);
try { try {
const [automations, scenes] = await Promise.all([ const [automations, scenes] = await Promise.all([
@@ -85,6 +85,10 @@ function renderAutomations(automations, sceneMap) {
const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) }))); const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) })));
const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) }))); const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) })));
if (csAutomations.isMounted()) {
csAutomations.reconcile(autoItems);
csScenes.reconcile(sceneItems);
} else {
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>`; 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); container.innerHTML = toolbar + csAutomations.render(autoItems) + csScenes.render(sceneItems);
csAutomations.bind(); csAutomations.bind();
@@ -95,6 +99,7 @@ function renderAutomations(automations, sceneMap) {
el.textContent = t(el.getAttribute('data-i18n')); el.textContent = t(el.getAttribute('data-i18n'));
}); });
} }
}
function createAutomationCard(automation, sceneMap = new Map()) { function createAutomationCard(automation, sceneMap = new Map()) {
const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive'; const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive';

View File

@@ -364,7 +364,7 @@ export async function loadDashboard(forceFullRender = false) {
set_dashboardLoading(true); set_dashboardLoading(true);
const container = document.getElementById('dashboard-content'); const container = document.getElementById('dashboard-content');
if (!container) { set_dashboardLoading(false); return; } if (!container) { set_dashboardLoading(false); return; }
setTabRefreshing('dashboard-content', true); if (!container.children.length) setTabRefreshing('dashboard-content', true);
try { try {
// Fire all requests in a single batch to avoid sequential RTTs // Fire all requests in a single batch to avoid sequential RTTs

View File

@@ -1092,7 +1092,7 @@ function _tplRenderSpectrum() {
export async function loadPictureSources() { export async function loadPictureSources() {
if (_sourcesLoading) return; if (_sourcesLoading) return;
set_sourcesLoading(true); set_sourcesLoading(true);
setTabRefreshing('streams-list', true); if (!csRawStreams.isMounted()) setTabRefreshing('streams-list', true);
try { try {
const [streams] = await Promise.all([ const [streams] = await Promise.all([
streamsCache.fetch(), streamsCache.fetch(),
@@ -1368,34 +1368,48 @@ function renderPictureSourcesList(streams) {
}); });
}; };
// Build item arrays for all sections
const rawStreamItems = csRawStreams.applySortOrder(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
const rawTemplateItems = csRawTemplates.applySortOrder(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) })));
const procStreamItems = csProcStreams.applySortOrder(processedStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
const procTemplateItems = csProcTemplates.applySortOrder(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) })));
const multiItems = csAudioMulti.applySortOrder(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
const monoItems = csAudioMono.applySortOrder(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
const audioTemplateItems = csAudioTemplates.applySortOrder(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) })));
const staticItems = csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
if (csRawStreams.isMounted()) {
// Incremental update: reconcile cards in-place
tabs.forEach(tab => {
const btn = container.querySelector(`.stream-tab-btn[data-stream-tab="${tab.key}"] .stream-tab-count`);
if (btn) btn.textContent = tab.count;
});
csRawStreams.reconcile(rawStreamItems);
csRawTemplates.reconcile(rawTemplateItems);
csProcStreams.reconcile(procStreamItems);
csProcTemplates.reconcile(procTemplateItems);
csAudioMulti.reconcile(multiItems);
csAudioMono.reconcile(monoItems);
csAudioTemplates.reconcile(audioTemplateItems);
csStaticStreams.reconcile(staticItems);
csValueSources.reconcile(valueItems);
} else {
// First render: build full HTML
const panels = tabs.map(tab => { const panels = tabs.map(tab => {
let panelContent = ''; let panelContent = '';
if (tab.key === 'raw') panelContent = csRawStreams.render(rawStreamItems) + csRawTemplates.render(rawTemplateItems);
if (tab.key === 'raw') { else if (tab.key === 'processed') panelContent = csProcStreams.render(procStreamItems) + csProcTemplates.render(procTemplateItems);
panelContent = else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems) + csAudioTemplates.render(audioTemplateItems);
csRawStreams.render(csRawStreams.applySortOrder(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })))) + else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
csRawTemplates.render(csRawTemplates.applySortOrder(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) })))); else panelContent = csStaticStreams.render(staticItems);
} else if (tab.key === 'processed') {
panelContent =
csProcStreams.render(csProcStreams.applySortOrder(processedStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })))) +
csProcTemplates.render(csProcTemplates.applySortOrder(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) }))));
} else if (tab.key === 'audio') {
panelContent =
csAudioMulti.render(csAudioMulti.applySortOrder(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })))) +
csAudioMono.render(csAudioMono.applySortOrder(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })))) +
csAudioTemplates.render(csAudioTemplates.applySortOrder(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) }))));
} else if (tab.key === 'value') {
panelContent = csValueSources.render(csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) }))));
} else {
panelContent = csStaticStreams.render(csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))));
}
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`; return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).join(''); }).join('');
container.innerHTML = tabBar + panels; container.innerHTML = tabBar + panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources]); CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources]);
} }
}
export function onStreamTypeChange() { export function onStreamTypeChange() {
const streamType = document.getElementById('stream-type').value; const streamType = document.getElementById('stream-type').value;

View File

@@ -471,7 +471,7 @@ export async function loadTargetsTab() {
// Skip if another loadTargetsTab or a button action is already running // Skip if another loadTargetsTab or a button action is already running
if (_loadTargetsLock || _actionInFlight) return; if (_loadTargetsLock || _actionInFlight) return;
_loadTargetsLock = true; _loadTargetsLock = true;
setTabRefreshing('targets-panel-content', true); if (!csDevices.isMounted()) setTabRefreshing('targets-panel-content', true);
try { try {
// Fetch devices, targets, CSS sources, picture sources, pattern templates, and value sources in parallel // Fetch devices, targets, CSS sources, picture sources, pattern templates, and value sources in parallel