fix(ui): reconnect long-paused yt-dlp streams
This commit is contained in:
@@ -140,6 +140,7 @@ type Model struct {
|
||||
configSaver ConfigSaver
|
||||
vis *ui.Visualizer
|
||||
seekStepLarge time.Duration
|
||||
pausedAt time.Time
|
||||
|
||||
// Primed Nj seek: digit sets pct, next `j` completes.
|
||||
pendingSeekActive bool
|
||||
|
||||
+17
-4
@@ -2,6 +2,7 @@ package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cliamp/playlist"
|
||||
)
|
||||
@@ -11,6 +12,7 @@ func TestShouldReconnectOnUnpause(t *testing.T) {
|
||||
name string
|
||||
track playlist.Track
|
||||
idx int
|
||||
pause time.Duration
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
@@ -24,13 +26,24 @@ func TestShouldReconnectOnUnpause(t *testing.T) {
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "regular stream does not reconnect",
|
||||
name: "short-paused yt-dlp stream does not reconnect",
|
||||
track: playlist.Track{
|
||||
Path: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
Stream: true,
|
||||
},
|
||||
idx: 0,
|
||||
want: false,
|
||||
idx: 0,
|
||||
pause: ytdlReconnectPauseThreshold - time.Second,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "long-paused yt-dlp stream reconnects",
|
||||
track: playlist.Track{
|
||||
Path: "https://music.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
Stream: true,
|
||||
},
|
||||
idx: 0,
|
||||
pause: ytdlReconnectPauseThreshold,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "invalid current index does not reconnect",
|
||||
@@ -57,7 +70,7 @@ func TestShouldReconnectOnUnpause(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shouldReconnectOnUnpause(tt.track, tt.idx); got != tt.want {
|
||||
if got := shouldReconnectOnUnpause(tt.track, tt.idx, tt.pause); got != tt.want {
|
||||
t.Fatalf("shouldReconnectOnUnpause(...) = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
|
||||
+52
-6
@@ -9,6 +9,8 @@ import (
|
||||
"cliamp/playlist"
|
||||
)
|
||||
|
||||
const ytdlReconnectPauseThreshold = 45 * time.Second
|
||||
|
||||
// nextTrack advances to the next playlist track and starts playing it.
|
||||
// Unplayable tracks are skipped automatically.
|
||||
func (m *Model) nextTrack() tea.Cmd {
|
||||
@@ -185,6 +187,7 @@ func (m *Model) removeSelectedFromPlaylist() {
|
||||
// playTrack plays a track, using async HTTP for streams and sync I/O for local files.
|
||||
// yt-dlp URLs are streamed via a piped yt-dlp | ffmpeg chain for instant playback.
|
||||
func (m *Model) playTrack(track playlist.Track) tea.Cmd {
|
||||
m.pausedAt = time.Time{}
|
||||
if track.Feed || playlist.IsFeed(track.Path) {
|
||||
m.feedLoading = true
|
||||
m.status.Show("Loading feed...", statusTTLLong)
|
||||
@@ -253,8 +256,8 @@ func (m *Model) playTrack(track playlist.Track) tea.Cmd {
|
||||
}
|
||||
|
||||
// togglePlayPause starts playback if stopped, or toggles pause if playing.
|
||||
// For live streams, unpausing reconnects to get current audio instead of
|
||||
// playing stale data sitting in OS/decoder buffers from before the pause.
|
||||
// For live streams and long-paused yt-dlp streams, unpausing reconnects instead
|
||||
// of playing stale data sitting in OS/decoder buffers from before the pause.
|
||||
func (m *Model) togglePlayPause() tea.Cmd {
|
||||
if m.buffering {
|
||||
return nil
|
||||
@@ -267,19 +270,62 @@ func (m *Model) togglePlayPause() tea.Cmd {
|
||||
}
|
||||
if m.player.IsPaused() {
|
||||
track, idx := m.currentPlaybackTrack()
|
||||
if shouldReconnectOnUnpause(track, idx) {
|
||||
pausedFor := time.Duration(0)
|
||||
if !m.pausedAt.IsZero() {
|
||||
pausedFor = time.Since(m.pausedAt)
|
||||
}
|
||||
if shouldReconnectOnUnpause(track, idx, pausedFor) {
|
||||
if playlist.IsYTDL(track.Path) && m.player.IsYTDLSeek() {
|
||||
return m.reconnectYTDLOnUnpause()
|
||||
}
|
||||
m.pausedAt = time.Time{}
|
||||
m.player.Stop()
|
||||
return m.playTrack(track)
|
||||
}
|
||||
}
|
||||
m.player.TogglePause()
|
||||
m.togglePlayerPause()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) togglePlayerPause() {
|
||||
m.player.TogglePause()
|
||||
if m.player.IsPaused() {
|
||||
m.pausedAt = time.Now()
|
||||
return
|
||||
}
|
||||
m.pausedAt = time.Time{}
|
||||
}
|
||||
|
||||
func (m *Model) reconnectYTDLOnUnpause() tea.Cmd {
|
||||
m.seek.active = true
|
||||
m.seek.targetPos = m.player.Position()
|
||||
m.seek.timer = 0
|
||||
m.seek.timerFor = 0
|
||||
m.seek.grace = 0
|
||||
m.seek.graceFor = 0
|
||||
m.player.CancelSeekYTDL()
|
||||
m.status.Show("Reconnecting stream...", statusTTLMedium)
|
||||
|
||||
p := m.player
|
||||
return func() tea.Msg {
|
||||
err := p.SeekYTDL(0)
|
||||
if err == nil {
|
||||
p.TogglePause()
|
||||
}
|
||||
return ytdlUnpauseReconnectMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// shouldReconnectOnUnpause reports whether unpausing should reconnect and
|
||||
// restart instead of resuming buffered audio.
|
||||
func shouldReconnectOnUnpause(track playlist.Track, idx int) bool {
|
||||
return idx >= 0 && track.IsLive()
|
||||
func shouldReconnectOnUnpause(track playlist.Track, idx int, pausedFor time.Duration) bool {
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
if track.IsLive() {
|
||||
return true
|
||||
}
|
||||
return pausedFor >= ytdlReconnectPauseThreshold && playlist.IsYTDL(track.Path)
|
||||
}
|
||||
|
||||
// applyResume seeks to the saved resume position if the current track matches.
|
||||
|
||||
+64
-14
@@ -12,13 +12,18 @@ import (
|
||||
|
||||
type playbackFakeEngine struct {
|
||||
playing bool
|
||||
paused bool
|
||||
ytdlSeek bool
|
||||
position time.Duration
|
||||
playCalls []string
|
||||
seekYTDLCalls []time.Duration
|
||||
preloadCalls []string
|
||||
clearPreloadCalls int
|
||||
}
|
||||
|
||||
func (f *playbackFakeEngine) Play(path string, _ time.Duration) error {
|
||||
f.playing = true
|
||||
f.paused = false
|
||||
f.playCalls = append(f.playCalls, path)
|
||||
return nil
|
||||
}
|
||||
@@ -29,22 +34,25 @@ func (f *playbackFakeEngine) Preload(path string, _ time.Duration) error {
|
||||
}
|
||||
func (f *playbackFakeEngine) PreloadYTDL(string, time.Duration) error { return nil }
|
||||
func (f *playbackFakeEngine) ClearPreload() { f.clearPreloadCalls++ }
|
||||
func (f *playbackFakeEngine) Stop() { f.playing = false }
|
||||
func (f *playbackFakeEngine) Stop() { f.playing, f.paused = false, false }
|
||||
func (f *playbackFakeEngine) Close() {}
|
||||
func (f *playbackFakeEngine) TogglePause() {}
|
||||
func (f *playbackFakeEngine) TogglePause() { f.paused = !f.paused }
|
||||
func (f *playbackFakeEngine) Seek(time.Duration) error { return nil }
|
||||
func (f *playbackFakeEngine) SeekYTDL(time.Duration) error { return nil }
|
||||
func (f *playbackFakeEngine) CancelSeekYTDL() {}
|
||||
func (f *playbackFakeEngine) IsPlaying() bool { return f.playing }
|
||||
func (f *playbackFakeEngine) IsPaused() bool { return false }
|
||||
func (f *playbackFakeEngine) Drained() bool { return false }
|
||||
func (f *playbackFakeEngine) HasPreload() bool { return false }
|
||||
func (f *playbackFakeEngine) Seekable() bool { return false }
|
||||
func (f *playbackFakeEngine) IsStreamSeek() bool { return false }
|
||||
func (f *playbackFakeEngine) IsYTDLSeek() bool { return false }
|
||||
func (f *playbackFakeEngine) GaplessAdvanced() bool { return false }
|
||||
func (f *playbackFakeEngine) Position() time.Duration { return 0 }
|
||||
func (f *playbackFakeEngine) Duration() time.Duration { return 0 }
|
||||
func (f *playbackFakeEngine) SeekYTDL(d time.Duration) error {
|
||||
f.seekYTDLCalls = append(f.seekYTDLCalls, d)
|
||||
return nil
|
||||
}
|
||||
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) HasPreload() bool { return false }
|
||||
func (f *playbackFakeEngine) Seekable() bool { return false }
|
||||
func (f *playbackFakeEngine) IsStreamSeek() bool { return false }
|
||||
func (f *playbackFakeEngine) IsYTDLSeek() bool { return f.ytdlSeek }
|
||||
func (f *playbackFakeEngine) GaplessAdvanced() bool { return false }
|
||||
func (f *playbackFakeEngine) Position() time.Duration { return f.position }
|
||||
func (f *playbackFakeEngine) Duration() time.Duration { return 0 }
|
||||
func (f *playbackFakeEngine) PositionAndDuration() (time.Duration, time.Duration) {
|
||||
return 0, 0
|
||||
}
|
||||
@@ -133,6 +141,48 @@ func TestTogglePlayPauseRestartsQueuedCurrentTrack(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTogglePlayPauseReconnectsLongPausedYTDLAtCurrentPosition(t *testing.T) {
|
||||
player := &playbackFakeEngine{
|
||||
playing: true,
|
||||
paused: true,
|
||||
ytdlSeek: true,
|
||||
position: 90 * time.Second,
|
||||
}
|
||||
p := playlist.New()
|
||||
p.Replace([]playlist.Track{{
|
||||
Title: "YouTube Music",
|
||||
Path: "https://music.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
Stream: true,
|
||||
DurationSecs: 180,
|
||||
}})
|
||||
p.SetIndex(0)
|
||||
|
||||
m := Model{
|
||||
player: player,
|
||||
playlist: p,
|
||||
vis: ui.NewVisualizer(float64(player.SampleRate())),
|
||||
pausedAt: time.Now().Add(-ytdlReconnectPauseThreshold),
|
||||
}
|
||||
|
||||
cmd := m.togglePlayPause()
|
||||
if cmd == nil {
|
||||
t.Fatal("togglePlayPause() = nil, want reconnect command")
|
||||
}
|
||||
msg := cmd()
|
||||
if reconnect, ok := msg.(ytdlUnpauseReconnectMsg); !ok || reconnect.err != nil {
|
||||
t.Fatalf("cmd() = %#v, want successful ytdlUnpauseReconnectMsg", msg)
|
||||
}
|
||||
if len(player.seekYTDLCalls) != 1 || player.seekYTDLCalls[0] != 0 {
|
||||
t.Fatalf("seekYTDLCalls = %v, want [0]", player.seekYTDLCalls)
|
||||
}
|
||||
if player.paused {
|
||||
t.Fatal("player stayed paused after reconnect command")
|
||||
}
|
||||
if !m.seek.active || m.seek.targetPos != 90*time.Second {
|
||||
t.Fatalf("seek state = active:%v target:%s, want active target 1m30s", m.seek.active, m.seek.targetPos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayCurrentTrackUnplayableUsesSelectionOrder(t *testing.T) {
|
||||
player := &playbackFakeEngine{}
|
||||
p := playlist.New()
|
||||
|
||||
@@ -16,6 +16,8 @@ const seekDebounceTicks = 8 // ~800ms at 100ms tick interval
|
||||
// seekTickMsg fires when the async seek completes.
|
||||
type seekTickMsg struct{}
|
||||
|
||||
type ytdlUnpauseReconnectMsg struct{ err error }
|
||||
|
||||
// doSeek handles a seek keypress. For yt-dlp streams, accumulates into a
|
||||
// single target position and debounces. For HTTP seekable streams, dispatches
|
||||
// the seek asynchronously to avoid blocking the UI. For local files, seeks
|
||||
|
||||
+16
-1
@@ -104,6 +104,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.finishSeek()
|
||||
return m, nil
|
||||
|
||||
case ytdlUnpauseReconnectMsg:
|
||||
m.seek.active = false
|
||||
m.seek.timer = 0
|
||||
m.seek.timerFor = 0
|
||||
m.seek.grace = 10
|
||||
m.seek.graceFor = 0
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
} else {
|
||||
m.err = nil
|
||||
m.pausedAt = time.Time{}
|
||||
}
|
||||
m.notifyAll()
|
||||
return m, nil
|
||||
|
||||
case tickMsg:
|
||||
now := time.Time(msg)
|
||||
dt := m.tickDelta(now)
|
||||
@@ -709,7 +724,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case playback.PauseMsg:
|
||||
if m.player.IsPlaying() && !m.player.IsPaused() {
|
||||
m.player.TogglePause()
|
||||
m.togglePlayerPause()
|
||||
m.notifyAll()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
Reference in New Issue
Block a user