feat(soundtouch-web): add multi-room zone management

Backend:
- GET /api/zone/{id} — zone info enriched with device names and role flags
- POST /api/zone/{id}/add/{slaveId} — add slave (creates zone if standalone)
- POST /api/zone/{id}/remove/{slaveId} — remove slave from zone
- POST /api/zone/{id}/dissolve — dissolve zone to standalone
- POST /api/zone/{id}/leave — slave leaves its zone (backend finds master)

Frontend (Zone.js):
- Standalone: shows "Group with…" button, opens device picker overlay
- Master: member list with per-row Remove, Add speaker, Dissolve buttons
- Slave: shows master name, Leave zone button
- Lazy-loads on device detail open; refreshes after each zone operation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-08 21:22:57 +02:00
co-authored by Claude Sonnet 4.6
parent 3122c4ed3a
commit b040c8a90c
9 changed files with 413 additions and 5 deletions
+1 -1
View File
@@ -348,4 +348,4 @@ func TestJSONAPIConsistency(t *testing.T) {
}
})
}
}
}
+252 -1
View File
@@ -83,14 +83,18 @@ func (app *WebApp) Mount(r chi.Router) {
r.Get("/api/device/{id}", app.HandleAPIDevice)
r.Post("/api/discover", func(w http.ResponseWriter, r *http.Request) {
app.HandleAPIDiscover(w, r)
go func() {
cfg, err := config.LoadFromEnv()
if err != nil {
cfg = config.DefaultConfig()
}
cfg.DiscoveryTimeout = 10 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
app.BroadcastDiscoveryStatus("starting", len(app.Devices))
app.discoverDevices(ctx, discovery.NewUnifiedDiscoveryService(cfg))
app.BroadcastDiscoveryStatus("completed", len(app.Devices))
@@ -112,6 +116,11 @@ func (app *WebApp) Mount(r chi.Router) {
r.Get("/api/device-power-status/{id}", app.HandleDevicePowerStatus)
r.Get("/api/device-recents/{id}", app.HandleDeviceRecents)
r.Post("/api/device-play/{id}", app.HandleDevicePlay)
r.Get("/api/zone/{id}", app.HandleGetZone)
r.Post("/api/zone/{id}/add/{slaveId}", app.HandleZoneAdd)
r.Post("/api/zone/{id}/remove/{slaveId}", app.HandleZoneRemove)
r.Post("/api/zone/{id}/dissolve", app.HandleZoneDissolve)
r.Post("/api/zone/{id}/leave", app.HandleZoneLeave)
r.Get("/api/device-ws/{id}", app.HandleDeviceWebSocket)
r.Get("/", app.serveIndex)
@@ -121,6 +130,7 @@ func (app *WebApp) Mount(r chi.Router) {
func (app *WebApp) serveIndex(w http.ResponseWriter, _ *http.Request) {
data, _ := staticFS.ReadFile("static/index.html")
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write(data)
}
@@ -132,6 +142,7 @@ func (app *WebApp) discoverDevices(ctx context.Context, discoveryService *discov
if err != nil {
log.Printf("Discovery failed: %v", err)
app.BroadcastDiscoveryStatus("failed", len(app.Devices))
return
}
@@ -257,30 +268,35 @@ func (app *WebApp) handleControlAction(w http.ResponseWriter, r *http.Request, a
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
app.sendControlResponse(w, device.Client.Play(), "Started playback")
case "pause":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
app.sendControlResponse(w, device.Client.Pause(), "Paused playback")
case "stop":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
app.sendControlResponse(w, device.Client.Stop(), "Stopped playback")
case "next":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
app.sendControlResponse(w, device.Client.NextTrack(), "Next track")
case "previous":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
app.sendControlResponse(w, device.Client.PrevTrack(), "Previous track")
case "volume":
app.handleVolumeControl(w, r, device)
@@ -289,6 +305,7 @@ func (app *WebApp) handleControlAction(w http.ResponseWriter, r *http.Request, a
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
app.sendControlResponse(w, device.Client.SendKey(models.KeyMute), "Toggled mute")
case "preset":
app.handlePresetControl(w, r, device)
@@ -602,6 +619,240 @@ func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request)
}
}
// findIPByHwID returns the map key (IP) for the device whose hardware ID matches hwID.
func (app *WebApp) findIPByHwID(hwID string) string {
for ip, conn := range app.Devices {
if conn.DeviceInfo != nil && conn.DeviceInfo.DeviceID == hwID {
return ip
}
}
return ""
}
// HandleGetZone returns zone info for a device, enriched with device names.
func (app *WebApp) HandleGetZone(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
device, exists := app.Devices[deviceID]
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
zone, err := device.Client.GetZone()
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
currentHwID := ""
if device.DeviceInfo != nil {
currentHwID = device.DeviceInfo.DeviceID
}
masterIP := app.findIPByHwID(zone.Master)
masterName := ""
if conn, ok := app.Devices[masterIP]; ok && conn.DeviceInfo != nil {
masterName = conn.DeviceInfo.Name
}
type memberInfo struct {
IP string `json:"ip"`
HwID string `json:"hwId"`
Name string `json:"name"`
}
members := make([]memberInfo, 0, len(zone.Members))
for _, m := range zone.Members {
name := ""
if conn, ok := app.Devices[m.IP]; ok && conn.DeviceInfo != nil {
name = conn.DeviceInfo.Name
}
members = append(members, memberInfo{IP: m.IP, HwID: m.DeviceID, Name: name})
}
isMaster := zone.Master == currentHwID && !zone.IsStandalone()
isSlave := false
for _, m := range zone.Members {
if m.DeviceID == currentHwID {
isSlave = true
break
}
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{
Success: true,
Data: map[string]interface{}{
"masterIp": masterIP,
"masterHwId": zone.Master,
"masterName": masterName,
"members": members,
"isMaster": isMaster,
"isSlave": isSlave,
"isStandalone": !isMaster && !isSlave,
},
}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleZoneAdd adds a slave device to the zone where {id} is or becomes the master.
func (app *WebApp) HandleZoneAdd(w http.ResponseWriter, r *http.Request) {
masterIP := chi.URLParam(r, "id")
slaveIP := chi.URLParam(r, "slaveId")
masterConn, ok := app.Devices[masterIP]
if !ok {
app.sendError(w, "Master device not found", http.StatusNotFound)
return
}
slaveConn, ok := app.Devices[slaveIP]
if !ok {
app.sendError(w, "Slave device not found", http.StatusNotFound)
return
}
if masterConn.Client == nil || masterConn.DeviceInfo == nil || slaveConn.DeviceInfo == nil {
app.sendError(w, "Device not ready", http.StatusInternalServerError)
return
}
masterHwID := masterConn.DeviceInfo.DeviceID
slaveHwID := slaveConn.DeviceInfo.DeviceID
zone, err := masterConn.Client.GetZone()
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
var zoneReq *models.ZoneRequest
if zone.IsStandalone() {
zoneReq = models.NewZoneRequest(masterHwID)
} else {
zoneReq = zone.ToZoneRequest()
}
zoneReq.AddMember(slaveHwID, slaveIP)
w.Header().Set("Content-Type", "application/json")
app.sendControlResponse(w, masterConn.Client.SetZone(zoneReq), "Device added to zone")
}
// HandleZoneRemove removes a slave from the zone.
func (app *WebApp) HandleZoneRemove(w http.ResponseWriter, r *http.Request) {
masterIP := chi.URLParam(r, "id")
slaveIP := chi.URLParam(r, "slaveId")
masterConn, ok := app.Devices[masterIP]
if !ok {
app.sendError(w, "Master device not found", http.StatusNotFound)
return
}
slaveConn, ok := app.Devices[slaveIP]
if !ok {
app.sendError(w, "Slave device not found", http.StatusNotFound)
return
}
if masterConn.Client == nil || slaveConn.DeviceInfo == nil {
app.sendError(w, "Device not ready", http.StatusInternalServerError)
return
}
zone, err := masterConn.Client.GetZone()
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
zoneReq := zone.ToZoneRequest()
zoneReq.RemoveMember(slaveConn.DeviceInfo.DeviceID)
w.Header().Set("Content-Type", "application/json")
app.sendControlResponse(w, masterConn.Client.SetZone(zoneReq), "Device removed from zone")
}
// HandleZoneDissolve dissolves the zone, making all devices standalone.
func (app *WebApp) HandleZoneDissolve(w http.ResponseWriter, r *http.Request) {
masterIP := chi.URLParam(r, "id")
masterConn, ok := app.Devices[masterIP]
if !ok {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
if masterConn.Client == nil || masterConn.DeviceInfo == nil {
app.sendError(w, "Device not ready", http.StatusInternalServerError)
return
}
zoneReq := models.NewZoneRequest(masterConn.DeviceInfo.DeviceID)
w.Header().Set("Content-Type", "application/json")
app.sendControlResponse(w, masterConn.Client.SetZone(zoneReq), "Zone dissolved")
}
// HandleZoneLeave removes the calling device from its zone (slave perspective).
func (app *WebApp) HandleZoneLeave(w http.ResponseWriter, r *http.Request) {
slaveIP := chi.URLParam(r, "id")
slaveConn, ok := app.Devices[slaveIP]
if !ok {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
if slaveConn.Client == nil || slaveConn.DeviceInfo == nil {
app.sendError(w, "Device not ready", http.StatusInternalServerError)
return
}
zone, err := slaveConn.Client.GetZone()
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
masterIP := app.findIPByHwID(zone.Master)
if masterIP == "" {
app.sendError(w, "Zone master not found in device list", http.StatusNotFound)
return
}
masterConn, ok := app.Devices[masterIP]
if !ok || masterConn.Client == nil {
app.sendError(w, "Master device not available", http.StatusInternalServerError)
return
}
masterZone, err := masterConn.Client.GetZone()
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
zoneReq := masterZone.ToZoneRequest()
zoneReq.RemoveMember(slaveConn.DeviceInfo.DeviceID)
w.Header().Set("Content-Type", "application/json")
app.sendControlResponse(w, masterConn.Client.SetZone(zoneReq), "Left zone")
}
// HandleDeviceRecents returns recently played items for a device.
func (app *WebApp) HandleDeviceRecents(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
@@ -748,4 +999,4 @@ func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
}
+1 -1
View File
@@ -9,9 +9,9 @@ import (
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
"github.com/go-chi/chi/v5"
)
@@ -296,6 +296,35 @@ img { display: block; max-width: 100%; }
.source-icon { font-size: .9rem; line-height: 1; }
.source-name { font-weight: 500; }
/* ── Zone ────────────────────────────────────────────────────────────────── */
.zone-section { margin-top: 1.25rem; }
.zone-row { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.zone-status-label { font-size: .875rem; color: var(--text-dim); }
.zone-members { display: flex; flex-direction: column; gap: .3rem; }
.zone-member {
display: flex; align-items: center; gap: .6rem;
padding: .4rem .6rem;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius);
}
.zone-master-row { background: var(--bg); }
.zone-badge {
font-size: .65rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .05em; padding: .15rem .4rem; border-radius: 3px; flex-shrink: 0;
}
.zone-badge.master { background: var(--accent); color: var(--accent-fg); }
.zone-badge.slave { background: var(--border); color: var(--text-dim); }
.zone-member-name { flex: 1; font-size: .875rem; }
.zone-remove { font-size: .75rem; color: var(--text-dim); padding: .15rem .35rem; }
.zone-remove:hover { color: var(--text); }
.zone-actions { display: flex; gap: .5rem; margin-top: .25rem; flex-wrap: wrap; }
.zone-btn { font-size: .8rem; padding: .3rem .7rem; }
/* ── Recents ─────────────────────────────────────────────────────────────── */
.recents-section { margin-top: 1.25rem; }
@@ -18,6 +18,11 @@ export const api = {
}),
power: (id) => req(`/api/device-power/${id}`, { method: 'POST' }),
recents: (id) => req(`/api/device-recents/${id}`),
zone: (id) => req(`/api/zone/${id}`),
zoneAdd: (masterId, slaveId) => req(`/api/zone/${masterId}/add/${slaveId}`, { method: 'POST' }),
zoneRemove: (masterId, slaveId) => req(`/api/zone/${masterId}/remove/${slaveId}`, { method: 'POST' }),
zoneDissolve: (id) => req(`/api/zone/${id}/dissolve`, { method: 'POST' }),
zoneLeave: (id) => req(`/api/zone/${id}/leave`, { method: 'POST' }),
play: (id, item) => req(`/api/device-play/${id}`, {
method: 'POST',
headers: JSON_HEADERS,
@@ -6,6 +6,7 @@ import { NowPlaying } from './components/NowPlaying.js';
import { Controls } from './components/Controls.js';
import { Presets } from './components/Presets.js';
import { Sources } from './components/Sources.js';
import { Zone } from './components/Zone.js';
import { Recents } from './components/Recents.js';
import { TuneInBrowser } from './components/TuneInBrowser.js';
import { api } from './api.js';
@@ -35,6 +36,7 @@ function DeviceDetail({ deviceId, devices, onBack }) {
<${Controls} deviceId=${deviceId} status=${device.status} />
<${Presets} deviceId=${deviceId} status=${device.status} />
<${Sources} deviceId=${deviceId} status=${device.status} />
<${Zone} deviceId=${deviceId} devices=${devices} />
<${Recents} deviceId=${deviceId} />
</div>
`;
@@ -0,0 +1,118 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import htm from 'htm';
import { api } from '../api.js';
const html = htm.bind(h);
export function Zone({ deviceId, devices }) {
const [zone, setZone] = useState(null);
const [loading, setLoading] = useState(true);
const [showPicker, setShowPicker] = useState(false);
function refresh() {
api.zone(deviceId).then(resp => {
if (resp.success) setZone(resp.data);
}).finally(() => setLoading(false));
}
useEffect(() => { refresh(); }, [deviceId]);
async function addDevice(slaveId) {
setShowPicker(false);
await api.zoneAdd(deviceId, slaveId);
refresh();
}
async function removeDevice(slaveId) {
await api.zoneRemove(deviceId, slaveId);
refresh();
}
async function dissolve() {
await api.zoneDissolve(deviceId);
refresh();
}
async function leave() {
await api.zoneLeave(deviceId);
refresh();
}
if (loading) return html`
<div class="zone-section">
<div class="section-title">Zone</div>
<div class="loading-bar"></div>
</div>
`;
if (!zone) return null;
// Devices not already in the zone are available to add
const zoneIps = new Set([zone.masterIp, ...(zone.members || []).map(m => m.ip)].filter(Boolean));
const available = Object.entries(devices || {}).filter(([ip]) => !zoneIps.has(ip));
const deviceName = (ip) => devices[ip]?.info?.Name ?? ip;
return html`
<div class="zone-section">
<div class="section-title">Zone</div>
${zone.isStandalone && html`
<div class="zone-row">
<span class="zone-status-label">Standalone</span>
${available.length > 0 && html`
<button class="btn-secondary zone-btn" onClick=${() => setShowPicker(true)}>+ Group with</button>
`}
</div>
`}
${zone.isMaster && html`
<div class="zone-members">
<div class="zone-member zone-master-row">
<span class="zone-badge master">Master</span>
<span class="zone-member-name">${deviceName(deviceId)}</span>
</div>
${(zone.members || []).map(m => html`
<div class="zone-member" key=${m.ip}>
<span class="zone-badge slave">Member</span>
<span class="zone-member-name">${m.name || m.ip}</span>
<button class="btn-icon zone-remove" title="Remove from zone"
onClick=${() => removeDevice(m.ip)}></button>
</div>
`)}
<div class="zone-actions">
${available.length > 0 && html`
<button class="btn-secondary zone-btn" onClick=${() => setShowPicker(true)}>+ Add speaker</button>
`}
<button class="btn-secondary zone-btn" onClick=${dissolve}>Dissolve zone</button>
</div>
</div>
`}
${zone.isSlave && html`
<div class="zone-row">
<span class="zone-badge slave">Member</span>
<span class="zone-member-name">Zone: ${zone.masterName || zone.masterIp}</span>
<button class="btn-secondary zone-btn" onClick=${leave}>Leave zone</button>
</div>
`}
${showPicker && html`
<div class="overlay" onClick=${() => setShowPicker(false)}>
<div class="device-picker" onClick=${e => e.stopPropagation()}>
<div class="picker-title">Add to zone</div>
<div class="picker-devices">
${available.map(([ip, d]) => html`
<button class="picker-device-btn" key=${ip} onClick=${() => addDevice(ip)}>
${d.info?.Name ?? ip}
</button>
`)}
</div>
<button class="btn-secondary picker-cancel" onClick=${() => setShowPicker(false)}>Cancel</button>
</div>
</div>
`}
</div>
`;
}
+4 -1
View File
@@ -56,6 +56,7 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
go func() {
defer conn.Close()
for {
if _, _, err := conn.NextReader(); err != nil {
log.Printf("WebSocket read error: %v", err)
@@ -226,6 +227,7 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
go func() {
defer conn.Close()
for {
if _, _, err := conn.NextReader(); err != nil {
log.Printf("Device WebSocket read error for %s: %v", deviceID, err)
@@ -305,6 +307,7 @@ func (app *WebApp) broadcast(msg webtypes.WebSocketMessage) {
for client := range app.WSClients {
if err := client.WriteJSON(msg); err != nil {
log.Printf("Failed to broadcast to WebSocket client: %v", err)
failed = append(failed, client)
}
}
@@ -313,4 +316,4 @@ func (app *WebApp) broadcast(msg webtypes.WebSocketMessage) {
delete(app.WSClients, client)
client.Close()
}
}
}
+1 -1
View File
@@ -71,4 +71,4 @@ type WebSocketMessage struct {
Type string `json:"type"`
DeviceID string `json:"deviceId,omitempty"`
Data interface{} `json:"data,omitempty"`
}
}