Split the model into smaller files
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,4 +1,4 @@
|
||||
package ui
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -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]) + "…"
|
||||
@@ -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,4 +1,4 @@
|
||||
package ui
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package ui
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -1,4 +1,4 @@
|
||||
package ui
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -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,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"
|
||||
@@ -1,4 +1,4 @@
|
||||
package ui
|
||||
package model
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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"
|
||||
@@ -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,4 +1,4 @@
|
||||
package ui
|
||||
package model
|
||||
|
||||
import "unicode/utf8"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
package ui
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -1,4 +1,4 @@
|
||||
package ui
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
package ui
|
||||
package model
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user