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