diff --git a/README.md b/README.md index bab3d28..0315606 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,19 @@ These screenshots use [Navidrome's demo server](https://demo.navidrome.org/) ([c ### Queue -![Queue View](./docs/screenshots/queue.png) +![Queue View](./docs/screenshots/queue_scaled.png) ### Browser -![Browser View](./docs/screenshots/browser.png) +![Browser View](./docs/screenshots/browser_scaled.png) + +### Playlists + +![Playlists View](./docs/screenshots/playlists_scaled.png) + +### Search + +![Search View](./docs/screenshots/search_scaled.png) ## Dependencies @@ -96,6 +104,7 @@ These controls are accessible from any view: - `-`/`=`: Volume down/volume up - `,`/`.`: Seek -10/+10 seconds - `r`: Add 50 random songs to the queue +- `s`: Start a server library scan ### Browser Controls @@ -116,9 +125,13 @@ These controls are accessible from any view: - `y`: Toggle star on song - `k`: Move song up in queue - `j`: Move song down in queue +- `s`: Save the queue as a playlist +- `S`: Shuffle the songs in the queue If the currently playing song is moved, the music is stopped before the move, and must be re-started manually. +The save function includes an autocomplete function; if an existing playlist is selected (or manually entered), the `Overwrite` checkbox **must** be checked, or else the queue will not be saved. If a playlist is saved over, it will be **replaced** with the queue contents. + ### Playlist Controls - `n`: New playlist diff --git a/docs/screenshots/browser.png b/docs/screenshots/browser.png index b4affcd..3c0b34f 100644 Binary files a/docs/screenshots/browser.png and b/docs/screenshots/browser.png differ diff --git a/docs/screenshots/queue.png b/docs/screenshots/queue.png index a0765f5..a4dc937 100644 Binary files a/docs/screenshots/queue.png and b/docs/screenshots/queue.png differ diff --git a/gui.go b/gui.go index 934fd47..0e3930d 100644 --- a/gui.go +++ b/gui.go @@ -42,10 +42,12 @@ type Ui struct { logPage *LogPage // modals - addToPlaylistList *tview.List - messageBox *tview.Modal - helpModal tview.Primitive - helpWidget *HelpWidget + addToPlaylistList *tview.List + messageBox *tview.Modal + helpModal tview.Primitive + helpWidget *HelpWidget + selectPlaylistModal tview.Primitive + selectPlaylistWidget *PlaylistSelectionWidget starIdList map[string]struct{} @@ -72,6 +74,7 @@ const ( PageAddToPlaylist = "addToPlaylist" PageMessageBox = "messageBox" PageHelpBox = "helpBox" + PageSelectPlaylist = "selectPlaylist" ) func InitGui(indexes *[]subsonic.SubsonicIndex, @@ -112,6 +115,7 @@ func InitGui(indexes *[]subsonic.SubsonicIndex, ui.menuWidget = ui.createMenuWidget() ui.helpWidget = ui.createHelpWidget() + ui.selectPlaylistWidget = ui.createPlaylistSelectionWidget() // same as 'playlistList' except for the addToPlaylistModal // - we need a specific version of this because we need different keybinds @@ -126,6 +130,8 @@ func InitGui(indexes *[]subsonic.SubsonicIndex, return event }) + ui.selectPlaylistModal = makeModal(ui.selectPlaylistWidget.Root, 80, 5) + // help box modal ui.helpModal = makeModal(ui.helpWidget.Root, 80, 30) ui.helpWidget.Root.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { @@ -166,6 +172,7 @@ func InitGui(indexes *[]subsonic.SubsonicIndex, AddPage(PageDeletePlaylist, ui.playlistPage.DeletePlaylistModal, true, false). AddPage(PageNewPlaylist, ui.playlistPage.NewPlaylistModal, true, false). AddPage(PageAddToPlaylist, ui.browserPage.AddToPlaylistModal, true, false). + AddPage(PageSelectPlaylist, ui.selectPlaylistModal, true, false). AddPage(PageMessageBox, ui.messageBox, true, false). AddPage(PageHelpBox, ui.helpModal, true, false). AddPage(PageLog, ui.logPage.Root, true, false) @@ -217,6 +224,18 @@ func (ui *Ui) CloseHelp() { ui.pages.HidePage(PageHelpBox) } +func (ui *Ui) ShowSelectPlaylist() { + ui.pages.ShowPage(PageSelectPlaylist) + ui.pages.SendToFront(PageSelectPlaylist) + ui.app.SetFocus(ui.selectPlaylistModal) + ui.selectPlaylistWidget.visible = true +} + +func (ui *Ui) CloseSelectPlaylist() { + ui.pages.HidePage(PageSelectPlaylist) + ui.selectPlaylistWidget.visible = false +} + func (ui *Ui) showMessageBox(text string) { ui.pages.ShowPage(PageMessageBox) ui.messageBox.SetText(text) diff --git a/gui_handlers.go b/gui_handlers.go index dd71e9b..09682c6 100644 --- a/gui_handlers.go +++ b/gui_handlers.go @@ -12,7 +12,7 @@ import ( func (ui *Ui) handlePageInput(event *tcell.EventKey) *tcell.EventKey { // we don't want any of these firing if we're trying to add a new playlist focused := ui.app.GetFocus() - if ui.playlistPage.IsNewPlaylistInputFocused(focused) || ui.browserPage.IsSearchFocused(focused) || focused == ui.searchPage.searchField { + if ui.playlistPage.IsNewPlaylistInputFocused(focused) || ui.browserPage.IsSearchFocused(focused) || focused == ui.searchPage.searchField || ui.selectPlaylistWidget.visible { return event } @@ -99,6 +99,11 @@ func (ui *Ui) handlePageInput(event *tcell.EventKey) *tcell.EventKey { } ui.queuePage.UpdateQueue() + case 's': + if err := ui.connection.StartScan(); err != nil { + ui.logger.PrintError("startScan:", err) + } + default: return event } @@ -112,6 +117,7 @@ func (ui *Ui) ShowPage(name string) { } func (ui *Ui) Quit() { + // TODO savePlayQueue/getPlayQueue ui.player.Quit() ui.app.Stop() } @@ -166,6 +172,7 @@ func (ui *Ui) addSongToQueue(entity *subsonic.SubsonicEntity) { Album: album, TrackNumber: entity.Track, CoverArtId: entity.CoverArtId, + DiscNumber: entity.DiscNumber, } ui.player.AddToQueue(queueItem) } @@ -180,6 +187,7 @@ func makeSongHandler(entity *subsonic.SubsonicEntity, ui *Ui, fallbackArtist str duration := entity.Duration track := entity.Track coverArtId := entity.CoverArtId + disc := entity.DiscNumber response, err := ui.connection.GetAlbum(entity.Parent) album := "" @@ -198,6 +206,7 @@ func makeSongHandler(entity *subsonic.SubsonicEntity, ui *Ui, fallbackArtist str return func() { if err := ui.player.PlayUri(id, uri, title, artist, album, duration, track, coverArtId); err != nil { + if err := ui.player.PlayUri(id, uri, title, artist, album, duration, track, disc); err != nil { ui.logger.PrintError("SongHandler Play", err) return } diff --git a/help_text.go b/help_text.go index 667110a..bea4e2e 100644 --- a/help_text.go +++ b/help_text.go @@ -10,6 +10,7 @@ P stop -/=(+) volume down/volume up ,/. seek -10/+10 seconds r add 50 random songs to queue +s start server library scan ` const helpPageBrowser = ` @@ -34,6 +35,7 @@ D remove all songs from queue y toggle star on song k move selected song up in queue j move selected song down in queue +s save queue as a playlist S shuffle the current queue ` diff --git a/mpvplayer/player.go b/mpvplayer/player.go index 151297c..faa67ac 100644 --- a/mpvplayer/player.go +++ b/mpvplayer/player.go @@ -125,8 +125,8 @@ func (p *Player) PlayNextTrack() error { return nil } -func (p *Player) PlayUri(id, uri, title, artist, album string, duration, track int, coverArtId string) error { - p.queue = []QueueItem{{id, uri, title, artist, duration, album, track, coverArtId}} +func (p *Player) PlayUri(id, uri, title, artist, album string, duration, track, disc int, coverArtId string) error { + p.queue = []QueueItem{{id, uri, title, artist, duration, album, track, disc, coverArtId}} p.replaceInProgress = true if ip, e := p.IsPaused(); ip && e == nil { if err := p.Pause(); err != nil { diff --git a/mpvplayer/queue_item.go b/mpvplayer/queue_item.go index cbff8a0..2b147e4 100644 --- a/mpvplayer/queue_item.go +++ b/mpvplayer/queue_item.go @@ -16,6 +16,7 @@ type QueueItem struct { Album string TrackNumber int CoverArtId string + DiscNumber int } var _ remote.TrackInterface = (*QueueItem)(nil) @@ -55,3 +56,7 @@ func (q QueueItem) GetAlbum() string { func (q QueueItem) GetTrackNumber() int { return q.TrackNumber } + +func (q QueueItem) GetDiscNumber() int { + return q.DiscNumber +} diff --git a/page_browser.go b/page_browser.go index 500d820..3b0cccb 100644 --- a/page_browser.go +++ b/page_browser.go @@ -258,6 +258,8 @@ func (b *BrowserPage) handleAddArtistToQueue() { return } + sort.Sort(b.currentDirectory.Entities) + for _, entity := range b.currentDirectory.Entities { if entity.IsDirectory { b.addDirectoryToQueue(&entity) diff --git a/page_playlist.go b/page_playlist.go index 95cb187..358012f 100644 --- a/page_playlist.go +++ b/page_playlist.go @@ -256,8 +256,7 @@ func (p *PlaylistPage) UpdatePlaylists() { p.ui.addToPlaylistList.Clear() for _, playlist := range p.ui.playlists { - p.playlistList.AddItem(tview.Escape(playlist.Name), "", 0, nil) - p.ui.addToPlaylistList.AddItem(tview.Escape(playlist.Name), "", 0, nil) + p.addPlaylist(playlist) } p.isUpdating = false @@ -266,6 +265,11 @@ func (p *PlaylistPage) UpdatePlaylists() { }() } +func (p *PlaylistPage) addPlaylist(playlist subsonic.SubsonicPlaylist) { + p.playlistList.AddItem(tview.Escape(playlist.Name), "", 0, nil) + p.ui.addToPlaylistList.AddItem(tview.Escape(playlist.Name), "", 0, nil) +} + func (p *PlaylistPage) handleAddPlaylistSongToQueue() { playlistIndex := p.playlistList.GetCurrentItem() entityIndex := p.selectedPlaylist.GetCurrentItem() @@ -321,7 +325,7 @@ func (p *PlaylistPage) handlePlaylistSelected(playlist subsonic.SubsonicPlaylist } func (p *PlaylistPage) newPlaylist(name string) { - response, err := p.ui.connection.CreatePlaylist(name) + response, err := p.ui.connection.CreatePlaylist("", name, nil) if err != nil { p.logger.Printf("newPlaylist: CreatePlaylist %s -- %s", name, err.Error()) return diff --git a/page_queue.go b/page_queue.go index 9052c86..a7b12e7 100644 --- a/page_queue.go +++ b/page_queue.go @@ -18,8 +18,11 @@ import ( "github.com/rivo/tview" "github.com/spezifisch/stmps/logger" "github.com/spezifisch/stmps/mpvplayer" + "github.com/spezifisch/stmps/subsonic" ) +// TODO show total # of entries somewhere (top?) + // columns: star, title, artist, duration const queueDataColumns = 4 const starIcon = "♥" @@ -67,7 +70,7 @@ func init() { func (ui *Ui) createQueuePage() *QueuePage { tmpl := template.New("song info").Funcs(template.FuncMap{ "formatTime": func(i int) string { - return fmt.Sprintf("%s", time.Duration(i)*time.Second) + return (time.Duration(i) * time.Second).String() }, }) songInfoTemplate, err := tmpl.Parse(songInfoTemplateString) @@ -99,6 +102,12 @@ func (ui *Ui) createQueuePage() *QueuePage { queuePage.moveSongDown() case 'k': queuePage.moveSongUp() + case 's': + if len(queuePage.queueData.playerQueue) == 0 { + queuePage.logger.Print("no items in queue to save") + return nil + } + queuePage.ui.ShowSelectPlaylist() case 'S': queuePage.shuffle() default: @@ -255,7 +264,8 @@ func (q *QueuePage) moveSongUp() { } if currentIndex == 1 { - q.ui.player.Stop() + // An error here won't affect re-arranging the queue. + _ = q.ui.player.Stop() } // remove the item from the queue @@ -280,7 +290,8 @@ func (q *QueuePage) moveSongDown() { } if currentIndex == 0 { - q.ui.player.Stop() + // An error here won't affect re-arranging the queue. + _ = q.ui.player.Stop() } if currentIndex > queueLen-2 { @@ -294,12 +305,75 @@ func (q *QueuePage) moveSongDown() { q.updateQueue() } +// saveQueue persists the current queue as a playlist. It presents the user +// with a way of choosing the playlist name, and if a playlist with the +// same name already exists it requires the user to confirm that they +// want to overwrite the existing playlist. +// +// Errors are reported to the user and require confirmation to dismiss, +// and logged. +func (q *QueuePage) saveQueue(playlistName string) { + // When updating an existing playlist, there are two options: + // updatePlaylist, and createPlaylist. createPlaylist on an + // existing playlist is a replace function. + // + // updatePlaylist is more surgical: it can selectively add and + // remove songs, and update playlist attributes. It is more + // network efficient than using createPlaylist to change an + // existing playlist. However, using it here would require + // a more complex diffing algorithm, and much more code. + // Consequently, this version of save() uses the more simple + // brute-force approach of always using createPlaylist(). + songIds := make([]string, len(q.queueData.playerQueue)) + for i, it := range q.queueData.playerQueue { + songIds[i] = it.Id + } + + var playlistId string + for _, p := range q.ui.playlists { + if p.Name == playlistName { + playlistId = string(p.Id) + break + } + } + var response *subsonic.SubsonicResponse + var err error + if playlistId == "" { + q.logger.Printf("Saving %d items to playlist %s", len(q.queueData.playerQueue), playlistName) + response, err = q.ui.connection.CreatePlaylist("", playlistName, songIds) + } else { + q.logger.Printf("Replacing playlist %s with %d", playlistId, len(q.queueData.playerQueue)) + response, err = q.ui.connection.CreatePlaylist(playlistId, "", songIds) + } + if err != nil { + message := fmt.Sprintf("Error saving queue: %s", err) + q.ui.showMessageBox(message) + q.logger.Print(message) + } else { + if playlistId != "" { + for i, pl := range q.ui.playlists { + if string(pl.Id) == playlistId { + q.ui.playlists[i] = response.Playlist + break + } + } + } else { + q.ui.playlistPage.addPlaylist(response.Playlist) + q.ui.playlists = append(q.ui.playlists, response.Playlist) + } + q.ui.playlistPage.handlePlaylistSelected(response.Playlist) + } +} + +// shuffle randomly shuffles entries in the queue, updates it, and moves +// the selected-item to the new first entry. func (q *QueuePage) shuffle() { if len(q.queueData.playerQueue) == 0 { return } - q.ui.player.Stop() + // An error here won't affect re-arranging the queue. + _ = q.ui.player.Stop() q.ui.player.Shuffle() q.queueList.Select(0, 0) @@ -368,6 +442,7 @@ func (q *queueData) GetColumnCount() int { var songInfoTemplateString = `[blue::b]Title:[-:-:-:-] [green::i]{{.Title}}[-:-:-:-] [blue::b]Artist:[-:-:-:-] [::i]{{.Artist}}[-:-:-:-] [blue::b]Album:[-:-:-:-] [::i]{{.GetAlbum}}[-:-:-:-] +[blue::b]Disc:[-:-:-:-] [::i]{{.GetDiscNumber}}[-:-:-:-] [blue::b]Track:[-:-:-:-] [::i]{{.GetTrackNumber}}[-:-:-:-] [blue::b]Duration:[-:-:-:-] [::i]{{formatTime .Duration}}[-:-:-:-] ` diff --git a/page_search.go b/page_search.go index e6e7908..ace2cbd 100644 --- a/page_search.go +++ b/page_search.go @@ -228,7 +228,7 @@ func (s *SearchPage) search() { s.artists = append(s.artists, &artist) } for _, album := range res.SearchResults.Album { - s.albumList.AddItem(tview.Escape(album.Album), "", 0, nil) + s.albumList.AddItem(tview.Escape(album.Name), "", 0, nil) s.albums = append(s.albums, &album) } for _, song := range res.SearchResults.Song { @@ -247,14 +247,30 @@ func (s *SearchPage) addArtistToQueue(entity subsonic.Ider) { s.logger.Printf("addArtistToQueue: GetArtist %s -- %s", entity.ID(), err.Error()) return } - artistName := response.Artist.Name + artistId := response.Artist.Id for _, album := range response.Artist.Album { response, err = s.ui.connection.GetAlbum(album.Id) + if err != nil { + s.logger.Printf("error getting album %s while adding artist to queue", album.Id) + return + } sort.Sort(response.Album.Song) + // We make sure we add only albums who's artists match the artist + // being added; this prevents collection albums with many different + // artists that show up in the Album column having _all_ of the songs + // on the album -- even ones that don't match the artist -- from + // being added when the user adds an album from the search results. for _, e := range response.Album.Song { + // Depending on the server implementation, the server may or may not + // respond with a list of artists. If either the Artist field matches, + // or the artist name is in a list of artists, then we add the song. + if e.ArtistId == artistId { + s.ui.addSongToQueue(&e) + continue + } for _, art := range e.Artists { - if art.Name == artistName { + if art.Id == artistId { s.ui.addSongToQueue(&e) break } diff --git a/remote/interfaces.go b/remote/interfaces.go index 651c6fd..08dbf2f 100644 --- a/remote/interfaces.go +++ b/remote/interfaces.go @@ -43,6 +43,7 @@ type TrackInterface interface { GetAlbumArtist() string GetAlbum() string GetTrackNumber() int + GetDiscNumber() int // something like ID != "" IsValid() bool diff --git a/subsonic/api.go b/subsonic/api.go index 6aa8cf8..95ae7c3 100644 --- a/subsonic/api.go +++ b/subsonic/api.go @@ -6,6 +6,7 @@ package subsonic import ( "bytes" "encoding/json" + "errors" "fmt" "image" "image/gif" @@ -14,6 +15,7 @@ import ( "io" "net/http" "net/url" + "sort" "strconv" "strings" @@ -39,7 +41,7 @@ type SubsonicConnection struct { func Init(logger logger.LoggerInterface) *SubsonicConnection { return &SubsonicConnection{ clientName: "example", - clientVersion: "1.0.0", + clientVersion: "1.8.0", logger: logger, directoryCache: make(map[string]SubsonicResponse), @@ -118,6 +120,11 @@ type SubsonicResults struct { Song SubsonicEntities `json:"song"` } +type ScanStatus struct { + Scanning bool `json:"scanning"` + Count int `json:"count"` +} + type Artist struct { Id string `json:"id"` Name string `json:"name"` @@ -132,6 +139,7 @@ func (s Artist) ID() string { type Album struct { Id string `json:"id"` Created string `json:"created"` + ArtistId string `json:"artistId"` Artist string `json:"artist"` Artists []Artist `json:"artists"` DisplayArtist string `json:"displayArtist"` @@ -145,6 +153,7 @@ type Album struct { Genres []Genre `json:"genres"` Year int `json:"year"` Song SubsonicEntities `json:"song"` + CoverArt string `json:"coverArt"` } func (s Album) ID() string { @@ -160,11 +169,12 @@ type SubsonicEntity struct { IsDirectory bool `json:"isDir"` Parent string `json:"parent"` Title string `json:"title"` + ArtistId string `json:"artistId"` Artist string `json:"artist"` Artists []Artist `json:"artists"` Duration int `json:"duration"` Track int `json:"track"` - DiskNumber int `json:"diskNumber"` + DiscNumber int `json:"discNumber"` Path string `json:"path"` CoverArtId string `json:"coverArt"` } @@ -211,11 +221,19 @@ func (s SubsonicEntities) Less(i, j int) bool { } return true } - // If the tracks are the same, sort alphabetically - if s[i].Track == s[j].Track { - return s[i].Title < s[j].Title + // Disk and track numbers are only relevant within the same parent + if s[i].Parent == s[j].Parent { + // sort first by DiskNumber + if s[i].DiscNumber == s[j].DiscNumber { + // Tracks on the same disk are sorted by track + return s[i].Track < s[j].Track + } + return s[i].DiscNumber < s[j].DiscNumber } - return s[i].Track < s[j].Track + // If we get here, the songs are either from different albums, or else + // they're on the same disk + + return s[i].Title < s[j].Title } type SubsonicIndexes struct { @@ -252,6 +270,7 @@ type SubsonicResponse struct { Artist Artist `json:"artist"` Album Album `json:"album"` SearchResults SubsonicResults `json:"searchResult3"` + ScanStatus ScanStatus `json:"scanStatus"` } type responseWrapper struct { @@ -304,6 +323,8 @@ func (connection *SubsonicConnection) GetArtist(id string) (*SubsonicResponse, e connection.directoryCache[id] = *resp } + sort.Sort(resp.Directory.Entities) + return resp, nil } @@ -328,6 +349,8 @@ func (connection *SubsonicConnection) GetAlbum(id string) (*SubsonicResponse, er connection.directoryCache[id] = *resp } + sort.Sort(resp.Directory.Entities) + return resp, nil } @@ -349,6 +372,8 @@ func (connection *SubsonicConnection) GetMusicDirectory(id string) (*SubsonicRes connection.directoryCache[id] = *resp } + sort.Sort(resp.Directory.Entities) + return resp, nil } @@ -517,9 +542,26 @@ func (connection *SubsonicConnection) GetPlaylist(id string) (*SubsonicResponse, return connection.getResponse("GetPlaylist", requestUrl) } -func (connection *SubsonicConnection) CreatePlaylist(name string) (*SubsonicResponse, error) { +// CreatePlaylist creates or updates a playlist on the server. +// If id is provided, the existing playlist with that ID is updated with the new song list. +// If name is provided, a new playlist is created with the song list. +// Either id or name _must_ be populated, or the function returns an error. +// If _both_ id and name are poplated, the function returns an error. +// songIds may be nil, in which case the new playlist is created empty, or all +// songs are removed from the existing playlist. +func (connection *SubsonicConnection) CreatePlaylist(id, name string, songIds []string) (*SubsonicResponse, error) { + if (id == "" && name == "") || (id != "" && name != "") { + return nil, errors.New("CreatePlaylist: exactly one of id or name must be provided") + } query := defaultQuery(connection) - query.Set("name", name) + if id != "" { + query.Set("id", id) + } else { + query.Set("name", name) + } + for _, sid := range songIds { + query.Add("songId", sid) + } requestUrl := connection.Host + "/rest/createPlaylist" + "?" + query.Encode() return connection.getResponse("GetPlaylist", requestUrl) } @@ -607,3 +649,17 @@ func (connection *SubsonicConnection) Search(searchTerm string, artistOffset, al res, err := connection.getResponse("Search", requestUrl) return res, err } + +// StartScan tells the Subsonic server to initiate a media library scan. Whether +// this is a deep or surface scan is dependent on the server implementation. +// https://subsonic.org/pages/api.jsp#startScan +func (connection *SubsonicConnection) StartScan() error { + query := defaultQuery(connection) + requestUrl := fmt.Sprintf("%s/rest/startScan?%s", connection.Host, query.Encode()) + if res, err := connection.getResponse("StartScan", requestUrl); err != nil { + return err + } else if !res.ScanStatus.Scanning { + return fmt.Errorf("server returned false for scan status on scan attempt") + } + return nil +} diff --git a/widget_selectplaylist.go b/widget_selectplaylist.go new file mode 100644 index 0000000..5298cfc --- /dev/null +++ b/widget_selectplaylist.go @@ -0,0 +1,249 @@ +package main + +import ( + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +type PlaylistSelectionWidget struct { + Root *tview.Flex + ui *Ui + inputField *tview.InputField + overwrite *tview.Checkbox + accept *tview.Button + cancel *tview.Button + overwriteEnabled bool + visible bool +} + +// createPlaylistSelectionWidget creates the widget and sets up all of the +// behaviors, including the key bindings. +func (ui *Ui) createPlaylistSelectionWidget() (m *PlaylistSelectionWidget) { + m = &PlaylistSelectionWidget{ + ui: ui, + } + + m.overwrite = tview.NewCheckbox() + m.overwrite.SetDisabled(true) + m.overwriteEnabled = false + m.overwrite.SetLabel("Overwrite?").SetFieldTextColor(tcell.ColorBlack) + m.overwrite.SetBackgroundColor(tcell.ColorGray) + m.overwrite.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Rune() == ' ' { + m.overwrite.SetChecked(!m.overwrite.IsChecked()) + m.accept.SetDisabled(!m.overwrite.IsChecked()) + return nil + } + return event + }) + m.accept = tview.NewButton("Accept").SetLabelColor(tcell.ColorBlack) + m.cancel = tview.NewButton("Cancel").SetLabelColor(tcell.ColorBlack) + m.inputField = tview.NewInputField().SetAutocompleteFunc(func(current string) []string { + rv := make([]string, 0) + var exactMatch bool + for _, p := range ui.playlists { + if strings.Contains(p.Name, current) { + rv = append(rv, p.Name) + } + if p.Name == current { + exactMatch = true + } + } + if exactMatch { + m.overwrite.SetDisabled(false) + m.overwriteEnabled = true + m.accept.SetDisabled(!m.overwrite.IsChecked()) + } else { + m.overwrite.SetDisabled(true) + m.overwriteEnabled = false + m.accept.SetDisabled(false) + } + return rv + }).SetFieldTextColor(tcell.ColorBlack) + m.inputField.SetDoneFunc(func(key tcell.Key) { + m.focusNext(nil) + }) + // FIXME with this code in place, the list isn't navigable + // m.inputField.SetAutocompletedFunc(func(text string, index int, source int) bool { + // m.inputField.SetText(text) + // for _, p := range ui.playlists { + // if p.Name == text { + // m.overwrite.SetDisabled(false) + // m.overwriteEnabled = true + // m.focusNext(nil) + // return false + // } + // } + // m.overwrite.SetDisabled(true) + // m.overwriteEnabled = false + // return false + // }) + acceptFunc := func() { + inputText := m.inputField.GetText() + if !m.overwrite.IsChecked() { + for _, p := range ui.playlists { + if p.Name == inputText { + return + } + } + } + ui.queuePage.saveQueue(inputText) + ui.CloseSelectPlaylist() + } + m.accept.SetSelectedFunc(acceptFunc) + cancelFunc := func() { + m.inputField.SetText("") + m.overwrite.SetDisabled(true) + m.overwriteEnabled = false + m.overwrite.SetChecked(false) + m.accept.SetDisabled(!m.overwrite.IsChecked()) + ui.CloseSelectPlaylist() + } + m.cancel.SetSelectedFunc(cancelFunc) + + buttons := tview.NewFlex(). + SetDirection(tview.FlexColumn). + AddItem(tview.NewFlex(), 0, 1, false). + AddItem(m.accept, 0, 4, false). + AddItem(tview.NewFlex(), 0, 1, false). + AddItem(m.cancel, 0, 4, false). + AddItem(tview.NewFlex(), 0, 1, false) + + m.Root = tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(m.inputField, 1, 1, true). + AddItem(m.overwrite, 1, 1, false). + AddItem(buttons, 0, 1, false) + + m.Root.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if m.ui.app.GetFocus() == m.inputField { + switch event.Key() { + case tcell.KeyTab: + if event.Modifiers()&tcell.ModShift != 0 { + return m.focusPrev(event) + } else { + return m.focusNext(event) + } + case tcell.KeyBacktab: + return m.focusPrev(event) + case tcell.KeyESC: + cancelFunc() + } + return event + } + if event.Rune() == ' ' { + focused := m.ui.app.GetFocus() + if focused == m.accept { + acceptFunc() + return nil + } + if focused == m.cancel { + cancelFunc() + return nil + } + return event + } + switch event.Key() { + case tcell.KeyESC: + cancelFunc() + return nil + case tcell.KeyCR: + focused := m.ui.app.GetFocus() + if focused == m.accept { + acceptFunc() + return nil + } else if focused == m.cancel { + cancelFunc() + return nil + } + m.focusNext(event) + return event + case tcell.KeyTab: + if event.Modifiers()&tcell.ModShift != 0 { + return m.focusPrev(event) + } else { + return m.focusNext(event) + } + case tcell.KeyBacktab: + return m.focusPrev(event) + case tcell.KeyDown: + return m.focusNext(event) + case tcell.KeyUp: + return m.focusPrev(event) + default: + m.ui.logger.Printf("non-input key = %d", event.Key()) + } + return event + }) + + m.Root.Box.SetBorder(true).SetTitle(" Playlist Name ") + + return +} + +func (m *PlaylistSelectionWidget) focusNext(event *tcell.EventKey) *tcell.EventKey { + switch m.ui.app.GetFocus() { + case m.inputField: + st := m.inputField.GetText() + found := false + for _, p := range m.ui.playlists { + if p.Name == st { + m.overwrite.SetDisabled(false) + m.overwriteEnabled = true + m.accept.SetDisabled(!m.overwrite.IsChecked()) + m.ui.app.SetFocus(m.overwrite) + found = true + } + } + if !found { + m.overwrite.SetDisabled(true) + m.overwriteEnabled = false + m.accept.SetDisabled(false) + m.ui.app.SetFocus(m.accept) + } + case m.overwrite: + if m.overwrite.IsChecked() { + m.ui.app.SetFocus(m.accept) + } else { + m.ui.app.SetFocus(m.cancel) + } + case m.accept: + m.ui.app.SetFocus(m.cancel) + case m.cancel: + m.ui.app.SetFocus(m.inputField) + default: + return event + } + return nil +} + +func (m PlaylistSelectionWidget) focusPrev(event *tcell.EventKey) *tcell.EventKey { + switch m.ui.app.GetFocus() { + case m.inputField: + m.ui.app.SetFocus(m.cancel) + case m.overwrite: + m.ui.app.SetFocus(m.inputField) + case m.accept: + if m.overwriteEnabled { + m.ui.app.SetFocus(m.overwrite) + } else { + m.ui.app.SetFocus(m.inputField) + } + case m.cancel: + // FIXME There's some bug in back-tabbing from cancel; _something_ is disabling the overwriteEnabled field, and I can't find it. Tabbing forward works fine, but tabbing backward fails to work properly when the playlist name matches an existing playlist + if m.overwriteEnabled { + if m.overwrite.IsChecked() { + m.ui.app.SetFocus(m.accept) + } else { + m.ui.app.SetFocus(m.overwrite) + } + } else { + m.ui.app.SetFocus(m.accept) + } + default: + return event + } + return nil +}