@@ -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