1131 lines
30 KiB
Go
1131 lines
30 KiB
Go
package model
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"github.com/bjarneo/cliamp/history"
|
|
"github.com/bjarneo/cliamp/internal/playback"
|
|
"github.com/bjarneo/cliamp/ipc"
|
|
"github.com/bjarneo/cliamp/player"
|
|
"github.com/bjarneo/cliamp/playlist"
|
|
"github.com/bjarneo/cliamp/provider"
|
|
"github.com/bjarneo/cliamp/theme"
|
|
"github.com/bjarneo/cliamp/ui"
|
|
)
|
|
|
|
// Update handles messages: key presses, ticks, and window resizes.
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
wasScreen := m.activeScreen()
|
|
wasMode := ui.VisNone
|
|
if m.vis != nil {
|
|
wasMode = m.vis.Mode
|
|
}
|
|
wasPlaying := false
|
|
wasPaused := false
|
|
if m.player != nil {
|
|
wasPlaying = m.player.IsPlaying()
|
|
wasPaused = m.player.IsPaused()
|
|
}
|
|
defer func() {
|
|
m.maybeRequestVisualizerRefresh(msg, wasScreen, wasMode, wasPlaying, wasPaused)
|
|
m.emitPluginEvents()
|
|
}()
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.PasteMsg:
|
|
cmd := m.handlePaste(msg.Content)
|
|
return m, cmd
|
|
|
|
case tea.KeyPressMsg:
|
|
cmd := m.handleKey(msg)
|
|
if m.quitting {
|
|
return m, tea.Quit
|
|
}
|
|
m.applyHeightMode()
|
|
m.adjustScroll()
|
|
return m, cmd
|
|
|
|
case autoPlayMsg:
|
|
if m.playlist.Len() > 0 && !m.player.IsPlaying() {
|
|
cmd := m.playCurrentTrack()
|
|
m.notifyAll()
|
|
return m, cmd
|
|
}
|
|
return m, nil
|
|
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
// Dynamic frame width: use full terminal width, or cap at 80 in compact mode.
|
|
frameW := msg.Width
|
|
if m.compact {
|
|
frameW = min(frameW, 80)
|
|
}
|
|
ui.FrameStyle = ui.FrameStyle.Width(frameW)
|
|
m.restorePanelWidth()
|
|
if m.fullVis {
|
|
m.vis.Rows = max(ui.DefaultVisRows, (m.height-10)*4/5)
|
|
ui.PanelWidth = max(0, m.width-2*ui.PaddingH)
|
|
}
|
|
m.recomputeChrome()
|
|
m.applyHeightMode()
|
|
m.adjustScroll()
|
|
if m.focus == focusProvider {
|
|
m.providerMaybeAdjustScroll()
|
|
}
|
|
if m.fileBrowser.visible {
|
|
m.fbMaybeAdjustScroll(m.fbVisible())
|
|
}
|
|
if m.plPicker.visible {
|
|
m.plPickerMaybeAdjustScroll(m.plPickerVisible())
|
|
}
|
|
if m.keymap.visible {
|
|
m.keymapMaybeAdjustScroll(m.keymapVisible())
|
|
}
|
|
if m.plManager.visible {
|
|
if m.plManager.screen == plMgrScreenList {
|
|
m.plMgrListMaybeAdjustScroll(m.plMgrListVisible())
|
|
} else if m.plManager.screen == plMgrScreenTracks {
|
|
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
case seekTickMsg:
|
|
// Async seek completed.
|
|
// Only clear seekActive if no new seek keypresses arrived during loading.
|
|
if m.seek.timer <= 0 {
|
|
m.seek.active = false
|
|
}
|
|
// Grace period: suppress reconnect for a few ticks after seek completes.
|
|
m.seek.grace = 10
|
|
m.seek.graceFor = 0
|
|
m.finishSeek()
|
|
return m, nil
|
|
|
|
case ytdlUnpauseReconnectMsg:
|
|
m.seek.active = false
|
|
m.seek.timer = 0
|
|
m.seek.timerFor = 0
|
|
m.seek.grace = 10
|
|
m.seek.graceFor = 0
|
|
if msg.err != nil {
|
|
m.err = msg.err
|
|
} else {
|
|
m.err = nil
|
|
m.pausedAt = time.Time{}
|
|
}
|
|
m.notifyAll()
|
|
return m, nil
|
|
|
|
case tickMsg:
|
|
now := time.Time(msg)
|
|
dt := m.tickDelta(now)
|
|
|
|
// Cache expensive player state once per tick so View() render
|
|
// functions don't re-acquire speaker.Lock() multiple times.
|
|
// PositionAndDuration() batches both reads under one speaker lock.
|
|
if !m.buffering {
|
|
if m.seek.active {
|
|
m.cachedPos = m.seek.targetPos
|
|
m.cachedDur = m.player.Duration()
|
|
} else {
|
|
m.cachedPos, m.cachedDur = m.player.PositionAndDuration()
|
|
// Piped SSH streams report 0 duration — use metadata fallback.
|
|
if m.cachedDur == 0 {
|
|
if track, _ := m.currentPlaybackTrack(); track.DurationSecs > 0 && strings.HasPrefix(track.Path, "ssh://") {
|
|
m.cachedDur = time.Duration(track.DurationSecs) * time.Second
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
track, _ := m.currentPlaybackTrack()
|
|
m.cachedDur = time.Duration(track.DurationSecs) * time.Second
|
|
m.cachedPos = 0
|
|
}
|
|
m.tickVisualizer(now)
|
|
// Process debounced yt-dlp seek.
|
|
var seekCmd tea.Cmd
|
|
if cmd := m.tickSeek(dt); cmd != nil {
|
|
seekCmd = cmd
|
|
}
|
|
// Expire temporary status messages.
|
|
wasStatus := m.status.text != ""
|
|
if !m.status.expiresAt.IsZero() && !now.Before(m.status.expiresAt) {
|
|
m.status.Clear()
|
|
}
|
|
// Drain app log buffer and expire old entries.
|
|
wasLogs := len(m.logLines)
|
|
m.tickLogLines(now)
|
|
if (wasStatus && m.status.text == "") || len(m.logLines) != wasLogs {
|
|
m.applyHeightMode()
|
|
m.adjustScroll()
|
|
}
|
|
m.tickPendingSpeedSave(dt)
|
|
// Decrement seek grace period.
|
|
advanceTickUnits(&m.seek.grace, &m.seek.graceFor, dt, ui.TickFast)
|
|
// Surface stream errors (e.g., connection drops) and auto-reconnect streams.
|
|
// Suppress during yt-dlp seek and grace period — killing the old pipeline
|
|
// triggers a transient error that can persist for a few ticks.
|
|
if err := m.player.StreamErr(); err != nil && !m.seek.active && m.seek.grace == 0 {
|
|
track, idx := m.currentPlaybackTrack()
|
|
isStream := idx >= 0 && (track.Stream || playlist.IsYouTubeURL(track.Path) || playlist.IsYTDL(track.Path))
|
|
if isStream && m.reconnect.attempts < 5 {
|
|
// Schedule reconnect with exponential backoff: 1s, 2s, 4s, 8s, 16s
|
|
if m.reconnect.at.IsZero() {
|
|
delay := time.Second << m.reconnect.attempts
|
|
m.reconnect.at = now.Add(delay)
|
|
m.reconnect.attempts++
|
|
m.err = fmt.Errorf("reconnecting in %s", delay)
|
|
}
|
|
} else {
|
|
m.err = err
|
|
m.reconnect.at = time.Time{}
|
|
}
|
|
}
|
|
var lyricCmd tea.Cmd
|
|
// Poll ICY stream title for live radio display.
|
|
if title := m.player.StreamTitle(); title != "" && title != m.streamTitle {
|
|
m.streamTitle = title
|
|
m.applyHeightMode()
|
|
m.adjustScroll()
|
|
m.notifyAll()
|
|
// Auto-fetch lyrics when the stream song changes and lyrics overlay is open.
|
|
if m.lyrics.visible && !m.lyrics.loading {
|
|
if artist, song, ok := strings.Cut(title, " - "); ok {
|
|
q := artist + "\n" + song
|
|
if q != m.lyrics.query {
|
|
m.lyrics.query = q
|
|
m.lyrics.loading = true
|
|
m.lyrics.lines = nil
|
|
m.lyrics.err = nil
|
|
m.lyrics.scroll = 0
|
|
lyricCmd = fetchLyricsCmd(artist, song)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
m.network.sampleFor += dt
|
|
if m.network.sampleFor >= time.Second {
|
|
downloaded, _ := m.player.StreamBytes()
|
|
if downloaded > 0 || m.player.IsPlaying() {
|
|
m.notifyAll()
|
|
}
|
|
delta := downloaded - m.network.lastBytes
|
|
if delta > 0 {
|
|
// Exponential moving average for smooth display.
|
|
instant := float64(delta) / m.network.sampleFor.Seconds() // bytes/sec
|
|
if m.network.speed == 0 {
|
|
m.network.speed = instant
|
|
} else {
|
|
m.network.speed = m.network.speed*0.6 + instant*0.4
|
|
}
|
|
} else if downloaded == 0 {
|
|
m.network.speed = 0
|
|
}
|
|
m.network.lastBytes = downloaded
|
|
m.network.sampleFor = 0
|
|
}
|
|
// Fire scheduled reconnect when the timer expires.
|
|
if !m.reconnect.at.IsZero() && now.After(m.reconnect.at) {
|
|
m.reconnect.at = time.Time{}
|
|
track, idx := m.currentPlaybackTrack()
|
|
m.player.Stop()
|
|
if idx >= 0 {
|
|
// Preserve any seek/lyric commands already queued this tick
|
|
// rather than dropping them on the early return.
|
|
batch := []tea.Cmd{m.playTrack(track), tickCmdAt(ui.TickFast)}
|
|
if seekCmd != nil {
|
|
batch = append(batch, seekCmd)
|
|
}
|
|
if lyricCmd != nil {
|
|
batch = append(batch, lyricCmd)
|
|
}
|
|
return m, tea.Batch(batch...)
|
|
}
|
|
}
|
|
var cmds []tea.Cmd
|
|
if seekCmd != nil {
|
|
cmds = append(cmds, seekCmd)
|
|
}
|
|
if lyricCmd != nil {
|
|
cmds = append(cmds, lyricCmd)
|
|
}
|
|
// Check gapless transition (audio already playing next track)
|
|
if m.player.GaplessAdvanced() {
|
|
// Capture the track that just finished before advancing the playlist.
|
|
// For gapless, the track played fully (100% ≥ 50%), so elapsed = duration.
|
|
finishedTrack, _ := m.currentPlaybackTrack()
|
|
fullDur := time.Duration(finishedTrack.DurationSecs) * time.Second
|
|
m.maybeScrobble(finishedTrack, fullDur, fullDur)
|
|
|
|
var newTrack playlist.Track
|
|
var ok bool
|
|
if m.playbackDetached {
|
|
var idx int
|
|
newTrack, idx = m.playlist.Current()
|
|
ok = idx >= 0
|
|
m.playbackDetached = false
|
|
} else {
|
|
newTrack, ok = m.playlist.Next()
|
|
}
|
|
if !ok {
|
|
m.player.Stop()
|
|
m.clearPlaybackTrack()
|
|
m.notifyAll()
|
|
cmds = append(cmds, tickCmdAt(m.tickInterval()))
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
m.plCursor = m.playlist.Index()
|
|
m.adjustScroll()
|
|
m.titleOff = 0
|
|
var gaplessLyricCmd tea.Cmd
|
|
newTrack, gaplessLyricCmd = m.beginPlaybackTrack(newTrack)
|
|
if gaplessLyricCmd != nil {
|
|
cmds = append(cmds, gaplessLyricCmd)
|
|
}
|
|
// The preload that just fired is consumed — clear the in-flight flag
|
|
// so the next track can be preloaded.
|
|
m.preloading = false
|
|
// A stream decoder error at the track boundary (e.g., server closing
|
|
// the connection when the preload HTTP request opens) is expected and
|
|
// not a user-visible problem. Clear any pending error so the red
|
|
// message doesn't flash at every track transition.
|
|
m.err = nil
|
|
// Gapless advances without calling playTrack(), so emit now-playing here.
|
|
m.nowPlaying(newTrack)
|
|
cmds = append(cmds, m.preloadNext())
|
|
m.notifyAll()
|
|
}
|
|
// Check if gapless drained (end of playlist, no preloaded next).
|
|
// Skip if already buffering a yt-dlp download to avoid advancing
|
|
// the playlist on every tick while waiting for the resolve.
|
|
if m.player.IsPlaying() && !m.player.IsPaused() && m.player.Drained() && !m.buffering && m.reconnect.at.IsZero() {
|
|
// Track drained to end — always ≥ 50%.
|
|
finishedTrack, _ := m.currentPlaybackTrack()
|
|
drainDur := time.Duration(finishedTrack.DurationSecs) * time.Second
|
|
m.maybeScrobble(finishedTrack, drainDur, drainDur)
|
|
|
|
// Stop the player before dispatching the async nextTrack command.
|
|
// This clears the gapless streamer so the finished track cannot
|
|
// replay while waiting for a yt-dlp pipe chain to spin up.
|
|
m.player.Stop()
|
|
cmds = append(cmds, m.nextTrack())
|
|
m.notifyAll()
|
|
}
|
|
if m.player.IsPlaying() && !m.player.IsPaused() {
|
|
if now.Sub(m.titleLastScroll) >= 200*time.Millisecond {
|
|
m.titleOff++
|
|
m.titleLastScroll = now
|
|
}
|
|
}
|
|
// Retry deferred stream preload: preloadNext() returns nil (defers) when
|
|
// the current stream has >streamPreloadLeadTime remaining. Poll every tick
|
|
// until we're within the window and the preload gets armed.
|
|
// Guard with !m.preloading so we don't fire a second concurrent HTTP
|
|
// connection while the first preloadStreamCmd goroutine is still running.
|
|
if m.player.IsPlaying() && !m.player.IsPaused() && !m.buffering && !m.preloading && !m.player.HasPreload() {
|
|
if cmd := m.preloadNext(); cmd != nil {
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
}
|
|
|
|
m.advanceTerminalTitle()
|
|
cmds = append(cmds, tickCmdAt(m.tickInterval()))
|
|
return m, tea.Batch(cmds...)
|
|
|
|
case []playlist.PlaylistInfo:
|
|
m.providerLists = msg
|
|
m.provLoading = false
|
|
// Start loading catalog when the provider supports lazy catalog loading.
|
|
if loader, ok := m.provider.(provider.CatalogLoader); ok && !m.catalogBatch.loading && !m.catalogBatch.done {
|
|
m.catalogBatch.loading = true
|
|
return m, fetchCatalogBatchCmd(loader, m.catalogBatch.offset, catalogBatchSize)
|
|
}
|
|
return m, nil
|
|
|
|
case tracksLoadedMsg:
|
|
if m.player.IsPlaying() || m.buffering {
|
|
m.detachPlaybackTrack()
|
|
m.player.ClearPreload()
|
|
m.preloading = false
|
|
} else {
|
|
m.player.Stop()
|
|
m.player.ClearPreload()
|
|
m.clearPlaybackTrack()
|
|
}
|
|
m.resetYTDLBatch()
|
|
m.playlist.Replace(msg.tracks)
|
|
m.setHeaderStateFromTracks(msg.tracks)
|
|
if msg.playlistExact && m.localProvider != nil && msg.providerName == m.localProvider.Name() && msg.playlistID != history.PlaylistName {
|
|
m.loadedPlaylist = msg.playlistID
|
|
} else {
|
|
m.loadedPlaylist = ""
|
|
}
|
|
m.plCursor = 0
|
|
m.plScroll = 0
|
|
m.focus = focusPlaylist
|
|
m.applyHeightMode()
|
|
m.adjustScroll()
|
|
m.provLoading = false
|
|
m.notifyAll()
|
|
return m, nil
|
|
|
|
case navArtistsLoadedMsg:
|
|
m.navBrowser.artists = []provider.ArtistInfo(msg)
|
|
m.navBrowser.loading = false
|
|
m.navBrowser.cursor = 0
|
|
m.navBrowser.scroll = 0
|
|
return m, nil
|
|
|
|
case navAlbumsLoadedMsg:
|
|
if msg.offset == 0 {
|
|
// Fresh load (new sort or drill-in): replace the list.
|
|
m.navBrowser.albums = msg.albums
|
|
m.navBrowser.albumDone = false
|
|
} else {
|
|
// Lazy-load page: append.
|
|
m.navBrowser.albums = append(m.navBrowser.albums, msg.albums...)
|
|
}
|
|
if msg.isLast {
|
|
m.navBrowser.albumDone = true
|
|
}
|
|
m.navBrowser.albumLoading = false
|
|
if msg.offset == 0 {
|
|
m.navBrowser.cursor = 0
|
|
m.navBrowser.scroll = 0
|
|
}
|
|
// If we just loaded the first page and it was a full menu → list transition,
|
|
// also clear the general loading flag.
|
|
m.navBrowser.loading = false
|
|
return m, nil
|
|
|
|
case navTracksLoadedMsg:
|
|
m.navBrowser.tracks = []playlist.Track(msg)
|
|
m.setHeaderStateFromTracks(m.navBrowser.tracks)
|
|
m.navBrowser.loading = false
|
|
m.navBrowser.cursor = 0
|
|
m.navBrowser.scroll = 0
|
|
m.navBrowser.screen = navBrowseScreenTracks
|
|
return m, nil
|
|
|
|
case catalogBatchMsg:
|
|
m.catalogBatch.loading = false
|
|
if msg.err != nil {
|
|
m.catalogBatch.done = true
|
|
m.status.Show("Catalog load failed", statusTTLDefault)
|
|
return m, nil
|
|
}
|
|
if msg.added == 0 {
|
|
m.catalogBatch.done = true
|
|
return m, nil
|
|
}
|
|
if lists, err := m.provider.Playlists(); err == nil {
|
|
m.providerLists = lists
|
|
}
|
|
m.catalogBatch.offset += msg.added
|
|
if msg.added < catalogBatchSize {
|
|
m.catalogBatch.done = true
|
|
}
|
|
return m, nil
|
|
|
|
case catalogSearchMsg:
|
|
m.provLoading = false
|
|
if msg.err != nil {
|
|
m.status.Show("Search failed", statusTTLDefault)
|
|
} else {
|
|
if lists, err := m.provider.Playlists(); err == nil {
|
|
m.providerLists = lists
|
|
}
|
|
m.provCursor = 0
|
|
m.provScroll = 0
|
|
if msg.count == 0 {
|
|
m.status.Show("No stations found", statusTTLDefault)
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
case ytdlBatchMsg:
|
|
// Discard stale responses from a previous batch session.
|
|
if msg.gen != m.ytdlBatch.gen {
|
|
return m, nil
|
|
}
|
|
m.ytdlBatch.loading = false
|
|
if msg.err != nil {
|
|
m.ytdlBatch.done = true
|
|
m.status.Showf(statusTTLBatch, "Radio batch load failed: %v", msg.err)
|
|
return m, nil
|
|
}
|
|
if len(msg.tracks) == 0 {
|
|
m.ytdlBatch.done = true
|
|
return m, nil
|
|
}
|
|
m.playlist.Add(msg.tracks...)
|
|
m.loadedPlaylist = ""
|
|
m.addToHeaderState(msg.tracks)
|
|
m.ytdlBatch.offset += len(msg.tracks)
|
|
if len(msg.tracks) < ytdlBatchSize {
|
|
m.ytdlBatch.done = true
|
|
return m, nil
|
|
}
|
|
// Immediately fetch the next batch.
|
|
m.ytdlBatch.loading = true
|
|
return m, fetchYTDLBatchCmd(m.ytdlBatch.gen, m.ytdlBatch.url, m.ytdlBatch.offset, ytdlBatchSize)
|
|
|
|
case feedTrackResolvedMsg:
|
|
m.feedLoading = false
|
|
if len(msg.tracks) == 0 {
|
|
m.status.Show("No episodes found in feed.", statusTTLDefault)
|
|
return m, nil
|
|
}
|
|
m.playlist.Replace(msg.tracks)
|
|
m.loadedPlaylist = ""
|
|
m.setHeaderStateFromTracks(msg.tracks)
|
|
m.plCursor = 0
|
|
m.plScroll = 0
|
|
m.applyHeightMode()
|
|
m.adjustScroll()
|
|
m.status.Showf(statusTTLDefault, "Loaded %d episode(s)", len(msg.tracks))
|
|
playCmd := m.playCurrentTrack()
|
|
m.notifyAll()
|
|
return m, playCmd
|
|
|
|
case feedsLoadedMsg:
|
|
m.feedLoading = false
|
|
if len(msg.tracks) > 0 {
|
|
m.playlist.Add(msg.tracks...)
|
|
m.loadedPlaylist = ""
|
|
m.addToHeaderState(msg.tracks)
|
|
m.status.Showf(statusTTLDefault, "Loaded %d track(s)", len(msg.tracks))
|
|
} else {
|
|
m.status.Show("No tracks found at URL.", statusTTLDefault)
|
|
}
|
|
if len(msg.tracks) > 0 {
|
|
// Set up incremental loading for YouTube Radio playlists.
|
|
// The source URLs are carried in the message so we don't
|
|
// need to re-scan pendingURLs (which misses interactive loads).
|
|
batchCmd := m.initYTDLBatch(msg.urls)
|
|
if msg.autoPlay && m.playlist.Len() > 0 && !m.player.IsPlaying() {
|
|
playCmd := m.playCurrentTrack()
|
|
m.notifyAll()
|
|
if batchCmd != nil {
|
|
return m, tea.Batch(playCmd, batchCmd)
|
|
}
|
|
return m, playCmd
|
|
}
|
|
if batchCmd != nil {
|
|
return m, batchCmd
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
case netSearchResultsMsg:
|
|
m.netSearch.loading = false
|
|
m.netSearch.cursor = 0
|
|
m.netSearch.scroll = 0
|
|
if msg.err != nil {
|
|
m.netSearch.err = msg.err.Error()
|
|
return m, nil
|
|
}
|
|
m.netSearch.results = msg.tracks
|
|
m.netSearch.cursor = 0
|
|
m.netSearch.screen = netSearchResults
|
|
if len(msg.tracks) == 0 {
|
|
m.netSearch.err = "No results found"
|
|
}
|
|
return m, nil
|
|
|
|
case lyricsLoadedMsg:
|
|
m.lyrics.loading = false
|
|
m.lyrics.err = msg.err
|
|
m.lyrics.scroll = 0
|
|
if msg.err == nil {
|
|
m.lyrics.lines = msg.lines
|
|
}
|
|
return m, nil
|
|
|
|
case fbTracksResolvedMsg:
|
|
if len(msg.tracks) == 0 {
|
|
m.status.Show("No audio files found", statusTTLDefault)
|
|
return m, nil
|
|
}
|
|
if msg.targetPlaylist != "" {
|
|
added, skipped, err := m.writeTracksToPlaylist(msg.targetPlaylist, msg.tracks)
|
|
if err != nil {
|
|
m.status.Showf(statusTTLDefault, "Add failed: %s", err)
|
|
} else if skipped > 0 {
|
|
m.status.Showf(statusTTLBatch, "Added %d to %q, skipped %d duplicates", added, msg.targetPlaylist, skipped)
|
|
} else {
|
|
m.status.Showf(statusTTLDefault, "Added %d to %q", added, msg.targetPlaylist)
|
|
}
|
|
m.refreshPlaylistManagerAfterWrite(msg.targetPlaylist)
|
|
return m, nil
|
|
}
|
|
if msg.toPlaylist {
|
|
m.openPlaylistPicker(msg.tracks, fmt.Sprintf("%d tracks selected", len(msg.tracks)))
|
|
return m, nil
|
|
}
|
|
if msg.replace {
|
|
m.player.Stop()
|
|
m.player.ClearPreload()
|
|
m.resetYTDLBatch()
|
|
m.playlist.Replace(msg.tracks)
|
|
m.loadedPlaylist = ""
|
|
m.setHeaderStateFromTracks(msg.tracks)
|
|
m.plCursor = 0
|
|
m.plScroll = 0
|
|
} else {
|
|
m.playlist.Add(msg.tracks...)
|
|
m.loadedPlaylist = ""
|
|
m.addToHeaderState(msg.tracks)
|
|
}
|
|
m.focus = focusPlaylist
|
|
m.applyHeightMode()
|
|
m.adjustScroll()
|
|
m.status.Showf(statusTTLDefault, "Added %d track(s)", len(msg.tracks))
|
|
if !m.player.IsPlaying() && m.playlist.Len() > 0 {
|
|
if msg.replace {
|
|
m.playlist.SetIndex(0)
|
|
}
|
|
cmd := m.playCurrentTrack()
|
|
m.notifyAll()
|
|
return m, cmd
|
|
}
|
|
return m, nil
|
|
|
|
case streamPlayedMsg:
|
|
m.buffering = false
|
|
if msg.err != nil {
|
|
m.err = msg.err
|
|
if track, idx := m.currentPlaybackTrack(); idx >= 0 {
|
|
m.status.Showf(statusTTLLong, "Couldn't play %s — track is gated, restricted, or unavailable.", track.DisplayName())
|
|
}
|
|
} else {
|
|
m.err = nil
|
|
m.reconnect.attempts = 0
|
|
m.reconnect.at = time.Time{}
|
|
m.applyResume()
|
|
}
|
|
m.notifyAll()
|
|
return m, m.preloadNext()
|
|
|
|
case streamPreloadedMsg:
|
|
m.preloading = false
|
|
return m, nil
|
|
|
|
case ytdlSavedMsg:
|
|
m.save.finishDownload()
|
|
if msg.err != nil {
|
|
m.status.Showf(statusTTLMedium, "Download failed: %s", msg.err)
|
|
} else {
|
|
m.status.Showf(statusTTLMedium, "Saved to %s", msg.path)
|
|
}
|
|
return m, nil
|
|
|
|
case ytdlResolvedMsg:
|
|
m.buffering = false
|
|
if msg.err != nil {
|
|
m.err = msg.err
|
|
return m, nil
|
|
}
|
|
// Update the track with the downloaded local file and metadata.
|
|
m.playlist.SetTrack(msg.index, msg.track)
|
|
// Play the local file (seekable).
|
|
cmd := m.playTrack(msg.track)
|
|
m.notifyAll()
|
|
return m, cmd
|
|
|
|
case error:
|
|
if errors.Is(msg, playlist.ErrNeedsAuth) {
|
|
m.provLoading = false
|
|
m.provSignIn = true
|
|
m.err = nil
|
|
return m, nil
|
|
}
|
|
m.err = msg
|
|
m.provLoading = false
|
|
m.feedLoading = false
|
|
m.buffering = false
|
|
return m, nil
|
|
|
|
case spotSearchResultsMsg:
|
|
m.spotSearch.loading = false
|
|
m.spotSearch.cursor = 0
|
|
m.spotSearch.scroll = 0
|
|
if msg.err != nil {
|
|
m.spotSearch.err = msg.err.Error()
|
|
return m, nil
|
|
}
|
|
m.spotSearch.results = msg.tracks
|
|
m.spotSearch.cursor = 0
|
|
m.spotSearch.screen = spotSearchResults
|
|
if len(msg.tracks) == 0 {
|
|
m.spotSearch.err = "No results found"
|
|
}
|
|
return m, nil
|
|
|
|
case spotPlaylistsMsg:
|
|
m.spotSearch.loading = false
|
|
m.spotSearch.cursor = 0
|
|
m.spotSearch.scroll = 0
|
|
if msg.err != nil {
|
|
m.spotSearch.err = msg.err.Error()
|
|
return m, nil
|
|
}
|
|
m.spotSearch.playlists = msg.playlists
|
|
m.spotSearch.cursor = 0
|
|
m.spotSearch.screen = spotSearchPlaylist
|
|
return m, nil
|
|
|
|
case spotAddedMsg:
|
|
m.spotSearch.loading = false
|
|
if msg.err != nil {
|
|
m.spotSearch.err = "Add failed: " + msg.err.Error()
|
|
return m, nil
|
|
}
|
|
m.status.Showf(statusTTLDefault, "Added to %q", msg.name)
|
|
m.closeSpotSearch()
|
|
return m, nil
|
|
|
|
case spotCreatedMsg:
|
|
m.spotSearch.loading = false
|
|
if msg.err != nil {
|
|
m.spotSearch.err = "Create failed: " + msg.err.Error()
|
|
return m, nil
|
|
}
|
|
m.status.Showf(statusTTLDefault, "Created %q & added track", msg.name)
|
|
m.closeSpotSearch()
|
|
return m, nil
|
|
|
|
case provAuthDoneMsg:
|
|
m.provAuthURL = ""
|
|
if msg.err != nil {
|
|
m.err = msg.err
|
|
m.provLoading = false
|
|
m.provSignIn = false
|
|
return m, nil
|
|
}
|
|
m.provSignIn = false
|
|
m.provLoading = true
|
|
return m, fetchPlaylistsCmd(m.provider)
|
|
|
|
case ProvAuthURLMsg:
|
|
m.provAuthURL = msg.URL
|
|
return m, nil
|
|
|
|
case devicesListedMsg:
|
|
m.devicePicker.loading = false
|
|
if msg.err != nil {
|
|
m.status.Showf(statusTTLDefault, "Device list failed: %s", msg.err)
|
|
m.devicePicker.visible = false
|
|
} else {
|
|
m.devicePicker.devices = msg.devices
|
|
}
|
|
return m, nil
|
|
|
|
case deviceSwitchedMsg:
|
|
if msg.err != nil {
|
|
m.status.Showf(statusTTLDefault, "Switch failed: %s", msg.err)
|
|
} else {
|
|
m.status.Showf(statusTTLDefault, "Audio output: %s", msg.name)
|
|
_ = m.configSaver.Save("audio_device", msg.name)
|
|
}
|
|
// Invalidate cached list so the next open refreshes Active markers.
|
|
m.devicePicker.devices = nil
|
|
return m, nil
|
|
|
|
case attachNotifierMsg:
|
|
m.attachNotifier(msg.notifier)
|
|
return m, nil
|
|
|
|
case playback.PlayPauseMsg:
|
|
cmd := m.togglePlayPause()
|
|
m.notifyAll()
|
|
return m, cmd
|
|
|
|
case playback.PlayMsg:
|
|
if !m.player.IsPlaying() || m.player.IsPaused() {
|
|
cmd := m.togglePlayPause()
|
|
m.notifyAll()
|
|
return m, cmd
|
|
}
|
|
return m, nil
|
|
|
|
case playback.PauseMsg:
|
|
if m.player.IsPlaying() && !m.player.IsPaused() {
|
|
m.togglePlayerPause()
|
|
m.notifyAll()
|
|
}
|
|
return m, nil
|
|
|
|
case playback.NextMsg:
|
|
m.scrobbleCurrent()
|
|
cmd := m.nextTrack()
|
|
m.notifyAll()
|
|
return m, cmd
|
|
|
|
case playback.PrevMsg:
|
|
m.scrobbleCurrent()
|
|
cmd := m.prevTrack()
|
|
m.notifyAll()
|
|
return m, cmd
|
|
|
|
case playback.SeekMsg:
|
|
return m, m.seekRelative(msg.Offset, 0)
|
|
|
|
case playback.SetPositionMsg:
|
|
return m, m.seekAbsolute(msg.Position)
|
|
|
|
case playback.SetVolumeMsg:
|
|
m.player.SetVolume(msg.VolumeDB)
|
|
m.notifyAll()
|
|
return m, nil
|
|
|
|
case playback.StopMsg:
|
|
m.player.Stop()
|
|
m.clearPlaybackTrack()
|
|
m.notifyAll()
|
|
return m, nil
|
|
|
|
case playback.QuitMsg:
|
|
m.flushPendingSpeedSave()
|
|
m.player.Close()
|
|
m.clearPlaybackTrack()
|
|
m.quitting = true
|
|
return m, tea.Quit
|
|
|
|
case SetEQPresetMsg:
|
|
m.SetEQPreset(msg.Name, msg.Bands)
|
|
return m, nil
|
|
|
|
case PluginQueueMsg:
|
|
return m, m.handlePluginQueue(msg)
|
|
|
|
case pluginQueueAddedMsg:
|
|
if len(msg.tracks) > 0 {
|
|
m.playlist.Add(msg.tracks...)
|
|
m.loadedPlaylist = ""
|
|
m.notifyPlayback()
|
|
}
|
|
return m, nil
|
|
|
|
case ShowStatusMsg:
|
|
ttl := statusTTLDefault
|
|
if msg.Duration > 0 {
|
|
ttl = statusTTL(msg.Duration)
|
|
}
|
|
m.status.Show(msg.Text, ttl)
|
|
return m, nil
|
|
|
|
// IPC-specific messages (PlayMsg, PauseMsg have different semantics from toggle).
|
|
// Shared types (NextMsg, PrevMsg, StopMsg, PlayPauseMsg) are handled above via
|
|
// playback.* types.
|
|
case ipc.PlayMsg:
|
|
if m.player.IsPaused() {
|
|
cmd := m.togglePlayPause()
|
|
m.notifyAll()
|
|
return m, cmd
|
|
}
|
|
return m, nil
|
|
case ipc.PauseMsg:
|
|
if m.player.IsPlaying() && !m.player.IsPaused() {
|
|
cmd := m.togglePlayPause()
|
|
m.notifyAll()
|
|
return m, cmd
|
|
}
|
|
return m, nil
|
|
case ipc.VolumeMsg:
|
|
m.player.SetVolume(msg.DB)
|
|
m.notifyAll()
|
|
return m, nil
|
|
case ipc.SeekMsg:
|
|
_ = m.player.Seek(msg.Offset)
|
|
m.notifyAll()
|
|
return m, nil
|
|
case ipc.LoadMsg:
|
|
tracks, err := m.localProvider.Tracks(msg.Playlist)
|
|
if err != nil {
|
|
if msg.Reply != nil {
|
|
msg.Reply <- ipc.Response{OK: false, Error: fmt.Sprintf("playlist %q: %v", msg.Playlist, err)}
|
|
}
|
|
return m, nil
|
|
}
|
|
m.playlist.Replace(tracks)
|
|
m.setHeaderStateFromTracks(tracks)
|
|
if msg.Playlist != history.PlaylistName {
|
|
m.loadedPlaylist = msg.Playlist
|
|
} else {
|
|
m.loadedPlaylist = ""
|
|
}
|
|
cmd := m.playCurrentTrack()
|
|
m.notifyAll()
|
|
if msg.Reply != nil {
|
|
msg.Reply <- ipc.Response{OK: true, Playlist: msg.Playlist, Total: len(tracks)}
|
|
}
|
|
return m, cmd
|
|
case ipc.QueueMsg:
|
|
t := playlist.TrackFromPath(msg.Path)
|
|
m.playlist.Add(t)
|
|
m.loadedPlaylist = ""
|
|
m.addToHeaderState([]playlist.Track{t})
|
|
m.notifyAll()
|
|
return m, nil
|
|
case ipc.ThemeMsg:
|
|
// Reload themes from disk to pick up new custom themes.
|
|
// Same pattern as openThemePicker() — LoadAll is fast (<1ms for local TOML files).
|
|
m.themes = theme.LoadAll()
|
|
if m.SetTheme(msg.Name) {
|
|
// Persist immediately so the setting survives ungraceful exits.
|
|
themeName := msg.Name
|
|
if strings.EqualFold(themeName, "default") {
|
|
themeName = ""
|
|
}
|
|
_ = m.configSaver.Save("theme", fmt.Sprintf("%q", themeName))
|
|
if msg.Reply != nil {
|
|
msg.Reply <- ipc.Response{OK: true}
|
|
}
|
|
} else {
|
|
if msg.Reply != nil {
|
|
msg.Reply <- ipc.Response{OK: false, Error: fmt.Sprintf("theme %q not found", msg.Name)}
|
|
}
|
|
}
|
|
return m, nil
|
|
case ipc.VisMsg:
|
|
if m.vis == nil {
|
|
if msg.Reply != nil {
|
|
msg.Reply <- ipc.Response{OK: false, Error: "visualizer not available"}
|
|
}
|
|
return m, nil
|
|
}
|
|
var resp ipc.Response
|
|
if strings.EqualFold(msg.Name, "next") {
|
|
m.vis.CycleMode()
|
|
m.vis.RequestRefresh()
|
|
m.refreshChrome()
|
|
resp = ipc.Response{OK: true, Visualizer: m.vis.ModeName()}
|
|
} else if m.SetVisualizer(msg.Name) {
|
|
resp = ipc.Response{OK: true, Visualizer: m.vis.ModeName()}
|
|
} else {
|
|
resp = ipc.Response{OK: false, Error: fmt.Sprintf("visualizer %q not found", msg.Name)}
|
|
}
|
|
if msg.Reply != nil {
|
|
msg.Reply <- resp
|
|
}
|
|
return m, nil
|
|
case ipc.ShuffleMsg:
|
|
switch strings.ToLower(msg.Name) {
|
|
case "on":
|
|
if !m.playlist.Shuffled() {
|
|
m.playlist.ToggleShuffle()
|
|
}
|
|
case "off":
|
|
if m.playlist.Shuffled() {
|
|
m.playlist.ToggleShuffle()
|
|
}
|
|
default: // "toggle" or empty
|
|
m.playlist.ToggleShuffle()
|
|
}
|
|
shuffled := m.playlist.Shuffled()
|
|
if err := m.configSaver.Save("shuffle", fmt.Sprintf("%v", shuffled)); err != nil {
|
|
m.status.Showf(statusTTLDefault, "Config save failed: %s", err)
|
|
}
|
|
m.player.ClearPreload()
|
|
cmd := m.preloadNext()
|
|
if msg.Reply != nil {
|
|
msg.Reply <- ipc.Response{OK: true, Shuffle: &shuffled}
|
|
}
|
|
return m, cmd
|
|
|
|
case ipc.RepeatMsg:
|
|
switch strings.ToLower(msg.Name) {
|
|
case "off":
|
|
m.playlist.SetRepeat(playlist.RepeatOff)
|
|
case "all":
|
|
m.playlist.SetRepeat(playlist.RepeatAll)
|
|
case "one":
|
|
m.playlist.SetRepeat(playlist.RepeatOne)
|
|
default: // "cycle" or empty
|
|
m.playlist.CycleRepeat()
|
|
}
|
|
mode := m.playlist.Repeat()
|
|
if err := m.configSaver.Save("repeat", fmt.Sprintf("%q", mode.String())); err != nil {
|
|
m.status.Showf(statusTTLDefault, "Config save failed: %s", err)
|
|
}
|
|
m.player.ClearPreload()
|
|
cmd := m.preloadNext()
|
|
if msg.Reply != nil {
|
|
msg.Reply <- ipc.Response{OK: true, Repeat: mode.String()}
|
|
}
|
|
return m, cmd
|
|
|
|
case ipc.MonoMsg:
|
|
switch strings.ToLower(msg.Name) {
|
|
case "on":
|
|
if !m.player.Mono() {
|
|
m.player.ToggleMono()
|
|
}
|
|
case "off":
|
|
if m.player.Mono() {
|
|
m.player.ToggleMono()
|
|
}
|
|
default: // "toggle" or empty
|
|
m.player.ToggleMono()
|
|
}
|
|
mono := m.player.Mono()
|
|
if msg.Reply != nil {
|
|
msg.Reply <- ipc.Response{OK: true, Mono: &mono}
|
|
}
|
|
return m, nil
|
|
|
|
case ipc.SpeedMsg:
|
|
m.player.SetSpeed(msg.Speed)
|
|
m.saveSpeed()
|
|
if msg.Reply != nil {
|
|
msg.Reply <- ipc.Response{OK: true, Speed: m.player.Speed()}
|
|
}
|
|
return m, nil
|
|
|
|
case ipc.EQMsg:
|
|
if msg.Band > 0 || (msg.Band == 0 && msg.Name == "") {
|
|
// Set a specific band (0-9).
|
|
m.player.SetEQBand(msg.Band, msg.Value)
|
|
m.saveEQ()
|
|
if msg.Reply != nil {
|
|
msg.Reply <- ipc.Response{OK: true, EQPreset: m.EQPresetName()}
|
|
}
|
|
} else if msg.Name != "" {
|
|
// Apply a preset by name.
|
|
m.SetEQPreset(msg.Name, nil)
|
|
m.saveEQ()
|
|
if msg.Reply != nil {
|
|
msg.Reply <- ipc.Response{OK: true, EQPreset: m.EQPresetName()}
|
|
}
|
|
} else {
|
|
if msg.Reply != nil {
|
|
msg.Reply <- ipc.Response{OK: false, Error: "eq requires a preset name or --band"}
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
case ipc.DeviceMsg:
|
|
if strings.EqualFold(msg.Name, "list") {
|
|
devices, err := player.ListAudioDevices()
|
|
if err != nil {
|
|
if msg.Reply != nil {
|
|
msg.Reply <- ipc.Response{OK: false, Error: fmt.Sprintf("list devices: %v", err)}
|
|
}
|
|
return m, nil
|
|
}
|
|
// Encode device list as newline-separated string in the Device field.
|
|
var lines []string
|
|
for _, d := range devices {
|
|
marker := " "
|
|
if d.Active {
|
|
marker = "* "
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%s%s", marker, d.Name))
|
|
}
|
|
if msg.Reply != nil {
|
|
msg.Reply <- ipc.Response{OK: true, Device: strings.Join(lines, "\n")}
|
|
}
|
|
return m, nil
|
|
}
|
|
err := player.SwitchAudioDevice(msg.Name)
|
|
if err != nil {
|
|
if msg.Reply != nil {
|
|
msg.Reply <- ipc.Response{OK: false, Error: fmt.Sprintf("switch device: %v", err)}
|
|
}
|
|
return m, nil
|
|
}
|
|
_ = m.configSaver.Save("audio_device", msg.Name)
|
|
m.status.Showf(statusTTLDefault, "Audio output: %s", msg.Name)
|
|
// Invalidate cached list so the next open refreshes Active markers.
|
|
m.devicePicker.devices = nil
|
|
if msg.Reply != nil {
|
|
msg.Reply <- ipc.Response{OK: true, Device: msg.Name}
|
|
}
|
|
return m, nil
|
|
|
|
case ipc.StatusRequestMsg:
|
|
resp := ipc.Response{OK: true}
|
|
switch {
|
|
case m.player.IsPlaying() && !m.player.IsPaused():
|
|
resp.State = "playing"
|
|
case m.player.IsPaused():
|
|
resp.State = "paused"
|
|
default:
|
|
resp.State = "stopped"
|
|
}
|
|
if cur, _ := m.currentPlaybackTrack(); cur.Path != "" {
|
|
resp.Track = &ipc.TrackInfo{
|
|
Title: cur.Title,
|
|
Artist: cur.Artist,
|
|
Path: cur.Path,
|
|
}
|
|
}
|
|
resp.Position = m.player.Position().Seconds()
|
|
resp.Duration = m.player.Duration().Seconds()
|
|
resp.Volume = m.player.Volume()
|
|
resp.Index = m.playlist.Index()
|
|
resp.Total = m.playlist.Len()
|
|
resp.Visualizer = m.vis.ModeName()
|
|
shuffled := m.playlist.Shuffled()
|
|
resp.Shuffle = &shuffled
|
|
resp.Repeat = m.playlist.Repeat().String()
|
|
mono := m.player.Mono()
|
|
resp.Mono = &mono
|
|
resp.Speed = m.player.Speed()
|
|
resp.EQPreset = m.EQPresetName()
|
|
if m.themeIdx >= 0 && m.themeIdx < len(m.themes) {
|
|
t := m.themes[m.themeIdx]
|
|
resp.Theme = &ipc.ThemeInfo{
|
|
Name: t.Name,
|
|
Accent: t.Accent,
|
|
Fg: t.FG,
|
|
BrightFg: t.BrightFG,
|
|
Green: t.Green,
|
|
Yellow: t.Yellow,
|
|
Red: t.Red,
|
|
}
|
|
} else {
|
|
resp.Theme = &ipc.ThemeInfo{Name: theme.DefaultName}
|
|
}
|
|
if msg.Reply != nil {
|
|
msg.Reply <- resp
|
|
}
|
|
return m, nil
|
|
|
|
case ipc.BandsRequestMsg:
|
|
resp := ipc.Response{OK: true}
|
|
if m.vis != nil {
|
|
resp.Visualizer = m.vis.ModeName()
|
|
b := m.vis.SmoothedBands()
|
|
if len(b) > 0 {
|
|
out := make([]float64, len(b))
|
|
copy(out, b)
|
|
resp.Bands = out
|
|
}
|
|
}
|
|
if msg.Reply != nil {
|
|
msg.Reply <- resp
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// restorePanelWidth resets PanelWidth to the correct value based on compact mode.
|
|
func (m *Model) restorePanelWidth() {
|
|
frameW := m.width
|
|
if m.compact {
|
|
frameW = min(frameW, 80)
|
|
}
|
|
ui.PanelWidth = max(0, frameW-2*ui.PaddingH)
|
|
}
|