`;
+ // The pair's balance belongs here, where the pair is made and undone, and
+ // nowhere near a volume slider: it is a READ-OUT, not a control. The firmware
+ // accepts no balance write that sticks (every attempt hung the endpoint until
+ // the speaker was woken), so shown beside a slider it reads as a control that
+ // is broken. An owner said exactly that: "steht neben dem Lautstaerkeregler
+ // und hat auch keinen Effekt" (2026-08-09), and #70 asked twice where it was.
+ const pairBalance = livePair
+ ? ``
+ : '';
+
root.innerHTML = beta + topbar + previewNote + updateWarn +
`
@@ -167,6 +178,8 @@ export function renderMultiroom(fetchLive) {
+
+ if (livePair) fillPairBalance(livePair, strBoxes).catch(() => {});
${escapeHtml(t('multiroom.stereoHeading'))} ${escapeHtml(t('common.alpha'))}
${escapeHtml(t('multiroom.stereoNote'))}
${canPair ? '' : `
${escapeHtml(t('multiroom.stereoNeedTwo'))}
`}
@@ -428,3 +441,28 @@ async function doDissolveZone(strBoxes) {
}
renderMultiroom(true);
}
+
+// fillPairBalance shows the pair's balance as information, with where to change
+// it, because here it cannot be changed. Asked from the pair's MASTER whichever
+// half is selected: only the master reports one (#70).
+async function fillPairBalance(pair, boxes) {
+ const el = document.getElementById('pairBalance');
+ if (!el || !pair) return;
+ const master = pairMemberBoxes(pair, boxes).map(x => x.box)
+ .find(b => b && String(b.deviceID || '').toUpperCase() === String(pair.master || '').toUpperCase());
+ const src = master || pairMemberBoxes(pair, boxes).map(x => x.box).find(Boolean);
+ if (!src || src.kind === 'stock') return;
+ let b = null;
+ try {
+ const r = await deps.boxFetch(src, '/api/box/balance');
+ b = await r.json();
+ } catch { /* asleep or unreachable: show nothing rather than an error */ }
+ if (!b || !b.available) return;
+ const v = Number(b.actual) || 0;
+ const reading = v === 0
+ ? t('controls.balanceCentre')
+ : (v < 0 ? t('controls.balanceLeft', { n: Math.abs(v) })
+ : t('controls.balanceRight', { n: v }));
+ el.textContent = reading + '. ' + t('controls.balanceTitle');
+ el.hidden = false;
+}
diff --git a/desktop-app/frontend/src/views/settings.js b/desktop-app/frontend/src/views/settings.js
index a8dab439..36634eb4 100644
--- a/desktop-app/frontend/src/views/settings.js
+++ b/desktop-app/frontend/src/views/settings.js
@@ -30,6 +30,7 @@ import { COUNTRIES, optFlag } from '../localization.js';
// as one combined error; reconstructing "how many still copied" from that
// message is a pure decision in copyreport.js (vitest-covered).
import { summarizePresetCopyError, countValidPresetSlots } from '../copyreport.js';
+import { balanceSourceBox, stereoPairOf } from '../groups.js';
import {
BoxSettings,
BoxAgentVersion,
@@ -320,6 +321,10 @@ export async function loadBoxSettings() {
}
state.settingsReconnect = null;
renderBoxSettings(s, state.settingsBox);
+ // Read-only, and only present on a stereo pair, so it is filled after the
+ // markup exists rather than being part of it.
+ refreshBoxBalanceRow(state.settingsBox, stereoPairOf(state.zoneLive || {}), state.boxes)
+ .catch(() => {});
return;
} catch (e) {
lastErr = e;
@@ -554,6 +559,9 @@ function renderBoxSettings(s, box) {
${vol.actual || 0}
@@ -2432,3 +2440,31 @@ const debouncedSetBass = debounce(async (box, defaultBass) => {
await SetBoxBass(box.host, box.port, rel + (defaultBass || 0));
} catch (e) { showError(e); }
}, 200);
+
+// The stereo balance, shown where people look for it.
+//
+// It has been on the Play page next to the volume since v0.9.35, and the owner
+// who asked for it went to Speaker settings twice and reported it missing, on
+// the very version that added it (#70, 2026-08-08). A feature nobody can find
+// is not shipped. Read-only on purpose: the firmware accepts no write that
+// sticks, which the tooltip says.
+export async function refreshBoxBalanceRow(box, pair, boxes) {
+ const row = document.getElementById('boxBalanceRow');
+ const el = document.getElementById('boxBalance');
+ if (!row || !el) return;
+ const src = balanceSourceBox(box, pair, boxes) || box;
+ if (!src || src.kind === 'stock') { row.hidden = true; return; }
+ let b = null;
+ try {
+ const r = await boxFetch(src, '/api/box/balance');
+ b = await r.json();
+ } catch { /* asleep or unreachable: show nothing rather than an error */ }
+ if (!b || !b.available) { row.hidden = true; return; }
+ const v = Number(b.actual) || 0;
+ el.textContent = v === 0
+ ? t('controls.balanceCentre')
+ : (v < 0 ? t('controls.balanceLeft', { n: Math.abs(v) })
+ : t('controls.balanceRight', { n: v }));
+ el.title = t('controls.balanceTitle');
+ row.hidden = false;
+}
diff --git a/internal/webui/zonevolume.go b/internal/webui/zonevolume.go
index 9d7dc1c5..4291637c 100644
--- a/internal/webui/zonevolume.go
+++ b/internal/webui/zonevolume.go
@@ -158,7 +158,12 @@ func (s *Server) storedGroupIsLive() bool {
func (s *Server) zoneVolumeGet(w http.ResponseWriter, r *http.Request) {
members, grouped, stereo := s.groupMembers()
- if grouped && !s.storedGroupIsLive() {
+ // A stereo pair is NOT a zone. It is a firmware group created with
+ // /addGroup, and /getZone answers for a perfectly healthy pair, so
+ // the liveness check below must never be applied to one: doing so reported
+ // a working pair as standalone seconds after it was created (caught live on
+ // two SoundTouch 10s, 2026-08-09).
+ if grouped && !stereo && !s.storedGroupIsLive() {
grouped = false
}
if !grouped {
diff --git a/internal/webui/zonevolume_live_test.go b/internal/webui/zonevolume_live_test.go
index a17105fc..a90ac4b0 100644
--- a/internal/webui/zonevolume_live_test.go
+++ b/internal/webui/zonevolume_live_test.go
@@ -43,3 +43,20 @@ func readSourceFile(name string) (string, error) {
}
func contains(hay, needle string) bool { return strings.Contains(hay, needle) }
+
+// A stereo pair must never be judged by /getZone. It is a firmware group made
+// with /addGroup, and /getZone answers for a perfectly healthy pair.
+// Applying the liveness check to one reported a working pair as standalone six
+// seconds after it was created, caught live on two SoundTouch 10s 2026-08-09:
+//
+// 19:44:48 stereo: paired id=str-grp-... members=2
+// 19:44:54 zone: the stored group is not on the speaker any more
+func TestStereoPairIsNotJudgedByGetZone(t *testing.T) {
+ src, err := readSourceFile("zonevolume.go")
+ if err != nil {
+ t.Fatalf("read source: %v", err)
+ }
+ if !contains(src, "grouped && !stereo && !s.storedGroupIsLive()") {
+ t.Error("the zone liveness check still applies to stereo pairs, which /getZone never reports")
+ }
+}