From e5f5e35c01678841611b5a2787cd6d9a05d6d710 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 30 May 2026 10:48:08 +0200 Subject: [PATCH] fix(web): stabilise speaker WebSocket connection and sync stale now-playing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The speaker WebSocket was cycling every ~65 s because the gorilla pong handler was never set, so the 60-second read deadline in readLoop fired after each ping cycle (30 s interval + 5 s reconnect = ~65 s loop). Setting a pong handler that extends the deadline on every pong response keeps the connection alive indefinitely during quiet periods. After any (re)connect the Go server now immediately fetches current device state via HTTP, because Bose speakers do not replay WebSocket events on new connections — anything that changed during a disconnect window would otherwise stay stale until the next speaker-side event. A 30-second periodic HTTP poll per device is added as a backstop for Spotify Connect track changes that the SoundTouch API does not surface as nowPlayingUpdated WebSocket events. On the browser side, track identity (TrackID / ContentItem.Location) is added to the NowPlaying timer effect deps so the local counter resets whenever the track changes regardless of start position, and the time label is clamped to the song total to prevent "4:17 / 4:09" overruns. Co-Authored-By: Claude Sonnet 4.6 --- pkg/client/websocket.go | 9 +++++++++ pkg/service/soundtouchweb/discovery.go | 12 ++++++++++++ .../soundtouchweb/static/js/components/NowPlaying.js | 5 +++-- pkg/service/soundtouchweb/websocket.go | 5 +++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/pkg/client/websocket.go b/pkg/client/websocket.go index cd6c25c..71d5018 100644 --- a/pkg/client/websocket.go +++ b/pkg/client/websocket.go @@ -241,6 +241,15 @@ func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error { ws.conn = conn ws.connected = true + // Extend the read deadline on every pong so the connection survives + // quiet periods between speaker events. Without this, the 60-second + // read deadline in readLoop fires reliably after one ping cycle (30 s + // ping interval + 5 s reconnect = ~65 s disconnect loop). + conn.SetPongHandler(func(string) error { + _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) + // Start background goroutines for connection management go ws.readLoop(config) go ws.pingLoop(config) diff --git a/pkg/service/soundtouchweb/discovery.go b/pkg/service/soundtouchweb/discovery.go index 4e687cf..1cc2b24 100644 --- a/pkg/service/soundtouchweb/discovery.go +++ b/pkg/service/soundtouchweb/discovery.go @@ -71,6 +71,18 @@ func (app *WebApp) AddDeviceByHost(host string, port int, source string) { go app.UpdateDeviceStatus(host, conn) + // Poll via HTTP every 30 s as a fallback for WebSocket events that the + // speaker does not emit (e.g. Spotify Connect track changes) and for the + // window between a WS disconnect and its reconnect. + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for range ticker.C { + app.UpdateDeviceStatus(host, conn) + } + }() + log.Printf("Added %s device %s (%s) at %s:%d", sanitizeLog(source), sanitizeLog(info.Name), sanitizeLog(info.Type), sanitizeLog(host), port) } diff --git a/pkg/service/soundtouchweb/static/js/components/NowPlaying.js b/pkg/service/soundtouchweb/static/js/components/NowPlaying.js index f59b774..0a243cf 100644 --- a/pkg/service/soundtouchweb/static/js/components/NowPlaying.js +++ b/pkg/service/soundtouchweb/static/js/components/NowPlaying.js @@ -93,7 +93,8 @@ export function NowPlaying({ nowPlaying, deviceId, presets }) { if (nowPlaying?.PlayStatus !== 'PLAY_STATE') return; const id = setInterval(() => setPosition(p => p + 1), 1000); return () => clearInterval(id); - }, [nowPlaying?.Time?.Position, nowPlaying?.PlayStatus]); + }, [nowPlaying?.Time?.Position, nowPlaying?.PlayStatus, + nowPlaying?.TrackID, nowPlaying?.ContentItem?.Location]); if (!nowPlaying || nowPlaying.Source === 'STANDBY') { return html`
Standby
`; @@ -121,7 +122,7 @@ export function NowPlaying({ nowPlaying, deviceId, presets }) {
- ${fmt(position)} / ${fmt(total)} + ${fmt(Math.min(position, total))} / ${fmt(total)} `} diff --git a/pkg/service/soundtouchweb/websocket.go b/pkg/service/soundtouchweb/websocket.go index 24d722e..028c567 100644 --- a/pkg/service/soundtouchweb/websocket.go +++ b/pkg/service/soundtouchweb/websocket.go @@ -216,6 +216,11 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device log.Printf("WebSocket connected for device %s", sanitizeLog(deviceID)) + // Fetch current state immediately: speakers do not replay events on + // new WebSocket connections, so anything that changed while we were + // disconnected would otherwise stay stale until the next WS event. + go app.UpdateDeviceStatus(deviceID, conn) + // Reset backoff after a successful connect so the next failure // starts at the lowest cadence again. backoff = initialBackoff