Merge branch 'main' into embedded-lyrics-mpris-art

This commit is contained in:
lentikr
2026-07-01 20:41:57 +08:00
committed by GitHub
37 changed files with 2421 additions and 43 deletions
+1 -1
View File
@@ -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
View File
@@ -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")
}
+4
View File
@@ -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()
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+63 -2
View File
@@ -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()
+2
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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")
}