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` +