feat(ui): prioritize browse content
This commit is contained in:
@@ -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
@@ -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
@@ -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 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 & 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 & 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>
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user