// Package ui implements the Bubbletea TUI for the CLIAMP terminal music player. package model import ( "time" "github.com/bjarneo/cliamp/history" "github.com/bjarneo/cliamp/internal/playback" "github.com/bjarneo/cliamp/luaplugin" "github.com/bjarneo/cliamp/player" "github.com/bjarneo/cliamp/playlist" "github.com/bjarneo/cliamp/theme" "github.com/bjarneo/cliamp/ui" ) // ConfigSaver persists individual config key-value pairs. // Satisfied by config.SaveFunc (the default) or a test stub. type ConfigSaver interface { Save(key, value string) error } type focusArea int const ( focusPlaylist focusArea = iota focusEQ focusSpeed focusProvPill focusSearch focusProvider focusNetSearch ) func (f focusArea) label() string { switch f { case focusPlaylist: return "Playlist" case focusEQ: return "Equalizer" case focusSpeed: return "Speed" case focusProvPill: return "Source" case focusProvider: return "Provider" case focusSearch: return "Search" case focusNetSearch: return "Online Search" default: return "" } } type topLevelScreen int const ( screenMain topLevelScreen = iota screenKeymap screenThemePicker screenVisPicker screenDevicePicker screenPlaylistPicker screenFileBrowser screenNavBrowser screenPlaylistManager screenSpotSearch screenQueue screenInfo screenSearch screenNetSearch screenURLInput screenLyrics screenJump screenFullVisualizer ) func (s topLevelScreen) hidesVisualizer() bool { // Every overlay now renders inline in the playlist region while the // now-playing, visualizer, and controls chrome stays visible above it, so // the visualizer is never fully hidden. return false } // maxPlVisible caps the playlist at a readable height even on tall terminals. // maxPlExpandVisible is the higher cap used when the user expands with 'x'. const ( maxPlVisible = 12 maxPlExpandVisible = 24 ) type plMgrScreenType int const ( plMgrScreenList plMgrScreenType = iota plMgrScreenTracks plMgrScreenNewName plMgrScreenRename ) // navBrowseModeType identifies which Navidrome browse mode is active. type navBrowseModeType int const ( navBrowseModeMenu navBrowseModeType = iota // top-level mode selector navBrowseModeByAlbum // paginated album list → track list navBrowseModeByArtist // artist list → track list (album-separated) navBrowseModeByArtistAlbum // artist list → album list → track list ) // navBrowseScreenType identifies which screen within the active browse mode is shown. type navBrowseScreenType int const ( navBrowseScreenList navBrowseScreenType = iota // first-level list (artists or albums) navBrowseScreenAlbums // artist's albums (ArtistAlbum mode only) navBrowseScreenTracks // final song list in any mode ) // ProviderEntry pairs a display name with a key and provider implementation. type ProviderEntry struct { Key string // config key: "radio", "navidrome", "spotify" Name string // display name: "Radio", "Navidrome", "Spotify" Provider playlist.Provider // nil if not configured } // statusTTL* constants define how long a status message is shown. const ( statusTTLShort statusTTL = statusTTL(2 * time.Second) // brief confirmations statusTTLDefault statusTTL = statusTTL(3 * time.Second) // standard status messages statusTTLMedium statusTTL = statusTTL(4 * time.Second) // messages needing extra visibility statusTTLBatch statusTTL = statusTTL(4500 * time.Millisecond) // batch operation feedback statusTTLLong statusTTL = statusTTL(6 * time.Second) // loading indicators ) // Model is the Bubbletea model for the CLIAMP TUI. type Model struct { // Core playback player player.Engine playlist *playlist.Playlist configSaver ConfigSaver vis *ui.Visualizer seekStepLarge time.Duration pausedAt time.Time // Primed Nj seek: digit sets pct, next `j` completes. pendingSeekActive bool pendingSeekPct int // UI navigation focus focusArea prevFocus focusArea // focus to restore on cancel (search, net search) eqCursor int // selected EQ band (0-9) plCursor int // selected playlist item plScroll int // scroll offset for playlist view plVisible int // desired max visible playlist lines titleOff int // scroll offset for long track titles titleLastScroll time.Time // last time the title scrolled err error quitting bool width int height int // Provider state provider playlist.Provider localProvider playlist.Provider // local playlist provider for file-based playlist management (always available) providerLists []playlist.PlaylistInfo provCursor int provScroll int provLoading bool provSignIn bool // true when provider needs interactive sign-in provAuthURL string // OAuth URL to display while interactive auth is in flight providers []ProviderEntry // all available providers provPillIdx int // selected pill index eqPresetIdx int // -1 = custom, 0+ = index into eqPresets eqCustomLabel string // non-empty = plugin-defined preset label (shown instead of "Custom") // Overlay / feature state (see state.go for struct definitions) search searchState netSearch netSearchState provSearch provSearchState seek seekState themePicker themePickerState visPicker visPickerState lyrics lyricsState keymap keymapOverlay queue queueOverlay plManager plManagerState plPicker playlistPickerState spotSearch spotSearchState fileBrowser fileBrowserState navBrowser navBrowserState catalogBatch catalogBatchState ytdlBatch ytdlBatchState reconnect reconnectState save saveState status statusMsg logLines []logLine network networkStats speedSaveAfter time.Duration termTitle terminalTitleState // Jump to time mode jumping bool jumpInput string // URL input mode (load playlist/stream URL at runtime) urlInputting bool urlInput string // Async feed/M3U URL resolution pendingURLs []string feedLoading bool visVolumeLinked bool // when true, samples are scaled by volume gain before FFT // Async stream buffering (true while HTTP connect is in progress) buffering bool bufferingAt time.Time // when buffering started, for elapsed display // resume holds the path and position to seek to when the matching track // starts playing. Cleared after the seek is performed. resume struct { path string secs int } loadedPlaylist string // name of the currently loaded local playlist (for resume) // activeProviderPlaylistID is the ID of the most recently loaded playlist // from a non-local provider (Spotify, Navidrome, …). Used to highlight that // row in the provider browser. Empty when no provider playlist is active. activeProviderPlaylistID string // exitResume holds the playback state captured just before player.Close() // so ResumeState() can read it after the player is shut down. exitResume struct { path string secs int playlist string } // preloading is true while a preloadStreamCmd goroutine is in-flight. preloading bool // Live stream title from ICY metadata (e.g., "Artist - Song") streamTitle string // playingTrack is the track currently owned by the audio engine. It can differ // from playlist.Current() after browsing loads a new provider playlist while // the old track keeps playing. playingTrack playlist.Track playingTrackActive bool playbackDetached bool notifier playback.Notifier // Lua plugin manager (nil if no plugins loaded) luaMgr *luaplugin.Manager // pluginEmit tracks last-emitted player/queue state so Update can fire // delta events to plugins from one place. Held behind a pointer so the // snapshot survives Update's value-receiver copy. pluginEmit *pluginEmitState // History recorder (nil if config dir unavailable; safe to call when nil) historyStore *history.Store // initialDir is the starting path for the file browser ('o' key). initialDir string // Theme state: -1 = Default (ANSI), 0+ = index into themes themes []theme.Theme themeIdx int // Track info overlay (metadata details) showInfo bool showAlbumHeaders bool headerManual bool // Running counters for the cohesion heuristic so Add can update header // visibility in O(k) instead of walking the whole playlist on each call. headerLastAlbum string headerSegments int headerTracks int // Audio device picker overlay devicePicker devicePickerState // Full-screen visualizer mode (Shift+V) fullVis bool autoPlay bool // start playing immediately on launch lowPower bool // lower UI/render cadences in low-power mode compact bool // compact mode: cap frame width at 80 columns heightExpanded bool // tracks whether manual 'x' expansion is active // Cached per-tick to avoid repeated speaker.Lock() calls in View(). cachedPos time.Duration cachedDur time.Duration lastTickAt time.Time // wall time of previous tickMsg; used for tick delta // Cached height of the fixed chrome (title, track info, time, seek bar, // controls, provider pill, playlist header, help, bottom status, no // transient footer). Reused to avoid rendering all chrome sections twice // per View() call. The measurement in effectivePlaylistVisible() uses // this cache instead of a full render pass. chromeHeight int chromeOK bool } func (m Model) activeScreen() topLevelScreen { switch { case m.keymap.visible: return screenKeymap case m.themePicker.visible: return screenThemePicker case m.visPicker.visible: return screenVisPicker case m.devicePicker.visible: return screenDevicePicker case m.plPicker.visible: return screenPlaylistPicker case m.fileBrowser.visible: return screenFileBrowser case m.navBrowser.visible: return screenNavBrowser case m.plManager.visible: return screenPlaylistManager case m.spotSearch.visible: return screenSpotSearch case m.queue.visible: return screenQueue case m.showInfo: return screenInfo case m.search.active: return screenSearch case m.netSearch.active: return screenNetSearch case m.urlInputting: return screenURLInput case m.lyrics.visible: return screenLyrics case m.jumping: return screenJump case m.fullVis: return screenFullVisualizer default: return screenMain } } // isOverlayActive reports whether an overlay suppresses the live main view. // Overlays now render inline over the live view (see hidesVisualizer), so this // is always false; it is kept as the single seam the tick loop gates on. func (m Model) isOverlayActive() bool { return false } func (m Model) isPlaying() bool { return m.player != nil && m.player.IsPlaying() } func (m Model) isPaused() bool { return m.player != nil && m.player.IsPaused() }