205 lines
6.3 KiB
Go
205 lines
6.3 KiB
Go
package model
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"github.com/bjarneo/cliamp/history"
|
|
"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"
|
|
)
|
|
|
|
// applyThemeAll updates colors, spectrum styles, and model-specific styles.
|
|
func applyThemeAll(t theme.Theme) {
|
|
ui.ApplyThemeColors(t)
|
|
rebuildModelStyles()
|
|
}
|
|
|
|
// New creates a Model wired to the given player and playlist.
|
|
// providers is the ordered list of available providers (Radio, Navidrome, Spotify, Jellyfin, etc.).
|
|
// defaultProvider is the config key of the provider to select initially.
|
|
// localProv is an optional direct reference to the local provider for write ops.
|
|
func New(p player.Engine, pl *playlist.Playlist, providers []ProviderEntry, defaultProvider string, localProv playlist.Provider, themes []theme.Theme, luaMgr *luaplugin.Manager, cs ConfigSaver) Model {
|
|
m := Model{
|
|
player: p,
|
|
playlist: pl,
|
|
configSaver: cs,
|
|
vis: ui.NewVisualizer(float64(p.SampleRate())),
|
|
seekStepLarge: 30 * time.Second,
|
|
plVisible: 5,
|
|
eqPresetIdx: -1, // custom until a preset is selected
|
|
themes: themes,
|
|
themeIdx: -1, // Default (ANSI)
|
|
localProvider: localProv,
|
|
providers: providers,
|
|
navBrowser: navBrowserState{},
|
|
luaMgr: luaMgr,
|
|
historyStore: history.New(),
|
|
showAlbumHeaders: false,
|
|
}
|
|
if luaMgr != nil {
|
|
m.pluginEmit = &pluginEmitState{}
|
|
}
|
|
m.termTitle = initialTerminalTitleState()
|
|
// Select the default provider pill.
|
|
for i, pe := range providers {
|
|
if pe.Key == defaultProvider {
|
|
m.provPillIdx = i
|
|
m.provider = pe.Provider
|
|
break
|
|
}
|
|
}
|
|
// Fallback: select first available provider.
|
|
if m.provider == nil && len(providers) > 0 {
|
|
m.provPillIdx = 0
|
|
m.provider = providers[0].Provider
|
|
}
|
|
return m
|
|
}
|
|
|
|
// SetVisVolumeLinked controls whether the visualizer scales samples by the
|
|
// current volume before FFT analysis, making bar height follow volume.
|
|
func (m *Model) SetVisVolumeLinked(linked bool) {
|
|
m.visVolumeLinked = linked
|
|
}
|
|
|
|
// findProviderWith returns the first registered provider that satisfies the
|
|
// given capability check. This is used for cross-provider shortcuts like "N"
|
|
// (browse) and "F" (search) which should work regardless of the active provider.
|
|
func (m *Model) findProviderWith(check func(playlist.Provider) bool) playlist.Provider {
|
|
// Prefer the active provider if it matches.
|
|
if check(m.provider) {
|
|
return m.provider
|
|
}
|
|
for _, pe := range m.providers {
|
|
if pe.Provider != nil && check(pe.Provider) {
|
|
return pe.Provider
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetAutoPlay makes the player start playback immediately on Init.
|
|
func (m *Model) SetAutoPlay(v bool) { m.autoPlay = v }
|
|
|
|
// SetLowPower lowers UI cadences without affecting normal mode.
|
|
func (m *Model) SetLowPower(v bool) { m.lowPower = v }
|
|
|
|
// SetCompact enables compact mode which caps the frame width at 80 columns.
|
|
func (m *Model) SetCompact(v bool) {
|
|
m.compact = v
|
|
m.refreshChrome()
|
|
}
|
|
|
|
// SetInitialDirectory sets the initial directory for the file browser.
|
|
func (m *Model) SetInitialDirectory(dir string) { m.initialDir = dir }
|
|
|
|
// SetSeekStepLarge configures the Shift+Left/Right seek jump amount.
|
|
func (m *Model) SetSeekStepLarge(d time.Duration) {
|
|
switch {
|
|
case d <= 0:
|
|
m.seekStepLarge = 30 * time.Second
|
|
case d <= 5*time.Second:
|
|
m.seekStepLarge = 6 * time.Second
|
|
default:
|
|
m.seekStepLarge = d
|
|
}
|
|
}
|
|
|
|
// SetTheme finds a theme by name and applies it. Returns true if found.
|
|
func (m *Model) SetTheme(name string) bool {
|
|
if name == "" || strings.EqualFold(name, "default") {
|
|
m.themeIdx = -1
|
|
applyThemeAll(theme.Default())
|
|
return true
|
|
}
|
|
for i, t := range m.themes {
|
|
if strings.EqualFold(t.Name, name) {
|
|
m.themeIdx = i
|
|
applyThemeAll(t)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SetVisualizer sets the visualizer mode by name (case-insensitive).
|
|
// Returns true if a valid mode name was recognized. Does not modify state
|
|
// if the name is not found, matching the SetTheme guard pattern.
|
|
func (m *Model) SetVisualizer(name string) bool {
|
|
mode, ok := ui.StringToVisModeExact(name)
|
|
if !ok {
|
|
return false
|
|
}
|
|
m.vis.Mode = mode
|
|
m.vis.RequestRefresh()
|
|
m.refreshChrome()
|
|
// Skip the terminal-title intro animation when the visualizer is disabled
|
|
// (e.g. low-power mode); the user opted out of visual flair, so the 3-second
|
|
// TickFast intro burn (~20 FPS UI rendering) is just wasted CPU.
|
|
if mode == ui.VisNone {
|
|
m.termTitle.introActive = false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// VisualizerName returns the current visualizer mode's display name.
|
|
func (m *Model) VisualizerName() string {
|
|
return m.vis.ModeName()
|
|
}
|
|
|
|
// RegisterLuaVisualizers adds Lua visualizer plugins to the visualizer cycle.
|
|
func (m *Model) RegisterLuaVisualizers(names []string, renderer ui.LuaVisRenderer) {
|
|
m.vis.RegisterLuaVisualizers(names, renderer)
|
|
}
|
|
|
|
// SetResume registers a path+position to seek to when that track first plays.
|
|
func (m *Model) SetResume(path string, secs int) {
|
|
m.resume.path = path
|
|
m.resume.secs = secs
|
|
}
|
|
|
|
// ResumePlaylist loads a playlist into the model for session resume.
|
|
func (m *Model) ResumePlaylist(name string, tracks []playlist.Track) {
|
|
m.playlist.Replace(tracks)
|
|
m.setHeaderStateFromTracks(tracks)
|
|
m.loadedPlaylist = name
|
|
}
|
|
|
|
// ResumeState returns the track path, playback position, and playlist name captured at exit.
|
|
// Called after prog.Run() returns (player already closed).
|
|
func (m Model) ResumeState() (path string, secs int, playlist string) {
|
|
return m.exitResume.path, m.exitResume.secs, m.exitResume.playlist
|
|
}
|
|
|
|
// ThemeName returns the current theme name.
|
|
func (m Model) ThemeName() string {
|
|
if m.themeIdx < 0 || m.themeIdx >= len(m.themes) {
|
|
return theme.DefaultName
|
|
}
|
|
return m.themes[m.themeIdx].Name
|
|
}
|
|
|
|
// Init starts the tick timer and requests the terminal size.
|
|
func (m Model) Init() tea.Cmd {
|
|
if m.luaMgr != nil {
|
|
m.luaMgr.Emit(luaplugin.EventAppStart, nil)
|
|
}
|
|
cmds := []tea.Cmd{tickCmd(), func() tea.Msg { return tea.RequestWindowSize() }}
|
|
if m.provider != nil {
|
|
cmds = append(cmds, fetchPlaylistsCmd(m.provider))
|
|
}
|
|
if len(m.pendingURLs) > 0 {
|
|
cmds = append(cmds, resolveRemoteCmd(m.pendingURLs, m.autoPlay))
|
|
}
|
|
if m.autoPlay && m.playlist.Len() > 0 {
|
|
cmds = append(cmds, func() tea.Msg { return autoPlayMsg{} })
|
|
}
|
|
return tea.Batch(cmds...)
|
|
}
|