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.
479 lines
12 KiB
Go
479 lines
12 KiB
Go
// state.go defines sub-structs that group related fields in the Model,
|
|
// making the overall model scannable and maintainable.
|
|
|
|
package model
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/bjarneo/cliamp/applog"
|
|
"github.com/bjarneo/cliamp/lyrics"
|
|
"github.com/bjarneo/cliamp/player"
|
|
"github.com/bjarneo/cliamp/playlist"
|
|
"github.com/bjarneo/cliamp/provider"
|
|
)
|
|
|
|
// searchState holds state for the playlist search overlay.
|
|
type searchState struct {
|
|
active bool
|
|
query string
|
|
results []int // indices into playlist tracks
|
|
cursor int
|
|
scroll int
|
|
}
|
|
|
|
type playlistUndo struct {
|
|
active bool
|
|
snapshot playlist.Snapshot
|
|
loaded string
|
|
saved []playlist.Track
|
|
persisted bool
|
|
}
|
|
|
|
// netSearchScreenType identifies which screen of the net search overlay is active.
|
|
type netSearchScreenType int
|
|
|
|
const (
|
|
netSearchInput netSearchScreenType = iota // typing search query
|
|
netSearchResults // browsing search results
|
|
)
|
|
|
|
// netSearchState holds state for the internet search overlay.
|
|
type netSearchState struct {
|
|
active bool
|
|
screen netSearchScreenType
|
|
query string
|
|
soundcloud bool // true = SoundCloud (scsearch), false = YouTube (ytsearch)
|
|
loading bool
|
|
results []playlist.Track
|
|
cursor int
|
|
scroll int
|
|
err string
|
|
request string
|
|
}
|
|
|
|
// provSearchState holds state for filtering the provider playlist list.
|
|
type provSearchState struct {
|
|
active bool
|
|
query string
|
|
results []int // indices into providerLists
|
|
cursor int
|
|
scroll int
|
|
}
|
|
|
|
// seekState holds debounce state for yt-dlp seek-by-restart.
|
|
type seekState struct {
|
|
active bool // true from first keypress until seek completes
|
|
targetPos time.Duration // absolute target position
|
|
timer int // tick countdown for debounce (0 = idle)
|
|
grace int // ticks to suppress reconnect after seek completes
|
|
timerFor time.Duration
|
|
graceFor time.Duration
|
|
}
|
|
|
|
// themePickerState holds state for the theme picker overlay.
|
|
type themePickerState struct {
|
|
visible bool
|
|
cursor int // view index into filtered when filter != "", otherwise raw theme index
|
|
scroll int
|
|
savedName string // theme name before opening picker, for cancel/restore after reload
|
|
filtering bool
|
|
filter string
|
|
filtered []int // raw indices into [Default, themes...]
|
|
savedCursor int
|
|
savedScroll int
|
|
}
|
|
|
|
// visPickerState holds state for the visualizer picker overlay.
|
|
type visPickerState struct {
|
|
visible bool
|
|
cursor int // view index into filtered when filter != "", otherwise raw visualizer mode
|
|
scroll int
|
|
savedMode int // vis.Mode before opening, for cancel/restore
|
|
modes []string // mode names captured at open (stable while open)
|
|
filtering bool
|
|
filter string
|
|
filtered []int // raw indices into modes
|
|
savedCursor int
|
|
savedScroll int
|
|
}
|
|
|
|
// lyricsState holds state for the lyrics display overlay.
|
|
type lyricsState struct {
|
|
visible bool
|
|
lines []lyrics.Line
|
|
loading bool
|
|
err error
|
|
query string // "artist\ntitle" of the last fetch
|
|
scroll int
|
|
}
|
|
|
|
// keymapOverlay holds state for the keybindings overlay.
|
|
type keymapOverlay struct {
|
|
visible bool
|
|
cursor int
|
|
scroll int
|
|
savedCursor int
|
|
savedScroll int
|
|
searching bool
|
|
search string
|
|
filtered []int // indices into entries
|
|
entries []keymapEntry // core keys + plugin keys, rebuilt on openKeymap
|
|
}
|
|
|
|
// queueOverlay holds state for the queue manager overlay.
|
|
type queueOverlay struct {
|
|
visible bool
|
|
cursor int
|
|
scroll int
|
|
}
|
|
|
|
// plManagerState holds state for the playlist manager overlay.
|
|
type plManagerState struct {
|
|
visible bool
|
|
screen plMgrScreenType
|
|
cursor int // view-index: offset into filtered when filter != "", else direct index
|
|
scroll int
|
|
playlists []playlist.PlaylistInfo
|
|
selPlaylist string // playlist name open in screen 1
|
|
tracks []playlist.Track // tracks in the selected playlist
|
|
missingLocal []bool // cached missing-file state, indexed with tracks
|
|
newName string
|
|
confirmDel bool
|
|
renameOldName string
|
|
renameName string
|
|
inputErr string
|
|
marked map[int]bool // real track indices marked on the tracks screen
|
|
sortMode int
|
|
undo plManagerUndo
|
|
|
|
// Filter (`/`) state. Reset on screen change. `filtered` indexes into
|
|
// `playlists` (list screen) or `tracks` (tracks screen).
|
|
filtering bool
|
|
filter string
|
|
filtered []int
|
|
savedCursor int // cursor before `/` was pressed, restored on Esc
|
|
savedScroll int
|
|
}
|
|
|
|
type plManagerUndoKind int
|
|
|
|
const (
|
|
plUndoNone plManagerUndoKind = iota
|
|
plUndoTracks
|
|
plUndoPlaylist
|
|
)
|
|
|
|
type plManagerUndo struct {
|
|
kind plManagerUndoKind
|
|
name string
|
|
tracks []playlist.Track
|
|
missingLocal []bool
|
|
}
|
|
|
|
type playlistPickerScreen int
|
|
|
|
const (
|
|
plPickerChoose playlistPickerScreen = iota
|
|
plPickerNewName
|
|
)
|
|
|
|
// playlistPickerState holds the reusable local "write to playlist" picker.
|
|
type playlistPickerState struct {
|
|
visible bool
|
|
screen playlistPickerScreen
|
|
cursor int
|
|
scroll int
|
|
playlists []playlist.PlaylistInfo
|
|
tracks []playlist.Track
|
|
title string
|
|
newName string
|
|
inputErr string
|
|
}
|
|
|
|
// fileBrowserState holds state for the file browser overlay.
|
|
type fileBrowserState struct {
|
|
visible bool
|
|
dir string
|
|
entries []fbEntry
|
|
cursor int
|
|
scroll int
|
|
savedCursor int
|
|
savedScroll int
|
|
selected map[string]bool
|
|
err string
|
|
searching bool
|
|
search string
|
|
filtered []int // indices into entries
|
|
targetPlaylist string
|
|
confirmReplace bool
|
|
}
|
|
|
|
// navBrowserState holds state for the provider browser overlay.
|
|
type navBrowserState struct {
|
|
prov playlist.Provider
|
|
visible bool
|
|
mode navBrowseModeType
|
|
screen navBrowseScreenType
|
|
cursor int
|
|
scroll int
|
|
artists []provider.ArtistInfo
|
|
albums []provider.AlbumInfo
|
|
tracks []playlist.Track
|
|
selArtist provider.ArtistInfo
|
|
selAlbum provider.AlbumInfo
|
|
sortType string
|
|
albumLoading bool
|
|
albumDone bool
|
|
loading bool
|
|
searching bool
|
|
search string
|
|
searchIdx []int
|
|
confirmReplace bool
|
|
}
|
|
|
|
// requestState tracks the latest request in each independently asynchronous UI
|
|
// domain. Completion messages must match their generation before they can
|
|
// change the current screen.
|
|
type requestState struct {
|
|
provider uint64
|
|
tracks uint64
|
|
nav uint64
|
|
lyrics uint64
|
|
netSearch uint64
|
|
spotSearch uint64
|
|
spotAlbum uint64
|
|
spotLists uint64
|
|
spotMutation uint64
|
|
auth uint64
|
|
catalog uint64
|
|
stream uint64
|
|
preload uint64
|
|
}
|
|
|
|
func nextRequest(gen *uint64) uint64 {
|
|
*gen = *gen + 1
|
|
return *gen
|
|
}
|
|
|
|
// spotSearchScreenType identifies which screen of the Spotify search overlay is active.
|
|
type spotSearchScreenType int
|
|
|
|
const (
|
|
spotSearchInput spotSearchScreenType = iota // typing search query
|
|
spotSearchResults // browsing search results
|
|
spotSearchPlaylist // picking a playlist to add to
|
|
spotSearchNewName // typing new playlist name
|
|
)
|
|
|
|
// spotSearchState holds state for the provider search + add-to-playlist overlay.
|
|
type spotSearchState struct {
|
|
prov playlist.Provider // the provider being searched (may differ from active provider)
|
|
visible bool
|
|
screen spotSearchScreenType
|
|
query string
|
|
results []playlist.Track
|
|
cursor int
|
|
scroll int
|
|
loading bool
|
|
// albumLoading is separate from loading so the results screen can say an
|
|
// album is being expanded without claiming so during the playlist fetch.
|
|
albumLoading bool
|
|
playlists []playlist.PlaylistInfo // user's Spotify playlists for picker
|
|
selTrack playlist.Track // track selected to add
|
|
newName string // new playlist name input
|
|
err string
|
|
cancel func()
|
|
}
|
|
|
|
// catalogBatchState holds state for lazy-loading catalog entries from a provider.CatalogLoader.
|
|
type catalogBatchState struct {
|
|
offset int // next offset to fetch
|
|
loading bool // true while a fetch is in flight
|
|
done bool // true when all stations have been loaded
|
|
}
|
|
|
|
// ytdlBatchState holds state for incremental yt-dlp playlist loading.
|
|
type ytdlBatchState struct {
|
|
url string
|
|
gen uint64
|
|
offset int
|
|
done bool
|
|
loading bool
|
|
}
|
|
|
|
// reconnectState holds state for stream auto-reconnect with exponential backoff.
|
|
type reconnectState struct {
|
|
attempts int
|
|
at time.Time
|
|
}
|
|
|
|
// devicePickerState holds state for the audio device picker overlay.
|
|
type devicePickerState struct {
|
|
visible bool
|
|
devices []player.AudioDevice
|
|
cursor int
|
|
scroll int
|
|
loading bool
|
|
}
|
|
|
|
type saveState struct {
|
|
pendingDownloads int
|
|
}
|
|
|
|
func (s saveState) activityText() string {
|
|
switch s.pendingDownloads {
|
|
case 0:
|
|
return ""
|
|
case 1:
|
|
return "Downloading..."
|
|
default:
|
|
return fmt.Sprintf("Downloading... (%d)", s.pendingDownloads)
|
|
}
|
|
}
|
|
|
|
func (s *saveState) startDownload() {
|
|
s.pendingDownloads++
|
|
}
|
|
|
|
func (s *saveState) finishDownload() {
|
|
if s.pendingDownloads > 0 {
|
|
s.pendingDownloads--
|
|
}
|
|
}
|
|
|
|
// feedbackKind determines feedback styling and lifecycle.
|
|
type feedbackKind int
|
|
|
|
const (
|
|
feedbackActivity feedbackKind = iota
|
|
feedbackSuccess
|
|
feedbackWarning
|
|
feedbackError
|
|
)
|
|
|
|
// statusTTL is how long a status line stays visible.
|
|
type statusTTL time.Duration
|
|
|
|
func (t statusTTL) expiresAt(now time.Time) time.Time {
|
|
return now.Add(time.Duration(t))
|
|
}
|
|
|
|
// statusMsg holds structured feedback shown at the bottom of the UI. A zero
|
|
// expiry is durable and remains until a later message replaces it or Clear is
|
|
// called. Inline form errors use their own state so they remain beside retry.
|
|
type statusMsg struct {
|
|
kind feedbackKind
|
|
text string
|
|
expiresAt time.Time // zero = no active message
|
|
}
|
|
|
|
func (s statusMsg) Expired(now time.Time) bool {
|
|
return !s.expiresAt.IsZero() && !now.Before(s.expiresAt)
|
|
}
|
|
|
|
func (s *statusMsg) Show(text string, ttl statusTTL) {
|
|
s.Success(text, ttl)
|
|
}
|
|
|
|
func (s *statusMsg) Showf(ttl statusTTL, format string, args ...any) {
|
|
s.Show(fmt.Sprintf(format, args...), ttl)
|
|
}
|
|
|
|
func (s *statusMsg) Activityf(ttl statusTTL, format string, args ...any) {
|
|
s.Activity(fmt.Sprintf(format, args...), ttl)
|
|
}
|
|
|
|
func (s *statusMsg) Successf(ttl statusTTL, format string, args ...any) {
|
|
s.Success(fmt.Sprintf(format, args...), ttl)
|
|
}
|
|
|
|
func (s *statusMsg) Warningf(ttl statusTTL, format string, args ...any) {
|
|
s.Warning(fmt.Sprintf(format, args...), ttl)
|
|
}
|
|
|
|
func (s *statusMsg) Errorf(ttl statusTTL, format string, args ...any) {
|
|
s.Error(fmt.Sprintf(format, args...), ttl)
|
|
}
|
|
|
|
func (s *statusMsg) Activity(text string, ttl statusTTL) {
|
|
s.show(feedbackActivity, text, ttl)
|
|
}
|
|
|
|
func (s *statusMsg) Success(text string, ttl statusTTL) {
|
|
s.show(feedbackSuccess, text, ttl)
|
|
}
|
|
|
|
func (s *statusMsg) Warning(text string, ttl statusTTL) {
|
|
s.show(feedbackWarning, text, ttl)
|
|
}
|
|
|
|
func (s *statusMsg) Error(text string, ttl statusTTL) {
|
|
s.show(feedbackError, text, ttl)
|
|
}
|
|
|
|
func (s *statusMsg) show(kind feedbackKind, text string, ttl statusTTL) {
|
|
s.ShowAtKind(time.Now(), kind, text, ttl)
|
|
}
|
|
|
|
func (s *statusMsg) ShowAt(now time.Time, text string, ttl statusTTL) {
|
|
s.ShowAtKind(now, feedbackSuccess, text, ttl)
|
|
}
|
|
|
|
func (s *statusMsg) ShowAtKind(now time.Time, kind feedbackKind, text string, ttl statusTTL) {
|
|
s.kind = kind
|
|
s.text = text
|
|
if ttl > 0 {
|
|
s.expiresAt = ttl.expiresAt(now)
|
|
} else {
|
|
s.expiresAt = time.Time{}
|
|
}
|
|
}
|
|
|
|
func (s *statusMsg) Clear() {
|
|
*s = statusMsg{}
|
|
}
|
|
|
|
// logLine is a timestamped log message shown in the footer.
|
|
type logLine struct {
|
|
text string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
const logLineTTL = 6 * time.Second
|
|
|
|
// tickLogLines drains the applog buffer and expires old entries.
|
|
func (m *Model) tickLogLines(now time.Time) {
|
|
for _, e := range applog.Drain() {
|
|
text := strings.TrimRight(e.Text, "\n")
|
|
m.logLines = append(m.logLines, logLine{
|
|
text: text,
|
|
expiresAt: e.At.Add(logLineTTL),
|
|
})
|
|
}
|
|
// Expire old entries.
|
|
n := 0
|
|
for _, l := range m.logLines {
|
|
if now.Before(l.expiresAt) {
|
|
m.logLines[n] = l
|
|
n++
|
|
}
|
|
}
|
|
m.logLines = m.logLines[:n]
|
|
}
|
|
|
|
// networkStats tracks network throughput for the stream status bar.
|
|
type networkStats struct {
|
|
speed float64 // bytes per second (smoothed)
|
|
lastBytes int64
|
|
sampleFor time.Duration
|
|
}
|
|
|
|
type terminalTitleState struct {
|
|
introActive bool
|
|
introOffset int
|
|
introTick int
|
|
}
|