package views import ( "fmt" "strconv" "time" "git.dayanhub.com/sagi/subsonic-tui/internal/client" "git.dayanhub.com/sagi/subsonic-tui/internal/common" "git.dayanhub.com/sagi/subsonic-tui/internal/config" "github.com/delucks/go-subsonic" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) var _ View = &main{} type mainviewmode int const ( mainModeAlbum mainviewmode = iota mainModePlaylist mainviewmode = iota mainModeArtist mainviewmode = iota mainModeSearch mainviewmode = iota ) type main struct { view *tview.Flex client *client.Client mode mainviewmode query string miniView bool album *subsonic.AlbumID3 searchResult *subsonic.SearchResult3 playlist *subsonic.Playlist songList *tview.Table artist *subsonic.ArtistID3 artistInfo *subsonic.ArtistInfo2 playAllFunc func(song ...*subsonic.Child) addSongsFunc func(song ...*subsonic.Child) log func(format string, a ...any) } func NewMainView(client *client.Client, log func(format string, a ...any)) *main { playlistAlbum := &main{ client: client, log: log, } flex := tview.NewFlex().SetDirection(tview.FlexRow) flex.SetBackgroundColor(config.ColorBackground) flex.SetTitle("Subsonic TUI [`]") flex.SetBorder(true) flex.SetFocusFunc(func() { flex.SetBorderColor(config.ColorSelectedBoarder) if playlistAlbum.songList != nil { // playlistAlbum.view.Blur() playlistAlbum.songList.Focus(nil) } }) flex.SetBlurFunc(func() { flex.SetBorderColor(config.ColorBluredBoarder) if playlistAlbum.songList != nil { playlistAlbum.songList.Blur() } }) playlistAlbum.view = flex // Empty Box for starters... playlistAlbum.view.AddItem(EmptyBox, 0, 1, false) return playlistAlbum } func (m *main) SetMiniView(mini bool) { m.miniView = mini } func (m *main) SetAlbum(album *subsonic.AlbumID3) { m.mode = mainModeAlbum m.album = album m.Update() m.log("") } func (m *main) SetPlaylist(playlist *subsonic.Playlist) { m.mode = mainModePlaylist m.playlist = playlist m.Update() m.log("") } func (m *main) SetArtist(artist *subsonic.ArtistID3) { m.mode = mainModeArtist m.artist = artist info, _ := m.client.GetArtistInfo(artist.ID) m.artistInfo = info m.Update() m.log("") } func (m *main) SetSearch(result *subsonic.SearchResult3, query string) { m.mode = mainModeSearch m.searchResult = result m.query = query m.Update() m.log("Found #%d artists, #%d albums and #%d songs", len(result.Artist), len(result.Album), len(result.Song), ) } func (m *main) drawPlaylist() { subtitle := fmt.Sprintf("%s\n\nCreated by: %s | %s", m.playlist.Comment, m.playlist.Owner, time.Duration(m.playlist.Duration*int(time.Second)).String()) m.populateHeader(m.playlist.Name, subtitle, m.playlist.Duration, m.playlist.CoverArt) playBtn := m.drawPlaylistAlbumButtons(m.playlist.Entry) m.populateSongs(m.playlist.Entry, playBtn) } func (m *main) drawAlbum() { subtitle := fmt.Sprintf("%s\n\n%d | %s", m.album.Artist, m.album.Year, time.Duration(m.album.Duration*int(time.Second)).String()) m.populateHeader(m.album.Name, subtitle, m.album.Duration, m.album.CoverArt) playBtn := m.drawPlaylistAlbumButtons(m.album.Song) m.populateSongs(m.album.Song, playBtn) } func (m *main) drawArtist() { m.populateHeader(m.artist.Name, m.artistInfo.Biography, 0, m.artist.CoverArt) btn := m.drawArtistButtons() m.populateAlbums(btn) } func (m *main) drawSearch() { sub := "Query: " + m.query m.populateHeader("Search Results", sub, 0, "") m.populateSearchResults() } func (m *main) Update() { m.view.Clear() switch m.mode { case mainModeAlbum: m.drawAlbum() case mainModePlaylist: m.drawPlaylist() case mainModeArtist: m.drawArtist() case mainModeSearch: m.drawSearch() } m.songList.Focus(nil) } func (m *main) drawArtistButtons() *tview.Button { // Add buttons: Radio topSongs := 10 songs, _ := m.client.GetTopSongs(m.artist.Name, topSongs) f := tview.NewFlex() f.SetBackgroundColor(config.ColorBackground) f.SetBorderPadding(0, 0, 2, 2) // Buttons radio := NewButton("Radio") top10 := NewButton("Top 10") // Button callbacks top10.SetSelectedFunc(func() { top10.Blur() m.log("Playing %s's top 10 songs", m.artist.Name) m.playAllFunc(songs...) m.songList.Focus(nil) }) radio.SetSelectedFunc(func() { radio.Blur() m.log("Generating %s's radio", m.artist.Name) go func() { radioSongs := []*subsonic.Child{} if config.ExperimentalRadioAlgo() { radioSongs, _ = m.client.GetExperimentalArtistRadio(m.artist, m.artistInfo, config.MaxRadioSongs()) } if len(radioSongs) == 0 { radioSongs, _ = m.client.GetSimilarSongs(m.artist.ID, config.MaxRadioSongs()) } common.ShuffleSlice(radioSongs) m.playAllFunc(radioSongs...) }() m.songList.Focus(nil) }) radio.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyDown: radio.Blur() m.songList.Focus(nil) return nil case tcell.KeyRight: radio.Blur() top10.Focus(nil) return nil default: return event } }) top10.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyDown: top10.Blur() m.songList.Focus(nil) return nil case tcell.KeyLeft: top10.Blur() radio.Focus(nil) return nil default: return event } }) f.AddItem(radio, 0, 1, false) f.AddItem(EmptyBox, 0, 1, false) if len(songs) > 0 { f.AddItem(top10, 0, 1, false) } f.AddItem(EmptyBox, 0, 1, false) // Add the buttons to the view m.view.AddItem(f, 1, 0, false) // Margin bottom of 1 line m.view.AddItem(EmptyBox, 1, 0, false) return radio } func (m *main) populateAlbums(btn *tview.Button) { table := tview.NewTable() table.SetBackgroundColor(config.ColorBackground) table.SetWrapSelection(true, false) for i, album := range m.artist.Album { year := tview.NewTableCell(strconv.Itoa(album.Year)).SetTextColor(config.ColorTextAccent) name := tview.NewTableCell(album.Name).SetExpansion(2).SetAlign(tview.AlignCenter) d := time.Second * time.Duration(album.Duration) duration := tview.NewTableCell(d.String()).SetAlign(tview.AlignRight).SetExpansion(1) table.SetCell(i, 0, year) table.SetCell(i, 1, name) table.SetCell(i, 2, duration) } table.SetFocusFunc(func() { m.view.SetBorderColor(config.ColorSelectedBoarder) table.SetSelectable(true, false) }) table.SetBlurFunc(func() { m.view.SetBorderColor(config.ColorBluredBoarder) table.SetSelectable(false, false) }) table.SetSelectedFunc(func(row, column int) { alb, _ := m.client.GetAlbum(m.artist.Album[row].ID) m.SetAlbum(alb) m.view.Focus(nil) }) table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { row, _ := m.songList.GetSelection() if row == 0 && event.Key() == tcell.KeyUp { table.Blur() m.view.SetBorderColor(config.ColorSelectedBoarder) btn.Focus(nil) return nil } return event }) m.songList = table m.view.AddItem(table, 0, 1, false) } func (m *main) drawPlaylistAlbumButtons(songs []*subsonic.Child) *tview.Button { // Add buttons: Play | Shuffle | Add to queue f := tview.NewFlex() f.SetBackgroundColor(config.ColorBackground) f.SetBorderPadding(0, 0, 2, 2) // Buttons play := NewButton("Play") shuffle := NewButton("Shuffle") queue := NewButton("Queue") artist := NewButton("Artist") // Button callbacks play.SetSelectedFunc(func() { play.Blur() m.playAllFunc(songs...) m.songList.Focus(nil) }) play.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyLeft: play.Blur() artist.Focus(nil) return nil case tcell.KeyRight: play.Blur() shuffle.Focus(nil) return nil case tcell.KeyDown: play.Blur() m.songList.Focus(nil) return nil default: return event } }) shuffle.SetSelectedFunc(func() { shuffle.Blur() cpy := make([]*subsonic.Child, len(songs)) copy(cpy, songs) common.ShuffleSlice(cpy) m.playAllFunc(cpy...) m.songList.Focus(nil) }) shuffle.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyLeft: shuffle.Blur() play.Focus(nil) return nil case tcell.KeyRight: shuffle.Blur() queue.Focus(nil) return nil case tcell.KeyDown: shuffle.Blur() m.songList.Focus(nil) return nil default: return event } }) queue.SetSelectedFunc(func() { queue.Blur() m.addSongsFunc(songs...) m.songList.Focus(nil) }) queue.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyLeft: queue.Blur() shuffle.Focus(nil) return nil case tcell.KeyRight: queue.Blur() artist.Focus(nil) return nil case tcell.KeyDown: queue.Blur() m.songList.Focus(nil) return nil default: return event } }) artist.SetSelectedFunc(func() { artist.Blur() ar, _ := m.client.GetArtist(m.album.ArtistID) m.SetArtist(ar) m.songList.Focus(nil) }) artist.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyLeft: artist.Blur() queue.Focus(nil) return nil case tcell.KeyRight: artist.Blur() play.Focus(nil) return nil case tcell.KeyDown: artist.Blur() m.songList.Focus(nil) return nil } return event }) f.AddItem(play, 0, 1, true) f.AddItem(EmptyBox, 0, 1, false) f.AddItem(shuffle, 0, 1, false) f.AddItem(EmptyBox, 0, 1, false) f.AddItem(queue, 0, 1, false) f.AddItem(EmptyBox, 0, 1, false) f.AddItem(artist, 0, 1, false) // Add the buttons to the view m.view.AddItem(f, 1, 0, false) // Margin bottom of 1 line m.view.AddItem(EmptyBox, 1, 0, false) return play } func (m *main) populateSongs(songs []*subsonic.Child, play *tview.Button) { table := tview.NewTable() table.SetBackgroundColor(config.ColorBackground) table.SetWrapSelection(true, false) for i, song := range songs { num := tview.NewTableCell(strconv.Itoa(i + 1)).SetTextColor(config.ColorTextAccent) title := tview.NewTableCell(song.Title).SetMaxWidth(15).SetExpansion(2) artist := tview.NewTableCell(song.Artist).SetMaxWidth(15).SetExpansion(1) d := time.Second * time.Duration(song.Duration) duration := tview.NewTableCell(d.String()).SetAlign(tview.AlignRight).SetExpansion(1) table.SetCell(i, 0, num) table.SetCell(i, 1, title) table.SetCell(i, 2, artist) table.SetCell(i, 3, duration) } m.songList = table table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { // on first line and pressing up -> play button row, _ := table.GetSelection() if row == 0 && event.Key() == tcell.KeyUp { table.Blur() m.view.SetBorderColor(config.ColorSelectedBoarder) play.Focus(nil) return nil } else if event.Rune() == 'r' { song := songs[row] m.log("Generating song (%s) radio....", song.Title) go func() { radioSongs, _ := m.client.GetSimilarSongs(song.ID, config.MaxRadioSongs()) m.playAllFunc(radioSongs...) }() } return event }) table.SetFocusFunc(func() { m.view.SetBorderColor(config.ColorSelectedBoarder) table.SetSelectable(true, false) }) table.SetBlurFunc(func() { m.view.SetBorderColor(config.ColorBluredBoarder) table.SetSelectable(false, false) }) m.songList.SetSelectedFunc(func(row, column int) { m.addSongsFunc(songs[row]) }) m.view.AddItem(table, 0, 1, false) } func (m *main) populateSearchResults() { table := tview.NewTable() table.SetBackgroundColor(config.ColorBackground) table.SetWrapSelection(true, false) row := 0 lastArtist := 0 lastAlbum := 0 // Artists if len(m.searchResult.Artist) > 0 { // Header header := tview.NewTableCell("Artists").SetSelectable(false) table.SetCell(row, 0, header) row++ // List for i, artist := range m.searchResult.Artist { index := tview.NewTableCell(strconv.Itoa(i + 1)). SetTextColor(config.ColorTextAccent) a := tview.NewTableCell(artist.Name).SetExpansion(2).SetMaxWidth(15) count := tview.NewTableCell(fmt.Sprintf("%d Albums", artist.AlbumCount)). SetExpansion(1).SetAlign(tview.AlignRight).SetMaxWidth(15) table.SetCell(row, 0, index) table.SetCell(row, 1, a) table.SetCell(row, 2, count) row++ } lastArtist = row } // Albums if len(m.searchResult.Album) > 0 { // Header header := tview.NewTableCell("Albums").SetSelectable(false) table.SetCell(row, 0, header) row++ // List for i, album := range m.searchResult.Album { index := tview.NewTableCell(strconv.Itoa(i + 1)). SetTextColor(config.ColorTextAccent) title := tview.NewTableCell(album.Name).SetExpansion(2).SetMaxWidth(15) artist := tview.NewTableCell(album.Artist). SetExpansion(1).SetAlign(tview.AlignRight).SetMaxWidth(15) table.SetCell(row, 0, index) table.SetCell(row, 1, title) table.SetCell(row, 2, artist) row++ } lastAlbum = row } // Songs if len(m.searchResult.Song) > 0 { // Header header := tview.NewTableCell("Songs").SetSelectable(false) table.SetCell(row, 0, header) row++ // List for i, song := range m.searchResult.Song { index := tview.NewTableCell(strconv.Itoa(i + 1)). SetTextColor(config.ColorTextAccent) title := tview.NewTableCell(song.Title).SetExpansion(2).SetMaxWidth(15) artist := tview.NewTableCell(song.Artist). SetExpansion(1).SetAlign(tview.AlignRight).SetMaxWidth(15) table.SetCell(row, 0, index) table.SetCell(row, 1, title) table.SetCell(row, 2, artist) row++ } } m.songList = table table.SetFocusFunc(func() { m.view.SetBorderColor(config.ColorSelectedBoarder) table.SetSelectable(true, false) }) table.SetBlurFunc(func() { m.view.SetBorderColor(config.ColorBluredBoarder) table.SetSelectable(false, false) }) table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { row, _ := table.GetSelection() if row <= lastAlbum { return event } if event.Rune() != 'r' { return event } cell := table.GetCell(row, 0) index, err := strconv.Atoi(cell.Text) if err != nil { return nil } song := m.searchResult.Song[index-1] m.log("Generating song (%s) radio....", song.Title) go func() { radioSongs, _ := m.client.GetSimilarSongs(song.ID, config.MaxRadioSongs()) m.playAllFunc(radioSongs...) }() return nil }) m.songList.SetSelectedFunc(func(row, column int) { cell := table.GetCell(row, 0) index, err := strconv.Atoi(cell.Text) if err != nil { return } if row <= lastArtist { artist, _ := m.client.GetArtist(m.searchResult.Artist[index-1].ID) m.SetArtist(artist) } else if lastArtist < row && row <= lastAlbum { album, _ := m.client.GetAlbum(m.searchResult.Album[index-1].ID) m.SetAlbum(album) } else { m.addSongsFunc(m.searchResult.Song[index-1]) } }) m.view.AddItem(table, 0, 1, false) } func (m *main) populateHeader(title, subtitle string, duration int, coverArtID string, ) { _ = duration header := tview.NewFlex() header.SetBackgroundColor(config.ColorBackground) art := tview.NewImage() art.SetBackgroundColor(config.ColorBackground) img, _ := m.client.GetCoverArt(coverArtID) art.SetImage(img) t := tview.NewTextView(). SetTextColor(config.ColorTextAccent). SetTextAlign(tview.AlignCenter). SetText(title) t.SetBackgroundColor(config.ColorBackground) s := tview.NewTextView(). SetTextColor(config.ColorText). SetTextAlign(tview.AlignCenter). SetText(subtitle).SetWordWrap(true) s.SetBackgroundColor(config.ColorBackground) g := tview.NewFlex().SetDirection(tview.FlexRow) g.SetBackgroundColor(config.ColorBackground) g.AddItem(EmptyBox, 1, 1, false) g.AddItem(t, 1, 1, false) g.AddItem(s, 0, 1, false) header.AddItem(art, 0, 1, false) header.AddItem(g, 0, 3, false) size := 6 if m.miniView { size = 4 } m.view.AddItem(header, size, 1, false) // Margin bottom of 1 line if !m.miniView { m.view.AddItem(EmptyBox, 1, 0, false) } } func (m *main) SetPlayAllFunc(f func(song ...*subsonic.Child)) { m.playAllFunc = f } func (m *main) SetPlayAddSongFunc(f func(song ...*subsonic.Child)) { m.addSongsFunc = f } func (m *main) GetView() tview.Primitive { return m.view }