Split the model into smaller files

This commit is contained in:
Bjarne Øverli
2026-03-30 16:52:25 +02:00
parent d09af49c9f
commit 77cac44689
62 changed files with 2899 additions and 2749 deletions
+13 -12
View File
@@ -25,6 +25,7 @@ import (
"cliamp/resolve"
"cliamp/theme"
"cliamp/ui"
"cliamp/ui/model"
"cliamp/upgrade"
)
@@ -40,8 +41,8 @@ func run(overrides config.Overrides, positional []string) error {
// Build provider list: Radio is always available, Navidrome and Spotify if configured.
radioProv := radio.New()
var providers []ui.ProviderEntry
providers = append(providers, ui.ProviderEntry{Key: "radio", Name: "Radio", Provider: radioProv})
var providers []model.ProviderEntry
providers = append(providers, model.ProviderEntry{Key: "radio", Name: "Radio", Provider: radioProv})
var navClient *navidrome.NavidromeClient
if c := navidrome.NewFromConfig(cfg.Navidrome); c != nil {
@@ -50,17 +51,17 @@ func run(overrides config.Overrides, positional []string) error {
navClient = c
}
if navClient != nil {
providers = append(providers, ui.ProviderEntry{Key: "navidrome", Name: "Navidrome", Provider: navClient})
providers = append(providers, model.ProviderEntry{Key: "navidrome", Name: "Navidrome", Provider: navClient})
}
if plexProv := plex.NewFromConfig(cfg.Plex); plexProv != nil {
providers = append(providers, ui.ProviderEntry{Key: "plex", Name: "Plex", Provider: plexProv})
providers = append(providers, model.ProviderEntry{Key: "plex", Name: "Plex", Provider: plexProv})
}
var spotifyProv *spotify.SpotifyProvider
if cfg.Spotify.IsSet() {
spotifyProv = spotify.New(nil, cfg.Spotify.ClientID)
providers = append(providers, ui.ProviderEntry{Key: "spotify", Name: "Spotify", Provider: spotifyProv})
providers = append(providers, model.ProviderEntry{Key: "spotify", Name: "Spotify", Provider: spotifyProv})
}
var ytProviders ytmusic.Providers
@@ -101,9 +102,9 @@ func run(overrides config.Overrides, positional []string) error {
if player.YTDLPAvailable() {
ytProviders = ytmusic.New(nil, ytClientID, ytClientSecret, cfg.YouTubeMusic.CookiesFrom != "")
providers = append(providers,
ui.ProviderEntry{Key: "yt", Name: "YouTube (All)", Provider: ytProviders.All},
ui.ProviderEntry{Key: "youtube", Name: "YouTube", Provider: ytProviders.Video},
ui.ProviderEntry{Key: "ytmusic", Name: "YouTube Music", Provider: ytProviders.Music},
model.ProviderEntry{Key: "yt", Name: "YouTube (All)", Provider: ytProviders.All},
model.ProviderEntry{Key: "youtube", Name: "YouTube", Provider: ytProviders.Video},
model.ProviderEntry{Key: "ytmusic", Name: "YouTube Music", Provider: ytProviders.Music},
)
}
}
@@ -198,7 +199,7 @@ func run(overrides config.Overrides, positional []string) error {
defer luaMgr.Close()
}
m := ui.NewModel(p, pl, providers, defaultProvider, localProv, themes, cfg.Navidrome.BrowseSort, luaMgr)
m := model.New(p, pl, providers, defaultProvider, localProv, themes, cfg.Navidrome.BrowseSort, luaMgr)
// Wire Lua plugin state provider with read-only access to player/playlist.
if luaMgr != nil {
@@ -268,7 +269,7 @@ func run(overrides config.Overrides, positional []string) error {
}
prog := tea.NewProgram(m, tea.WithAltScreen())
prog.SetWindowTitle(ui.InitialTerminalTitle())
prog.SetWindowTitle(model.InitialTerminalTitle())
// Wire Lua plugin control provider (needs prog.Send for next/prev).
if luaMgr != nil {
@@ -283,7 +284,7 @@ func run(overrides config.Overrides, positional []string) error {
_ = p.Seek(time.Duration(secs * float64(time.Second)))
},
SetEQPreset: func(name string, bands *[10]float64) {
prog.Send(ui.SetEQPresetMsg{Name: name, Bands: bands})
prog.Send(model.SetEQPresetMsg{Name: name, Bands: bands})
},
Next: func() { prog.Send(mpris.NextMsg{}) },
Prev: func() { prog.Send(mpris.PrevMsg{}) },
@@ -301,7 +302,7 @@ func run(overrides config.Overrides, positional []string) error {
}
// Persist theme selection and resume state across restarts.
if fm, ok := finalModel.(ui.Model); ok {
if fm, ok := finalModel.(model.Model); ok {
themeName := fm.ThemeName()
if themeName == theme.DefaultName {
themeName = ""
-2046
View File
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
package model
import (
"fmt"
"strconv"
"strings"
"time"
"cliamp/config"
)
const speedSaveDebounce = time.Second
// SetEQPreset sets the preset by name. If it matches a built-in preset,
// those bands are applied. Otherwise the name is used as a custom label.
// If bands is non-nil, they are applied regardless of whether the name matches.
func (m *Model) SetEQPreset(name string, bands *[10]float64) {
m.eqCustomLabel = ""
// Check built-in presets first.
for i, p := range eqPresets {
if strings.EqualFold(p.Name, name) {
m.eqPresetIdx = i
if bands != nil {
for j, gain := range bands {
m.player.SetEQBand(j, gain)
}
} else {
m.applyEQPreset()
}
return
}
}
// Custom label — set bands if provided, otherwise keep current.
m.eqPresetIdx = -1
m.eqCustomLabel = name
if bands != nil {
for i, gain := range bands {
m.player.SetEQBand(i, gain)
}
}
}
// EQPresetName returns the current preset name, or "Custom".
func (m Model) EQPresetName() string {
if m.eqPresetIdx >= 0 && m.eqPresetIdx < len(eqPresets) {
return eqPresets[m.eqPresetIdx].Name
}
if m.eqCustomLabel != "" {
return m.eqCustomLabel
}
return "Custom"
}
// applyEQPreset writes the current preset's bands to the player.
func (m *Model) applyEQPreset() {
if m.eqPresetIdx < 0 || m.eqPresetIdx >= len(eqPresets) {
return
}
bands := eqPresets[m.eqPresetIdx].Bands
for i, gain := range bands {
m.player.SetEQBand(i, gain)
}
}
// saveEQ persists the current EQ state (preset name and band values) to config.
func (m *Model) saveEQ() {
name := m.EQPresetName()
if err := config.Save("eq_preset", fmt.Sprintf("%q", name)); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err)
}
bands := m.player.EQBands()
parts := make([]string, len(bands))
for i, g := range bands {
parts[i] = strconv.FormatFloat(g, 'f', -1, 64)
}
eqVal := "[" + strings.Join(parts, ", ") + "]"
if err := config.Save("eq", eqVal); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err)
}
}
// saveSpeed persists the current playback speed to the config file.
func (m *Model) saveSpeed() {
speed := m.player.Speed()
if err := config.Save("speed", fmt.Sprintf("%.2f", speed)); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err)
}
}
func (m *Model) changeSpeed(delta float64) {
m.player.SetSpeed(m.player.Speed() + delta)
m.speedSaveAfter = speedSaveDebounce
}
func (m *Model) tickPendingSpeedSave(dt time.Duration) {
if m.speedSaveAfter <= 0 {
return
}
m.speedSaveAfter -= dt
if m.speedSaveAfter > 0 {
return
}
m.speedSaveAfter = 0
m.saveSpeed()
}
func (m *Model) flushPendingSpeedSave() {
if m.speedSaveAfter <= 0 {
return
}
m.speedSaveAfter = 0
m.saveSpeed()
}
+1 -1
View File
@@ -1,4 +1,4 @@
package ui
package model
import (
"context"
+1 -1
View File
@@ -1,4 +1,4 @@
package ui
package model
const eqBandCount = 10
@@ -1,4 +1,4 @@
package ui
package model
import (
"fmt"
@@ -9,6 +9,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"cliamp/player"
"cliamp/ui"
"cliamp/playlist"
"cliamp/resolve"
)
@@ -317,7 +318,7 @@ func (m Model) renderFileBrowser() string {
label := check + e.name + suffix
// Truncate long names.
maxW := panelWidth - 4
maxW := ui.PanelWidth - 4
labelRunes := []rune(label)
if len(labelRunes) > maxW {
label = string(labelRunes[:maxW-1]) + "…"
+171
View File
@@ -0,0 +1,171 @@
package model
import (
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"cliamp/luaplugin"
"cliamp/player"
"cliamp/playlist"
"cliamp/theme"
"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).
// defaultProvider is the config key of the provider to select initially.
// localProv is an optional direct reference to the local provider for write ops.
// browseSortType seeds the initial album browse sort preference (empty = default).
func New(p *player.Player, pl *playlist.Playlist, providers []ProviderEntry, defaultProvider string, localProv playlist.Provider, themes []theme.Theme, browseSortType string, luaMgr *luaplugin.Manager) Model {
if browseSortType == "" {
browseSortType = "alphabeticalByName"
}
m := Model{
player: p,
playlist: pl,
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{sortType: browseSortType},
luaMgr: luaMgr,
}
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
}
// 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 }
// SetCompact enables compact mode which caps the frame width at 80 columns.
func (m *Model) SetCompact(v bool) { m.compact = v }
// 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.
func (m *Model) SetVisualizer(name string) bool {
mode := ui.StringToVisMode(name)
m.vis.Mode = mode
m.vis.RequestRefresh()
return name == "" || strings.EqualFold(name, m.vis.ModeName())
}
// 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
}
// ResumeState returns the track path and playback position captured at exit.
// Called after prog.Run() returns (player already closed).
func (m Model) ResumeState() (path string, secs int) {
return m.exitResume.path, m.exitResume.secs
}
// 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(), tea.WindowSize()}
if cmd := m.terminalTitleCmd(); cmd != nil {
cmds = append(cmds, cmd)
}
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...)
}
+1 -1
View File
@@ -1,4 +1,4 @@
package ui
package model
import (
"fmt"
+1 -1
View File
@@ -1,4 +1,4 @@
package ui
package model
import (
"testing"
+1 -1
View File
@@ -1,4 +1,4 @@
package ui
package model
import (
"strings"
+6 -5
View File
@@ -1,4 +1,4 @@
package ui
package model
import (
"context"
@@ -12,6 +12,7 @@ import (
"github.com/charmbracelet/lipgloss"
"cliamp/config"
"cliamp/ui"
"cliamp/internal/fileutil"
"cliamp/playlist"
"cliamp/provider"
@@ -272,7 +273,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
case "esc", "backspace", "b":
if m.fullVis {
m.fullVis = false
m.vis.Rows = defaultVisRows
m.vis.Rows = ui.DefaultVisRows
} else if m.focus == focusPlaylist {
m.plVisible = m.defaultPlVisible()
m.focus = focusProvider
@@ -575,9 +576,9 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
case "V":
m.fullVis = !m.fullVis
if m.fullVis {
m.vis.Rows = max(defaultVisRows, (m.height-10)*4/5)
m.vis.Rows = max(ui.DefaultVisRows, (m.height-10)*4/5)
} else {
m.vis.Rows = defaultVisRows
m.vis.Rows = ui.DefaultVisRows
}
case "x":
@@ -820,7 +821,7 @@ func (m *Model) toggleExpandPlaylist() {
m.renderControls(), "", m.renderPlaylistHeader(),
"x", "", m.renderHelp(), m.renderBottomStatus(),
}, "\n")
fixedLines := lipgloss.Height(frameStyle.Render(probe)) - 1
fixedLines := lipgloss.Height(ui.FrameStyle.Render(probe)) - 1
m.plVisible = max(minPlVisible, min(maxPlExpandVisible, m.height-fixedLines))
} else {
m.plVisible = defVis
+1 -1
View File
@@ -1,4 +1,4 @@
package ui
package model
import (
tea "github.com/charmbracelet/bubbletea"
+1 -1
View File
@@ -1,4 +1,4 @@
package ui
package model
import (
tea "github.com/charmbracelet/bubbletea"
@@ -1,4 +1,4 @@
package ui
package model
import (
tea "github.com/charmbracelet/bubbletea"
+56
View File
@@ -0,0 +1,56 @@
package model
import (
"strings"
"cliamp/playlist"
)
// lyricsArtistTitle resolves the best artist and title for a lyrics lookup.
// For streams with ICY metadata ("Artist - Song"), it parses the stream title.
// For regular tracks, it uses the track's metadata fields.
func (m *Model) lyricsArtistTitle() (artist, title string) {
track, idx := m.playlist.Current()
if idx < 0 {
return "", ""
}
// For streams, prefer the live ICY stream title which updates per-song.
if m.streamTitle != "" && track.Stream {
if a, t, ok := strings.Cut(m.streamTitle, " - "); ok {
return strings.TrimSpace(a), strings.TrimSpace(t)
}
}
return track.Artist, track.Title
}
// lyricsSyncable reports whether synced lyrics can track the current playback
// position. This is true for local files and Navidrome streams (which have
// accurate position tracking), but false for live radio (ICY — position is
// from stream start, not song start) and yt-dlp pipe streams (position is 0).
func (m *Model) lyricsSyncable() bool {
track, idx := m.playlist.Current()
if idx < 0 {
return false
}
// YouTube/yt-dlp pipe streams report position 0.
if playlist.IsYouTubeURL(track.Path) || playlist.IsYTDL(track.Path) {
return false
}
// ICY radio streams: position counts from stream connect, not song start.
// Provider streams with metadata (e.g. Navidrome) track position correctly.
if track.Stream && len(track.ProviderMeta) == 0 {
return false
}
return true
}
// lyricsHaveTimestamps reports whether the loaded lyrics have meaningful
// timestamps (i.e., not all lines at 0).
func (m *Model) lyricsHaveTimestamps() bool {
for _, l := range m.lyrics.lines {
if l.Start > 0 {
return true
}
}
return false
}
+267
View File
@@ -0,0 +1,267 @@
// Package ui implements the Bubbletea TUI for the CLIAMP terminal music player.
package model
import (
"time"
"cliamp/luaplugin"
"cliamp/mpris"
"cliamp/player"
"cliamp/playlist"
"cliamp/theme"
"cliamp/ui"
)
type focusArea int
const (
focusPlaylist focusArea = iota
focusEQ
focusSpeed
focusProvPill
focusSearch
focusProvider
focusNetSearch
)
type topLevelScreen int
const (
screenMain topLevelScreen = iota
screenKeymap
screenThemePicker
screenFileBrowser
screenNavBrowser
screenPlaylistManager
screenSpotSearch
screenQueue
screenInfo
screenSearch
screenNetSearch
screenURLInput
screenLyrics
screenJump
screenFullVisualizer
)
func (s topLevelScreen) hidesVisualizer() bool {
return s != screenMain && s != screenFullVisualizer
}
// 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
)
// 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
)
// minPlVisible is the minimum playlist height when collapsed.
const minPlVisible = 5
// Model is the Bubbletea model for the CLIAMP TUI.
type Model struct {
// Core playback
player *player.Player
playlist *playlist.Playlist
vis *ui.Visualizer
seekStepLarge time.Duration
// 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
provLoading bool
provSignIn bool // true when provider needs interactive sign-in
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
lyrics lyricsState
keymap keymapOverlay
queue queueOverlay
plManager plManagerState
spotSearch spotSearchState
fileBrowser fileBrowserState
navBrowser navBrowserState
catalogBatch catalogBatchState
ytdlBatch ytdlBatchState
reconnect reconnectState
save saveState
status statusMsg
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
// 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
}
// 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
}
// preloading is true while a preloadStreamCmd goroutine is in-flight.
preloading bool
// Live stream title from ICY metadata (e.g., "Artist - Song")
streamTitle string
// MPRIS D-Bus service (nil on non-Linux or if D-Bus unavailable)
mpris *mpris.Service
// Lua plugin manager (nil if no plugins loaded)
luaMgr *luaplugin.Manager
// Theme state: -1 = Default (ANSI), 0+ = index into themes
themes []theme.Theme
themeIdx int
// Track info overlay (metadata details)
showInfo bool
// Full-screen visualizer mode (Shift+V)
fullVis bool
autoPlay bool // start playing immediately on launch
compact bool // compact mode: cap frame width at 80 columns
// 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
}
func (m Model) activeScreen() topLevelScreen {
switch {
case m.keymap.visible:
return screenKeymap
case m.themePicker.visible:
return screenThemePicker
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
}
}
func (m Model) isOverlayActive() bool {
return m.activeScreen().hidesVisualizer()
}
func (m Model) isPlaying() bool {
return m.player != nil && m.player.IsPlaying()
}
func (m Model) isPaused() bool {
return m.player != nil && m.player.IsPaused()
}
+159
View File
@@ -0,0 +1,159 @@
package model
import (
"strings"
"time"
"cliamp/luaplugin"
"cliamp/mpris"
"cliamp/playlist"
"cliamp/provider"
)
// notifyAll sends the current playback state to both MPRIS and Lua plugins.
func (m *Model) notifyAll() {
m.notifyMPRIS()
m.notifyPlugins()
}
// notifyPlugins emits a playback state event to Lua plugins.
func (m *Model) notifyPlugins() {
if m.luaMgr == nil || !m.luaMgr.HasHooks() {
return
}
track, _ := m.playlist.Current()
artist, title := m.resolveTrackDisplay(track)
status := "stopped"
if m.player.IsPlaying() {
if m.player.IsPaused() {
status = "paused"
} else {
status = "playing"
}
}
data := trackToMap(track)
data["status"] = status
data["title"] = title
data["artist"] = artist
data["position"] = m.player.Position().Seconds()
m.luaMgr.Emit(luaplugin.EventPlaybackState, data)
}
// resolveTrackDisplay returns the display artist and title, applying ICY
// stream title override for radio streams.
func (m *Model) resolveTrackDisplay(track playlist.Track) (artist, title string) {
artist, title = track.Artist, track.Title
if m.streamTitle != "" && track.Stream {
if a, t, ok := strings.Cut(m.streamTitle, " - "); ok {
artist, title = a, t
} else {
title = m.streamTitle
}
}
return
}
// trackToMap builds a metadata map from a track for Lua plugin events.
func trackToMap(track playlist.Track) map[string]any {
return map[string]any{
"title": track.Title,
"artist": track.Artist,
"album": track.Album,
"genre": track.Genre,
"year": track.Year,
"path": track.Path,
"duration": track.DurationSecs,
"stream": track.Stream,
}
}
// notifyMPRIS sends the current playback state to the MPRIS service
// so desktop widgets and playerctl stay in sync.
func (m *Model) notifyMPRIS() {
if m.mpris == nil {
return
}
status := "Stopped"
if m.player.IsPlaying() {
if m.player.IsPaused() {
status = "Paused"
} else {
status = "Playing"
}
}
track, _ := m.playlist.Current()
artist, title := m.resolveTrackDisplay(track)
info := mpris.TrackInfo{
Title: title,
Artist: artist,
Album: track.Album,
Genre: track.Genre,
TrackNumber: track.TrackNumber,
URL: track.Path,
Length: m.player.Duration().Microseconds(),
}
m.mpris.Update(status, info, m.player.Volume(),
m.player.Position().Microseconds(), m.player.Seekable())
}
// nowPlaying fires a now-playing notification for the given track if configured.
func (m *Model) nowPlaying(track playlist.Track) {
if m.luaMgr != nil && m.luaMgr.HasHooks() {
m.luaMgr.Emit(luaplugin.EventTrackChange, trackToMap(track))
}
if scrobbler := m.findScrobbler(); scrobbler != nil {
go scrobbler.Scrobble(track, false)
}
}
// maybeScrobble fires a submission scrobble for the given track if all
// conditions are met:
// - navClient is configured
// - scrobbling is enabled in config
// - a registered provider implements Scrobbler
// - elapsed is at least 50% of the track's known duration
//
// The call is dispatched in a goroutine so it never blocks the UI.
func (m *Model) maybeScrobble(track playlist.Track, elapsed, duration time.Duration) {
// Emit scrobble event to Lua plugins for all tracks (not just Navidrome).
if m.luaMgr != nil && m.luaMgr.HasHooks() {
dur := duration
if dur <= 0 {
dur = time.Duration(track.DurationSecs) * time.Second
}
if dur > 0 && elapsed >= dur/2 {
data := trackToMap(track)
data["played_secs"] = elapsed.Seconds()
m.luaMgr.Emit(luaplugin.EventTrackScrobble, data)
}
}
scrobbler := m.findScrobbler()
if scrobbler == nil {
return
}
if duration <= 0 {
// Unknown duration: use DurationSecs metadata as fallback.
duration = time.Duration(track.DurationSecs) * time.Second
}
if duration <= 0 {
return // still unknown — skip
}
if elapsed < duration/2 {
return // less than 50% played
}
go scrobbler.Scrobble(track, true)
}
// findScrobbler returns the first registered provider that implements Scrobbler.
func (m *Model) findScrobbler() provider.Scrobbler {
prov := m.findProviderWith(func(p playlist.Provider) bool {
_, ok := p.(provider.Scrobbler)
return ok
})
if prov == nil {
return nil
}
return prov.(provider.Scrobbler)
}
+86
View File
@@ -0,0 +1,86 @@
package model
import (
"cliamp/theme"
)
// openThemePicker re-loads themes from disk (picking up new user files)
// and opens the theme selector overlay.
func (m *Model) openThemePicker() {
m.themes = theme.LoadAll()
m.themePicker.visible = true
m.themePicker.savedIdx = m.themeIdx
// Position cursor on the currently active theme.
// Picker list: 0 = Default, 1..N = themes[0..N-1]
m.themePicker.cursor = m.themeIdx + 1
}
// themePickerApply applies the theme under the cursor for live preview.
func (m *Model) themePickerApply() {
if m.themePicker.cursor == 0 {
m.themeIdx = -1
applyThemeAll(theme.Default())
} else {
m.themeIdx = m.themePicker.cursor - 1
applyThemeAll(m.themes[m.themeIdx])
}
}
// themePickerSelect confirms the current selection and closes the picker.
func (m *Model) themePickerSelect() {
m.themePickerApply()
m.themePicker.visible = false
}
// themePickerCancel restores the theme from before the picker was opened.
func (m *Model) themePickerCancel() {
m.themeIdx = m.themePicker.savedIdx
if m.themeIdx < 0 {
applyThemeAll(theme.Default())
} else {
applyThemeAll(m.themes[m.themeIdx])
}
m.themePicker.visible = false
}
// openPlaylistManager loads playlist metadata and opens the manager overlay.
func (m *Model) openPlaylistManager() {
m.plMgrRefreshList()
m.plManager.screen = plMgrScreenList
m.plManager.confirmDel = false
m.plManager.visible = true
}
// plMgrEnterTrackList loads the tracks for a playlist and switches to screen 1.
func (m *Model) plMgrEnterTrackList(name string) {
tracks, err := m.localProvider.Tracks(name)
if err != nil {
m.status.Showf(statusTTLDefault, "Load failed: %s", err)
return
}
m.plManager.selPlaylist = name
m.plManager.tracks = tracks
m.plManager.screen = plMgrScreenTracks
m.plManager.cursor = 0
m.plManager.confirmDel = false
}
// plMgrRefreshList reloads playlist names and counts from disk and clamps the cursor.
func (m *Model) plMgrRefreshList() {
if m.localProvider == nil {
return
}
playlists, err := m.localProvider.Playlists()
if err != nil {
m.status.Showf(statusTTLDefault, "Load failed: %s", err)
}
m.plManager.playlists = playlists
// +1 for the "+ New Playlist..." entry
total := len(m.plManager.playlists) + 1
if m.plManager.cursor >= total {
m.plManager.cursor = total - 1
}
if m.plManager.cursor < 0 {
m.plManager.cursor = 0
}
}
@@ -1,4 +1,4 @@
package ui
package model
import (
"testing"
+161
View File
@@ -0,0 +1,161 @@
package model
import (
"time"
tea "github.com/charmbracelet/bubbletea"
"cliamp/playlist"
)
// nextTrack advances to the next playlist track and starts playing it.
// Returns a tea.Cmd for async stream playback.
func (m *Model) nextTrack() tea.Cmd {
track, ok := m.playlist.Next()
if !ok {
m.player.Stop()
return nil
}
m.plCursor = m.playlist.Index()
m.adjustScroll()
return m.playTrack(track)
}
// prevTrack goes to the previous track, or restarts if >3s into the current one.
func (m *Model) prevTrack() tea.Cmd {
if m.player.Position() > 3*time.Second {
if m.player.Seekable() {
// Local file or seekable stream: jump back to the beginning.
m.player.Seek(-m.player.Position())
return nil
}
// Non-seekable stream (e.g. Icecast radio): restart by replaying the URL.
track, idx := m.playlist.Current()
if idx >= 0 {
return m.playTrack(track)
}
return nil
}
track, ok := m.playlist.Prev()
if !ok {
return nil
}
m.plCursor = m.playlist.Index()
m.adjustScroll()
return m.playTrack(track)
}
// playCurrentTrack starts playing whatever track the playlist cursor points to.
func (m *Model) playCurrentTrack() tea.Cmd {
track, idx := m.playlist.Current()
if idx < 0 {
return nil
}
m.titleOff = 0
return m.playTrack(track)
}
// playTrack plays a track, using async HTTP for streams and sync I/O for local files.
// yt-dlp URLs are streamed via a piped yt-dlp | ffmpeg chain for instant playback.
func (m *Model) playTrack(track playlist.Track) tea.Cmd {
m.reconnect.attempts = 0
m.reconnect.at = time.Time{}
m.streamTitle = ""
m.lyrics.lines = nil
m.lyrics.err = nil
m.lyrics.query = ""
m.lyrics.scroll = 0
m.seek.active = false
m.seek.timer = 0
m.seek.timerFor = 0
m.seek.grace = 0
m.seek.graceFor = 0
var fetchCmd tea.Cmd
if m.lyrics.visible && track.Artist != "" && track.Title != "" {
m.lyrics.loading = true
m.lyrics.query = track.Artist + "\n" + track.Title
fetchCmd = fetchLyricsCmd(track.Artist, track.Title)
}
// Stream yt-dlp URLs (YouTube, SoundCloud, Bandcamp, etc.) via pipe chain.
if playlist.IsYTDL(track.Path) {
m.buffering = true
m.bufferingAt = time.Now()
m.err = nil
dur := time.Duration(track.DurationSecs) * time.Second
if fetchCmd != nil {
return tea.Batch(playYTDLStreamCmd(m.player, track.Path, dur), fetchCmd)
}
return playYTDLStreamCmd(m.player, track.Path, dur)
}
// Fire now-playing notification for Navidrome tracks.
m.nowPlaying(track)
dur := time.Duration(track.DurationSecs) * time.Second
if track.Stream {
m.buffering = true
m.bufferingAt = time.Now()
m.err = nil
return tea.Batch(playStreamCmd(m.player, track.Path, dur), fetchCmd)
}
if err := m.player.Play(track.Path, dur); err != nil {
m.err = err
} else {
m.err = nil
m.applyResume()
}
if fetchCmd != nil {
return tea.Batch(m.preloadNext(), fetchCmd)
}
return m.preloadNext()
}
// togglePlayPause starts playback if stopped, or toggles pause if playing.
// For live streams, unpausing reconnects to get current audio instead of
// playing stale data sitting in OS/decoder buffers from before the pause.
func (m *Model) togglePlayPause() tea.Cmd {
if m.buffering {
return nil
}
if !m.player.IsPlaying() {
return m.playCurrentTrack()
}
if m.player.IsPaused() {
track, idx := m.playlist.Current()
if shouldReconnectOnUnpause(track, idx) {
m.player.Stop()
return m.playTrack(track)
}
}
m.player.TogglePause()
return nil
}
// shouldReconnectOnUnpause reports whether unpausing should reconnect and
// restart instead of resuming buffered audio.
func shouldReconnectOnUnpause(track playlist.Track, idx int) bool {
return idx >= 0 && track.IsLive()
}
// applyResume seeks to the saved resume position if the current track matches.
// It clears the resume state after a successful seek so it only fires once.
func (m *Model) applyResume() {
// secs == 0 is indistinguishable from "never played"; skip resume.
if m.resume.path == "" || m.resume.secs <= 0 {
return
}
track, _ := m.playlist.Current()
if track.Path != m.resume.path {
return
}
// Only seek if the player reports the stream is seekable; otherwise the
// seek is a no-op that returns nil, which we must not mistake for success.
if !m.player.Seekable() {
return
}
target := time.Duration(m.resume.secs) * time.Second
if err := m.player.Seek(target - m.player.Position()); err == nil {
m.resume.path = ""
m.resume.secs = 0
}
}
+76
View File
@@ -0,0 +1,76 @@
package model
import (
"time"
tea "github.com/charmbracelet/bubbletea"
"cliamp/playlist"
)
// streamPreloadLeadTime is how far before the end of a stream we arm the
// gapless next pipeline. Opening the preload HTTP connection too early can
// cause the server to close the current stream (e.g., per-user concurrent
// stream limits on Navidrome), which makes the mp3 decoder error out and
// triggers a premature gapless transition. 3 seconds is short enough that
// most servers won't enforce a concurrency limit for such a brief overlap,
// and any resulting early skip is imperceptible (≤3 s from the true end).
const streamPreloadLeadTime = 3 * time.Second
// ytdlPreloadLeadTime is the lead time used for yt-dlp (YouTube/SoundCloud)
// URLs. These need longer because spinning up the yt-dlp | ffmpeg pipe chain
// takes 3-10 seconds, so we start preloading much earlier.
const ytdlPreloadLeadTime = 15 * time.Second
// preloadNext looks ahead in the playlist and preloads the next track for
// gapless transition. Errors are silently ignored — playback falls back to
// non-gapless if preloading fails.
//
// For HTTP streams with a known duration, preloading is deferred until the
// current track is within streamPreloadLeadTime of its end. This prevents the
// gapless streamer from having a live HTTP connection armed too early, which
// would cause the player to skip to the next track if the decoder signals EOF
// prematurely (e.g. a mis-estimated Content-Length from a transcoding server).
// When position has not yet reached the threshold, this function returns nil
// and the tick loop will retry on the next pass.
func (m *Model) preloadNext() tea.Cmd {
next, ok := m.playlist.PeekNext()
if !ok {
return nil
}
// Preload yt-dlp tracks with the same lead-time deferral as HTTP streams.
if playlist.IsYTDL(next.Path) {
dur := m.player.Duration()
if dur > 0 {
remaining := dur - m.player.Position()
if remaining > ytdlPreloadLeadTime {
return nil
}
}
nextDur := time.Duration(next.DurationSecs) * time.Second
m.preloading = true
return preloadYTDLStreamCmd(m.player, next.Path, nextDur)
}
if next.Stream {
// For streams, only arm gapless if we're within the lead-time window.
// If we don't know the duration yet (0), preload immediately as before
// so that streams without duration metadata still get gapless behaviour.
dur := m.player.Duration()
if dur > 0 {
pos := m.player.Position()
remaining := dur - pos
if remaining > streamPreloadLeadTime {
// Too early — caller should retry from the tick loop.
return nil
}
}
nextDur := time.Duration(next.DurationSecs) * time.Second
// Mark in-flight so the tick loop doesn't dispatch a second concurrent
// preload before this goroutine has finished arming gapless.SetNext.
m.preloading = true
return preloadStreamCmd(m.player, next.Path, nextDur)
}
nextDur := time.Duration(next.DurationSecs) * time.Second
m.player.Preload(next.Path, nextDur)
return nil
}
+153
View File
@@ -0,0 +1,153 @@
package model
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"cliamp/playlist"
"cliamp/provider"
)
// StartInProvider configures the model to begin in the provider browse view.
// Call this from main when no CLI tracks or pending URLs were given.
func (m *Model) StartInProvider() {
if m.provider != nil {
m.focus = focusProvider
m.provLoading = true
}
}
// switchProvider sets the active provider by pill index and fetches its playlists.
func (m *Model) switchProvider(idx int) tea.Cmd {
if idx < 0 || idx >= len(m.providers) {
return nil
}
m.provPillIdx = idx
m.provider = m.providers[idx].Provider
m.providerLists = nil
m.provCursor = 0
m.provLoading = true
m.provSignIn = false
m.provSearch.active = false
m.catalogBatch = catalogBatchState{} // reset catalog batch for new provider
m.focus = focusProvider
return fetchPlaylistsCmd(m.provider)
}
// switchToProvider finds a provider by config key and switches to it.
// Returns nil if the provider is not configured.
func (m *Model) switchToProvider(key string) tea.Cmd {
for i, pe := range m.providers {
if pe.Key == key {
return m.switchProvider(i)
}
}
return nil
}
// SetPendingURLs stores remote URLs (feeds, M3U) for async resolution after Init.
func (m *Model) SetPendingURLs(urls []string) {
m.pendingURLs = urls
m.feedLoading = len(urls) > 0
}
// findBrowseProvider returns the first provider that supports browsing
// (ArtistBrowser or AlbumBrowser), preferring the active provider.
func (m *Model) findBrowseProvider() playlist.Provider {
return m.findProviderWith(func(p playlist.Provider) bool {
if _, ok := p.(provider.ArtistBrowser); ok {
return true
}
_, ok := p.(provider.AlbumBrowser)
return ok
})
}
func (m *Model) openNavBrowserWith(prov playlist.Provider) {
m.navBrowser.prov = prov
m.navBrowser.visible = true
m.navBrowser.mode = navBrowseModeMenu
m.navBrowser.screen = navBrowseScreenList
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
m.navBrowser.artists = nil
m.navBrowser.albums = nil
m.navBrowser.tracks = nil
m.navBrowser.loading = false
m.navBrowser.albumLoading = false
m.navBrowser.albumDone = false
m.navBrowser.searching = false
m.navBrowser.search = ""
m.navBrowser.searchIdx = nil
}
// navUpdateSearch rebuilds navSearchIdx from the current navSearch query
// against whichever list is active on the current nav screen.
func (m *Model) navUpdateSearch() {
q := strings.ToLower(m.navBrowser.search)
if q == "" {
m.navBrowser.searchIdx = nil
return
}
m.navBrowser.searchIdx = nil
switch {
case m.navBrowser.mode == navBrowseModeByArtist && m.navBrowser.screen == navBrowseScreenList,
m.navBrowser.mode == navBrowseModeByArtistAlbum && m.navBrowser.screen == navBrowseScreenList:
for i, a := range m.navBrowser.artists {
if strings.Contains(strings.ToLower(a.Name), q) {
m.navBrowser.searchIdx = append(m.navBrowser.searchIdx, i)
}
}
case m.navBrowser.mode == navBrowseModeByAlbum && m.navBrowser.screen == navBrowseScreenList,
m.navBrowser.mode == navBrowseModeByArtistAlbum && m.navBrowser.screen == navBrowseScreenAlbums:
for i, a := range m.navBrowser.albums {
if strings.Contains(strings.ToLower(a.Name), q) ||
strings.Contains(strings.ToLower(a.Artist), q) {
m.navBrowser.searchIdx = append(m.navBrowser.searchIdx, i)
}
}
case m.navBrowser.screen == navBrowseScreenTracks:
for i, t := range m.navBrowser.tracks {
if strings.Contains(strings.ToLower(t.Title), q) ||
strings.Contains(strings.ToLower(t.Artist), q) ||
strings.Contains(strings.ToLower(t.Album), q) {
m.navBrowser.searchIdx = append(m.navBrowser.searchIdx, i)
}
}
}
}
// navClearSearch resets the nav search state.
func (m *Model) navClearSearch() {
m.navBrowser.searching = false
m.navBrowser.search = ""
m.navBrowser.searchIdx = nil
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
}
// fetchNavArtistAllTracksCmd first fetches the artist's album list, then fetches
// all tracks across every album. This is used by the "By Artist" browse mode.
// The provider must implement both ArtistBrowser and AlbumTrackLoader.
func (m *Model) fetchNavArtistAllTracksCmd(ab provider.ArtistBrowser, artistID string) tea.Cmd {
loader, _ := m.navBrowser.prov.(provider.AlbumTrackLoader)
return func() tea.Msg {
albums, err := ab.ArtistAlbums(artistID)
if err != nil {
return err
}
if loader == nil {
return navTracksLoadedMsg(nil)
}
var all []playlist.Track
for _, album := range albums {
tracks, err := loader.AlbumTracks(album.ID)
if err != nil {
return err
}
all = append(all, tracks...)
}
return navTracksLoadedMsg(all)
}
}
@@ -1,4 +1,4 @@
package ui
package model
import "testing"
+109
View File
@@ -0,0 +1,109 @@
package model
import (
"strings"
"github.com/charmbracelet/lipgloss"
"cliamp/playlist"
"cliamp/ui"
)
// renderedLineCount returns how many rendered lines tracks[from..to) would
// take, including album separator lines between different albums.
func renderedLineCount(tracks []playlist.Track, from, to int) int {
lines := 0
prevAlbum := ""
if from > 0 {
prevAlbum = tracks[from-1].Album
}
for i := from; i < to && i < len(tracks); i++ {
if album := tracks[i].Album; album != "" && album != prevAlbum {
lines++ // album separator
}
prevAlbum = tracks[i].Album
lines++ // track line
}
return lines
}
// defaultPlVisible recalculates the natural plVisible for the current terminal
// height (same logic as the window-resize handler, capped at maxPlVisible).
func (m *Model) defaultPlVisible() int {
saved := m.plVisible
m.plVisible = 3 // temporary minimal value for measurement
defer func() { m.plVisible = saved }()
probe := strings.Join([]string{
m.renderTitle(), m.renderTrackInfo(), m.renderTimeStatus(), "",
m.renderSpectrum(), m.renderSeekBar(), "",
m.renderControls(), "", m.renderPlaylistHeader(),
"x", "", m.renderHelp(), m.renderBottomStatus(),
}, "\n")
fixedLines := lipgloss.Height(ui.FrameStyle.Render(probe)) - 1
return max(3, min(maxPlVisible, m.height-fixedLines))
}
// adjustScroll ensures plCursor is visible in the playlist view.
// It accounts for album separator lines that reduce the number of
// tracks that fit in the visible window.
func (m *Model) adjustScroll() {
tracks := m.playlist.Tracks()
if len(tracks) == 0 {
return
}
visible := m.effectivePlaylistVisible()
if visible <= 0 {
return
}
m.plScroll = m.playlistScroll(visible)
}
func (m Model) playlistScroll(visible int) int {
tracks := m.playlist.Tracks()
scroll := max(0, m.plScroll)
if scroll >= len(tracks) {
scroll = max(0, len(tracks)-1)
}
if m.plCursor < scroll {
return m.plCursor
}
lines := renderedLineCount(tracks, scroll, m.plCursor+1)
if lines <= visible {
return scroll
}
scroll = m.plCursor
lines = 1 // the cursor track itself
for i := m.plCursor - 1; i >= 0; i-- {
add := 1 // track line
if tracks[i+1].Album != "" && tracks[i+1].Album != tracks[i].Album {
add++ // separator above track i+1
}
if lines+add > visible {
break
}
lines += add
scroll = i
}
if scroll > 0 && tracks[scroll].Album != "" && tracks[scroll].Album != tracks[scroll-1].Album {
if lines+1 > visible {
scroll++
}
}
return scroll
}
func (m Model) mainFrameFixedLines(includeTransient bool) int {
content := strings.Join(m.mainSections("", includeTransient), "\n")
return lipgloss.Height(ui.FrameStyle.Render(content))
}
func (m Model) effectivePlaylistVisible() int {
available := m.height - m.mainFrameFixedLines(true)
if available <= 0 {
return 0
}
if m.plVisible <= 0 {
return 0
}
return min(m.plVisible, available)
}
+20
View File
@@ -0,0 +1,20 @@
package model
import (
"strings"
)
// updateSearch filters the playlist by the current search query.
func (m *Model) updateSearch() {
m.search.results = nil
m.search.cursor = 0
if m.search.query == "" {
return
}
query := strings.ToLower(m.search.query)
for i, t := range m.playlist.Tracks() {
if strings.Contains(strings.ToLower(t.DisplayName()), query) {
m.search.results = append(m.search.results, i)
}
}
}
+4 -2
View File
@@ -1,9 +1,11 @@
package ui
package model
import (
"time"
tea "github.com/charmbracelet/bubbletea"
"cliamp/ui"
)
// seekDebounceTicks is how many ticks to wait after the last seek keypress
@@ -71,7 +73,7 @@ func (m *Model) tickSeek(dt time.Duration) tea.Cmd {
m.seek.timerFor = 0
return nil
}
if advanceTickUnits(&m.seek.timer, &m.seek.timerFor, dt, tickFast) == 0 || m.seek.timer > 0 {
if advanceTickUnits(&m.seek.timer, &m.seek.timerFor, dt, ui.TickFast) == 0 || m.seek.timer > 0 {
return nil
}
@@ -1,4 +1,4 @@
package ui
package model
import (
"testing"
@@ -1,6 +1,8 @@
package ui
package model
import (
"cliamp/ui"
"os"
"path/filepath"
"strings"
@@ -67,13 +69,13 @@ func TestTickPendingSpeedSaveUsesElapsedTime(t *testing.T) {
configPath := filepath.Join(home, ".config", "cliamp", "config.toml")
for i := 0; i < 4; i++ {
m.tickPendingSpeedSave(tickSlow)
m.tickPendingSpeedSave(ui.TickSlow)
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
t.Fatalf("config created after %d slow ticks, want no save before %v", i+1, speedSaveDebounce)
}
}
m.tickPendingSpeedSave(tickSlow)
m.tickPendingSpeedSave(ui.TickSlow)
data, err := os.ReadFile(configPath)
if err != nil {
+1 -1
View File
@@ -1,7 +1,7 @@
// state.go defines sub-structs that group related fields in the Model,
// making the overall model scannable and maintainable.
package ui
package model
import (
"fmt"
@@ -1,4 +1,4 @@
package ui
package model
import (
"testing"
+77
View File
@@ -0,0 +1,77 @@
package model
import (
"github.com/charmbracelet/lipgloss"
"cliamp/ui"
)
// Model-specific lipgloss styles, rebuilt when the theme changes.
var (
titleStyle = lipgloss.NewStyle().
Foreground(ui.ColorTitle).
Bold(true)
trackStyle = lipgloss.NewStyle().
Foreground(ui.ColorAccent)
timeStyle = lipgloss.NewStyle().
Foreground(ui.ColorText)
statusStyle = lipgloss.NewStyle().
Foreground(ui.ColorPlaying).
Bold(true)
dimStyle = lipgloss.NewStyle().
Foreground(ui.ColorDim)
labelStyle = lipgloss.NewStyle().
Foreground(ui.ColorText).
Bold(true)
eqActiveStyle = lipgloss.NewStyle().
Foreground(ui.ColorAccent).
Bold(true)
eqInactiveStyle = lipgloss.NewStyle().
Foreground(ui.ColorDim)
playlistActiveStyle = lipgloss.NewStyle().
Foreground(ui.ColorPlaying).
Bold(true)
playlistItemStyle = lipgloss.NewStyle().
Foreground(ui.ColorText)
playlistSelectedStyle = lipgloss.NewStyle().
Foreground(ui.ColorAccent).
Bold(true)
helpStyle = lipgloss.NewStyle().
Foreground(ui.ColorDim)
errorStyle = lipgloss.NewStyle().
Foreground(ui.ColorError)
)
// rebuildModelStyles reconstructs all model-specific lipgloss styles from current color variables.
func rebuildModelStyles() {
titleStyle = lipgloss.NewStyle().Foreground(ui.ColorTitle).Bold(true)
trackStyle = lipgloss.NewStyle().Foreground(ui.ColorAccent)
timeStyle = lipgloss.NewStyle().Foreground(ui.ColorText)
statusStyle = lipgloss.NewStyle().Foreground(ui.ColorPlaying).Bold(true)
dimStyle = lipgloss.NewStyle().Foreground(ui.ColorDim)
labelStyle = lipgloss.NewStyle().Foreground(ui.ColorText).Bold(true)
eqActiveStyle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true)
eqInactiveStyle = lipgloss.NewStyle().Foreground(ui.ColorDim)
playlistActiveStyle = lipgloss.NewStyle().Foreground(ui.ColorPlaying).Bold(true)
playlistItemStyle = lipgloss.NewStyle().Foreground(ui.ColorText)
playlistSelectedStyle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true)
helpStyle = lipgloss.NewStyle().Foreground(ui.ColorDim)
errorStyle = lipgloss.NewStyle().Foreground(ui.ColorError)
seekFillStyle = lipgloss.NewStyle().Foreground(ui.ColorSeekBar)
seekDimStyle = lipgloss.NewStyle().Foreground(ui.ColorDim)
volBarStyle = lipgloss.NewStyle().Foreground(ui.ColorVolume)
activeToggle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true)
}
+1 -1
View File
@@ -1,4 +1,4 @@
package ui
package model
import "unicode/utf8"
+154
View File
@@ -0,0 +1,154 @@
package model
import (
"time"
tea "github.com/charmbracelet/bubbletea"
"cliamp/ui"
)
type tickMsg time.Time
type autoPlayMsg struct{}
var teaTick = tea.Tick
func tickCmd() tea.Cmd {
return tickCmdAt(ui.TickFast)
}
func tickCmdAt(d time.Duration) tea.Cmd {
return teaTick(d, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func (m *Model) visualizerPlaying() bool {
return m.player != nil && m.vis != nil && m.vis.Mode != ui.VisNone &&
!m.isOverlayActive() && m.player.IsPlaying() && !m.player.IsPaused()
}
func (m *Model) visualizerPaused() bool {
return m.player != nil && m.vis != nil && m.vis.Mode != ui.VisNone &&
!m.isOverlayActive() && m.player.IsPlaying() && m.player.IsPaused()
}
func (m *Model) visualizerTickContext(now time.Time) ui.VisTickContext {
sampled := false
samplesRead := 0
sampledSize := 0
cache := map[ui.VisAnalysisSpec][]float64{}
return ui.VisTickContext{
Now: now,
Playing: m.visualizerPlaying(),
Paused: m.visualizerPaused(),
OverlayActive: m.isOverlayActive(),
Analyze: func(spec ui.VisAnalysisSpec) []float64 {
spec = ui.NormalizeAnalysisSpec(spec)
if m.player == nil || m.vis == nil || m.vis.Mode == ui.VisNone {
return nil
}
if bands, ok := cache[spec]; ok {
return bands
}
buf := m.vis.EnsureSampleBuf(spec.FFTSize)
if !sampled || spec.FFTSize > sampledSize {
samplesRead = m.player.SamplesInto(buf)
sampled = true
sampledSize = spec.FFTSize
}
start := max(0, samplesRead-spec.FFTSize)
bands := m.vis.Analyze(buf[start:samplesRead], spec)
cache[spec] = bands
return bands
},
}
}
func (m *Model) tickDelta(now time.Time) time.Duration {
dt := m.tickInterval()
if !now.IsZero() && !m.lastTickAt.IsZero() {
dt = now.Sub(m.lastTickAt)
}
if dt <= 0 {
dt = ui.TickFast
}
if !now.IsZero() {
m.lastTickAt = now
}
return dt
}
func advanceTickUnits(counter *int, elapsed *time.Duration, dt, quantum time.Duration) int {
if *counter <= 0 {
*elapsed = 0
return 0
}
*elapsed += dt
if *elapsed < quantum {
return 0
}
steps := int(*elapsed / quantum)
if steps > *counter {
steps = *counter
}
*counter -= steps
if *counter == 0 {
*elapsed = 0
return steps
}
*elapsed -= time.Duration(steps) * quantum
return steps
}
func (m *Model) tickInterval() time.Duration {
if m.termTitle.introActive {
return ui.TickFast
}
if m.vis == nil {
return ui.TickSlow
}
return m.vis.TickInterval(m.visualizerTickContext(time.Time{}))
}
func (m *Model) tickVisualizer(now time.Time) {
if m.vis == nil {
return
}
m.vis.Tick(m.visualizerTickContext(now))
}
func (m Model) refreshVisualizerIfPending() {
if m.vis == nil || m.vis.Mode == ui.VisNone || m.activeScreen().hidesVisualizer() || !m.vis.ConsumeRefresh() {
return
}
m.tickVisualizer(time.Now())
}
func (m Model) maybeRequestVisualizerRefresh(msg tea.Msg, wasScreen topLevelScreen, wasMode ui.VisMode, wasPlaying, wasPaused bool) {
if m.vis == nil {
return
}
if _, ok := msg.(tickMsg); ok {
return
}
screen := m.activeScreen()
if screen.hidesVisualizer() || m.vis.Mode == ui.VisNone {
return
}
playing := false
paused := false
if m.player != nil {
playing = m.player.IsPlaying()
paused = m.player.IsPaused()
}
if wasScreen != screen ||
wasMode != m.vis.Mode ||
(!wasPlaying && playing) ||
(wasPaused && !paused) {
m.vis.RequestRefresh()
}
}
+211
View File
@@ -0,0 +1,211 @@
package model
import (
"os"
"testing"
"time"
"cliamp/player"
"cliamp/playlist"
"cliamp/ui"
tea "github.com/charmbracelet/bubbletea"
)
var sharedPlayer *player.Player
func TestMain(m *testing.M) {
sr := player.DeviceSampleRate()
if sr <= 0 {
sr = 44100
}
p, err := player.New(player.Quality{SampleRate: sr, BufferMs: 100, ResampleQuality: 1})
if err == nil {
sharedPlayer = p
defer p.Close()
}
os.Exit(m.Run())
}
// TestTickIntervalStoppedUsesSlow verifies that when the player is stopped,
// the tick interval is ui.TickSlow (~200ms) not ui.TickFast (~50ms), regardless of
// the visualizer mode. This matters for CPU usage (issue #92).
func TestTickIntervalStoppedUsesSlow(t *testing.T) {
if sharedPlayer == nil {
t.Skip("audio hardware unavailable")
}
m := Model{
player: sharedPlayer,
vis: ui.NewVisualizer(float64(sharedPlayer.SampleRate())),
playlist: playlist.New(),
termTitle: terminalTitleState{last: baseTerminalTitle},
}
// Player is stopped by default (IsPlaying=false).
// vis.Mode defaults to ui.VisBars (0 != ui.VisNone).
if sharedPlayer.IsPlaying() {
t.Fatal("expected player to be stopped")
}
if m.vis.Mode == ui.VisNone {
t.Fatal("expected default vis mode to be non-None (ui.VisBars)")
}
_, cmd := m.Update(tickMsg(time.Now()))
if cmd == nil {
t.Fatal("tickMsg returned nil cmd")
}
start := time.Now()
cmd() // blocks until the tick timer fires
elapsed := time.Since(start)
// ui.TickSlow=200ms, ui.TickFast=50ms. With tolerance for scheduling jitter.
const tolerance = 80 * time.Millisecond
if elapsed < ui.TickSlow-tolerance {
t.Errorf("tick fired after %v, want ~%v (ui.TickSlow); got ui.TickFast instead — CPU fix not working",
elapsed, ui.TickSlow)
}
t.Logf("tick interval when stopped: %v (want ~%v ui.TickSlow)", elapsed.Round(time.Millisecond), ui.TickSlow)
}
func TestInitialTickUsesFastCadence(t *testing.T) {
prev := teaTick
t.Cleanup(func() {
teaTick = prev
})
called := false
teaTick = func(d time.Duration, fn func(time.Time) tea.Msg) tea.Cmd {
called = true
if d != ui.TickFast {
t.Fatalf("tick duration = %v, want %v", d, ui.TickFast)
}
return func() tea.Msg {
return fn(time.Unix(0, 0))
}
}
msg := tickCmd()()
if _, ok := msg.(tickMsg); !ok {
t.Fatalf("tickCmd() message = %T, want tickMsg", msg)
}
if !called {
t.Fatal("tickCmd() did not schedule teaTick")
}
}
func TestRefreshVisualizerIfPendingConsumesOneShotRequest(t *testing.T) {
m := Model{
vis: ui.NewVisualizer(44100),
}
m.vis.RequestRefresh()
m.refreshVisualizerIfPending()
if m.vis.RefreshPending() {
t.Fatal("refreshPending = true after refreshVisualizerIfPending(), want false")
}
if m.vis.Frame() != 1 {
t.Fatalf("frame after refreshVisualizerIfPending() = %d, want 1", m.vis.Frame())
}
m.refreshVisualizerIfPending()
if m.vis.Frame() != 1 {
t.Fatalf("frame after second refreshVisualizerIfPending() = %d, want 1", m.vis.Frame())
}
}
func TestLyricsScreenHidesVisualizerTicks(t *testing.T) {
m := Model{
vis: ui.NewVisualizer(44100),
lyrics: lyricsState{
visible: true,
},
}
if got := m.activeScreen(); got != screenLyrics {
t.Fatalf("activeScreen() = %v, want %v", got, screenLyrics)
}
if !m.isOverlayActive() {
t.Fatal("isOverlayActive() = false, want true while lyrics screen is visible")
}
if !m.visualizerTickContext(time.Now()).OverlayActive {
t.Fatal("visualizerTickContext(...).OverlayActive = false, want true for lyrics screen")
}
m.vis.RequestRefresh()
m.refreshVisualizerIfPending()
if !m.vis.RefreshPending() {
t.Fatal("refreshPending = false after lyrics-screen refresh attempt, want true")
}
if m.vis.Frame() != 0 {
t.Fatalf("frame after lyrics-screen refresh attempt = %d, want 0", m.vis.Frame())
}
}
func TestUpdateRequestsVisualizerRefreshWhenOverlayCloses(t *testing.T) {
m := Model{
vis: ui.NewVisualizer(44100),
keymap: keymapOverlay{
visible: true,
},
}
nextModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEscape})
if cmd != nil {
t.Fatalf("Update() cmd = %v, want nil", cmd)
}
next, ok := nextModel.(Model)
if !ok {
t.Fatalf("Update() model = %T, want Model", nextModel)
}
if next.keymap.visible {
t.Fatal("keymap overlay remained visible after escape")
}
if !next.vis.RefreshPending() {
t.Fatal("refreshPending = false after overlay close, want true")
}
}
func TestUpdateRequestsVisualizerRefreshWhenLyricsClose(t *testing.T) {
m := Model{
vis: ui.NewVisualizer(44100),
lyrics: lyricsState{
visible: true,
},
}
nextModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEscape})
if cmd != nil {
t.Fatalf("Update() cmd = %v, want nil", cmd)
}
next, ok := nextModel.(Model)
if !ok {
t.Fatalf("Update() model = %T, want Model", nextModel)
}
if next.lyrics.visible {
t.Fatal("lyrics overlay remained visible after escape")
}
if !next.vis.RefreshPending() {
t.Fatal("refreshPending = false after lyrics close, want true")
}
}
func TestAdvanceTickUnitsClearsElapsedWhenCounterCompletes(t *testing.T) {
ttl := 1
elapsed := time.Duration(0)
if got := advanceTickUnits(&ttl, &elapsed, 3*time.Second, ui.TickFast); got != 1 {
t.Fatalf("advanceTickUnits() steps = %d, want 1", got)
}
if ttl != 0 {
t.Fatalf("ttl after completion = %d, want 0", ttl)
}
if elapsed != 0 {
t.Fatalf("elapsed after completion = %v, want 0", elapsed)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
package ui
package model
import (
"strings"
+1 -1
View File
@@ -1,4 +1,4 @@
package ui
package model
import (
"strings"
+626
View File
@@ -0,0 +1,626 @@
package model
import (
"errors"
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"cliamp/mpris"
"cliamp/playlist"
"cliamp/provider"
"cliamp/ui"
)
// Update handles messages: key presses, ticks, and window resizes.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
wasScreen := m.activeScreen()
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, wasMode, wasPlaying, wasPaused)
}()
switch msg := msg.(type) {
case tea.KeyMsg:
cmd := m.handleKey(msg)
if m.quitting {
return m, tea.Quit
}
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
// Dynamic frame width: use full terminal width, or cap at 80 in compact mode.
frameW := msg.Width
if m.compact {
frameW = min(frameW, 80)
}
ui.FrameStyle = ui.FrameStyle.Width(frameW)
ui.PanelWidth = max(0, frameW-2*ui.PaddingH)
if m.fullVis {
m.vis.Rows = max(ui.DefaultVisRows, (m.height-10)*4/5)
}
m.plVisible = m.defaultPlVisible()
return m, m.terminalTitleCmd()
case seekTickMsg:
// Async yt-dlp 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
if m.mpris != nil {
m.mpris.EmitSeeked(m.player.Position().Microseconds())
}
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.
if !m.buffering {
m.cachedPos = m.displayPosition()
m.cachedDur = m.player.Duration()
} else {
track, _ := m.playlist.Current()
m.cachedDur = time.Duration(track.DurationSecs) * time.Second
m.cachedPos = 0
}
m.tickVisualizer(now)
// Process debounced yt-dlp seek.
var seekCmd tea.Cmd
if cmd := m.tickSeek(dt); cmd != nil {
seekCmd = cmd
}
// Expire temporary status messages.
if !m.status.expiresAt.IsZero() && !now.Before(m.status.expiresAt) {
m.status.Clear()
}
m.tickPendingSpeedSave(dt)
// 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.playlist.Current()
isStream := idx >= 0 && (track.Stream || playlist.IsYouTubeURL(track.Path) || playlist.IsYTDL(track.Path))
if isStream && m.reconnect.attempts < 5 {
// Schedule reconnect with exponential backoff: 1s, 2s, 4s, 8s, 16s
if m.reconnect.at.IsZero() {
delay := time.Second << m.reconnect.attempts
m.reconnect.at = now.Add(delay)
m.reconnect.attempts++
m.err = fmt.Errorf("Reconnecting in %s...", delay)
}
} 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.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)
}
}
}
}
m.network.sampleFor += dt
if m.network.sampleFor >= time.Second {
m.notifyAll()
downloaded, _ := m.player.StreamBytes()
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{}
m.player.Stop()
if track, idx := m.playlist.Current(); idx >= 0 {
return m, tea.Batch(m.playTrack(track), tickCmdAt(ui.TickFast))
}
}
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.playlist.Current()
fullDur := time.Duration(finishedTrack.DurationSecs) * time.Second
m.maybeScrobble(finishedTrack, fullDur, fullDur)
m.playlist.Next()
m.plCursor = m.playlist.Index()
m.adjustScroll()
m.titleOff = 0
// 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
// Fire now-playing notification for the track the audio engine just
// started. playTrack() is not called on this path, so we must notify
// here explicitly.
if newTrack, idx := m.playlist.Current(); idx >= 0 {
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() {
// Track drained to end — always ≥ 50%.
finishedTrack, _ := m.playlist.Current()
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()
if cmd := m.terminalTitleCmd(); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, tickCmdAt(m.tickInterval()))
return m, tea.Batch(cmds...)
case []playlist.PlaylistInfo:
m.providerLists = msg
m.provLoading = false
// 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, fetchCatalogBatchCmd(loader, m.catalogBatch.offset, catalogBatchSize)
}
return m, nil
case tracksLoadedMsg:
wasPlaying := m.player.IsPlaying()
if !wasPlaying {
m.player.Stop()
m.player.ClearPreload()
}
m.resetYTDLBatch()
m.playlist.Replace(msg)
m.plCursor = 0
m.plScroll = 0
m.focus = focusPlaylist
m.provLoading = false
if m.playlist.Len() > 0 && !wasPlaying {
cmd := m.playCurrentTrack()
m.notifyAll()
return m, cmd
}
return m, nil
case navArtistsLoadedMsg:
m.navBrowser.artists = []provider.ArtistInfo(msg)
m.navBrowser.loading = false
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
return m, nil
case navAlbumsLoadedMsg:
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
}
m.navBrowser.albumLoading = false
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.
m.navBrowser.loading = false
return m, nil
case navTracksLoadedMsg:
m.navBrowser.tracks = []playlist.Track(msg)
m.navBrowser.loading = false
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
m.navBrowser.screen = navBrowseScreenTracks
return m, nil
case catalogBatchMsg:
m.catalogBatch.loading = false
if msg.err != nil {
m.catalogBatch.done = true
m.status.Show("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:
m.provLoading = false
if msg.err != nil {
m.status.Show("Search failed", statusTTLDefault)
} else {
if lists, err := m.provider.Playlists(); err == nil {
m.providerLists = lists
}
m.provCursor = 0
if msg.count == 0 {
m.status.Show("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.Showf(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.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 feedsLoadedMsg:
m.feedLoading = false
if len(msg.tracks) > 0 {
m.playlist.Add(msg.tracks...)
m.status.Showf(statusTTLDefault, "Loaded %d track(s)", len(msg.tracks))
} else {
m.status.Show("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 netSearchLoadedMsg:
if len(msg) == 0 {
m.status.Show("No tracks found online.", statusTTLDefault)
return m, nil
}
startIdx := m.playlist.Len()
m.playlist.Add(msg...)
for i := startIdx; i < m.playlist.Len(); i++ {
m.playlist.Queue(i)
}
m.status.Showf(statusTTLDefault, "Added to Queue: %s", msg[0].DisplayName())
if !m.player.IsPlaying() {
cmd := m.playCurrentTrack()
m.notifyAll()
return m, cmd
}
return m, nil
case lyricsLoadedMsg:
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.Show("No audio files found", statusTTLDefault)
return m, nil
}
if msg.replace {
m.player.Stop()
m.player.ClearPreload()
m.resetYTDLBatch()
m.playlist.Replace(msg.tracks)
m.plCursor = 0
m.plScroll = 0
} else {
m.playlist.Add(msg.tracks...)
}
m.focus = focusPlaylist
m.status.Showf(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:
m.buffering = false
if msg.err != nil {
m.err = msg.err
} else {
m.err = nil
m.reconnect.attempts = 0
m.reconnect.at = time.Time{}
m.applyResume()
}
m.notifyAll()
return m, m.preloadNext()
case streamPreloadedMsg:
m.preloading = false
return m, nil
case ytdlSavedMsg:
m.save.finishDownload()
if msg.err != nil {
m.status.Showf(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:
m.spotSearch.loading = false
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
if len(msg.tracks) == 0 {
m.spotSearch.err = "No results found"
}
return m, nil
case spotPlaylistsMsg:
m.spotSearch.loading = false
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
return m, nil
case spotAddedMsg:
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.spotSearch.visible = false
return m, nil
case spotCreatedMsg:
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.spotSearch.visible = false
return m, nil
case provAuthDoneMsg:
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, fetchPlaylistsCmd(m.provider)
case mpris.InitMsg:
m.mpris = msg.Svc
m.notifyAll()
return m, nil
case mpris.PlayPauseMsg:
cmd := m.togglePlayPause()
m.notifyAll()
return m, cmd
case mpris.NextMsg:
m.scrobbleCurrent()
cmd := m.nextTrack()
m.notifyAll()
return m, cmd
case mpris.PrevMsg:
m.scrobbleCurrent()
cmd := m.prevTrack()
m.notifyAll()
return m, cmd
case mpris.SeekMsg:
offset := time.Duration(msg.Offset) * time.Microsecond
m.player.Seek(offset)
m.notifyAll()
if m.mpris != nil {
m.mpris.EmitSeeked(m.player.Position().Microseconds())
}
return m, nil
case mpris.SetPositionMsg:
pos := time.Duration(msg.Position) * time.Microsecond
m.player.Seek(pos - m.player.Position())
m.notifyAll()
if m.mpris != nil {
m.mpris.EmitSeeked(m.player.Position().Microseconds())
}
return m, nil
case mpris.SetVolumeMsg:
m.player.SetVolume(mpris.LinearToDb(msg.Volume))
m.notifyAll()
return m, nil
case mpris.StopMsg:
m.player.Stop()
m.notifyAll()
return m, nil
case mpris.QuitMsg:
m.flushPendingSpeedSave()
m.player.Close()
m.quitting = true
return m, tea.Quit
case SetEQPresetMsg:
m.SetEQPreset(msg.Name, msg.Bands)
return m, nil
}
return m, nil
}
+23 -22
View File
@@ -1,4 +1,4 @@
package ui
package model
import (
"fmt"
@@ -10,6 +10,7 @@ import (
"github.com/charmbracelet/lipgloss"
"cliamp/playlist"
"cliamp/ui"
"cliamp/provider"
"cliamp/theme"
)
@@ -20,10 +21,10 @@ var titleScrollSep = []rune(" ♫ ")
// Pre-built styles for elements created per-render to avoid repeated allocation.
var (
seekFillStyle = lipgloss.NewStyle().Foreground(colorSeekBar)
seekDimStyle = lipgloss.NewStyle().Foreground(colorDim)
volBarStyle = lipgloss.NewStyle().Foreground(colorVolume)
activeToggle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
seekFillStyle = lipgloss.NewStyle().Foreground(ui.ColorSeekBar)
seekDimStyle = lipgloss.NewStyle().Foreground(ui.ColorDim)
volBarStyle = lipgloss.NewStyle().Foreground(ui.ColorVolume)
activeToggle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true)
)
// playlistLabel formats a playlist entry, omitting the track count when it is
@@ -79,7 +80,7 @@ func (m Model) View() string {
}
content := strings.Join(m.mainSections(m.renderPlaylist(), true), "\n")
frame := frameStyle.Render(content)
frame := ui.FrameStyle.Render(content)
return m.centerFrame(frame)
}
@@ -107,7 +108,7 @@ func (m Model) mainSections(playlist string, includeTransient bool) []string {
m.renderTrackInfo(),
m.renderTimeStatus(),
"",
// Visualizer
// ui.Visualizer
m.renderSpectrum(),
m.renderSeekBar(),
"",
@@ -175,7 +176,7 @@ func (m Model) centerFrame(frame string) string {
// centerOverlay wraps content in a frame and centers it in the terminal.
func (m Model) centerOverlay(content string) string {
return m.centerFrame(frameStyle.Render(content))
return m.centerFrame(ui.FrameStyle.Render(content))
}
func (m Model) renderTitle() string {
@@ -202,7 +203,7 @@ func (m Model) renderTrackInfo() string {
name += " · " + album
}
maxW := panelWidth - 4
maxW := ui.PanelWidth - 4
runes := []rune(name)
if len(runes) <= maxW {
@@ -257,7 +258,7 @@ func (m Model) renderTimeStatus() string {
}
left := timeStyle.Render(timeStr)
gap := panelWidth - lipgloss.Width(left) - lipgloss.Width(status)
gap := ui.PanelWidth - lipgloss.Width(left) - lipgloss.Width(status)
if gap < 1 {
gap = 1
}
@@ -266,7 +267,7 @@ func (m Model) renderTimeStatus() string {
}
func (m Model) renderSpectrum() string {
if m.vis.Mode == VisNone {
if m.vis.Mode == ui.VisNone {
return ""
}
return m.vis.Render()
@@ -289,19 +290,19 @@ func (m Model) renderFullVisualizer() string {
}
func (m Model) renderSeekBar() string {
if panelWidth <= 0 {
if ui.PanelWidth <= 0 {
return ""
}
// During buffering, show a dim bar — avoids speaker.Lock() contention.
if m.buffering {
return seekDimStyle.Render(strings.Repeat("━", panelWidth))
return seekDimStyle.Render(strings.Repeat("━", ui.PanelWidth))
}
// Show a static streaming bar for non-seekable streams with no known duration.
if !m.player.Seekable() && m.player.IsPlaying() && m.cachedDur == 0 {
label := " STREAMING "
pad := panelWidth - lipgloss.Width(label)
pad := ui.PanelWidth - lipgloss.Width(label)
if pad < 0 {
return seekFillStyle.Render(label[:panelWidth])
return seekFillStyle.Render(label[:ui.PanelWidth])
}
left := pad / 2
right := pad - left
@@ -317,11 +318,11 @@ func (m Model) renderSeekBar() string {
}
progress = max(0, min(1, progress))
filled := int(progress * float64(max(1, panelWidth-1)))
filled := int(progress * float64(max(1, ui.PanelWidth-1)))
return seekFillStyle.Render(strings.Repeat("━", filled)) +
seekFillStyle.Render("●") +
seekDimStyle.Render(strings.Repeat("━", max(0, panelWidth-filled-1)))
seekDimStyle.Render(strings.Repeat("━", max(0, ui.PanelWidth-filled-1)))
}
func (m Model) renderControls() string {
@@ -362,7 +363,7 @@ func (m Model) renderControls() string {
volSuffix := dimStyle.Render(dbStr) + monoStr
volLabelW := lipgloss.Width(volLabel)
volSuffixW := lipgloss.Width(volSuffix)
barW := max(6, (panelWidth-leftW-2-volLabelW-volSuffixW)*3/4)
barW := max(6, (ui.PanelWidth-leftW-2-volLabelW-volSuffixW)*3/4)
filled := int(frac * float64(barW))
bar := volBarStyle.Render(strings.Repeat("█", filled)) +
@@ -370,7 +371,7 @@ func (m Model) renderControls() string {
right := volLabel + bar + volSuffix
rightW := lipgloss.Width(right)
gap := max(1, panelWidth-leftW-rightW)
gap := max(1, ui.PanelWidth-leftW-rightW)
return left + strings.Repeat(" ", gap) + right
}
@@ -582,7 +583,7 @@ func (m Model) renderPlaylist() string {
albumSuffix = " · " + album
}
suffixLen := utf8.RuneCountInString(queueSuffix) + utf8.RuneCountInString(albumSuffix)
name = truncate(name, panelWidth-6-suffixLen)
name = truncate(name, ui.PanelWidth-6-suffixLen)
line := fmt.Sprintf("%s%d. %s", prefix, i+1, name)
line = style.Render(line)
@@ -668,7 +669,7 @@ func (m Model) renderHelp() string {
)
}
return fitHints(hints, panelWidth)
return fitHints(hints, ui.PanelWidth)
}
// helpHint is a rendered help key with an associated display priority.
@@ -761,7 +762,7 @@ func (m Model) renderBottomStatus() string {
leftW := lipgloss.Width(left)
rightW := lipgloss.Width(right)
gap := max(1, panelWidth-leftW-rightW)
gap := max(1, ui.PanelWidth-leftW-rightW)
if right == "" {
return left
@@ -1,9 +1,11 @@
package ui
package model
import (
"fmt"
"strings"
"unicode/utf8"
"cliamp/ui"
)
// truncate shortens s to maxW runes, appending "…" if truncated.
@@ -60,8 +62,8 @@ func albumSeparator(album string, year int) string {
label += fmt.Sprintf(" (%d)", year)
}
label += " "
if labelLen := utf8.RuneCountInString(label); labelLen < panelWidth {
label += strings.Repeat("─", panelWidth-labelLen)
if labelLen := utf8.RuneCountInString(label); labelLen < ui.PanelWidth {
label += strings.Repeat("─", ui.PanelWidth-labelLen)
}
return dimStyle.Render(label)
}
+6 -5
View File
@@ -1,10 +1,11 @@
package ui
package model
import (
"fmt"
"strings"
"cliamp/provider"
"cliamp/ui"
)
// — Navidrome browser renderers —
@@ -75,7 +76,7 @@ func (m Model) renderNavArtistList() []string {
items := m.navScrollItems(len(m.navBrowser.artists), func(i int) string {
a := m.navBrowser.artists[i]
return truncate(fmt.Sprintf("%s (%d albums)", a.Name, a.AlbumCount), panelWidth-6)
return truncate(fmt.Sprintf("%s (%d albums)", a.Name, a.AlbumCount), ui.PanelWidth-6)
})
lines = append(lines, items...)
@@ -129,7 +130,7 @@ func (m Model) renderNavAlbumList(artistAlbums bool) []string {
} else {
label = fmt.Sprintf("%s — %s", a.Name, a.Artist)
}
return truncate(label, panelWidth-6)
return truncate(label, ui.PanelWidth-6)
})
lines = append(lines, items...)
@@ -181,7 +182,7 @@ func (m Model) renderNavTrackList() []string {
if useFilter {
items := m.navScrollItems(len(m.navBrowser.tracks), func(i int) string {
return fmt.Sprintf("%d. %s", i+1, truncate(m.navBrowser.tracks[i].DisplayName(), panelWidth-8))
return fmt.Sprintf("%d. %s", i+1, truncate(m.navBrowser.tracks[i].DisplayName(), ui.PanelWidth-8))
})
lines = append(lines, items...)
} else {
@@ -203,7 +204,7 @@ func (m Model) renderNavTrackList() []string {
}
prevAlbum = t.Album
label := fmt.Sprintf("%d. %s", i+1, truncate(t.DisplayName(), panelWidth-8))
label := fmt.Sprintf("%d. %s", i+1, truncate(t.DisplayName(), ui.PanelWidth-8))
lines = append(lines, cursorLine(label, i == m.navBrowser.cursor))
rendered++
}
@@ -1,4 +1,4 @@
package ui
package model
import (
"errors"
@@ -6,6 +6,7 @@ import (
"strings"
"cliamp/lyrics"
"cliamp/ui"
"cliamp/theme"
)
@@ -151,7 +152,7 @@ func (m Model) renderPlMgrTracks() []string {
scroll := scrollStart(m.plManager.cursor, maxVisible)
for i := scroll; i < len(m.plManager.tracks) && i < scroll+maxVisible; i++ {
name := truncate(m.plManager.tracks[i].DisplayName(), panelWidth-8)
name := truncate(m.plManager.tracks[i].DisplayName(), ui.PanelWidth-8)
label := fmt.Sprintf("%d. %s", i+1, name)
lines = append(lines, cursorLine(label, i == m.plManager.cursor))
}
@@ -193,7 +194,7 @@ func (m Model) renderQueueOverlay() string {
} else {
scroll := scrollStart(m.queue.cursor, maxVisible)
for i := scroll; i < len(tracks) && i < scroll+maxVisible; i++ {
name := truncate(tracks[i].DisplayName(), panelWidth-8)
name := truncate(tracks[i].DisplayName(), ui.PanelWidth-8)
label := fmt.Sprintf("%d. %s", i+1, name)
lines = append(lines, cursorLine(label, i == m.queue.cursor))
rendered++
@@ -280,7 +281,7 @@ func (m Model) renderSearchOverlay() string {
if qp := m.playlist.QueuePosition(i); qp > 0 {
queueSuffix = fmt.Sprintf(" [Q%d]", qp)
}
name = truncate(name, panelWidth-8-len([]rune(queueSuffix)))
name = truncate(name, ui.PanelWidth-8-len([]rune(queueSuffix)))
line := fmt.Sprintf("%s%d. %s", prefix, i+1, name)
if queueSuffix != "" {
@@ -471,7 +472,7 @@ func (m Model) renderSpotSearchResults() []string {
scroll := scrollStart(m.spotSearch.cursor, maxVisible)
for i := scroll; i < len(m.spotSearch.results) && i < scroll+maxVisible; i++ {
t := m.spotSearch.results[i]
label := truncate(fmt.Sprintf("%s - %s", t.Artist, t.Title), panelWidth-8)
label := truncate(fmt.Sprintf("%s - %s", t.Artist, t.Title), ui.PanelWidth-8)
lines = append(lines, cursorLine(label, i == m.spotSearch.cursor))
rendered++
}
@@ -495,7 +496,7 @@ func (m Model) renderSpotSearchPlaylist() []string {
}
track := m.spotSearch.selTrack
lines = append(lines, dimStyle.Render(" "+truncate(fmt.Sprintf("%s - %s", track.Artist, track.Title), panelWidth-8)), "")
lines = append(lines, dimStyle.Render(" "+truncate(fmt.Sprintf("%s - %s", track.Artist, track.Title), ui.PanelWidth-8)), "")
count := len(m.spotSearch.playlists) + 1 // +1 for "+ New Playlist..."
maxVisible := 12
@@ -1,4 +1,4 @@
package ui
package model
import (
"fmt"
@@ -6,18 +6,19 @@ import (
"testing"
"cliamp/playlist"
"cliamp/ui"
"github.com/charmbracelet/lipgloss"
)
func withFrameWidth(t *testing.T, width int) {
t.Helper()
prevFrameStyle := frameStyle
prevPanelWidth := panelWidth
frameStyle = frameStyle.Width(width)
panelWidth = max(0, width-2*paddingH)
prevFrameStyle := ui.FrameStyle
prevPanelWidth := ui.PanelWidth
ui.FrameStyle = ui.FrameStyle.Width(width)
ui.PanelWidth = max(0, width-2*ui.PaddingH)
t.Cleanup(func() {
frameStyle = prevFrameStyle
panelWidth = prevPanelWidth
ui.FrameStyle = prevFrameStyle
ui.PanelWidth = prevPanelWidth
})
}
@@ -38,11 +39,11 @@ func TestMainViewShrinksPlaylistForFooterMessages(t *testing.T) {
m := Model{
player: sharedPlayer,
playlist: pl,
vis: NewVisualizer(float64(sharedPlayer.SampleRate())),
vis: ui.NewVisualizer(float64(sharedPlayer.SampleRate())),
width: 80,
plVisible: 3,
}
m.vis.Mode = VisNone
m.vis.Mode = ui.VisNone
m.save.startDownload()
m.status.Show("Saved", statusTTLDefault)
m.height = m.mainFrameFixedLines(true) + 1
@@ -74,14 +75,14 @@ func TestRenderPlaylistKeepsCursorVisibleWhenFooterShrinksBudget(t *testing.T) {
m := Model{
player: sharedPlayer,
playlist: pl,
vis: NewVisualizer(float64(sharedPlayer.SampleRate())),
vis: ui.NewVisualizer(float64(sharedPlayer.SampleRate())),
width: 80,
focus: focusPlaylist,
plVisible: 3,
plScroll: 7,
plCursor: 9,
}
m.vis.Mode = VisNone
m.vis.Mode = ui.VisNone
m.save.startDownload()
m.status.Show("Saved", statusTTLDefault)
m.height = m.mainFrameFixedLines(true) + 2
@@ -105,22 +106,22 @@ func TestViewConsumesInitialVisualizerRefresh(t *testing.T) {
m := Model{
player: sharedPlayer,
playlist: playlist.New(),
vis: NewVisualizer(float64(sharedPlayer.SampleRate())),
vis: ui.NewVisualizer(float64(sharedPlayer.SampleRate())),
width: 80,
height: 24,
}
if !m.vis.refreshPending {
if !m.vis.RefreshPending() {
t.Fatal("refreshPending = false on new visualizer, want initial refresh request")
}
_ = m.View()
if m.vis.refreshPending {
if m.vis.RefreshPending() {
t.Fatal("refreshPending = true after first View(), want refresh consumed")
}
if m.vis.frame != 1 {
t.Fatalf("visualizer frame after first View() = %d, want 1", m.vis.frame)
if m.vis.Frame() != 1 {
t.Fatalf("visualizer frame after first View() = %d, want 1", m.vis.Frame())
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
package ui
package model
import (
"net/url"
-292
View File
@@ -1,292 +0,0 @@
package ui
import (
"os"
"testing"
"time"
"cliamp/player"
"cliamp/playlist"
tea "github.com/charmbracelet/bubbletea"
)
var sharedPlayer *player.Player
func TestMain(m *testing.M) {
sr := player.DeviceSampleRate()
if sr <= 0 {
sr = 44100
}
p, err := player.New(player.Quality{SampleRate: sr, BufferMs: 100, ResampleQuality: 1})
if err == nil {
sharedPlayer = p
defer p.Close()
}
os.Exit(m.Run())
}
// TestTickIntervalStoppedUsesSlow verifies that when the player is stopped,
// the tick interval is tickSlow (~200ms) not tickFast (~50ms), regardless of
// the visualizer mode. This matters for CPU usage (issue #92).
func TestTickIntervalStoppedUsesSlow(t *testing.T) {
if sharedPlayer == nil {
t.Skip("audio hardware unavailable")
}
m := Model{
player: sharedPlayer,
vis: NewVisualizer(float64(sharedPlayer.SampleRate())),
playlist: playlist.New(),
termTitle: terminalTitleState{last: baseTerminalTitle},
}
// Player is stopped by default (IsPlaying=false).
// vis.Mode defaults to VisBars (0 != VisNone).
if sharedPlayer.IsPlaying() {
t.Fatal("expected player to be stopped")
}
if m.vis.Mode == VisNone {
t.Fatal("expected default vis mode to be non-None (VisBars)")
}
_, cmd := m.Update(tickMsg(time.Now()))
if cmd == nil {
t.Fatal("tickMsg returned nil cmd")
}
start := time.Now()
cmd() // blocks until the tick timer fires
elapsed := time.Since(start)
// tickSlow=200ms, tickFast=50ms. With tolerance for scheduling jitter.
const tolerance = 80 * time.Millisecond
if elapsed < tickSlow-tolerance {
t.Errorf("tick fired after %v, want ~%v (tickSlow); got tickFast instead — CPU fix not working",
elapsed, tickSlow)
}
t.Logf("tick interval when stopped: %v (want ~%v tickSlow)", elapsed.Round(time.Millisecond), tickSlow)
}
func TestInitialTickUsesFastCadence(t *testing.T) {
prev := teaTick
t.Cleanup(func() {
teaTick = prev
})
called := false
teaTick = func(d time.Duration, fn func(time.Time) tea.Msg) tea.Cmd {
called = true
if d != tickFast {
t.Fatalf("tick duration = %v, want %v", d, tickFast)
}
return func() tea.Msg {
return fn(time.Unix(0, 0))
}
}
msg := tickCmd()()
if _, ok := msg.(tickMsg); !ok {
t.Fatalf("tickCmd() message = %T, want tickMsg", msg)
}
if !called {
t.Fatal("tickCmd() did not schedule teaTick")
}
}
func TestTickIntervalClassicPeakSettlingUsesAdaptiveCadence(t *testing.T) {
m := Model{
vis: NewVisualizer(44100),
playlist: playlist.New(),
}
activateMode(t, m.vis, VisClassicPeak)
driver := classicPeakDriverFor(t, m.vis)
m.vis.Rows = defaultVisRows
m.vis.bands = uniformBands(0.3)
driver.barPos = repeatedClassicPeakSlice(8, 0.3)
driver.peakPos = repeatedClassicPeakSlice(8, 0.5)
driver.peakVel = repeatedClassicPeakSlice(8, 0)
withPanelWidth(t, 8)
if !driver.animating(m.vis) {
t.Fatal("animating() = false, want true while ClassicPeak caps are still settling")
}
wantFPS := classicPeakLaunchMax * float64(defaultVisRows*len(classicPeakGlyphs))
wantFPS = min(classicPeakMaxFPS, max(classicPeakMinFPS, wantFPS))
want := time.Duration(float64(time.Second) / wantFPS)
if got := m.tickInterval(); got != want {
t.Fatalf("tickInterval() = %v, want %v while ClassicPeak caps are still settling", got, want)
}
}
func TestClassicPeakAnalysisIntervalUsesFFTOverlapLimit(t *testing.T) {
m := Model{
vis: NewVisualizer(44100),
}
activateMode(t, m.vis, VisClassicPeak)
driver := classicPeakDriverFor(t, m.vis)
m.vis.Rows = 24
frame := driver.frameInterval(m.vis)
if frame != tickClassicPeak {
t.Fatalf("frameInterval() = %v, want %v when rows clamp to max FPS", frame, tickClassicPeak)
}
spec := driver.AnalysisSpec(m.vis)
window := time.Duration(float64(time.Second) * float64(spec.FFTSize) / m.vis.sr)
want := max(frame, max(classicPeakSampleFloor, time.Duration(float64(window)/classicPeakFFTOverlap)))
if got := driver.analysisInterval(m.vis); got != want {
t.Fatalf("analysisInterval() = %v, want %v", got, want)
}
}
func TestTickClassicPeakStoppedDecayKeepsAnimatingTowardSilence(t *testing.T) {
v := NewVisualizer(44100)
activateMode(t, v, VisClassicPeak)
driver := classicPeakDriverFor(t, v)
v.Rows = defaultVisRows
spec := driver.AnalysisSpec(v)
v.prevBySpec[spec] = uniformBandsN(spec.BandCount, 0.6)
v.bands = uniformBandsN(spec.BandCount, 0.6)
driver.barPos = repeatedClassicPeakSlice(8, 0.6)
driver.peakPos = repeatedClassicPeakSlice(8, 0.6)
driver.peakVel = repeatedClassicPeakSlice(8, 0)
driver.peakHold = repeatedClassicPeakSlice(8, 0)
withPanelWidth(t, 8)
calls := 0
driver.Tick(v, visTickContext{
Now: time.Now(),
Analyze: func(visAnalysisSpec) []float64 {
calls++
return uniformBands(1)
},
})
if calls != 0 {
t.Fatalf("Analyze() calls = %d, want 0 while stopped decay runs toward silence", calls)
}
if got := v.bands[0]; got >= 0.6 {
t.Fatalf("tickClassicPeak() kept stopped band at %v, want decay below 0.6", got)
}
if !driver.animating(v) {
t.Fatal("animating() = false after stopped decay, want true while bars settle toward silence")
}
}
func TestRefreshVisualizerIfPendingConsumesOneShotRequest(t *testing.T) {
m := Model{
vis: NewVisualizer(44100),
}
m.vis.requestRefresh()
m.refreshVisualizerIfPending()
if m.vis.refreshPending {
t.Fatal("refreshPending = true after refreshVisualizerIfPending(), want false")
}
if m.vis.frame != 1 {
t.Fatalf("frame after refreshVisualizerIfPending() = %d, want 1", m.vis.frame)
}
m.refreshVisualizerIfPending()
if m.vis.frame != 1 {
t.Fatalf("frame after second refreshVisualizerIfPending() = %d, want 1", m.vis.frame)
}
}
func TestLyricsScreenHidesVisualizerTicks(t *testing.T) {
m := Model{
vis: NewVisualizer(44100),
lyrics: lyricsState{
visible: true,
},
}
if got := m.activeScreen(); got != screenLyrics {
t.Fatalf("activeScreen() = %v, want %v", got, screenLyrics)
}
if !m.isOverlayActive() {
t.Fatal("isOverlayActive() = false, want true while lyrics screen is visible")
}
if !m.visualizerTickContext(time.Now()).OverlayActive {
t.Fatal("visualizerTickContext(...).OverlayActive = false, want true for lyrics screen")
}
m.vis.requestRefresh()
m.refreshVisualizerIfPending()
if !m.vis.refreshPending {
t.Fatal("refreshPending = false after lyrics-screen refresh attempt, want true")
}
if m.vis.frame != 0 {
t.Fatalf("frame after lyrics-screen refresh attempt = %d, want 0", m.vis.frame)
}
}
func TestUpdateRequestsVisualizerRefreshWhenOverlayCloses(t *testing.T) {
m := Model{
vis: NewVisualizer(44100),
keymap: keymapOverlay{
visible: true,
},
}
nextModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEscape})
if cmd != nil {
t.Fatalf("Update() cmd = %v, want nil", cmd)
}
next, ok := nextModel.(Model)
if !ok {
t.Fatalf("Update() model = %T, want ui.Model", nextModel)
}
if next.keymap.visible {
t.Fatal("keymap overlay remained visible after escape")
}
if !next.vis.refreshPending {
t.Fatal("refreshPending = false after overlay close, want true")
}
}
func TestUpdateRequestsVisualizerRefreshWhenLyricsClose(t *testing.T) {
m := Model{
vis: NewVisualizer(44100),
lyrics: lyricsState{
visible: true,
},
}
nextModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEscape})
if cmd != nil {
t.Fatalf("Update() cmd = %v, want nil", cmd)
}
next, ok := nextModel.(Model)
if !ok {
t.Fatalf("Update() model = %T, want ui.Model", nextModel)
}
if next.lyrics.visible {
t.Fatal("lyrics overlay remained visible after escape")
}
if !next.vis.refreshPending {
t.Fatal("refreshPending = false after lyrics close, want true")
}
}
func TestAdvanceTickUnitsClearsElapsedWhenCounterCompletes(t *testing.T) {
ttl := 1
elapsed := time.Duration(0)
if got := advanceTickUnits(&ttl, &elapsed, 3*time.Second, tickFast); got != 1 {
t.Fatalf("advanceTickUnits() steps = %d, want 1", got)
}
if ttl != 0 {
t.Fatalf("ttl after completion = %d, want 0", ttl)
}
if elapsed != 0 {
t.Fatalf("elapsed after completion = %v, want 0", elapsed)
}
}
+50 -123
View File
@@ -9,149 +9,76 @@ import (
// CLIAMP color palette using standard ANSI terminal colors (0-15).
// These adapt to the user's terminal theme for consistent appearance.
var (
colorTitle lipgloss.TerminalColor = lipgloss.ANSIColor(10) // bright green
colorText lipgloss.TerminalColor = lipgloss.ANSIColor(15) // bright white
colorDim lipgloss.TerminalColor = lipgloss.ANSIColor(7) // white (light gray)
colorAccent lipgloss.TerminalColor = lipgloss.ANSIColor(11) // bright yellow
colorPlaying lipgloss.TerminalColor = lipgloss.ANSIColor(10) // bright green
colorSeekBar lipgloss.TerminalColor = lipgloss.ANSIColor(11) // bright yellow
colorVolume lipgloss.TerminalColor = lipgloss.ANSIColor(2) // green
colorError lipgloss.TerminalColor = lipgloss.ANSIColor(9) // bright red
ColorTitle lipgloss.TerminalColor = lipgloss.ANSIColor(10) // bright green
ColorText lipgloss.TerminalColor = lipgloss.ANSIColor(15) // bright white
ColorDim lipgloss.TerminalColor = lipgloss.ANSIColor(7) // white (light gray)
ColorAccent lipgloss.TerminalColor = lipgloss.ANSIColor(11) // bright yellow
ColorPlaying lipgloss.TerminalColor = lipgloss.ANSIColor(10) // bright green
ColorSeekBar lipgloss.TerminalColor = lipgloss.ANSIColor(11) // bright yellow
ColorVolume lipgloss.TerminalColor = lipgloss.ANSIColor(2) // green
ColorError lipgloss.TerminalColor = lipgloss.ANSIColor(9) // bright red
// Spectrum gradient: green -> yellow -> red
spectrumLow lipgloss.TerminalColor = lipgloss.ANSIColor(10) // bright green
spectrumMid lipgloss.TerminalColor = lipgloss.ANSIColor(11) // bright yellow
spectrumHigh lipgloss.TerminalColor = lipgloss.ANSIColor(9) // bright red
SpectrumLow lipgloss.TerminalColor = lipgloss.ANSIColor(10) // bright green
SpectrumMid lipgloss.TerminalColor = lipgloss.ANSIColor(11) // bright yellow
SpectrumHigh lipgloss.TerminalColor = lipgloss.ANSIColor(9) // bright red
)
// paddingH is the horizontal padding inside the frame.
var paddingH = 3
// PaddingH is the horizontal padding inside the frame.
var PaddingH = 3
// paddingV is the vertical padding inside the frame.
var paddingV = 1
// panelWidth is the usable inner width of the frame.
// PanelWidth is the usable inner width of the frame.
// Updated dynamically in WindowSizeMsg based on terminal width.
var panelWidth = 80 - 2*paddingH
var PanelWidth = 80 - 2*PaddingH
// SetPadding updates the frame padding and derived styles.
func SetPadding(h, v int) {
paddingH = h
PaddingH = h
paddingV = v
panelWidth = 80 - 2*paddingH
frameStyle = frameStyle.Padding(paddingV, paddingH)
PanelWidth = 80 - 2*PaddingH
FrameStyle = FrameStyle.Padding(paddingV, PaddingH)
}
// Lip Gloss styles
var (
frameStyle = lipgloss.NewStyle().
Padding(paddingV, paddingH).
Width(80)
// FrameStyle is the outer frame style for the TUI.
var FrameStyle = lipgloss.NewStyle().
Padding(paddingV, PaddingH).
Width(80)
titleStyle = lipgloss.NewStyle().
Foreground(colorTitle).
Bold(true)
trackStyle = lipgloss.NewStyle().
Foreground(colorAccent)
timeStyle = lipgloss.NewStyle().
Foreground(colorText)
statusStyle = lipgloss.NewStyle().
Foreground(colorPlaying).
Bold(true)
dimStyle = lipgloss.NewStyle().
Foreground(colorDim)
labelStyle = lipgloss.NewStyle().
Foreground(colorText).
Bold(true)
eqActiveStyle = lipgloss.NewStyle().
Foreground(colorAccent).
Bold(true)
eqInactiveStyle = lipgloss.NewStyle().
Foreground(colorDim)
playlistActiveStyle = lipgloss.NewStyle().
Foreground(colorPlaying).
Bold(true)
playlistItemStyle = lipgloss.NewStyle().
Foreground(colorText)
playlistSelectedStyle = lipgloss.NewStyle().
Foreground(colorAccent).
Bold(true)
helpStyle = lipgloss.NewStyle().
Foreground(colorDim)
errorStyle = lipgloss.NewStyle().
Foreground(colorError)
)
// applyTheme updates all color variables and rebuilds derived styles.
// ApplyThemeColors updates all color variables and rebuilds spectrum styles.
// If the theme is the default (empty hex values), ANSI fallback colors are restored.
func applyTheme(t theme.Theme) {
func ApplyThemeColors(t theme.Theme) {
if t.IsDefault() {
// Restore ANSI defaults.
colorTitle = lipgloss.ANSIColor(10)
colorText = lipgloss.ANSIColor(15)
colorDim = lipgloss.ANSIColor(7)
colorAccent = lipgloss.ANSIColor(11)
colorPlaying = lipgloss.ANSIColor(10)
colorSeekBar = lipgloss.ANSIColor(11)
colorVolume = lipgloss.ANSIColor(2)
colorError = lipgloss.ANSIColor(9)
spectrumLow = lipgloss.ANSIColor(10)
spectrumMid = lipgloss.ANSIColor(11)
spectrumHigh = lipgloss.ANSIColor(9)
ColorTitle = lipgloss.ANSIColor(10)
ColorText = lipgloss.ANSIColor(15)
ColorDim = lipgloss.ANSIColor(7)
ColorAccent = lipgloss.ANSIColor(11)
ColorPlaying = lipgloss.ANSIColor(10)
ColorSeekBar = lipgloss.ANSIColor(11)
ColorVolume = lipgloss.ANSIColor(2)
ColorError = lipgloss.ANSIColor(9)
SpectrumLow = lipgloss.ANSIColor(10)
SpectrumMid = lipgloss.ANSIColor(11)
SpectrumHigh = lipgloss.ANSIColor(9)
} else {
colorTitle = lipgloss.Color(t.Accent)
colorText = lipgloss.Color(t.BrightFG)
colorDim = lipgloss.Color(t.FG)
colorAccent = lipgloss.Color(t.Accent)
colorPlaying = lipgloss.Color(t.Green)
colorSeekBar = lipgloss.Color(t.Accent)
colorVolume = lipgloss.Color(t.Green)
colorError = lipgloss.Color(t.Red)
spectrumLow = lipgloss.Color(t.Green)
spectrumMid = lipgloss.Color(t.Yellow)
spectrumHigh = lipgloss.Color(t.Red)
ColorTitle = lipgloss.Color(t.Accent)
ColorText = lipgloss.Color(t.BrightFG)
ColorDim = lipgloss.Color(t.FG)
ColorAccent = lipgloss.Color(t.Accent)
ColorPlaying = lipgloss.Color(t.Green)
ColorSeekBar = lipgloss.Color(t.Accent)
ColorVolume = lipgloss.Color(t.Green)
ColorError = lipgloss.Color(t.Red)
SpectrumLow = lipgloss.Color(t.Green)
SpectrumMid = lipgloss.Color(t.Yellow)
SpectrumHigh = lipgloss.Color(t.Red)
}
rebuildStyles()
}
// rebuildStyles reconstructs all lipgloss styles from current color variables.
func rebuildStyles() {
// styles.go styles
titleStyle = lipgloss.NewStyle().Foreground(colorTitle).Bold(true)
trackStyle = lipgloss.NewStyle().Foreground(colorAccent)
timeStyle = lipgloss.NewStyle().Foreground(colorText)
statusStyle = lipgloss.NewStyle().Foreground(colorPlaying).Bold(true)
dimStyle = lipgloss.NewStyle().Foreground(colorDim)
labelStyle = lipgloss.NewStyle().Foreground(colorText).Bold(true)
eqActiveStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
eqInactiveStyle = lipgloss.NewStyle().Foreground(colorDim)
playlistActiveStyle = lipgloss.NewStyle().Foreground(colorPlaying).Bold(true)
playlistItemStyle = lipgloss.NewStyle().Foreground(colorText)
playlistSelectedStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
helpStyle = lipgloss.NewStyle().Foreground(colorDim)
errorStyle = lipgloss.NewStyle().Foreground(colorError)
// view.go pre-built styles
seekFillStyle = lipgloss.NewStyle().Foreground(colorSeekBar)
seekDimStyle = lipgloss.NewStyle().Foreground(colorDim)
volBarStyle = lipgloss.NewStyle().Foreground(colorVolume)
activeToggle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
// visualizer.go pre-built styles
specLowStyle = lipgloss.NewStyle().Foreground(spectrumLow)
specMidStyle = lipgloss.NewStyle().Foreground(spectrumMid)
specHighStyle = lipgloss.NewStyle().Foreground(spectrumHigh)
// Rebuild visualizer spectrum styles.
specLowStyle = lipgloss.NewStyle().Foreground(SpectrumLow)
specMidStyle = lipgloss.NewStyle().Foreground(SpectrumMid)
specHighStyle = lipgloss.NewStyle().Foreground(SpectrumHigh)
}
+9
View File
@@ -0,0 +1,9 @@
package ui
import "time"
// Tick intervals: fast for visualizer animation, slow for time/seek display.
const (
TickFast = 50 * time.Millisecond // 20 FPS — visualizer active
TickSlow = 200 * time.Millisecond // 5 FPS — visualizer off or overlay
)
+86
View File
@@ -0,0 +1,86 @@
package ui
import (
"testing"
"time"
)
func TestTickIntervalClassicPeakSettlingUsesAdaptiveCadence(t *testing.T) {
v := NewVisualizer(44100)
activateMode(t, v, VisClassicPeak)
driver := classicPeakDriverFor(t, v)
v.Rows = DefaultVisRows
v.bands = uniformBands(0.3)
driver.barPos = repeatedClassicPeakSlice(8, 0.3)
driver.peakPos = repeatedClassicPeakSlice(8, 0.5)
driver.peakVel = repeatedClassicPeakSlice(8, 0)
withPanelWidth(t, 8)
if !driver.animating(v) {
t.Fatal("animating() = false, want true while ClassicPeak caps are still settling")
}
wantFPS := classicPeakLaunchMax * float64(DefaultVisRows*len(classicPeakGlyphs))
wantFPS = min(classicPeakMaxFPS, max(classicPeakMinFPS, wantFPS))
want := time.Duration(float64(time.Second) / wantFPS)
ctx := VisTickContext{}
if got := v.TickInterval(ctx); got != want {
t.Fatalf("TickInterval() = %v, want %v while ClassicPeak caps are still settling", got, want)
}
}
func TestClassicPeakAnalysisIntervalUsesFFTOverlapLimit(t *testing.T) {
v := NewVisualizer(44100)
activateMode(t, v, VisClassicPeak)
driver := classicPeakDriverFor(t, v)
v.Rows = 24
frame := driver.frameInterval(v)
if frame != tickClassicPeak {
t.Fatalf("frameInterval() = %v, want %v when rows clamp to max FPS", frame, tickClassicPeak)
}
spec := driver.AnalysisSpec(v)
window := time.Duration(float64(time.Second) * float64(spec.FFTSize) / v.sr)
want := max(frame, max(classicPeakSampleFloor, time.Duration(float64(window)/classicPeakFFTOverlap)))
if got := driver.analysisInterval(v); got != want {
t.Fatalf("analysisInterval() = %v, want %v", got, want)
}
}
func TestTickClassicPeakStoppedDecayKeepsAnimatingTowardSilence(t *testing.T) {
v := NewVisualizer(44100)
activateMode(t, v, VisClassicPeak)
driver := classicPeakDriverFor(t, v)
v.Rows = DefaultVisRows
spec := driver.AnalysisSpec(v)
v.prevBySpec[spec] = uniformBandsN(spec.BandCount, 0.6)
v.bands = uniformBandsN(spec.BandCount, 0.6)
driver.barPos = repeatedClassicPeakSlice(8, 0.6)
driver.peakPos = repeatedClassicPeakSlice(8, 0.6)
driver.peakVel = repeatedClassicPeakSlice(8, 0)
driver.peakHold = repeatedClassicPeakSlice(8, 0)
withPanelWidth(t, 8)
calls := 0
driver.Tick(v, VisTickContext{
Now: time.Now(),
Analyze: func(VisAnalysisSpec) []float64 {
calls++
return uniformBands(1)
},
})
if calls != 0 {
t.Fatalf("Analyze() calls = %d, want 0 while stopped decay runs toward silence", calls)
}
if got := v.bands[0]; got >= 0.6 {
t.Fatalf("tickClassicPeak() kept stopped band at %v, want decay below 0.6", got)
}
if !driver.animating(v) {
t.Fatal("animating() = false after stopped decay, want true while bars settle toward silence")
}
}
+2 -2
View File
@@ -12,7 +12,7 @@ import (
func (v *Visualizer) renderButterfly(bands []float64) string {
height := v.Rows
dotRows := height * 4
dotCols := panelWidth * 2
dotCols := PanelWidth * 2
centerX := dotCols / 2
bandCount := len(bands)
@@ -73,7 +73,7 @@ func (v *Visualizer) renderButterfly(bands []float64) string {
lines := make([]string, height)
for row := range height {
var content strings.Builder
for ch := range panelWidth {
for ch := range PanelWidth {
var braille rune = '\u2800'
for dr := range 4 {
for dc := range 2 {
+9 -9
View File
@@ -63,8 +63,8 @@ func newClassicPeakDriver() visModeDriver {
return &classicPeakDriver{}
}
func (*classicPeakDriver) AnalysisSpec(*Visualizer) visAnalysisSpec {
return visAnalysisSpec{
func (*classicPeakDriver) AnalysisSpec(*Visualizer) VisAnalysisSpec {
return VisAnalysisSpec{
BandCount: classicPeakSpectrumBands,
FFTSize: classicPeakFFTSize,
}
@@ -73,7 +73,7 @@ func (*classicPeakDriver) AnalysisSpec(*Visualizer) visAnalysisSpec {
func (d *classicPeakDriver) Render(v *Visualizer) string {
height := v.Rows
cols, peaks := d.renderState(v)
rowPad := max(0, panelWidth-classicPeakRenderWidth(len(cols)))
rowPad := max(0, PanelWidth-classicPeakRenderWidth(len(cols)))
lines := make([]string, height)
for row := range height {
@@ -104,7 +104,7 @@ func (d *classicPeakDriver) Render(v *Visualizer) string {
return strings.Join(lines, "\n")
}
func (d *classicPeakDriver) Tick(v *Visualizer, ctx visTickContext) {
func (d *classicPeakDriver) Tick(v *Visualizer, ctx VisTickContext) {
if ctx.OverlayActive || ctx.Paused {
d.bandsAt = time.Time{}
d.lastTick = time.Time{}
@@ -127,14 +127,14 @@ func (d *classicPeakDriver) Tick(v *Visualizer, ctx visTickContext) {
}
}
func (d *classicPeakDriver) TickInterval(v *Visualizer, ctx visTickContext) time.Duration {
func (d *classicPeakDriver) TickInterval(v *Visualizer, ctx VisTickContext) time.Duration {
if ctx.OverlayActive || ctx.Paused {
return tickSlow
return TickSlow
}
if ctx.Playing || d.animating(v) {
return d.frameInterval(v)
}
return tickSlow
return TickSlow
}
func (d *classicPeakDriver) OnEnter(*Visualizer) {
@@ -158,12 +158,12 @@ func (d *classicPeakDriver) animating(v *Visualizer) bool {
}
func (d *classicPeakDriver) levels(v *Visualizer) []float64 {
activeCols := classicPeakColsForWidth(panelWidth)
activeCols := classicPeakColsForWidth(PanelWidth)
return resampleBandsLinear(v.bands, activeCols)
}
func (d *classicPeakDriver) frameInterval(v *Visualizer) time.Duration {
rows := defaultVisRows
rows := DefaultVisRows
if v != nil && v.Rows > rows {
rows = v.Rows
}
+35 -35
View File
@@ -14,15 +14,15 @@ const classicPeakTestEpsilon = 1e-9
func withPanelWidth(t *testing.T, width int) {
t.Helper()
prevWidth := panelWidth
panelWidth = width
prevWidth := PanelWidth
PanelWidth = width
t.Cleanup(func() {
panelWidth = prevWidth
PanelWidth = prevWidth
})
}
func uniformBands(level float64) []float64 {
return uniformBandsN(defaultSpectrumBands, level)
return uniformBandsN(DefaultSpectrumBands, level)
}
func uniformBandsN(count int, level float64) []float64 {
@@ -85,7 +85,7 @@ func TestClassicPeakModeLookup(t *testing.T) {
func TestVisualizerFrameAdvancesOnTickNotRender(t *testing.T) {
v := NewVisualizer(44100)
v.Analyze(make([]float64, defaultFFTSize), spectrumAnalysisSpec(defaultSpectrumBands))
v.Analyze(make([]float64, defaultFFTSize), spectrumAnalysisSpec(DefaultSpectrumBands))
if v.frame != 0 {
t.Fatalf("Analyze() advanced frame to %d, want 0 before tick", v.frame)
}
@@ -96,7 +96,7 @@ func TestVisualizerFrameAdvancesOnTickNotRender(t *testing.T) {
t.Fatalf("Render() advanced frame to %d, want 0 before tick", v.frame)
}
v.Tick(visTickContext{})
v.Tick(VisTickContext{})
if v.frame != 1 {
t.Fatalf("Tick() advanced frame to %d, want 1", v.frame)
}
@@ -110,7 +110,7 @@ func TestVisualizerFrameAdvancesOnTickNotRender(t *testing.T) {
func TestClassicPeakLaunchAndSettle(t *testing.T) {
withPanelWidth(t, 8)
cols := classicPeakColsForWidth(panelWidth)
cols := classicPeakColsForWidth(PanelWidth)
v := NewVisualizer(44100)
activateMode(t, v, VisClassicPeak)
@@ -287,7 +287,7 @@ func TestClassicPeakDoesNotRelaunchWhileAirborne(t *testing.T) {
func TestClassicPeakResetsOnModeSwitchAndWidthChange(t *testing.T) {
withPanelWidth(t, 6)
cols6 := classicPeakColsForWidth(panelWidth)
cols6 := classicPeakColsForWidth(PanelWidth)
v := NewVisualizer(44100)
activateMode(t, v, VisClassicPeak)
@@ -325,8 +325,8 @@ func TestClassicPeakResetsOnModeSwitchAndWidthChange(t *testing.T) {
}
}
panelWidth = 8
cols8 := classicPeakColsForWidth(panelWidth)
PanelWidth = 8
cols8 := classicPeakColsForWidth(PanelWidth)
driver.sync(v)
if len(driver.barPos) != cols8 {
t.Fatalf("resize bar len = %d, want %d", len(driver.barPos), cols8)
@@ -359,9 +359,9 @@ func TestClassicPeakAnimatingWhenCapIsAboveBar(t *testing.T) {
activateMode(t, v, VisClassicPeak)
driver := classicPeakDriverFor(t, v)
v.bands = uniformBands(0.3)
driver.barPos = repeatedClassicPeakSlice(panelWidth, 0.3)
driver.peakPos = repeatedClassicPeakSlice(panelWidth, 0.5)
driver.peakVel = repeatedClassicPeakSlice(panelWidth, 0)
driver.barPos = repeatedClassicPeakSlice(PanelWidth, 0.3)
driver.peakPos = repeatedClassicPeakSlice(PanelWidth, 0.5)
driver.peakVel = repeatedClassicPeakSlice(PanelWidth, 0)
if !driver.animating(v) {
t.Fatal("animating() = false, want true when caps are still above the bar")
@@ -375,9 +375,9 @@ func TestClassicPeakAnimatingWhenBarsAreSettling(t *testing.T) {
activateMode(t, v, VisClassicPeak)
driver := classicPeakDriverFor(t, v)
v.bands = uniformBands(0.7)
driver.barPos = repeatedClassicPeakSlice(panelWidth, 0.5)
driver.peakPos = repeatedClassicPeakSlice(panelWidth, 0.5)
driver.peakVel = repeatedClassicPeakSlice(panelWidth, 0)
driver.barPos = repeatedClassicPeakSlice(PanelWidth, 0.5)
driver.peakPos = repeatedClassicPeakSlice(PanelWidth, 0.5)
driver.peakVel = repeatedClassicPeakSlice(PanelWidth, 0)
if !driver.animating(v) {
t.Fatal("animating() = false, want true while bars are still easing to target")
@@ -432,9 +432,9 @@ func TestClassicPeakRenderShowsAttachedCapWhileSettling(t *testing.T) {
driver := classicPeakDriverFor(t, v)
v.Rows = 5
v.bands = uniformBands(0.61)
driver.barPos = repeatedClassicPeakSlice(panelWidth, 0.61)
driver.peakPos = repeatedClassicPeakSlice(panelWidth, 0.68)
driver.peakVel = repeatedClassicPeakSlice(panelWidth, 0)
driver.barPos = repeatedClassicPeakSlice(PanelWidth, 0.61)
driver.peakPos = repeatedClassicPeakSlice(PanelWidth, 0.68)
driver.peakVel = repeatedClassicPeakSlice(PanelWidth, 0)
out := v.Render()
if !strings.ContainsAny(out, classicPeakTestGlyphs) {
@@ -450,17 +450,17 @@ func TestClassicPeakPauseFreezesStateAndClearsAnimationClock(t *testing.T) {
driver := classicPeakDriverFor(t, v)
v.Rows = 5
v.bands = uniformBands(0.6)
driver.barPos = repeatedClassicPeakSlice(panelWidth, 0.6)
driver.peakPos = repeatedClassicPeakSlice(panelWidth, 0.82)
driver.peakVel = repeatedClassicPeakSlice(panelWidth, 1.1)
driver.peakHold = repeatedClassicPeakSlice(panelWidth, classicPeakApexHold)
driver.barPos = repeatedClassicPeakSlice(PanelWidth, 0.6)
driver.peakPos = repeatedClassicPeakSlice(PanelWidth, 0.82)
driver.peakVel = repeatedClassicPeakSlice(PanelWidth, 1.1)
driver.peakHold = repeatedClassicPeakSlice(PanelWidth, classicPeakApexHold)
snapshotPeak := append([]float64(nil), driver.peakPos...)
snapshotVel := append([]float64(nil), driver.peakVel...)
snapshotHold := append([]float64(nil), driver.peakHold...)
driver.lastTick = time.Now()
driver.Tick(v, visTickContext{Now: time.Now(), Paused: true})
driver.Tick(v, VisTickContext{Now: time.Now(), Paused: true})
if !driver.lastTick.IsZero() {
t.Fatalf("lastTick after pause = %v, want zero", driver.lastTick)
@@ -479,8 +479,8 @@ func TestClassicPeakPauseFreezesStateAndClearsAnimationClock(t *testing.T) {
if !driver.animating(v) {
t.Fatal("animating() = false, want true while caps still airborne")
}
if got := v.TickInterval(visTickContext{Paused: true}); got != tickSlow {
t.Fatalf("TickInterval(paused) = %v, want %v", got, tickSlow)
if got := v.TickInterval(VisTickContext{Paused: true}); got != TickSlow {
t.Fatalf("TickInterval(paused) = %v, want %v", got, TickSlow)
}
}
@@ -492,17 +492,17 @@ func TestClassicPeakOverlayFreezesStateAndClearsAnimationClock(t *testing.T) {
driver := classicPeakDriverFor(t, v)
v.Rows = 5
v.bands = uniformBands(0.6)
driver.barPos = repeatedClassicPeakSlice(panelWidth, 0.6)
driver.peakPos = repeatedClassicPeakSlice(panelWidth, 0.82)
driver.peakVel = repeatedClassicPeakSlice(panelWidth, 1.1)
driver.peakHold = repeatedClassicPeakSlice(panelWidth, classicPeakApexHold)
driver.barPos = repeatedClassicPeakSlice(PanelWidth, 0.6)
driver.peakPos = repeatedClassicPeakSlice(PanelWidth, 0.82)
driver.peakVel = repeatedClassicPeakSlice(PanelWidth, 1.1)
driver.peakHold = repeatedClassicPeakSlice(PanelWidth, classicPeakApexHold)
snapshotPeak := append([]float64(nil), driver.peakPos...)
snapshotVel := append([]float64(nil), driver.peakVel...)
snapshotHold := append([]float64(nil), driver.peakHold...)
driver.lastTick = time.Now()
driver.Tick(v, visTickContext{Now: time.Now(), OverlayActive: true})
driver.Tick(v, VisTickContext{Now: time.Now(), OverlayActive: true})
if !driver.lastTick.IsZero() {
t.Fatalf("lastTick after overlay = %v, want zero", driver.lastTick)
@@ -521,8 +521,8 @@ func TestClassicPeakOverlayFreezesStateAndClearsAnimationClock(t *testing.T) {
if !driver.animating(v) {
t.Fatal("animating() = false, want true while overlay hides airborne caps")
}
if got := v.TickInterval(visTickContext{OverlayActive: true}); got != tickSlow {
t.Fatalf("TickInterval(overlay) = %v, want %v", got, tickSlow)
if got := v.TickInterval(VisTickContext{OverlayActive: true}); got != TickSlow {
t.Fatalf("TickInterval(overlay) = %v, want %v", got, TickSlow)
}
}
@@ -536,8 +536,8 @@ func TestClassicPeakRenderFillsEvenWidthPanels(t *testing.T) {
out := v.Render()
for _, line := range strings.Split(out, "\n") {
if got := lipgloss.Width(line); got != panelWidth {
t.Fatalf("Render() line width = %d, want %d for even panel width: %q", got, panelWidth, line)
if got := lipgloss.Width(line); got != PanelWidth {
t.Fatalf("Render() line width = %d, want %d for even panel width: %q", got, PanelWidth, line)
}
}
}
+2 -2
View File
@@ -12,7 +12,7 @@ import (
func (v *Visualizer) renderFirework(bands []float64) string {
height := v.Rows
dotRows := height * 4
dotCols := panelWidth * 2
dotCols := PanelWidth * 2
grid := make([]bool, dotRows*dotCols)
@@ -92,7 +92,7 @@ func (v *Visualizer) renderFirework(bands []float64) string {
lines := make([]string, height)
for row := range height {
var content strings.Builder
for ch := range panelWidth {
for ch := range PanelWidth {
var braille rune = '\u2800'
for dr := range 4 {
for dc := range 2 {
+2 -2
View File
@@ -11,7 +11,7 @@ import (
func (v *Visualizer) renderHeartbeat() string {
height := v.Rows
dotRows := height * 4
dotCols := panelWidth * 2
dotCols := PanelWidth * 2
samples := v.waveBuf
n := len(samples)
@@ -68,7 +68,7 @@ func (v *Visualizer) renderHeartbeat() string {
var sb, run strings.Builder
tag := -1
for ch := range panelWidth {
for ch := range PanelWidth {
var braille rune = '\u2800'
hasTrace := false
+2 -2
View File
@@ -11,7 +11,7 @@ import (
func (v *Visualizer) renderLightning(bands []float64) string {
height := v.Rows
dotRows := height * 4
dotCols := panelWidth * 2
dotCols := PanelWidth * 2
bandCount := len(bands)
grid := make([]bool, dotRows*dotCols)
@@ -101,7 +101,7 @@ func (v *Visualizer) renderLightning(bands []float64) string {
lines := make([]string, height)
for row := range height {
var content strings.Builder
for ch := range panelWidth {
for ch := range PanelWidth {
var braille rune = '\u2800'
for dr := range 4 {
for dc := range 2 {
+2 -2
View File
@@ -31,7 +31,7 @@ const (
func (v *Visualizer) renderLogo(bands []float64) string {
height := v.Rows
dotRows := height * 4
dotCols := panelWidth * 2
dotCols := PanelWidth * 2
grid := make([]bool, dotRows*dotCols)
@@ -97,7 +97,7 @@ func (v *Visualizer) renderLogo(bands []float64) string {
lines := make([]string, height)
for row := range height {
var content strings.Builder
for ch := range panelWidth {
for ch := range PanelWidth {
var braille rune = '\u2800'
for dr := range 4 {
for dc := range 2 {
+2 -2
View File
@@ -14,7 +14,7 @@ import (
func (v *Visualizer) renderPulse(bands []float64) string {
height := v.Rows
dotRows := height * 4
dotCols := panelWidth * 2
dotCols := PanelWidth * 2
bandCount := len(bands)
centerX := float64(dotCols) / 2.0
@@ -44,7 +44,7 @@ func (v *Visualizer) renderPulse(bands []float64) string {
var sb, run strings.Builder
tag := -1
for c := range panelWidth {
for c := range PanelWidth {
var braille rune = '\u2800'
var maxNorm float64
+1 -1
View File
@@ -10,7 +10,7 @@ import (
// scrolls toward the viewer. Uses Braille characters for sub-cell resolution.
func (v *Visualizer) renderRetro(bands []float64) string {
height := v.Rows
charCols := panelWidth
charCols := PanelWidth
dotRows := height * 4
dotCols := charCols * 2
bandCount := len(bands)
+2 -2
View File
@@ -29,7 +29,7 @@ var sakuraShapes = [][][2]int{
func (v *Visualizer) renderSakura(bands []float64) string {
height := v.Rows
dotRows := height * 4
dotCols := panelWidth * 2
dotCols := PanelWidth * 2
grid := make([]bool, dotRows*dotCols)
@@ -82,7 +82,7 @@ func (v *Visualizer) renderSakura(bands []float64) string {
lines := make([]string, height)
for row := range height {
var content strings.Builder
for ch := range panelWidth {
for ch := range PanelWidth {
var braille rune = '\u2800'
for dr := range 4 {
for dc := range 2 {
+2 -2
View File
@@ -12,7 +12,7 @@ import (
func (v *Visualizer) renderScope() string {
height := v.Rows
dotRows := height * 4
dotCols := panelWidth * 2
dotCols := PanelWidth * 2
samples := v.waveBuf
n := len(samples)
@@ -78,7 +78,7 @@ func (v *Visualizer) renderScope() string {
lines := make([]string, height)
for row := range height {
var content strings.Builder
for ch := range panelWidth {
for ch := range PanelWidth {
var braille rune = '\u2800'
for dr := range 4 {
for dc := range 2 {
+7 -7
View File
@@ -17,8 +17,8 @@ func newTerrainDriver() visModeDriver {
return &terrainDriver{}
}
func (*terrainDriver) AnalysisSpec(*Visualizer) visAnalysisSpec {
return spectrumAnalysisSpec(defaultSpectrumBands)
func (*terrainDriver) AnalysisSpec(*Visualizer) VisAnalysisSpec {
return spectrumAnalysisSpec(DefaultSpectrumBands)
}
func resizeTerrainBuf(buf []float64, dotCols int) []float64 {
@@ -38,14 +38,14 @@ func resizeTerrainBuf(buf []float64, dotCols int) []float64 {
func (d *terrainDriver) Render(v *Visualizer) string {
height := v.Rows
dotRows := height * 4
dotCols := panelWidth * 2
dotCols := PanelWidth * 2
buf := resizeTerrainBuf(d.buf, dotCols)
// Render: each dot column is filled from its terrain height down to the bottom.
lines := make([]string, height)
for row := range height {
var content strings.Builder
for ch := range panelWidth {
for ch := range PanelWidth {
var braille rune = '\u2800'
for dc := range 2 {
x := ch*2 + dc
@@ -68,13 +68,13 @@ func (d *terrainDriver) Render(v *Visualizer) string {
return strings.Join(lines, "\n")
}
func (d *terrainDriver) Tick(v *Visualizer, ctx visTickContext) {
func (d *terrainDriver) Tick(v *Visualizer, ctx VisTickContext) {
defaultDriverTick(v, ctx, d.AnalysisSpec(v))
if ctx.OverlayActive {
return
}
dotCols := panelWidth * 2
dotCols := PanelWidth * 2
d.buf = resizeTerrainBuf(d.buf, dotCols)
if len(d.buf) < 2 {
return
@@ -95,7 +95,7 @@ func (d *terrainDriver) Tick(v *Visualizer, ctx visTickContext) {
d.buf[dotCols-1] = min(1.0, avg+scatterHash(0, 0, 1, v.frame)*0.12)
}
func (*terrainDriver) TickInterval(_ *Visualizer, ctx visTickContext) time.Duration {
func (*terrainDriver) TickInterval(_ *Visualizer, ctx VisTickContext) time.Duration {
return defaultDriverTickInterval(ctx)
}
+1 -1
View File
@@ -6,7 +6,7 @@ import "strings"
// Each Braille character covers a 2×4 dot grid, giving smooth sub-cell resolution.
func (v *Visualizer) renderWave() string {
height := v.Rows
charCols := panelWidth
charCols := PanelWidth
dotRows := height * 4
dotCols := charCols * 2
+103 -91
View File
@@ -11,14 +11,14 @@ import (
)
const (
defaultSpectrumBands = 10
DefaultSpectrumBands = 10
defaultFFTSize = 2048
defaultVisRows = 5
DefaultVisRows = 5
minSpectrumHz = 20.0
maxSpectrumHz = 20000.0
)
var legacySpectrumEdges = [defaultSpectrumBands + 1]float64{
var legacySpectrumEdges = [DefaultSpectrumBands + 1]float64{
minSpectrumHz,
100,
200,
@@ -60,7 +60,7 @@ const (
VisButterfly // mirrored Rorschach spectrum
VisLightning // electric bolts from treble energy
VisNone // hidden — no visualizer
visCount // sentinel for cycling
VisCount // sentinel for cycling
)
// Unicode block elements for bar height (9 levels including space)
@@ -75,15 +75,15 @@ var brailleBit = [4][2]rune{
}
// visBandWidth returns the character width for band b so that all bands plus
// 1-char gaps exactly fill panelWidth. The remainder is distributed across the
// 1-char gaps exactly fill PanelWidth. The remainder is distributed across the
// first few bands.
func visBandWidth(totalBands, b int) int {
const gap = 1
if totalBands <= 0 {
return 0
}
base := (panelWidth - (totalBands-1)*gap) / totalBands
extra := (panelWidth - (totalBands-1)*gap) % totalBands
base := (PanelWidth - (totalBands-1)*gap) / totalBands
extra := (PanelWidth - (totalBands-1)*gap) % totalBands
if b < extra {
return base + 1
}
@@ -181,32 +181,32 @@ func averageSpectrumRangeLinear(magnitudes []float64, loPos, hiPos float64) floa
// Pre-built styles for spectrum bar colors to avoid per-frame allocation.
var (
specLowStyle = lipgloss.NewStyle().Foreground(spectrumLow)
specMidStyle = lipgloss.NewStyle().Foreground(spectrumMid)
specHighStyle = lipgloss.NewStyle().Foreground(spectrumHigh)
specLowStyle = lipgloss.NewStyle().Foreground(SpectrumLow)
specMidStyle = lipgloss.NewStyle().Foreground(SpectrumMid)
specHighStyle = lipgloss.NewStyle().Foreground(SpectrumHigh)
)
type visTickContext struct {
type VisTickContext struct {
Now time.Time
Playing bool
Paused bool
OverlayActive bool
Analyze func(visAnalysisSpec) []float64
Analyze func(VisAnalysisSpec) []float64
}
type visAnalysisSpec struct {
type VisAnalysisSpec struct {
BandCount int
FFTSize int
}
func spectrumAnalysisSpec(bandCount int) visAnalysisSpec {
return visAnalysisSpec{
func spectrumAnalysisSpec(bandCount int) VisAnalysisSpec {
return VisAnalysisSpec{
BandCount: bandCount,
FFTSize: defaultFFTSize,
}
}
func normalizeAnalysisSpec(spec visAnalysisSpec) visAnalysisSpec {
func NormalizeAnalysisSpec(spec VisAnalysisSpec) VisAnalysisSpec {
if spec.BandCount < 0 {
spec.BandCount = 0
}
@@ -217,10 +217,10 @@ func normalizeAnalysisSpec(spec visAnalysisSpec) visAnalysisSpec {
}
type visModeDriver interface {
AnalysisSpec(*Visualizer) visAnalysisSpec
AnalysisSpec(*Visualizer) VisAnalysisSpec
Render(*Visualizer) string
Tick(*Visualizer, visTickContext)
TickInterval(*Visualizer, visTickContext) time.Duration
Tick(*Visualizer, VisTickContext)
TickInterval(*Visualizer, VisTickContext) time.Duration
OnEnter(*Visualizer)
OnLeave(*Visualizer)
}
@@ -232,11 +232,11 @@ type visEntry struct {
}
type renderOnlyDriver struct {
spec visAnalysisSpec
spec VisAnalysisSpec
render func(*Visualizer, []float64) string
}
func (d *renderOnlyDriver) AnalysisSpec(*Visualizer) visAnalysisSpec {
func (d *renderOnlyDriver) AnalysisSpec(*Visualizer) VisAnalysisSpec {
return d.spec
}
@@ -244,11 +244,11 @@ func (d *renderOnlyDriver) Render(v *Visualizer) string {
return d.render(v, v.bands)
}
func (d *renderOnlyDriver) Tick(v *Visualizer, ctx visTickContext) {
func (d *renderOnlyDriver) Tick(v *Visualizer, ctx VisTickContext) {
defaultDriverTick(v, ctx, d.spec)
}
func (*renderOnlyDriver) TickInterval(_ *Visualizer, ctx visTickContext) time.Duration {
func (*renderOnlyDriver) TickInterval(_ *Visualizer, ctx VisTickContext) time.Duration {
return defaultDriverTickInterval(ctx)
}
@@ -258,21 +258,21 @@ func (*renderOnlyDriver) OnLeave(*Visualizer) {}
type noOpDriver struct{}
func (*noOpDriver) AnalysisSpec(*Visualizer) visAnalysisSpec { return visAnalysisSpec{} }
func (*noOpDriver) AnalysisSpec(*Visualizer) VisAnalysisSpec { return VisAnalysisSpec{} }
func (*noOpDriver) Render(*Visualizer) string { return "" }
func (*noOpDriver) Tick(*Visualizer, visTickContext) {}
func (*noOpDriver) Tick(*Visualizer, VisTickContext) {}
func (*noOpDriver) TickInterval(*Visualizer, visTickContext) time.Duration { return tickSlow }
func (*noOpDriver) TickInterval(*Visualizer, VisTickContext) time.Duration { return TickSlow }
func (*noOpDriver) OnEnter(*Visualizer) {}
func (*noOpDriver) OnLeave(*Visualizer) {}
func newRenderOnlyDriver(spec visAnalysisSpec, render func(*Visualizer, []float64) string) func() visModeDriver {
func newRenderOnlyDriver(spec VisAnalysisSpec, render func(*Visualizer, []float64) string) func() visModeDriver {
return func() visModeDriver {
return &renderOnlyDriver{spec: normalizeAnalysisSpec(spec), render: render}
return &renderOnlyDriver{spec: NormalizeAnalysisSpec(spec), render: render}
}
}
@@ -280,11 +280,11 @@ func newNoOpDriver() visModeDriver {
return &noOpDriver{}
}
func defaultDriverTick(v *Visualizer, ctx visTickContext, spec visAnalysisSpec) {
func defaultDriverTick(v *Visualizer, ctx VisTickContext, spec VisAnalysisSpec) {
if ctx.OverlayActive || ctx.Analyze == nil {
return
}
spec = normalizeAnalysisSpec(spec)
spec = NormalizeAnalysisSpec(spec)
bands := ctx.Analyze(spec)
if spec.BandCount > 0 {
v.bands = bands
@@ -294,19 +294,19 @@ func defaultDriverTick(v *Visualizer, ctx visTickContext, spec visAnalysisSpec)
// defaultDriverTickInterval uses fast ticks only when audio is actively playing with a live
// visualizer. Paused/stopped playback has no new audio samples, so slow ticks are sufficient
// and save CPU/GPU repaints. Overlays use slow ticks as well.
func defaultDriverTickInterval(ctx visTickContext) time.Duration {
func defaultDriverTickInterval(ctx VisTickContext) time.Duration {
if ctx.OverlayActive {
return tickSlow
return TickSlow
}
if ctx.Playing {
return tickFast
return TickFast
}
return tickSlow
return TickSlow
}
// Visualizer performs FFT analysis and renders spectrum bars.
type Visualizer struct {
prevBySpec map[visAnalysisSpec][]float64
prevBySpec map[VisAnalysisSpec][]float64
edgeCache map[int][]float64
fftBufCache map[int][]float64
windowCache map[int][]float64
@@ -317,26 +317,26 @@ type Visualizer struct {
waveBuf []float64 // raw samples for wave mode
frame uint64 // tick-driven animation clock
sampleBuf []float64 // reusable buffer for reading audio tap samples
drivers [visCount]visModeDriver
drivers [VisCount]visModeDriver
activeMode VisMode
activeModeSet bool
refreshPending bool
luaVisNames []string
luaRender luaVisRenderer
luaRender LuaVisRenderer
luaDriverCache map[int]visModeDriver
}
// luaVisRenderer is the callback type for rendering a Lua visualizer frame.
type luaVisRenderer func(name string, bands [defaultSpectrumBands]float64, rows, cols int, frame uint64) string
// LuaVisRenderer is the callback type for rendering a Lua visualizer frame.
type LuaVisRenderer func(name string, bands [DefaultSpectrumBands]float64, rows, cols int, frame uint64) string
// NewVisualizer creates a Visualizer for the given sample rate.
func NewVisualizer(sampleRate float64) *Visualizer {
return &Visualizer{
sr: sampleRate,
sampleBuf: make([]float64, defaultFFTSize),
Rows: defaultVisRows,
bands: make([]float64, defaultSpectrumBands),
prevBySpec: make(map[visAnalysisSpec][]float64),
Rows: DefaultVisRows,
bands: make([]float64, DefaultSpectrumBands),
prevBySpec: make(map[VisAnalysisSpec][]float64),
edgeCache: make(map[int][]float64),
fftBufCache: make(map[int][]float64),
windowCache: make(map[int][]float64),
@@ -347,54 +347,54 @@ func NewVisualizer(sampleRate float64) *Visualizer {
// CycleMode advances to the next visualizer mode, including Lua visualizers.
func (v *Visualizer) CycleMode() {
total := visCount + VisMode(len(v.luaVisNames))
total := VisCount + VisMode(len(v.luaVisNames))
v.Mode = (v.Mode + 1) % total
}
// visModes is the single source of truth for all visualizer modes.
// To add a new mode: add a const, add one line here, create a vis_*.go file.
var visModes = [visCount]visEntry{
VisBars: {"Bars", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderBars)},
VisBarsDot: {"BarsDot", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderBarsDot)},
VisRain: {"Rain", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderRain)},
VisBarsOutline: {"BarsOutline", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderBarsOutline)},
VisBricks: {"Bricks", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderBricks)},
VisColumns: {"Columns", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderColumns)},
var visModes = [VisCount]visEntry{
VisBars: {"Bars", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderBars)},
VisBarsDot: {"BarsDot", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderBarsDot)},
VisRain: {"Rain", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderRain)},
VisBarsOutline: {"BarsOutline", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderBarsOutline)},
VisBricks: {"Bricks", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderBricks)},
VisColumns: {"Columns", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderColumns)},
VisClassicPeak: {"ClassicPeak", newClassicPeakDriver},
VisWave: {"Wave", newRenderOnlyDriver(spectrumAnalysisSpec(0), func(v *Visualizer, _ []float64) string { return v.renderWave() })},
VisScatter: {"Scatter", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderScatter)},
VisFlame: {"Flame", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderFlame)},
VisRetro: {"Retro", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderRetro)},
VisPulse: {"Pulse", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderPulse)},
VisMatrix: {"Matrix", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderMatrix)},
VisBinary: {"Binary", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderBinary)},
VisSakura: {"Sakura", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderSakura)},
VisFirework: {"Firework", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderFirework)},
VisLogo: {"Logo", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderLogo)},
VisScatter: {"Scatter", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderScatter)},
VisFlame: {"Flame", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderFlame)},
VisRetro: {"Retro", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderRetro)},
VisPulse: {"Pulse", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderPulse)},
VisMatrix: {"Matrix", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderMatrix)},
VisBinary: {"Binary", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderBinary)},
VisSakura: {"Sakura", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderSakura)},
VisFirework: {"Firework", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderFirework)},
VisLogo: {"Logo", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderLogo)},
VisTerrain: {"Terrain", newTerrainDriver},
VisGlitch: {"Glitch", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderGlitch)},
VisGlitch: {"Glitch", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderGlitch)},
VisScope: {"Scope", newRenderOnlyDriver(spectrumAnalysisSpec(0), func(v *Visualizer, _ []float64) string { return v.renderScope() })},
VisHeartbeat: {"Heartbeat", newRenderOnlyDriver(spectrumAnalysisSpec(0), func(v *Visualizer, _ []float64) string { return v.renderHeartbeat() })},
VisButterfly: {"Butterfly", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderButterfly)},
VisLightning: {"Lightning", newRenderOnlyDriver(spectrumAnalysisSpec(defaultSpectrumBands), (*Visualizer).renderLightning)},
VisButterfly: {"Butterfly", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderButterfly)},
VisLightning: {"Lightning", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderLightning)},
VisNone: {"None", newNoOpDriver},
}
var visNameMap map[string]VisMode
func init() {
visNameMap = make(map[string]VisMode, visCount)
for i := range visCount {
visNameMap = make(map[string]VisMode, VisCount)
for i := range VisCount {
visNameMap[strings.ToLower(visModes[i].name)] = VisMode(i)
}
}
// ModeName returns the display name of the current mode.
func (v *Visualizer) ModeName() string {
if v.Mode < visCount {
if v.Mode < VisCount {
return visModes[v.Mode].name
}
luaIdx := int(v.Mode - visCount)
luaIdx := int(v.Mode - VisCount)
if luaIdx < len(v.luaVisNames) {
return v.luaVisNames[luaIdx]
}
@@ -443,7 +443,7 @@ func buildHannWindow(size int) []float64 {
return window
}
func (v *Visualizer) prevBands(spec visAnalysisSpec) []float64 {
func (v *Visualizer) prevBands(spec VisAnalysisSpec) []float64 {
if prev, ok := v.prevBySpec[spec]; ok {
return prev
}
@@ -486,8 +486,8 @@ func (v *Visualizer) resetSpectrumHistory() {
clear(v.prevBySpec)
}
func (v *Visualizer) ensureSampleBuf(size int) []float64 {
size = normalizeAnalysisSpec(visAnalysisSpec{FFTSize: size}).FFTSize
func (v *Visualizer) EnsureSampleBuf(size int) []float64 {
size = NormalizeAnalysisSpec(VisAnalysisSpec{FFTSize: size}).FFTSize
if cap(v.sampleBuf) < size {
v.sampleBuf = make([]float64, size)
} else {
@@ -498,19 +498,19 @@ func (v *Visualizer) ensureSampleBuf(size int) []float64 {
// RegisterLuaVisualizers adds Lua visualizer names so they can be cycled
// through with the v key. renderer is called when a Lua visualizer is active.
func (v *Visualizer) RegisterLuaVisualizers(names []string, renderer luaVisRenderer) {
func (v *Visualizer) RegisterLuaVisualizers(names []string, renderer LuaVisRenderer) {
v.luaVisNames = names
v.luaRender = renderer
clear(v.luaDriverCache)
// Add to name map for StringToVisMode lookups.
for i, name := range names {
visNameMap[strings.ToLower(name)] = visCount + VisMode(i)
visNameMap[strings.ToLower(name)] = VisCount + VisMode(i)
}
}
// Analyze runs FFT on raw audio samples and returns normalized band levels (0-1).
func (v *Visualizer) Analyze(samples []float64, spec visAnalysisSpec) []float64 {
spec = normalizeAnalysisSpec(spec)
func (v *Visualizer) Analyze(samples []float64, spec VisAnalysisSpec) []float64 {
spec = NormalizeAnalysisSpec(spec)
// Store raw samples for wave mode.
if n := len(samples); n > 0 {
@@ -593,13 +593,13 @@ func (v *Visualizer) Render() string {
return driver.Render(v)
}
func (v *Visualizer) requestRefresh() {
func (v *Visualizer) RequestRefresh() {
if v != nil {
v.refreshPending = true
}
}
func (v *Visualizer) consumeRefresh() bool {
func (v *Visualizer) ConsumeRefresh() bool {
if v == nil || !v.refreshPending {
return false
}
@@ -607,15 +607,27 @@ func (v *Visualizer) consumeRefresh() bool {
return true
}
func (v *Visualizer) TickInterval(ctx visTickContext) time.Duration {
// SampleBuf returns the internal sample buffer (for slicing after SamplesInto).
func (v *Visualizer) SampleBuf() []float64 { return v.sampleBuf }
// Bands returns the current spectrum band values.
func (v *Visualizer) Bands() []float64 { return v.bands }
// Frame returns the current animation frame counter.
func (v *Visualizer) Frame() uint64 { return v.frame }
// RefreshPending reports whether a refresh has been requested.
func (v *Visualizer) RefreshPending() bool { return v != nil && v.refreshPending }
func (v *Visualizer) TickInterval(ctx VisTickContext) time.Duration {
driver := v.syncDriverMode()
if driver == nil {
return tickSlow
return TickSlow
}
return driver.TickInterval(v, ctx)
}
func (v *Visualizer) Tick(ctx visTickContext) {
func (v *Visualizer) Tick(ctx VisTickContext) {
driver := v.syncDriverMode()
if driver == nil {
return
@@ -631,8 +643,8 @@ func (v *Visualizer) driverFor(mode VisMode) visModeDriver {
if v == nil || mode < 0 {
return nil
}
if mode >= visCount {
idx := int(mode - visCount)
if mode >= VisCount {
idx := int(mode - VisCount)
if idx < 0 || idx >= len(v.luaVisNames) {
return nil
}
@@ -657,22 +669,22 @@ type luaModeDriver struct {
index int
}
func (*luaModeDriver) AnalysisSpec(*Visualizer) visAnalysisSpec {
return spectrumAnalysisSpec(defaultSpectrumBands)
func (*luaModeDriver) AnalysisSpec(*Visualizer) VisAnalysisSpec {
return spectrumAnalysisSpec(DefaultSpectrumBands)
}
func (d *luaModeDriver) Render(v *Visualizer) string {
if v == nil || d.index < 0 || d.index >= len(v.luaVisNames) || v.luaRender == nil {
return ""
}
return v.luaRender(v.luaVisNames[d.index], luaBands(v.bands), v.Rows, panelWidth, v.frame)
return v.luaRender(v.luaVisNames[d.index], luaBands(v.bands), v.Rows, PanelWidth, v.frame)
}
func (d *luaModeDriver) Tick(v *Visualizer, ctx visTickContext) {
func (d *luaModeDriver) Tick(v *Visualizer, ctx VisTickContext) {
defaultDriverTick(v, ctx, d.AnalysisSpec(v))
}
func (*luaModeDriver) TickInterval(_ *Visualizer, ctx visTickContext) time.Duration {
func (*luaModeDriver) TickInterval(_ *Visualizer, ctx VisTickContext) time.Duration {
return defaultDriverTickInterval(ctx)
}
@@ -680,8 +692,8 @@ func (*luaModeDriver) OnEnter(*Visualizer) {}
func (*luaModeDriver) OnLeave(*Visualizer) {}
func luaBands(src []float64) [defaultSpectrumBands]float64 {
var bands [defaultSpectrumBands]float64
func luaBands(src []float64) [DefaultSpectrumBands]float64 {
var bands [DefaultSpectrumBands]float64
copy(bands[:], src)
return bands
}
@@ -701,13 +713,13 @@ func (v *Visualizer) syncDriverMode() visModeDriver {
}
if v.activeMode != v.Mode {
prev := v.driverFor(v.activeMode)
prevSpec := visAnalysisSpec{}
prevSpec := VisAnalysisSpec{}
if prev != nil {
prevSpec = normalizeAnalysisSpec(prev.AnalysisSpec(v))
prevSpec = NormalizeAnalysisSpec(prev.AnalysisSpec(v))
}
nextSpec := visAnalysisSpec{}
nextSpec := VisAnalysisSpec{}
if driver != nil {
nextSpec = normalizeAnalysisSpec(driver.AnalysisSpec(v))
nextSpec = NormalizeAnalysisSpec(driver.AnalysisSpec(v))
}
if (prevSpec.BandCount == 0) != (nextSpec.BandCount == 0) {
v.resetSpectrumHistory()
+35 -35
View File
@@ -14,8 +14,8 @@ func TestAnalyzeSupportsArbitraryBandCounts(t *testing.T) {
samples[i] = math.Sin(2 * math.Pi * 440 * float64(i) / v.sr)
}
for _, spec := range []visAnalysisSpec{
spectrumAnalysisSpec(defaultSpectrumBands),
for _, spec := range []VisAnalysisSpec{
spectrumAnalysisSpec(DefaultSpectrumBands),
{BandCount: 17, FFTSize: defaultFFTSize},
{BandCount: classicPeakSpectrumBands, FFTSize: classicPeakFFTSize},
} {
@@ -27,17 +27,17 @@ func TestAnalyzeSupportsArbitraryBandCounts(t *testing.T) {
}
func TestBuildSpectrumEdgesPreservesLegacyDefaultLayout(t *testing.T) {
got := buildSpectrumEdges(defaultSpectrumBands)
got := buildSpectrumEdges(DefaultSpectrumBands)
want := legacySpectrumEdges[:]
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildSpectrumEdges(%d) = %v, want %v", defaultSpectrumBands, got, want)
t.Fatalf("buildSpectrumEdges(%d) = %v, want %v", DefaultSpectrumBands, got, want)
}
}
func TestAnalyzeDecayStateIsIndependentPerAnalysisSpec(t *testing.T) {
v := NewVisualizer(44100)
specA := spectrumAnalysisSpec(defaultSpectrumBands)
specB := visAnalysisSpec{BandCount: defaultSpectrumBands, FFTSize: classicPeakFFTSize}
specA := spectrumAnalysisSpec(DefaultSpectrumBands)
specB := VisAnalysisSpec{BandCount: DefaultSpectrumBands, FFTSize: classicPeakFFTSize}
v.prevBySpec[specA] = uniformBandsN(specA.BandCount, 0.5)
v.prevBySpec[specB] = uniformBandsN(specB.BandCount, 0.8)
@@ -58,7 +58,7 @@ func TestAverageSpectrumRangeLinearDistinguishesSubBinLowBands(t *testing.T) {
magnitudes[i] = float64(i)
}
spec := visAnalysisSpec{BandCount: classicPeakSpectrumBands, FFTSize: classicPeakFFTSize}
spec := VisAnalysisSpec{BandCount: classicPeakSpectrumBands, FFTSize: classicPeakFFTSize}
edges := buildSpectrumEdges(spec.BandCount)
binHz := 44100.0 / float64(spec.FFTSize)
low := make([]float64, 3)
@@ -75,14 +75,14 @@ func TestRenderOnlyDriverUsesDefaultTickInterval(t *testing.T) {
v := NewVisualizer(44100)
activateMode(t, v, VisBars)
if got := v.TickInterval(visTickContext{Playing: true}); got != tickFast {
t.Fatalf("TickInterval(playing) = %v, want %v", got, tickFast)
if got := v.TickInterval(VisTickContext{Playing: true}); got != TickFast {
t.Fatalf("TickInterval(playing) = %v, want %v", got, TickFast)
}
if got := v.TickInterval(visTickContext{OverlayActive: true}); got != tickSlow {
t.Fatalf("TickInterval(overlay) = %v, want %v", got, tickSlow)
if got := v.TickInterval(VisTickContext{OverlayActive: true}); got != TickSlow {
t.Fatalf("TickInterval(overlay) = %v, want %v", got, TickSlow)
}
if got := v.TickInterval(visTickContext{}); got != tickSlow {
t.Fatalf("TickInterval(idle) = %v, want %v", got, tickSlow)
if got := v.TickInterval(VisTickContext{}); got != TickSlow {
t.Fatalf("TickInterval(idle) = %v, want %v", got, TickSlow)
}
}
@@ -91,9 +91,9 @@ func TestRenderOnlyDriverSkipsAnalyzeUnderOverlay(t *testing.T) {
activateMode(t, v, VisBars)
calls := 0
v.Tick(visTickContext{
v.Tick(VisTickContext{
OverlayActive: true,
Analyze: func(visAnalysisSpec) []float64 {
Analyze: func(VisAnalysisSpec) []float64 {
calls++
return uniformBands(0.6)
},
@@ -108,20 +108,20 @@ func TestRenderOnlyDriverRequestsConfiguredBandCount(t *testing.T) {
v := NewVisualizer(44100)
activateMode(t, v, VisBars)
var requested visAnalysisSpec
v.Tick(visTickContext{
Analyze: func(spec visAnalysisSpec) []float64 {
var requested VisAnalysisSpec
v.Tick(VisTickContext{
Analyze: func(spec VisAnalysisSpec) []float64 {
requested = spec
return uniformBandsN(spec.BandCount, 0.6)
},
})
want := spectrumAnalysisSpec(defaultSpectrumBands)
want := spectrumAnalysisSpec(DefaultSpectrumBands)
if requested != want {
t.Fatalf("Analyze() requested %+v, want %+v", requested, want)
}
if len(v.bands) != defaultSpectrumBands {
t.Fatalf("stored bands len = %d, want %d", len(v.bands), defaultSpectrumBands)
if len(v.bands) != DefaultSpectrumBands {
t.Fatalf("stored bands len = %d, want %d", len(v.bands), DefaultSpectrumBands)
}
}
@@ -129,17 +129,17 @@ func TestClassicPeakRequestsHighResBands(t *testing.T) {
v := NewVisualizer(44100)
activateMode(t, v, VisClassicPeak)
var requested visAnalysisSpec
v.Tick(visTickContext{
var requested VisAnalysisSpec
v.Tick(VisTickContext{
Now: time.Now(),
Playing: true,
Analyze: func(spec visAnalysisSpec) []float64 {
Analyze: func(spec VisAnalysisSpec) []float64 {
requested = spec
return uniformBandsN(spec.BandCount, 0.6)
},
})
want := visAnalysisSpec{BandCount: classicPeakSpectrumBands, FFTSize: classicPeakFFTSize}
want := VisAnalysisSpec{BandCount: classicPeakSpectrumBands, FFTSize: classicPeakFFTSize}
if requested != want {
t.Fatalf("Analyze() requested %+v, want %+v", requested, want)
}
@@ -153,10 +153,10 @@ func TestRawSampleModesRefreshWaveBufAtZeroBandCount(t *testing.T) {
activateMode(t, v, VisWave)
samples := []float64{-0.5, -0.1, 0.25, 0.75}
requested := visAnalysisSpec{BandCount: -1, FFTSize: -1}
v.Tick(visTickContext{
requested := VisAnalysisSpec{BandCount: -1, FFTSize: -1}
v.Tick(VisTickContext{
Playing: true,
Analyze: func(spec visAnalysisSpec) []float64 {
Analyze: func(spec VisAnalysisSpec) []float64 {
requested = spec
return v.Analyze(samples, spec)
},
@@ -173,7 +173,7 @@ func TestRawSampleModesRefreshWaveBufAtZeroBandCount(t *testing.T) {
func TestRawSampleModesClearSpectrumHistoryOnModeSwitch(t *testing.T) {
v := NewVisualizer(44100)
barsSpec := spectrumAnalysisSpec(defaultSpectrumBands)
barsSpec := spectrumAnalysisSpec(DefaultSpectrumBands)
v.prevBySpec[barsSpec] = uniformBandsN(barsSpec.BandCount, 0.8)
activateMode(t, v, VisBars)
@@ -199,10 +199,10 @@ func TestTerrainPreservesStateAcrossModeSwitch(t *testing.T) {
bands := uniformBands(0.6)
v.bands = bands
v.Tick(visTickContext{})
v.Tick(VisTickContext{})
snapshot := append([]float64(nil), driver.buf...)
if len(snapshot) != panelWidth*2 {
t.Fatalf("terrain buffer len = %d, want %d", len(snapshot), panelWidth*2)
if len(snapshot) != PanelWidth*2 {
t.Fatalf("terrain buffer len = %d, want %d", len(snapshot), PanelWidth*2)
}
activateMode(t, v, VisBars)
@@ -226,7 +226,7 @@ func TestTerrainRenderDoesNotAdvanceWithoutTick(t *testing.T) {
driver := terrainDriverFor(t, v)
v.bands = uniformBands(0.6)
v.Tick(visTickContext{})
v.Tick(VisTickContext{})
snapshot := append([]float64(nil), driver.buf...)
v.Render()
@@ -252,9 +252,9 @@ func TestTerrainTickSkipsAnalyzeUnderOverlay(t *testing.T) {
snapshot := append([]float64(nil), driver.buf...)
calls := 0
v.Tick(visTickContext{
v.Tick(VisTickContext{
OverlayActive: true,
Analyze: func(visAnalysisSpec) []float64 {
Analyze: func(VisAnalysisSpec) []float64 {
calls++
return uniformBands(0.6)
},