Skip to content

Commit 2df9f84

Browse files
committed
fix: reconnect stalled radio streams
1 parent 4ce486b commit 2df9f84

7 files changed

Lines changed: 153 additions & 10 deletions

File tree

internal/audio/player.go

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,16 @@ import (
1919
// internal — mpv echoes them back on each property-change event so the
2020
// translator can route by id without parsing names.
2121
const (
22-
propIDPause = 1
23-
propIDVolume = 2
24-
propIDMetadata = 3
25-
propIDMediaTitle = 4
26-
propIDIdleActive = 5
27-
propIDAudioBitr = 6
28-
propIDAudioCodec = 7
29-
propIDAudioParams = 8
30-
propIDCacheState = 9
22+
propIDPause = 1
23+
propIDVolume = 2
24+
propIDMetadata = 3
25+
propIDMediaTitle = 4
26+
propIDIdleActive = 5
27+
propIDAudioBitr = 6
28+
propIDAudioCodec = 7
29+
propIDAudioParams = 8
30+
propIDCacheState = 9
31+
propIDPausedForCache = 10
3132
)
3233

3334
// Event is the sealed interface implemented by every value emitted on
@@ -79,13 +80,21 @@ type CacheStateChanged struct {
7980
Seconds float64
8081
}
8182

83+
// BufferingChanged reports mpv's paused-for-cache state. It becomes true
84+
// when playback is no longer advancing because the demuxer is waiting for
85+
// network data (a common post-sleep failure mode for live radio streams).
86+
type BufferingChanged struct {
87+
Stalled bool
88+
}
89+
8290
func (MetadataChanged) isEvent() {}
8391
func (PlaybackStarted) isEvent() {}
8492
func (PlaybackPaused) isEvent() {}
8593
func (PlaybackError) isEvent() {}
8694
func (EOF) isEvent() {}
8795
func (StreamInfoChanged) isEvent() {}
8896
func (CacheStateChanged) isEvent() {}
97+
func (BufferingChanged) isEvent() {}
8998

9099
// Options controls Player startup.
91100
type Options struct {
@@ -250,6 +259,7 @@ func NewPlayer(ctx context.Context, opts Options) (*Player, error) {
250259
{propIDAudioCodec, "audio-codec-name"},
251260
{propIDAudioParams, "audio-params"},
252261
{propIDCacheState, "demuxer-cache-state"},
262+
{propIDPausedForCache, "paused-for-cache"},
253263
}
254264
for _, prop := range properties {
255265
if err := ipc.observe(ctx, prop.id, prop.name); err != nil {
@@ -470,6 +480,11 @@ func (p *Player) translatePropertyChange(raw ipcEvent) Event {
470480
}
471481
p.lastCacheSec = state.CacheDuration
472482
return CacheStateChanged{Seconds: state.CacheDuration}
483+
case "paused-for-cache":
484+
var stalled bool
485+
if err := json.Unmarshal(raw.Data, &stalled); err == nil {
486+
return BufferingChanged{Stalled: stalled}
487+
}
473488
}
474489
return nil
475490
}

internal/audio/player_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,22 @@ func TestTranslateOne_PlaybackRestart(t *testing.T) {
155155
}
156156
}
157157

158+
func TestTranslateOne_PausedForCache(t *testing.T) {
159+
p := &Player{}
160+
e := p.translateOne(ipcEvent{
161+
Event: "property-change",
162+
Name: "paused-for-cache",
163+
Data: json.RawMessage(`true`),
164+
})
165+
bc, ok := e.(BufferingChanged)
166+
if !ok {
167+
t.Fatalf("paused-for-cache: got %T, want BufferingChanged", e)
168+
}
169+
if !bc.Stalled {
170+
t.Fatal("paused-for-cache true produced Stalled=false")
171+
}
172+
}
173+
158174
func TestTranslateOne_UnknownEventDropped(t *testing.T) {
159175
p := &Player{}
160176
if e := p.translateOne(ipcEvent{Event: "property-change", Name: "idle-active", Data: json.RawMessage("true")}); e != nil {

internal/tui/commands.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ func waitForEvent(p *audio.Player) tea.Cmd {
8989
}
9090
case audio.CacheStateChanged:
9191
return CacheStateChangedMsg{Seconds: e.Seconds}
92+
case audio.BufferingChanged:
93+
return BufferingChangedMsg{Stalled: e.Stalled}
9294
}
9395
return nil
9496
}
@@ -167,3 +169,13 @@ func clockTick() tea.Cmd {
167169
return clockTickMsg{At: t}
168170
})
169171
}
172+
173+
// streamReconnectDelay is long enough to avoid flapping on brief network
174+
// hiccups, but short enough to recover quickly after laptop sleep.
175+
const streamReconnectDelay = 8 * time.Second
176+
177+
func reconnectStreamAfter(seq int) tea.Cmd {
178+
return tea.Tick(streamReconnectDelay, func(time.Time) tea.Msg {
179+
return reconnectStreamMsg{seq: seq}
180+
})
181+
}

internal/tui/messages.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ type CacheStateChangedMsg struct {
3333
Seconds float64
3434
}
3535

36+
// BufferingChangedMsg reports that mpv is internally paused waiting for
37+
// cache/network data. If it stays true, the UI can reload the live stream.
38+
type BufferingChangedMsg struct {
39+
Stalled bool
40+
}
41+
42+
// reconnectStreamMsg is fired by a delayed watchdog after a sustained
43+
// buffering stall. seq lets the model ignore stale timers.
44+
type reconnectStreamMsg struct{ seq int }
45+
3646
// PlaybackStartedMsg fires when mpv unpauses.
3747
type PlaybackStartedMsg struct{}
3848

internal/tui/model.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@ type Model struct {
156156
// cacheSeconds is how much audio mpv has buffered ahead. Drives
157157
// the buffer-health indicator under the now-playing card.
158158
cacheSeconds float64
159+
// bufferingStalled mirrors mpv's paused-for-cache state. When it stays
160+
// true after playback has already started, a watchdog reloads the live
161+
// stream so laptop sleep/network drops recover without station hopping.
162+
bufferingStalled bool
163+
// reconnectSeq invalidates stale reconnect timers when the stall clears,
164+
// playback is paused, or a different station is selected.
165+
reconnectSeq int
159166
// playStartedAt is when the current session started (i.e. last
160167
// successful Play call). Drives the "listening 1h 23m" uptime
161168
// label. Zero when nothing is playing.

internal/tui/model_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"strings"
66
"testing"
7+
"time"
78

89
tea "github.com/charmbracelet/bubbletea"
910

@@ -256,6 +257,29 @@ func TestPlaybackStartedWhileIdleDoesNotMarkPlaying(t *testing.T) {
256257
}
257258
}
258259

260+
func TestBufferingStallReconnectsActiveStream(t *testing.T) {
261+
m := fixture()
262+
m.playingIdx = 0
263+
m.playing = true
264+
m.loading = false
265+
266+
updated, cmd := m.Update(BufferingChangedMsg{Stalled: true})
267+
m = updated.(Model)
268+
if !m.bufferingStalled || m.reconnectSeq == 0 || cmd == nil {
269+
t.Fatalf("stall did not arm reconnect: stalled=%v seq=%d cmdNil=%v", m.bufferingStalled, m.reconnectSeq, cmd == nil)
270+
}
271+
272+
m.currentTrack = Track{Title: "old"}
273+
m.streamInfo = audio.StreamInfoChanged{Bitrate: 128000}
274+
m.cacheSeconds = 3
275+
m.playStartedAt = time.Now()
276+
updated, cmd = m.Update(reconnectStreamMsg{seq: m.reconnectSeq})
277+
m = updated.(Model)
278+
if !m.loading || m.currentTrack != (Track{}) || m.streamInfo != (audio.StreamInfoChanged{}) || m.cacheSeconds != 0 || !m.playStartedAt.IsZero() || cmd == nil {
279+
t.Fatalf("reconnect did not reset playback state: loading=%v track=%+v info=%+v cache=%v started=%v cmdNil=%v", m.loading, m.currentTrack, m.streamInfo, m.cacheSeconds, m.playStartedAt, cmd == nil)
280+
}
281+
}
282+
259283
func TestView_ShowsPlayingMarker(t *testing.T) {
260284
m := fixture()
261285
m = send(t, m, "space") // dispatch play; model goes into loading state

internal/tui/update.go

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4747
case clockTickMsg:
4848
m.nowTime = tick.At
4949
return m, clockTick()
50+
case reconnectStreamMsg:
51+
if tick.seq != m.reconnectSeq || !m.bufferingStalled || !m.playing || m.playingIdx < 0 || m.playingIdx >= len(m.cfg.Stations) {
52+
return m, nil
53+
}
54+
m.loading = true
55+
m.currentTrack = Track{}
56+
m.streamInfo = audio.StreamInfoChanged{}
57+
m.cacheSeconds = 0
58+
m.playStartedAt = time.Time{}
59+
m.toast = &Toast{Message: "reconnecting stream…", Kind: ToastInfo}
60+
return m, tea.Batch(playCmd(m.player, m.cfg.Stations[m.playingIdx].URL), clearToastAfter())
5061
case clearToastMsg:
5162
m.toast = nil
5263
return m, nil
@@ -116,10 +127,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
116127
if m.playingIdx < 0 || m.playingIdx >= len(m.cfg.Stations) {
117128
m.playing = false
118129
m.loading = false
130+
m.bufferingStalled = false
131+
m.reconnectSeq++
119132
return m, waitForEvent(m.player)
120133
}
121134
m.playing = true
122135
m.loading = false
136+
m.bufferingStalled = false
137+
m.reconnectSeq++
123138
// First start after a fresh Play call: anchor the uptime
124139
// counter. Resume after pause keeps the previous anchor so
125140
// "listening 1h 23m" doesn't reset every time the user toggles.
@@ -131,6 +146,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
131146
case PlaybackPausedMsg:
132147
m.playing = false
133148
m.loading = false
149+
m.bufferingStalled = false
150+
m.reconnectSeq++
134151
return m, waitForEvent(m.player)
135152

136153
case PlaybackErrorMsg:
@@ -141,6 +158,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
141158
m.currentTrack = Track{}
142159
m.streamInfo = audio.StreamInfoChanged{}
143160
m.cacheSeconds = 0
161+
m.bufferingStalled = false
162+
m.reconnectSeq++
144163
m.playStartedAt = time.Time{}
145164
return m, tea.Batch(clearToastAfter(), waitForEvent(m.player))
146165

@@ -151,6 +170,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
151170
m.currentTrack = Track{}
152171
m.streamInfo = audio.StreamInfoChanged{}
153172
m.cacheSeconds = 0
173+
m.bufferingStalled = false
174+
m.reconnectSeq++
154175
m.playStartedAt = time.Time{}
155176
return m, waitForEvent(m.player)
156177

@@ -167,6 +188,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
167188
m.cacheSeconds = msg.Seconds
168189
return m, waitForEvent(m.player)
169190

191+
case BufferingChangedMsg:
192+
m.bufferingStalled = msg.Stalled
193+
if !msg.Stalled {
194+
m.reconnectSeq++
195+
return m, waitForEvent(m.player)
196+
}
197+
// paused-for-cache also happens during initial buffering. Only arm
198+
// the self-heal watchdog after a station was already playing; slow
199+
// startup should keep buffering instead of reload-looping.
200+
if m.playing && !m.loading && m.playingIdx >= 0 && m.playingIdx < len(m.cfg.Stations) {
201+
m.reconnectSeq++
202+
seq := m.reconnectSeq
203+
m.toast = &Toast{Message: "stream stalled — reconnecting if it does not recover", Kind: ToastInfo}
204+
return m, tea.Batch(waitForEvent(m.player), reconnectStreamAfter(seq), clearToastAfter())
205+
}
206+
return m, waitForEvent(m.player)
207+
170208
case CommandFailedMsg:
171209
// Transient IPC failure (mpv was busy, request timed out) —
172210
// not a stream death. Toast and move on; do NOT touch
@@ -551,6 +589,8 @@ func (m Model) commitDelete() (tea.Model, tea.Cmd) {
551589
m.currentTrack = Track{}
552590
m.streamInfo = audio.StreamInfoChanged{}
553591
m.cacheSeconds = 0
592+
m.bufferingStalled = false
593+
m.reconnectSeq++
554594
m.playStartedAt = time.Time{}
555595
if m.player != nil {
556596
cmd = pauseCmd(m.player)
@@ -592,12 +632,29 @@ func (m Model) togglePlayPause() (tea.Model, tea.Cmd) {
592632
return m, clearToastAfter()
593633
}
594634
if m.cursor == m.playingIdx {
595-
// Toggle pause/resume on the currently-playing station.
635+
// Toggle pause/resume on the currently-playing station. Direct live
636+
// streams are reloaded on resume instead of merely unpaused: after a
637+
// laptop sleep mpv can be left with a dead HTTP connection, and
638+
// set pause=false will not reconnect it. YouTube keeps true resume
639+
// semantics because those URLs are finite media, not live radio.
596640
if m.playing {
597641
m.playing = false
642+
m.loading = false
643+
m.bufferingStalled = false
644+
m.reconnectSeq++
598645
return m, pauseCmd(m.player)
599646
}
600647
m.playing = true
648+
m.bufferingStalled = false
649+
m.reconnectSeq++
650+
if !m.cfg.Stations[m.cursor].IsYouTube() {
651+
m.loading = true
652+
m.currentTrack = Track{}
653+
m.streamInfo = audio.StreamInfoChanged{}
654+
m.cacheSeconds = 0
655+
m.playStartedAt = time.Time{}
656+
return m, playCmd(m.player, m.cfg.Stations[m.cursor].URL)
657+
}
601658
return m, resumeCmd(m.player)
602659
}
603660
// Switching to a different station — replace playback. Mark the
@@ -609,6 +666,8 @@ func (m Model) togglePlayPause() (tea.Model, tea.Cmd) {
609666
m.currentTrack = Track{}
610667
m.streamInfo = audio.StreamInfoChanged{}
611668
m.cacheSeconds = 0
669+
m.bufferingStalled = false
670+
m.reconnectSeq++
612671
m.playStartedAt = time.Time{}
613672
return m, playCmd(m.player, m.cfg.Stations[m.cursor].URL)
614673
}

0 commit comments

Comments
 (0)