From 92e3c4ea7d85824e5044a04fd48ba6c7d48beb3f Mon Sep 17 00:00:00 2001 From: Sagi Dayan Date: Wed, 18 Dec 2024 17:29:16 +0200 Subject: [PATCH] Added ci workflow + fixed linting issues Signed-off-by: Sagi Dayan --- .forgejo/workflows/build.yml | 65 +++++++++++- .golangci.yml | 97 ++++------------- Makefile | 24 ++++- internal/client/artwork_cache.go | 14 ++- internal/client/client.go | 87 +++++++++------ internal/common/shuffle.go | 2 + internal/config/config.go | 36 +++++-- internal/playback/controller.go | 17 ++- internal/playback/mpris.go | 83 +++++++++++---- internal/playback/queue.go | 18 +++- internal/tui/tui.go | 28 +++-- internal/tui/views/albums.go | 4 +- internal/tui/views/artists.go | 4 +- internal/tui/views/help.go | 7 +- internal/tui/views/layout.go | 169 ++++++++++++++++++------------ internal/tui/views/login.go | 2 +- internal/tui/views/main.go | 125 +++++++++++++++++----- internal/tui/views/player.go | 17 +-- internal/tui/views/playlists.go | 2 - internal/tui/views/queue.go | 9 +- internal/tui/views/status_line.go | 6 ++ internal/variables/variables.go | 6 ++ main.go | 6 +- 23 files changed, 567 insertions(+), 261 deletions(-) create mode 100644 internal/variables/variables.go diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index a79572c..32e7b86 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -1,4 +1,4 @@ -name: build bin +name: ci on: push: @@ -10,9 +10,52 @@ permissions: contents: read jobs: - build: + codespell: + name: check for spelling errors + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Annotate locations with typos + uses: "https://github.com/codespell-project/codespell-problem-matcher@v1" + - name: Codespell + uses: "https://github.com/codespell-project/actions-codespell@v2" + with: + check_filenames: true + check_hidden: true + skip: ./.git + + codequality: + name: code quality (lint/tests) + runs-on: ubuntu-latest + needs: + - codespell + if: ${{ success() }} + steps: + - name: checkout + uses: actions/checkout@v4 + - name: install alsa devel + run: apt update && apt install libasound2-dev -y + - uses: https://code.forgejo.org/actions/setup-go@v5 + name: install go + with: + go-version-file: './go.mod' + - name: install dependencies + run: make dep + - name: download golangci-lint + run: wget https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh + - name: install golangci-lint + run: sh ./install.sh v1.62.2 + - name: lint + run: ./bin/golangci-lint run + + compile: name: Make sure build does not fail runs-on: ubuntu-latest + needs: + - codespell + - codequality + if: ${{ success() }} steps: - uses: https://code.forgejo.org/actions/checkout@v4 name: checkout @@ -27,3 +70,21 @@ jobs: - run: make build name: install dependencies and build bin files + notify-fail: + name: notify-fail + runs-on: ubuntu-latest + needs: + - codespell + - codequality + - compile + if: ${{ failure() }} + env: + SERVER_URL: ${{ secrets.GOTIFY_SERVER_URL }} + TOKEN: ${{ secrets.GOTIFY_TOKEN }} + steps: + - name: gotify + run: |- + curl "${SERVER_URL}?token=${TOKEN}" \ + -F "title='CI failed'" \ + -F "message='Something failed at ${GITHUB_REPOSITORY}/${GITHUB_REF} for user ${GITHUB_ACTOR}'" \ + -F "priority=7" &> /dev/null diff --git a/.golangci.yml b/.golangci.yml index 8b44f70..e016e8d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,76 +1,23 @@ -# This file contains all available configuration options -# with their default values. - -# options for analysis running -run: - # default concurrency is a available CPU number - concurrency: 4 - - # timeout for analysis, e.g. 30s, 5m, default is 1m - timeout: 10m - - # exit code when at least one issue was found, default is 1 - issues-exit-code: 1 - - # include test files or not, default is true - tests: true - - # which dirs to skip: issues from them won't be reported; - # can use regexp here: generated.*, regexp is applied on full path; - # default value is empty list, but default dirs are skipped independently - # from this option's value (see skip-dirs-use-default). - skip-dirs: - - bin - - deploy - - docs - - examples - - hack - - packaging - - reports - -# output configuration options -output: - # colored-line-number|line-number|json|tab|checkstyle|code-climate, default is "colored-line-number" - format: colored-line-number - - # print lines of code with issue, default is true - print-issued-lines: true - - # print linter name in the end of issue text, default is true - print-linter-name: true - - # make issues output unique by line, default is true - uniq-by-line: true - -issues: - # List of regexps of issue texts to exclude, empty list by default. - # But independently from this option we use default exclude patterns, - # it can be disabled by `exclude-use-default: false`. To list all - # excluded by default patterns execute `golangci-lint run --help` - exclude: - - 'declaration of "err" shadows declaration at' - linters: - enable: - - megacheck - - govet - - gocyclo - - gofmt - - gosec - - megacheck - - unconvert - - gci - - goimports - - exportloopref - -linters-settings: - govet: - check-shadowing: true - - settings: - printf: - funcs: - - Infof - - Warnf - - Errorf - - Fatalf + enable-all: true + disable: + - wrapcheck + - exhaustruct + - varnamelen + - gochecknoglobals + - depguard + - gochecknoinits + - forbidigo + - revive + - gosec + - dogsled + - mnd + - funlen + - cyclop + - gocognit + - exhaustive + - maintidx + - gocritic + - nestif + - ireturn + - stylecheck diff --git a/Makefile b/Makefile index 10b4fc1..62dda54 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,16 @@ BINARY_NAME=subsonic-tui BUILD_FOLDER=build +VERSION=0.0.1 + +GO_BUILD_LD_FLAGS=-ldflags="-s -w -X 'git.dayanhub.com/sagi/subsonic-tui/internal/variables.Commit=$(shell git rev-parse --short HEAD)' \ + -X 'git.dayanhub.com/sagi/subsonic-tui/internal/variables.Version=${VERSION}'" .PHONY: build build: dep - GOARCH=amd64 GOOS=darwin go build -o ${BUILD_FOLDER}/${BINARY_NAME}-darwin main.go - GOARCH=amd64 GOOS=linux go build -o ${BUILD_FOLDER}/${BINARY_NAME}-linux main.go - GOARCH=amd64 GOOS=windows go build -o ${BUILD_FOLDER}/${BINARY_NAME}-windows main.go + GOARCH=amd64 GOOS=darwin go build ${GO_BUILD_LD_FLAGS} -o ${BUILD_FOLDER}/${BINARY_NAME}-${VERSION}-amd64-darwin main.go + GOARCH=amd64 GOOS=linux go build ${GO_BUILD_LD_FLAGS} -o ${BUILD_FOLDER}/${BINARY_NAME}-${VERSION}-amd64-linux main.go + GOARCH=amd64 GOOS=windows go build ${GO_BUILD_LD_FLAGS} -o ${BUILD_FOLDER}/${BINARY_NAME}-${VERSION}-amd64-windows main.go run: build ./${BINARY_NAME} @@ -30,3 +34,17 @@ vet: lint: golangci-lint run --enable-all + +.PHONY: install +install: build +ifeq ($(OS),Windows_NT) # is Windows_NT on XP, 2000, 7, Vista, 10... + $(error Unable to install on windows) +else +ifeq ($(shell uname), Linux) + sudo cp ${BUILD_FOLDER}/${BINARY_NAME}-${VERSION}-amd64-linux /usr/local/bin/${BINARY_NAME} +else + @echo $(shell uname) + $(error Unable to install on systems that are not linux) +endif +endif + diff --git a/internal/client/artwork_cache.go b/internal/client/artwork_cache.go index c3049e6..dc7516f 100644 --- a/internal/client/artwork_cache.go +++ b/internal/client/artwork_cache.go @@ -4,6 +4,7 @@ import ( "fmt" "image" "image/jpeg" + "io/fs" "os" "path" ) @@ -25,6 +26,7 @@ func (c *artcache) saveArt(id string, img image.Image) *string { if path != nil { c.artPaths[id] = *path } + return path } @@ -39,10 +41,12 @@ func (c *artcache) saveImage(id string, img image.Image) *string { if err != nil { return nil } + err = jpeg.Encode(f, img, nil) if err != nil { return nil } + return &filePath } @@ -50,6 +54,7 @@ func (c *artcache) GetPath(id string) *string { if path, ok := c.artPaths[id]; ok { return &path } + return nil } @@ -72,11 +77,12 @@ func (c *artcache) GetImage(id string) *image.Image { if err != nil { return nil } + return &img } func (c *artcache) filepath(id string) string { - return path.Join(c.cacheDir, fmt.Sprintf("%s.jpg", id)) + return path.Join(c.cacheDir, id+".jpg") } func (c *artcache) Destroy() { @@ -86,10 +92,14 @@ func (c *artcache) Destroy() { func init() { tmpDir := os.TempDir() cacheDir := path.Join(tmpDir, fmt.Sprintf("subsonic-tui-%d", os.Getpid())) - err := os.Mkdir(cacheDir, 0777) + + var permissions fs.FileMode = 0o777 + + err := os.Mkdir(cacheDir, permissions) if err != nil { panic("Failed to create cacheDir") } + ArtCache = &artcache{ cacheDir: cacheDir, artPaths: make(map[string]string), diff --git a/internal/client/client.go b/internal/client/client.go index 2115e82..729a1d7 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -1,10 +1,10 @@ package client import ( - "fmt" "image" "io" "net/http" + "strconv" "sync" "git.dayanhub.com/sagi/subsonic-tui/internal/common" @@ -16,7 +16,7 @@ type Client struct { } func NewClient(baseURL string) *Client { - var client subsonic.Client = subsonic.Client{ + client := subsonic.Client{ Client: &http.Client{}, ClientName: "subsonic-tui", BaseUrl: baseURL, @@ -30,6 +30,7 @@ func NewClient(baseURL string) *Client { func (c *Client) Authenticate(username, password string) error { c.client.User = username + return c.client.Authenticate(password) } @@ -41,8 +42,8 @@ func (c *Client) GetPlaylists() ([]*subsonic.Playlist, error) { return c.client.GetPlaylists(map[string]string{}) } -func (c *Client) GetPlaylist(ID string) (*subsonic.Playlist, error) { - return c.client.GetPlaylist(ID) +func (c *Client) GetPlaylist(id string) (*subsonic.Playlist, error) { + return c.client.GetPlaylist(id) } func (c *Client) GetArtists() ([]*subsonic.ArtistID3, error) { @@ -50,7 +51,9 @@ func (c *Client) GetArtists() ([]*subsonic.ArtistID3, error) { if err != nil { return nil, err } + artists := []*subsonic.ArtistID3{} + for _, i := range indexes.Index { artists = append(artists, i.Artist...) } @@ -64,53 +67,58 @@ func (c *Client) GetAlbums() ([]*subsonic.AlbumID3, error) { }) } -func (c *Client) GetArtist(ID string) (*subsonic.ArtistID3, error) { - return c.client.GetArtist(ID) +func (c *Client) GetArtist(id string) (*subsonic.ArtistID3, error) { + return c.client.GetArtist(id) } -func (c *Client) GetArtistInfo(ID string) (*subsonic.ArtistInfo2, error) { - return c.client.GetArtistInfo2(ID, map[string]string{ +func (c *Client) GetArtistInfo(id string) (*subsonic.ArtistInfo2, error) { + return c.client.GetArtistInfo2(id, map[string]string{ "count": "20", }) } -func (c *Client) GetAlbum(ID string) (*subsonic.AlbumID3, error) { - return c.client.GetAlbum(ID) +func (c *Client) GetAlbum(id string) (*subsonic.AlbumID3, error) { + return c.client.GetAlbum(id) } -func (c *Client) GetCoverArt(ID string) (image.Image, error) { - if img := ArtCache.GetImage(ID); img != nil { +func (c *Client) GetCoverArt(id string) (image.Image, error) { + if img := ArtCache.GetImage(id); img != nil { return *img, nil } - img, err := c.client.GetCoverArt(ID, map[string]string{ - //"size": "64", + + img, err := c.client.GetCoverArt(id, map[string]string{ + // "size": "64", }) if err != nil { return nil, err } - ArtCache.saveArt(ID, img) + + ArtCache.saveArt(id, img) + return img, err } func (c *Client) GetSimilarSongs(artistID string, maxSongs int) ([]*subsonic.Child, error) { - max := fmt.Sprintf("%d", maxSongs) + count := strconv.Itoa(maxSongs) + return c.client.GetSimilarSongs2(artistID, map[string]string{ - "count": max, + "count": count, }) } -func (c *Client) Stream(ID string) (io.Reader, error) { - return c.client.Stream(ID, map[string]string{ +func (c *Client) Stream(id string) (io.Reader, error) { + return c.client.Stream(id, map[string]string{ "format": "mp3", }) } -func (c *Client) Scrobble(ID string) error { - return c.client.Scrobble(ID, map[string]string{}) +func (c *Client) Scrobble(id string) error { + return c.client.Scrobble(id, map[string]string{}) } -func (c *Client) GetTopSongs(name string, max int) ([]*subsonic.Child, error) { - count := fmt.Sprintf("%d", max) +func (c *Client) GetTopSongs(name string, maxSongs int) ([]*subsonic.Child, error) { + count := strconv.Itoa(maxSongs) + return c.client.GetTopSongs(name, map[string]string{ "count": count, }) @@ -124,40 +132,59 @@ func (c *Client) Search(query string) (*subsonic.SearchResult3, error) { }) } -func (c *Client) GetExperimentalArtistRadio(artistId3 *subsonic.ArtistID3, info *subsonic.ArtistInfo2, max int) ([]*subsonic.Child, error) { +func (c *Client) GetExperimentalArtistRadio(artistID3 *subsonic.ArtistID3, + info *subsonic.ArtistInfo2, maxSongs int, +) ([]*subsonic.Child, error) { var wg sync.WaitGroup - ID := artistId3.ID + + ID := artistID3.ID similarArtists := info.SimilarArtist songs := []*subsonic.Child{} similarArtistsSongs := 10 thisArtistFactor := 3 portion := len(info.SimilarArtist) * similarArtistsSongs * thisArtistFactor - wg.Add(2) + + wg.Add(1) + go func() { s, _ := c.GetSimilarSongs(ID, portion) songs = append(songs, s...) + wg.Done() }() + + wg.Add(1) + go func() { - s, _ := c.GetTopSongs(artistId3.Name, similarArtistsSongs) + s, _ := c.GetTopSongs(artistID3.Name, similarArtistsSongs) songs = append(songs, s...) + wg.Done() }() + common.ShuffleSlice(similarArtists) + for _, a := range similarArtists { wg.Add(1) + artist := a + go func() { s, _ := c.GetSimilarSongs(artist.ID, similarArtistsSongs) songs = append(songs, s...) + wg.Done() }() } + wg.Wait() - if max > len(songs) { - max = len(songs) + + if maxSongs > len(songs) { + maxSongs = len(songs) } - songs = songs[:max] + + songs = songs[:maxSongs] common.ShuffleSlice(songs) + return songs, nil } diff --git a/internal/common/shuffle.go b/internal/common/shuffle.go index 82d420f..ae2a3de 100644 --- a/internal/common/shuffle.go +++ b/internal/common/shuffle.go @@ -11,11 +11,13 @@ func ShuffleSlice(slice interface{}) { swap := reflect.Swapper(slice) length := rv.Len() + for i := length - 1; i > 0; i-- { j, err := rand.Int(rand.Reader, big.NewInt(int64(i+1))) if err != nil { panic("Shuffle error") } + swap(i, int(j.Int64())) } } diff --git a/internal/config/config.go b/internal/config/config.go index 7d10602..86a0bfc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "encoding/base64" "fmt" + "io/fs" "os" "path" "strings" @@ -17,9 +18,9 @@ type _config struct { Username string `yaml:"username"` Password string `yaml:"password"` URL string `yaml:"url"` - EnableScrobble bool `yaml:"enable_scrobble" default:"false"` - MaxRadioSongs int `yaml:"max_radio_songs" default:"50"` - ExperimentalRadioAlgo bool `yaml:"experimental_radio_algo" default:"false"` + EnableScrobble bool `default:"false" yaml:"enableScrobble"` + MaxRadioSongs int `default:"50" yaml:"maxRadioSongs"` + ExperimentalRadioAlgo bool `default:"false" yaml:"experimentalRadioAlgo"` } var configStruct *_config @@ -33,13 +34,18 @@ func init() { fmt.Printf("[ERROR] Failed to fetch user config directory. %e\n", err) os.Exit(1) } + if _, err := os.Stat(configDir); os.IsNotExist(err) { - err := os.MkdirAll(configDir, 0700) + var permissions fs.FileMode = 0o700 + + err := os.MkdirAll(configDir, permissions) if err != nil { panic(err) } } + var configFile *os.File + if _, err := os.Stat(configPath); os.IsNotExist(err) { configFile, err = os.Create(configPath) defer func() { @@ -48,35 +54,44 @@ func init() { panic(err) } }() + if err != nil { fmt.Printf("[ERROR] Failed to create config file @ %s. %e\n", configPath, err) - os.Exit(1) + panic("unable to create config file") } } + configStruct, err = loadConfig() if err != nil { fmt.Printf("[ERROR] Failed to load config file @ %s. %e\n", configPath, err) - os.Exit(1) + panic("unable to load config file") } + fmt.Printf("Init Config %s\n", configPath) } func URL() string { return configStruct.URL } + func Username() string { return configStruct.Username } + func Password() string { p, _ := base64.StdEncoding.DecodeString(configStruct.Password) + return strings.TrimSpace(string(p)) } + func ScrobbleEnabled() bool { return configStruct.EnableScrobble } + func MaxRadioSongs() int { return configStruct.MaxRadioSongs } + func ExperimentalRadioAlgo() bool { return configStruct.ExperimentalRadioAlgo } @@ -95,17 +110,21 @@ func SetURL(u string) { func loadConfig() (*_config, error) { c := &_config{} + err := defaults.Set(c) if err != nil { panic(err) } + file, err := os.ReadFile(configPath) if err != nil { return nil, err } + if err = yaml.Unmarshal(file, c); err != nil { return nil, err } + return c, nil } @@ -115,7 +134,10 @@ func SaveConfig() { fmt.Printf("[ERROR] Failed to convert config to yaml. %e\n", err) os.Exit(1) } - err = os.WriteFile(configPath, yml, 0600) + + var permissions fs.FileMode = 0o600 + + err = os.WriteFile(configPath, yml, permissions) if err != nil { fmt.Printf("[ERROR] Failed to save config file @ %s. %e\n", configPath, err) os.Exit(1) diff --git a/internal/playback/controller.go b/internal/playback/controller.go index 69c9102..e93604e 100644 --- a/internal/playback/controller.go +++ b/internal/playback/controller.go @@ -46,7 +46,9 @@ func NewController(client *client.Client) *Controller { } controller.desktopPlayback = desktopPlayer(controller) controller.desktopPlayback.Start() + go controller.playbackTicker() + return controller } @@ -58,14 +60,17 @@ func (c *Controller) Play(song *subsonic.Child) { if song == nil { return } + r, err := c.client.Stream(song.ID) if err != nil { - //TODO: Log error c.Stop() + return } + c.Stream(r, song) } + func (c *Controller) Next() { song := c.queue.Next() if song != nil { @@ -78,6 +83,7 @@ func (c *Controller) AddToQueue(songs []*subsonic.Child) { if shouldPlay { c.Play(c.queue.GetCurrentSong()) } + c.desktopPlayback.OnPlaylistChanged() } @@ -108,6 +114,7 @@ func (c *Controller) Prev() { c.Play(song) } } + func (c *Controller) GetQueue() []*subsonic.Child { return c.queue.Get() } @@ -131,6 +138,7 @@ func (c *Controller) SetSongEndedFunc(f func(song *subsonic.Child)) { func (c *Controller) Close() error { c.Stop() c.closeChan <- true + return nil } @@ -143,6 +151,7 @@ func (c *Controller) TogglePlayPause() { c.playbackState = PlaybackStatePlaying } } + c.desktopPlayback.OnPlayPause() } @@ -150,12 +159,16 @@ func (c *Controller) Stop() { if c.ctrl == nil { return } + speaker.Clear() + c.ctrl.Paused = true c.playbackState = PlaybackStateStopped c.ctrl = nil c.stream = nil + c.songElapsedFunc(c.song, time.Duration(0)) + c.song = nil c.position = 0 c.desktopPlayback.OnPlayPause() @@ -193,6 +206,7 @@ func (c *Controller) Stream(reader io.Reader, song *subsonic.Child) { readerCloser := io.NopCloser(reader) decodedMp3, format, err := mp3.Decode(readerCloser) decodedMp3.Position() + if err != nil { panic("mp3.NewDecoder failed: " + err.Error()) } @@ -214,6 +228,7 @@ func (c *Controller) Stream(reader io.Reader, song *subsonic.Child) { ctrl := &beep.Ctrl{Streamer: stream} c.ctrl = ctrl c.playbackState = PlaybackStatePlaying + speaker.Play(beep.Seq(ctrl, beep.Callback(func() { c.songEndedFunc(song) c.songEndedChan <- true diff --git a/internal/playback/mpris.go b/internal/playback/mpris.go index dd7e83c..e3da072 100644 --- a/internal/playback/mpris.go +++ b/internal/playback/mpris.go @@ -9,11 +9,11 @@ import ( "github.com/godbus/dbus/v5" "github.com/quarckster/go-mpris-server/pkg/events" "github.com/quarckster/go-mpris-server/pkg/server" - . "github.com/quarckster/go-mpris-server/pkg/types" + mprisTypes "github.com/quarckster/go-mpris-server/pkg/types" ) const ( - mprisPlayerNmae = "MehSonic" + mprisPlayerNmae = "SubsonicTUI" mprisNoTrack = "/org/mpris/MediaPlayer2/TrackList/NoTrack" ) @@ -22,24 +22,31 @@ type mprisRoot struct{} func (r mprisRoot) Raise() error { return nil } + func (r mprisRoot) Quit() error { return nil } + func (r mprisRoot) CanQuit() (bool, error) { return true, nil } + func (r mprisRoot) CanRaise() (bool, error) { return false, nil } + func (r mprisRoot) HasTrackList() (bool, error) { return false, nil } + func (r mprisRoot) Identity() (string, error) { return mprisPlayerNmae, nil } + func (r mprisRoot) SupportedUriSchemes() ([]string, error) { return []string{}, nil } + func (r mprisRoot) SupportedMimeTypes() ([]string, error) { return []string{}, nil } @@ -48,21 +55,27 @@ type mprisPlayer struct { ctrl *Controller } -// Implement other methods of `pkg.types.OrgMprisMediaPlayer2PlayerAdapter` +// Implement other methods of `pkg.types.OrgMprisMediaPlayer2PlayerAdapter`. func (p mprisPlayer) Next() error { p.ctrl.Next() + return nil } + func (p mprisPlayer) Previous() error { p.ctrl.Prev() + return nil } + func (p mprisPlayer) Pause() error { if p.ctrl.State() == PlaybackStatePlaying { p.ctrl.TogglePlayPause() } + return nil } + func (p mprisPlayer) PlayPause() error { switch p.ctrl.State() { case PlaybackStatePaused, PlaybackStatePlaying: @@ -70,12 +83,16 @@ func (p mprisPlayer) PlayPause() error { case PlaybackStateStopped: p.ctrl.Play(p.ctrl.GetCurrentSong()) } + return nil } + func (p mprisPlayer) Stop() error { p.ctrl.Stop() + return nil } + func (p mprisPlayer) Play() error { switch p.ctrl.State() { case PlaybackStatePaused: @@ -83,45 +100,54 @@ func (p mprisPlayer) Play() error { case PlaybackStateStopped: p.ctrl.Play(p.ctrl.GetCurrentSong()) } + return nil } -func (p mprisPlayer) Seek(offset Microseconds) error { + +func (p mprisPlayer) Seek(offset mprisTypes.Microseconds) error { return nil } -func (p mprisPlayer) SetPosition(trackId string, position Microseconds) error { + +func (p mprisPlayer) SetPosition(trackId string, position mprisTypes.Microseconds) error { return nil } + func (p mprisPlayer) OpenUri(uri string) error { return nil } -func (p mprisPlayer) PlaybackStatus() (PlaybackStatus, error) { + +func (p mprisPlayer) PlaybackStatus() (mprisTypes.PlaybackStatus, error) { switch p.ctrl.State() { case PlaybackStatePlaying: - return PlaybackStatusPlaying, nil + return mprisTypes.PlaybackStatusPlaying, nil case PlaybackStatePaused: - return PlaybackStatusPaused, nil + return mprisTypes.PlaybackStatusPaused, nil case PlaybackStateStopped: - return PlaybackStatusStopped, nil + return mprisTypes.PlaybackStatusStopped, nil } // Should not get here - return PlaybackStatusStopped, nil - + return mprisTypes.PlaybackStatusStopped, nil } + func (p mprisPlayer) Rate() (float64, error) { return 1, nil } + func (p mprisPlayer) SetRate(float64) error { return nil } -func (p mprisPlayer) Metadata() (Metadata, error) { + +func (p mprisPlayer) Metadata() (mprisTypes.Metadata, error) { s := p.ctrl.GetCurrentSong() objPath := mprisNoTrack + if s != nil { - objPath = encodeTrackId(s.ID) + objPath = encodeTrackID(s.ID) } else { s = &subsonic.Child{} } - md := Metadata{ + + md := mprisTypes.Metadata{ TrackId: dbus.ObjectPath(objPath), Length: secondsToMicroseconds(s.Duration), Title: s.Title, @@ -134,41 +160,54 @@ func (p mprisPlayer) Metadata() (Metadata, error) { UseCount: int(s.PlayCount), } artw := client.ArtCache.GetPath(s.CoverArt) + if artw != nil { - md.ArtUrl = fmt.Sprintf("file://%s", *artw) + md.ArtUrl = "file://" + *artw } + return md, nil } + func (p mprisPlayer) Volume() (float64, error) { return 1, nil } + func (p mprisPlayer) SetVolume(float64) error { return nil } + func (p mprisPlayer) Position() (int64, error) { return int64(secondsToMicroseconds(int(p.ctrl.position))), nil } + func (p mprisPlayer) MinimumRate() (float64, error) { return 1, nil } + func (p mprisPlayer) MaximumRate() (float64, error) { return 1, nil } + func (p mprisPlayer) CanGoNext() (bool, error) { return p.ctrl.queue.HasNext(), nil } + func (p mprisPlayer) CanGoPrevious() (bool, error) { return p.ctrl.queue.HasPrev(), nil } + func (p mprisPlayer) CanPlay() (bool, error) { return p.ctrl.GetCurrentSong() != nil, nil } + func (p mprisPlayer) CanPause() (bool, error) { return true, nil } + func (p mprisPlayer) CanSeek() (bool, error) { return false, nil } + func (p mprisPlayer) CanControl() (bool, error) { return true, nil } @@ -197,18 +236,23 @@ func (p *mprisPlayback) OnPlayPause() { if p.err != nil { return } + p.err = p.eventHandler.Player.OnPlayPause() } + func (p *mprisPlayback) OnPlaylistChanged() { if p.err != nil { return } + p.err = p.eventHandler.Player.OnOptions() } + func (p *mprisPlayback) OnSongChanged() { if p.err != nil { return } + p.err = p.eventHandler.Player.OnTitle() } @@ -216,10 +260,12 @@ func (p *mprisPlayback) OnPositionChanged(position int) { if p.err != nil { return } + p.err = p.eventHandler.Player.OnSeek(secondsToMicroseconds(position)) if p.err != nil { return } + p.err = p.eventHandler.Player.OnOptions() } @@ -239,11 +285,12 @@ func desktopPlayer(c *Controller) DesktopPlayback { } } -func secondsToMicroseconds(s int) Microseconds { - return Microseconds(s * 1_000_000) +func secondsToMicroseconds(s int) mprisTypes.Microseconds { + return mprisTypes.Microseconds(s * 1_000_000) } -func encodeTrackId(id string) string { +func encodeTrackID(id string) string { data := []byte(id) + return fmt.Sprintf("/%s/Track/%s", mprisPlayerNmae, base32.StdEncoding.WithPadding('0').EncodeToString(data)) } diff --git a/internal/playback/queue.go b/internal/playback/queue.go index 2800f28..fa1d979 100644 --- a/internal/playback/queue.go +++ b/internal/playback/queue.go @@ -24,23 +24,28 @@ func (q *queue) Clear() { q.songQueue = []*subsonic.Child{} } -// returns true if queue was empty before addition +// returns true if queue was empty before addition. func (q *queue) Add(songs ...*subsonic.Child) bool { shouldStartPlaying := len(q.songQueue) == 0 q.songQueue = append(q.songQueue, songs...) + if shouldStartPlaying { q.currentSong = 0 + return true } + return false } -// returns a song if position has changed +// returns a song if position has changed. func (q *queue) SetPosition(position int) *subsonic.Child { if position == q.currentSong || position < 0 || len(q.songQueue) < position { return nil } + q.currentSong = position + return q.GetCurrentSong() } @@ -52,6 +57,7 @@ func (q *queue) GetCurrentSong() *subsonic.Child { if len(q.songQueue) == 0 { return nil } + return q.songQueue[q.currentSong] } @@ -65,17 +71,21 @@ func (q *queue) HasNext() bool { func (q *queue) Next() *subsonic.Child { if len(q.songQueue) > q.currentSong+1 { - q.currentSong = q.currentSong + 1 + q.currentSong++ + return q.GetCurrentSong() } + return nil } func (q *queue) Prev() *subsonic.Child { if q.currentSong > 0 { - q.currentSong = q.currentSong - 1 + q.currentSong-- + return q.GetCurrentSong() } + return nil } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 9927162..f8feec3 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -24,12 +24,14 @@ func NewLogin() *TUI { app := tview.NewApplication() layout := views.NewLoginView(func(u, p, url string) { c := client.NewClient(url) + err := c.Authenticate(u, p) if err != nil { app.Stop() fmt.Printf("[Error] Failed to login. Aborting %e", err) os.Exit(1) } + config.SetURL(url) config.SetUsername(u) config.SetPassword(p) @@ -47,6 +49,7 @@ func NewLogin() *TUI { app.Stop() os.Exit(0) } + return event }) @@ -79,36 +82,46 @@ func NewPlayer(client *client.Client, playbackCtl *playback.Controller) *TUI { pages.SwitchToPage("app") help.GetView().Blur() layout.GetView().Focus(nil) + return nil } + if layout.Mode() == views.StatusModeSearch { return event } - if event.Rune() == 'q' { + + switch event.Rune() { + case 'q': app.Stop() fmt.Println("Exiting..") + return nil - } else if event.Rune() == 'h' { + case 'h': return tcell.NewEventKey(tcell.KeyLeft, rune(tcell.KeyLeft), event.Modifiers()) - } else if event.Rune() == 'j' { + case 'j': return tcell.NewEventKey(tcell.KeyDown, rune(tcell.KeyDown), event.Modifiers()) - } else if event.Rune() == 'k' { + case 'k': return tcell.NewEventKey(tcell.KeyUp, rune(tcell.KeyUp), event.Modifiers()) - } else if event.Rune() == 'l' { + case 'l': return tcell.NewEventKey(tcell.KeyRight, rune(tcell.KeyRight), event.Modifiers()) - } else if event.Rune() == '?' { + case '?': pages.SwitchToPage("help") layout.GetView().Blur() + go app.Draw() + default: + return event } + return event }) + app.SetAfterDrawFunc(func(screen tcell.Screen) { layout.Update() }) app.GetFocus().Blur() - //app.SetFocus(layout.GetView()) + // app.SetFocus(layout.GetView()) return &TUI{ app: app, @@ -116,7 +129,6 @@ func NewPlayer(client *client.Client, playbackCtl *playback.Controller) *TUI { client: client, playbackCtl: playbackCtl, } - } func (t *TUI) Run() error { diff --git a/internal/tui/views/albums.go b/internal/tui/views/albums.go index 3cb9a50..ad584c3 100644 --- a/internal/tui/views/albums.go +++ b/internal/tui/views/albums.go @@ -17,7 +17,6 @@ type albums struct { } func NewAlbums(client *client.Client) *albums { - list := tview.NewTable() list.SetBackgroundColor(config.ColorBackground) @@ -46,6 +45,7 @@ func NewAlbums(client *client.Client) *albums { }) obj.Update() + return obj } @@ -56,9 +56,11 @@ func (a *albums) SetAlbums(al []*subsonic.AlbumID3) { func (a *albums) Update() { a.view.Clear() + for i, pl := range a.albums { title := tview.NewTableCell(pl.Name).SetExpansion(1).SetMaxWidth(15) artist := tview.NewTableCell(pl.Artist).SetExpansion(1).SetAlign(tview.AlignRight) + a.view.SetCell(i, 0, title) a.view.SetCell(i, 1, artist) } diff --git a/internal/tui/views/artists.go b/internal/tui/views/artists.go index 94f3d5c..f3e19c7 100644 --- a/internal/tui/views/artists.go +++ b/internal/tui/views/artists.go @@ -18,7 +18,6 @@ type artists struct { } func NewArtists(client *client.Client) *artists { - list := tview.NewTable() list.SetBackgroundColor(config.ColorBackground) @@ -38,7 +37,6 @@ func NewArtists(client *client.Client) *artists { for i, artist := range arts { cell := tview.NewTableCell(artist.Name).SetExpansion(1) list.SetCell(i, 0, cell) - // list.AddItem(artist.Name, fmt.Sprintf("%s", artist.Name), '0', nil) } resp := &artists{ @@ -57,12 +55,12 @@ func NewArtists(client *client.Client) *artists { func (a *artists) SetSelectArtistFunc(f func(artistId string)) { a.selectArtistFunc = f } + func (a *artists) SetOpenArtistFunc(f func(artistId string)) { a.openArtistFunc = f } func (a *artists) Update() { - } func (a *artists) GetView() tview.Primitive { diff --git a/internal/tui/views/help.go b/internal/tui/views/help.go index 0343076..a352084 100644 --- a/internal/tui/views/help.go +++ b/internal/tui/views/help.go @@ -60,8 +60,10 @@ func NewHelp() *help { h := &help{} view := tview.NewTable() view.SetBackgroundColor(config.ColorBackground) + height := 16 width := 100 + view.SetBorder(true) view.SetTitle(" Keybindings ") view.SetTitleAlign(tview.AlignCenter) @@ -69,9 +71,11 @@ func NewHelp() *help { for i, km := range keyMaps { odd := i%2 != 0 txtColor := config.ColorText + if odd { txtColor = config.ColorTextAccent } + keyCell := tview.NewTableCell(km.key). SetExpansion(1). SetAlign(tview.AlignCenter). @@ -80,6 +84,7 @@ func NewHelp() *help { SetExpansion(1). SetAlign(tview.AlignCenter). SetTextColor(txtColor) + view.SetCell(i, 0, keyCell) view.SetCell(i, 1, descCell) } @@ -94,7 +99,6 @@ func NewHelp() *help { AddItem(innerFlex, width, 1, true). AddItem(EmptyBox, 0, 1, false) wrapper.SetBackgroundColor((config.ColorBackground)) - h.view = wrapper return h @@ -105,7 +109,6 @@ func (h *help) GetView() tview.Primitive { } func (h *help) Update() { - } func (h *help) SetKeyPressedFunc(f func()) { diff --git a/internal/tui/views/layout.go b/internal/tui/views/layout.go index 0b2bc6d..8beb2cc 100644 --- a/internal/tui/views/layout.go +++ b/internal/tui/views/layout.go @@ -23,13 +23,16 @@ type layout struct { mainView *main } +//gocyclo:ignore func NewLayout(client *client.Client, playbackCtl *playback.Controller, refreshUI func()) *layout { layout := &layout{} largeView := tview.NewGrid().SetRows(0, 4, 1) largeView.SetBackgroundColor(config.ColorBackground) + smallView := tview.NewFlex().SetDirection(tview.FlexRow) smallView.SetBackgroundColor(config.ColorBackground) + layout.largeView = largeView layout.smallView = smallView pages := tview.NewPages() @@ -73,6 +76,7 @@ func NewLayout(client *client.Client, playbackCtl *playback.Controller, refreshU artists.SetSelectArtistFunc(func(artistId string) { artists.view.Blur() statusLine.Log("Fetching artist's albums...") + go func() { a, _ := client.GetArtist(artistId) albums.SetAlbums(a.Album) @@ -83,8 +87,10 @@ func NewLayout(client *client.Client, playbackCtl *playback.Controller, refreshU artists.SetOpenArtistFunc(func(artistId string) { artists.view.Blur() statusLine.Log("Fetching artists...") + layout.currentFocusedView = main.GetView() layout.rebuildSmallView() + go func() { a, _ := client.GetArtist(artistId) main.SetArtist(a) @@ -96,8 +102,11 @@ func NewLayout(client *client.Client, playbackCtl *playback.Controller, refreshU albums.SetCallback(func(albumID string) { albums.view.Blur() statusLine.Log("Fetching album...") + layout.currentFocusedView = main.GetView() + layout.rebuildSmallView() + go func() { a, _ := client.GetAlbum(albumID) main.SetAlbum(a) @@ -109,8 +118,10 @@ func NewLayout(client *client.Client, playbackCtl *playback.Controller, refreshU playlists.SetCallback(func(p *subsonic.Playlist) { playlists.view.Blur() statusLine.Log("Fetching playlist...") + layout.currentFocusedView = main.GetView() layout.rebuildSmallView() + go func() { playlist, _ := client.GetPlaylist(p.ID) main.SetPlaylist(playlist) @@ -140,6 +151,7 @@ func NewLayout(client *client.Client, playbackCtl *playback.Controller, refreshU playbackCtl.SetSongEndedFunc(func(song *subsonic.Child) { statusLine.Log("") queue.Update(playbackCtl.GetQueue(), playbackCtl.GetQueuePosition()) + if config.ScrobbleEnabled() { // Scrobble _ = client.Scrobble(song.ID) @@ -159,13 +171,16 @@ func NewLayout(client *client.Client, playbackCtl *playback.Controller, refreshU if len(quary) == 0 { layout.currentFocusedView.Focus(nil) statusLine.Log("Search canceled") + return } // Search... statusLine.Log("Searching for '%s'....", quary) statusLine.view.Blur() + go func() { result, _ := client.Search(quary) + layout.currentFocusedView.Blur() main.SetSearch(result, quary) layout.currentFocusedView = main.GetView() @@ -179,6 +194,7 @@ func NewLayout(client *client.Client, playbackCtl *playback.Controller, refreshU if statusLine.Mode() == StatusModeSearch { return event } + if event.Rune() == '1' { // Focus Artists artists.view.Blur() @@ -189,6 +205,7 @@ func NewLayout(client *client.Client, playbackCtl *playback.Controller, refreshU artists.view.Focus(nil) layout.currentFocusedView = artists.GetView() layout.rebuildSmallView() + return nil } else if event.Rune() == '2' { // Focus Albums @@ -200,6 +217,7 @@ func NewLayout(client *client.Client, playbackCtl *playback.Controller, refreshU albums.view.Focus(nil) layout.currentFocusedView = albums.GetView() layout.rebuildSmallView() + return nil } else if event.Rune() == '3' { // Focus Playlists @@ -211,6 +229,7 @@ func NewLayout(client *client.Client, playbackCtl *playback.Controller, refreshU playlists.view.Focus(nil) layout.currentFocusedView = playlists.GetView() layout.rebuildSmallView() + return nil } else if event.Rune() == '`' { // Focus Songs @@ -222,6 +241,7 @@ func NewLayout(client *client.Client, playbackCtl *playback.Controller, refreshU main.view.Focus(nil) layout.currentFocusedView = main.GetView() layout.rebuildSmallView() + return nil } else if event.Rune() == '4' { // Focus Queue @@ -233,19 +253,22 @@ func NewLayout(client *client.Client, playbackCtl *playback.Controller, refreshU queue.view.Focus(nil) layout.currentFocusedView = queue.GetView() layout.rebuildSmallView() + return nil } else if event.Rune() == 'n' { playbackCtl.Next() queue.Update(playbackCtl.GetQueue(), playbackCtl.GetQueuePosition()) - return nil + return nil } else if event.Rune() == 'N' { playbackCtl.Prev() queue.Update(playbackCtl.GetQueue(), playbackCtl.GetQueuePosition()) + return nil } else if event.Rune() == 's' { playbackCtl.Stop() queue.Update(playbackCtl.GetQueue(), playbackCtl.GetQueuePosition()) + return nil } else if event.Rune() == 'p' { if playbackCtl.State() == playback.PlaybackStateStopped { @@ -253,89 +276,95 @@ func NewLayout(client *client.Client, playbackCtl *playback.Controller, refreshU if song != nil { playbackCtl.Play(song) } + queue.Update(playbackCtl.GetQueue(), playbackCtl.GetQueuePosition()) + return nil } + playbackCtl.TogglePlayPause() + return nil } else if event.Rune() == 'c' { playbackCtl.ClearQueue() queue.Update(playbackCtl.GetQueue(), playbackCtl.GetQueuePosition()) + return nil } else if event.Rune() == '/' { layout.currentFocusedView.Blur() statusLine.Search() refreshUI() + return nil } else if event.Rune() == 'L' { - if layout.currentFocusedView == albums.GetView() { - layout.currentFocusedView.Blur() - main.view.Focus(nil) - layout.currentFocusedView = main.GetView() - layout.rebuildSmallView() - } else if layout.currentFocusedView == artists.GetView() { - layout.currentFocusedView.Blur() - main.view.Focus(nil) - layout.currentFocusedView = main.GetView() - layout.rebuildSmallView() - } else if layout.currentFocusedView == playlists.GetView() { - layout.currentFocusedView.Blur() - main.view.Focus(nil) - layout.currentFocusedView = main.GetView() - layout.rebuildSmallView() - } else if layout.currentFocusedView == main.GetView() { - layout.currentFocusedView.Blur() - queue.view.Focus(nil) - layout.currentFocusedView = queue.GetView() - layout.rebuildSmallView() - } + if layout.currentFocusedView == albums.GetView() { + layout.currentFocusedView.Blur() + main.view.Focus(nil) + layout.currentFocusedView = main.GetView() + layout.rebuildSmallView() + } else if layout.currentFocusedView == artists.GetView() { + layout.currentFocusedView.Blur() + main.view.Focus(nil) + layout.currentFocusedView = main.GetView() + layout.rebuildSmallView() + } else if layout.currentFocusedView == playlists.GetView() { + layout.currentFocusedView.Blur() + main.view.Focus(nil) + layout.currentFocusedView = main.GetView() + layout.rebuildSmallView() + } else if layout.currentFocusedView == main.GetView() { + layout.currentFocusedView.Blur() + queue.view.Focus(nil) + layout.currentFocusedView = queue.GetView() + layout.rebuildSmallView() + } } else if event.Rune() == 'H' { - if layout.currentFocusedView == queue.GetView() { - layout.currentFocusedView.Blur() - main.view.Focus(nil) - layout.currentFocusedView = main.GetView() - layout.rebuildSmallView() - } else if layout.currentFocusedView == main.GetView() { - layout.currentFocusedView.Blur() - artists.view.Focus(nil) - layout.currentFocusedView = artists.GetView() - layout.rebuildSmallView() - } + if layout.currentFocusedView == queue.GetView() { + layout.currentFocusedView.Blur() + main.view.Focus(nil) + layout.currentFocusedView = main.GetView() + layout.rebuildSmallView() + } else if layout.currentFocusedView == main.GetView() { + layout.currentFocusedView.Blur() + artists.view.Focus(nil) + layout.currentFocusedView = artists.GetView() + layout.rebuildSmallView() + } } else if event.Rune() == 'J' { - if layout.currentFocusedView == albums.GetView() { - layout.currentFocusedView.Blur() - playlists.view.Focus(nil) - layout.currentFocusedView = playlists.GetView() - layout.rebuildSmallView() - } else if layout.currentFocusedView == artists.GetView() { - layout.currentFocusedView.Blur() - albums.view.Focus(nil) - layout.currentFocusedView = albums.GetView() - layout.rebuildSmallView() - } else if layout.currentFocusedView == playlists.GetView() { - layout.currentFocusedView.Blur() - artists.view.Focus(nil) - layout.currentFocusedView = artists.GetView() - layout.rebuildSmallView() - } + if layout.currentFocusedView == albums.GetView() { + layout.currentFocusedView.Blur() + playlists.view.Focus(nil) + layout.currentFocusedView = playlists.GetView() + layout.rebuildSmallView() + } else if layout.currentFocusedView == artists.GetView() { + layout.currentFocusedView.Blur() + albums.view.Focus(nil) + layout.currentFocusedView = albums.GetView() + layout.rebuildSmallView() + } else if layout.currentFocusedView == playlists.GetView() { + layout.currentFocusedView.Blur() + artists.view.Focus(nil) + layout.currentFocusedView = artists.GetView() + layout.rebuildSmallView() + } } else if event.Rune() == 'K' { - if layout.currentFocusedView == albums.GetView() { - layout.currentFocusedView.Blur() - artists.view.Focus(nil) - layout.currentFocusedView = artists.GetView() - layout.rebuildSmallView() - } else if layout.currentFocusedView == artists.GetView() { - layout.currentFocusedView.Blur() - playlists.view.Focus(nil) - layout.currentFocusedView = playlists.GetView() - layout.rebuildSmallView() - } else if layout.currentFocusedView == playlists.GetView() { - layout.currentFocusedView.Blur() - albums.view.Focus(nil) - layout.currentFocusedView = albums.GetView() - layout.rebuildSmallView() - } - } + if layout.currentFocusedView == albums.GetView() { + layout.currentFocusedView.Blur() + artists.view.Focus(nil) + layout.currentFocusedView = artists.GetView() + layout.rebuildSmallView() + } else if layout.currentFocusedView == artists.GetView() { + layout.currentFocusedView.Blur() + playlists.view.Focus(nil) + layout.currentFocusedView = playlists.GetView() + layout.rebuildSmallView() + } else if layout.currentFocusedView == playlists.GetView() { + layout.currentFocusedView.Blur() + albums.view.Focus(nil) + layout.currentFocusedView = albums.GetView() + layout.rebuildSmallView() + } + } return event }) @@ -356,12 +385,14 @@ func NewLayout(client *client.Client, playbackCtl *playback.Controller, refreshU layout.view = pages layout.player = player - //Auto focus on artists + // Auto focus on artists artists.GetView().Focus(nil) layout.currentFocusedView = artists.GetView() layout.rebuildSmallView() + return layout } + func (l *layout) rebuildSmallView() { l.smallView.Clear() l.smallView.AddItem(l.currentFocusedView, 0, 1, false) @@ -381,18 +412,22 @@ func (l *layout) Update() { _, _, w, h := l.view.GetRect() page, _ := l.view.GetFrontPage() smallView := w < 100 || h < 30 + if smallView { if page != "small" { l.mainView.SetMiniView(true) go l.view.SwitchToPage("small") + return } } else { if page != "large" { l.mainView.SetMiniView(false) go l.view.SwitchToPage("large") + return } } + l.player.Update() } diff --git a/internal/tui/views/login.go b/internal/tui/views/login.go index 85d70a2..c512f5c 100644 --- a/internal/tui/views/login.go +++ b/internal/tui/views/login.go @@ -65,9 +65,9 @@ func NewLoginView(loginFunc func(u, p, url string), exitFunc func()) View { l.GetView().Focus(func(p tview.Primitive) {}) form.SetFocus(0) + return l } func (l *login) Update() { - } diff --git a/internal/tui/views/main.go b/internal/tui/views/main.go index a6bb8a2..813b62c 100644 --- a/internal/tui/views/main.go +++ b/internal/tui/views/main.go @@ -54,13 +54,15 @@ func NewMainView(client *client.Client, log func(format string, a ...any)) *main flex.SetBorder(true) flex.SetFocusFunc(func() { flex.SetBorderColor(config.ColorSelectedBoarder) + if playlistAlbum.songList != nil { - //playlistAlbum.view.Blur() + // playlistAlbum.view.Blur() playlistAlbum.songList.Focus(nil) } }) flex.SetBlurFunc(func() { flex.SetBorderColor(config.ColorBluredBoarder) + if playlistAlbum.songList != nil { playlistAlbum.songList.Blur() } @@ -69,6 +71,7 @@ func NewMainView(client *client.Client, log func(format string, a ...any)) *main playlistAlbum.view = flex // Empty Box for starters... playlistAlbum.view.AddItem(EmptyBox, 0, 1, false) + return playlistAlbum } @@ -112,34 +115,43 @@ func (m *main) SetSearch(result *subsonic.SearchResult3, query string) { } 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()) + 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()) + 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) + 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 := fmt.Sprintf("Query: %s", m.query) + 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() @@ -150,12 +162,14 @@ func (m *main) Update() { case mainModeSearch: m.drawSearch() } + m.songList.Focus(nil) } func (m *main) drawArtistButtons() *tview.Button { // Add buttons: Radio - songs, _ := m.client.GetTopSongs(m.artist.Name, 10) + topSongs := 10 + songs, _ := m.client.GetTopSongs(m.artist.Name, topSongs) f := tview.NewFlex() f.SetBackgroundColor(config.ColorBackground) f.SetBorderPadding(0, 0, 2, 2) @@ -172,11 +186,13 @@ func (m *main) drawArtistButtons() *tview.Button { 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()) } @@ -191,33 +207,41 @@ func (m *main) drawArtistButtons() *tview.Button { case tcell.KeyDown: radio.Blur() m.songList.Focus(nil) + return nil case tcell.KeyRight: radio.Blur() top10.Focus(nil) + return nil + default: + return event } - 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 } - 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 @@ -232,8 +256,9 @@ 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(fmt.Sprintf("%d", album.Year)).SetTextColor(config.ColorTextAccent) + 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) @@ -262,14 +287,16 @@ func (m *main) populateAlbums(btn *tview.Button) { 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 { @@ -293,20 +320,25 @@ func (m *main) drawPlaylistAlbumButtons(songs []*subsonic.Child) *tview.Button { 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 } - return event }) shuffle.SetSelectedFunc(func() { shuffle.Blur() + cpy := make([]*subsonic.Child, len(songs)) copy(cpy, songs) common.ShuffleSlice(cpy) @@ -318,17 +350,21 @@ func (m *main) drawPlaylistAlbumButtons(songs []*subsonic.Child) *tview.Button { 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 } - return event }) queue.SetSelectedFunc(func() { queue.Blur() @@ -340,20 +376,25 @@ func (m *main) drawPlaylistAlbumButtons(songs []*subsonic.Child) *tview.Button { 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 } - return event }) artist.SetSelectedFunc(func() { artist.Blur() + ar, _ := m.client.GetArtist(m.album.ArtistID) m.SetArtist(ar) m.songList.Focus(nil) @@ -363,16 +404,20 @@ func (m *main) drawPlaylistAlbumButtons(songs []*subsonic.Child) *tview.Button { 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 }) @@ -396,12 +441,14 @@ 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(fmt.Sprintf("%d", i+1)).SetTextColor(config.ColorTextAccent) + 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) @@ -416,15 +463,18 @@ func (m *main) populateSongs(songs []*subsonic.Child, play *tview.Button) { 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() { @@ -446,6 +496,7 @@ func (m *main) populateSearchResults() { table := tview.NewTable() table.SetBackgroundColor(config.ColorBackground) table.SetWrapSelection(true, false) + row := 0 lastArtist := 0 lastAlbum := 0 @@ -455,19 +506,23 @@ func (m *main) populateSearchResults() { // Header header := tview.NewTableCell("Artists").SetSelectable(false) table.SetCell(row, 0, header) + row++ - //List + // List for i, artist := range m.searchResult.Artist { - index := tview.NewTableCell(fmt.Sprintf("%d", i+1)). + index := tview.NewTableCell(strconv.Itoa(i + 1)). SetTextColor(config.ColorTextAccent) a := tview.NewTableCell(artist.Name).SetExpansion(2).SetMaxWidth(15) - acount := tview.NewTableCell(fmt.Sprintf("%d Albums", artist.AlbumCount)). + 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, acount) + table.SetCell(row, 2, count) + row++ } + lastArtist = row } @@ -477,19 +532,23 @@ func (m *main) populateSearchResults() { // Header header := tview.NewTableCell("Albums").SetSelectable(false) table.SetCell(row, 0, header) + row++ - //List + // List for i, album := range m.searchResult.Album { - index := tview.NewTableCell(fmt.Sprintf("%d", i+1)). + 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 @@ -497,17 +556,20 @@ func (m *main) populateSearchResults() { // Header header := tview.NewTableCell("Songs").SetSelectable(false) table.SetCell(row, 0, header) + row++ - //List + // List for i, song := range m.searchResult.Song { - index := tview.NewTableCell(fmt.Sprintf("%d", i+1)). + 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++ } } @@ -526,28 +588,36 @@ func (m *main) populateSearchResults() { 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) @@ -563,15 +633,18 @@ func (m *main) populateSearchResults() { } func (m *main) populateHeader(title, subtitle string, - duration int, coverArtID 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). @@ -591,10 +664,12 @@ func (m *main) populateHeader(title, subtitle string, 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 diff --git a/internal/tui/views/player.go b/internal/tui/views/player.go index 45fb8d0..76f901a 100644 --- a/internal/tui/views/player.go +++ b/internal/tui/views/player.go @@ -6,6 +6,7 @@ import ( "git.dayanhub.com/sagi/subsonic-tui/internal/client" "git.dayanhub.com/sagi/subsonic-tui/internal/config" + "git.dayanhub.com/sagi/subsonic-tui/internal/variables" "github.com/delucks/go-subsonic" "github.com/rivo/tview" ) @@ -26,7 +27,7 @@ func NewPlayer(client *client.Client) *player { grid := tview.NewGrid().SetColumns(9, 0) grid.SetBackgroundColor(config.ColorBackground) - //album art + // album art art := tview.NewImage() art.SetBackgroundColor(config.ColorBackground) @@ -57,7 +58,7 @@ func NewPlayer(client *client.Client) *player { player.SetSongInfo(&subsonic.Child{ Title: "Subsonic TUI", - Album: "MaVeZe", + Album: "Made with love ❤️", Artist: "ZeGoomba", CoverArt: "", Duration: 0, @@ -81,13 +82,14 @@ func (p *player) LoadAlbumArt(ID string) { } func (p *player) UpdateProgress(elapsed time.Duration) { - if p.song.Duration == 0 { // Startup... Show version number - versionInfo := tview.NewTextView().SetText("Version: 0.1") + versionInfo := tview.NewTextView().SetText(fmt.Sprintf("Version: %s | Commit: %s", + variables.Version, variables.Commit)) versionInfo.SetBackgroundColor(config.ColorPlaybackProgressRemaining) versionInfo.SetTextColor(config.ColorPlaybackProgressElapsed) p.progress.AddItem(versionInfo, 0, 1, false) + return } @@ -96,12 +98,15 @@ func (p *player) UpdateProgress(elapsed time.Duration) { overlappedBox.SetBackgroundColor(config.ColorPlaybackProgressElapsed) overlappedBox.SetTextColor(config.ColorPlaybackProgressRemaining) overlappedBox.SetText(songDuration.String()) + remainingBox := tview.NewTextView() remainingBox.SetBackgroundColor(config.ColorPlaybackProgressRemaining) remainingBox.SetTextColor(config.ColorPlaybackProgressElapsed) remainingBox.SetTextAlign(tview.AlignRight) + rm := time.Duration(songDuration.Seconds()-elapsed.Seconds()) * time.Second - remaining := fmt.Sprintf("-%s", rm.String()) + remaining := "-" + rm.String() + remainingBox.SetText(remaining) p.progress.Clear() p.progress.AddItem(overlappedBox, 0, int(elapsed.Seconds()), false) @@ -113,5 +118,5 @@ func (p *player) GetView() tview.Primitive { } func (p *player) Update() { - //p.UpdateProgress("00:00", 50) + // p.UpdateProgress("00:00", 50) } diff --git a/internal/tui/views/playlists.go b/internal/tui/views/playlists.go index 674720a..17e50be 100644 --- a/internal/tui/views/playlists.go +++ b/internal/tui/views/playlists.go @@ -16,7 +16,6 @@ type playlists struct { } func NewPlaylists(client *client.Client) *playlists { - obj := &playlists{ client: client, } @@ -55,7 +54,6 @@ func (p *playlists) SetCallback(f func(playlist *subsonic.Playlist)) { } func (p *playlists) Update() { - } func (p *playlists) GetView() tview.Primitive { diff --git a/internal/tui/views/queue.go b/internal/tui/views/queue.go index 8ef7bb7..edf5373 100644 --- a/internal/tui/views/queue.go +++ b/internal/tui/views/queue.go @@ -1,7 +1,7 @@ package views import ( - "fmt" + "strconv" "time" "git.dayanhub.com/sagi/subsonic-tui/internal/config" @@ -43,20 +43,24 @@ func (q *queue) drawQueue() { list := q.view list.SetWrapSelection(true, false) list.SetSelectable(true, false) + for i, song := range q.songQueue { isCurrentSong := q.currentSong == i isPlayed := i < q.currentSong bgColor := config.ColorBackground + if isCurrentSong { bgColor = config.ColorQueuePlayingBg } else if isPlayed { bgColor = config.ColorQueuePlayedBg } - num := tview.NewTableCell(fmt.Sprintf("%d", i+1)).SetTextColor(config.ColorTextAccent).SetBackgroundColor(bgColor) + + num := tview.NewTableCell(strconv.Itoa(i + 1)).SetTextColor(config.ColorTextAccent).SetBackgroundColor(bgColor) title := tview.NewTableCell(song.Title).SetMaxWidth(15).SetExpansion(2).SetBackgroundColor(bgColor) artist := tview.NewTableCell(song.Artist).SetMaxWidth(15).SetExpansion(1).SetBackgroundColor(bgColor) d := time.Second * time.Duration(song.Duration) duration := tview.NewTableCell(d.String()).SetAlign(tview.AlignRight).SetExpansion(1).SetBackgroundColor(bgColor) + list.SetCell(i, 0, num) list.SetCell(i, 1, title) list.SetCell(i, 2, artist) @@ -73,7 +77,6 @@ func (q *queue) Update(songs []*subsonic.Child, currentSong int) { q.songQueue = songs q.currentSong = currentSong q.drawQueue() - } func (q *queue) GetView() tview.Primitive { diff --git a/internal/tui/views/status_line.go b/internal/tui/views/status_line.go index 4de74b6..4009826 100644 --- a/internal/tui/views/status_line.go +++ b/internal/tui/views/status_line.go @@ -47,6 +47,7 @@ func (s *statusLine) Mode() Statusmode { func (s *statusLine) Search() { s.mode = StatusModeSearch s.view.Clear() + label := "Search: " _, _, w, _ := s.view.GetRect() query := "" @@ -81,9 +82,13 @@ func (s *statusLine) Log(format string, a ...any) { if s.mode != StatusModeLog { return } + str := fmt.Sprintf(format, a...) + s.view.Clear() + txt := tview.NewTextView().SetDynamicColors(true) + txt.SetBackgroundColor(config.ColorBackground) txt.SetText(str) s.view.AddItem(txt, 0, 1, false) @@ -93,6 +98,7 @@ func (s *statusLine) Log(format string, a ...any) { func (s *statusLine) GetView() tview.Primitive { return s.view } + func (s *statusLine) Update() { s.onUpdateFunc() } diff --git a/internal/variables/variables.go b/internal/variables/variables.go new file mode 100644 index 0000000..399b78f --- /dev/null +++ b/internal/variables/variables.go @@ -0,0 +1,6 @@ +package variables + +var ( + Commit = "HEAD" + Version = "development" +) diff --git a/main.go b/main.go index 3c8c8b6..2a86147 100644 --- a/main.go +++ b/main.go @@ -13,18 +13,22 @@ func main() { defer client.ArtCache.Destroy() // Create Client subsonicClient := client.NewClient(config.URL()) + err := subsonicClient.Authenticate(config.Username(), config.Password()) if err != nil { // We need to show Login... login := tui.NewLogin() + err := login.Run() if err != nil { panic(err) } - } + fmt.Println("Trying to login...") + subsonicClient = client.NewClient(config.URL()) + err = subsonicClient.Authenticate(config.Username(), config.Password()) if err != nil { panic(err)