fix(web): stabilise speaker WebSocket connection and sync stale now-playing

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 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-30 10:57:14 +02:00
co-authored by Claude Sonnet 4.6
parent c521414eb1
commit e5f5e35c01
4 changed files with 29 additions and 2 deletions
+9
View File
@@ -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)
+12
View File
@@ -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)
}
@@ -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`<div class="now-playing standby">Standby</div>`;
@@ -121,7 +122,7 @@ export function NowPlaying({ nowPlaying, deviceId, presets }) {
<div class="progress-bar">
<div class="progress-fill" style="width:${pct}%"></div>
</div>
<span class="progress-time">${fmt(position)} / ${fmt(total)}</span>
<span class="progress-time">${fmt(Math.min(position, total))} / ${fmt(total)}</span>
</div>
`}
</div>
+5
View File
@@ -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