@@ -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...)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user