From 3823b36ea05cb23d04e3e2a5d3201cc14a8de751 Mon Sep 17 00:00:00 2001 From: MattiaPun Date: Thu, 23 Apr 2026 13:06:14 +0200 Subject: [PATCH 1/3] feat(ui): visually multi-select songs/albums/artists --- internal/api/config.go | 17 +++--- internal/api/config.toml | 15 ++--- internal/ui/init.go | 3 + internal/ui/model.go | 5 ++ internal/ui/styles.go | 6 ++ internal/ui/update_keys.go | 122 ++++++++++++++++++++++++++++--------- internal/ui/view.go | 19 +++++- 7 files changed, 141 insertions(+), 46 deletions(-) diff --git a/internal/api/config.go b/internal/api/config.go index 0947724..6b09c38 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -133,14 +133,15 @@ type GlobalKeybinds struct { } type NavigationKeybinds struct { - Up []string `toml:"up"` - Down []string `toml:"down"` - Top []string `toml:"top"` - Bottom []string `toml:"bottom"` - Select []string `toml:"select"` - PlayShuffled []string `toml:"play_shuffled"` - GoHalfPageUp []string `toml:"go_half_page_up"` - GoHalfPageDown []string `toml:"go_half_page_down"` + Up []string `toml:"up"` + Down []string `toml:"down"` + Top []string `toml:"top"` + Bottom []string `toml:"bottom"` + Select []string `toml:"select"` + ToggleSelection []string `toml:"toggle_selection"` + PlayShuffled []string `toml:"play_shuffled"` + GoHalfPageUp []string `toml:"go_half_page_up"` + GoHalfPageDown []string `toml:"go_half_page_down"` } type SearchKeybinds struct { diff --git a/internal/api/config.toml b/internal/api/config.toml index 577de20..155b464 100644 --- a/internal/api/config.toml +++ b/internal/api/config.toml @@ -62,13 +62,14 @@ max_rating = 0 # Exclude songs with a rating less than or equal to this number ( hard_quit = ['ctrl+c'] [keybinds.navigation] - up = ['k', 'up'] - down = ['j', 'down'] - top = ['gg'] - bottom = ['G'] - select = ['enter'] - play_shuffled = ['alt+enter'] - go_half_page_up = ['ctrl+u'] + up = ['k', 'up'] + down = ['j', 'down'] + top = ['gg'] + bottom = ['G'] + select = ['enter'] + toggle_selection = ['x'] + play_shuffled = ['alt+enter'] + go_half_page_up = ['ctrl+u'] go_half_page_down = ['ctrl+d'] [keybinds.search] diff --git a/internal/ui/init.go b/internal/ui/init.go index c3e7a55..2f15386 100644 --- a/internal/ui/init.go +++ b/internal/ui/init.go @@ -28,6 +28,9 @@ func InitialModel() model { cursorMain: 0, cursorSide: 0, cursorPopup: 0, + showSelection: false, + selectionArray: make(map[int]bool), + selectionAnchor: -1, viewMode: startMode, filterMode: filterSongs, displayMode: displaySongs, diff --git a/internal/ui/model.go b/internal/ui/model.go index 2ed5415..6fa2a2f 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -91,6 +91,11 @@ type model struct { pageOffset int pageHasMore bool + // Selection state + showSelection bool + selectionAnchor int + selectionArray map[int]bool + // Mouse state lastClickTime time.Time lastClickId string diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 566d1dc..aa59b46 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -28,6 +28,7 @@ var ( cursorStyle lipgloss.Style cursorFocusedStyle lipgloss.Style currentPlaySongStyle lipgloss.Style + selectionStyle lipgloss.Style ) func checkColors(colors []string) lipgloss.AdaptiveColor { @@ -108,4 +109,9 @@ func InitStyles() { // Current playing song currentPlaySongStyle = lipgloss.NewStyle(). Foreground(Theme.Special) + + // Selection Style + selectionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#a8a8a8")) } diff --git a/internal/ui/update_keys.go b/internal/ui/update_keys.go index 8f08c64..c75d45c 100644 --- a/internal/ui/update_keys.go +++ b/internal/ui/update_keys.go @@ -110,6 +110,10 @@ func (m model) handlesKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return enter(m) } + if keyMatches(key, api.AppConfig.Keybinds.Navigation.ToggleSelection) { + return toggleSelection(m) + } + if keyMatches(key, api.AppConfig.Keybinds.Navigation.PlayShuffled) { return playShuffled(m) } @@ -442,6 +446,21 @@ func enter(m model) (tea.Model, tea.Cmd) { return m, nil } +func toggleSelection(m model) (tea.Model, tea.Cmd) { + if m.showSelection { + m.showSelection = false + m.selectionAnchor = -1 + } else { + m.showSelection = true + m.selectionAnchor = m.cursorMain + } + + // Clear map + m.selectionArray = make(map[int]bool) + + return m, nil +} + func playShuffled(m model) (tea.Model, tea.Cmd) { switch m.focus { case focusMain: @@ -515,6 +534,11 @@ func navigateTop(m model) model { case focusMain: m.cursorMain = 0 m.mainOffset = 0 + + if m.showSelection { + m = selectionScroller(m) + } + case focusSidebar: m.cursorSide = 0 m.sideOffset = 0 @@ -549,6 +573,10 @@ func navigateBottom(m model) (model, tea.Cmd) { m.mainOffset = 0 } + if m.showSelection { + m = selectionScroller(m) + } + case focusSidebar: total := len(albumTypes) + len(m.playlists) m.cursorSide = total - 1 @@ -590,6 +618,11 @@ func navigateUp(m model, steps int) model { if m.cursorMain < m.mainOffset { m.mainOffset = m.cursorMain } + + if m.showSelection { + m = selectionScroller(m) + } + case focusSidebar: m.cursorSide -= steps if m.cursorSide < 0 { @@ -619,44 +652,55 @@ func navigateDown(m model, steps int) (model, tea.Cmd) { } albumOffset := len(albumTypes) - if m.focus == focusMain && m.cursorMain < listLen-1 { - m.cursorMain += steps - if m.cursorMain > listLen-1 { - m.cursorMain = listLen - 1 - } + switch m.focus { + case focusMain: + if m.cursorMain < listLen-1 { + m.cursorMain += steps - // Height - Search(3) - Footer(6) - Margins(4) - TableHeader(2) = 17 - visibleRows := m.height - 17 - if m.cursorMain >= m.mainOffset+visibleRows { - m.mainOffset = m.cursorMain - visibleRows + 1 + if m.cursorMain > listLen-1 { + m.cursorMain = listLen - 1 + } + + // Height - Search(3) - Footer(6) - Margins(4) - TableHeader(2) = 17 + visibleRows := m.height - 17 + if m.cursorMain >= m.mainOffset+visibleRows { + m.mainOffset = m.cursorMain - visibleRows + 1 + } } - } else if m.focus == focusSidebar && m.cursorSide < len(m.playlists)+albumOffset-1 { // + because of the Album offset - m.cursorSide += steps - if m.cursorSide > len(m.playlists)+albumOffset-1 { - m.cursorSide = len(m.playlists) + albumOffset - 1 + if m.showSelection { + m = selectionScroller(m) } - headerHeight := 1 + case focusSidebar: + if m.cursorSide < len(m.playlists)+albumOffset-1 { // + because of the Album offset + m.cursorSide += steps - footerHeight := int(float64(m.height) * 0.10) - if footerHeight < 5 { - footerHeight = 5 - } + if m.cursorSide > len(m.playlists)+albumOffset-1 { + m.cursorSide = len(m.playlists) + albumOffset - 1 + } - mainHeight := m.height - headerHeight - footerHeight - (3 * 2) // 3 sections with each 2 borders (top and bottom) - if mainHeight < 0 { - mainHeight = 0 - } + headerHeight := 1 - visibleRows := mainHeight - 6 // Conservative estimate for headers - if visibleRows < 1 { - visibleRows = 1 - } + footerHeight := int(float64(m.height) * 0.10) + if footerHeight < 5 { + footerHeight = 5 + } + + mainHeight := m.height - headerHeight - footerHeight - (3 * 2) // 3 sections with each 2 borders (top and bottom) + if mainHeight < 0 { + mainHeight = 0 + } + + visibleRows := mainHeight - 6 // Conservative estimate for headers + if visibleRows < 1 { + visibleRows = 1 + } - if m.cursorSide >= m.sideOffset+visibleRows { - m.sideOffset = m.cursorSide - visibleRows + 1 + if m.cursorSide >= m.sideOffset+visibleRows { + m.sideOffset = m.cursorSide - visibleRows + 1 + } } } @@ -1613,6 +1657,7 @@ func loadMore(m model) (model, tea.Cmd) { return m, nil } +// Helper for checking if the cursor in bounds func cursorInBounds(m model) bool { switch m.displayMode { case displaySongs: @@ -1633,3 +1678,24 @@ func cursorInBounds(m model) bool { return false } + +func selectionScroller(m model) model { + if m.showSelection { + start := m.selectionAnchor + end := m.cursorMain + + // Swap if scrolling up + if start > end { + start, end = end, start + } + + // Clear selection + m.selectionArray = make(map[int]bool) + + // Select everything in range + for i := start; i <= end; i++ { + m.selectionArray[i] = true + } + } + return m +} diff --git a/internal/ui/view.go b/internal/ui/view.go index c6aa136..1484f10 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -407,7 +407,11 @@ func mainSongsContent(m model, width int, height int) string { } // Apply styling - row = style.Render(row) + if m.selectionArray[i] { + row = selectionStyle.Render(row) + } else { + row = style.Render(row) + } // Add zone ID zoneId = fmt.Sprintf("mainview_item_%d", i) @@ -478,7 +482,11 @@ func mainAlbumsContent(m model, width int, height int) string { } // Apply styling - row = style.Render(row) + if m.selectionArray[i] { + row = selectionStyle.Render(row) + } else { + row = style.Render(row) + } // Add zone ID zoneId = fmt.Sprintf("mainview_item_%d", i) @@ -549,7 +557,11 @@ func mainArtistContent(m model, width int, height int) string { } // Apply styling - row = style.Render(row) + if m.selectionArray[i] { + row = selectionStyle.Render(row) + } else { + row = style.Render(row) + } // Add zone ID zoneId = fmt.Sprintf("mainview_item_%d", i) @@ -1127,6 +1139,7 @@ func helpViewContent() string { line(keys(api.AppConfig.Keybinds.Navigation.Top), "Go to top"), line(keys(api.AppConfig.Keybinds.Navigation.Bottom), "Go to bottom"), line(keys(api.AppConfig.Keybinds.Navigation.Select), "Select"), + line(keys(api.AppConfig.Keybinds.Navigation.ToggleSelection), "Toggle Selection"), line(keys(api.AppConfig.Keybinds.Navigation.PlayShuffled), "Start shuffled"), line(keys(api.AppConfig.Keybinds.Navigation.GoHalfPageUp), "Go half page up"), line(keys(api.AppConfig.Keybinds.Navigation.GoHalfPageDown), "Go half page down"), From f44d6cb90ddfe5f56dee2837af4a8988a5258bac Mon Sep 17 00:00:00 2001 From: MattiaPun Date: Thu, 23 Apr 2026 19:02:56 +0200 Subject: [PATCH 2/3] feat(controls): added selection functionally for queuing, library/playlist management and creating share links --- internal/api/api.go | 94 ++++++-- internal/ui/cmds_api.go | 31 ++- internal/ui/init.go | 2 +- internal/ui/model.go | 2 +- internal/ui/queue.go | 21 +- internal/ui/update_keys.go | 430 +++++++++++++++++++++++++++---------- internal/ui/view.go | 6 +- 7 files changed, 422 insertions(+), 164 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 82eec8c..c588005 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -266,20 +266,45 @@ func SubsonicGetArtist(id string) ([]Album, error) { return data.Response.Artist.Albums, nil } -func SubsonicStar(id string) { - params := map[string]string{ - "id": id, +func SubsonicStar(ids []string) { + baseUrl := AppServerConfig.Server.URL + "/rest/star" + + v := getAuthParams() + for _, id := range ids { + v.Add("id", id) + } + + url := baseUrl + "?" + v.Encode() + + log.Printf("[API] Request: %s", redactURL(url)) + resp, err := httpClient.Get(url) + if err != nil { + log.Printf("[API] Failed to star: %v", err) + return } - _, _ = subsonicGET("/star", params) + defer func() { _ = resp.Body.Close() }() + } -func SubsonicUnstar(id string) { - params := map[string]string{ - "id": id, +func SubsonicUnstar(ids []string) { + baseUrl := AppServerConfig.Server.URL + "/rest/unstar" + + v := getAuthParams() + for _, id := range ids { + v.Add("id", id) } - _, _ = subsonicGET("/unstar", params) + url := baseUrl + "?" + v.Encode() + + log.Printf("[API] Request: %s", redactURL(url)) + resp, err := httpClient.Get(url) + if err != nil { + log.Printf("[API] Failed to star: %v", err) + return + } + + defer func() { _ = resp.Body.Close() }() } func SubsonicGetStarred() (*SearchResult3, error) { @@ -393,30 +418,57 @@ func SubsonicGetQueue() (*PlayQueue, error) { return &data.Response.PlayQueue, nil } -func SubsonicAddToPlaylist(songID string, playlistID string) { - params := map[string]string{ - "playlistId": playlistID, - "songIdToAdd": songID, +func SubsonicAddToPlaylist(playlistID string, songIds []string) { + baseUrl := AppServerConfig.Server.URL + "/rest/updatePlaylist" + + v := getAuthParams() + v.Set("playlistId", playlistID) + log.Printf("Playlist add: %s", playlistID) + + for _, id := range songIds { + v.Add("songIdToAdd", id) + log.Printf("Song to add: %s", id) } - _, _ = subsonicGET("/updatePlaylist", params) + url := baseUrl + "?" + v.Encode() + + log.Printf("[API] Request: %s", redactURL(url)) + resp, err := httpClient.Get(url) + if err != nil { + log.Printf("[API] Failed to update playlist: %v", err) + return + } + + defer func() { _ = resp.Body.Close() }() } -func SubsonicCreateShare(ID string) (string, error) { - params := map[string]string{ - "id": ID, +func SubsonicCreateShare(ids []string) (string, error) { + baseUrl := AppServerConfig.Server.URL + "/rest/createShare" + + v := getAuthParams() + for _, id := range ids { + v.Add("id", id) } - data, err := subsonicGET("/createShare", params) + url := baseUrl + "?" + v.Encode() + + log.Printf("[API] Request: %s", redactURL(url)) + resp, err := httpClient.Get(url) if err != nil { - log.Printf("[ERROR] API Error in CreateShare: %v", err) + log.Printf("[API] API Error in CreateShare: %v", err) + return "", err + } + + defer func() { _ = resp.Body.Close() }() + + var result SubsonicResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", err } - url := data.Response.Shares.ShareList[0].URL - log.Printf("[SHARE] Generated Share URL: %s", url) + shareUrl := result.Response.Shares.ShareList[0].URL + return shareUrl, nil - return url, nil } func SubsonicGetLyrics(ID string) ([]StructuredLyrics, error) { diff --git a/internal/ui/cmds_api.go b/internal/ui/cmds_api.go index 3754719..ad9d932 100644 --- a/internal/ui/cmds_api.go +++ b/internal/ui/cmds_api.go @@ -134,13 +134,10 @@ func openLikedSongsCmd() tea.Cmd { } } -func toggleStarCmd(id string, isCurrentlyStarred bool) tea.Cmd { +func toggleStarCmd(idsToStar []string, idsToUnstar []string) tea.Cmd { return func() tea.Msg { - if isCurrentlyStarred { - api.SubsonicUnstar(id) - } else { - api.SubsonicStar(id) - } + go api.SubsonicStar(idsToStar) + go api.SubsonicUnstar(idsToUnstar) return nil } } @@ -185,33 +182,35 @@ func savePlayQueueCmd(ids []string, currentID string) tea.Cmd { } } -func addSongToPlaylistCmd(songID string, playlistID string) tea.Cmd { +func addSongToPlaylistCmd(playlistID string, songIds []string) tea.Cmd { return func() tea.Msg { - if songID != "" && playlistID != "" { - api.SubsonicAddToPlaylist(songID, playlistID) + if playlistID == "" || len(songIds) == 0 { + return nil } + api.SubsonicAddToPlaylist(playlistID, songIds) return nil } } -func addRatingCmd(ID string, rating int) tea.Cmd { +func addRatingCmd(ids []string, rating int) tea.Cmd { return func() tea.Msg { - - if ID != "" && rating >= 0 && rating <= 5 { - api.SubsonicRate(ID, rating) + if rating >= 00 && rating <= 5 && len(ids) > 0 { + for i := range ids { + go api.SubsonicRate(ids[i], rating) + } } return nil } } -func createMediaShareCmd(ID string) tea.Cmd { +func createMediaShareCmd(ids []string) tea.Cmd { return func() tea.Msg { - if ID != "" { - url, err := api.SubsonicCreateShare(ID) + if len(ids) > 0 { + url, err := api.SubsonicCreateShare(ids) if err != nil { return errMsg{err} } diff --git a/internal/ui/init.go b/internal/ui/init.go index 2f15386..3e1adc3 100644 --- a/internal/ui/init.go +++ b/internal/ui/init.go @@ -29,8 +29,8 @@ func InitialModel() model { cursorSide: 0, cursorPopup: 0, showSelection: false, - selectionArray: make(map[int]bool), selectionAnchor: -1, + selectionMap: make(map[int]bool), viewMode: startMode, filterMode: filterSongs, displayMode: displaySongs, diff --git a/internal/ui/model.go b/internal/ui/model.go index 6fa2a2f..6f8b05f 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -94,7 +94,7 @@ type model struct { // Selection state showSelection bool selectionAnchor int - selectionArray map[int]bool + selectionMap map[int]bool // Mouse state lastClickTime time.Time diff --git a/internal/ui/queue.go b/internal/ui/queue.go index 58fbd79..c54de17 100644 --- a/internal/ui/queue.go +++ b/internal/ui/queue.go @@ -125,13 +125,26 @@ func getSelectedSongs(m model) []api.Song { case viewList: switch m.displayMode { case displaySongs: - return []api.Song{m.songs[m.cursorMain]} + + if m.showSelection { // Add selection + var songs []api.Song + for i := range m.selectionMap { + songs = append(songs, m.songs[i]) + } + + return songs + } else { // Add single song + return []api.Song{m.songs[m.cursorMain]} + } case displayAlbums: - songs, err := api.SubsonicGetAlbum(m.albums[m.cursorMain].ID) + var songs []api.Song - if err != nil { - return []api.Song{} + for i := range m.selectionMap { + albumSongs, err := api.SubsonicGetAlbum(m.albums[i].ID) + if err == nil { + songs = append(songs, albumSongs...) + } } songs = applyExclusionFilters(m, songs) diff --git a/internal/ui/update_keys.go b/internal/ui/update_keys.go index c75d45c..0cc1fb4 100644 --- a/internal/ui/update_keys.go +++ b/internal/ui/update_keys.go @@ -240,7 +240,7 @@ func (m model) handlesKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // FAVORITES KEYBINDS if keyMatches(key, api.AppConfig.Keybinds.Favorites.ToggleFavorite) { - return mediaToggleFavorite(m, msg) + return mediaToggleFavorite(m) } if keyMatches(key, api.AppConfig.Keybinds.Favorites.ViewFavorites) { @@ -325,6 +325,9 @@ func enter(m model) (tea.Model, tea.Cmd) { m.pageHasMore = true m.lastSearchQuery = query + // Reset selection + m = resetSelection(m) + switch m.filterMode { case filterSongs: m.displayMode = displaySongs @@ -367,6 +370,9 @@ func enter(m model) (tea.Model, tea.Cmd) { m.pageHasMore = true m.lastSearchQuery = "" + // Reset selection + m = resetSelection(m) + return m, getAlbumSongs(selectedAlbum.ID, false) } @@ -391,6 +397,9 @@ func enter(m model) (tea.Model, tea.Cmd) { m.pageHasMore = true m.lastSearchQuery = "" + // Reset selection + m = resetSelection(m) + return m, getArtistAlbums(selectedArtist.ID) } } @@ -408,6 +417,9 @@ func enter(m model) (tea.Model, tea.Cmd) { m.focus = focusMain m.viewMode = viewList + // Reset selection + m = resetSelection(m) + if m.cursorSide < albumOffset { m.displayMode = displayAlbums // Initialize pagination state @@ -456,7 +468,7 @@ func toggleSelection(m model) (tea.Model, tea.Cmd) { } // Clear map - m.selectionArray = make(map[int]bool) + m.selectionMap = make(map[int]bool) return m, nil } @@ -827,7 +839,6 @@ func cycleFilter(m model, forward bool) model { func toggleQueue(m model) model { if m.focus != focusSearch { - switch m.viewMode { case viewList: m.viewMode = viewQueue @@ -845,6 +856,9 @@ func toggleQueue(m model) model { m.cursorMain = 0 m.mainOffset = 0 } + + // Reset selection + m = resetSelection(m) } return m @@ -908,10 +922,10 @@ func mediaQueueNext(m model) model { selectedSongs := getSelectedSongs(m) if selectedSongs != nil { - if len(m.queue) == 0 { + if len(m.queue) == 0 { // Create a new queue m.queue = selectedSongs m.queueIndex = 0 - } else { + } else { // Add to current queue insertAt := m.queueIndex + 1 tail := append([]api.Song{}, m.queue[insertAt:]...) m.queue = append(m.queue[:insertAt], append(selectedSongs, tail...)...) @@ -946,17 +960,58 @@ func mediaQueueLast(m model) model { } func mediaDeleteSongFromQueue(m model) model { - if m.focus == focusMain && m.viewMode == viewQueue && len(m.queue) > 0 { - if m.cursorMain != m.queueIndex { - m.queue = append(m.queue[:m.cursorMain], m.queue[m.cursorMain+1:]...) - if m.cursorMain < m.queueIndex { - m.queueIndex-- + if m.focus != focusMain || m.viewMode != viewQueue || len(m.queue) == 0 { + return m + } + + // Get songs to delete + toDelete := make(map[int]bool) + if m.showSelection && len(m.selectionMap) > 0 { + for k := range m.selectionMap { + toDelete[k] = true + } + } else { + toDelete[m.cursorMain] = true + } + + // Do not remove current playing song + if toDelete[m.queueIndex] { + delete(toDelete, m.queueIndex) + } + + // Return if nothing to delete + if len(toDelete) == 0 { + return m + } + + var newQueue []api.Song + newQueueIndex := m.queueIndex + + for i, song := range m.queue { + if toDelete[i] { + // Shift index if song before index is deleted + if i < m.queueIndex { + newQueueIndex-- } + } else { + newQueue = append(newQueue, song) // Keep the song } } - if m.cursorMain >= len(m.queue) && m.cursorMain > 0 { - m.cursorMain-- + // Set new queue + m.queue = newQueue + m.queueIndex = newQueueIndex + + // Update cursor if out of bounds + if m.cursorMain >= len(m.queue) { + m.cursorMain = len(m.queue) - 1 + if m.cursorMain < 0 { + m.cursorMain = 0 + } + } + + if m.showSelection { + m = resetSelection(m) } // Sync MPV's Queue @@ -964,7 +1019,6 @@ func mediaDeleteSongFromQueue(m model) model { return m } - func mediaClearQueue(m model) model { if m.focus == focusMain { m.queue = nil @@ -982,51 +1036,96 @@ func mediaClearQueue(m model) model { } func mediaSongUpQueue(m model) model { - if m.focus == focusMain && m.viewMode == viewQueue && m.cursorMain > 0 { - tempSong := m.queue[m.cursorMain] + if m.focus != focusMain || m.viewMode != viewQueue { + return m + } + + minIndex, maxIndex := m.cursorMain, m.cursorMain + if m.showSelection && len(m.selectionMap) > 0 { + minIndex, maxIndex = -1, -1 + for i := range m.selectionMap { + if minIndex == -1 || i < minIndex { + minIndex = i + } + if maxIndex == -1 || i > maxIndex { + maxIndex = i + } + } + } + + if minIndex > 0 { + target := m.queue[minIndex-1] - m.queue[m.cursorMain] = m.queue[m.cursorMain-1] - m.queue[m.cursorMain-1] = tempSong + copy(m.queue[minIndex-1:maxIndex], m.queue[minIndex:maxIndex+1]) + m.queue[maxIndex] = target - switch m.queueIndex { - case m.cursorMain: + if m.queueIndex == minIndex-1 { + m.queueIndex = maxIndex + } else if m.queueIndex >= minIndex && m.queueIndex <= maxIndex { m.queueIndex-- - case m.cursorMain - 1: - m.queueIndex++ + } + + if m.showSelection { + newMap := make(map[int]bool) + for k := range m.selectionMap { + newMap[k-1] = true + } + m.selectionMap = newMap + m.selectionAnchor-- } m.cursorMain-- } - // Sync MPV's Queue m.syncNextSong() - return m } func mediaSongDownQueue(m model) model { - if m.focus == focusMain && m.viewMode == viewQueue && m.cursorMain < len(m.queue)-1 { - tempSong := m.queue[m.cursorMain] + if m.focus != focusMain || m.viewMode != viewQueue { + return m + } - m.queue[m.cursorMain] = m.queue[m.cursorMain+1] - m.queue[m.cursorMain+1] = tempSong + minIndex, maxIndex := m.cursorMain, m.cursorMain + if m.showSelection && len(m.selectionMap) > 0 { + minIndex, maxIndex = -1, -1 + for i := range m.selectionMap { + if minIndex == -1 || i < minIndex { + minIndex = i + } + if maxIndex == -1 || i > maxIndex { + maxIndex = i + } + } + } + + if maxIndex < len(m.queue)-1 { + target := m.queue[maxIndex+1] + + copy(m.queue[minIndex+1:maxIndex+2], m.queue[minIndex:maxIndex+1]) + m.queue[minIndex] = target - switch m.queueIndex { - case m.cursorMain: + if m.queueIndex == maxIndex+1 { + m.queueIndex = minIndex + } else if m.queueIndex >= minIndex && m.queueIndex <= maxIndex { m.queueIndex++ - case m.cursorMain + 1: - m.queueIndex-- + } + + if m.showSelection { + newMap := make(map[int]bool) + for k := range m.selectionMap { + newMap[k+1] = true + } + m.selectionMap = newMap + m.selectionAnchor++ } m.cursorMain++ } - // Sync MPV's Queue m.syncNextSong() - return m } - func mediaRestartSong(m model) model { if m.focus != focusSearch { player.RestartSong() @@ -1122,58 +1221,40 @@ func mediaToggleLoop(m model) model { return m } -func mediaToggleFavorite(m model, msg tea.Msg) (model, tea.Cmd) { - var id string +func mediaToggleFavorite(m model) (model, tea.Cmd) { + var ids []string + var idsToStar []string + var idsToUnstar []string switch m.focus { - case focusSearch: // Focus search bar - return typeInput(m, msg) - case focusMain: // Focus main view - switch m.displayMode { - case displaySongs: // Songs - var targetList []api.Song - switch m.viewMode { - case viewList: - targetList = m.songs - case viewQueue: - targetList = m.queue - } - - if len(targetList) > 0 { - id = targetList[m.cursorMain].ID - } - - case displayAlbums: // Albums - if len(m.albums) > 0 { - id = m.albums[m.cursorMain].ID - } - case displayArtist: // Artists - if len(m.artists) > 0 { - id = m.artists[m.cursorMain].ID - } - } + ids = selectionIdsGetter(m) case focusSong: // Focus footer if len(m.queue) > 0 { - id = m.queue[m.queueIndex].ID + ids = []string{m.queue[m.queueIndex].ID} } } // Return on no ID - if id == "" { + if len(ids) == 0 { return m, nil } - // Toggle favorite - isStarred := m.starredMap[id] - if isStarred { - delete(m.starredMap, id) - } else { - m.starredMap[id] = true + // Toggle favorite status + for i := range ids { + id := ids[i] + isStarred := m.starredMap[id] + if isStarred { + delete(m.starredMap, id) + idsToUnstar = append(idsToUnstar, id) + } else { + m.starredMap[id] = true + idsToStar = append(idsToStar, id) + } } - return m, toggleStarCmd(id, isStarred) + return m, toggleStarCmd(idsToStar, idsToUnstar) } func mediaShowFavorites(m model, msg tea.Msg) (model, tea.Cmd) { @@ -1238,21 +1319,42 @@ func mediaCreateShare(m model) tea.Cmd { return nil } - var id string + var ids []string + switch m.displayMode { + case displaySongs: + var targetList []api.Song - switch { - case m.viewMode == viewList && m.displayMode == displaySongs && len(m.songs) > 0: - id = m.songs[m.cursorMain].ID + switch m.viewMode { + case viewList: + targetList = m.songs + case viewQueue: + targetList = m.queue + } - case m.viewMode == viewList && m.displayMode == displayAlbums && len(m.albums) > 0: - id = m.albums[m.cursorMain].ID + if len(targetList) > 0 { + if m.showSelection { // Add selection + for i := range m.selectionMap { + ids = append(ids, targetList[i].ID) + } + } else { // Add single song + ids = []string{targetList[m.cursorMain].ID} + } + } - case m.viewMode == viewQueue && len(m.queue) > 0: - id = m.queue[m.cursorMain].ID + case displayAlbums: // Albums + if len(m.albums) > 0 { + if m.showSelection { // Add selection + for i := range m.selectionMap { + ids = append(ids, m.albums[i].ID) + } + } else { // Add single album + ids = []string{m.albums[m.cursorMain].ID} + } + } } - if id != "" { - return createMediaShareCmd(id) + if len(ids) > 0 { + return createMediaShareCmd(ids) } return nil @@ -1522,7 +1624,6 @@ func playerMenu(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func playlistsMenu(key string, m model) (model, tea.Cmd) { - var cmd tea.Cmd if keyMatches(key, api.AppConfig.Keybinds.Global.Back) || keyMatches(key, api.AppConfig.Keybinds.Library.AddToPlaylist) { m.showPlaylists = false m.cursorPopup = 0 @@ -1542,15 +1643,38 @@ func playlistsMenu(key string, m model) (model, tea.Cmd) { m.cursorPopup++ } } else if keyMatches(key, api.AppConfig.Keybinds.Navigation.Select) { + var targetList []api.Song + var ids []string inBounds := cursorInBounds(m) - if m.viewMode == viewList && inBounds { - cmd = addSongToPlaylistCmd(m.songs[m.cursorMain].ID, m.playlists[m.cursorPopup].ID) - } else if m.viewMode == viewQueue && inBounds { - cmd = addSongToPlaylistCmd(m.queue[m.cursorMain].ID, m.playlists[m.cursorPopup].ID) + // Cursor out of bounds + if !inBounds { + return m, nil + } + + switch m.viewMode { + case viewList: + targetList = m.songs + case viewQueue: + targetList = m.queue + } + + // No target list + if len(targetList) == 0 { + return m, nil } + + if m.showSelection { // Add selection + for i := range m.selectionMap { + ids = append(ids, targetList[i].ID) + } + } else { // Add single song + ids = []string{targetList[m.cursorMain].ID} + } + + // Toggle playlist view m.showPlaylists = !m.showPlaylists - return m, cmd + return m, addSongToPlaylistCmd(m.playlists[m.cursorPopup].ID, ids) } return m, nil @@ -1569,58 +1693,68 @@ func ratingMenu(key string, m model) (model, tea.Cmd) { } else if keyMatches(key, api.AppConfig.Keybinds.Navigation.Down) && m.cursorPopup < 5 { m.cursorPopup++ } else if keyMatches(key, api.AppConfig.Keybinds.Navigation.Select) && cursorInBounds(m) { + m, cmd = setRating(m, m.cursorPopup) - switch m.displayMode { - case displaySongs: - switch m.viewMode { - case viewList: - m.songs[m.cursorMain].Rating = m.cursorPopup - cmd = addRatingCmd(m.songs[m.cursorMain].ID, m.cursorPopup) - case viewQueue: - m.queue[m.cursorMain].Rating = m.cursorPopup - cmd = addRatingCmd(m.queue[m.cursorMain].ID, m.cursorPopup) - } - case displayAlbums: - cmd = addRatingCmd(m.albums[m.cursorMain].ID, m.cursorPopup) - m.albums[m.cursorMain].Rating = m.cursorPopup - case displayArtist: - cmd = addRatingCmd(m.artists[m.cursorMain].ID, m.cursorPopup) - m.artists[m.cursorMain].Rating = m.cursorPopup - } - + // Reset popup m.cursorPopup = 0 m.showRating = !m.showRating + return m, cmd } return m, nil } -func setRating(m model, rating int) (tea.Model, tea.Cmd) { +func setRating(m model, rating int) (model, tea.Cmd) { if !cursorInBounds(m) { return m, nil } - var cmd tea.Cmd + var ids []string switch m.displayMode { case displaySongs: + var targetList []api.Song switch m.viewMode { case viewList: - m.songs[m.cursorMain].Rating = rating - cmd = addRatingCmd(m.songs[m.cursorMain].ID, rating) + targetList = m.songs case viewQueue: - m.queue[m.cursorMain].Rating = rating - cmd = addRatingCmd(m.queue[m.cursorMain].ID, rating) + targetList = m.queue } + + if m.showSelection { // Add selection + for i := range m.selectionMap { + targetList[i].Rating = rating + ids = append(ids, targetList[i].ID) + } + } else { // Add single album + targetList[m.cursorMain].Rating = rating + ids = []string{targetList[m.cursorMain].ID} + } + case displayAlbums: - m.albums[m.cursorMain].Rating = rating - cmd = addRatingCmd(m.albums[m.cursorMain].ID, rating) + if m.showSelection { // Add selection + for i := range m.selectionMap { + m.albums[i].Rating = rating + ids = append(ids, m.albums[i].ID) + } + } else { // Add single album + m.albums[m.cursorMain].Rating = rating + ids = []string{m.albums[m.cursorMain].ID} + } + case displayArtist: - m.artists[m.cursorMain].Rating = rating - cmd = addRatingCmd(m.artists[m.cursorMain].ID, rating) + if m.showSelection { // Add selection + for i := range m.selectionMap { + m.artists[i].Rating = rating + ids = append(ids, m.artists[i].ID) + } + } else { // Add single artist + m.artists[m.cursorMain].Rating = rating + ids = []string{m.artists[m.cursorMain].ID} + } } - return m, cmd + return m, addRatingCmd(ids, rating) } // Helper for infinte scrolling @@ -1679,6 +1813,7 @@ func cursorInBounds(m model) bool { return false } +// Helper for highlighting selections func selectionScroller(m model) model { if m.showSelection { start := m.selectionAnchor @@ -1690,12 +1825,71 @@ func selectionScroller(m model) model { } // Clear selection - m.selectionArray = make(map[int]bool) + m.selectionMap = make(map[int]bool) // Select everything in range for i := start; i <= end; i++ { - m.selectionArray[i] = true + m.selectionMap[i] = true } } return m } + +// Helper for getting all ID's from a selection +func selectionIdsGetter(m model) []string { + var ids []string + + switch m.displayMode { + case displaySongs: // Songs + var targetList []api.Song + switch m.viewMode { + case viewList: + targetList = m.songs + case viewQueue: + targetList = m.queue + } + + if len(targetList) > 0 { + if m.showSelection { // Add selection + for i := range m.selectionMap { + ids = append(ids, targetList[i].ID) + } + } else { // Add single song + ids = []string{targetList[m.cursorMain].ID} + } + } + + case displayAlbums: // Albums + if len(m.albums) > 0 { + if m.showSelection { // Add selection + for i := range m.selectionMap { + ids = append(ids, m.albums[i].ID) + } + } else { // Add single album + ids = []string{m.albums[m.cursorMain].ID} + } + } + + case displayArtist: // Artists + if len(m.artists) > 0 { + if m.showSelection { // Add selection + for i := range m.selectionMap { + ids = append(ids, m.artists[i].ID) + } + } else { // Add single artist + ids = []string{m.artists[m.cursorMain].ID} + } + } + } + + return ids +} + +// Helper for resetting selection state +func resetSelection(m model) model { + m.showSelection = false + m.selectionAnchor = -1 + m.selectionMap = make(map[int]bool) + + return m +} diff --git a/internal/ui/view.go b/internal/ui/view.go index 1484f10..c79f053 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -407,7 +407,7 @@ func mainSongsContent(m model, width int, height int) string { } // Apply styling - if m.selectionArray[i] { + if m.selectionMap[i] { row = selectionStyle.Render(row) } else { row = style.Render(row) @@ -482,7 +482,7 @@ func mainAlbumsContent(m model, width int, height int) string { } // Apply styling - if m.selectionArray[i] { + if m.selectionMap[i] { row = selectionStyle.Render(row) } else { row = style.Render(row) @@ -557,7 +557,7 @@ func mainArtistContent(m model, width int, height int) string { } // Apply styling - if m.selectionArray[i] { + if m.selectionMap[i] { row = selectionStyle.Render(row) } else { row = style.Render(row) From 737d3673622368709451198c7621fdd37c81e60c Mon Sep 17 00:00:00 2001 From: MattiaPun Date: Thu, 23 Apr 2026 19:17:00 +0200 Subject: [PATCH 3/3] refactor(api): changed url parameter map to url values --- internal/api/api.go | 202 ++++++++++++++++---------------------------- 1 file changed, 73 insertions(+), 129 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index c588005..ea6d465 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -88,13 +88,15 @@ func redactURL(rawUrl string) string { return parsed.String() } -func subsonicGET(endpoint string, params map[string]string) (*SubsonicResponse, error) { +func subsonicGET(endpoint string, params url.Values) (*SubsonicResponse, error) { baseUrl := AppServerConfig.Server.URL + "/rest" + endpoint v := getAuthParams() - for key, value := range params { - v.Set(key, value) + for key, values := range params { + for _, value := range values { + v.Add(key, value) + } } fullUrl := baseUrl + "?" + v.Encode() @@ -121,8 +123,8 @@ func subsonicGET(endpoint string, params map[string]string) (*SubsonicResponse, } func SubsonicLoginCheck() error { - params := map[string]string{ - "username": AppServerConfig.Server.Username, + params := url.Values{ + "username": {AppServerConfig.Server.Username}, } data, err := subsonicGET("/getUser", params) @@ -145,14 +147,14 @@ func SubsonicLoginCheck() error { } func SubsonicSearchArtist(query string, offset int) ([]Artist, error) { - params := map[string]string{ - "query": query, - "artistCount": "150", - "artistOffset": strconv.Itoa(offset), - "albumCount": "0", - "albumOffset": "0", - "songCount": "0", - "songOffset": "0", + params := url.Values{ + "query": {query}, + "artistCount": {"150"}, + "artistOffset": {strconv.Itoa(offset)}, + "albumCount": {"0"}, + "albumOffset": {"0"}, + "songCount": {"0"}, + "songOffset": {"0"}, } data, err := subsonicGET("/search3", params) @@ -164,14 +166,14 @@ func SubsonicSearchArtist(query string, offset int) ([]Artist, error) { } func SubsonicSearchAlbum(query string, offset int) ([]Album, error) { - params := map[string]string{ - "query": query, - "artistCount": "0", - "artistOffset": "0", - "albumCount": "150", - "albumOffset": strconv.Itoa(offset), - "songCount": "0", - "songOffset": "0", + params := url.Values{ + "query": {query}, + "artistCount": {"0"}, + "artistOffset": {"0"}, + "albumCount": {"150"}, + "albumOffset": {strconv.Itoa(offset)}, + "songCount": {"0"}, + "songOffset": {"0"}, } data, err := subsonicGET("/search3", params) @@ -183,14 +185,14 @@ func SubsonicSearchAlbum(query string, offset int) ([]Album, error) { } func SubsonicSearchSong(query string, offset int) ([]Song, error) { - params := map[string]string{ - "query": query, - "artistCount": "0", - "artistOffset": "0", - "albumCount": "0", - "albumOffset": "0", - "songCount": "150", - "songOffset": strconv.Itoa(offset), + params := url.Values{ + "query": {query}, + "artistCount": {"0"}, + "artistOffset": {"0"}, + "albumCount": {"0"}, + "albumOffset": {"0"}, + "songCount": {"150"}, + "songOffset": {strconv.Itoa(offset)}, } data, err := subsonicGET("/search3", params) @@ -202,8 +204,8 @@ func SubsonicSearchSong(query string, offset int) ([]Song, error) { } func SubsonicGetPlaylistSongs(id string) ([]Song, error) { - params := map[string]string{ - "id": id, + params := url.Values{ + "id": {id}, } data, err := subsonicGET("/getPlaylist", params) @@ -215,7 +217,7 @@ func SubsonicGetPlaylistSongs(id string) ([]Song, error) { } func SubsonicGetPlaylists() ([]Playlist, error) { - params := map[string]string{} + params := url.Values{} data, err := subsonicGET("/getPlaylists", params) if err != nil { @@ -226,8 +228,8 @@ func SubsonicGetPlaylists() ([]Playlist, error) { } func SubsonicGetAlbum(id string) ([]Song, error) { - params := map[string]string{ - "id": id, + params := url.Values{ + "id": {id}, } data, err := subsonicGET("/getAlbum", params) @@ -239,10 +241,10 @@ func SubsonicGetAlbum(id string) ([]Song, error) { } func SubsonicGetAlbumList(searchType string, offset int) ([]Album, error) { - params := map[string]string{ - "type": searchType, - "size": "150", - "offset": strconv.Itoa(offset), + params := url.Values{ + "type": {searchType}, + "size": {"150"}, + "offset": {strconv.Itoa(offset)}, } data, err := subsonicGET("/getAlbumList", params) @@ -254,8 +256,8 @@ func SubsonicGetAlbumList(searchType string, offset int) ([]Album, error) { } func SubsonicGetArtist(id string) ([]Album, error) { - params := map[string]string{ - "id": id, + params := url.Values{ + "id": {id}, } data, err := subsonicGET("/getArtist", params) @@ -267,44 +269,21 @@ func SubsonicGetArtist(id string) ([]Album, error) { } func SubsonicStar(ids []string) { - baseUrl := AppServerConfig.Server.URL + "/rest/star" - - v := getAuthParams() + params := url.Values{} for _, id := range ids { - v.Add("id", id) - } - - url := baseUrl + "?" + v.Encode() - - log.Printf("[API] Request: %s", redactURL(url)) - resp, err := httpClient.Get(url) - if err != nil { - log.Printf("[API] Failed to star: %v", err) - return + params.Add("id", id) } - defer func() { _ = resp.Body.Close() }() - + _, _ = subsonicGET("/star", params) } func SubsonicUnstar(ids []string) { - baseUrl := AppServerConfig.Server.URL + "/rest/unstar" - - v := getAuthParams() + params := url.Values{} for _, id := range ids { - v.Add("id", id) + params.Add("id", id) } - url := baseUrl + "?" + v.Encode() - - log.Printf("[API] Request: %s", redactURL(url)) - resp, err := httpClient.Get(url) - if err != nil { - log.Printf("[API] Failed to star: %v", err) - return - } - - defer func() { _ = resp.Body.Close() }() + _, _ = subsonicGET("/unstar", params) } func SubsonicGetStarred() (*SearchResult3, error) { @@ -321,9 +300,9 @@ func SubsonicGetStarred() (*SearchResult3, error) { } func SubsonicRate(ID string, rating int) { - params := map[string]string{ - "id": ID, - "rating": strconv.Itoa(rating), + params := url.Values{ + "id": {ID}, + "rating": {strconv.Itoa(rating)}, } _, _ = subsonicGET("/setRating", params) @@ -345,10 +324,10 @@ func SubsonicStream(id string) string { func SubsonicScrobble(id string, submission bool) { time := strconv.FormatInt(time.Now().UTC().UnixMilli(), 10) - params := map[string]string{ - "id": id, - "time": time, - "submission": strconv.FormatBool(submission), + params := url.Values{ + "id": {id}, + "time": {time}, + "submission": {strconv.FormatBool(submission)}, } _, _ = subsonicGET("/scrobble", params) @@ -386,29 +365,19 @@ func SubsonicCoverArt(id string, size int) ([]byte, error) { } func SubsonicSaveQueue(ids []string, currentID string) { - baseUrl := AppServerConfig.Server.URL + "/rest/savePlayQueue" - - v := getAuthParams() - - v.Set("current", currentID) - for _, id := range ids { - v.Add("id", id) + params := url.Values{ + "current": {currentID}, } - url := baseUrl + "?" + v.Encode() - - log.Printf("[API] Request: %s", redactURL(url)) - resp, err := httpClient.Get(url) - if err != nil { - log.Printf("[API] Failed to save queue: %v", err) - return + for _, id := range ids { + params.Add("id", id) } - defer func() { _ = resp.Body.Close() }() + _, _ = subsonicGET("/savePlayQueue", params) } func SubsonicGetQueue() (*PlayQueue, error) { - params := map[string]string{} + params := url.Values{} data, err := subsonicGET("/getPlayQueue", params) if err != nil { @@ -419,61 +388,36 @@ func SubsonicGetQueue() (*PlayQueue, error) { } func SubsonicAddToPlaylist(playlistID string, songIds []string) { - baseUrl := AppServerConfig.Server.URL + "/rest/updatePlaylist" - - v := getAuthParams() - v.Set("playlistId", playlistID) - log.Printf("Playlist add: %s", playlistID) - - for _, id := range songIds { - v.Add("songIdToAdd", id) - log.Printf("Song to add: %s", id) + params := url.Values{ + "playlistId": {playlistID}, } - url := baseUrl + "?" + v.Encode() - - log.Printf("[API] Request: %s", redactURL(url)) - resp, err := httpClient.Get(url) - if err != nil { - log.Printf("[API] Failed to update playlist: %v", err) - return + for _, id := range songIds { + params.Add("id", id) } - defer func() { _ = resp.Body.Close() }() + _, _ = subsonicGET("/updatePlaylist", params) } func SubsonicCreateShare(ids []string) (string, error) { - baseUrl := AppServerConfig.Server.URL + "/rest/createShare" + params := url.Values{} - v := getAuthParams() for _, id := range ids { - v.Add("id", id) + params.Add("id", id) } - url := baseUrl + "?" + v.Encode() - - log.Printf("[API] Request: %s", redactURL(url)) - resp, err := httpClient.Get(url) + data, err := subsonicGET("/createShare", params) if err != nil { - log.Printf("[API] API Error in CreateShare: %v", err) - return "", err - } - - defer func() { _ = resp.Body.Close() }() - - var result SubsonicResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", err + log.Printf("[ERROR] API Error in CreateShare: %s", err) } - shareUrl := result.Response.Shares.ShareList[0].URL - return shareUrl, nil + return data.Response.Shares.ShareList[0].URL, nil } func SubsonicGetLyrics(ID string) ([]StructuredLyrics, error) { - params := map[string]string{ - "id": ID, + params := url.Values{ + "id": {ID}, } data, err := subsonicGET("/getLyricsBySongId", params)