feat(web): live planned-XML preview + reset stale form state on device switch

Two related fixes for the Plan-card → Customize-pane preview flow:

1. Live planned-XML preview. The Customize panel's "Planned Config
   (AfterTouch)" pane previously showed summary.planned_config —
   server-rendered, only updated on the next showSummary fetch. So
   editing a URL field in the Plan card had no visible effect on the
   preview until the user manually refreshed. The new
   renderPlannedXMLPreview composes the same XML client-side from
   plan-target-url + the four override inputs, mirroring exactly what
   migrateViaXML writes (target-derived defaults + applyURLOverrides),
   and is called from validatePlanURLs which already runs on every
   keystroke.

2. Per-device form-state isolation on speaker switch. The Plan card
   inputs preserve manual edits across summary refreshes (force=false)
   so a user's typed URL doesn't get clobbered by a re-fetch. That
   semantic is right within one device but wrong across devices: if
   the user edited a URL on speaker A and then picked speaker B in
   the dropdown, A's value silently appeared in B's preview.

   showSummary now compares the previous summary-device-id to the new
   one and, on change, calls resetPlanCardForDeviceSwitch to clear
   the four URL inputs, the Soundcork checkbox, the "saved" hint
   dataset, the URL-validation banner, and both apply-status lines.
   The downstream fillPlanURLInputs(defaults, force=false) then fills
   the now-empty inputs with the new device's canonical defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-11 00:37:11 +02:00
co-authored by Claude Opus 4.7
parent 967d516d4d
commit af6fe78f3f
+107
View File
@@ -1730,9 +1730,22 @@ async function showSummary(deviceId) {
const ip = summary.ip_address || deviceId;
const finalDisplay = summary.device_name ? `${summary.device_name} (${ip})` : ip;
document.getElementById("summary-device-display").innerText = finalDisplay;
// Detect a device switch BEFORE we clobber the hidden id input.
// When the user moves between speakers in the dropdown, any
// per-device form state (plan-card URL edits, the soundcork
// checkbox, the "saved" hint) belongs to the previous device
// and would otherwise leak into the new device's preview.
const prevDeviceId = document.getElementById("summary-device-id").value;
const deviceChanged = prevDeviceId && prevDeviceId !== deviceId;
// Keep deviceId hidden for subsequent calls
document.getElementById("summary-device-id").value = deviceId;
if (deviceChanged) {
resetPlanCardForDeviceSwitch();
}
// Update table row if it exists
const rowId = "device-row-" + deviceId;
const row = document.getElementById(rowId);
@@ -2590,6 +2603,11 @@ function validatePlanURLs() {
applyBtn.disabled = noPlan || errors.length > 0;
}
// Refresh the client-side XML preview so the Customize panel's
// Planned Config pane tracks every keystroke without a backend
// round-trip.
renderPlannedXMLPreview();
return errors.length === 0;
}
@@ -2603,6 +2621,46 @@ function resetPlanURLsToDefaults() {
fillPlanURLInputs(defaultServiceURLs(targetUrl, {soundcorkMode: soundcork}), {force: true});
}
// resetPlanCardForDeviceSwitch clears every per-device form state on
// the Plan card so the previously-edited values don't leak into the
// new device's preview. Called from showSummary the moment the user
// picks a different speaker in the dropdown.
//
// Doesn't refill defaults itself — that happens further down in
// showSummary via fillPlanURLInputs(defaults), which writes only into
// empty inputs.
function resetPlanCardForDeviceSwitch() {
const inputs = ["plan-marge-url", "plan-stats-url", "plan-sw_update-url", "plan-bmx-url"];
for (const id of inputs) {
const el = document.getElementById(id);
if (el) {
el.value = "";
el.style.borderColor = "";
}
}
const soundcork = document.getElementById("plan-soundcork-mode");
if (soundcork) soundcork.checked = false;
const saved = document.getElementById("plan-target-saved");
if (saved) {
saved.innerText = "";
delete saved.dataset.savedValue;
}
const validationBox = document.getElementById("plan-url-validation");
if (validationBox) {
validationBox.style.display = "none";
validationBox.replaceChildren();
}
const applyStatus = document.getElementById("plan-apply-status");
if (applyStatus) applyStatus.innerText = "";
const customizeStatus = document.getElementById("customize-apply-status");
if (customizeStatus) customizeStatus.innerText = "";
}
// toggleSoundcorkMode reapplies defaults so the /marge suffix appears
// or disappears on margeServerUrl. Manual edits are intentionally
// reset — the checkbox is a deliberate "give me the canonical
@@ -2657,6 +2715,55 @@ function computeSuggestedPlan(summary) {
};
}
// renderPlannedXMLPreview rewrites the #planned-config <pre> from the
// current Plan card inputs so the preview tracks the user's edits live
// — without a backend round-trip. The output mirrors what
// migrateViaXML actually writes: target-derived defaults, with each
// per-field override (marge_url / stats_url / sw_update_url /
// bmx_url) substituted in. The three boolean fields are constants
// matching the Go-side PrivateCfg{} the migration constructs.
//
// Pure client-side once GetMigrationSummary has populated everything,
// so the preview updates on every keystroke and stays in sync with
// validatePlanURLs's input border / Apply-button state.
function renderPlannedXMLPreview() {
const targetUrl = (document.getElementById("plan-target-url") || {}).value || "";
const overrides = readPlanURLOptions();
const defaults = defaultServiceURLs(targetUrl);
const marge = overrides.marge_url || defaults.marge;
const stats = overrides.stats_url || defaults.stats;
const swUpdate = overrides.sw_update_url || defaults.sw_update;
const bmx = overrides.bmx_url || defaults.bmx;
const xml = [
'<?xml version="1.0" encoding="utf-8"?>',
'<SoundTouchSdkPrivateCfg>',
` <margeServerUrl>${escapeForXMLText(marge)}</margeServerUrl>`,
` <statsServerUrl>${escapeForXMLText(stats)}</statsServerUrl>`,
` <swUpdateUrl>${escapeForXMLText(swUpdate)}</swUpdateUrl>`,
' <usePandoraProductionServer>true</usePandoraProductionServer>',
' <isZeroconfEnabled>true</isZeroconfEnabled>',
' <saveMargeCustomerReport>false</saveMargeCustomerReport>',
` <bmxRegistryUrl>${escapeForXMLText(bmx)}</bmxRegistryUrl>`,
'</SoundTouchSdkPrivateCfg>',
].join("\n");
const el = document.getElementById("planned-config");
if (el) el.innerText = xml;
}
// escapeForXMLText escapes the characters that have special meaning
// inside XML text content. URLs typically only need & escaping, but
// covering < and > too keeps the preview honest if the user pastes
// something exotic.
function escapeForXMLText(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// renderPlanCurrentURLs populates the "Current on Device" cells in the
// Service URLs table from whichever transport answered (telnet
// getpdo, falling back to the SSH-read XML config).