Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion desktop-app/frontend/src/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [],
};
}
Expand Down
21 changes: 21 additions & 0 deletions desktop-app/frontend/src/groups.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
4 changes: 2 additions & 2 deletions desktop-app/frontend/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1215,7 +1215,7 @@ $('view-box').innerHTML = `
<input type="range" id="musicVolume" min="0" max="100" step="1" aria-label="${escapeAttr(t('controls.volume'))}" title="${escapeAttr(t('controls.volumeWheelHint'))}" />
<button class="btn btn-mini vol-step" id="volUp" aria-label="${escapeAttr(t('controls.volumeUp'))}" title="${escapeAttr(t('controls.volumeUp'))}">+</button>
<span class="vol-val" id="musicVolumeVal">--</span>
<span class="vol-balance hidden" id="musicBalance"></span>

</div>
</div>
<div class="grid" id="presets"></div>
Expand Down
38 changes: 38 additions & 0 deletions desktop-app/frontend/src/views/multiroom.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,20 @@ export function renderMultiroom(fetchLive) {
}))}</div>`
: `<div class="muted small">${escapeHtml(t('multiroom.stereoNoPair'))}</div>`;

// 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
? `<div class="muted small" id="pairBalance" hidden></div>`
: '';

root.innerHTML = beta + topbar + previewNote + updateWarn +
`<div class="zone-pick-hint muted small">${escapeHtml(t('multiroom.pickHint'))}</div>
<div class="zone-cards">${cards}</div>
${pairBalance}
<div class="zone-controls">
<div class="zone-field"><span>${escapeHtml(t('multiroom.modeLabel'))}</span>
<div class="seg">${modeBtn('native', t('multiroom.modeNative'))}${modeBtn('mirror', t('multiroom.modeMirror'))}</div>
Expand All @@ -167,6 +178,8 @@ export function renderMultiroom(fetchLive) {
</div>

<div class="zone-controls" style="margin-top:22px;border-top:1px solid var(--c-border);padding-top:16px">

if (livePair) fillPairBalance(livePair, strBoxes).catch(() => {});
<b>${escapeHtml(t('multiroom.stereoHeading'))} <span class="beta-pill alpha-pill">${escapeHtml(t('common.alpha'))}</span></b>
<div class="muted small">${escapeHtml(t('multiroom.stereoNote'))}</div>
${canPair ? '' : `<div class="setup-warn small">${escapeHtml(t('multiroom.stereoNeedTwo'))}</div>`}
Expand Down Expand Up @@ -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;
}
36 changes: 36 additions & 0 deletions desktop-app/frontend/src/views/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -554,6 +559,9 @@ function renderBoxSettings(s, box) {
<input type="range" id="boxVolume" min="0" max="100" value="${vol.actual || 0}" />
<span class="setting-value" id="boxVolumeVal">${vol.actual || 0}</span>
</div>
<div class="setting-row" id="boxBalanceRow" hidden>
<span class="muted small" id="boxBalance"></span>
</div>
${vol.muted ? `<small class="muted small">${escapeHtml(t('settingsView.muted'))}</small>` : ''}
</div>

Expand Down Expand Up @@ -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;
}
7 changes: 6 additions & 1 deletion internal/webui/zonevolume.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <zone /> 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 {
Expand Down
17 changes: 17 additions & 0 deletions internal/webui/zonevolume_live_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <zone /> 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")
}
}