Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

367 lines
11 KiB
Go

// 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()
}