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