fix: make built-in themes accessible

This commit is contained in:
Bjarne Øverli
2026-08-18 19:31:35 +02:00
parent 5a588f5d7d
commit ea464dedc2
44 changed files with 420 additions and 156 deletions
+20 -16
View File
@@ -1,6 +1,6 @@
# Themes # Themes
cliamp ships with 21 built-in color themes and supports custom themes via simple TOML files. cliamp ships with 21 contrast-checked color themes and supports custom themes via simple TOML files.
Press `t` during playback to open the theme picker. Navigate with `↑`/`↓`, preview live as you move, confirm with `Enter`, or cancel with `Esc`. Press `t` during playback to open the theme picker. Navigate with `↑`/`↓`, preview live as you move, confirm with `Enter`, or cancel with `Esc`.
@@ -18,13 +18,15 @@ Create a `.toml` file in `~/.config/cliamp/themes/`:
mkdir -p ~/.config/cliamp/themes mkdir -p ~/.config/cliamp/themes
``` ```
Each file needs all 6 colors as `#RRGGBB` hex values. Incomplete or malformed Each file needs all 6 foreground colors as `#RRGGBB` hex values. Add `bg` to set
custom themes are ignored, so they cannot silently make focus, warning, error, a matching background; omit it to keep your terminal background. Incomplete or
or disabled states unreadable. The filename (minus `.toml`) becomes the theme name. malformed custom themes are ignored. The filename (minus `.toml`) becomes the
theme name.
### Example: `~/.config/cliamp/themes/solarized.toml` ### Example: `~/.config/cliamp/themes/solarized.toml`
```toml ```toml
bg = "#002b36"
accent = "#268bd2" accent = "#268bd2"
bright_fg = "#eee8d5" bright_fg = "#eee8d5"
fg = "#839496" fg = "#839496"
@@ -37,20 +39,22 @@ That's it. Press `t` and your theme appears in the list immediately.
### Color reference ### Color reference
| Key | What it colors | | Key | What it colors |
|-------------|---------------------------------------------------| |-------------|---------------------------------------------|
| `accent` | Title, track name, seek bar, selected items | | `bg` | Optional application background |
| `bright_fg` | Primary text, time display, help key pill text | | `accent` | Title, track name, seek bar, selected items |
| `fg` | Muted/secondary text, help bar, inactive elements, help key pill background | | `bright_fg` | Primary text and time display |
| `green` | Playing indicator, volume bar, spectrum low | | `fg` | Muted text, help bar, inactive elements |
| `yellow` | Spectrum middle | | `green` | Playing, success, volume, spectrum low |
| `red` | Spectrum top, error messages | | `yellow` | Warnings and spectrum middle |
| `red` | Errors and spectrum top |
All values are six-digit hex strings (for example, `"#ff5733"`). All values are six-digit hex strings (for example, `"#ff5733"`). Help-key
pill text automatically switches between black and white for readable contrast.
Important UI states also use stable text markers such as `>`, `Q`, `★`, and `!`, Important UI states also use stable text markers such as `>`, `Q`, `★`, `!`,
so selection, queued, bookmarked, and unavailable tracks remain distinguishable `WARN:`, and `ERR:`, so state and feedback remain distinguishable in monochrome
in monochrome terminals. terminals.
## Overriding a built-in theme ## Overriding a built-in theme
+1
View File
@@ -70,6 +70,7 @@ type Response struct {
// Empty hex fields mean the default (ANSI fallback) theme is active. // Empty hex fields mean the default (ANSI fallback) theme is active.
type ThemeInfo struct { type ThemeInfo struct {
Name string `json:"name"` Name string `json:"name"`
BG string `json:"bg,omitempty"`
Accent string `json:"accent,omitempty"` Accent string `json:"accent,omitempty"`
Fg string `json:"fg,omitempty"` Fg string `json:"fg,omitempty"`
BrightFg string `json:"bright_fg,omitempty"` BrightFg string `json:"bright_fg,omitempty"`
+1 -1
View File
@@ -754,7 +754,7 @@ user_id = "your-account-user-id"</code></pre>
</div> </div>
<div class="features-grid reveal"> <div class="features-grid reveal">
<div class="feature"><div class="feature-icon"></div><div class="feature-name">10-Band Equalizer</div><p>Parametric EQ presets plus a persistent Custom curve that survives preset changes and restarts.</p></div> <div class="feature"><div class="feature-icon"></div><div class="feature-name">10-Band Equalizer</div><p>Parametric EQ presets plus a persistent Custom curve that survives preset changes and restarts.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Themes &amp; Visualizers</div><p>21 built-in themes and spectrum, waveform, particle, and true-stereo modes. Stereo provides dedicated L/R horizontal LED peak meters. Hot-swap with <kbd>t</kbd> / <kbd>v</kbd>.</p></div> <div class="feature"><div class="feature-icon"></div><div class="feature-name">Themes &amp; Visualizers</div><p>21 contrast-checked built-in themes and spectrum, waveform, particle, and true-stereo modes. Stereo provides dedicated L/R horizontal LED peak meters. Hot-swap with <kbd>t</kbd> / <kbd>v</kbd>.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Playlists</div><p>TOML playlists with dynamic directory sources (<code>--dir</code>), M3U/M3U8/PLS import/export, duplicate-safe writes, and TUI/CLI sort tools.</p></div> <div class="feature"><div class="feature-icon"></div><div class="feature-name">Playlists</div><p>TOML playlists with dynamic directory sources (<code>--dir</code>), M3U/M3U8/PLS import/export, duplicate-safe writes, and TUI/CLI sort tools.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Recently Played</div><p>Auto-recorded listening history. Browse it as a virtual playlist or run <code>cliamp history</code> from the shell.</p></div> <div class="feature"><div class="feature-icon"></div><div class="feature-name">Recently Played</div><p>Auto-recorded listening history. Browse it as a virtual playlist or run <code>cliamp history</code> from the shell.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">HTTP Streaming</div><p>Play from URLs, internet radio, remote M3U playlists, and HLS (<code>.m3u8</code>) live streams via ffmpeg.</p></div> <div class="feature"><div class="feature-icon"></div><div class="feature-name">HTTP Streaming</div><p>Play from URLs, internet radio, remote M3U playlists, and HLS (<code>.m3u8</code>) live streams via ffmpeg.</p></div>
+62
View File
@@ -0,0 +1,62 @@
package theme
import (
"math"
"strconv"
"strings"
"testing"
)
func TestBuiltinThemesMeetTextContrast(t *testing.T) {
t.Setenv("HOME", t.TempDir())
themes := LoadAll()
if len(themes) != 21 {
t.Fatalf("LoadAll() returned %d built-in themes, want 21", len(themes))
}
for _, th := range themes {
if th.BG == "" {
t.Errorf("built-in theme %q has no background", th.Name)
continue
}
for _, role := range []struct {
name string
color string
}{
{"accent", th.Accent},
{"bright_fg", th.BrightFG},
{"fg", th.FG},
{"green", th.Green},
{"yellow", th.Yellow},
{"red", th.Red},
} {
if ratio := contrastRatio(role.color, th.BG); ratio < 4.5 {
t.Errorf("theme %q %s contrast = %.2f:1, want at least 4.5:1", th.Name, role.name, ratio)
}
}
}
}
func contrastRatio(a, b string) float64 {
lighter := relativeLuminance(a)
darker := relativeLuminance(b)
if lighter < darker {
lighter, darker = darker, lighter
}
return (lighter + 0.05) / (darker + 0.05)
}
func relativeLuminance(hex string) float64 {
value, err := strconv.ParseUint(strings.TrimPrefix(hex, "#"), 16, 24)
if err != nil {
return 0
}
linear := func(channel uint64) float64 {
component := float64(channel) / 255
if component <= 0.04045 {
return component / 12.92
}
return math.Pow((component+0.055)/1.055, 2.4)
}
return 0.2126*linear(value>>16) + 0.7152*linear((value>>8)&0xff) + 0.0722*linear(value&0xff)
}
+1
View File
@@ -36,6 +36,7 @@ func TestLoadAllIncludesWinampPalette(t *testing.T) {
t.Setenv("HOME", t.TempDir()) t.Setenv("HOME", t.TempDir())
want := Theme{ want := Theme{
Name: "winamp", Name: "winamp",
BG: "#000000",
Accent: "#00FF00", Accent: "#00FF00",
BrightFG: "#FFFFFF", BrightFG: "#FFFFFF",
FG: "#969696", FG: "#969696",
+9 -3
View File
@@ -25,6 +25,7 @@ const DefaultName = "Default - Terminal colors"
// Theme holds a named color scheme with hex color values. // Theme holds a named color scheme with hex color values.
type Theme struct { type Theme struct {
Name string Name string
BG string
Accent string // hex Accent string // hex
BrightFG string BrightFG string
FG string FG string
@@ -37,11 +38,11 @@ var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// IsDefault returns true if this is the sentinel default theme (no hex values). // IsDefault returns true if this is the sentinel default theme (no hex values).
func (t Theme) IsDefault() bool { func (t Theme) IsDefault() bool {
return t.Accent == "" && t.Green == "" && t.BrightFG == "" return t.BG == "" && t.Accent == "" && t.Green == "" && t.BrightFG == ""
} }
// Validate ensures a custom theme supplies the complete six-color palette in // Validate ensures a custom theme supplies the complete six-color foreground
// CSS hex notation. The terminal-default sentinel intentionally has no colors. // palette in CSS hex notation. Background is optional for custom themes.
func (t Theme) Validate() error { func (t Theme) Validate() error {
if t.IsDefault() { if t.IsDefault() {
return nil return nil
@@ -64,6 +65,9 @@ func (t Theme) Validate() error {
return fmt.Errorf("theme %q: %s must be #RRGGBB", t.Name, color.name) return fmt.Errorf("theme %q: %s must be #RRGGBB", t.Name, color.name)
} }
} }
if t.BG != "" && !hexColor.MatchString(t.BG) {
return fmt.Errorf("theme %q: bg must be #RRGGBB", t.Name)
}
return nil return nil
} }
@@ -92,6 +96,8 @@ func Parse(name string, r io.Reader) (Theme, error) {
val = strings.Trim(val, `"'`) val = strings.Trim(val, `"'`)
switch key { switch key {
case "bg":
t.BG = val
case "accent": case "accent":
t.Accent = val t.Accent = val
case "bright_fg": case "bright_fg":
+10
View File
@@ -23,6 +23,7 @@ func TestIsDefault(t *testing.T) {
}{ }{
{"empty hex values", Theme{Name: "Default"}, true}, {"empty hex values", Theme{Name: "Default"}, true},
{"has accent", Theme{Name: "Custom", Accent: "#ff0000"}, false}, {"has accent", Theme{Name: "Custom", Accent: "#ff0000"}, false},
{"has background", Theme{Name: "Custom", BG: "#000000"}, false},
{"has green", Theme{Name: "Custom", Green: "#00ff00"}, false}, {"has green", Theme{Name: "Custom", Green: "#00ff00"}, false},
{"has bright fg", Theme{Name: "Custom", BrightFG: "#ffffff"}, false}, {"has bright fg", Theme{Name: "Custom", BrightFG: "#ffffff"}, false},
} }
@@ -37,6 +38,7 @@ func TestIsDefault(t *testing.T) {
func TestParse(t *testing.T) { func TestParse(t *testing.T) {
input := `# Solarized Dark theme input := `# Solarized Dark theme
bg = "#002b36"
accent = "#268bd2" accent = "#268bd2"
bright_fg = "#93a1a1" bright_fg = "#93a1a1"
fg = "#839496" fg = "#839496"
@@ -52,6 +54,9 @@ red = "#dc322f"
if th.Name != "solarized-dark" { if th.Name != "solarized-dark" {
t.Errorf("Name = %q, want solarized-dark", th.Name) t.Errorf("Name = %q, want solarized-dark", th.Name)
} }
if th.BG != "#002b36" {
t.Errorf("BG = %q, want #002b36", th.BG)
}
if th.Accent != "#268bd2" { if th.Accent != "#268bd2" {
t.Errorf("Accent = %q, want #268bd2", th.Accent) t.Errorf("Accent = %q, want #268bd2", th.Accent)
} }
@@ -144,4 +149,9 @@ func TestThemeValidate(t *testing.T) {
if err := valid.Validate(); err == nil { if err := valid.Validate(); err == nil {
t.Fatal("Validate() accepted invalid color") t.Fatal("Validate() accepted invalid color")
} }
valid.Red = "#667788"
valid.BG = "black"
if err := valid.Validate(); err == nil {
t.Fatal("Validate() accepted invalid background color")
}
} }
+1
View File
@@ -1,3 +1,4 @@
bg = "#1f2430"
accent = "#73d0ff" accent = "#73d0ff"
bright_fg = "#f3f4f5" bright_fg = "#f3f4f5"
fg = "#cccac2" fg = "#cccac2"
+5 -4
View File
@@ -1,6 +1,7 @@
accent = "#1e66f5" bg = "#eff1f5"
accent = "#1a5cc8"
bright_fg = "#4c4f69" bright_fg = "#4c4f69"
fg = "#8c8fa1" fg = "#686b80"
green = "#40a02b" green = "#287a16"
yellow = "#df8e1d" yellow = "#956400"
red = "#d20f39" red = "#d20f39"
+1
View File
@@ -1,3 +1,4 @@
bg = "#1e1e2e"
accent = "#89b4fa" accent = "#89b4fa"
bright_fg = "#cdd6f4" bright_fg = "#cdd6f4"
fg = "#9399b2" fg = "#9399b2"
+2 -1
View File
@@ -1,8 +1,9 @@
# Dracula — gothic purple with vivid spectrum. # Dracula — gothic purple with vivid spectrum.
# The most popular terminal theme. draculatheme.com # The most popular terminal theme. draculatheme.com
bg = "#282a36"
accent = "#bd93f9" accent = "#bd93f9"
bright_fg = "#f8f8f2" bright_fg = "#f8f8f2"
fg = "#6272a4" fg = "#8b9ac4"
green = "#50fa7b" green = "#50fa7b"
yellow = "#f1fa8c" yellow = "#f1fa8c"
red = "#ff5555" red = "#ff5555"
+3 -2
View File
@@ -1,8 +1,9 @@
# Ember — deep warm hearth. # Ember — deep warm hearth.
# Campfire coals, darkroom safelight, analog warmth. # Campfire coals, darkroom safelight, analog warmth.
bg = "#121212"
accent = "#e07040" accent = "#e07040"
bright_fg = "#e8d0b8" bright_fg = "#e8d0b8"
fg = "#907868" fg = "#907868"
green = "#a08858" green = "#9aaa68"
yellow = "#d8a050" yellow = "#d8a050"
red = "#c04848" red = "#d15a5a"
+1
View File
@@ -1,3 +1,4 @@
bg = "#060b1e"
accent = "#7d82d9" accent = "#7d82d9"
bright_fg = "#ffcead" bright_fg = "#ffcead"
fg = "#9a96a8" fg = "#9a96a8"
+2 -1
View File
@@ -1,6 +1,7 @@
bg = "#2d353b"
accent = "#7fbbb3" accent = "#7fbbb3"
bright_fg = "#d3c6aa" bright_fg = "#d3c6aa"
fg = "#7a8478" fg = "#9aa59a"
green = "#a7c080" green = "#a7c080"
yellow = "#dbbc7f" yellow = "#dbbc7f"
red = "#e67e80" red = "#e67e80"
+4 -3
View File
@@ -1,6 +1,7 @@
bg = "#FFFCF0"
accent = "#205EA6" accent = "#205EA6"
bright_fg = "#100F0F" bright_fg = "#100F0F"
fg = "#6F6E69" fg = "#6F6E69"
green = "#879A39" green = "#617A0A"
yellow = "#D0A215" yellow = "#8B6A00"
red = "#D14D41" red = "#AF3029"
+1
View File
@@ -1,3 +1,4 @@
bg = "#282828"
accent = "#7daea3" accent = "#7daea3"
bright_fg = "#d4be98" bright_fg = "#d4be98"
fg = "#a89984" fg = "#a89984"
+4 -3
View File
@@ -1,6 +1,7 @@
bg = "#0b0c16"
accent = "#82FB9C" accent = "#82FB9C"
bright_fg = "#ddf7ff" bright_fg = "#ddf7ff"
fg = "#8e95b8" fg = "#8e95b8"
green = "#4fe88f" green = "#2ec27e"
yellow = "#50f7d4" yellow = "#f7df50"
red = "#50f872" red = "#ff5f78"
+2 -1
View File
@@ -1,6 +1,7 @@
bg = "#1f1f28"
accent = "#7e9cd8" accent = "#7e9cd8"
bright_fg = "#dcd7ba" bright_fg = "#dcd7ba"
fg = "#938aa9" fg = "#938aa9"
green = "#76946a" green = "#76946a"
yellow = "#c0a36e" yellow = "#c0a36e"
red = "#c34043" red = "#e46876"
+4 -3
View File
@@ -1,6 +1,7 @@
bg = "#121212"
accent = "#e68e0d" accent = "#e68e0d"
bright_fg = "#bebebe" bright_fg = "#bebebe"
fg = "#777777" fg = "#838383"
green = "#FFC107" green = "#8FAF5F"
yellow = "#b91c1c" yellow = "#FFC107"
red = "#D35F5F" red = "#D35F5F"
+6 -5
View File
@@ -1,6 +1,7 @@
accent = "#78824b" bg = "#222222"
accent = "#a5af6b"
bright_fg = "#c2c2b0" bright_fg = "#c2c2b0"
fg = "#666666" fg = "#929292"
green = "#5f875f" green = "#87af87"
yellow = "#b36d43" yellow = "#c9a554"
red = "#685742" red = "#c87575"
+2 -1
View File
@@ -1,9 +1,10 @@
# Blade Runner 1982 — Neon noir. # Blade Runner 1982 — Neon noir.
# Hot pink neon signs, cyan rain reflections, amber instrument readouts. # Hot pink neon signs, cyan rain reflections, amber instrument readouts.
# Cronenweth's Los Angeles: perpetual night, wet streets, practical light. # Cronenweth's Los Angeles: perpetual night, wet streets, practical light.
bg = "#121212"
accent = "#e8609a" accent = "#e8609a"
bright_fg = "#b8c4d0" bright_fg = "#b8c4d0"
fg = "#758494" fg = "#758494"
green = "#4eb8a8" green = "#4eb8a8"
yellow = "#d4a040" yellow = "#d4a040"
red = "#c85070" red = "#d65f7e"
+3 -2
View File
@@ -1,6 +1,7 @@
bg = "#2e3440"
accent = "#81a1c1" accent = "#81a1c1"
bright_fg = "#d8dee9" bright_fg = "#d8dee9"
fg = "#8690a0" fg = "#9da7b8"
green = "#a3be8c" green = "#a3be8c"
yellow = "#ebcb8b" yellow = "#ebcb8b"
red = "#bf616a" red = "#e08a92"
+3 -2
View File
@@ -1,6 +1,7 @@
bg = "#111c18"
accent = "#509475" accent = "#509475"
bright_fg = "#F7E8B2" bright_fg = "#F7E8B2"
fg = "#C1C497" fg = "#C1C497"
green = "#549e6a" green = "#86c994"
yellow = "#459451" yellow = "#e5c736"
red = "#FF5345" red = "#FF5345"
+2 -1
View File
@@ -1,6 +1,7 @@
bg = "#2c2525"
accent = "#f38d70" accent = "#f38d70"
bright_fg = "#e6d9db" bright_fg = "#e6d9db"
fg = "#948a8b" fg = "#9a9091"
green = "#adda78" green = "#adda78"
yellow = "#f9cc6c" yellow = "#f9cc6c"
red = "#fd6883" red = "#fd6883"
+5 -4
View File
@@ -1,6 +1,7 @@
accent = "#56949f" bg = "#faf4ed"
accent = "#3e7380"
bright_fg = "#575279" bright_fg = "#575279"
fg = "#908caa" fg = "#6e6a86"
green = "#286983" green = "#286983"
yellow = "#ea9d34" yellow = "#966400"
red = "#b4637a" red = "#9c4f66"
+2 -1
View File
@@ -1,6 +1,7 @@
bg = "#1a1b26"
accent = "#7aa2f7" accent = "#7aa2f7"
bright_fg = "#cfc9c2" bright_fg = "#cfc9c2"
fg = "#737aa2" fg = "#848cb8"
green = "#9ece6a" green = "#9ece6a"
yellow = "#e0af68" yellow = "#e0af68"
red = "#f7768e" red = "#f7768e"
+5 -4
View File
@@ -1,6 +1,7 @@
accent = "#d0d0d0" bg = "#000000"
accent = "#ececec"
bright_fg = "#ffffff" bright_fg = "#ffffff"
fg = "#8d8d8d" fg = "#8d8d8d"
green = "#b6b6b6" green = "#a4a4a4"
yellow = "#cecece" yellow = "#b9b9b9"
red = "#a4a4a4" red = "#cecece"
+1
View File
@@ -1,5 +1,6 @@
# Winamp 2.91 base skin. Text colors come from PLEDIT.TXT and the # Winamp 2.91 base skin. Text colors come from PLEDIT.TXT and the
# spectrum colors from VISCOLOR.TXT. # spectrum colors from VISCOLOR.TXT.
bg = "#000000"
accent = "#00FF00" accent = "#00FF00"
bright_fg = "#FFFFFF" bright_fg = "#FFFFFF"
fg = "#969696" fg = "#969696"
+3 -3
View File
@@ -93,7 +93,7 @@ func (m *Model) cycleEQPreset() {
func (m *Model) saveEQ() { func (m *Model) saveEQ() {
name := m.EQPresetName() name := m.EQPresetName()
if err := m.configSaver.Save("eq_preset", fmt.Sprintf("%q", name)); err != nil { if err := m.configSaver.Save("eq_preset", fmt.Sprintf("%q", name)); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err) m.status.Errorf(statusTTLDefault, "Config save failed: %s", err)
} }
bands := m.eqCustomBands bands := m.eqCustomBands
parts := make([]string, len(bands)) parts := make([]string, len(bands))
@@ -102,7 +102,7 @@ func (m *Model) saveEQ() {
} }
eqVal := "[" + strings.Join(parts, ", ") + "]" eqVal := "[" + strings.Join(parts, ", ") + "]"
if err := m.configSaver.Save("eq", eqVal); err != nil { if err := m.configSaver.Save("eq", eqVal); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err) m.status.Errorf(statusTTLDefault, "Config save failed: %s", err)
} }
} }
@@ -110,7 +110,7 @@ func (m *Model) saveEQ() {
func (m *Model) saveSpeed() { func (m *Model) saveSpeed() {
speed := m.player.Speed() speed := m.player.Speed()
if err := m.configSaver.Save("speed", fmt.Sprintf("%.2f", speed)); err != nil { if err := m.configSaver.Save("speed", fmt.Sprintf("%.2f", speed)); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err) m.status.Errorf(statusTTLDefault, "Config save failed: %s", err)
} }
} }
+4 -4
View File
@@ -323,7 +323,7 @@ func (m Model) renderJumpBody() string {
budget := m.effectivePlaylistVisible() budget := m.effectivePlaylistVisible()
pos := m.player.Position() pos := m.player.Position()
dur := m.player.Duration() dur := m.player.Duration()
inputLine := dimStyle.Faint(true).Render(" " + formatJumpPlaceholder(dur)) inputLine := dimStyle.Render(" " + formatJumpPlaceholder(dur))
if m.jumpInput != "" { if m.jumpInput != "" {
inputLine = playlistSelectedStyle.Render(" " + m.textWithCursor("jump", m.jumpInput)) inputLine = playlistSelectedStyle.Render(" " + m.textWithCursor("jump", m.jumpInput))
} }
@@ -358,7 +358,7 @@ func (m Model) renderLyricsBody() string {
if errors.Is(m.lyrics.err, lyrics.ErrNotFound) { if errors.Is(m.lyrics.err, lyrics.ErrNotFound) {
lines = append(lines, dimStyle.Render(" No lyrics found for this track.")) lines = append(lines, dimStyle.Render(" No lyrics found for this track."))
} else { } else {
lines = append(lines, helpStyle.Render(" Lyrics fetch failed: "+m.lyrics.err.Error())) lines = append(lines, errorStyle.Render(" Lyrics fetch failed: "+m.lyrics.err.Error()))
} }
case len(m.lyrics.lines) == 0: case len(m.lyrics.lines) == 0:
artist, title := m.lyricsArtistTitle() artist, title := m.lyricsArtistTitle()
@@ -444,7 +444,7 @@ func (m Model) renderNetSearchBody() string {
lines = append(lines, dimStyle.Render(" Type a query and press Enter to search "+m.netSearchSource()+".")) lines = append(lines, dimStyle.Render(" Type a query and press Enter to search "+m.netSearchSource()+"."))
} }
if m.netSearch.err != "" { if m.netSearch.err != "" {
lines = append(lines, "", helpStyle.Render(" "+m.netSearch.err)) lines = append(lines, "", errorStyle.Render(" "+m.netSearch.err))
} }
return bodyLines(lines, budget) return bodyLines(lines, budget)
} }
@@ -531,7 +531,7 @@ func (m Model) renderSpotSearchBody() string {
body = bodyLines(lines, budget) body = bodyLines(lines, budget)
} }
if m.spotSearch.err != "" && m.spotSearch.screen != spotSearchPlaylist { if m.spotSearch.err != "" && m.spotSearch.screen != spotSearchPlaylist {
return strings.Join([]string{body, helpStyle.Render(" " + m.spotSearch.err)}, "\n") return strings.Join([]string{body, errorStyle.Render(" " + m.spotSearch.err)}, "\n")
} }
return body return body
} }
+30 -30
View File
@@ -367,7 +367,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
} }
m.provLoading = true m.provLoading = true
m.activeProviderPlaylistID = "" m.activeProviderPlaylistID = ""
m.status.Showf(statusTTLShort, "Refreshing %s…", m.provider.Name()) m.status.Activityf(statusTTLShort, "Refreshing %s…", m.provider.Name())
return m.fetchProviderPlaylists() return m.fetchProviderPlaylists()
} }
case "f": case "f":
@@ -531,7 +531,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
tracks := m.playlist.Tracks() tracks := m.playlist.Tracks()
track := tracks[m.plCursor] track := tracks[m.plCursor]
if err := bs.SetBookmarkByPath(m.loadedPlaylist, track.Path); err != nil { if err := bs.SetBookmarkByPath(m.loadedPlaylist, track.Path); err != nil {
m.status.Showf(statusTTLDefault, "Save failed: %s", err) m.status.Errorf(statusTTLDefault, "Save failed: %s", err)
return nil return nil
} }
m.playlist.ToggleBookmark(m.plCursor) m.playlist.ToggleBookmark(m.plCursor)
@@ -640,7 +640,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
case "r": case "r":
m.playlist.CycleRepeat() m.playlist.CycleRepeat()
if err := m.configSaver.Save("repeat", fmt.Sprintf("%q", m.playlist.Repeat().String())); err != nil { if err := m.configSaver.Save("repeat", fmt.Sprintf("%q", m.playlist.Repeat().String())); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err) m.status.Errorf(statusTTLDefault, "Config save failed: %s", err)
} }
m.player.ClearPreload() m.player.ClearPreload()
return m.preloadNext() return m.preloadNext()
@@ -648,7 +648,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
case "z": case "z":
m.playlist.ToggleShuffle() m.playlist.ToggleShuffle()
if err := m.configSaver.Save("shuffle", fmt.Sprintf("%v", m.playlist.Shuffled())); err != nil { if err := m.configSaver.Save("shuffle", fmt.Sprintf("%v", m.playlist.Shuffled())); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err) m.status.Errorf(statusTTLDefault, "Config save failed: %s", err)
} }
m.player.ClearPreload() m.player.ClearPreload()
return m.preloadNext() return m.preloadNext()
@@ -782,7 +782,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
m.applyHeightMode() m.applyHeightMode()
m.adjustScroll() m.adjustScroll()
if err := m.configSaver.Save("visualizer", fmt.Sprintf("%q", m.vis.ModeName())); err != nil { if err := m.configSaver.Save("visualizer", fmt.Sprintf("%q", m.vis.ModeName())); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err) m.status.Errorf(statusTTLDefault, "Config save failed: %s", err)
} }
case "ctrl+v": case "ctrl+v":
@@ -885,19 +885,19 @@ func (m *Model) handleFullVisualizerKey(msg tea.KeyPressMsg) tea.Cmd {
func (m *Model) saveTrack() tea.Cmd { func (m *Model) saveTrack() tea.Cmd {
track, idx := m.currentPlaybackTrack() track, idx := m.currentPlaybackTrack()
if idx < 0 { if idx < 0 {
m.status.Show("Nothing to save", statusTTLShort) m.status.Warning("Nothing to save", statusTTLShort)
return nil return nil
} }
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
m.status.Showf(statusTTLShort, "Save failed: %s", err) m.status.Errorf(statusTTLShort, "Save failed: %s", err)
return nil return nil
} }
saveDir := filepath.Join(home, "Music", "cliamp") saveDir := filepath.Join(home, "Music", "cliamp")
if err := os.MkdirAll(saveDir, 0o755); err != nil { if err := os.MkdirAll(saveDir, 0o755); err != nil {
m.status.Showf(statusTTLShort, "Save failed: %s", err) m.status.Errorf(statusTTLShort, "Save failed: %s", err)
return nil return nil
} }
@@ -910,7 +910,7 @@ func (m *Model) saveTrack() tea.Cmd {
// Only save local temp files (yt-dlp downloads), not streams or user's own files. // Only save local temp files (yt-dlp downloads), not streams or user's own files.
if track.Stream || !strings.HasPrefix(track.Path, os.TempDir()) { if track.Stream || !strings.HasPrefix(track.Path, os.TempDir()) {
m.status.Show("Only downloaded tracks can be saved", statusTTLShort) m.status.Warning("Only downloaded tracks can be saved", statusTTLShort)
return nil return nil
} }
@@ -930,7 +930,7 @@ func (m *Model) saveTrack() tea.Cmd {
dest := filepath.Join(saveDir, name+ext) dest := filepath.Join(saveDir, name+ext)
if err := fileutil.CopyFile(track.Path, dest); err != nil { if err := fileutil.CopyFile(track.Path, dest); err != nil {
m.status.Showf(statusTTLShort, "Save failed: %s", err) m.status.Errorf(statusTTLShort, "Save failed: %s", err)
return nil return nil
} }
@@ -1586,7 +1586,7 @@ func (m *Model) handlePlMgrListKey(msg tea.KeyPressMsg) tea.Cmd {
} }
if d, ok := m.localProvider.(provider.PlaylistDeleter); ok { if d, ok := m.localProvider.(provider.PlaylistDeleter); ok {
if err := d.DeletePlaylist(name); err != nil { if err := d.DeletePlaylist(name); err != nil {
m.status.Showf(statusTTLDefault, "Delete failed: %s", err) m.status.Errorf(statusTTLDefault, "Delete failed: %s", err)
} else { } else {
m.status.Showf(statusTTLDefault, "Deleted %q (u to undo)", name) m.status.Showf(statusTTLDefault, "Deleted %q (u to undo)", name)
} }
@@ -1672,7 +1672,7 @@ func (m *Model) handlePlMgrListKey(msg tea.KeyPressMsg) tea.Cmd {
case "w": case "w":
tracks := m.playlist.Tracks() tracks := m.playlist.Tracks()
if len(tracks) == 0 { if len(tracks) == 0 {
m.status.Show("Queue is empty", statusTTLShort) m.status.Warning("Queue is empty", statusTTLShort)
return nil return nil
} }
m.openPlaylistPicker(tracks, fmt.Sprintf("Save %d queued tracks", len(tracks))) m.openPlaylistPicker(tracks, fmt.Sprintf("Save %d queued tracks", len(tracks)))
@@ -1683,7 +1683,7 @@ func (m *Model) handlePlMgrListKey(msg tea.KeyPressMsg) tea.Cmd {
} }
name := m.plManager.playlists[realIdx].Name name := m.plManager.playlists[realIdx].Name
if name == history.PlaylistName { if name == history.PlaylistName {
m.status.Show("Recently Played cannot be renamed", statusTTLDefault) m.status.Warning("Recently Played cannot be renamed", statusTTLDefault)
return nil return nil
} }
m.plManager.renameOldName = name m.plManager.renameOldName = name
@@ -2019,16 +2019,16 @@ func (m *Model) plMgrSetTrackUndo() {
func (m *Model) plMgrUndoLast() { func (m *Model) plMgrUndoLast() {
undo := m.plManager.undo undo := m.plManager.undo
if undo.kind == plUndoNone || undo.name == "" { if undo.kind == plUndoNone || undo.name == "" {
m.status.Show("Nothing to undo", statusTTLShort) m.status.Warning("Nothing to undo", statusTTLShort)
return return
} }
saver := m.localSaver() saver := m.localSaver()
if saver == nil { if saver == nil {
m.status.Show("Undo unavailable", statusTTLDefault) m.status.Warning("Undo unavailable", statusTTLDefault)
return return
} }
if err := saver.SavePlaylist(undo.name, cloneTracks(undo.tracks)); err != nil { if err := saver.SavePlaylist(undo.name, cloneTracks(undo.tracks)); err != nil {
m.status.Showf(statusTTLDefault, "Undo failed: %s", err) m.status.Errorf(statusTTLDefault, "Undo failed: %s", err)
return return
} }
m.plManager.undo = plManagerUndo{} m.plManager.undo = plManagerUndo{}
@@ -2115,11 +2115,11 @@ func (m *Model) plMgrToggleMarkAll() {
func (m *Model) plMgrSaveTracks(status string) bool { func (m *Model) plMgrSaveTracks(status string) bool {
saver := m.localSaver() saver := m.localSaver()
if saver == nil { if saver == nil {
m.status.Show("Playlist saving is not supported", statusTTLDefault) m.status.Warning("Playlist saving is not supported", statusTTLDefault)
return false return false
} }
if err := saver.SavePlaylist(m.plManager.selPlaylist, m.plManager.tracks); err != nil { if err := saver.SavePlaylist(m.plManager.selPlaylist, m.plManager.tracks); err != nil {
m.status.Showf(statusTTLDefault, "Save failed: %s", err) m.status.Errorf(statusTTLDefault, "Save failed: %s", err)
return false return false
} }
if status != "" { if status != "" {
@@ -2135,7 +2135,7 @@ func (m *Model) plMgrRemoveSelectedTracks() {
} }
for _, i := range indices { for _, i := range indices {
if m.plManager.tracks[i].DirSourced { if m.plManager.tracks[i].DirSourced {
m.status.Showf(statusTTLDefault, "Can't remove %q: it's supplied by the playlist's directory source", m.plManager.tracks[i].DisplayName()) m.status.Warningf(statusTTLDefault, "Can't remove %q: it's supplied by the playlist's directory source", m.plManager.tracks[i].DisplayName())
return return
} }
} }
@@ -2164,7 +2164,7 @@ func (m *Model) plMgrRemoveSelectedTracks() {
func (m *Model) plMgrMoveTrack(delta int) { func (m *Model) plMgrMoveTrack(delta int) {
if m.plManager.filter != "" { if m.plManager.filter != "" {
m.status.Show("Clear filter before moving tracks", statusTTLDefault) m.status.Warning("Clear filter before moving tracks", statusTTLDefault)
return return
} }
from := m.plManager.cursor from := m.plManager.cursor
@@ -2249,11 +2249,11 @@ func (m *Model) persistLoadedPlaylistOrder() {
} }
} }
if err := saver.SavePlaylist(m.loadedPlaylist, m.playlist.Tracks()); err != nil { if err := saver.SavePlaylist(m.loadedPlaylist, m.playlist.Tracks()); err != nil {
m.status.Showf(statusTTLDefault, "Save failed: %s", err) m.status.Errorf(statusTTLDefault, "Save failed: %s", err)
return return
} }
if hasDirTracks { if hasDirTracks {
m.status.Showf(statusTTLDefault, "Reordered %q (directory-sourced tracks keep scan order)", m.loadedPlaylist) m.status.Warningf(statusTTLDefault, "Reordered %q (directory-sourced tracks keep scan order)", m.loadedPlaylist)
return return
} }
m.status.Showf(statusTTLDefault, "Reordered %q", m.loadedPlaylist) m.status.Showf(statusTTLDefault, "Reordered %q", m.loadedPlaylist)
@@ -2263,28 +2263,28 @@ func (m *Model) persistLoadedPlaylistOrder() {
func (m *Model) addToPlaylist(name string) { func (m *Model) addToPlaylist(name string) {
track, idx := m.currentPlaybackTrack() track, idx := m.currentPlaybackTrack()
if idx < 0 { if idx < 0 {
m.status.Show("No track to add", statusTTLShort) m.status.Warning("No track to add", statusTTLShort)
return return
} }
if bw, ok := m.localProvider.(provider.PlaylistBatchWriter); ok { if bw, ok := m.localProvider.(provider.PlaylistBatchWriter); ok {
added, skipped, err := bw.AddTracksToPlaylist(context.Background(), name, []playlist.Track{track}) added, skipped, err := bw.AddTracksToPlaylist(context.Background(), name, []playlist.Track{track})
if err != nil { if err != nil {
m.status.Showf(statusTTLDefault, "Failed: %s", err) m.status.Errorf(statusTTLDefault, "Failed: %s", err)
return return
} }
switch { switch {
case added > 0: case added > 0:
m.status.Showf(statusTTLDefault, "Added to %q", name) m.status.Showf(statusTTLDefault, "Added to %q", name)
case skipped > 0: case skipped > 0:
m.status.Showf(statusTTLDefault, "Already in %q", name) m.status.Warningf(statusTTLDefault, "Already in %q", name)
default: default:
m.status.Showf(statusTTLDefault, "Nothing added to %q", name) m.status.Warningf(statusTTLDefault, "Nothing added to %q", name)
} }
return return
} }
if w, ok := m.localProvider.(provider.PlaylistWriter); ok { if w, ok := m.localProvider.(provider.PlaylistWriter); ok {
if err := w.AddTrackToPlaylist(context.Background(), name, track); err != nil { if err := w.AddTrackToPlaylist(context.Background(), name, track); err != nil {
m.status.Showf(statusTTLDefault, "Failed: %s", err) m.status.Errorf(statusTTLDefault, "Failed: %s", err)
} else { } else {
m.status.Showf(statusTTLDefault, "Added to %q", name) m.status.Showf(statusTTLDefault, "Added to %q", name)
} }
@@ -2310,19 +2310,19 @@ func (m *Model) createPlaylistFromManager(name string) bool {
if bw, ok := m.localProvider.(provider.PlaylistBatchWriter); ok { if bw, ok := m.localProvider.(provider.PlaylistBatchWriter); ok {
added, skipped, err := bw.AddTracksToPlaylist(context.Background(), id, []playlist.Track{track}) added, skipped, err := bw.AddTracksToPlaylist(context.Background(), id, []playlist.Track{track})
if err != nil { if err != nil {
m.status.Showf(statusTTLDefault, "Created %q, add failed: %s", name, err) m.status.Errorf(statusTTLDefault, "Created %q, add failed: %s", name, err)
return true return true
} }
if added > 0 { if added > 0 {
m.status.Showf(statusTTLDefault, "Created %q & added track", name) m.status.Showf(statusTTLDefault, "Created %q & added track", name)
} else if skipped > 0 { } else if skipped > 0 {
m.status.Showf(statusTTLDefault, "Created %q; track was duplicate", name) m.status.Warningf(statusTTLDefault, "Created %q; track was duplicate", name)
} }
return true return true
} }
if w, ok := m.localProvider.(provider.PlaylistWriter); ok { if w, ok := m.localProvider.(provider.PlaylistWriter); ok {
if err := w.AddTrackToPlaylist(context.Background(), id, track); err != nil { if err := w.AddTrackToPlaylist(context.Background(), id, track); err != nil {
m.status.Showf(statusTTLDefault, "Created %q, add failed: %s", name, err) m.status.Errorf(statusTTLDefault, "Created %q, add failed: %s", name, err)
return true return true
} }
m.status.Showf(statusTTLDefault, "Created %q & added track", name) m.status.Showf(statusTTLDefault, "Created %q & added track", name)
+1 -1
View File
@@ -298,7 +298,7 @@ func (m *Model) handleNavAlbumListKey(msg tea.KeyPressMsg, artistAlbums bool) te
m.navClearSearch() m.navClearSearch()
if saver, ok := m.navBrowser.prov.(provider.AlbumSortSaver); ok { if saver, ok := m.navBrowser.prov.(provider.AlbumSortSaver); ok {
if err := saver.SaveAlbumSort(m.navBrowser.sortType); err != nil { if err := saver.SaveAlbumSort(m.navBrowser.sortType); err != nil {
m.status.Showf(statusTTLDefault, "Sort save failed: %s", err) m.status.Errorf(statusTTLDefault, "Sort save failed: %s", err)
} }
} }
return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, 0, m.nextNavRequest()) return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, 0, m.nextNavRequest())
+3 -3
View File
@@ -192,7 +192,7 @@ func (m *Model) visPickerSelect() {
return return
} }
if err := m.configSaver.Save("visualizer", fmt.Sprintf("%q", m.vis.ModeName())); err != nil { if err := m.configSaver.Save("visualizer", fmt.Sprintf("%q", m.vis.ModeName())); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err) m.status.Errorf(statusTTLDefault, "Config save failed: %s", err)
} }
m.visPickerClose() m.visPickerClose()
} }
@@ -370,7 +370,7 @@ func (m *Model) openPlaylistManager() {
func (m *Model) plMgrEnterTrackList(name string) { func (m *Model) plMgrEnterTrackList(name string) {
tracks, err := m.localProvider.Tracks(name) tracks, err := m.localProvider.Tracks(name)
if err != nil { if err != nil {
m.status.Showf(statusTTLDefault, "Load failed: %s", err) m.status.Errorf(statusTTLDefault, "Load failed: %s", err)
return return
} }
m.plManager.selPlaylist = name m.plManager.selPlaylist = name
@@ -463,7 +463,7 @@ func (m *Model) plMgrRefreshList() {
} }
playlists, err := m.localProvider.Playlists() playlists, err := m.localProvider.Playlists()
if err != nil { if err != nil {
m.status.Showf(statusTTLDefault, "Load failed: %s", err) m.status.Errorf(statusTTLDefault, "Load failed: %s", err)
} }
m.plManager.playlists = playlists m.plManager.playlists = playlists
if m.plManager.filter != "" { if m.plManager.filter != "" {
+5 -5
View File
@@ -15,12 +15,12 @@ import (
func (m *Model) openPlaylistPicker(tracks []playlist.Track, title string) { func (m *Model) openPlaylistPicker(tracks []playlist.Track, title string) {
if m.localProvider == nil { if m.localProvider == nil {
m.status.Show("Local playlists are unavailable", statusTTLDefault) m.status.Warning("Local playlists are unavailable", statusTTLDefault)
return return
} }
lists, err := m.localProvider.Playlists() lists, err := m.localProvider.Playlists()
if err != nil { if err != nil {
m.status.Showf(statusTTLDefault, "Playlist list failed: %s", err) m.status.Errorf(statusTTLDefault, "Playlist list failed: %s", err)
return return
} }
playlists := make([]playlist.PlaylistInfo, 0, len(lists)) playlists := make([]playlist.PlaylistInfo, 0, len(lists))
@@ -231,13 +231,13 @@ func (m *Model) writePickerTracks(name string) bool {
} }
switch { switch {
case added > 0 && skipped > 0: case added > 0 && skipped > 0:
m.status.Showf(statusTTLBatch, "Added %d to %q, skipped %d duplicates", added, name, skipped) m.status.Warningf(statusTTLBatch, "Added %d to %q, skipped %d duplicates", added, name, skipped)
case added > 0: case added > 0:
m.status.Showf(statusTTLDefault, "Added %d to %q", added, name) m.status.Showf(statusTTLDefault, "Added %d to %q", added, name)
case skipped > 0: case skipped > 0:
m.status.Showf(statusTTLDefault, "Skipped %d duplicates in %q", skipped, name) m.status.Warningf(statusTTLDefault, "Skipped %d duplicates in %q", skipped, name)
default: default:
m.status.Showf(statusTTLDefault, "Nothing added to %q", name) m.status.Warningf(statusTTLDefault, "Nothing added to %q", name)
} }
m.refreshPlaylistManagerAfterWrite(name) m.refreshPlaylistManagerAfterWrite(name)
return true return true
+11 -11
View File
@@ -85,11 +85,11 @@ func (m *Model) playCurrentTrack() tea.Cmd {
if !ok { if !ok {
m.player.Stop() m.player.Stop()
m.clearPlaybackTrack() m.clearPlaybackTrack()
m.status.Show("No available tracks", statusTTLDefault) m.status.Warning("No available tracks", statusTTLDefault)
return nil return nil
} }
if activation.Skipped { if activation.Skipped {
m.status.Show("Track unavailable, skipping...", statusTTLDefault) m.status.Warning("Track unavailable, skipping...", statusTTLDefault)
} }
m.plCursor = activation.Index m.plCursor = activation.Index
m.adjustScroll() m.adjustScroll()
@@ -192,7 +192,7 @@ func (m *Model) removeSelectedFromPlaylist() {
snapshot := m.playlist.Snapshot() snapshot := m.playlist.Snapshot()
track := m.playlist.Tracks()[idx] track := m.playlist.Tracks()[idx]
if track.DirSourced { if track.DirSourced {
m.status.Showf(statusTTLDefault, "Can't remove %q: it's supplied by the playlist's directory source", track.DisplayName()) m.status.Warningf(statusTTLDefault, "Can't remove %q: it's supplied by the playlist's directory source", track.DisplayName())
return return
} }
loaded := m.loadedPlaylist loaded := m.loadedPlaylist
@@ -203,7 +203,7 @@ func (m *Model) removeSelectedFromPlaylist() {
var err error var err error
saved, err = m.localProvider.Tracks(loaded) saved, err = m.localProvider.Tracks(loaded)
if err != nil { if err != nil {
m.status.Showf(statusTTLDefault, "Remove failed: %s", err) m.status.Errorf(statusTTLDefault, "Remove failed: %s", err)
return return
} }
// saved rescans directory sources, so a new file could have shifted // saved rescans directory sources, so a new file could have shifted
@@ -217,13 +217,13 @@ func (m *Model) removeSelectedFromPlaylist() {
} }
} }
if savedIdx < 0 { if savedIdx < 0 {
m.status.Showf(statusTTLDefault, "Remove failed: selected track is no longer in %q", loaded) m.status.Errorf(statusTTLDefault, "Remove failed: selected track is no longer in %q", loaded)
return return
} }
original := cloneTracks(saved) original := cloneTracks(saved)
saved = append(saved[:savedIdx:savedIdx], saved[savedIdx+1:]...) saved = append(saved[:savedIdx:savedIdx], saved[savedIdx+1:]...)
if err := saver.SavePlaylist(loaded, saved); err != nil { if err := saver.SavePlaylist(loaded, saved); err != nil {
m.status.Showf(statusTTLDefault, "Remove failed: %s", err) m.status.Errorf(statusTTLDefault, "Remove failed: %s", err)
return return
} }
saved = original saved = original
@@ -257,17 +257,17 @@ func (m *Model) removeSelectedFromPlaylist() {
func (m *Model) undoPlaylistMutation() { func (m *Model) undoPlaylistMutation() {
undo := m.playlistUndo undo := m.playlistUndo
if !undo.active { if !undo.active {
m.status.Show("Nothing to undo", statusTTLShort) m.status.Warning("Nothing to undo", statusTTLShort)
return return
} }
if undo.persisted { if undo.persisted {
saver := m.localSaver() saver := m.localSaver()
if saver == nil { if saver == nil {
m.status.Show("Undo unavailable", statusTTLDefault) m.status.Warning("Undo unavailable", statusTTLDefault)
return return
} }
if err := saver.SavePlaylist(undo.loaded, cloneTracks(undo.saved)); err != nil { if err := saver.SavePlaylist(undo.loaded, cloneTracks(undo.saved)); err != nil {
m.status.Showf(statusTTLDefault, "Undo failed: %s", err) m.status.Errorf(statusTTLDefault, "Undo failed: %s", err)
return return
} }
} }
@@ -286,7 +286,7 @@ func (m *Model) playTrack(track playlist.Track) tea.Cmd {
m.pausedAt = time.Time{} m.pausedAt = time.Time{}
if track.Feed || playlist.IsFeed(track.Path) { if track.Feed || playlist.IsFeed(track.Path) {
m.feedLoading = true m.feedLoading = true
m.status.Show("Loading feed...", statusTTLLong) m.status.Activity("Loading feed...", statusTTLLong)
return resolveFeedTrackCmd(track.Path) return resolveFeedTrackCmd(track.Path)
} }
track, fetchCmd := m.beginPlaybackTrack(track) track, fetchCmd := m.beginPlaybackTrack(track)
@@ -457,7 +457,7 @@ func (m *Model) reconnectYTDLOnUnpause() tea.Cmd {
m.seek.grace = 0 m.seek.grace = 0
m.seek.graceFor = 0 m.seek.graceFor = 0
m.player.CancelSeekYTDL() m.player.CancelSeekYTDL()
m.status.Show("Reconnecting stream...", statusTTLMedium) m.status.Activity("Reconnecting stream...", statusTTLMedium)
p := m.player p := m.player
return func() tea.Msg { return func() tea.Msg {
+6
View File
@@ -225,6 +225,9 @@ func TestPlayCurrentTrackUnplayableUsesSelectionOrder(t *testing.T) {
if m.status.text != "Track unavailable, skipping..." { if m.status.text != "Track unavailable, skipping..." {
t.Fatalf("status.text = %q, want %q", m.status.text, "Track unavailable, skipping...") t.Fatalf("status.text = %q, want %q", m.status.text, "Track unavailable, skipping...")
} }
if m.status.kind != feedbackWarning {
t.Fatalf("status.kind = %v, want %v", m.status.kind, feedbackWarning)
}
if p.QueueLen() != 1 { if p.QueueLen() != 1 {
t.Fatalf("QueueLen() = %d, want 1", p.QueueLen()) t.Fatalf("QueueLen() = %d, want 1", p.QueueLen())
} }
@@ -260,6 +263,9 @@ func TestPlayCurrentTrackUnplayableStopsWhenNoReplacementExists(t *testing.T) {
if m.status.text != "No available tracks" { if m.status.text != "No available tracks" {
t.Fatalf("status.text = %q, want %q", m.status.text, "No available tracks") t.Fatalf("status.text = %q, want %q", m.status.text, "No available tracks")
} }
if m.status.kind != feedbackWarning {
t.Fatalf("status.kind = %v, want %v", m.status.kind, feedbackWarning)
}
} }
func modelAfterProviderPlaylistLoadWhilePlaying(t *testing.T) (Model, *playbackFakeEngine) { func modelAfterProviderPlaylistLoadWhilePlaying(t *testing.T) (Model, *playbackFakeEngine) {
+6 -2
View File
@@ -388,6 +388,10 @@ func (s *statusMsg) Warningf(ttl statusTTL, format string, args ...any) {
s.Warning(fmt.Sprintf(format, args...), ttl) s.Warning(fmt.Sprintf(format, args...), ttl)
} }
func (s *statusMsg) Errorf(ttl statusTTL, format string, args ...any) {
s.Error(fmt.Sprintf(format, args...), ttl)
}
func (s *statusMsg) Activity(text string, ttl statusTTL) { func (s *statusMsg) Activity(text string, ttl statusTTL) {
s.show(feedbackActivity, text, ttl) s.show(feedbackActivity, text, ttl)
} }
@@ -400,8 +404,8 @@ func (s *statusMsg) Warning(text string, ttl statusTTL) {
s.show(feedbackWarning, text, ttl) s.show(feedbackWarning, text, ttl)
} }
func (s *statusMsg) Error(text string) { func (s *statusMsg) Error(text string, ttl statusTTL) {
s.show(feedbackError, text, 0) s.show(feedbackError, text, ttl)
} }
func (s *statusMsg) show(kind feedbackKind, text string, ttl statusTTL) { func (s *statusMsg) show(kind feedbackKind, text string, ttl statusTTL) {
+43
View File
@@ -14,6 +14,9 @@ func TestStatusShowAtSetsTextAndDeadline(t *testing.T) {
if status.text != "Saved" { if status.text != "Saved" {
t.Fatalf("text = %q, want %q", status.text, "Saved") t.Fatalf("text = %q, want %q", status.text, "Saved")
} }
if status.kind != feedbackSuccess {
t.Fatalf("kind = %v, want %v", status.kind, feedbackSuccess)
}
want := now.Add(time.Duration(statusTTLMedium)) want := now.Add(time.Duration(statusTTLMedium))
if !status.expiresAt.Equal(want) { if !status.expiresAt.Equal(want) {
t.Fatalf("expiresAt = %v, want %v", status.expiresAt, want) t.Fatalf("expiresAt = %v, want %v", status.expiresAt, want)
@@ -48,3 +51,43 @@ func TestStatusClearResetsMessage(t *testing.T) {
t.Fatalf("expiresAt after Clear() = %v, want zero", status.expiresAt) t.Fatalf("expiresAt after Clear() = %v, want zero", status.expiresAt)
} }
} }
func TestStatusSemanticKinds(t *testing.T) {
tests := []struct {
name string
show func(*statusMsg)
want feedbackKind
}{
{name: "activity", show: func(s *statusMsg) { s.Activity("Loading...", statusTTLShort) }, want: feedbackActivity},
{name: "warning", show: func(s *statusMsg) { s.Warning("Unavailable", statusTTLShort) }, want: feedbackWarning},
{name: "error", show: func(s *statusMsg) { s.Error("Load failed", statusTTLShort) }, want: feedbackError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var status statusMsg
tt.show(&status)
if status.kind != tt.want {
t.Fatalf("kind = %v, want %v", status.kind, tt.want)
}
if status.expiresAt.IsZero() {
t.Fatal("expiresAt is zero, want timed feedback")
}
})
}
}
func TestStatusErrorf(t *testing.T) {
var status statusMsg
status.Errorf(statusTTLShort, "Load failed: %s", "offline")
if status.text != "Load failed: offline" {
t.Fatalf("text = %q, want %q", status.text, "Load failed: offline")
}
if status.kind != feedbackError {
t.Fatalf("kind = %v, want %v", status.kind, feedbackError)
}
if status.expiresAt.IsZero() {
t.Fatal("expiresAt is zero, want timed error")
}
}
+4 -5
View File
@@ -30,7 +30,7 @@ var (
Bold(true) Bold(true)
feedbackWarningStyle = lipgloss.NewStyle(). feedbackWarningStyle = lipgloss.NewStyle().
Foreground(ui.ColorAccent). Foreground(ui.ColorWarning).
Bold(true) Bold(true)
dimStyle = lipgloss.NewStyle(). dimStyle = lipgloss.NewStyle().
@@ -59,8 +59,7 @@ var (
Bold(true) Bold(true)
playlistUnavailableStyle = lipgloss.NewStyle(). playlistUnavailableStyle = lipgloss.NewStyle().
Foreground(ui.ColorDim). Foreground(ui.ColorDim)
Faint(true)
helpStyle = lipgloss.NewStyle(). helpStyle = lipgloss.NewStyle().
Foreground(ui.ColorDim) Foreground(ui.ColorDim)
@@ -82,7 +81,7 @@ func rebuildModelStyles() {
statusStyle = lipgloss.NewStyle().Foreground(ui.ColorPlaying).Bold(true) statusStyle = lipgloss.NewStyle().Foreground(ui.ColorPlaying).Bold(true)
feedbackActivityStyle = lipgloss.NewStyle().Foreground(ui.ColorDim) feedbackActivityStyle = lipgloss.NewStyle().Foreground(ui.ColorDim)
feedbackSuccessStyle = lipgloss.NewStyle().Foreground(ui.ColorPlaying).Bold(true) feedbackSuccessStyle = lipgloss.NewStyle().Foreground(ui.ColorPlaying).Bold(true)
feedbackWarningStyle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true) feedbackWarningStyle = lipgloss.NewStyle().Foreground(ui.ColorWarning).Bold(true)
dimStyle = lipgloss.NewStyle().Foreground(ui.ColorDim) dimStyle = lipgloss.NewStyle().Foreground(ui.ColorDim)
labelStyle = lipgloss.NewStyle().Foreground(ui.ColorText).Bold(true) labelStyle = lipgloss.NewStyle().Foreground(ui.ColorText).Bold(true)
eqActiveStyle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true) eqActiveStyle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true)
@@ -90,7 +89,7 @@ func rebuildModelStyles() {
playlistActiveStyle = lipgloss.NewStyle().Foreground(ui.ColorPlaying).Bold(true) playlistActiveStyle = lipgloss.NewStyle().Foreground(ui.ColorPlaying).Bold(true)
playlistItemStyle = lipgloss.NewStyle().Foreground(ui.ColorText) playlistItemStyle = lipgloss.NewStyle().Foreground(ui.ColorText)
playlistSelectedStyle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true) playlistSelectedStyle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true)
playlistUnavailableStyle = lipgloss.NewStyle().Foreground(ui.ColorDim).Faint(true) playlistUnavailableStyle = lipgloss.NewStyle().Foreground(ui.ColorDim)
helpStyle = lipgloss.NewStyle().Foreground(ui.ColorDim) helpStyle = lipgloss.NewStyle().Foreground(ui.ColorDim)
helpKeyStyle = lipgloss.NewStyle().Foreground(ui.ColorKeyFG).Background(ui.ColorKeyBG).Bold(true) helpKeyStyle = lipgloss.NewStyle().Foreground(ui.ColorKeyFG).Background(ui.ColorKeyBG).Bold(true)
errorStyle = lipgloss.NewStyle().Foreground(ui.ColorError) errorStyle = lipgloss.NewStyle().Foreground(ui.ColorError)
+22 -22
View File
@@ -393,7 +393,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.navBrowser.loading = false m.navBrowser.loading = false
if msg.err != nil { if msg.err != nil {
m.status.Showf(statusTTLDefault, "Artist load failed: %s", msg.err) m.status.Errorf(statusTTLDefault, "Artist load failed: %s", msg.err)
return m, nil return m, nil
} }
m.navBrowser.artists = msg.artists m.navBrowser.artists = msg.artists
@@ -408,7 +408,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.navBrowser.albumLoading = false m.navBrowser.albumLoading = false
m.navBrowser.loading = false m.navBrowser.loading = false
if msg.err != nil { if msg.err != nil {
m.status.Showf(statusTTLDefault, "Album load failed: %s", msg.err) m.status.Errorf(statusTTLDefault, "Album load failed: %s", msg.err)
return m, nil return m, nil
} }
if msg.offset == 0 { if msg.offset == 0 {
@@ -436,7 +436,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.navBrowser.loading = false m.navBrowser.loading = false
if msg.err != nil { if msg.err != nil {
m.status.Showf(statusTTLDefault, "Track load failed: %s", msg.err) m.status.Errorf(statusTTLDefault, "Track load failed: %s", msg.err)
return m, nil return m, nil
} }
m.navBrowser.tracks = msg.tracks m.navBrowser.tracks = msg.tracks
@@ -453,7 +453,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.catalogBatch.loading = false m.catalogBatch.loading = false
if msg.err != nil { if msg.err != nil {
m.catalogBatch.done = true m.catalogBatch.done = true
m.status.Show("Catalog load failed", statusTTLDefault) m.status.Error("Catalog load failed", statusTTLDefault)
return m, nil return m, nil
} }
if msg.added == 0 { if msg.added == 0 {
@@ -475,7 +475,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.provLoading = false m.provLoading = false
if msg.err != nil { if msg.err != nil {
m.status.Show("Search failed", statusTTLDefault) m.status.Error("Search failed", statusTTLDefault)
} else { } else {
if lists, err := m.provider.Playlists(); err == nil { if lists, err := m.provider.Playlists(); err == nil {
m.providerLists = lists m.providerLists = lists
@@ -483,7 +483,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.provCursor = 0 m.provCursor = 0
m.provScroll = 0 m.provScroll = 0
if msg.count == 0 { if msg.count == 0 {
m.status.Show("No stations found", statusTTLDefault) m.status.Warning("No stations found", statusTTLDefault)
} }
} }
return m, nil return m, nil
@@ -496,7 +496,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.ytdlBatch.loading = false m.ytdlBatch.loading = false
if msg.err != nil { if msg.err != nil {
m.ytdlBatch.done = true m.ytdlBatch.done = true
m.status.Showf(statusTTLBatch, "Radio batch load failed: %v", msg.err) m.status.Errorf(statusTTLBatch, "Radio batch load failed: %v", msg.err)
return m, nil return m, nil
} }
if len(msg.tracks) == 0 { if len(msg.tracks) == 0 {
@@ -518,7 +518,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case feedTrackResolvedMsg: case feedTrackResolvedMsg:
m.feedLoading = false m.feedLoading = false
if len(msg.tracks) == 0 { if len(msg.tracks) == 0 {
m.status.Show("No episodes found in feed.", statusTTLDefault) m.status.Warning("No episodes found in feed.", statusTTLDefault)
return m, nil return m, nil
} }
m.playlist.Replace(msg.tracks) m.playlist.Replace(msg.tracks)
@@ -541,7 +541,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.addToHeaderState(msg.tracks) m.addToHeaderState(msg.tracks)
m.status.Showf(statusTTLDefault, "Loaded %d track(s)", len(msg.tracks)) m.status.Showf(statusTTLDefault, "Loaded %d track(s)", len(msg.tracks))
} else { } else {
m.status.Show("No tracks found at URL.", statusTTLDefault) m.status.Warning("No tracks found at URL.", statusTTLDefault)
} }
if len(msg.tracks) > 0 { if len(msg.tracks) > 0 {
// Set up incremental loading for YouTube Radio playlists. // Set up incremental loading for YouTube Radio playlists.
@@ -597,17 +597,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case fbTracksResolvedMsg: case fbTracksResolvedMsg:
if len(msg.tracks) == 0 { if len(msg.tracks) == 0 {
m.status.Show("No audio files found", statusTTLDefault) m.status.Warning("No audio files found", statusTTLDefault)
return m, nil return m, nil
} }
if msg.targetPlaylist != "" { if msg.targetPlaylist != "" {
added, skipped, err := m.writeTracksToPlaylist(msg.targetPlaylist, msg.tracks) added, skipped, err := m.writeTracksToPlaylist(msg.targetPlaylist, msg.tracks)
if err != nil { if err != nil {
m.status.Showf(statusTTLDefault, "Add failed: %s", err) m.status.Errorf(statusTTLDefault, "Add failed: %s", err)
} else if skipped > 0 { } else if skipped > 0 {
m.status.Showf(statusTTLBatch, "Added %d to %q, skipped %d duplicates", added, msg.targetPlaylist, skipped) m.status.Warningf(statusTTLBatch, "Added %d to %q, skipped %d duplicates", added, msg.targetPlaylist, skipped)
} else { } else if added > 0 {
m.status.Showf(statusTTLDefault, "Added %d to %q", added, msg.targetPlaylist) m.status.Showf(statusTTLDefault, "Added %d to %q", added, msg.targetPlaylist)
} else {
m.status.Warningf(statusTTLDefault, "Nothing added to %q", msg.targetPlaylist)
} }
m.refreshPlaylistManagerAfterWrite(msg.targetPlaylist) m.refreshPlaylistManagerAfterWrite(msg.targetPlaylist)
return m, nil return m, nil
@@ -657,7 +659,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.err != nil { if msg.err != nil {
m.err = msg.err m.err = msg.err
if track, idx := m.currentPlaybackTrack(); idx >= 0 { if track, idx := m.currentPlaybackTrack(); idx >= 0 {
m.status.Showf(statusTTLLong, "Couldn't play %s — track is gated, restricted, or unavailable.", track.DisplayName()) m.status.Errorf(statusTTLLong, "Couldn't play %s — track is gated, restricted, or unavailable.", track.DisplayName())
} }
} else { } else {
m.err = nil m.err = nil
@@ -678,7 +680,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case ytdlSavedMsg: case ytdlSavedMsg:
m.save.finishDownload() m.save.finishDownload()
if msg.err != nil { if msg.err != nil {
m.status.Showf(statusTTLMedium, "Download failed: %s", msg.err) m.status.Errorf(statusTTLMedium, "Download failed: %s", msg.err)
} else { } else {
m.status.Showf(statusTTLMedium, "Saved to %s", msg.path) m.status.Showf(statusTTLMedium, "Saved to %s", msg.path)
} }
@@ -725,9 +727,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spotSearch.results = msg.tracks m.spotSearch.results = msg.tracks
m.spotSearch.cursor = 0 m.spotSearch.cursor = 0
m.spotSearch.screen = spotSearchResults m.spotSearch.screen = spotSearchResults
if len(msg.tracks) == 0 {
m.spotSearch.err = "No results found"
}
m.applyHeightMode() m.applyHeightMode()
m.clampActiveScrollState() m.clampActiveScrollState()
return m, nil return m, nil
@@ -803,7 +802,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case devicesListedMsg: case devicesListedMsg:
m.devicePicker.loading = false m.devicePicker.loading = false
if msg.err != nil { if msg.err != nil {
m.status.Showf(statusTTLDefault, "Device list failed: %s", msg.err) m.status.Errorf(statusTTLDefault, "Device list failed: %s", msg.err)
m.devicePicker.visible = false m.devicePicker.visible = false
} else { } else {
m.devicePicker.devices = msg.devices m.devicePicker.devices = msg.devices
@@ -812,7 +811,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case deviceSwitchedMsg: case deviceSwitchedMsg:
if msg.err != nil { if msg.err != nil {
m.status.Showf(statusTTLDefault, "Switch failed: %s", msg.err) m.status.Errorf(statusTTLDefault, "Switch failed: %s", msg.err)
} else { } else {
m.status.Showf(statusTTLDefault, "Audio output: %s", msg.name) m.status.Showf(statusTTLDefault, "Audio output: %s", msg.name)
_ = m.configSaver.Save("audio_device", msg.name) _ = m.configSaver.Save("audio_device", msg.name)
@@ -1020,7 +1019,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
shuffled := m.playlist.Shuffled() shuffled := m.playlist.Shuffled()
if err := m.configSaver.Save("shuffle", fmt.Sprintf("%v", shuffled)); err != nil { if err := m.configSaver.Save("shuffle", fmt.Sprintf("%v", shuffled)); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err) m.status.Errorf(statusTTLDefault, "Config save failed: %s", err)
} }
m.player.ClearPreload() m.player.ClearPreload()
cmd := m.preloadNext() cmd := m.preloadNext()
@@ -1042,7 +1041,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
mode := m.playlist.Repeat() mode := m.playlist.Repeat()
if err := m.configSaver.Save("repeat", fmt.Sprintf("%q", mode.String())); err != nil { if err := m.configSaver.Save("repeat", fmt.Sprintf("%q", mode.String())); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err) m.status.Errorf(statusTTLDefault, "Config save failed: %s", err)
} }
m.player.ClearPreload() m.player.ClearPreload()
cmd := m.preloadNext() cmd := m.preloadNext()
@@ -1174,6 +1173,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
t := m.themes[m.themeIdx] t := m.themes[m.themeIdx]
resp.Theme = &ipc.ThemeInfo{ resp.Theme = &ipc.ThemeInfo{
Name: t.Name, Name: t.Name,
BG: t.BG,
Accent: t.Accent, Accent: t.Accent,
Fg: t.FG, Fg: t.FG,
BrightFg: t.BrightFG, BrightFg: t.BrightFG,
+12 -1
View File
@@ -119,6 +119,10 @@ func (m Model) View() tea.View {
if m.layout.tooSmall() { if m.layout.tooSmall() {
content := fmt.Sprintf("Terminal too small. Resize to at least 40x10 (current: %dx%d).", m.width, m.height) content := fmt.Sprintf("Terminal too small. Resize to at least 40x10 (current: %dx%d).", m.width, m.height)
view := tea.NewView(ui.FitRect(content, max(1, m.width), max(1, m.height))) view := tea.NewView(ui.FitRect(content, max(1, m.width), max(1, m.height)))
view.BackgroundColor = ui.ColorBackground
if ui.ColorBackground != nil {
view.ForegroundColor = ui.ColorText
}
view.AltScreen = true view.AltScreen = true
return view return view
} }
@@ -148,6 +152,10 @@ func (m Model) View() tea.View {
rendered = ui.FitRect(rendered, m.layout.frameWidth, max(1, m.height)) rendered = ui.FitRect(rendered, m.layout.frameWidth, max(1, m.height))
view := tea.NewView(rendered) view := tea.NewView(rendered)
view.BackgroundColor = ui.ColorBackground
if ui.ColorBackground != nil {
view.ForegroundColor = ui.ColorText
}
view.AltScreen = true view.AltScreen = true
view.WindowTitle = currentTerminalTitle(m.termTitle, m.width, m.terminalTitleValues()) view.WindowTitle = currentTerminalTitle(m.termTitle, m.width, m.terminalTitleValues())
return view return view
@@ -247,16 +255,19 @@ func (m Model) renderTransient() string {
return ui.FitRect(feedbackActivityStyle.Render(text), m.layout.panelWidth, 1) return ui.FitRect(feedbackActivityStyle.Render(text), m.layout.panelWidth, 1)
} }
if m.status.text != "" { if m.status.text != "" {
text := m.status.text
style := feedbackSuccessStyle style := feedbackSuccessStyle
switch m.status.kind { switch m.status.kind {
case feedbackActivity: case feedbackActivity:
style = feedbackActivityStyle style = feedbackActivityStyle
case feedbackWarning: case feedbackWarning:
style = feedbackWarningStyle style = feedbackWarningStyle
text = "WARN: " + text
case feedbackError: case feedbackError:
style = errorStyle style = errorStyle
text = "ERR: " + text
} }
return ui.FitRect(style.Render(m.status.text), m.layout.panelWidth, 1) return ui.FitRect(style.Render(text), m.layout.panelWidth, 1)
} }
if n := len(m.logLines); n > 0 { if n := len(m.logLines); n > 0 {
return ui.FitRect(dimStyle.Render(m.logLines[n-1].text), m.layout.panelWidth, 1) return ui.FitRect(dimStyle.Render(m.logLines[n-1].text), m.layout.panelWidth, 1)
+40
View File
@@ -9,6 +9,7 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/bjarneo/cliamp/playlist" "github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/theme"
"github.com/bjarneo/cliamp/ui" "github.com/bjarneo/cliamp/ui"
) )
@@ -59,6 +60,45 @@ func TestMainViewShrinksPlaylistForFooterMessages(t *testing.T) {
} }
} }
func TestViewAppliesThemeBackground(t *testing.T) {
applyThemeAll(theme.Theme{
Name: "test",
BG: "#112233",
Accent: "#88aacc",
BrightFG: "#ffffff",
FG: "#aabbcc",
Green: "#88cc88",
Yellow: "#ddcc77",
Red: "#ee8888",
})
t.Cleanup(func() { applyThemeAll(theme.Default()) })
view := (Model{width: 20, height: 5}).View()
if view.BackgroundColor == nil {
t.Fatal("BackgroundColor is nil for a theme with bg")
}
if view.ForegroundColor == nil {
t.Fatal("ForegroundColor is nil for a theme with bg")
}
r, g, b, _ := view.BackgroundColor.RGBA()
if r>>8 != 0x11 || g>>8 != 0x22 || b>>8 != 0x33 {
t.Fatalf("BackgroundColor = #%02x%02x%02x, want #112233", r>>8, g>>8, b>>8)
}
}
func TestRenderTransientIncludesNonColorSeverityLabels(t *testing.T) {
m := Model{layout: frameLayout{panelWidth: 80}}
m.status.Warning("Unavailable", statusTTLShort)
if got := stripAnsi(m.renderTransient()); !strings.Contains(got, "WARN: Unavailable") {
t.Fatalf("warning feedback = %q, want WARN label", got)
}
m.status.Error("Load failed", statusTTLShort)
if got := stripAnsi(m.renderTransient()); !strings.Contains(got, "ERR: Load failed") {
t.Fatalf("error feedback = %q, want ERR label", got)
}
}
func TestRenderPlaylistKeepsCursorVisibleWhenFooterShrinksBudget(t *testing.T) { func TestRenderPlaylistKeepsCursorVisibleWhenFooterShrinksBudget(t *testing.T) {
if sharedPlayer == nil { if sharedPlayer == nil {
t.Skip("audio hardware unavailable") t.Skip("audio hardware unavailable")
+44 -11
View File
@@ -2,6 +2,9 @@ package ui
import ( import (
"image/color" "image/color"
"math"
"strconv"
"strings"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
@@ -11,16 +14,18 @@ import (
// CLIAMP color palette using standard ANSI terminal colors (0-15). // CLIAMP color palette using standard ANSI terminal colors (0-15).
// These adapt to the user's terminal theme for consistent appearance. // These adapt to the user's terminal theme for consistent appearance.
var ( var (
ColorTitle color.Color = lipgloss.ANSIColor(10) // bright green ColorBackground color.Color
ColorText color.Color = lipgloss.ANSIColor(15) // bright white ColorTitle color.Color = lipgloss.ANSIColor(10) // bright green
ColorDim color.Color = lipgloss.ANSIColor(7) // white (light gray) ColorText color.Color = lipgloss.ANSIColor(15) // bright white
ColorAccent color.Color = lipgloss.ANSIColor(11) // bright yellow ColorDim color.Color = lipgloss.ANSIColor(7) // white (light gray)
ColorPlaying color.Color = lipgloss.ANSIColor(10) // bright green ColorAccent color.Color = lipgloss.ANSIColor(11) // bright yellow
ColorSeekBar color.Color = lipgloss.ANSIColor(11) // bright yellow ColorPlaying color.Color = lipgloss.ANSIColor(10) // bright green
ColorVolume color.Color = lipgloss.ANSIColor(2) // green ColorSeekBar color.Color = lipgloss.ANSIColor(11) // bright yellow
ColorError color.Color = lipgloss.ANSIColor(9) // bright red ColorVolume color.Color = lipgloss.ANSIColor(2) // green
ColorKeyBG color.Color = lipgloss.ANSIColor(8) // bright black (dark gray) ColorError color.Color = lipgloss.ANSIColor(9) // bright red
ColorKeyFG color.Color = lipgloss.ANSIColor(15) // bright white ColorWarning color.Color = lipgloss.ANSIColor(11) // bright yellow
ColorKeyBG color.Color = lipgloss.ANSIColor(8) // bright black (dark gray)
ColorKeyFG color.Color = lipgloss.ANSIColor(15) // bright white
// Spectrum gradient: green -> yellow -> red // Spectrum gradient: green -> yellow -> red
SpectrumLow color.Color = lipgloss.ANSIColor(10) // bright green SpectrumLow color.Color = lipgloss.ANSIColor(10) // bright green
@@ -60,6 +65,7 @@ var FrameStyle = lipgloss.NewStyle().
// If the theme is the default (empty hex values), ANSI fallback colors are restored. // If the theme is the default (empty hex values), ANSI fallback colors are restored.
func ApplyThemeColors(t theme.Theme) { func ApplyThemeColors(t theme.Theme) {
if t.IsDefault() { if t.IsDefault() {
ColorBackground = nil
ColorTitle = lipgloss.ANSIColor(10) ColorTitle = lipgloss.ANSIColor(10)
ColorText = lipgloss.ANSIColor(15) ColorText = lipgloss.ANSIColor(15)
ColorDim = lipgloss.ANSIColor(7) ColorDim = lipgloss.ANSIColor(7)
@@ -68,12 +74,18 @@ func ApplyThemeColors(t theme.Theme) {
ColorSeekBar = lipgloss.ANSIColor(11) ColorSeekBar = lipgloss.ANSIColor(11)
ColorVolume = lipgloss.ANSIColor(2) ColorVolume = lipgloss.ANSIColor(2)
ColorError = lipgloss.ANSIColor(9) ColorError = lipgloss.ANSIColor(9)
ColorWarning = lipgloss.ANSIColor(11)
ColorKeyBG = lipgloss.ANSIColor(8) ColorKeyBG = lipgloss.ANSIColor(8)
ColorKeyFG = lipgloss.ANSIColor(15) ColorKeyFG = lipgloss.ANSIColor(15)
SpectrumLow = lipgloss.ANSIColor(10) SpectrumLow = lipgloss.ANSIColor(10)
SpectrumMid = lipgloss.ANSIColor(11) SpectrumMid = lipgloss.ANSIColor(11)
SpectrumHigh = lipgloss.ANSIColor(9) SpectrumHigh = lipgloss.ANSIColor(9)
} else { } else {
if t.BG == "" {
ColorBackground = nil
} else {
ColorBackground = lipgloss.Color(t.BG)
}
ColorTitle = lipgloss.Color(t.Accent) ColorTitle = lipgloss.Color(t.Accent)
ColorText = lipgloss.Color(t.BrightFG) ColorText = lipgloss.Color(t.BrightFG)
ColorDim = lipgloss.Color(t.FG) ColorDim = lipgloss.Color(t.FG)
@@ -82,8 +94,9 @@ func ApplyThemeColors(t theme.Theme) {
ColorSeekBar = lipgloss.Color(t.Accent) ColorSeekBar = lipgloss.Color(t.Accent)
ColorVolume = lipgloss.Color(t.Green) ColorVolume = lipgloss.Color(t.Green)
ColorError = lipgloss.Color(t.Red) ColorError = lipgloss.Color(t.Red)
ColorWarning = lipgloss.Color(t.Yellow)
ColorKeyBG = lipgloss.Color(t.Accent) ColorKeyBG = lipgloss.Color(t.Accent)
ColorKeyFG = lipgloss.Color(t.BrightFG) ColorKeyFG = lipgloss.Color(contrastingTextColor(t.Accent))
SpectrumLow = lipgloss.Color(t.Green) SpectrumLow = lipgloss.Color(t.Green)
SpectrumMid = lipgloss.Color(t.Yellow) SpectrumMid = lipgloss.Color(t.Yellow)
SpectrumHigh = lipgloss.Color(t.Red) SpectrumHigh = lipgloss.Color(t.Red)
@@ -95,3 +108,23 @@ func ApplyThemeColors(t theme.Theme) {
specHighStyle = lipgloss.NewStyle().Foreground(SpectrumHigh) specHighStyle = lipgloss.NewStyle().Foreground(SpectrumHigh)
refreshSpecANSI() refreshSpecANSI()
} }
func contrastingTextColor(hex string) string {
value, err := strconv.ParseUint(strings.TrimPrefix(hex, "#"), 16, 24)
if err != nil {
return "#ffffff"
}
linear := func(channel uint64) float64 {
component := float64(channel) / 255
if component <= 0.04045 {
return component / 12.92
}
return math.Pow((component+0.055)/1.055, 2.4)
}
luminance := 0.2126*linear(value>>16) + 0.7152*linear((value>>8)&0xff) + 0.0722*linear(value&0xff)
// This is the crossover where black provides more contrast than white.
if luminance > 0.179 {
return "#000000"
}
return "#ffffff"
}
+23
View File
@@ -0,0 +1,23 @@
package ui
import "testing"
func TestContrastingTextColor(t *testing.T) {
tests := []struct {
name string
accent string
want string
}{
{name: "light accent", accent: "#f7df50", want: "#000000"},
{name: "dark accent", accent: "#3e4a5e", want: "#ffffff"},
{name: "invalid accent", accent: "blue", want: "#ffffff"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := contrastingTextColor(tt.accent); got != tt.want {
t.Errorf("contrastingTextColor(%q) = %q, want %q", tt.accent, got, tt.want)
}
})
}
}