From 1e17f4edfcd622eaeafc018694818b82dd70da62 Mon Sep 17 00:00:00 2001 From: JRpersonal Date: Sun, 9 Aug 2026 20:02:09 +0200 Subject: [PATCH] fix(app): the stereo balance is readable again, and sits where people look Three faults, one feature. The pair's master was never resolved. The agent sends that field as masterDeviceID and the app read st.master, so pair.master was empty on every pair and "ask the pair's MASTER for the balance" quietly asked whichever half happened to be selected. That is precisely what the earlier fix set out to do, so the balance stayed invisible for anyone who picked the other speaker, and the owner who reported it said so again on the very release that claimed to fix it. Both spellings are accepted now, so an older agent keeps working. It also sat next to the volume slider on the Play page, where it reads as a control that does nothing. It is a read-out: the firmware accepts no balance write that sticks. It now appears in Multi-Room, where a pair is made and undone, and in Speaker settings, with the reading and where the value can actually be changed as visible text rather than a tooltip nobody opens. And a stereo pair is no longer judged by /getZone. A pair is a firmware group made with /addGroup, and /getZone answers for a healthy one, so the new liveness check reported a working pair as standalone six seconds after it was created. Caught on two SoundTouch 10s while testing. Release-Note: fix(app): the stereo balance of a pair is shown again, in Multi-Room and in the speaker settings Release-Note: fix(multiroom): a stereo pair is no longer reported as ungrouped moments after it is created Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ --- desktop-app/frontend/src/groups.js | 7 +++- desktop-app/frontend/src/groups.test.js | 21 ++++++++++++ desktop-app/frontend/src/main.js | 4 +-- desktop-app/frontend/src/views/multiroom.js | 38 +++++++++++++++++++++ desktop-app/frontend/src/views/settings.js | 36 +++++++++++++++++++ internal/webui/zonevolume.go | 7 +++- internal/webui/zonevolume_live_test.go | 17 +++++++++ 7 files changed, 126 insertions(+), 4 deletions(-) diff --git a/desktop-app/frontend/src/groups.js b/desktop-app/frontend/src/groups.js index 5a2433ba..31da968b 100644 --- a/desktop-app/frontend/src/groups.js +++ b/desktop-app/frontend/src/groups.js @@ -280,7 +280,12 @@ export function stereoPairOf(zoneLive) { if (st && ((st.members || []).length || st.id)) { return { id: st.id || '', - master: String(st.master || '').toUpperCase(), + // The agent names this field masterDeviceID. Reading only st.master + // left pair.master empty on every pair, so "ask the pair's MASTER for + // the balance" silently asked whichever half happened to be selected, + // which is the bug that was supposed to be fixed (#70). Both spellings + // are accepted so an older agent keeps working. + master: String(st.masterDeviceID || st.master || '').toUpperCase(), members: st.members || [], }; } diff --git a/desktop-app/frontend/src/groups.test.js b/desktop-app/frontend/src/groups.test.js index f3be025b..2d42ec03 100644 --- a/desktop-app/frontend/src/groups.test.js +++ b/desktop-app/frontend/src/groups.test.js @@ -436,3 +436,24 @@ describe('balanceSourceBox', () => { expect(balanceSourceBox(right, pair, [right])).toBe(right); }); }); + +describe('stereoPairOf master field', () => { + it('reads the masterDeviceID the agent actually sends', () => { + const zoneLive = { + A: { + members: [], + stereo: { + id: 'str-grp-AA', name: 'Stereo pair', masterDeviceID: 'AA', + members: [{ deviceID: 'AA', ip: '192.0.2.1', role: 'LEFT' }, + { deviceID: 'BB', ip: '192.0.2.2', role: 'RIGHT' }], + }, + }, + }; + expect(stereoPairOf(zoneLive).master).toBe('AA'); + }); + + it('still accepts the older master spelling', () => { + const zoneLive = { A: { stereo: { id: 'x', master: 'cc', members: [] } } }; + expect(stereoPairOf(zoneLive).master).toBe('CC'); + }); +}); diff --git a/desktop-app/frontend/src/main.js b/desktop-app/frontend/src/main.js index 43eec708..aee72e9b 100644 --- a/desktop-app/frontend/src/main.js +++ b/desktop-app/frontend/src/main.js @@ -344,7 +344,7 @@ import { // before without reimplementing them. All hoisted function declarations, safe // to pass here. initRecentView({ showSlotPicker, playStation, openPick, toggleFav, isFav }); -initMultiroomView({ boxNeedsUpdate, discoverBoxes, selectBox }); +initMultiroomView({ boxNeedsUpdate, discoverBoxes, selectBox, boxFetch }); initSpotifyView({ switchView, // Live STR speaker list for the "sync Spotify login to all speakers" action. @@ -1215,7 +1215,7 @@ $('view-box').innerHTML = ` -- - +
diff --git a/desktop-app/frontend/src/views/multiroom.js b/desktop-app/frontend/src/views/multiroom.js index b8c9477e..3549b788 100644 --- a/desktop-app/frontend/src/views/multiroom.js +++ b/desktop-app/frontend/src/views/multiroom.js @@ -150,9 +150,20 @@ export function renderMultiroom(fetchLive) { }))}` : `
${escapeHtml(t('multiroom.stereoNoPair'))}
`; + // 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 + `
${escapeHtml(t('multiroom.pickHint'))}
${cards}
+ ${pairBalance}
${escapeHtml(t('multiroom.modeLabel'))}
${modeBtn('native', t('multiroom.modeNative'))}${modeBtn('mirror', t('multiroom.modeMirror'))}
@@ -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}
+ ${vol.muted ? `${escapeHtml(t('settingsView.muted'))}` : ''}
@@ -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") + } +}