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") + } +}