feat(ui): prioritize browse content

This commit is contained in:
Bjarne Øverli
2026-07-21 22:46:40 +02:00
parent 336b6a6d29
commit 605c80da4d
21 changed files with 988 additions and 164 deletions
+8 -2
View File
@@ -62,7 +62,7 @@ eq_preset = "Flat"
eq = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
# Visualizer mode (leave empty for default Bars)
# Options: Bars, BarsDot, Rain, BarsOutline, Bricks, Columns, ClassicPeak, Wave, Scatter, Flame, Retro, Pulse, Matrix, Binary, Sakura, Firework, Bubbles, Logo, Terrain, Scope, Heartbeat, Butterfly, Ascii, Firefly, Mosaic, Sand, Geyser, ClassicLED, None
# Options: Bars, BarsDot, Rain, BarsOutline, Bricks, Columns, ClassicPeak, ClassicPeakBricks, Wave, Scatter, Flame, Retro, Pulse, Matrix, Binary, Sakura, Firework, Bubbles, Logo, Terrain, Scope, Heartbeat, Butterfly, Ascii, Firefly, Mosaic, Sand, Geyser, ClassicLED, None
visualizer = "Bars"
# Visualizer volume linking (default: true)
@@ -93,7 +93,7 @@ cliamp adapts its playback screen to the available terminal rectangle:
| Terminal size | Layout |
| --- | --- |
| At least `80x24` | Full controls, five-row visualizer, and detailed source controls |
| At least `80x24` | Full controls, four-row visualizer, and detailed source controls |
| At least `56x16` | Compact controls and a two-row visualizer |
| At least `40x10` | Minimal playback, list, seek bar, and help layout |
| Smaller than `40x10` | A resize message only |
@@ -101,6 +101,12 @@ cliamp adapts its playback screen to the available terminal rectangle:
`compact = true` caps the frame at 80 columns on wide terminals. It does not
change the minimum supported terminal size.
List-heavy views such as provider browsing, file selection, queues, playlists,
search results, themes, and keybindings use a content-first layout. It replaces
the visualizer and detailed controls with a compact now-playing summary so more
rows remain available for navigation. The visualizer picker keeps its live
preview instead.
## Secrets from Environment Variables
Any string value in `config.toml` can be read from an environment variable by setting the value to `$VAR_NAME` or `${VAR_NAME}`. This keeps passwords, tokens, and client secrets out of the file itself.
+17 -6
View File
@@ -1,6 +1,8 @@
# Keybindings
Press `Ctrl+K` from any mode, or `?` from the player, to see all keybindings.
Press `Ctrl+K` from any mode, or `?` from the player, to see keybindings. The
keymap starts with actions for the screen you opened it from, followed by player
and library commands.
## Playback
@@ -22,7 +24,7 @@ Press `Ctrl+K` from any mode, or `?` from the player, to see all keybindings.
| Key | Action |
|---|---|
| `Tab` | Toggle focus (Playlist / EQ) |
| `Tab` | Cycle visible controls (Playlist / EQ / Source / Speed on full and compact layouts) |
| `j` `k` / `Up` `Down` | Playlist scroll / EQ band adjust (wraps around) |
| `PageUp` `PageDown` / `Ctrl+U` `Ctrl+D` | Scroll playlist/file browser by page (outside text input) |
| `Home` `End` / `g` `G` | Go to top/end of playlist/file browser |
@@ -35,6 +37,10 @@ Press `Ctrl+K` from any mode, or `?` from the player, to see all keybindings.
| `o` | Open file browser |
| `b` `Esc` | Back to provider |
At the minimal `40x10` layout, `Tab` keeps playback focus on the playlist, so
EQ, source, and speed settings cannot be changed accidentally. `Esc` still
opens the separate, visible provider-list view.
## Text Input
Playlist and native-provider search, URL, playlist-name, keymap, and jump
@@ -59,6 +65,11 @@ fields support these editor keys:
| `V` | Full screen visualizer |
| `Ctrl+H` | Toggle album headers |
Theme and visualizer pickers support `/` filtering. While browsing, arrow keys
preview the highlighted option, `Enter` keeps it, and `Esc` restores the option
active when the picker opened. While typing a filter, `Enter` finishes it and
`Esc` clears it.
## Features
| Key | Action |
@@ -69,9 +80,9 @@ fields support these editor keys:
| `y` | Show or close lyrics |
| `r` | Retry lyrics lookup while lyrics are open |
| `i` | Show track metadata (`↑`/`↓` scrolls) |
| `Ctrl+S` | Save track to ~/Music |
| `Ctrl+S` | Save track to `~/Music/cliamp` |
| `w` | Write the highlighted track to a local playlist |
| `N` | Navidrome browser |
| `N` | Open the active provider browser when available |
| `L` | Browse local playlists (with cliamp radio) |
| `R` | Open radio provider |
| `S` | Open Spotify provider |
@@ -147,7 +158,7 @@ When you press `N` to drill into a provider (Navidrome, Plex, Jellyfin, Emby, Sp
| `S` `N` `P` `J` `E` `Y` `C` `M` `Q` `L` | Quick-switch to that provider without going back through the main pane. `R` replaces the queue on the track screen. |
| `Esc` `b` | Walk back one level / close the browser |
The track screen shows a `N tracks · 47:22` subtitle and right-aligned per-track durations when the provider returns them.
The header shows a source breadcrumb such as `Navidrome / Miles Davis / Kind of Blue / Tracks`, so the current provider and drill-down location remain visible. Track rows show right-aligned durations when the provider returns them.
## Provider playlist list
@@ -165,7 +176,7 @@ The playlists pane (visible when focus is on a provider — Spotify, Navidrome,
| `Tab` | Switch focus to EQ |
| `Esc` `b` | Back to the playlist pane |
Playlist rows show `Name · N tracks · 1h 23m` when the provider returns track counts and total duration. The currently loaded playlist is marked with a `▶` prefix. Spotify groups its playlists under section headers (`── library ──`, `── your playlists ──`, `── followed playlists ──`).
Playlist rows show `Name · N tracks · 1h 23m` when the provider returns track counts and total duration. The header identifies the scope as `Provider / Playlists`. The currently loaded playlist is marked with a `▶` prefix. Spotify groups its playlists under section headers (`── library ──`, `── your playlists ──`, `── followed playlists ──`).
## Search results overlays
+2 -9
View File
@@ -579,14 +579,7 @@ footer{border-top:1px solid var(--line);background:var(--ink-2);padding:46px 0 5
<div class="t-row t-src-row">
<span class="t-label">SRC</span>
<span class="t-pill-static">[Radio]</span>
<span class="t-pill-static">[Local]</span>
<span class="t-pill-static">[Navidrome]</span>
<span class="t-pill-static">[Plex]</span>
<span class="t-pill-static">[Jellyfin]</span>
<span class="t-pill-static">[Emby]</span>
<span class="t-pill-static">[Spotify]</span>
<span class="t-pill-static">[YouTube]</span>
<span class="t-pill-static">[YT&nbsp;Music]</span>
<span class="t-dim">1/9</span>
</div>
<div class="t-spacer"></div>
<div class="t-row t-pl-header">
@@ -756,7 +749,7 @@ user_id = "your-account-user-id"</code></pre>
</div>
<div class="features-grid reveal">
<div class="feature"><div class="feature-icon"></div><div class="feature-name">10-Band Equalizer</div><p>Parametric EQ with presets: Rock, Jazz, Pop, Classical, and more.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Themes &amp; Visualizers</div><p>17 built-in themes and multiple visualizer modes. Custom palettes are validated before loading. 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>20 built-in themes and multiple visualizer modes. ClassicPeakBricks stacks separated spectrum blocks under falling caps. Custom palettes are validated before loading. 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, 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">HTTP Streaming</div><p>Play from URLs, internet radio, remote M3U playlists, and HLS (<code>.m3u8</code>) live streams via ffmpeg.</p></div>
+25 -12
View File
@@ -39,6 +39,9 @@ const (
commandModeVisPicker
commandModeDevicePicker
commandModeInfo
commandModeThemePickerFilter
commandModeVisPickerFilter
commandModeProviderSearch
)
const commandModeAny = ^commandMode(0)
@@ -91,7 +94,7 @@ var commandRegistry = []commandSpec{
{Mode: commandModeMain, Keys: []string{"shift+up", "shift+down"}, KeyLabel: "Shift+Up Down", Label: "Move track up/down", Keymap: true},
{Mode: commandModeMain, Keys: []string{"h", "l"}, KeyLabel: "h l", Label: "EQ cursor left/right", Keymap: true},
{Mode: commandModeMain, Keys: []string{"enter"}, KeyLabel: "Enter", Label: "Play selected track", Keymap: true, ContextHelp: true, Primary: true},
{Mode: commandModeMain, Keys: []string{"a"}, KeyLabel: "a", Label: "Toggle queue (play next)", Keymap: true},
{Mode: commandModeMain, Keys: []string{"a"}, KeyLabel: "a", Label: "Toggle queue (play next)", Keymap: true, ContextHelp: true},
{Mode: commandModeMain, Keys: []string{"A"}, KeyLabel: "A", Label: "Queue manager", Keymap: true},
{Mode: commandModeMain, Keys: []string{"x"}, KeyLabel: "x", Label: "Remove selected track from playlist", Destructive: true, Keymap: true},
{Mode: commandModeMain, Keys: []string{"w"}, KeyLabel: "w", Label: "Write selected track/selection to playlist", Keymap: true},
@@ -110,15 +113,15 @@ var commandRegistry = []commandSpec{
{Mode: commandModeMain, Keys: []string{"ctrl+j"}, KeyLabel: "Ctrl+J", Label: "Jump to time", Keymap: true},
{Mode: commandModeMain, Keys: []string{"p"}, KeyLabel: "p", Label: "Playlist manager", Keymap: true},
{Mode: commandModeMain, Keys: []string{"ctrl+h"}, KeyLabel: "Ctrl+H", Label: "Toggle album headers", Keymap: true},
{Mode: commandModeMain, Keys: []string{"i"}, KeyLabel: "i", Label: "Track info / metadata", Keymap: true},
{Mode: commandModeMain, Keys: []string{"ctrl+s"}, KeyLabel: "Ctrl+S", Label: "Save/download track to ~/Music", Keymap: true},
{Mode: commandModeMain, Keys: []string{"i"}, KeyLabel: "i", Label: "Track info / metadata", Keymap: true, ContextHelp: true},
{Mode: commandModeMain, Keys: []string{"ctrl+s"}, KeyLabel: "Ctrl+S", Label: "Save/download track to ~/Music/cliamp", Keymap: true},
{Mode: commandModeMain, Keys: []string{"ctrl+x"}, KeyLabel: "Ctrl+X", Label: "Expand/collapse view", Keymap: true},
{Mode: commandModeMain, Keys: []string{"ctrl+x"}, KeyLabel: "Ctrl+X", Label: "Expand", ContextHelp: true, Enabled: func(m Model) bool {
{Mode: commandModeMain, Keys: []string{"ctrl+x"}, KeyLabel: "Ctrl+X", Label: "Expand", Enabled: func(m Model) bool {
return !m.heightExpanded && m.layout.bodyRows > m.plVisible
}},
{Mode: commandModeMain, Keys: []string{"/"}, KeyLabel: "/", Label: "Filter/search list", Keymap: true},
{Mode: commandModeMain, Keys: []string{"/"}, KeyLabel: "/", Label: "Filter/search list", Keymap: true, ContextHelp: true},
{Mode: commandModeMain, Keys: []string{"f"}, KeyLabel: "f", Label: "Toggle bookmark/favorite", Keymap: true},
{Mode: commandModeMain, Keys: []string{"ctrl+f"}, KeyLabel: "Ctrl+F", Label: "Search active provider or YouTube", Keymap: true},
{Mode: commandModeMain, Keys: []string{"ctrl+f"}, KeyLabel: "Ctrl+F", Label: "Search active provider or YouTube", Keymap: true, ContextHelp: true},
{Mode: commandModeMain, Keys: []string{"u"}, KeyLabel: "u", Label: "Load URL (stream/playlist)", Keymap: true},
{Mode: commandModeMain, Keys: []string{"d"}, KeyLabel: "d", Label: "Audio device picker", Keymap: true},
{Mode: commandModeMain, Keys: []string{"y"}, KeyLabel: "y", Label: "Show lyrics", Keymap: true},
@@ -132,7 +135,7 @@ var commandRegistry = []commandSpec{
// Shared text editing is reserved even though these are intentionally absent
// from the global keymap, where they would be misleading outside a field.
{Mode: commandModeKeymapSearch | commandModeFileBrowserSearch | commandModeNavSearch | commandModePlaylistManagerInput | commandModePlaylistPickerInput | commandModeSearch | commandModeNetSearch | commandModeSpotSearch | commandModeJump | commandModeURL, Keys: []string{"left", "right", "home", "end", "ctrl+a", "ctrl+e", "backspace", "delete", "ctrl+w", "ctrl+u"}, KeyLabel: "Text editor", Label: "Move cursor and delete text"},
{Mode: commandModeKeymapSearch | commandModeFileBrowserSearch | commandModeNavSearch | commandModePlaylistManagerInput | commandModePlaylistPickerInput | commandModeSearch | commandModeNetSearch | commandModeSpotSearch | commandModeJump | commandModeURL | commandModeThemePickerFilter | commandModeVisPickerFilter | commandModeProviderSearch, Keys: []string{"left", "right", "home", "end", "ctrl+a", "ctrl+e", "backspace", "delete", "ctrl+w", "ctrl+u"}, KeyLabel: "Text editor", Label: "Move cursor and delete text"},
{Mode: commandModeProvider, Keys: []string{"enter"}, KeyLabel: "Enter", Label: "Load", ContextHelp: true, Primary: true},
{Mode: commandModeProvider, Keys: []string{"esc", "backspace", "b"}, KeyLabel: "Esc", Label: "Back", ContextHelp: true, Cancel: true},
@@ -140,12 +143,22 @@ var commandRegistry = []commandSpec{
{Mode: commandModeSpeed, Keys: []string{"left", "right"}, KeyLabel: "Left Right", Label: "Speed", ContextHelp: true},
{Mode: commandModeProviderPill, Keys: []string{"enter"}, KeyLabel: "Enter", Label: "Open", ContextHelp: true, Primary: true},
{Mode: commandModeProviderPill, Keys: []string{"esc", "backspace"}, KeyLabel: "Esc", Label: "Back", ContextHelp: true, Cancel: true},
{Mode: commandModeKeymap | commandModeFileBrowser | commandModeNavBrowser | commandModePlaylistManager | commandModePlaylistPicker | commandModeQueue | commandModeThemePicker | commandModeVisPicker | commandModeDevicePicker, Keys: []string{"esc"}, KeyLabel: "Esc", Label: "Back", ContextHelp: true, Cancel: true},
{Mode: commandModeKeymapSearch | commandModeFileBrowserSearch | commandModeNavSearch | commandModePlaylistManagerInput | commandModePlaylistPickerInput | commandModeSearch | commandModeNetSearch | commandModeSpotSearch | commandModeJump | commandModeURL, Keys: []string{"esc"}, KeyLabel: "Esc", Label: "Cancel", ContextHelp: true, Cancel: true},
{Mode: commandModeKeymapSearch | commandModeFileBrowserSearch | commandModeNavSearch | commandModePlaylistManagerInput | commandModePlaylistPickerInput | commandModeSearch | commandModeNetSearch | commandModeSpotSearch | commandModeJump | commandModeURL, Keys: []string{"enter"}, KeyLabel: "Enter", Label: "Confirm", ContextHelp: true, Primary: true},
{Mode: commandModeNavBrowser | commandModePlaylistManager | commandModePlaylistPicker | commandModeQueue | commandModeThemePicker | commandModeVisPicker | commandModeDevicePicker, Keys: []string{"up", "down", "k", "j"}, KeyLabel: "Up Down", Label: "Navigate", ContextHelp: true},
{Mode: commandModeFileBrowser | commandModeNavBrowser | commandModePlaylistManager | commandModePlaylistPicker | commandModeThemePicker | commandModeVisPicker | commandModeDevicePicker, Keys: []string{"enter"}, KeyLabel: "Enter", Label: "Select", ContextHelp: true, Primary: true},
{Mode: commandModeKeymap | commandModeFileBrowser | commandModeNavBrowser | commandModePlaylistManager | commandModePlaylistPicker | commandModeQueue | commandModeDevicePicker, Keys: []string{"esc"}, KeyLabel: "Esc", Label: "Back", ContextHelp: true, Cancel: true},
{Mode: commandModeKeymapSearch | commandModeFileBrowserSearch | commandModeNavSearch | commandModePlaylistManagerInput | commandModePlaylistPickerInput | commandModeSearch | commandModeNetSearch | commandModeSpotSearch | commandModeJump | commandModeURL | commandModeProviderSearch, Keys: []string{"esc"}, KeyLabel: "Esc", Label: "Cancel", ContextHelp: true, Cancel: true},
{Mode: commandModeKeymapSearch | commandModeFileBrowserSearch | commandModeNavSearch | commandModePlaylistManagerInput | commandModePlaylistPickerInput | commandModeSearch | commandModeNetSearch | commandModeSpotSearch | commandModeJump | commandModeURL | commandModeProviderSearch, Keys: []string{"enter"}, KeyLabel: "Enter", Label: "Confirm", ContextHelp: true, Primary: true},
{Mode: commandModeNavBrowser | commandModePlaylistManager | commandModePlaylistPicker | commandModeQueue | commandModeDevicePicker | commandModeProviderSearch, Keys: []string{"up", "down", "k", "j"}, KeyLabel: "Up Down", Label: "Navigate", ContextHelp: true},
{Mode: commandModeFileBrowser | commandModeNavBrowser | commandModePlaylistManager | commandModePlaylistPicker | commandModeDevicePicker, Keys: []string{"enter"}, KeyLabel: "Enter", Label: "Select", ContextHelp: true, Primary: true},
{Mode: commandModeKeymap, Keys: []string{"/"}, KeyLabel: "/", Label: "Filter", ContextHelp: true, Primary: true},
{Mode: commandModeThemePicker, Keys: []string{"esc"}, KeyLabel: "Esc", Label: "Cancel preview", ContextHelp: true, Cancel: true},
{Mode: commandModeThemePicker, Keys: []string{"enter"}, KeyLabel: "Enter", Label: "Apply theme", ContextHelp: true, Primary: true},
{Mode: commandModeThemePicker, Keys: []string{"up", "down", "k", "j"}, KeyLabel: "Up Down", Label: "Preview", ContextHelp: true},
{Mode: commandModeThemePicker, Keys: []string{"/"}, KeyLabel: "/", Label: "Filter", ContextHelp: true},
{Mode: commandModeVisPicker, Keys: []string{"esc"}, KeyLabel: "Esc", Label: "Cancel preview", ContextHelp: true, Cancel: true},
{Mode: commandModeVisPicker, Keys: []string{"enter"}, KeyLabel: "Enter", Label: "Save", ContextHelp: true, Primary: true},
{Mode: commandModeVisPicker, Keys: []string{"up", "down", "k", "j"}, KeyLabel: "Up Down", Label: "Preview", ContextHelp: true},
{Mode: commandModeVisPicker, Keys: []string{"/"}, KeyLabel: "/", Label: "Filter", ContextHelp: true},
{Mode: commandModeThemePickerFilter | commandModeVisPickerFilter, Keys: []string{"esc"}, KeyLabel: "Esc", Label: "Cancel filter", ContextHelp: true, Cancel: true},
{Mode: commandModeThemePickerFilter | commandModeVisPickerFilter, Keys: []string{"enter"}, KeyLabel: "Enter", Label: "Finish filter", ContextHelp: true, Primary: true},
{Mode: commandModeQueue, Keys: []string{"d"}, KeyLabel: "d", Label: "Remove", Destructive: true, ContextHelp: true, Primary: true, Enabled: func(m Model) bool { return m.playlist != nil && m.playlist.QueueLen() > 0 }},
{Mode: commandModeQueue, Keys: []string{"c"}, KeyLabel: "c", Label: "Clear", Destructive: true, ContextHelp: true},
{Mode: commandModeFileBrowser, Keys: []string{"R"}, KeyLabel: "R", Label: "Replace queue", Destructive: true, ContextHelp: true},
+21 -6
View File
@@ -136,13 +136,9 @@ func (m Model) activeOverlay() (overlayView, bool) {
case m.navBrowser.visible:
return overlayView{(*Model).navHeaderLine, (*Model).navHelpLine, (*Model).renderNavBody}, true
case m.themePicker.visible:
return overlayView{
func(m *Model) string { return sepHeaderN("Themes", m.themePicker.cursor+1, m.themeCount()) },
(*Model).themePickerHelpLine, (*Model).renderThemeBody}, true
return overlayView{(*Model).themePickerHeaderLine, (*Model).themePickerHelpLine, (*Model).renderThemeBody}, true
case m.visPicker.visible:
return overlayView{
func(m *Model) string { return sepHeaderN("Visualizers", m.visPicker.cursor+1, len(m.visPicker.modes)) },
(*Model).visPickerHelpLine, (*Model).renderVisPickerList}, true
return overlayView{(*Model).visPickerHeaderLine, (*Model).visPickerHelpLine, (*Model).renderVisPickerList}, true
case m.plManager.visible:
return overlayView{(*Model).plMgrHeaderLine, (*Model).plMgrHelpLine, (*Model).renderPlMgrBody}, true
case m.queue.visible:
@@ -195,6 +191,13 @@ func (m Model) searchHeaderLine() string {
func (m Model) themeCount() int { return len(m.themes) + 1 }
func (m Model) themePickerHeaderLine() string {
if m.themePicker.filtering || m.themePicker.filter != "" {
return m.filterCountHeader("theme-picker-filter", m.themePicker.filter, fmt.Sprintf("%d/%d", m.themePickerViewCount(), m.themeCount()))
}
return sepHeaderN("Themes", m.themePicker.cursor+1, m.themePickerViewCount())
}
func (m Model) renderThemeBody() string {
budget := m.effectivePlaylistVisible()
items := make([]string, 0, m.themeCount())
@@ -202,6 +205,18 @@ func (m Model) renderThemeBody() string {
for _, t := range m.themes {
items = append(items, t.Name)
}
if m.themePicker.filter != "" {
filtered := make([]string, 0, len(m.themePicker.filtered))
for _, rawIdx := range m.themePicker.filtered {
if rawIdx >= 0 && rawIdx < len(items) {
filtered = append(filtered, items[rawIdx])
}
}
items = filtered
}
if len(items) == 0 {
return bodyMessage("No matches.", budget)
}
return windowList(items, m.themePicker.cursor, m.themePicker.scroll, budget)
}
+12 -27
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"strings"
"charm.land/lipgloss/v2"
"github.com/bjarneo/cliamp/ui"
)
@@ -46,44 +48,27 @@ func (m Model) navView() navViewKind {
}
}
func (m Model) navTrackBreadcrumb() string {
switch m.navBrowser.mode {
case navBrowseModeByArtist:
return "Artist: " + m.navBrowser.selArtist.Name
case navBrowseModeByAlbum:
return "Album: " + m.navBrowser.selAlbum.Name
case navBrowseModeByArtistAlbum:
return m.navBrowser.selArtist.Name + " / " + m.navBrowser.selAlbum.Name
}
return "Tracks"
}
func (m Model) navHeaderLine() string {
if m.navBrowser.confirmReplace {
return sepHeader("Replace current queue?")
}
if m.navBrowser.searching {
return m.filterPromptHeader("nav-search", m.navBrowser.search)
input := m.textWithCursor("nav-search", m.navBrowser.search)
prefix := " / "
input = truncate(input, max(1, ui.PanelWidth-lipgloss.Width(prefix)-4))
suffix := " / " + input
pathWidth := max(1, ui.PanelWidth-lipgloss.Width(prefix)-lipgloss.Width(suffix))
return playlistSelectedStyle.Render(prefix + truncate(m.navBreadcrumb(), pathWidth) + suffix)
}
switch m.navView() {
case navViewArtists:
return sepHeaderN("Artists", m.navBrowser.cursor+1, len(m.navBrowser.artists))
return sepHeaderN(m.navBreadcrumb(), m.navBrowser.cursor+1, len(m.navBrowser.artists))
case navViewAlbums:
label := "Albums"
if m.navBrowser.mode == navBrowseModeByArtistAlbum {
label = "Albums: " + m.navBrowser.selArtist.Name
} else if s := m.navSortLabel(m.navBrowser.sortType); s != "" {
label += " Sort: " + s
}
return sepHeaderN(label, m.navBrowser.cursor+1, len(m.navBrowser.albums))
return sepHeaderN(m.navBreadcrumb(), m.navBrowser.cursor+1, len(m.navBrowser.albums))
case navViewTracks:
return sepHeaderN(m.navTrackBreadcrumb(), m.navBrowser.cursor+1, len(m.navBrowser.tracks))
return sepHeaderN(m.navBreadcrumb(), m.navBrowser.cursor+1, len(m.navBrowser.tracks))
default:
name := "Browse"
if m.navBrowser.prov != nil {
name = m.navBrowser.prov.Name()
}
return sepHeader(name)
return sepHeader(m.navBreadcrumb())
}
}
+94 -6
View File
@@ -27,18 +27,37 @@ func ReservedKeys() map[string]bool {
return out
}
// buildKeymapEntries returns the core keybindings plus any plugin-registered
// binds that supplied a description. Plugins appear under a divider row.
// Only called when the overlay is opened; the result is cached on keymap.entries
// buildKeymapEntries starts with commands for the screen that opened Ctrl+K,
// then lists global player and library commands. The result is cached on open
// so navigation (which calls keymapCount many times per frame) is allocation-free.
func (m Model) buildKeymapEntries() []keymapEntry {
out := make([]keymapEntry, 0, len(commandRegistry)+4)
out := make([]keymapEntry, 0, len(commandRegistry)+6)
seen := make(map[string]bool)
add := func(command commandSpec) {
id := command.KeyLabel + "\x00" + command.Label
if seen[id] {
return
}
seen[id] = true
out = append(out, keymapEntry{key: command.KeyLabel, action: command.Label})
}
mode, label := m.keymapContext()
if mode != commandModeMain {
out = append(out, keymapEntry{action: "— current: " + label + " —", divider: true})
for _, command := range commandRegistry {
if command.Mode != commandModeAny && (command.Keymap || command.ContextHelp) && command.enabled(m) && command.Mode&mode != 0 {
add(command)
}
}
out = append(out, keymapEntry{action: "— player & library —", divider: true})
}
for _, command := range commandRegistry {
if command.Keymap && command.enabled(m) {
out = append(out, keymapEntry{key: command.KeyLabel, action: command.Label})
add(command)
}
}
if m.luaMgr == nil {
if mode != commandModeMain || m.luaMgr == nil {
return out
}
binds := m.luaMgr.KeyBindings()
@@ -56,6 +75,75 @@ func (m Model) buildKeymapEntries() []keymapEntry {
return out
}
func (m Model) keymapContext() (commandMode, string) {
switch m.activeScreen() {
case screenDevicePicker:
return commandModeDevicePicker, "Audio Device"
case screenPlaylistPicker:
if m.plPicker.screen == plPickerNewName {
return commandModePlaylistPickerInput, "Playlist Name"
}
return commandModePlaylistPicker, "Save to Playlist"
case screenFileBrowser:
if m.fileBrowser.searching {
return commandModeFileBrowserSearch, "File Filter"
}
return commandModeFileBrowser, "Files"
case screenSpotSearch:
return commandModeSpotSearch, "Provider Search"
case screenNavBrowser:
if m.navBrowser.searching {
return commandModeNavSearch, "Browser Filter"
}
return commandModeNavBrowser, "Browse"
case screenThemePicker:
if m.themePicker.filtering {
return commandModeThemePickerFilter, "Theme Filter"
}
return commandModeThemePicker, "Themes"
case screenVisPicker:
if m.visPicker.filtering {
return commandModeVisPickerFilter, "Visualizer Filter"
}
return commandModeVisPicker, "Visualizers"
case screenPlaylistManager:
if m.plManager.screen == plMgrScreenNewName || m.plManager.screen == plMgrScreenRename {
return commandModePlaylistManagerInput, "Playlist Name"
}
return commandModePlaylistManager, "Playlists"
case screenQueue:
return commandModeQueue, "Queue"
case screenInfo:
return commandModeInfo, "Track Info"
case screenSearch:
return commandModeSearch, "Playlist Filter"
case screenNetSearch:
return commandModeNetSearch, "Online Search"
case screenURLInput:
return commandModeURL, "Load URL"
case screenLyrics:
return commandModeLyrics, "Lyrics"
case screenJump:
return commandModeJump, "Jump to Time"
}
switch m.focus {
case focusProvider:
if m.provSearch.active {
return commandModeProviderSearch, "Provider Filter"
}
return commandModeProvider, "Provider"
case focusEQ:
return commandModeEQ, "Equalizer"
case focusSpeed:
return commandModeSpeed, "Speed"
case focusProvPill:
return commandModeProviderPill, "Source"
default:
return commandModeMain, "Playlist"
}
}
func (m *Model) keymapCount() int {
if m.keymap.searching || m.keymap.search != "" {
return len(m.keymap.filtered)
+131 -27
View File
@@ -57,13 +57,9 @@ func (m *Model) handleSpeedKey(msg tea.KeyPressMsg) tea.Cmd {
case "[", "left", "h", "down", "j":
m.changeSpeed(-0.25)
case "tab":
m.focus = focusPlaylist
m.focus = m.nextMainFocus(focusSpeed)
case "esc", "backspace":
if len(m.providers) > 1 {
m.focus = focusProvPill
} else {
m.focus = focusEQ
}
m.focus = m.previousMainFocus(focusSpeed)
case "space":
return m.togglePlayPause()
}
@@ -348,7 +344,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
return m.fetchProviderTracks(m.providerLists[m.provCursor].ID)
}
case "tab":
m.focus = focusEQ
m.focus = m.nextMainFocus(focusPlaylist)
case "esc", "backspace", "b":
// If viewing catalog search results, clear them first.
if cs, ok := m.provider.(provider.CatalogSearcher); ok && cs.IsSearching() {
@@ -441,9 +437,9 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
case "enter":
return m.switchProvider(m.provPillIdx)
case "tab":
m.focus = focusSpeed
m.focus = m.nextMainFocus(focusProvPill)
case "esc", "backspace":
m.focus = focusEQ
m.focus = m.previousMainFocus(focusProvPill)
case "space":
return m.togglePlayPause()
}
@@ -662,22 +658,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
return m.preloadNext()
case "tab":
switch m.focus {
case focusPlaylist:
m.focus = focusEQ
case focusEQ:
if len(m.providers) > 1 {
m.focus = focusProvPill
} else {
m.focus = focusSpeed
}
case focusProvPill:
m.focus = focusSpeed
case focusSpeed:
m.focus = focusPlaylist
default:
m.focus = focusPlaylist
}
m.focus = m.nextMainFocus(m.focus)
case "h":
if m.focus == focusEQ && m.eqCursor > 0 {
@@ -690,6 +671,9 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
}
case "e":
if m.layout.tier == layoutMinimal {
break
}
m.eqPresetIdx++
if m.eqPresetIdx >= len(eqPresets) {
m.eqPresetIdx = 0
@@ -1222,6 +1206,18 @@ func (m *Model) handlePaste(content string) tea.Cmd {
return nil
}
if m.themePicker.visible && m.themePicker.filtering {
m.insertText("theme-picker-filter", &m.themePicker.filter, content)
m.themePickerRecomputeFilter()
return nil
}
if m.visPicker.visible && m.visPicker.filtering {
m.insertText("visualizer-picker-filter", &m.visPicker.filter, content)
m.visPickerRecomputeFilter()
return nil
}
// Provider search can sit above the navigation browser.
if m.spotSearch.visible {
switch m.spotSearch.screen {
@@ -2323,9 +2319,55 @@ func (m *Model) createPlaylistFromManager(name string) bool {
return true
}
func (m *Model) handleThemeFilterKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "ctrl+c":
m.themePickerCancel()
return m.quit()
case "esc":
m.themePicker.filtering = false
m.themePicker.filter = ""
m.themePicker.filtered = nil
m.themePicker.cursor = m.themePicker.savedCursor
m.themePicker.scroll = m.themePicker.savedScroll
return nil
case "enter":
m.themePicker.filtering = false
if m.themePicker.filter == "" {
m.themePicker.cursor = m.themePicker.savedCursor
m.themePicker.scroll = m.themePicker.savedScroll
}
return nil
case "down":
m.themePicker.filtering = false
if m.themePickerViewCount() > 0 {
m.themePicker.cursor = 0
m.themePickerApply()
m.themePickerMaybeAdjustScroll(m.themePickerVisible())
}
return nil
case "backspace":
if m.themePicker.filter == "" {
m.themePicker.filtering = false
m.themePicker.cursor = m.themePicker.savedCursor
m.themePicker.scroll = m.themePicker.savedScroll
return nil
}
}
if m.editText("theme-picker-filter", &m.themePicker.filter, msg) {
m.themePickerRecomputeFilter()
}
return nil
}
// handleThemeKey processes key presses while the theme picker is open.
func (m *Model) handleThemeKey(msg tea.KeyPressMsg) tea.Cmd {
count := len(m.themes) + 1 // +1 for Default
if m.themePicker.filtering {
return m.handleThemeFilterKey(msg)
}
count := m.themePickerViewCount()
switch msg.String() {
case "ctrl+c":
m.themePickerCancel()
@@ -2384,15 +2426,69 @@ func (m *Model) handleThemeKey(msg tea.KeyPressMsg) tea.Cmd {
case "enter":
m.themePickerSelect()
case "/":
m.themePicker.savedCursor = m.themePicker.cursor
m.themePicker.savedScroll = m.themePicker.scroll
m.themePicker.filtering = true
m.themePicker.filter = ""
m.themePickerRecomputeFilter()
return nil
case "esc", "q", "t":
m.themePickerCancel()
}
return nil
}
func (m *Model) handleVisPickerFilterKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "ctrl+c":
m.visPickerCancel()
return m.quit()
case "esc":
m.visPicker.filtering = false
m.visPicker.filter = ""
m.visPicker.filtered = nil
m.visPicker.cursor = m.visPicker.savedCursor
m.visPicker.scroll = m.visPicker.savedScroll
return nil
case "enter":
m.visPicker.filtering = false
if m.visPicker.filter == "" {
m.visPicker.cursor = m.visPicker.savedCursor
m.visPicker.scroll = m.visPicker.savedScroll
}
return nil
case "down":
m.visPicker.filtering = false
if m.visPickerViewCount() > 0 {
m.visPicker.cursor = 0
m.visPickerApply()
m.visPickerMaybeAdjustScroll(m.visPickerVisible())
}
return nil
case "backspace":
if m.visPicker.filter == "" {
m.visPicker.filtering = false
m.visPicker.cursor = m.visPicker.savedCursor
m.visPicker.scroll = m.visPicker.savedScroll
return nil
}
}
if m.editText("visualizer-picker-filter", &m.visPicker.filter, msg) {
m.visPickerRecomputeFilter()
}
return nil
}
// handleVisPickerKey processes key presses while the visualizer picker is open.
func (m *Model) handleVisPickerKey(msg tea.KeyPressMsg) tea.Cmd {
count := len(m.visPicker.modes)
if m.visPicker.filtering {
return m.handleVisPickerFilterKey(msg)
}
count := m.visPickerViewCount()
switch msg.String() {
case "ctrl+c":
m.visPickerCancel()
@@ -2451,6 +2547,14 @@ func (m *Model) handleVisPickerKey(msg tea.KeyPressMsg) tea.Cmd {
case "enter":
m.visPickerSelect()
case "/":
m.visPicker.savedCursor = m.visPicker.cursor
m.visPicker.savedScroll = m.visPicker.scroll
m.visPicker.filtering = true
m.visPicker.filter = ""
m.visPickerRecomputeFilter()
return nil
case "esc", "q", "ctrl+v":
m.visPickerCancel()
}
+24 -2
View File
@@ -66,12 +66,21 @@ func (m *Model) recomputeLayout() {
layout.tier = layoutMinimal
layout.fixedRows = 7
}
contentFirst := m.usesContentFirstLayout()
if contentFirst {
layout.visualizerRows = 0
if layout.tier == layoutMinimal {
layout.fixedRows = 6
} else {
layout.fixedRows = 7
}
}
layout.fullVisualizerRows = max(1, height-6-2*paddingV)
if !layout.tooSmall() {
layout.bodyRows = max(1, height-2*paddingV-layout.fixedRows-layout.footerRows)
limit := maxPlVisible
if m.heightExpanded {
if m.heightExpanded || contentFirst {
limit = maxPlExpandVisible
}
m.plVisible = min(limit, layout.bodyRows)
@@ -85,7 +94,20 @@ func (m *Model) recomputeLayout() {
if m.fullVis {
m.vis.Rows = layout.fullVisualizerRows
} else {
m.vis.Rows = layout.visualizerRows
rows := layout.visualizerRows
if contentFirst {
// The frame omits the visualizer, but plugin renderers still need
// a valid canvas while their mode remains active in the background.
switch layout.tier {
case layoutFull:
rows = max(1, ui.DefaultVisRows-1)
case layoutCompact:
rows = 2
default:
rows = 1
}
}
m.vis.Rows = rows
}
}
}
+90
View File
@@ -116,6 +116,96 @@ func TestResizeClampsActiveOverlayCursor(t *testing.T) {
}
}
func TestContentFirstLayoutPrioritizesLists(t *testing.T) {
playback := newLayoutTestModel(80, 24)
browse := newLayoutTestModel(80, 24)
browse.keymap.visible = true
browse.keymap.entries = browse.buildKeymapEntries()
browse.recomputeLayout()
if !browse.usesContentFirstLayout() {
t.Fatal("keymap must use the content-first layout")
}
if browse.layout.bodyRows <= playback.layout.bodyRows {
t.Fatalf("content-first body rows = %d, want more than playback rows %d", browse.layout.bodyRows, playback.layout.bodyRows)
}
if browse.layout.visualizerRows != 0 {
t.Fatalf("content-first visualizer rows = %d, want 0", browse.layout.visualizerRows)
}
if browse.vis.Rows < 1 {
t.Fatalf("content-first visualizer canvas rows = %d, want positive", browse.vis.Rows)
}
preview := newLayoutTestModel(80, 24)
preview.visPicker.visible = true
preview.visPicker.modes = preview.vis.AllModeNames()
preview.recomputeLayout()
if preview.usesContentFirstLayout() {
t.Fatal("visualizer picker must retain the live preview layout")
}
if preview.layout.visualizerRows == 0 {
t.Fatal("visualizer picker must retain visualizer rows")
}
}
func TestMinimalLayoutRejectsHiddenMainFocus(t *testing.T) {
m := newLayoutTestModel(80, 24)
m.focus = focusEQ
m.prevFocus = focusProvPill
updated, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 10})
m = updated.(Model)
if m.focus != focusPlaylist {
t.Fatalf("focus = %v, want playlist at minimal size", m.focus)
}
if m.prevFocus != focusPlaylist {
t.Fatalf("previous focus = %v, want playlist at minimal size", m.prevFocus)
}
m.handleKey(tea.KeyPressMsg{Code: tea.KeyTab})
if m.focus != focusPlaylist {
t.Fatalf("focus after Tab = %v, want playlist at minimal size", m.focus)
}
beforePreset := m.eqPresetIdx
m.handleKey(tea.KeyPressMsg{Text: "e"})
if m.eqPresetIdx != beforePreset {
t.Fatalf("EQ preset = %d after hidden shortcut, want %d", m.eqPresetIdx, beforePreset)
}
}
func TestCompactEqualizerShowsActiveBand(t *testing.T) {
m := newLayoutTestModel(56, 16)
m.focus = focusEQ
m.eqCursor = 4
if plain := lipgloss.NewStyle().Render(m.renderCompactControls()); !strings.Contains(plain, "1k") {
t.Fatalf("compact controls = %q, want active EQ band", plain)
}
}
func TestAsyncSearchResultLayoutUsesContentFirstRows(t *testing.T) {
m := newLayoutTestModel(80, 24)
m.netSearch.active = true
m.netSearch.screen = netSearchInput
m.netSearch.request = "ambient"
m.requests.netSearch = 1
m.recomputeLayout()
inputRows := m.layout.bodyRows
updated, _ := m.Update(netSearchResultsMsg{
gen: 1,
query: "ambient",
tracks: []playlist.Track{{Title: "Result"}},
})
m = updated.(Model)
if !m.usesContentFirstLayout() {
t.Fatal("search results must use the content-first layout")
}
if m.layout.bodyRows <= inputRows {
t.Fatalf("result body rows = %d, want more than input rows %d", m.layout.bodyRows, inputRows)
}
}
func TestLayoutClampsConfiguredPadding(t *testing.T) {
previousStyle := ui.FrameStyle
previousPanelWidth := ui.PanelWidth
+79
View File
@@ -52,6 +52,61 @@ func (f focusArea) label() string {
}
}
// mainFocusAreas returns only controls rendered in the current density tier.
// Secondary screens own their input independently, so this applies to the
// main playback screen and the provider playlist pane.
func (m Model) mainFocusAreas() []focusArea {
areas := []focusArea{focusPlaylist}
if m.layout.tier == layoutMinimal || m.layout.tier == layoutTooSmall {
return areas
}
areas = append(areas, focusEQ)
if len(m.providers) > 1 {
areas = append(areas, focusProvPill)
}
return append(areas, focusSpeed)
}
func (m Model) mainFocusAllowed(focus focusArea) bool {
for _, area := range m.mainFocusAreas() {
if area == focus {
return true
}
}
return false
}
func (m Model) nextMainFocus(current focusArea) focusArea {
areas := m.mainFocusAreas()
for i, area := range areas {
if area == current {
return areas[(i+1)%len(areas)]
}
}
return areas[0]
}
func (m Model) previousMainFocus(current focusArea) focusArea {
areas := m.mainFocusAreas()
for i, area := range areas {
if area == current {
return areas[(i+len(areas)-1)%len(areas)]
}
}
return areas[0]
}
// normalizeMainFocus clears a focus restored from a wider terminal when its
// control is not rendered at the current size.
func (m *Model) normalizeMainFocus() {
if (m.focus == focusEQ || m.focus == focusSpeed || m.focus == focusProvPill) && !m.mainFocusAllowed(m.focus) {
m.focus = focusPlaylist
}
if (m.prevFocus == focusEQ || m.prevFocus == focusSpeed || m.prevFocus == focusProvPill) && !m.mainFocusAllowed(m.prevFocus) {
m.prevFocus = focusPlaylist
}
}
type topLevelScreen int
const (
@@ -398,6 +453,30 @@ func (m Model) isOverlayActive() bool {
return false
}
// usesContentFirstLayout gives list-heavy tasks more room while preserving a
// compact now-playing summary. The visualizer picker deliberately keeps the
// normal playback chrome for live previews.
func (m Model) usesContentFirstLayout() bool {
if m.activeScreen() == screenMain && m.focus == focusProvider {
return true
}
if m.keymap.visible || m.devicePicker.visible || m.fileBrowser.visible ||
m.navBrowser.visible || m.themePicker.visible || m.queue.visible ||
m.search.active {
return true
}
if m.plPicker.visible && m.plPicker.screen == plPickerChoose {
return true
}
if m.spotSearch.visible && (m.spotSearch.screen == spotSearchResults || m.spotSearch.screen == spotSearchPlaylist) {
return true
}
if m.plManager.visible && (m.plManager.screen == plMgrScreenList || m.plManager.screen == plMgrScreenTracks) {
return true
}
return m.netSearch.active && m.netSearch.screen == netSearchResults
}
func (m Model) isPlaying() bool {
return m.player != nil && m.player.IsPlaying()
}
+76
View File
@@ -0,0 +1,76 @@
package model
import (
"strings"
"testing"
"charm.land/lipgloss/v2"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
"github.com/bjarneo/cliamp/ui"
"github.com/charmbracelet/x/ansi"
)
func TestNavHeaderIncludesSourceBreadcrumb(t *testing.T) {
m := Model{
navBrowser: navBrowserState{
prov: commandsTestProvider{name: "Navidrome"},
mode: navBrowseModeByArtistAlbum,
screen: navBrowseScreenTracks,
selArtist: provider.ArtistInfo{Name: "Miles Davis"},
selAlbum: provider.AlbumInfo{Name: "Kind of Blue"},
tracks: make([]playlist.Track, 1),
},
}
plain := ansi.Strip(m.navHeaderLine())
want := "Navidrome / Miles Davis / Kind of Blue / Tracks"
if !strings.Contains(plain, want) {
t.Fatalf("nav header = %q, want breadcrumb %q", plain, want)
}
}
func TestKeymapStartsWithCurrentScreenCommands(t *testing.T) {
m := Model{navBrowser: navBrowserState{visible: true, mode: navBrowseModeMenu}}
entries := m.buildKeymapEntries()
if len(entries) < 2 || !entries[0].divider || entries[0].action != "— current: Browse —" {
t.Fatalf("keymap starts with %+v, want current Browse section", entries)
}
if !strings.Contains(entries[1].action, "Back") && !strings.Contains(entries[1].action, "Help") {
t.Fatalf("first current-screen keymap entry = %+v, want browser command", entries[1])
}
}
func TestNavFilterHeaderKeepsInputVisible(t *testing.T) {
oldPanelWidth := ui.PanelWidth
ui.PanelWidth = 40
t.Cleanup(func() { ui.PanelWidth = oldPanelWidth })
m := Model{
navBrowser: navBrowserState{
prov: commandsTestProvider{name: "A very long provider name"},
mode: navBrowseModeByArtistAlbum,
screen: navBrowseScreenTracks,
selArtist: provider.ArtistInfo{Name: "An extremely long artist name"},
selAlbum: provider.AlbumInfo{Name: "An extremely long album name"},
searching: true,
search: "find",
},
}
plain := ansi.Strip(m.navHeaderLine())
if !strings.Contains(plain, "find_") {
t.Fatalf("nav filter header = %q, want visible query cursor", plain)
}
if width := lipgloss.Width(plain); width > ui.PanelWidth {
t.Fatalf("nav filter width = %d, want <= %d", width, ui.PanelWidth)
}
}
func TestKeymapIdentifiesProviderFilter(t *testing.T) {
m := Model{focus: focusProvider, provSearch: provSearchState{active: true}}
entries := m.buildKeymapEntries()
if len(entries) == 0 || entries[0].action != "— current: Provider Filter —" {
t.Fatalf("keymap starts with %+v, want provider-filter context", entries)
}
}
+139 -14
View File
@@ -11,9 +11,22 @@ import (
// openThemePicker re-loads themes from disk (picking up new user files)
// and opens the theme selector overlay.
func (m *Model) openThemePicker() {
savedName := m.ThemeName()
m.themes = theme.LoadAll()
m.themePicker.visible = true
m.themePicker.savedIdx = m.themeIdx
m.themePicker.savedName = savedName
m.themePicker.filtering = false
m.themePicker.filter = ""
m.themePicker.filtered = nil
m.themePicker.savedCursor = 0
m.themePicker.savedScroll = 0
m.themeIdx = -1
for i, t := range m.themes {
if strings.EqualFold(t.Name, savedName) {
m.themeIdx = i
break
}
}
// Position cursor on the currently active theme.
// Picker list: 0 = Default, 1..N = themes[0..N-1]
m.themePicker.cursor = m.themeIdx + 1
@@ -22,34 +35,48 @@ func (m *Model) openThemePicker() {
}
// themePickerApply applies the theme under the cursor for live preview.
func (m *Model) themePickerApply() {
if m.themePicker.cursor == 0 {
func (m *Model) themePickerApply() bool {
rawIdx, ok := m.themePickerRawIndex(m.themePicker.cursor)
if !ok {
return false
}
if rawIdx == 0 {
m.themeIdx = -1
applyThemeAll(theme.Default())
} else {
m.themeIdx = m.themePicker.cursor - 1
m.themeIdx = rawIdx - 1
applyThemeAll(m.themes[m.themeIdx])
}
return true
}
// themePickerSelect confirms the current selection and closes the picker.
func (m *Model) themePickerSelect() {
m.themePickerApply()
if !m.themePickerApply() {
return
}
m.themePicker.visible = false
m.themePicker.filtering = false
m.themePicker.filter = ""
m.themePicker.filtered = nil
}
// themePickerCancel restores the theme from before the picker was opened.
func (m *Model) themePickerCancel() {
m.themeIdx = m.themePicker.savedIdx
if m.themeIdx < 0 {
if !m.SetTheme(m.themePicker.savedName) {
m.themeIdx = -1
applyThemeAll(theme.Default())
} else {
applyThemeAll(m.themes[m.themeIdx])
}
m.themePicker.visible = false
m.themePicker.filtering = false
m.themePicker.filter = ""
m.themePicker.filtered = nil
}
func (m *Model) themePickerHelpLine() string {
if m.themePicker.filtering {
return m.commandHelp(commandModeThemePickerFilter)
}
return m.commandHelp(commandModeThemePicker)
}
@@ -58,7 +85,52 @@ func (m *Model) themePickerVisible() int {
}
func (m *Model) themePickerMaybeAdjustScroll(visible int) {
clampScroll(&m.themePicker.cursor, &m.themePicker.scroll, len(m.themes)+1, visible)
clampScroll(&m.themePicker.cursor, &m.themePicker.scroll, m.themePickerViewCount(), visible)
}
func (m Model) themePickerViewCount() int {
if m.themePicker.filter != "" {
return len(m.themePicker.filtered)
}
return m.themeCount()
}
func (m Model) themePickerRawIndex(viewIdx int) (int, bool) {
if m.themePicker.filter != "" {
if viewIdx < 0 || viewIdx >= len(m.themePicker.filtered) {
return 0, false
}
return m.themePicker.filtered[viewIdx], true
}
if viewIdx < 0 || viewIdx >= m.themeCount() {
return 0, false
}
return viewIdx, true
}
func (m Model) themePickerName(rawIdx int) string {
if rawIdx == 0 {
return theme.DefaultName
}
if rawIdx > 0 && rawIdx <= len(m.themes) {
return m.themes[rawIdx-1].Name
}
return ""
}
func (m *Model) themePickerRecomputeFilter() {
m.themePicker.filtered = nil
m.themePicker.cursor = 0
m.themePicker.scroll = 0
if m.themePicker.filter == "" {
return
}
query := strings.ToLower(m.themePicker.filter)
for rawIdx := range m.themeCount() {
if strings.Contains(strings.ToLower(m.themePickerName(rawIdx)), query) {
m.themePicker.filtered = append(m.themePicker.filtered, rawIdx)
}
}
}
// openVisPicker opens the visualizer picker, which renders the mode list in the
@@ -69,6 +141,11 @@ func (m *Model) openVisPicker() {
m.visPicker.savedMode = int(m.vis.Mode)
m.visPicker.cursor = int(m.vis.Mode)
m.visPicker.scroll = 0
m.visPicker.filtering = false
m.visPicker.filter = ""
m.visPicker.filtered = nil
m.visPicker.savedCursor = 0
m.visPicker.savedScroll = 0
// Capture the mode list once; it is stable while the picker is open (Lua
// visualizers are registered at startup), so callers avoid re-allocating it.
m.visPicker.modes = m.vis.AllModeNames()
@@ -83,19 +160,27 @@ func (m *Model) openVisPicker() {
// cursor move so the live preview updates as the user scrolls. Only recompute
// the layout when crossing the VisNone boundary, since that is the sole mode
// change that adds/removes the spectrum block (all other modes share a height).
func (m *Model) visPickerApply() {
func (m *Model) visPickerApply() bool {
rawIdx, ok := m.visPickerRawIndex(m.visPicker.cursor)
if !ok {
return false
}
wasNone := m.vis.Mode == ui.VisNone
m.vis.SetMode(ui.VisMode(m.visPicker.cursor))
m.vis.SetMode(ui.VisMode(rawIdx))
if wasNone != (m.vis.Mode == ui.VisNone) {
m.refreshChrome()
m.applyHeightMode()
}
return true
}
// visPickerClose restores playlist sizing after the picker layout is dismissed.
func (m *Model) visPickerClose() {
m.visPicker.visible = false
m.visPicker.modes = nil
m.visPicker.filtering = false
m.visPicker.filter = ""
m.visPicker.filtered = nil
m.refreshChrome()
m.applyHeightMode()
m.adjustScroll()
@@ -103,7 +188,9 @@ func (m *Model) visPickerClose() {
// visPickerSelect confirms the current selection, persists it, and closes.
func (m *Model) visPickerSelect() {
m.visPickerApply()
if !m.visPickerApply() {
return
}
if err := m.configSaver.Save("visualizer", fmt.Sprintf("%q", m.vis.ModeName())); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err)
}
@@ -117,6 +204,9 @@ func (m *Model) visPickerCancel() {
}
func (m *Model) visPickerHelpLine() string {
if m.visPicker.filtering {
return m.commandHelp(commandModeVisPickerFilter)
}
return m.commandHelp(commandModeVisPicker)
}
@@ -125,7 +215,42 @@ func (m *Model) visPickerVisible() int {
}
func (m *Model) visPickerMaybeAdjustScroll(visible int) {
clampScroll(&m.visPicker.cursor, &m.visPicker.scroll, len(m.visPicker.modes), visible)
clampScroll(&m.visPicker.cursor, &m.visPicker.scroll, m.visPickerViewCount(), visible)
}
func (m Model) visPickerViewCount() int {
if m.visPicker.filter != "" {
return len(m.visPicker.filtered)
}
return len(m.visPicker.modes)
}
func (m Model) visPickerRawIndex(viewIdx int) (int, bool) {
if m.visPicker.filter != "" {
if viewIdx < 0 || viewIdx >= len(m.visPicker.filtered) {
return 0, false
}
return m.visPicker.filtered[viewIdx], true
}
if viewIdx < 0 || viewIdx >= len(m.visPicker.modes) {
return 0, false
}
return viewIdx, true
}
func (m *Model) visPickerRecomputeFilter() {
m.visPicker.filtered = nil
m.visPicker.cursor = 0
m.visPicker.scroll = 0
if m.visPicker.filter == "" {
return
}
query := strings.ToLower(m.visPicker.filter)
for rawIdx, name := range m.visPicker.modes {
if strings.Contains(strings.ToLower(name), query) {
m.visPicker.filtered = append(m.visPicker.filtered, rawIdx)
}
}
}
func (m *Model) devicePickerHelpLine() string {
+65
View File
@@ -0,0 +1,65 @@
package model
import (
"strings"
"testing"
"github.com/bjarneo/cliamp/theme"
"github.com/bjarneo/cliamp/ui"
"github.com/charmbracelet/x/ansi"
)
func TestThemePickerFilterPreservesRawThemeIndex(t *testing.T) {
m := Model{
themes: []theme.Theme{
{Name: "Ayu", Accent: "#000000", BrightFG: "#ffffff", FG: "#111111", Green: "#00ff00", Yellow: "#ffff00", Red: "#ff0000"},
{Name: "Midnight", Accent: "#000000", BrightFG: "#ffffff", FG: "#111111", Green: "#00ff00", Yellow: "#ffff00", Red: "#ff0000"},
},
themePicker: themePickerState{filter: "mid"},
}
m.themePickerRecomputeFilter()
if len(m.themePicker.filtered) != 1 || m.themePicker.filtered[0] != 2 {
t.Fatalf("filtered theme indices = %v, want [2]", m.themePicker.filtered)
}
if rawIdx, ok := m.themePickerRawIndex(0); !ok || rawIdx != 2 {
t.Fatalf("raw theme index = %d, %t; want 2, true", rawIdx, ok)
}
}
func TestThemePickerCancelHandlesRemovedTheme(t *testing.T) {
m := Model{themePicker: themePickerState{visible: true, savedName: "Removed"}}
m.themePickerCancel()
if m.themePicker.visible {
t.Fatal("theme picker remains visible after cancel")
}
if m.themeIdx != -1 {
t.Fatalf("theme index = %d, want default index -1", m.themeIdx)
}
}
func TestVisualizerPickerFilterPreservesModeIndex(t *testing.T) {
m := Model{
vis: ui.NewVisualizer(44_100),
visPicker: visPickerState{
modes: []string{"None", "Bars", "Wave"},
filter: "wa",
},
}
m.visPickerRecomputeFilter()
if len(m.visPicker.filtered) != 1 || m.visPicker.filtered[0] != 2 {
t.Fatalf("filtered visualizer indices = %v, want [2]", m.visPicker.filtered)
}
if rawIdx, ok := m.visPickerRawIndex(0); !ok || rawIdx != 2 {
t.Fatalf("raw visualizer index = %d, %t; want 2, true", rawIdx, ok)
}
}
func TestPickerFilterHelpDescribesFilterInput(t *testing.T) {
m := Model{themePicker: themePickerState{filtering: true}}
plain := ansi.Strip(m.themePickerHelpLine())
if !strings.Contains(plain, "Cancel filter") || !strings.Contains(plain, "Finish filter") {
t.Fatalf("theme filter help = %q, want filter actions", plain)
}
}
+1
View File
@@ -28,6 +28,7 @@ func clampScroll(cursor, scroll *int, count, visible int) {
// applyHeightMode sets plVisible based on the current heightExpanded state.
func (m *Model) applyHeightMode() {
m.recomputeLayout()
m.normalizeMainFocus()
}
// adjustScroll ensures plCursor is visible in the playlist view.
+19 -9
View File
@@ -75,19 +75,29 @@ type seekState struct {
// themePickerState holds state for the theme picker overlay.
type themePickerState struct {
visible bool
cursor int
scroll int
savedIdx int // themeIdx before opening picker, for cancel/restore
visible bool
cursor int // view index into filtered when filter != "", otherwise raw theme index
scroll int
savedName string // theme name before opening picker, for cancel/restore after reload
filtering bool
filter string
filtered []int // raw indices into [Default, themes...]
savedCursor int
savedScroll int
}
// visPickerState holds state for the visualizer picker overlay.
type visPickerState struct {
visible bool
cursor int
scroll int
savedMode int // vis.Mode before opening, for cancel/restore
modes []string // mode names captured at open (stable while open)
visible bool
cursor int // view index into filtered when filter != "", otherwise raw visualizer mode
scroll int
savedMode int // vis.Mode before opening, for cancel/restore
modes []string // mode names captured at open (stable while open)
filtering bool
filter string
filtered []int // raw indices into modes
savedCursor int
savedScroll int
}
// lyricsState holds state for the lyrics display overlay.
+7
View File
@@ -62,6 +62,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
m.recomputeLayout()
m.normalizeMainFocus()
m.clampActiveScrollState()
return m, nil
@@ -566,6 +567,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if len(msg.tracks) == 0 {
m.netSearch.err = "No results found"
}
m.applyHeightMode()
m.clampActiveScrollState()
return m, nil
case lyricsLoadedMsg:
@@ -713,6 +716,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if len(msg.tracks) == 0 {
m.spotSearch.err = "No results found"
}
m.applyHeightMode()
m.clampActiveScrollState()
return m, nil
case spotPlaylistsMsg:
@@ -729,6 +734,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spotSearch.playlists = msg.playlists
m.spotSearch.cursor = 0
m.spotSearch.screen = spotSearchPlaylist
m.applyHeightMode()
m.clampActiveScrollState()
return m, nil
case spotAddedMsg:
+85 -43
View File
@@ -124,7 +124,8 @@ func (m Model) View() tea.View {
}
screen := m.activeScreen()
if !screen.hidesVisualizer() {
contentFirst := m.usesContentFirstLayout()
if !screen.hidesVisualizer() && !contentFirst {
m.refreshVisualizerIfPending()
}
@@ -133,12 +134,12 @@ func (m Model) View() tea.View {
case screenFullVisualizer:
content = m.renderFullVisualizer()
default:
// Every overlay renders inline in the playlist region (renderMainBody),
// with its header/help supplied by renderPlaylistHeader / renderHelp, so
// the now-playing + visualizer chrome stays live above and the layout
// height never shifts when an overlay opens.
// Overlays render in the playlist region (renderMainBody), with their
// header/help supplied by renderPlaylistHeader / renderHelp. List-heavy
// tasks collapse to a compact now-playing summary so they can use the
// reclaimed rows for browsing.
body := ui.FitRect(m.renderMainBody(), m.layout.panelWidth, m.layout.bodyRows)
content = strings.Join(m.mainSections(body, true), "\n")
content = strings.Join(m.mainSections(body, true, contentFirst), "\n")
}
// Every screen now renders within the main frame, so frame and center
@@ -159,41 +160,60 @@ func trimTrailingEmpty(sections []string) []string {
return sections
}
func (m Model) mainSections(playlist string, includeTransient bool) []string {
func (m Model) mainSections(playlist string, includeTransient, contentFirst bool) []string {
var sections []string
switch m.layout.tier {
case layoutCompact:
sections = []string{
m.renderTitle(),
m.renderTrackInfo(),
m.renderTimeStatus(),
m.renderSpectrum(),
m.renderSeekBar(),
m.renderCompactControls(),
m.renderCompactSource(),
m.renderPlaylistHeader(),
if contentFirst {
if m.layout.tier == layoutMinimal {
sections = []string{
m.renderTrackInfo(),
m.renderTimeStatus(),
m.renderSeekBar(),
m.renderPlaylistHeader(),
}
} else {
sections = []string{
m.renderTitle(),
m.renderTrackInfo(),
m.renderTimeStatus(),
m.renderSeekBar(),
m.renderPlaylistHeader(),
}
}
case layoutMinimal:
sections = []string{
m.renderTrackInfo(),
m.renderTimeStatus(),
m.renderSeekBar(),
m.renderPlaylistHeader(),
} else {
switch m.layout.tier {
case layoutCompact:
sections = []string{
m.renderTitle(),
m.renderTrackInfo(),
m.renderTimeStatus(),
m.renderSpectrum(),
m.renderSeekBar(),
m.renderCompactControls(),
m.renderCompactSource(),
m.renderPlaylistHeader(),
}
case layoutMinimal:
sections = []string{
m.renderTrackInfo(),
m.renderTimeStatus(),
m.renderSeekBar(),
m.renderPlaylistHeader(),
}
default:
sections = []string{
m.renderTitle(),
m.renderTrackInfo(),
m.renderTimeStatus(),
"",
m.renderSpectrum(),
m.renderSeekBar(),
m.renderControls(),
}
if source := m.renderProviderPill(); source != "" {
sections = append(sections, source)
}
sections = append(sections, m.renderPlaylistHeader())
}
default:
sections = []string{
m.renderTitle(),
m.renderTrackInfo(),
m.renderTimeStatus(),
"",
m.renderSpectrum(),
m.renderSeekBar(),
m.renderControls(),
}
if source := m.renderProviderPill(); source != "" {
sections = append(sections, source)
}
sections = append(sections, m.renderPlaylistHeader())
}
if playlist != "" {
sections = append(sections, playlist)
@@ -249,7 +269,15 @@ func (m Model) renderCompactControls() string {
if m.player.Mono() {
mono = " [M]"
}
return labelStyle.Render("EQ ") + activeToggle.Render("["+m.EQPresetName()+"]") +
eqLabel := labelStyle.Render("EQ ")
eqValue := activeToggle.Render("[" + m.EQPresetName() + "]")
if m.focus == focusEQ {
eqLabel = activeToggle.Render("EQ ▸ ")
bands := m.player.EQBands()
labels := [10]string{"70", "180", "320", "600", "1k", "3k", "6k", "12k", "14k", "16k"}
eqValue += " " + eqActiveStyle.Render(fmt.Sprintf("%s %+.0fdB", labels[m.eqCursor], bands[m.eqCursor]))
}
return eqLabel + eqValue +
" " + labelStyle.Render("VOL ") + fmt.Sprintf("%+.0fdB", m.player.Volume()) + mono
}
@@ -547,7 +575,11 @@ func (m Model) renderPlaylistHeader() string {
return ov.header(&m)
}
if m.focus == focusProvider {
return dimStyle.Render(labeledSeparator("", fmt.Sprintf("%s Playlists", m.provider.Name())))
label := m.provider.Name() + " / Playlists"
if m.provSearch.active {
label += " / Filter"
}
return dimStyle.Render(labeledSeparator("", label))
}
var shuffle string
@@ -813,22 +845,28 @@ func (m Model) renderPlaylist() string {
queueSuffix = fmt.Sprintf(" [Q%d]", queuePosition)
}
queueLen := lipgloss.Width(queueSuffix)
duration := formatTrackTime(t.DurationSecs)
durationLen := lipgloss.Width(duration)
durationGap := 0
if duration != "" {
durationGap = durationLen + 1
}
linePrefixWidth := lipgloss.Width(markers) + numWidth + 2 // 2 for ". "
// State markers always occupy the same cells; low-priority queue position,
// album, and unavailable labels appear only when the terminal has room.
name = truncate(name, ui.PanelWidth-linePrefixWidth-queueLen)
name = truncate(name, ui.PanelWidth-linePrefixWidth-queueLen-durationGap)
// Truncate the album to fit whatever space remains after the track name.
albumSuffix := ""
nameLen := lipgloss.Width(name)
if t.Unplayable && ui.PanelWidth >= 68 {
remaining := ui.PanelWidth - linePrefixWidth - nameLen - queueLen
remaining := ui.PanelWidth - linePrefixWidth - nameLen - queueLen - durationGap
if remaining >= len(" (unavailable)") {
albumSuffix = truncate(" (unavailable)", remaining)
}
} else if album := t.Album; album != "" && !m.showAlbumHeaders && ui.PanelWidth >= 56 {
remaining := ui.PanelWidth - linePrefixWidth - nameLen - queueLen - 3 // 3 = " · "
remaining := ui.PanelWidth - linePrefixWidth - nameLen - queueLen - durationGap - 3 // 3 = " · "
if remaining >= 4 {
albumSuffix = " · " + truncate(album, remaining)
}
@@ -845,6 +883,10 @@ func (m Model) renderPlaylist() string {
if queueSuffix != "" {
line += activeToggle.Render(queueSuffix)
}
if duration != "" {
padding := max(1, ui.PanelWidth-lipgloss.Width(line)-durationLen)
line += strings.Repeat(" ", padding) + dimStyle.Render(duration)
}
lines = append(lines, line)
}
+54 -1
View File
@@ -1,6 +1,10 @@
package model
import "github.com/bjarneo/cliamp/provider"
import (
"strings"
"github.com/bjarneo/cliamp/provider"
)
// navSortLabel resolves a sort-type ID to its human label for the active
// provider's album browser, falling back to the raw ID.
@@ -14,3 +18,52 @@ func (m Model) navSortLabel(sortID string) string {
}
return sortID
}
func (m Model) navSourceName() string {
if m.navBrowser.prov != nil {
return m.navBrowser.prov.Name()
}
return "Provider"
}
// navBreadcrumb keeps source, location, and scope visible while drilling into
// a provider catalog. The separator header clips the completed path safely.
func (m Model) navBreadcrumb() string {
parts := []string{m.navSourceName()}
switch m.navView() {
case navViewArtists:
parts = append(parts, "Artists")
case navViewAlbums:
if m.navBrowser.mode == navBrowseModeByArtistAlbum && m.navBrowser.selArtist.Name != "" {
parts = append(parts, m.navBrowser.selArtist.Name)
}
parts = append(parts, "Albums")
if m.navBrowser.mode == navBrowseModeByAlbum {
if sort := m.navSortLabel(m.navBrowser.sortType); sort != "" {
parts = append(parts, "Sort: "+sort)
}
}
case navViewTracks:
switch m.navBrowser.mode {
case navBrowseModeByArtist:
if m.navBrowser.selArtist.Name != "" {
parts = append(parts, m.navBrowser.selArtist.Name)
}
case navBrowseModeByAlbum:
if m.navBrowser.selAlbum.Name != "" {
parts = append(parts, m.navBrowser.selAlbum.Name)
}
case navBrowseModeByArtistAlbum:
if m.navBrowser.selArtist.Name != "" {
parts = append(parts, m.navBrowser.selArtist.Name)
}
if m.navBrowser.selAlbum.Name != "" {
parts = append(parts, m.navBrowser.selAlbum.Name)
}
}
parts = append(parts, "Tracks")
default:
parts = append(parts, "Browse")
}
return strings.Join(parts, " / ")
}
+19
View File
@@ -19,6 +19,18 @@ func (m Model) renderVisPickerList() string {
return ""
}
items := m.visPicker.modes
if m.visPicker.filter != "" {
filtered := make([]string, 0, len(m.visPicker.filtered))
for _, rawIdx := range m.visPicker.filtered {
if rawIdx >= 0 && rawIdx < len(m.visPicker.modes) {
filtered = append(filtered, m.visPicker.modes[rawIdx])
}
}
items = filtered
}
if len(items) == 0 {
return bodyMessage("No matches.", budget)
}
scroll := m.visPicker.scroll
lines := make([]string, 0, budget)
@@ -28,6 +40,13 @@ func (m Model) renderVisPickerList() string {
return strings.Join(padLines(lines, budget, len(lines)), "\n")
}
func (m Model) visPickerHeaderLine() string {
if m.visPicker.filtering || m.visPicker.filter != "" {
return m.filterCountHeader("visualizer-picker-filter", m.visPicker.filter, fmt.Sprintf("%d/%d", m.visPickerViewCount(), len(m.visPicker.modes)))
}
return sepHeaderN("Visualizers", m.visPicker.cursor+1, m.visPickerViewCount())
}
// — playlist manager (inline) —
func (m Model) plMgrHeaderLine() string {
+20
View File
@@ -46,6 +46,26 @@ func TestPlaylistStateMarkersStayVisibleWithoutColor(t *testing.T) {
}
}
func TestPlaylistShowsKnownTrackDuration(t *testing.T) {
oldPanelWidth := ui.PanelWidth
ui.PanelWidth = 80
t.Cleanup(func() { ui.PanelWidth = oldPanelWidth })
p := playlist.New()
p.Add(playlist.Track{Title: "Timed", DurationSecs: 222})
m := Model{
player: &playbackFakeEngine{},
playlist: p,
focus: focusPlaylist,
plVisible: 1,
}
plain := ansi.Strip(m.renderPlaylist())
if !strings.Contains(plain, "3:42") {
t.Fatalf("playlist row = %q, want right-aligned duration", plain)
}
}
func TestNonSeekableStreamUsesLiveTime(t *testing.T) {
fake := &playbackFakeEngine{}
p := playlist.New()