fix: keep live radio on current station

Fixes #305
This commit is contained in:
Bjarne Øverli
2026-08-18 18:25:42 +02:00
parent 7ce3dc47d5
commit eee503d6e2
4 changed files with 92 additions and 28 deletions
+11 -11
View File
@@ -229,17 +229,17 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
pl.Add(tracks...)
} else if defaultRadio {
pl.Add(
playlist.Track{Path: "http://radio.cliamp.stream/lofi/stream", Title: "Lofi Stream", Stream: true},
playlist.Track{Path: "http://radio.cliamp.stream/synthwave/stream", Title: "Synthwave Stream", Stream: true},
playlist.Track{Path: "http://radio.cliamp.stream/edm/stream", Title: "EDM Stream", Stream: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs/stream", Title: "NCS Stream", Stream: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-house/stream", Title: "NCS House Stream", Stream: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-dubstep/stream", Title: "NCS Dubstep Stream", Stream: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-dnb/stream", Title: "NCS Drum & Bass Stream", Stream: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-trap/stream", Title: "NCS Trap Stream", Stream: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-phonk/stream", Title: "NCS Phonk Stream", Stream: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-pop/stream", Title: "NCS Pop Stream", Stream: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-chill/stream", Title: "NCS Chill Stream", Stream: true},
playlist.Track{Path: "http://radio.cliamp.stream/lofi/stream", Title: "Lofi Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/synthwave/stream", Title: "Synthwave Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/edm/stream", Title: "EDM Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs/stream", Title: "NCS Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-house/stream", Title: "NCS House Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-dubstep/stream", Title: "NCS Dubstep Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-dnb/stream", Title: "NCS Drum & Bass Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-trap/stream", Title: "NCS Trap Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-phonk/stream", Title: "NCS Phonk Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-pop/stream", Title: "NCS Pop Stream", Stream: true, Realtime: true},
playlist.Track{Path: "http://radio.cliamp.stream/ncs-chill/stream", Title: "NCS Chill Stream", Stream: true, Realtime: true},
)
}
pl.Add(resolved.Tracks...)
+48 -1
View File
@@ -13,6 +13,7 @@ import (
type playbackFakeEngine struct {
playing bool
gaplessAdvanced bool
drained bool
paused bool
ytdlSeek bool
position time.Duration
@@ -46,7 +47,7 @@ func (f *playbackFakeEngine) SeekYTDL(d time.Duration) error {
func (f *playbackFakeEngine) CancelSeekYTDL() {}
func (f *playbackFakeEngine) IsPlaying() bool { return f.playing }
func (f *playbackFakeEngine) IsPaused() bool { return f.paused }
func (f *playbackFakeEngine) Drained() bool { return false }
func (f *playbackFakeEngine) Drained() bool { return f.drained }
func (f *playbackFakeEngine) HasPreload() bool { return false }
func (f *playbackFakeEngine) Seekable() bool { return false }
func (f *playbackFakeEngine) IsStreamSeek() bool { return false }
@@ -347,6 +348,52 @@ func TestPreloadAfterProviderPlaylistLoadUsesFirstNewTrack(t *testing.T) {
}
}
func TestPreloadNextSkipsLiveStream(t *testing.T) {
player := &playbackFakeEngine{playing: true}
p := playlist.New()
p.Replace([]playlist.Track{
{Title: "Station 1", Path: "https://example.com/one", Stream: true, Realtime: true},
{Title: "Station 2", Path: "https://example.com/two", Stream: true, Realtime: true},
})
p.SetIndex(0)
m := Model{player: player, playlist: p}
if cmd := m.preloadNext(); cmd != nil {
t.Fatal("preloadNext() returned a command for a live stream, want nil")
}
if m.preloading {
t.Fatal("preloading = true for a live stream, want false")
}
}
func TestDrainedLiveStreamReconnectsCurrentStation(t *testing.T) {
player := &playbackFakeEngine{playing: true, drained: true}
p := playlist.New()
p.Replace([]playlist.Track{
{Title: "Station 1", Path: "https://example.com/one", Stream: true, Realtime: true},
{Title: "Station 2", Path: "https://example.com/two", Stream: true, Realtime: true},
})
p.SetIndex(0)
m := Model{
player: player,
playlist: p,
vis: ui.NewVisualizer(float64(player.SampleRate())),
}
m.SetVisualizer("none")
now := time.Now()
updated, _ := m.Update(tickMsg(now))
m = updated.(Model)
if got := m.playlist.Index(); got != 0 {
t.Fatalf("playlist index = %d, want 0 after live stream drained", got)
}
if m.reconnect.at.IsZero() || !m.reconnect.at.After(now) {
t.Fatalf("reconnect time = %v, want a future retry", m.reconnect.at)
}
}
func TestBeginPlaybackTrackFetchesEmbeddedLyricsWithoutNetworkMetadata(t *testing.T) {
m := Model{lyrics: lyricsState{visible: true}}
track := playlist.Track{Title: "Local", EmbeddedLyrics: "Line one\nLine two"}
+7
View File
@@ -34,6 +34,13 @@ const ytdlPreloadLeadTime = 15 * time.Second
// When position has not yet reached the threshold, this function returns nil
// and the tick loop will retry on the next pass.
func (m *Model) preloadNext() tea.Cmd {
// Live streams do not have a track boundary. Preloading another station
// would turn a transient EOF into a gapless switch instead of reconnecting
// the station the user selected.
if current, idx := m.currentPlaybackTrack(); idx >= 0 && current.IsLive() {
return nil
}
var next playlist.Track
var ok bool
if m.playbackDetached {
+26 -16
View File
@@ -18,6 +18,16 @@ import (
"github.com/bjarneo/cliamp/ui"
)
func (m *Model) scheduleReconnect(now time.Time) {
if !m.reconnect.at.IsZero() || m.reconnect.attempts >= 5 {
return
}
delay := time.Second << m.reconnect.attempts
m.reconnect.at = now.Add(delay)
m.reconnect.attempts++
m.err = fmt.Errorf("reconnecting in %s", delay)
}
// Update handles messages: key presses, ticks, and window resizes.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
wasScreen := m.activeScreen()
@@ -152,13 +162,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
track, idx := m.currentPlaybackTrack()
isStream := idx >= 0 && (track.Stream || playlist.IsYouTubeURL(track.Path) || playlist.IsYTDL(track.Path))
if isStream && m.reconnect.attempts < 5 {
// Schedule reconnect with exponential backoff: 1s, 2s, 4s, 8s, 16s
if m.reconnect.at.IsZero() {
delay := time.Second << m.reconnect.attempts
m.reconnect.at = now.Add(delay)
m.reconnect.attempts++
m.err = fmt.Errorf("reconnecting in %s", delay)
}
m.scheduleReconnect(now)
} else {
m.err = err
m.reconnect.at = time.Time{}
@@ -282,16 +286,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Skip if already buffering a yt-dlp download to avoid advancing
// the playlist on every tick while waiting for the resolve.
if m.player.IsPlaying() && !m.player.IsPaused() && m.player.Drained() && !m.buffering && m.reconnect.at.IsZero() {
// Track drained to end — always ≥ 50%.
finishedTrack, _ := m.currentPlaybackTrack()
drainDur := time.Duration(finishedTrack.DurationSecs) * time.Second
m.maybeScrobble(finishedTrack, drainDur, drainDur)
finishedTrack, idx := m.currentPlaybackTrack()
if idx >= 0 && finishedTrack.IsLive() {
// A live stream has no natural end. A clean decoder EOF is a
// disconnect, so retry this station instead of advancing.
m.scheduleReconnect(now)
} else {
// Track drained to end — always ≥ 50%.
drainDur := time.Duration(finishedTrack.DurationSecs) * time.Second
m.maybeScrobble(finishedTrack, drainDur, drainDur)
// Stop the player before dispatching the async nextTrack command.
// This clears the gapless streamer so the finished track cannot
// replay while waiting for a yt-dlp pipe chain to spin up.
m.player.Stop()
cmds = append(cmds, m.nextTrack())
// Stop the player before dispatching the async nextTrack command.
// This clears the gapless streamer so the finished track cannot
// replay while waiting for a yt-dlp pipe chain to spin up.
m.player.Stop()
cmds = append(cmds, m.nextTrack())
}
m.notifyAll()
}
if m.player.IsPlaying() && !m.player.IsPaused() {