498 lines
14 KiB
Go
498 lines
14 KiB
Go
package model
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/bjarneo/cliamp/theme"
|
|
"github.com/bjarneo/cliamp/ui"
|
|
)
|
|
|
|
// 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.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
|
|
m.themePicker.scroll = 0
|
|
m.themePickerMaybeAdjustScroll(m.themePickerVisible())
|
|
}
|
|
|
|
// themePickerApply applies the theme under the cursor for live preview.
|
|
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 = rawIdx - 1
|
|
applyThemeAll(m.themes[m.themeIdx])
|
|
}
|
|
return true
|
|
}
|
|
|
|
// themePickerSelect confirms the current selection and closes the picker.
|
|
func (m *Model) themePickerSelect() {
|
|
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() {
|
|
if !m.SetTheme(m.themePicker.savedName) {
|
|
m.themeIdx = -1
|
|
applyThemeAll(theme.Default())
|
|
}
|
|
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)
|
|
}
|
|
|
|
func (m *Model) themePickerVisible() int {
|
|
return m.effectivePlaylistVisible()
|
|
}
|
|
|
|
func (m *Model) themePickerMaybeAdjustScroll(visible int) {
|
|
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
|
|
// playlist region while keeping the visualizer live above it for preview. The
|
|
// cursor starts on the currently active mode.
|
|
func (m *Model) openVisPicker() {
|
|
m.visPicker.visible = true
|
|
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()
|
|
// Recompute chrome/height for the picker layout (its header + help differ
|
|
// from the playlist), then fit the cursor into the visible window.
|
|
m.refreshChrome()
|
|
m.applyHeightMode()
|
|
m.visPickerMaybeAdjustScroll(m.visPickerVisible())
|
|
}
|
|
|
|
// visPickerApply switches to the visualizer mode under the cursor. Run on every
|
|
// 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() bool {
|
|
rawIdx, ok := m.visPickerRawIndex(m.visPicker.cursor)
|
|
if !ok {
|
|
return false
|
|
}
|
|
wasNone := m.vis.Mode == ui.VisNone
|
|
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()
|
|
}
|
|
|
|
// visPickerSelect confirms the current selection, persists it, and closes.
|
|
func (m *Model) visPickerSelect() {
|
|
if !m.visPickerApply() {
|
|
return
|
|
}
|
|
if err := m.configSaver.Save("visualizer", fmt.Sprintf("%q", m.vis.ModeName())); err != nil {
|
|
m.status.Errorf(statusTTLDefault, "Config save failed: %s", err)
|
|
}
|
|
m.visPickerClose()
|
|
}
|
|
|
|
// visPickerCancel restores the mode from before the picker was opened.
|
|
func (m *Model) visPickerCancel() {
|
|
m.vis.SetMode(ui.VisMode(m.visPicker.savedMode))
|
|
m.visPickerClose()
|
|
}
|
|
|
|
func (m *Model) visPickerHelpLine() string {
|
|
if m.visPicker.filtering {
|
|
return m.commandHelp(commandModeVisPickerFilter)
|
|
}
|
|
return m.commandHelp(commandModeVisPicker)
|
|
}
|
|
|
|
func (m *Model) visPickerVisible() int {
|
|
return m.effectivePlaylistVisible()
|
|
}
|
|
|
|
func (m *Model) visPickerMaybeAdjustScroll(visible int) {
|
|
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 {
|
|
return m.commandHelp(commandModeDevicePicker)
|
|
}
|
|
|
|
func (m *Model) devicePickerVisible() int {
|
|
return m.effectivePlaylistVisible()
|
|
}
|
|
|
|
func (m *Model) queueHelpLine() string {
|
|
return m.commandHelp(commandModeQueue)
|
|
}
|
|
|
|
func (m *Model) queueVisible() int {
|
|
return m.effectivePlaylistVisible()
|
|
}
|
|
|
|
func (m *Model) searchHelpLine() string {
|
|
return m.commandHelp(commandModeSearch)
|
|
}
|
|
|
|
func (m *Model) searchVisible() int {
|
|
return m.effectivePlaylistVisible()
|
|
}
|
|
|
|
// closeSearchLayout restores playlist sizing after the inline search header and
|
|
// help line are dismissed, then refits the playlist cursor into view.
|
|
func (m *Model) closeSearchLayout() {
|
|
m.refreshChrome()
|
|
m.applyHeightMode()
|
|
m.adjustScroll()
|
|
}
|
|
|
|
func (m *Model) netSearchResultsHelpLine() string {
|
|
return m.commandHelp(commandModeNetSearch)
|
|
}
|
|
|
|
func (m *Model) netSearchResultsVisible() int {
|
|
return m.effectivePlaylistVisible()
|
|
}
|
|
|
|
func (m *Model) spotSearchResultsHelpLine() string {
|
|
return m.commandHelp(commandModeSpotSearch)
|
|
}
|
|
|
|
func (m *Model) spotSearchResultsVisible() int {
|
|
return m.effectivePlaylistVisible()
|
|
}
|
|
|
|
func (m *Model) spotSearchPlaylistHelpLine() string {
|
|
return m.commandHelp(commandModeSpotSearch)
|
|
}
|
|
|
|
func (m *Model) spotSearchPlaylistVisible() int {
|
|
return m.effectivePlaylistVisible()
|
|
}
|
|
|
|
// navVisible returns the nav-browser list height. The nav browser renders
|
|
// inline in the playlist region, so it shares the playlist's row budget.
|
|
func (m *Model) navVisible() int {
|
|
return m.effectivePlaylistVisible()
|
|
}
|
|
|
|
func (m *Model) plMgrListHelpLine() string {
|
|
return m.commandHelp(commandModePlaylistManager)
|
|
}
|
|
|
|
func (m *Model) plMgrListVisible() int {
|
|
return m.effectivePlaylistVisible()
|
|
}
|
|
|
|
func (m *Model) plMgrListMaybeAdjustScroll(visible int) {
|
|
clampScroll(&m.plManager.cursor, &m.plManager.scroll, m.plMgrListViewCount(), visible)
|
|
}
|
|
|
|
func (m *Model) plMgrTracksHelpLine() string {
|
|
return m.commandHelp(commandModePlaylistManager)
|
|
}
|
|
|
|
func (m *Model) plMgrTracksVisible() int {
|
|
return m.effectivePlaylistVisible()
|
|
}
|
|
|
|
func (m *Model) plMgrTracksMaybeAdjustScroll(visible int) {
|
|
if m.plManager.filter != "" || !m.showAlbumHeaders {
|
|
clampScroll(&m.plManager.cursor, &m.plManager.scroll, m.plMgrTracksViewCount(), visible)
|
|
return
|
|
}
|
|
tracks := m.plManager.tracks
|
|
if len(tracks) == 0 {
|
|
return
|
|
}
|
|
if m.plManager.cursor < m.plManager.scroll {
|
|
m.plManager.scroll = m.plManager.cursor
|
|
}
|
|
for m.plManager.scroll < m.plManager.cursor && m.albumSeparatorRows(tracks, m.plManager.scroll, m.plManager.cursor, true) > visible {
|
|
m.plManager.scroll++
|
|
}
|
|
}
|
|
|
|
// openPlaylistManager loads playlist metadata and opens the manager overlay.
|
|
func (m *Model) openPlaylistManager() {
|
|
m.plMgrResetFilter()
|
|
m.plMgrRefreshList()
|
|
m.plManager.screen = plMgrScreenList
|
|
m.plManager.cursor = 0
|
|
m.plManager.scroll = 0
|
|
m.plManager.confirmDel = false
|
|
m.plManager.renameOldName = ""
|
|
m.plManager.renameName = ""
|
|
m.plManager.visible = true
|
|
m.plMgrListMaybeAdjustScroll(m.plMgrListVisible())
|
|
}
|
|
|
|
// plMgrEnterTrackList loads the tracks for a playlist and switches to screen 1.
|
|
func (m *Model) plMgrEnterTrackList(name string) {
|
|
tracks, err := m.localProvider.Tracks(name)
|
|
if err != nil {
|
|
m.status.Errorf(statusTTLDefault, "Load failed: %s", err)
|
|
return
|
|
}
|
|
m.plManager.selPlaylist = name
|
|
m.plManager.tracks = tracks
|
|
m.plManager.marked = make(map[int]bool)
|
|
m.plManager.sortMode = 0
|
|
m.setHeaderStateFromTracks(tracks)
|
|
m.plManager.screen = plMgrScreenTracks
|
|
m.plManager.cursor = 0
|
|
m.plManager.scroll = 0
|
|
m.plManager.confirmDel = false
|
|
m.plMgrResetFilter()
|
|
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
|
|
}
|
|
|
|
// plMgrResetFilter clears any active `/` filter on the playlist manager.
|
|
func (m *Model) plMgrResetFilter() {
|
|
m.plManager.filtering = false
|
|
m.plManager.filter = ""
|
|
m.plManager.filtered = nil
|
|
m.plManager.cursor = 0
|
|
m.plManager.scroll = 0
|
|
m.plManager.savedCursor = 0
|
|
m.plManager.savedScroll = 0
|
|
}
|
|
|
|
// plMgrRecomputeFilter rebuilds the filter index for the active screen.
|
|
func (m *Model) plMgrRecomputeFilter() {
|
|
m.plManager.filtered = m.plManager.filtered[:0]
|
|
if m.plManager.filter == "" {
|
|
m.plManager.filtered = nil
|
|
return
|
|
}
|
|
q := strings.ToLower(m.plManager.filter)
|
|
switch m.plManager.screen {
|
|
case plMgrScreenList:
|
|
for i, p := range m.plManager.playlists {
|
|
if strings.Contains(strings.ToLower(p.Name), q) {
|
|
m.plManager.filtered = append(m.plManager.filtered, i)
|
|
}
|
|
}
|
|
case plMgrScreenTracks:
|
|
for i, t := range m.plManager.tracks {
|
|
hay := strings.ToLower(t.DisplayName() + " " + t.Album + " " + t.Artist)
|
|
if strings.Contains(hay, q) {
|
|
m.plManager.filtered = append(m.plManager.filtered, i)
|
|
}
|
|
}
|
|
}
|
|
if m.plManager.cursor < 0 {
|
|
m.plManager.cursor = 0
|
|
}
|
|
m.plManager.scroll = 0
|
|
if m.plManager.screen == plMgrScreenList {
|
|
m.plMgrListMaybeAdjustScroll(m.plMgrListVisible())
|
|
} else if m.plManager.screen == plMgrScreenTracks {
|
|
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
|
|
}
|
|
}
|
|
|
|
// plMgrRealIndex maps a view-index to the real index in the underlying slice
|
|
// (playlists on the list screen, tracks on the track screen). Returns -1 if
|
|
// out of range or pointing at the "+ New Playlist" pseudo-entry on the list
|
|
// screen. unfilteredLen is the length of the unfiltered slice.
|
|
func (m Model) plMgrRealIndex(view, unfilteredLen int) int {
|
|
if m.plManager.filter == "" {
|
|
if view < 0 || view >= unfilteredLen {
|
|
return -1
|
|
}
|
|
return view
|
|
}
|
|
if view < 0 || view >= len(m.plManager.filtered) {
|
|
return -1
|
|
}
|
|
return m.plManager.filtered[view]
|
|
}
|
|
|
|
func (m Model) plMgrPlaylistRealIndex(view int) int {
|
|
return m.plMgrRealIndex(view, len(m.plManager.playlists))
|
|
}
|
|
|
|
func (m Model) plMgrTrackRealIndex(view int) int {
|
|
return m.plMgrRealIndex(view, len(m.plManager.tracks))
|
|
}
|
|
|
|
// plMgrRefreshList reloads playlist names and counts from disk and clamps the cursor.
|
|
func (m *Model) plMgrRefreshList() {
|
|
if m.localProvider == nil {
|
|
return
|
|
}
|
|
playlists, err := m.localProvider.Playlists()
|
|
if err != nil {
|
|
m.status.Errorf(statusTTLDefault, "Load failed: %s", err)
|
|
}
|
|
m.plManager.playlists = playlists
|
|
if m.plManager.filter != "" {
|
|
m.plMgrRecomputeFilter()
|
|
}
|
|
total := m.plMgrListViewCount()
|
|
if m.plManager.cursor >= total {
|
|
m.plManager.cursor = total - 1
|
|
}
|
|
if m.plManager.cursor < 0 {
|
|
m.plManager.cursor = 0
|
|
}
|
|
m.plMgrListMaybeAdjustScroll(m.plMgrListVisible())
|
|
}
|
|
|
|
// plMgrListViewCount returns the visible row count on the list screen
|
|
// (filtered playlists + "+ New Playlist..." entry).
|
|
func (m Model) plMgrListViewCount() int {
|
|
if m.plManager.filter != "" {
|
|
return len(m.plManager.filtered) + 1
|
|
}
|
|
return len(m.plManager.playlists) + 1
|
|
}
|
|
|
|
// plMgrTracksViewCount returns the visible row count on the tracks screen.
|
|
func (m Model) plMgrTracksViewCount() int {
|
|
if m.plManager.filter != "" {
|
|
return len(m.plManager.filtered)
|
|
}
|
|
return len(m.plManager.tracks)
|
|
}
|