Merge branch 'main' into embedded-lyrics-mpris-art
This commit is contained in:
@@ -67,7 +67,7 @@ func windowList(items []string, cursor, scroll, budget int) string {
|
||||
for i := scroll; i < len(items) && len(lines) < budget; i++ {
|
||||
lines = append(lines, cursorLine(items[i], i == cursor))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
return strings.Join(padLines(lines, budget, len(lines)), "\n")
|
||||
}
|
||||
|
||||
// bodyLines fits pre-built lines into the budget (truncate + pad to budget).
|
||||
|
||||
+3
-2
@@ -53,6 +53,7 @@ var keymapEntries = []keymapEntry{
|
||||
{key: "M", action: "Open NetEase provider"},
|
||||
{key: "J", action: "Open Jellyfin provider"},
|
||||
{key: "E", action: "Open Emby provider"},
|
||||
{key: "Q", action: "Open Qobuz provider"},
|
||||
{key: "Ctrl+J", action: "Jump to time"},
|
||||
{key: "p", action: "Playlist manager"},
|
||||
{key: "Ctrl+H", action: "Toggle album headers"},
|
||||
@@ -103,7 +104,7 @@ var coreReservedKeys = []string{
|
||||
"r", "z", "m", "e", "a", "A", "ctrl+h",
|
||||
"ctrl+s", "S", "/", "ctrl+f",
|
||||
"ctrl+j", "J", "E", "p", "t", "i", "y", "o", "u",
|
||||
"N", "L", "R", "P", "Y", "C", "M",
|
||||
"N", "L", "R", "P", "Y", "C", "M", "Q",
|
||||
"v", "V", "ctrl+v", "ctrl+x", "x", "d", "ctrl+k", "?",
|
||||
"ctrl+r",
|
||||
}
|
||||
@@ -405,5 +406,5 @@ func (m Model) renderKeymapList() string {
|
||||
lines = append(lines, cursorLine(line, i == m.keymap.cursor))
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
return strings.Join(padLines(lines, budget, len(lines)), "\n")
|
||||
}
|
||||
|
||||
@@ -368,6 +368,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
|
||||
return m.switchToProvider("soundcloud")
|
||||
case "M":
|
||||
return m.switchToProvider("netease")
|
||||
case "Q":
|
||||
return m.switchToProvider("qobuz")
|
||||
case "L":
|
||||
return m.switchToProvider("local")
|
||||
case "R":
|
||||
@@ -749,6 +751,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
|
||||
return m.switchToProvider("soundcloud")
|
||||
case "M":
|
||||
return m.switchToProvider("netease")
|
||||
case "Q":
|
||||
return m.switchToProvider("qobuz")
|
||||
|
||||
case "ctrl+h":
|
||||
m.toggleAlbumHeadersManual()
|
||||
|
||||
@@ -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)
|
||||
@@ -261,8 +264,8 @@ func (m *Model) beginPlaybackTrack(track playlist.Track) (playlist.Track, tea.Cm
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -275,19 +278,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.
|
||||
|
||||
@@ -13,13 +13,18 @@ import (
|
||||
type playbackFakeEngine struct {
|
||||
playing bool
|
||||
gaplessAdvanced 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
|
||||
}
|
||||
@@ -30,9 +35,9 @@ 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() {}
|
||||
@@ -51,6 +56,20 @@ func (f *playbackFakeEngine) GaplessAdvanced() bool {
|
||||
return true
|
||||
}
|
||||
func (f *playbackFakeEngine) Position() 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
|
||||
@@ -140,6 +159,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)
|
||||
@@ -713,7 +728,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
|
||||
|
||||
@@ -23,7 +23,7 @@ func (m Model) renderVisPickerList() string {
|
||||
for i := scroll; i < len(items) && len(lines) < budget; i++ {
|
||||
lines = append(lines, cursorLine(items[i], i == m.visPicker.cursor))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
return strings.Join(padLines(lines, budget, len(lines)), "\n")
|
||||
}
|
||||
|
||||
// — playlist manager (inline) —
|
||||
@@ -259,5 +259,5 @@ func (m Model) renderSearchList() string {
|
||||
// cursorLine adds the "> "/" " prefix and selected styling.
|
||||
lines = append(lines, cursorLine(item, j == m.search.cursor))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
return strings.Join(padLines(lines, budget, len(lines)), "\n")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user