fix: make built-in themes accessible
This commit is contained in:
+20
-16
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 & 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 & 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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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":
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
|
bg = "#1f2430"
|
||||||
accent = "#73d0ff"
|
accent = "#73d0ff"
|
||||||
bright_fg = "#f3f4f5"
|
bright_fg = "#f3f4f5"
|
||||||
fg = "#cccac2"
|
fg = "#cccac2"
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
|
bg = "#1e1e2e"
|
||||||
accent = "#89b4fa"
|
accent = "#89b4fa"
|
||||||
bright_fg = "#cdd6f4"
|
bright_fg = "#cdd6f4"
|
||||||
fg = "#9399b2"
|
fg = "#9399b2"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
|
bg = "#060b1e"
|
||||||
accent = "#7d82d9"
|
accent = "#7d82d9"
|
||||||
bright_fg = "#ffcead"
|
bright_fg = "#ffcead"
|
||||||
fg = "#9a96a8"
|
fg = "#9a96a8"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
|
bg = "#282828"
|
||||||
accent = "#7daea3"
|
accent = "#7daea3"
|
||||||
bright_fg = "#d4be98"
|
bright_fg = "#d4be98"
|
||||||
fg = "#a89984"
|
fg = "#a89984"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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 != "" {
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user