Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

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
}
}