23b3222a62
* feat(spotify): return albums from search
/v1/search was asked for type=track,episode, so an album could never appear
in the results. Searching an artist returned whichever of their tracks
Spotify ranked highest, and there was no way to reach a record as a record.
Albums are now requested too and lead the results, as an album placeholder:
a Track carrying the album's name, artist and year, marked through
ProviderMeta so the UI can tell it apart without knowing which provider
produced it. Placeholders are not streamable, because spotify:album: URIs
are not something go-librespot can play, so SpotifyProvider now implements
provider.AlbumTrackLoader to expand a chosen one into its tracks.
/v1/albums/{id}/tracks returns simplified track objects without the album
they belong to, so the album's own metadata is fetched once and filled in
on every track for display.
* feat(ui): play a whole album from the search results
Enter, a and q on an album expand it through AlbumTrackLoader and then act
on the full record, matching what they already did for a single track:
Enter starts it now, a appends it, q queues it next. Like playTrackImmediate
they add rather than replace, so a queue built up over an evening survives
picking an album.
The overlay stays open while the expansion runs, showing "Loading album...":
closing it would bump the request generation and drop the response. The
in-flight flag is separate from the playlist fetch's so the results screen
only claims to be loading an album when it is.
p is refused on an album with an explanation. The playlist picker adds one
track, an album is many, and Spotify has no single call to add a record to
a playlist.
* feat(ui): group search results into labeled sections
With albums and tracks in one flat list an album read exactly like one of
its own tracks. The results now carry "Albums" and "Tracks" separators in
the same style the playlist already uses for album headers, and the label
repeats at the top of the viewport when it opens mid-section.
Separators take rows of their own, so scrolling counts rendered rows rather
than results, the way albumSeparatorRows does for the playlist. Without it
the cursor could sit below the bottom of the window.
1271 lines
34 KiB
Go
1271 lines
34 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"
|
|
)
|
|
|
|
func (m *Model) scheduleReconnect(now time.Time) {
|
|
if !m.reconnect.at.IsZero() || m.reconnect.attempts >= 5 {
|
|
return
|
|
}
|
|
delay := time.Second << m.reconnect.attempts
|
|
m.reconnect.at = now.Add(delay)
|
|
m.reconnect.attempts++
|
|
m.err = fmt.Errorf("reconnecting in %s", delay)
|
|
}
|
|
|
|
// Update handles messages: key presses, ticks, and window resizes.
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
wasScreen := m.activeScreen()
|
|
wasVisualizerVisible := m.visualizerVisible()
|
|
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, wasVisualizerVisible, 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
|
|
m.recomputeLayout()
|
|
m.normalizeMainFocus()
|
|
m.clampActiveScrollState()
|
|
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)
|
|
m.tickProgressReport(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)
|
|
m.tickPendingEQSave(dt)
|
|
if m.pendingSeekActive && !m.pendingSeekExpiresAt.IsZero() && !now.Before(m.pendingSeekExpiresAt) {
|
|
m.pendingSeekActive = false
|
|
m.pendingSeekExpiresAt = time.Time{}
|
|
}
|
|
// 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 {
|
|
m.scheduleReconnect(now)
|
|
} 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, q, nextRequest(&m.requests.lyrics))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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()
|
|
m.normalizeQueueOverlay()
|
|
}
|
|
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() {
|
|
finishedTrack, idx := m.currentPlaybackTrack()
|
|
if idx >= 0 && finishedTrack.IsLive() {
|
|
// A live stream has no natural end. A clean decoder EOF is a
|
|
// disconnect, so retry this station instead of advancing.
|
|
m.scheduleReconnect(now)
|
|
} else {
|
|
// Track drained to end — always ≥ 50%.
|
|
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 playlistsLoadedMsg:
|
|
if msg.gen != m.requests.provider || !m.isActiveProvider(msg.providerName) {
|
|
return m, nil
|
|
}
|
|
m.provLoading = false
|
|
if msg.err != nil {
|
|
if errors.Is(msg.err, playlist.ErrNeedsAuth) {
|
|
m.provSignIn = true
|
|
m.err = nil
|
|
return m, nil
|
|
}
|
|
m.err = msg.err
|
|
return m, nil
|
|
}
|
|
m.providerLists = msg.playlists
|
|
// 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, m.fetchCatalogBatch(loader)
|
|
}
|
|
return m, nil
|
|
|
|
case tracksLoadedMsg:
|
|
if msg.gen != m.requests.tracks || !m.isActiveProvider(msg.providerName) {
|
|
return m, nil
|
|
}
|
|
m.provLoading = false
|
|
if msg.err != nil {
|
|
if errors.Is(msg.err, playlist.ErrNeedsAuth) {
|
|
m.provSignIn = true
|
|
m.err = nil
|
|
return m, nil
|
|
}
|
|
m.err = msg.err
|
|
return m, nil
|
|
}
|
|
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.replacePlaylist(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.applyTracksResume(msg)
|
|
m.focus = focusPlaylist
|
|
m.applyHeightMode()
|
|
m.adjustScroll()
|
|
m.notifyAll()
|
|
return m, nil
|
|
|
|
case navArtistsLoadedMsg:
|
|
if !m.isCurrentNavRequest(msg.gen) {
|
|
return m, nil
|
|
}
|
|
m.navBrowser.loading = false
|
|
if msg.err != nil {
|
|
m.status.Errorf(statusTTLDefault, "Artist load failed: %s", msg.err)
|
|
return m, nil
|
|
}
|
|
m.navBrowser.artists = msg.artists
|
|
m.navBrowser.cursor = 0
|
|
m.navBrowser.scroll = 0
|
|
return m, nil
|
|
|
|
case navAlbumsLoadedMsg:
|
|
if !m.isCurrentNavRequest(msg.gen) {
|
|
return m, nil
|
|
}
|
|
m.navBrowser.albumLoading = false
|
|
m.navBrowser.loading = false
|
|
if msg.err != nil {
|
|
m.status.Errorf(statusTTLDefault, "Album load failed: %s", msg.err)
|
|
return m, nil
|
|
}
|
|
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
|
|
}
|
|
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.
|
|
return m, nil
|
|
|
|
case navTracksLoadedMsg:
|
|
if !m.isCurrentNavRequest(msg.gen) {
|
|
return m, nil
|
|
}
|
|
m.navBrowser.loading = false
|
|
if msg.err != nil {
|
|
m.status.Errorf(statusTTLDefault, "Track load failed: %s", msg.err)
|
|
return m, nil
|
|
}
|
|
m.navBrowser.tracks = msg.tracks
|
|
m.setHeaderStateFromTracks(m.navBrowser.tracks)
|
|
m.navBrowser.cursor = 0
|
|
m.navBrowser.scroll = 0
|
|
m.navBrowser.screen = navBrowseScreenTracks
|
|
return m, nil
|
|
|
|
case catalogBatchMsg:
|
|
if msg.gen != m.requests.catalog || !m.isActiveProvider(msg.providerName) {
|
|
return m, nil
|
|
}
|
|
m.catalogBatch.loading = false
|
|
if msg.err != nil {
|
|
m.catalogBatch.done = true
|
|
m.status.Error("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:
|
|
if msg.gen != m.requests.catalog || !m.isActiveProvider(msg.providerName) {
|
|
return m, nil
|
|
}
|
|
m.provLoading = false
|
|
if msg.err != nil {
|
|
m.status.Error("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.Warning("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.Errorf(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.Warning("No episodes found in feed.", statusTTLDefault)
|
|
return m, nil
|
|
}
|
|
m.replacePlaylist(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.Warning("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:
|
|
if msg.gen != m.requests.netSearch || !m.netSearch.active || msg.query != m.netSearch.request {
|
|
return m, nil
|
|
}
|
|
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"
|
|
}
|
|
m.applyHeightMode()
|
|
m.clampActiveScrollState()
|
|
return m, nil
|
|
|
|
case lyricsLoadedMsg:
|
|
if msg.gen != m.requests.lyrics || !m.lyrics.visible || msg.query != m.lyrics.query {
|
|
return m, nil
|
|
}
|
|
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.Warning("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.Errorf(statusTTLDefault, "Add failed: %s", err)
|
|
} else if skipped > 0 {
|
|
m.status.Warningf(statusTTLBatch, "Added %d to %q, skipped %d duplicates", added, msg.targetPlaylist, skipped)
|
|
} else if added > 0 {
|
|
m.status.Showf(statusTTLDefault, "Added %d to %q", added, msg.targetPlaylist)
|
|
} else {
|
|
m.status.Warningf(statusTTLDefault, "Nothing added to %q", 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.replacePlaylist(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()
|
|
if msg.replace {
|
|
m.status.Successf(statusTTLDefault, "Replaced queue with %d track(s)", len(msg.tracks))
|
|
} else {
|
|
m.status.Successf(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:
|
|
track, _ := m.currentPlaybackTrack()
|
|
if msg.gen != m.requests.stream || msg.path != track.Path {
|
|
return m, nil
|
|
}
|
|
m.buffering = false
|
|
if msg.err != nil {
|
|
m.err = msg.err
|
|
if track, idx := m.currentPlaybackTrack(); idx >= 0 {
|
|
m.status.Errorf(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:
|
|
if msg.gen != m.requests.preload {
|
|
return m, nil
|
|
}
|
|
m.preloading = false
|
|
return m, nil
|
|
|
|
case ytdlSavedMsg:
|
|
m.save.finishDownload()
|
|
if msg.err != nil {
|
|
m.status.Errorf(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:
|
|
if !m.isCurrentSpotRequest(msg.gen, msg.providerName) || m.spotSearch.query != msg.query {
|
|
return m, nil
|
|
}
|
|
m.cancelSpotRequest()
|
|
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
|
|
m.applyHeightMode()
|
|
m.clampActiveScrollState()
|
|
return m, nil
|
|
|
|
case spotAlbumTracksMsg:
|
|
if msg.gen != m.requests.spotAlbum {
|
|
return m, nil
|
|
}
|
|
m.spotSearch.albumLoading = false
|
|
if msg.err != nil {
|
|
m.spotSearch.err = msg.err.Error()
|
|
return m, nil
|
|
}
|
|
if len(msg.tracks) == 0 {
|
|
m.spotSearch.err = "That album has no tracks available here."
|
|
return m, nil
|
|
}
|
|
album := msg.album
|
|
tracks := msg.tracks
|
|
m.closeSpotSearch()
|
|
switch msg.action {
|
|
case spotAlbumAppend:
|
|
return m, m.appendAlbum(album, tracks)
|
|
case spotAlbumQueueNext:
|
|
return m, m.queueAlbumNext(album, tracks)
|
|
default:
|
|
return m, m.playAlbumImmediate(album, tracks)
|
|
}
|
|
|
|
case spotPlaylistsMsg:
|
|
if !m.isCurrentSpotListRequest(msg.gen, msg.providerName) {
|
|
return m, nil
|
|
}
|
|
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
|
|
m.applyHeightMode()
|
|
m.clampActiveScrollState()
|
|
return m, nil
|
|
|
|
case spotAddedMsg:
|
|
if !m.isCurrentSpotMutation(msg.gen, msg.providerName) {
|
|
return m, nil
|
|
}
|
|
m.cancelSpotRequest()
|
|
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:
|
|
if !m.isCurrentSpotMutation(msg.gen, msg.providerName) {
|
|
return m, nil
|
|
}
|
|
m.cancelSpotRequest()
|
|
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:
|
|
if msg.gen != m.requests.auth || !m.isActiveProvider(msg.providerName) {
|
|
return m, nil
|
|
}
|
|
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, m.fetchProviderPlaylists()
|
|
|
|
case ProvAuthURLMsg:
|
|
if !m.provLoading || !m.isActiveProvider(msg.ProviderName) {
|
|
return m, nil
|
|
}
|
|
m.provAuthURL = msg.URL
|
|
return m, nil
|
|
|
|
case devicesListedMsg:
|
|
m.devicePicker.loading = false
|
|
if msg.err != nil {
|
|
m.status.Errorf(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.Errorf(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.flushPendingEQSave()
|
|
m.player.Close()
|
|
m.clearPlaybackTrack()
|
|
m.quitting = true
|
|
return m, tea.Quit
|
|
|
|
case SetEQPresetMsg:
|
|
m.SetEQPreset(msg.Name, msg.Bands)
|
|
m.scheduleEQSave()
|
|
return m, nil
|
|
|
|
case SetEQBandMsg:
|
|
m.setCustomEQBand(msg.Band, msg.Gain)
|
|
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.replacePlaylist(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.Errorf(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.Errorf(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.setCustomEQBand(msg.Band, msg.Value)
|
|
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.scheduleEQSave()
|
|
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
|
|
items := make([]ipc.DeviceInfo, 0, len(devices))
|
|
for _, d := range devices {
|
|
marker := " "
|
|
if d.Active {
|
|
marker = "* "
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%s%s", marker, d.Name))
|
|
items = append(items, ipc.DeviceInfo{Name: d.Name, Active: d.Active})
|
|
}
|
|
if msg.Reply != nil {
|
|
msg.Reply <- ipc.Response{OK: true, Device: strings.Join(lines, "\n"), Devices: items}
|
|
}
|
|
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 != "" {
|
|
info := ipcTrackInfo(cur, m.playlist.Index(), m.playlist.QueuePosition(m.playlist.Index()))
|
|
if artist, title := m.resolveTrackDisplay(cur); title != "" {
|
|
if cur.Stream && title != cur.Title {
|
|
info.Station = cur.Title
|
|
}
|
|
info.Artist, info.Title = artist, title
|
|
}
|
|
if cur.Stream {
|
|
info.StreamTitle = m.streamTitle
|
|
}
|
|
resp.Track = &info
|
|
}
|
|
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.Playlist = m.loadedPlaylist
|
|
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()
|
|
bands := m.player.EQBands()
|
|
resp.EQBands = append([]float64(nil), bands[:]...)
|
|
if m.themeIdx >= 0 && m.themeIdx < len(m.themes) {
|
|
t := m.themes[m.themeIdx]
|
|
resp.Theme = &ipc.ThemeInfo{
|
|
Name: t.Name,
|
|
BG: t.BG,
|
|
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.QueueRequestMsg:
|
|
return m, m.handleIPCQueue(msg)
|
|
|
|
case ipc.LibraryRequestMsg:
|
|
return m, m.handleIPCLibrary(msg)
|
|
|
|
case ipcProviderLoadResult:
|
|
return m, m.handleIPCProviderLoad(msg)
|
|
|
|
case ipc.LyricsRequestMsg:
|
|
return m, m.handleIPCLyrics(msg)
|
|
|
|
case ipc.HistoryRequestMsg:
|
|
return m, m.handleIPCHistory(msg)
|
|
|
|
case ipc.URLRequestMsg:
|
|
return m, m.handleIPCURL(msg)
|
|
|
|
case ipcURLLoadResult:
|
|
return m, m.handleIPCURLResult(msg)
|
|
|
|
case ipc.SaveRequestMsg:
|
|
return m, m.handleIPCSave(msg)
|
|
|
|
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
|
|
}
|