subsonic-tui/internal/tui/views/main.go
Sagi Dayan 92e3c4ea7d
All checks were successful
ci / check for spelling errors (pull_request) Successful in 21s
ci / code quality (lint/tests) (pull_request) Successful in 2m29s
ci / Make sure build does not fail (pull_request) Successful in 2m25s
ci / notify-fail (pull_request) Has been skipped
ci / check for spelling errors (push) Successful in 22s
ci / code quality (lint/tests) (push) Successful in 1m53s
ci / Make sure build does not fail (push) Successful in 2m55s
ci / notify-fail (push) Has been skipped
Added ci workflow + fixed linting issues
Signed-off-by: Sagi Dayan <sagidayan@gmail.com>
2024-12-18 18:02:01 +02:00

691 lines
16 KiB
Go

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
}