Fix buffering for streams
Release / build (amd64, darwin) (push) Has been cancelled
Release / build (amd64, linux) (push) Has been cancelled
Release / build (amd64, windows) (push) Has been cancelled
Release / build (arm64, darwin) (push) Has been cancelled
Release / build (arm64, linux) (push) Has been cancelled
Release / release (push) Has been cancelled

This commit is contained in:
Bjarne Øverli
2026-02-26 20:07:16 +01:00
parent 41a79dd52f
commit c61818ed68
6 changed files with 188 additions and 77 deletions
+4 -3
View File
@@ -29,17 +29,17 @@ func run() error {
provider = c
}
tracks, err := resolve.Tracks(os.Args[1:])
resolved, err := resolve.Args(os.Args[1:])
if err != nil {
return err
}
if len(tracks) == 0 && provider == nil {
if len(resolved.Tracks) == 0 && len(resolved.Pending) == 0 && provider == nil {
return errors.New("usage: cliamp <file|folder> [...] or configure a provider via ENV\n\n - Navidrome: NAVIDROME_URL, NAVIDROME_USER, NAVIDROME_PASS\n")
}
pl := playlist.New()
pl.Add(tracks...)
pl.Add(resolved.Tracks...)
p := player.New(beep.SampleRate(player.DefaultSampleRate))
defer p.Close()
@@ -48,6 +48,7 @@ func run() error {
cfg.ApplyPlaylist(pl)
m := ui.NewModel(p, pl, provider)
m.SetPendingURLs(resolved.Pending)
if cfg.EQPreset != "" && cfg.EQPreset != "Custom" {
m.SetEQPreset(cfg.EQPreset)
}
+4 -2
View File
@@ -31,11 +31,13 @@ var SupportedExts = map[string]bool{
".opus": true,
}
// httpClient is used for all HTTP streaming. It sets a dial/TLS/header
// httpClient is used for all HTTP streaming. It sets a generous header
// timeout but no overall timeout, so infinite live streams aren't killed.
// ForceAttemptHTTP2 is left at its default (false) to avoid HTTP/2
// framing issues with some podcast CDNs.
var httpClient = &http.Client{
Transport: &http.Transport{
ResponseHeaderTimeout: 15 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
},
}
+40 -23
View File
@@ -17,29 +17,24 @@ import (
"cliamp/playlist"
)
// Tracks resolves CLI arguments into playlist tracks.
// Each argument may be a file path, directory, glob pattern, HTTP stream URL,
// M3U playlist URL, or RSS podcast feed URL.
func Tracks(args []string) ([]playlist.Track, error) {
// Result holds the output of Args: instantly-resolved tracks and
// remote URLs (feeds, M3U) that need async HTTP fetching.
type Result struct {
Tracks []playlist.Track // local files, dirs, plain stream URLs
Pending []string // feed/M3U URLs to resolve asynchronously
}
// Args separates CLI arguments into immediately-resolved local tracks
// and pending remote URLs (feeds, M3U) that require HTTP fetching.
func Args(args []string) (Result, error) {
var r Result
var files []string
var feedTracks []playlist.Track
for _, arg := range args {
if playlist.IsURL(arg) {
switch {
case playlist.IsFeed(arg):
tracks, err := resolveFeed(arg)
if err != nil {
return nil, fmt.Errorf("resolving feed %s: %w", arg, err)
}
feedTracks = append(feedTracks, tracks...)
case playlist.IsM3U(arg):
streams, err := resolveM3U(arg)
if err != nil {
return nil, fmt.Errorf("resolving m3u %s: %w", arg, err)
}
files = append(files, streams...)
default:
if playlist.IsFeed(arg) || playlist.IsM3U(arg) {
r.Pending = append(r.Pending, arg)
} else {
files = append(files, arg)
}
continue
@@ -51,17 +46,39 @@ func Tracks(args []string) ([]playlist.Track, error) {
for _, path := range matches {
resolved, err := collectAudioFiles(path)
if err != nil {
return nil, fmt.Errorf("scanning %s: %w", path, err)
return r, fmt.Errorf("scanning %s: %w", path, err)
}
files = append(files, resolved...)
}
}
var tracks []playlist.Track
for _, f := range files {
tracks = append(tracks, playlist.TrackFromPath(f))
r.Tracks = append(r.Tracks, playlist.TrackFromPath(f))
}
return r, nil
}
// Remote fetches feed and M3U URLs and returns the resolved tracks.
func Remote(urls []string) ([]playlist.Track, error) {
var tracks []playlist.Track
for _, u := range urls {
switch {
case playlist.IsFeed(u):
t, err := resolveFeed(u)
if err != nil {
return nil, fmt.Errorf("resolving feed %s: %w", u, err)
}
tracks = append(tracks, t...)
case playlist.IsM3U(u):
streams, err := resolveM3U(u)
if err != nil {
return nil, fmt.Errorf("resolving m3u %s: %w", u, err)
}
for _, s := range streams {
tracks = append(tracks, playlist.TrackFromPath(s))
}
}
}
tracks = append(tracks, feedTracks...)
return tracks, nil
}
+14 -8
View File
@@ -30,7 +30,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
m.provCursor--
}
case " ":
m.togglePlayPause()
return m.togglePlayPause()
case "down", "j":
if m.provCursor < len(m.providerLists)-1 {
m.provCursor++
@@ -59,20 +59,23 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
}
case " ":
m.togglePlayPause()
cmd := m.togglePlayPause()
m.notifyMPRIS()
return cmd
case "s":
m.player.Stop()
m.notifyMPRIS()
case ">", ".":
m.nextTrack()
cmd := m.nextTrack()
m.notifyMPRIS()
return cmd
case "<", ",":
m.prevTrack()
cmd := m.prevTrack()
m.notifyMPRIS()
return cmd
case "left":
if m.focus == focusEQ {
@@ -119,8 +122,9 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
case "enter":
if m.focus == focusPlaylist {
m.playlist.SetIndex(m.plCursor)
m.playCurrentTrack()
cmd := m.playCurrentTrack()
m.notifyMPRIS()
return cmd
}
case "+", "=":
@@ -134,12 +138,12 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
case "r":
m.playlist.CycleRepeat()
m.player.ClearPreload()
m.preloadNext()
return m.preloadNext()
case "z":
m.playlist.ToggleShuffle()
m.player.ClearPreload()
m.preloadNext()
return m.preloadNext()
case "tab":
if m.focus == focusPlaylist {
@@ -198,16 +202,18 @@ func (m *Model) handleSearchKey(msg tea.KeyMsg) tea.Cmd {
m.focus = m.prevFocus
case tea.KeyEnter:
var cmd tea.Cmd
if len(m.searchResults) > 0 {
idx := m.searchResults[m.searchCursor]
m.playlist.SetIndex(idx)
m.plCursor = idx
m.adjustScroll()
m.playCurrentTrack()
cmd = m.playCurrentTrack()
m.notifyMPRIS()
}
m.searching = false
m.focus = focusPlaylist
return cmd
case tea.KeyUp:
if m.searchCursor > 0 {
+121 -41
View File
@@ -10,6 +10,7 @@ import (
"cliamp/mpris"
"cliamp/player"
"cliamp/playlist"
"cliamp/resolve"
)
type focusArea int
@@ -56,6 +57,13 @@ type Model struct {
searchCursor int
prevFocus focusArea // focus to restore on cancel
// Async feed/M3U URL resolution
pendingURLs []string
feedLoading bool
// Async stream buffering (true while HTTP connect is in progress)
buffering bool
// MPRIS D-Bus service (nil on non-Linux or if D-Bus unavailable)
mpris *mpris.Service
}
@@ -77,6 +85,12 @@ func NewModel(p *player.Player, pl *playlist.Playlist, prov playlist.Provider) M
return m
}
// SetPendingURLs stores remote URLs (feeds, M3U) for async resolution after Init.
func (m *Model) SetPendingURLs(urls []string) {
m.pendingURLs = urls
m.feedLoading = len(urls) > 0
}
// SetEQPreset sets the preset index by name. Returns true if found.
func (m *Model) SetEQPreset(name string) bool {
for i, p := range eqPresets {
@@ -120,6 +134,38 @@ func fetchPlaylistsCmd(prov playlist.Provider) tea.Cmd {
type tracksLoadedMsg []playlist.Track
// feedsLoadedMsg carries tracks resolved from remote feed/M3U URLs.
type feedsLoadedMsg []playlist.Track
func resolveRemoteCmd(urls []string) tea.Cmd {
return func() tea.Msg {
tracks, err := resolve.Remote(urls)
if err != nil {
return err
}
return feedsLoadedMsg(tracks)
}
}
// streamPlayedMsg signals that async stream Play() completed.
type streamPlayedMsg struct{ err error }
// streamPreloadedMsg signals that async stream Preload() completed.
type streamPreloadedMsg struct{}
func playStreamCmd(p *player.Player, path string) tea.Cmd {
return func() tea.Msg {
return streamPlayedMsg{err: p.Play(path)}
}
}
func preloadStreamCmd(p *player.Player, path string) tea.Cmd {
return func() tea.Msg {
p.Preload(path) // errors silently ignored
return streamPreloadedMsg{}
}
}
func fetchTracksCmd(prov playlist.Provider, playlistID string) tea.Cmd {
return func() tea.Msg {
tracks, err := prov.Tracks(playlistID)
@@ -136,6 +182,9 @@ func (m Model) Init() tea.Cmd {
if m.provider != nil {
cmds = append(cmds, fetchPlaylistsCmd(m.provider))
}
if len(m.pendingURLs) > 0 {
cmds = append(cmds, resolveRemoteCmd(m.pendingURLs))
}
return tea.Batch(cmds...)
}
@@ -164,22 +213,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err := m.player.StreamErr(); err != nil {
m.err = err
}
var cmds []tea.Cmd
// Check gapless transition (audio already playing next track)
if m.player.GaplessAdvanced() {
m.playlist.Next()
m.plCursor = m.playlist.Index()
m.adjustScroll()
m.titleOff = 0
m.preloadNext()
cmds = append(cmds, m.preloadNext())
m.notifyMPRIS()
}
// Check if gapless drained (end of playlist, no preloaded next)
if m.player.IsPlaying() && !m.player.IsPaused() && m.player.Drained() {
m.nextTrack()
cmds = append(cmds, m.nextTrack())
m.notifyMPRIS()
}
m.titleOff++
return m, tickCmd()
cmds = append(cmds, tickCmd())
return m, tea.Batch(cmds...)
case []playlist.PlaylistInfo:
m.providerLists = msg
@@ -191,14 +242,40 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.focus = focusPlaylist
m.provLoading = false
if m.playlist.Len() > 0 {
m.playCurrentTrack()
cmd := m.playCurrentTrack()
m.notifyMPRIS()
return m, cmd
}
return m, nil
case feedsLoadedMsg:
m.feedLoading = false
m.playlist.Add(msg...)
if m.playlist.Len() > 0 && !m.player.IsPlaying() {
cmd := m.playCurrentTrack()
m.notifyMPRIS()
return m, cmd
}
return m, nil
case streamPlayedMsg:
m.buffering = false
if msg.err != nil {
m.err = msg.err
} else {
m.err = nil
}
m.notifyMPRIS()
return m, m.preloadNext()
case streamPreloadedMsg:
return m, nil
case error:
m.err = msg
m.provLoading = false
m.feedLoading = false
m.buffering = false
return m, nil
case mpris.InitMsg:
@@ -206,23 +283,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case mpris.PlayPauseMsg:
if !m.player.IsPlaying() {
m.playCurrentTrack()
} else {
m.player.TogglePause()
}
cmd := m.togglePlayPause()
m.notifyMPRIS()
return m, nil
return m, cmd
case mpris.NextMsg:
m.nextTrack()
cmd := m.nextTrack()
m.notifyMPRIS()
return m, nil
return m, cmd
case mpris.PrevMsg:
m.prevTrack()
cmd := m.prevTrack()
m.notifyMPRIS()
return m, nil
return m, cmd
case mpris.StopMsg:
m.player.Stop()
@@ -239,64 +312,71 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// nextTrack advances to the next playlist track and starts playing it.
func (m *Model) nextTrack() {
// Returns a tea.Cmd for async stream playback.
func (m *Model) nextTrack() tea.Cmd {
track, ok := m.playlist.Next()
if !ok {
m.player.Stop()
return
return nil
}
m.plCursor = m.playlist.Index()
m.adjustScroll()
if err := m.player.Play(track.Path); err != nil {
m.err = err
} else {
m.err = nil
}
m.preloadNext()
return m.playTrack(track)
}
// prevTrack goes to the previous track, or restarts if >3s into the current one.
func (m *Model) prevTrack() {
func (m *Model) prevTrack() tea.Cmd {
if m.player.Position() > 3*time.Second {
m.player.Seek(-m.player.Position())
return
return nil
}
track, ok := m.playlist.Prev()
if !ok {
return
return nil
}
m.plCursor = m.playlist.Index()
m.adjustScroll()
if err := m.player.Play(track.Path); err != nil {
m.err = err
} else {
m.err = nil
}
m.preloadNext()
return m.playTrack(track)
}
// playCurrentTrack starts playing whatever track the playlist cursor points to.
func (m *Model) playCurrentTrack() {
func (m *Model) playCurrentTrack() tea.Cmd {
track, idx := m.playlist.Current()
if idx < 0 {
return
return nil
}
m.titleOff = 0
return m.playTrack(track)
}
// playTrack plays a track, using async HTTP for streams and sync I/O for local files.
func (m *Model) playTrack(track playlist.Track) tea.Cmd {
if track.Stream {
m.buffering = true
m.err = nil
return playStreamCmd(m.player, track.Path)
}
if err := m.player.Play(track.Path); err != nil {
m.err = err
} else {
m.err = nil
}
m.preloadNext()
return m.preloadNext()
}
// preloadNext looks ahead in the playlist and preloads the next track for
// gapless transition. Errors are silently ignored — playback falls back to
// non-gapless if preloading fails.
func (m *Model) preloadNext() {
if next, ok := m.playlist.PeekNext(); ok {
m.player.Preload(next.Path)
func (m *Model) preloadNext() tea.Cmd {
next, ok := m.playlist.PeekNext()
if !ok {
return nil
}
if next.Stream {
return preloadStreamCmd(m.player, next.Path)
}
m.player.Preload(next.Path)
return nil
}
// adjustScroll ensures plCursor is visible in the playlist view.
@@ -333,12 +413,12 @@ func (m *Model) notifyMPRIS() {
}
// togglePlayPause starts playback if stopped, or toggles pause if playing.
func (m *Model) togglePlayPause() {
func (m *Model) togglePlayPause() tea.Cmd {
if !m.player.IsPlaying() {
m.playCurrentTrack()
} else {
m.player.TogglePause()
return m.playCurrentTrack()
}
m.player.TogglePause()
return nil
}
// updateSearch filters the playlist by the current search query.
+5
View File
@@ -158,6 +158,8 @@ func (m Model) renderTimeStatus() string {
var status string
switch {
case m.buffering:
status = statusStyle.Render("◌ Buffering...")
case m.player.IsPlaying() && m.player.IsPaused():
status = statusStyle.Render("⏸ Paused")
case m.player.IsPlaying() && track.Stream:
@@ -300,6 +302,9 @@ func (m Model) renderPlaylist() string {
tracks := m.playlist.Tracks()
if len(tracks) == 0 {
if m.feedLoading {
return dimStyle.Render(" Loading feed...")
}
return dimStyle.Render(" No tracks loaded")
}