From b040c8a90c326d3762e0aef8dcac853b8320d0ab Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Tue, 5 May 2026 23:38:13 +0200 Subject: [PATCH] feat(soundtouch-web): add multi-room zone management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/soundtouch-web/spa_test.go | 2 +- pkg/service/soundtouchweb/handler.go | 253 +++++++++++++++++- pkg/service/soundtouchweb/handler_test.go | 2 +- pkg/service/soundtouchweb/static/css/app.css | 29 ++ pkg/service/soundtouchweb/static/js/api.js | 5 + pkg/service/soundtouchweb/static/js/app.js | 2 + .../static/js/components/Zone.js | 118 ++++++++ pkg/service/soundtouchweb/websocket.go | 5 +- pkg/service/soundtouchweb/webtypes/types.go | 2 +- 9 files changed, 413 insertions(+), 5 deletions(-) create mode 100644 pkg/service/soundtouchweb/static/js/components/Zone.js diff --git a/cmd/soundtouch-web/spa_test.go b/cmd/soundtouch-web/spa_test.go index 62772dd..590a7ff 100644 --- a/cmd/soundtouch-web/spa_test.go +++ b/cmd/soundtouch-web/spa_test.go @@ -348,4 +348,4 @@ func TestJSONAPIConsistency(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/pkg/service/soundtouchweb/handler.go b/pkg/service/soundtouchweb/handler.go index 7d26a3c..f00a8e7 100644 --- a/pkg/service/soundtouchweb/handler.go +++ b/pkg/service/soundtouchweb/handler.go @@ -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) } -} \ No newline at end of file +} diff --git a/pkg/service/soundtouchweb/handler_test.go b/pkg/service/soundtouchweb/handler_test.go index 27edff6..14afdd6 100644 --- a/pkg/service/soundtouchweb/handler_test.go +++ b/pkg/service/soundtouchweb/handler_test.go @@ -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" ) diff --git a/pkg/service/soundtouchweb/static/css/app.css b/pkg/service/soundtouchweb/static/css/app.css index 9196f7e..4650d31 100644 --- a/pkg/service/soundtouchweb/static/css/app.css +++ b/pkg/service/soundtouchweb/static/css/app.css @@ -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; } diff --git a/pkg/service/soundtouchweb/static/js/api.js b/pkg/service/soundtouchweb/static/js/api.js index 7537187..32709b1 100644 --- a/pkg/service/soundtouchweb/static/js/api.js +++ b/pkg/service/soundtouchweb/static/js/api.js @@ -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, diff --git a/pkg/service/soundtouchweb/static/js/app.js b/pkg/service/soundtouchweb/static/js/app.js index 2274ea6..ee9022c 100644 --- a/pkg/service/soundtouchweb/static/js/app.js +++ b/pkg/service/soundtouchweb/static/js/app.js @@ -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} /> `; diff --git a/pkg/service/soundtouchweb/static/js/components/Zone.js b/pkg/service/soundtouchweb/static/js/components/Zone.js new file mode 100644 index 0000000..cc547e3 --- /dev/null +++ b/pkg/service/soundtouchweb/static/js/components/Zone.js @@ -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` +
+
Zone
+
+
+ `; + + 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` +
+
Zone
+ + ${zone.isStandalone && html` +
+ Standalone + ${available.length > 0 && html` + + `} +
+ `} + + ${zone.isMaster && html` +
+
+ Master + ${deviceName(deviceId)} +
+ ${(zone.members || []).map(m => html` +
+ Member + ${m.name || m.ip} + +
+ `)} +
+ ${available.length > 0 && html` + + `} + +
+
+ `} + + ${zone.isSlave && html` +
+ Member + Zone: ${zone.masterName || zone.masterIp} + +
+ `} + + ${showPicker && html` +
setShowPicker(false)}> +
e.stopPropagation()}> +
Add to zone
+
+ ${available.map(([ip, d]) => html` + + `)} +
+ +
+
+ `} +
+ `; +} \ No newline at end of file diff --git a/pkg/service/soundtouchweb/websocket.go b/pkg/service/soundtouchweb/websocket.go index 517689f..a661b43 100644 --- a/pkg/service/soundtouchweb/websocket.go +++ b/pkg/service/soundtouchweb/websocket.go @@ -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() } -} \ No newline at end of file +} diff --git a/pkg/service/soundtouchweb/webtypes/types.go b/pkg/service/soundtouchweb/webtypes/types.go index 964abc5..69eceac 100644 --- a/pkg/service/soundtouchweb/webtypes/types.go +++ b/pkg/service/soundtouchweb/webtypes/types.go @@ -71,4 +71,4 @@ type WebSocketMessage struct { Type string `json:"type"` DeviceID string `json:"deviceId,omitempty"` Data interface{} `json:"data,omitempty"` -} \ No newline at end of file +}