437 lines
12 KiB
Go
437 lines
12 KiB
Go
package model
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"github.com/bjarneo/cliamp/playlist"
|
|
"github.com/bjarneo/cliamp/provider"
|
|
)
|
|
|
|
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 {
|
|
if m.playbackDetached {
|
|
m.playbackDetached = false
|
|
if m.playlist.Len() == 0 {
|
|
m.player.Stop()
|
|
m.clearPlaybackTrack()
|
|
return nil
|
|
}
|
|
return m.playCurrentTrack()
|
|
}
|
|
track, ok := m.playlist.Next()
|
|
if !ok {
|
|
m.player.Stop()
|
|
m.clearPlaybackTrack()
|
|
return nil
|
|
}
|
|
m.plCursor = m.playlist.Index()
|
|
m.adjustScroll()
|
|
return m.playTrack(track)
|
|
}
|
|
|
|
// prevTrack goes to the previous track, or restarts if >3s into the current one.
|
|
// Unplayable tracks are skipped automatically.
|
|
func (m *Model) prevTrack() tea.Cmd {
|
|
if m.player.Position() > 3*time.Second {
|
|
if m.player.Seekable() {
|
|
// Seekable media rewinds in place; non-seekable streams must be restarted.
|
|
m.player.Seek(-m.player.Position())
|
|
return nil
|
|
}
|
|
track, idx := m.currentPlaybackTrack()
|
|
if idx >= 0 {
|
|
return m.playTrack(track)
|
|
}
|
|
return nil
|
|
}
|
|
track, ok := m.playlist.Prev()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
m.plCursor = m.playlist.Index()
|
|
m.adjustScroll()
|
|
return m.playTrack(track)
|
|
}
|
|
|
|
// playCurrentLogicalTrack starts playback from the playlist's active logical
|
|
// track, preserving queued playback state.
|
|
func (m *Model) playCurrentLogicalTrack() tea.Cmd {
|
|
track, idx := m.playlist.Current()
|
|
if idx < 0 {
|
|
return nil
|
|
}
|
|
m.titleOff = 0
|
|
m.plCursor = idx
|
|
m.adjustScroll()
|
|
return m.playTrack(track)
|
|
}
|
|
|
|
// playCurrentTrack starts playing the selected track, skipping forward in
|
|
// playlist order if the selection is unplayable.
|
|
func (m *Model) playCurrentTrack() tea.Cmd {
|
|
m.titleOff = 0
|
|
if m.playlist.Len() == 0 {
|
|
return nil
|
|
}
|
|
activation, ok := m.playlist.ActivateSelected()
|
|
if !ok {
|
|
m.player.Stop()
|
|
m.clearPlaybackTrack()
|
|
m.status.Show("No available tracks", statusTTLDefault)
|
|
return nil
|
|
}
|
|
if activation.Skipped {
|
|
m.status.Show("Track unavailable, skipping...", statusTTLDefault)
|
|
}
|
|
m.plCursor = activation.Index
|
|
m.adjustScroll()
|
|
return m.playTrack(activation.Track)
|
|
}
|
|
|
|
// playTrackImmediate appends a track to the playlist and starts playing it now,
|
|
// stopping any current playback. Used by search-result "Play now" actions.
|
|
func (m *Model) playTrackImmediate(track playlist.Track) tea.Cmd {
|
|
m.player.Stop()
|
|
m.player.ClearPreload()
|
|
m.playlist.Add(track)
|
|
m.loadedPlaylist = ""
|
|
m.addToHeaderState([]playlist.Track{track})
|
|
idx := m.playlist.Len() - 1
|
|
m.playlist.SetIndex(idx)
|
|
m.plCursor = idx
|
|
m.adjustScroll()
|
|
m.status.Showf(statusTTLMedium, "Playing: %s", track.DisplayName())
|
|
cmd := m.playCurrentTrack()
|
|
m.notifyPlayback()
|
|
return cmd
|
|
}
|
|
|
|
// appendTrack appends a track to the playlist; auto-plays if nothing is playing.
|
|
func (m *Model) appendTrack(track playlist.Track) tea.Cmd {
|
|
wasEmpty := m.playlist.Len() == 0
|
|
m.playlist.Add(track)
|
|
m.loadedPlaylist = ""
|
|
m.addToHeaderState([]playlist.Track{track})
|
|
idx := m.playlist.Len() - 1
|
|
m.status.Showf(statusTTLMedium, "Added: %s", track.DisplayName())
|
|
if wasEmpty || !m.player.IsPlaying() {
|
|
m.playlist.SetIndex(idx)
|
|
m.plCursor = idx
|
|
m.adjustScroll()
|
|
cmd := m.playCurrentTrack()
|
|
m.notifyPlayback()
|
|
return cmd
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// closeNetSearch fully resets the net search overlay and restores focus,
|
|
// dropping any cached results so they don't linger between sessions.
|
|
func (m *Model) closeNetSearch() {
|
|
m.netSearch = netSearchState{}
|
|
m.focus = m.prevFocus
|
|
}
|
|
|
|
// closeSpotSearch fully resets the Spotify search overlay, dropping cached
|
|
// results, playlists, and the selected track.
|
|
func (m *Model) closeSpotSearch() {
|
|
m.spotSearch = spotSearchState{}
|
|
}
|
|
|
|
// queueTrackNext adds a track to the playlist and queues it to play next.
|
|
func (m *Model) queueTrackNext(track playlist.Track) tea.Cmd {
|
|
m.playlist.Add(track)
|
|
m.loadedPlaylist = ""
|
|
m.addToHeaderState([]playlist.Track{track})
|
|
idx := m.playlist.Len() - 1
|
|
m.playlist.Queue(idx)
|
|
m.status.Showf(statusTTLMedium, "Queued: %s", track.DisplayName())
|
|
if !m.player.IsPlaying() {
|
|
cmd := m.nextTrack()
|
|
m.notifyPlayback()
|
|
return cmd
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// removeSelectedFromPlaylist removes the track at the current playlist cursor.
|
|
// If the active track is removed, playback is stopped; the cursor is clamped
|
|
// to the new playlist length.
|
|
func (m *Model) removeSelectedFromPlaylist() {
|
|
idx := m.plCursor
|
|
if idx < 0 || idx >= m.playlist.Len() {
|
|
return
|
|
}
|
|
track := m.playlist.Tracks()[idx]
|
|
loaded := m.loadedPlaylist
|
|
if loaded != "" {
|
|
if saver, ok := m.localProvider.(provider.PlaylistSaver); ok {
|
|
saved, err := m.localProvider.Tracks(loaded)
|
|
if err != nil {
|
|
m.status.Showf(statusTTLDefault, "Remove failed: %s", err)
|
|
return
|
|
}
|
|
removed := false
|
|
for i := range saved {
|
|
if saved[i].Path == track.Path {
|
|
saved = append(saved[:i], saved[i+1:]...)
|
|
removed = true
|
|
break
|
|
}
|
|
}
|
|
if !removed {
|
|
m.status.Showf(statusTTLDefault, "Remove failed: %s is not in %q", track.DisplayName(), loaded)
|
|
return
|
|
}
|
|
if err := saver.SavePlaylist(loaded, saved); err != nil {
|
|
m.status.Showf(statusTTLDefault, "Remove failed: %s", err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
wasActive := idx == m.playlist.Index()
|
|
if !m.playlist.Remove(idx) {
|
|
return
|
|
}
|
|
if wasActive {
|
|
m.player.Stop()
|
|
m.player.ClearPreload()
|
|
m.clearPlaybackTrack()
|
|
}
|
|
if newLen := m.playlist.Len(); newLen == 0 {
|
|
m.plCursor = 0
|
|
} else if m.plCursor >= newLen {
|
|
m.plCursor = newLen - 1
|
|
}
|
|
m.adjustScroll()
|
|
if loaded != "" {
|
|
m.status.Showf(statusTTLDefault, "Removed from %q: %s", loaded, track.DisplayName())
|
|
} else {
|
|
m.status.Showf(statusTTLDefault, "Removed from queue: %s", track.DisplayName())
|
|
}
|
|
m.notifyPlayback()
|
|
}
|
|
|
|
// 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)
|
|
return resolveFeedTrackCmd(track.Path)
|
|
}
|
|
track, fetchCmd := m.beginPlaybackTrack(track)
|
|
|
|
// Stream yt-dlp URLs (YouTube, SoundCloud, Bandcamp, etc.) via pipe chain.
|
|
if playlist.IsYTDL(track.Path) {
|
|
m.buffering = true
|
|
m.bufferingAt = time.Now()
|
|
m.err = nil
|
|
dur := time.Duration(track.DurationSecs) * time.Second
|
|
if fetchCmd != nil {
|
|
return tea.Batch(playYTDLStreamCmd(m.player, track.Path, dur), fetchCmd)
|
|
}
|
|
return playYTDLStreamCmd(m.player, track.Path, dur)
|
|
}
|
|
// Fire now-playing notification for Navidrome tracks.
|
|
m.nowPlaying(track)
|
|
dur := time.Duration(track.DurationSecs) * time.Second
|
|
if track.Stream {
|
|
m.buffering = true
|
|
m.bufferingAt = time.Now()
|
|
m.err = nil
|
|
return tea.Batch(playStreamCmd(m.player, track.Path, dur), fetchCmd)
|
|
}
|
|
if err := m.player.Play(track.Path, dur); err != nil {
|
|
// Provider session went stale (e.g. Spotify auth expired and
|
|
// silent reconnect failed). Surface the standard sign-in
|
|
// overlay rather than the raw stream error.
|
|
if errors.Is(err, playlist.ErrNeedsAuth) {
|
|
m.provSignIn = true
|
|
m.err = nil
|
|
} else {
|
|
m.err = err
|
|
}
|
|
} else {
|
|
m.err = nil
|
|
m.applyResume()
|
|
m.backfillLoadedPlaylistDuration(track)
|
|
}
|
|
|
|
if fetchCmd != nil {
|
|
return tea.Batch(m.preloadNext(), fetchCmd)
|
|
}
|
|
return m.preloadNext()
|
|
}
|
|
|
|
func (m *Model) backfillLoadedPlaylistDuration(track playlist.Track) {
|
|
if m.loadedPlaylist == "" || track.DurationSecs > 0 || track.Stream || playlist.IsURL(track.Path) || strings.HasPrefix(track.Path, "ssh://") {
|
|
return
|
|
}
|
|
dur := int(m.player.Duration().Seconds())
|
|
if dur <= 0 {
|
|
return
|
|
}
|
|
saver, ok := m.localProvider.(provider.PlaylistSaver)
|
|
if !ok {
|
|
return
|
|
}
|
|
tracks, err := m.localProvider.Tracks(m.loadedPlaylist)
|
|
if err != nil {
|
|
return
|
|
}
|
|
changed := false
|
|
for i := range tracks {
|
|
if tracks[i].Path == track.Path && tracks[i].DurationSecs == 0 {
|
|
tracks[i].DurationSecs = dur
|
|
changed = true
|
|
break
|
|
}
|
|
}
|
|
if !changed {
|
|
return
|
|
}
|
|
if err := saver.SavePlaylist(m.loadedPlaylist, tracks); err == nil {
|
|
if idx := m.playlist.Index(); idx >= 0 {
|
|
track.DurationSecs = dur
|
|
m.playlist.SetTrack(idx, track)
|
|
}
|
|
}
|
|
}
|
|
|
|
// beginPlaybackTrack centralizes metadata refresh and model state reset for a
|
|
// new active track. It is used both by explicit playback and by gapless
|
|
// transitions, which advance audio without calling playTrack.
|
|
func (m *Model) beginPlaybackTrack(track playlist.Track) (playlist.Track, tea.Cmd) {
|
|
track = playlist.RefreshEmbeddedMetadata(track)
|
|
m.setPlaybackTrack(track)
|
|
m.reconnect.attempts = 0
|
|
m.reconnect.at = time.Time{}
|
|
m.streamTitle = ""
|
|
m.lyrics.lines = nil
|
|
m.lyrics.err = nil
|
|
m.lyrics.query = ""
|
|
m.lyrics.scroll = 0
|
|
m.seek.active = false
|
|
m.seek.timer = 0
|
|
m.seek.timerFor = 0
|
|
m.seek.grace = 0
|
|
m.seek.graceFor = 0
|
|
if m.lyrics.visible {
|
|
q := lyricsLookupKey(track, track.Artist, track.Title)
|
|
if q == "" {
|
|
m.lyrics.loading = false
|
|
return track, nil
|
|
}
|
|
m.lyrics.loading = true
|
|
m.lyrics.query = q
|
|
return track, fetchTrackLyricsCmd(track, track.Artist, track.Title)
|
|
}
|
|
m.lyrics.loading = false
|
|
return track, nil
|
|
}
|
|
|
|
// togglePlayPause starts playback if stopped, or toggles pause if playing.
|
|
// 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
|
|
}
|
|
if !m.player.IsPlaying() {
|
|
if m.playlist.CurrentIsQueued() {
|
|
return m.playCurrentLogicalTrack()
|
|
}
|
|
return m.playCurrentTrack()
|
|
}
|
|
if m.player.IsPaused() {
|
|
track, idx := m.currentPlaybackTrack()
|
|
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.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, 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.
|
|
// It clears the resume state after a successful seek so it only fires once.
|
|
func (m *Model) applyResume() {
|
|
// secs == 0 is indistinguishable from "never played"; skip resume.
|
|
if m.resume.path == "" || m.resume.secs <= 0 {
|
|
return
|
|
}
|
|
track, _ := m.currentPlaybackTrack()
|
|
if track.Path != m.resume.path {
|
|
return
|
|
}
|
|
// Only seek if the player reports the stream is seekable; otherwise the
|
|
// seek is a no-op that returns nil, which we must not mistake for success.
|
|
if !m.player.Seekable() {
|
|
return
|
|
}
|
|
target := time.Duration(m.resume.secs) * time.Second
|
|
if err := m.player.Seek(target - m.player.Position()); err == nil {
|
|
m.resume.path = ""
|
|
m.resume.secs = 0
|
|
}
|
|
}
|