├── ui ├── columns.go ├── icons.go ├── logs.go ├── tracks_test.go ├── score_test.go ├── input.go ├── score.go ├── help.go ├── ui.go └── tracks.go ├── Makefile ├── screenshot.png ├── .gitignore ├── scripts ├── build-linux.sh ├── build-darwin.sh └── kill.sh ├── library ├── video.go ├── library.go ├── track.go ├── mock.go ├── audio_test.go ├── audio_shelf.go └── local_audio.go ├── docker-compose.yaml ├── Dockerfile ├── .goreleaser-linux.yml ├── .github └── workflows │ └── release.yml ├── player ├── audio.go ├── mock.go └── beep.go ├── .goreleaser.yml ├── BACKLOG.md ├── LICENSE ├── go.mod ├── main.go ├── internal └── config │ └── config.go ├── README.md └── go.sum /ui/columns.go: -------------------------------------------------------------------------------- 1 | package ui 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | gorun: 2 | go run main.go 3 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhulihan/grump/HEAD/screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /app.log 2 | /main.log 3 | /sample*/ 4 | dist/ 5 | /grump 6 | /grump.log 7 | *.prof 8 | -------------------------------------------------------------------------------- /scripts/build-linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | goreleaser --skip-publish --rm-dist -f .goreleaser-linux.yml 4 | -------------------------------------------------------------------------------- /scripts/build-darwin.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | goreleaser --snapshot --skip-publish --rm-dist -f .goreleaser.yml 4 | -------------------------------------------------------------------------------- /ui/icons.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | const ( 4 | trackIconEmptyText = " " 5 | trackIconPlayingText = "🔈" 6 | trackIconPausedText = "🔇" 7 | 8 | shuffleIconOff = " " 9 | shuffleIconOn = "🔀" 10 | ) 11 | -------------------------------------------------------------------------------- /library/video.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | // Video represents video media from any source 4 | type Video struct { 5 | Title string 6 | Description string 7 | Author string 8 | Path string 9 | } 10 | -------------------------------------------------------------------------------- /scripts/kill.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | OUTPUT="`ps aux | grep "go-build"`" 4 | 5 | # override IFS 6 | IFS=' 7 | ' 8 | for LINE in $OUTPUT; do 9 | KPID=$(echo $LINE | awk '{print $2}') 10 | echo "killing ${KPID}" 11 | kill -9 $KPID 12 | done 13 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | build-linux: 4 | image: gobuilder 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | working_dir: /data 9 | volumes: 10 | - ".:/data" 11 | entrypoint: 12 | - ./scripts/build-linux.sh 13 | -------------------------------------------------------------------------------- /library/library.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | // Library handles metadata about your media library 4 | type Library struct { 5 | AudioShelves []AudioShelf 6 | } 7 | 8 | // NewLibrary creates a new library 9 | func NewLibrary(m []AudioShelf) (*Library, error) { 10 | return &Library{ 11 | AudioShelves: m, 12 | }, nil 13 | } 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.14-stretch 2 | 3 | RUN apt-get update && apt-get install libasound2-dev build-essential -y -q 4 | 5 | #RUN go get -u -v github.com/goreleaser/goreleaser 6 | RUN curl -sfL https://install.goreleaser.com/github.com/goreleaser/goreleaser.sh | sh 7 | 8 | WORKDIR /data 9 | 10 | ENTRYPOINT ['./scripts/build.sh'] 11 | -------------------------------------------------------------------------------- /library/track.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | // Track represents audio media from any source 4 | type Track struct { 5 | Album string 6 | AlbumArtist string 7 | Artist string 8 | Comment string 9 | Composer string 10 | DiscNumber int 11 | DiscTotal int 12 | FileType string 13 | Genre string 14 | 15 | // Length is length of track in millis 16 | Length int 17 | Lyrics string 18 | MimeType string 19 | Path string 20 | PlayCount uint64 21 | Rating uint8 22 | RatingEmail string 23 | Title string 24 | TrackNumber int 25 | TrackTotal int 26 | Year int 27 | } 28 | 29 | func (t Track) String() string { 30 | return t.Path 31 | } 32 | -------------------------------------------------------------------------------- /.goreleaser-linux.yml: -------------------------------------------------------------------------------- 1 | # This is an example goreleaser.yaml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | before: 4 | hooks: 5 | # You may remove this if you don't use go modules. 6 | - go mod download 7 | # you may remove this if you don't need go generate 8 | - go generate ./... 9 | builds: 10 | - env: 11 | - CGO_ENABLED=1 12 | goos: 13 | - linux 14 | goarch: 15 | - amd64 16 | ignore: 17 | - goos: freebsd 18 | - goos: darwin 19 | checksum: 20 | name_template: 'checksums.txt' 21 | snapshot: 22 | name_template: "{{ .Tag }}-next" 23 | changelog: 24 | sort: asc 25 | filters: 26 | exclude: 27 | - '^docs:' 28 | - '^test:' 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | pull_request: 5 | push: 6 | tags: 7 | - '*' 8 | 9 | jobs: 10 | goreleaser: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - 14 | name: Checkout 15 | uses: actions/checkout@v2 16 | - 17 | name: Unshallow 18 | run: git fetch --prune --unshallow 19 | - 20 | name: Set up Go 21 | uses: actions/setup-go@v1 22 | with: 23 | go-version: 1.14.x 24 | - 25 | name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v1 27 | with: 28 | version: latest 29 | args: release --rm-dist 30 | key: ${{ secrets.YOUR_PRIVATE_KEY }} 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /library/mock.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import "context" 4 | 5 | type MockAudioLibrary struct { 6 | tracks []Track 7 | } 8 | 9 | func NewMockAudioLibrary(tracks []Track) AudioShelf { 10 | l := MockAudioLibrary{ 11 | tracks: tracks, 12 | } 13 | 14 | return &l 15 | } 16 | 17 | // Tracks -- 18 | func (l *MockAudioLibrary) Tracks() []Track { 19 | return l.tracks 20 | } 21 | 22 | func (l *MockAudioLibrary) LoadTracks() (uint64, error) { 23 | return uint64(len(l.tracks)), nil 24 | } 25 | 26 | func (l *MockAudioLibrary) LoadTrack(ctx context.Context, location string) (*Track, error) { 27 | return nil, nil 28 | } 29 | 30 | func (l *MockAudioLibrary) SaveTrack(ctx context.Context, prev, track *Track) (*Track, error) { 31 | return nil, nil 32 | } 33 | 34 | func (l *MockAudioLibrary) DeleteTrack(ctx context.Context, track *Track) error { 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /player/audio.go: -------------------------------------------------------------------------------- 1 | package player 2 | 3 | import ( 4 | "github.com/dhulihan/grump/library" 5 | ) 6 | 7 | const ( 8 | // SeekSecs is the amount of seconds to skip forward or backward 9 | SeekSecs = 5 10 | ) 11 | 12 | // AudioPlayer is an interface for playing audio tracks. 13 | type AudioPlayer interface { 14 | Play(library.Track, bool) (AudioController, error) 15 | } 16 | 17 | // AudioController will control playing audio 18 | type AudioController interface { 19 | Paused() bool 20 | PauseToggle() bool 21 | PlayState() (PlayState, error) 22 | SeekForward() error 23 | SeekBackward() error 24 | SpeedUp() 25 | SpeedDown() 26 | Stop() 27 | VolumeUp() 28 | VolumeDown() 29 | } 30 | 31 | // PlayState represents the current state of playing audio. 32 | type PlayState struct { 33 | Finished bool 34 | Progress float32 35 | Position string 36 | Volume string 37 | Speed string 38 | } 39 | -------------------------------------------------------------------------------- /library/audio_test.go: -------------------------------------------------------------------------------- 1 | package library_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/dhulihan/grump/library" 7 | ) 8 | 9 | func TestShouldInclude(t *testing.T) { 10 | s, _ := library.NewLocalAudioShelf("empty") 11 | 12 | var tests = []struct { 13 | path string 14 | expected bool 15 | }{ 16 | {".", false}, 17 | {"..", false}, 18 | {"my-dir/", false}, 19 | {"my-dir/99-11-tame_impala-glimmer.mp3", true}, 20 | {"my-dir/99-11-tame_impala-glimmer.MP3", true}, 21 | {"my-dir/99-11-tame_impala-glimmer.flac", true}, 22 | {"my-dir/99-11-tame_impala-glimmer.wav", true}, 23 | {"my-dir/foo.zip", false}, 24 | {"my-dir/foo.pdf", false}, 25 | {"my-dir/Cover.jpg", false}, 26 | } 27 | 28 | for _, test := range tests { 29 | ret := s.ShouldInclude(test.path) 30 | if ret != test.expected { 31 | t.Errorf("for [%s] wanted [%t], got [%t]", test.path, test.expected, ret) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example goreleaser.yaml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | before: 4 | hooks: 5 | # You may remove this if you don't use go modules. 6 | - go mod download 7 | # you may remove this if you don't need go generate 8 | - go generate ./... 9 | brews: 10 | - name: grump 11 | tap: 12 | owner: dhulihan 13 | name: homebrew-grump 14 | download_strategy: CurlDownloadStrategy 15 | folder: formula 16 | homepage: "https://github.com/dhulihan/grump" 17 | builds: 18 | - env: 19 | - CGO_ENABLED=1 20 | goos: 21 | - darwin 22 | goarch: 23 | - amd64 24 | ignore: 25 | - goos: freebsd 26 | - goos: linux 27 | checksum: 28 | name_template: 'checksums.txt' 29 | snapshot: 30 | name_template: "{{ .Tag }}-next" 31 | changelog: 32 | sort: asc 33 | filters: 34 | exclude: 35 | - '^docs:' 36 | - '^test:' 37 | -------------------------------------------------------------------------------- /BACKLOG.md: -------------------------------------------------------------------------------- 1 | ## BACKLOG (move this to a better place later) 2 | 3 | * add search support [HIGH] 4 | * sort by title/name/track [HIGH] 5 | * add ID3 tag editing support [HIGH] 6 | * add cli flags for loglevel, etc. [HIGH] 7 | * add envvars/rcfile for specifying CLI flags [HIGH] 8 | * Shuffle mode [HIGH] 9 | * Maintain same speed/vol between tracks [MED] 10 | * "flash" vol/progress/speed/etc. [MED] 11 | * add support for event hooks [MED] 12 | * CLI subcommands [MED] - what do we need this for? 13 | * add support for other media sources (remote, spotify, etc.) [MED] 14 | * customize columns [MED] 15 | * "Now Playing" playlist [LOW] 16 | * Accept file path CLI argument and start playing immediately [LOW] 17 | * Tech Debt 18 | * Display full path to file in a prettier way [MED] 19 | * Use queuing/internal playlist to handling track stop/start/play next [LOW] 20 | * Bugs 21 | * Cannot seek forward/backward on FLAC files (needs to be implemented in beep) 22 | 23 | -------------------------------------------------------------------------------- /library/audio_shelf.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // AudioShelf is an abstract collection of audio. A shelf has one source type 8 | // (local, internet, spotify account, etc.). For example, a LocalAudioShelf 9 | // contains files stored in a local filesystem. 10 | type AudioShelf interface { 11 | Tracks() []Track 12 | 13 | // LoadTracks fills the shelf with tracks 14 | LoadTracks() (count uint64, err error) 15 | LoadTrack(ctx context.Context, location string) (*Track, error) 16 | SaveTrack(ctx context.Context, prev, track *Track) (*Track, error) 17 | DeleteTrack(ctx context.Context, track *Track) error 18 | } 19 | 20 | // TrackHandler is responsible for performing track type-specific operations 21 | // (eg: saving an MP3, loading a FLAC file, etc.). 22 | type TrackHandler interface { 23 | Load(ctx context.Context, location string) (*Track, error) 24 | Save(ctx context.Context, track *Track) (*Track, error) 25 | } 26 | -------------------------------------------------------------------------------- /ui/logs.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gdamore/tcell" 7 | "github.com/rivo/tview" 8 | ) 9 | 10 | type LogsPage struct { 11 | theme *tview.Theme 12 | } 13 | 14 | func NewLogsPage(ctx context.Context) *LogsPage { 15 | theme := defaultTheme() 16 | 17 | return &LogsPage{ 18 | theme: theme, 19 | } 20 | } 21 | 22 | // Page populates the layout for the help page 23 | func (p *LogsPage) Page(ctx context.Context) tview.Primitive { 24 | logs.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 25 | globalInputCapture(event) 26 | 27 | switch event.Key() { 28 | case tcell.KeyESC: 29 | pages.SwitchToPage("tracks") 30 | } 31 | 32 | return event 33 | }) 34 | 35 | bottom := tview.NewTextView().SetText("Press escape to go back.") 36 | 37 | main := tview.NewFlex().SetDirection(tview.FlexRow). 38 | //AddItem(p.middle, 0, 6, true). 39 | AddItem(logs, 0, 6, true). 40 | AddItem(bottom, 1, 0, false) 41 | 42 | // Create the layout. 43 | flex := tview.NewFlex(). 44 | AddItem(main, 0, 3, true) 45 | 46 | return flex 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Dave Hulihan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /player/mock.go: -------------------------------------------------------------------------------- 1 | package player 2 | 3 | import ( 4 | "github.com/dhulihan/grump/library" 5 | ) 6 | 7 | // MockAudioPlayer is an audio player implementation that uses beep 8 | type MockAudioPlayer struct{} 9 | 10 | // NewMockAudioPlayer -- 11 | func NewMockAudioPlayer() *MockAudioPlayer { 12 | bmp := MockAudioPlayer{} 13 | return &bmp 14 | } 15 | 16 | // Play a track and return a controller that lets you perform changes to a running track. 17 | func (bmp *MockAudioPlayer) Play(track library.Track, repeat bool) (*MockAudioController, error) { 18 | return &MockAudioController{}, nil 19 | } 20 | 21 | type MockAudioController struct{} 22 | 23 | func (p *MockAudioController) Paused() bool { return false } 24 | func (p *MockAudioController) PauseToggle() bool { return true } 25 | func (p *MockAudioController) PlayState() (PlayState, error) { return PlayState{}, nil } 26 | func (p *MockAudioController) SeekForward() error { return nil } 27 | func (p *MockAudioController) SeekBackward() error { return nil } 28 | func (p *MockAudioController) SpeedUp() {} 29 | func (p *MockAudioController) SpeedDown() {} 30 | func (p *MockAudioController) Stop() {} 31 | func (p *MockAudioController) VolumeUp() {} 32 | func (p *MockAudioController) VolumeDown() {} 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dhulihan/grump 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/bogem/id3v2 v1.2.0 7 | github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8 8 | github.com/faiface/beep v1.0.3-0.20200712202812-d836f29bdc50 9 | github.com/gdamore/tcell v1.3.0 10 | github.com/rivo/tview v0.0.0-20200404204604-ca37f83cb2e7 11 | github.com/sirupsen/logrus v1.5.0 12 | github.com/stretchr/testify v1.4.0 13 | gopkg.in/yaml.v2 v2.3.0 14 | ) 15 | 16 | require ( 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/gdamore/encoding v1.0.0 // indirect 19 | github.com/hajimehoshi/go-mp3 v0.3.0 // indirect 20 | github.com/hajimehoshi/oto v0.6.1 // indirect 21 | github.com/icza/bitio v1.0.0 // indirect 22 | github.com/jfreymuth/oggvorbis v1.0.1 // indirect 23 | github.com/jfreymuth/vorbis v1.0.0 // indirect 24 | github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect 25 | github.com/lucasb-eyer/go-colorful v1.0.3 // indirect 26 | github.com/mattn/go-runewidth v0.0.8 // indirect 27 | github.com/mewkiz/flac v1.0.6 // indirect 28 | github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2 // indirect 29 | github.com/pkg/errors v0.9.1 // indirect 30 | github.com/pmezard/go-difflib v1.0.0 // indirect 31 | github.com/rivo/uniseg v0.1.0 // indirect 32 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 // indirect 33 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 // indirect 34 | golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 // indirect 35 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect 36 | golang.org/x/text v0.3.3 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /ui/tracks_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/dhulihan/grump/library" 9 | "github.com/dhulihan/grump/player" 10 | "github.com/stretchr/testify/suite" 11 | ) 12 | 13 | type TrackPageSuite struct { 14 | suite.Suite 15 | page *TrackPage 16 | } 17 | 18 | func (s *TrackPageSuite) SetupSuite() { 19 | } 20 | 21 | func (s *TrackPageSuite) SetupTest() { 22 | ctx := context.Background() 23 | pl := player.NewMockAudioPlayer() 24 | 25 | shelf := library.NewMockAudioLibrary(s.mockTracks()) 26 | s.page = NewTrackPage(ctx, shelf, pl) 27 | } 28 | 29 | func (s *TrackPageSuite) mockTracks() []library.Track { 30 | // generate mocktracks 31 | tracks := make([]library.Track, 5) 32 | 33 | for i := 1; i <= 5; i++ { 34 | tracks[i-1] = library.Track{ 35 | Title: fmt.Sprintf("Mock Track %d", i), 36 | Path: fmt.Sprintf("mock-track-path-%d", i), 37 | } 38 | 39 | } 40 | return tracks 41 | } 42 | 43 | func (s *TrackPageSuite) TestDeleteTrack() { 44 | // play track 45 | //s.page.playTrack(&s.page.tracks[1]) 46 | s.page.cellChosen(2, 0) 47 | 48 | s.Equal(&s.page.tracks[1], s.page.currentlyPlayingTrack) 49 | s.Equal(2, s.page.currentlyPlayingRow) 50 | 51 | err := s.page.deleteTrack() 52 | if s.NoError(err) { 53 | s.Equal(&s.page.tracks[1], s.page.currentlyPlayingTrack) 54 | s.Equal(2, s.page.currentlyPlayingRow) 55 | } 56 | } 57 | 58 | // In order for 'go test' to run this suite, we need to create 59 | // a normal test function and pass our suite to suite.Run 60 | func TestTrackPageSuite(t *testing.T) { 61 | suite.Run(t, new(TrackPageSuite)) 62 | } 63 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/dhulihan/grump/internal/config" 9 | "github.com/dhulihan/grump/library" 10 | "github.com/dhulihan/grump/player" 11 | "github.com/dhulihan/grump/ui" 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | var ( 16 | version = "dev" 17 | commit = "none" 18 | date = "unknown" 19 | builtBy = "unknown" 20 | ) 21 | 22 | func main() { 23 | ctx := context.Background() 24 | 25 | c, err := config.Setup(ctx) 26 | if err != nil { 27 | logrus.WithError(err).Fatal("could not set up config") 28 | } 29 | 30 | if len(os.Args) < 2 { 31 | help() 32 | } 33 | 34 | path := os.Args[1] 35 | logrus.WithField("path", path).Info("starting up") 36 | 37 | audioShelf, err := library.NewLocalAudioShelf(path) 38 | if err != nil { 39 | logrus.WithError(err).Fatal("could not set up audio library") 40 | } 41 | 42 | count, err := audioShelf.LoadTracks() 43 | if err != nil { 44 | logrus.WithError(err).Fatal("could not load audio library") 45 | } 46 | logrus.WithField("count", count).Info("loaded library") 47 | 48 | audioShelves := []library.AudioShelf{audioShelf} 49 | db, err := library.NewLibrary(audioShelves) 50 | if err != nil { 51 | logrus.WithError(err).Fatal("could not set up player db") 52 | } 53 | 54 | player, err := player.NewBeepAudioPlayer() 55 | if err != nil { 56 | logrus.WithError(err).Fatal("could not set up audio player") 57 | } 58 | 59 | build := ui.BuildInfo{ 60 | Version: version, 61 | Commit: commit, 62 | } 63 | 64 | err = ui.Start(ctx, build, db, player, c.Loggers()) 65 | if err != nil { 66 | logrus.WithError(err).Fatal("ui exited with an error") 67 | } 68 | } 69 | 70 | func help() { 71 | cmd := os.Args[0] 72 | fmt.Printf("%s \n", cmd) 73 | os.Exit(2) 74 | } 75 | -------------------------------------------------------------------------------- /ui/score_test.go: -------------------------------------------------------------------------------- 1 | package ui_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/dhulihan/grump/ui" 7 | "github.com/gdamore/tcell" 8 | ) 9 | 10 | func TestScore(t *testing.T) { 11 | var tests = []struct { 12 | score string 13 | rating uint8 14 | err error 15 | }{ 16 | {ui.Score00, 0, nil}, 17 | {ui.Score05, 13, nil}, 18 | {ui.Score10, 1, nil}, 19 | {ui.Score15, 54, nil}, 20 | {ui.Score20, 64, nil}, 21 | {ui.Score25, 118, nil}, 22 | {ui.Score30, 128, nil}, 23 | {ui.Score35, 186, nil}, 24 | {ui.Score40, 196, nil}, 25 | {ui.Score45, 242, nil}, 26 | {ui.Score50, 255, nil}, 27 | } 28 | 29 | for _, test := range tests { 30 | score := ui.Score(test.rating) 31 | if score != test.score { 32 | t.Errorf("for %d, wanted %s, got %s", test.rating, test.score, score) 33 | } 34 | } 35 | } 36 | 37 | func TestScoreScolor(t *testing.T) { 38 | var tests = []struct { 39 | score string 40 | color tcell.Color 41 | }{ 42 | {"0.0", tcell.ColorGrey}, 43 | {"4.5", tcell.ColorGreen}, 44 | {"5.0", tcell.ColorAqua}, 45 | } 46 | 47 | for _, test := range tests { 48 | color := ui.ScoreColor(test.score) 49 | if color != test.color { 50 | t.Errorf("wanted %#v, got %#v", test.color, color) 51 | } 52 | } 53 | } 54 | 55 | func TestRating(t *testing.T) { 56 | var tests = []struct { 57 | score string 58 | rating uint8 59 | err error 60 | }{ 61 | {ui.Score00, 0, nil}, 62 | {ui.Score05, 13, nil}, 63 | {ui.Score10, 1, nil}, 64 | {ui.Score15, 54, nil}, 65 | {ui.Score20, 64, nil}, 66 | {ui.Score25, 118, nil}, 67 | {ui.Score30, 128, nil}, 68 | {ui.Score35, 186, nil}, 69 | {ui.Score40, 196, nil}, 70 | {ui.Score45, 242, nil}, 71 | {ui.Score50, 255, nil}, 72 | } 73 | 74 | for _, test := range tests { 75 | rating := ui.Rating(test.score) 76 | if rating != test.rating { 77 | t.Errorf("wanted %d, got %d", test.rating, rating) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /ui/input.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/gdamore/tcell" 5 | "github.com/rivo/tview" 6 | log "github.com/sirupsen/logrus" 7 | ) 8 | 9 | func modalWrapper(p tview.Primitive, width, height int) *tview.Flex { 10 | return tview.NewFlex(). 11 | AddItem(nil, 0, 1, false). 12 | AddItem(tview.NewFlex().SetDirection(tview.FlexRow). 13 | AddItem(nil, 0, 1, false). 14 | AddItem(p, height, 1, false). 15 | AddItem(nil, 0, 1, false), width, 1, false). 16 | AddItem(nil, 0, 1, false) 17 | } 18 | 19 | func newInputField(label string, text string, done func(key tcell.Key)) *tview.InputField { 20 | return tview.NewInputField(). 21 | SetFieldWidth(40). 22 | SetLabel(label). 23 | SetText(text). 24 | SetDoneFunc(done) 25 | } 26 | 27 | func newDropDown(label string, options []string, current int) *tview.DropDown { 28 | return tview.NewDropDown(). 29 | SetLabel(label). 30 | SetOptions(options, nil). 31 | SetCurrentOption(current) 32 | } 33 | 34 | // get the text of an input field belonging to a form 35 | func getFormInputText(f *tview.Form, label string) string { 36 | i := inputField(f, label) 37 | if i == nil { 38 | return "" 39 | } 40 | 41 | return i.GetText() 42 | } 43 | 44 | // input helpers 45 | func inputField(f *tview.Form, label string) *tview.InputField { 46 | fi := f.GetFormItemByLabel(label) 47 | if fi == nil { 48 | log.WithField("label", label).Error("could not find form item") 49 | return nil 50 | } 51 | 52 | var input *tview.InputField 53 | var ok bool 54 | if input, ok = fi.(*tview.InputField); !ok { 55 | log.WithField("label", label).Error("could not cast FormItem into InputField") 56 | return nil 57 | } 58 | 59 | return input 60 | } 61 | 62 | func (t *TrackPage) dropDown(label string) *tview.DropDown { 63 | fi := editForm.GetFormItemByLabel(label) 64 | 65 | if fi == nil { 66 | log.WithField("label", label).Error("could not find form item") 67 | } 68 | 69 | var input *tview.DropDown 70 | var ok bool 71 | if input, ok = fi.(*tview.DropDown); !ok { 72 | log.WithField("label", label).Error("could not cast FormItem into DropDown") 73 | return nil 74 | } 75 | 76 | return input 77 | } 78 | 79 | // get index of first matching string in slice 80 | func indexOf(s []string, x string) int { 81 | for i, y := range s { 82 | if x == y { 83 | return i 84 | } 85 | } 86 | 87 | return -1 88 | } 89 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "io/ioutil" 7 | "os" 8 | "os/user" 9 | "path/filepath" 10 | 11 | "github.com/sirupsen/logrus" 12 | log "github.com/sirupsen/logrus" 13 | "gopkg.in/yaml.v2" 14 | ) 15 | 16 | // Config is application configuration settings 17 | type Config struct { 18 | LogToFile bool `yaml:"log_to_file"` 19 | LogFile string `yaml:"log_file"` 20 | LogLevel string `yaml:"log_level"` 21 | Columns []string 22 | KeyboardShortcuts map[string]string 23 | 24 | loggers []io.Writer 25 | } 26 | 27 | // DefaultConfig is (you guessed it) default application config. 28 | func DefaultConfig() *Config { 29 | return &Config{ 30 | LogLevel: "warn", 31 | LogToFile: false, 32 | LogFile: "grump.log", 33 | Columns: []string{ 34 | "artist", 35 | "album", 36 | "title", 37 | "rating", 38 | }, 39 | } 40 | } 41 | 42 | // Setup setups up application configuration 43 | func Setup(ctx context.Context) (*Config, error) { 44 | c, err := loadConfig(ctx) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | // TODO: make this configurable 50 | if c.LogToFile && c.LogFile != "" { 51 | // Log as JSON instead of the default ASCII formatter. 52 | logrus.SetFormatter(&logrus.JSONFormatter{}) 53 | 54 | logfile := c.LogFile 55 | // TODO: close this 56 | f, err := os.OpenFile(logfile, os.O_WRONLY|os.O_CREATE, 0755) 57 | if err != nil { 58 | log.WithError(err).Fatal("could not open logfile") 59 | } 60 | 61 | err = os.Truncate(logfile, 0) 62 | if err != nil { 63 | log.WithError(err).Fatal("could not truncate file") 64 | } 65 | 66 | // keep this file logger around 67 | c.loggers = append(c.loggers, f) 68 | 69 | log.SetOutput(f) 70 | } 71 | 72 | if c.LogFile == "" { 73 | return c, nil 74 | } 75 | 76 | level, err := log.ParseLevel(c.LogLevel) 77 | if err != nil { 78 | log.WithError(err).Warn("could not set log level") 79 | } else { 80 | log.SetLevel(level) 81 | } 82 | 83 | return c, nil 84 | } 85 | 86 | // loadConfig looks for configuration and loads it 87 | func loadConfig(ctx context.Context) (*Config, error) { 88 | // look for logfile 89 | var c *Config 90 | 91 | c, err := homeConfig(ctx) 92 | if err != nil { 93 | // use default config if something went wrong 94 | c = DefaultConfig() 95 | } 96 | 97 | c.loggers = []io.Writer{} 98 | 99 | return c, nil 100 | } 101 | 102 | func homeConfig(ctx context.Context) (*Config, error) { 103 | c := &Config{} 104 | 105 | usr, err := user.Current() 106 | if err != nil { 107 | log.WithError(err).Warn("could not obtain current user") 108 | return nil, err 109 | } 110 | 111 | homeConfig := filepath.Join(usr.HomeDir, ".grump.yaml") 112 | b, err := ioutil.ReadFile(homeConfig) 113 | if err != nil { 114 | log.WithError(err).WithField("path", homeConfig).Debug("could not read config file") 115 | return nil, err 116 | } 117 | 118 | err = yaml.Unmarshal(b, c) 119 | if err != nil { 120 | log.WithError(err).WithField("path", homeConfig).Warn("could not unmarshal config yaml") 121 | return nil, err 122 | } 123 | 124 | return c, nil 125 | } 126 | 127 | // Loggers returns a slice of loggers to use in the application 128 | func (c *Config) Loggers() []io.Writer { 129 | return c.loggers 130 | } 131 | -------------------------------------------------------------------------------- /ui/score.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/gdamore/tcell" 5 | ) 6 | 7 | const ( 8 | ratingMin = 0.0 9 | ratingMax = 255 10 | scoreMin = 0.0 11 | scoreMax = 5.0 12 | 13 | Score00 = "🌑" 14 | Score05 = "🌗" 15 | Score10 = "🌕" 16 | Score15 = "🌕🌗" 17 | Score20 = "🌕🌕" 18 | Score25 = "🌕🌕🌗" 19 | Score30 = "🌕🌕🌕" 20 | Score35 = "🌕🌕🌕🌗" 21 | Score40 = "🌕🌕🌕🌕" 22 | Score45 = "🌕🌕🌕🌕🌗" 23 | Score50 = "🌕🌕🌕🌕🌕" 24 | 25 | // mapping of track uint8 ratings to human friendly scores 26 | Rating00 = 0 27 | Rating05 = 13 28 | Rating10 = 1 29 | Rating15 = 54 30 | Rating20 = 64 31 | Rating25 = 118 32 | Rating30 = 128 33 | Rating35 = 186 34 | Rating40 = 196 35 | Rating45 = 242 36 | Rating50 = 255 37 | ) 38 | 39 | var ( 40 | Scores = []string{ 41 | Score00, 42 | Score05, 43 | Score10, 44 | Score15, 45 | Score20, 46 | Score25, 47 | Score30, 48 | Score35, 49 | Score40, 50 | Score45, 51 | Score50, 52 | } 53 | ) 54 | 55 | // Score returns a human-friendly rating string and color. It clamsp 0-255 to a 56 | // 0-5 rating string (think 5 stars). 57 | func Score(rating uint8) string { 58 | switch { 59 | case rating == Rating00: 60 | return Score00 61 | case rating <= Rating10: 62 | return Score10 63 | // yes this is bizarre 64 | case rating <= Rating05: 65 | return Score05 66 | case rating <= Rating15: 67 | return Score15 68 | case rating <= Rating20: 69 | return Score20 70 | case rating <= Rating25: 71 | return Score25 72 | case rating <= Rating30: 73 | return Score30 74 | case rating <= Rating35: 75 | return Score35 76 | case rating <= Rating40: 77 | return Score40 78 | case rating <= Rating45: 79 | return Score45 80 | case rating <= ratingMax: 81 | return Score50 82 | default: 83 | return Score00 84 | } 85 | } 86 | 87 | // ScoreColor returns a color for the score 88 | func ScoreColor(score string) tcell.Color { 89 | switch score { 90 | case "0.0": 91 | return tcell.ColorGrey 92 | case "0.5": 93 | return tcell.ColorRed 94 | case "1.0": 95 | return tcell.ColorRed 96 | case "1.5": 97 | return tcell.ColorRed 98 | case "2.0": 99 | return tcell.ColorOrange 100 | case "2.5": 101 | return tcell.ColorOrange 102 | case "3.0": 103 | return tcell.ColorYellow 104 | case "3.5": 105 | return tcell.ColorYellow 106 | case "4.0": 107 | return tcell.ColorGreen 108 | case "4.5": 109 | return tcell.ColorGreen 110 | case "5.0": 111 | return tcell.ColorAqua 112 | default: 113 | return tcell.ColorWhite 114 | } 115 | } 116 | 117 | //// Rating converts a human-friendly score value to a rating 118 | //func Rating(score string) (uint8, error) { 119 | //s, err := strconv.ParseFloat(score, 32) 120 | //if err != nil { 121 | //return 0, err 122 | //} 123 | 124 | //percentMax := s / scoreMax 125 | //r := percentMax * ratingMax 126 | //return uint8(r), nil 127 | //} 128 | 129 | // Rating converts a human-friendly score value to a rating 130 | func Rating(score string) uint8 { 131 | switch score { 132 | case Score00: 133 | return Rating00 134 | case Score05: 135 | return Rating05 136 | case Score10: 137 | return Rating10 138 | case Score15: 139 | return Rating15 140 | case Score20: 141 | return Rating20 142 | case Score25: 143 | return Rating25 144 | case Score30: 145 | return Rating30 146 | case Score35: 147 | return Rating35 148 | case Score40: 149 | return Rating40 150 | case Score45: 151 | return Rating45 152 | case Score50: 153 | return Rating50 154 | default: 155 | return Rating00 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /ui/help.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gdamore/tcell" 7 | "github.com/rivo/tview" 8 | ) 9 | 10 | type HelpPage struct { 11 | middle *tview.Table 12 | bottom *tview.Box 13 | theme *tview.Theme 14 | kb []KeyboardShortcut 15 | } 16 | 17 | func NewHelpPage(ctx context.Context) *HelpPage { 18 | theme := defaultTheme() 19 | 20 | kb := []KeyboardShortcut{ 21 | KeyboardShortcut{"space", "pause/unpause"}, 22 | KeyboardShortcut{"escape", "stop track"}, 23 | KeyboardShortcut{"d", "describe currently playing track"}, 24 | KeyboardShortcut{"e", "edit currently playing track"}, 25 | KeyboardShortcut{"delete", "delete currently playing track (with prompt)"}, 26 | KeyboardShortcut{"l", "view logs page"}, 27 | KeyboardShortcut{"left", "seek forward (does not work on flac)"}, 28 | KeyboardShortcut{"right", "seek backward (does not work on flac)"}, 29 | KeyboardShortcut{"]", "play next track"}, 30 | KeyboardShortcut{"[", "play previous track"}, 31 | KeyboardShortcut{"=", "volume up"}, 32 | KeyboardShortcut{"-", "volume down"}, 33 | KeyboardShortcut{"S", "toggle shuffle"}, 34 | KeyboardShortcut{"+", "speed up"}, 35 | KeyboardShortcut{"_", "speed down"}, 36 | KeyboardShortcut{"q", "quit"}, 37 | KeyboardShortcut{"0", "set rating of currently playing track to " + Score00}, 38 | KeyboardShortcut{"shift+0", "set rating of currently playing track to " + Score05}, 39 | KeyboardShortcut{"1", "set rating of currently playing track to " + Score10}, 40 | KeyboardShortcut{"shift+1", "set rating of currently playing track to " + Score15}, 41 | KeyboardShortcut{"2", "set rating of currently playing track to " + Score20}, 42 | KeyboardShortcut{"shift+2", "set rating of currently playing track to " + Score25}, 43 | KeyboardShortcut{"3", "set rating of currently playing track to " + Score30}, 44 | KeyboardShortcut{"shift+3", "set rating of currently playing track to " + Score35}, 45 | KeyboardShortcut{"4", "set rating of currently playing track to " + Score40}, 46 | KeyboardShortcut{"shift+4", "set rating of currently playing track to " + Score45}, 47 | KeyboardShortcut{"5", "set rating of currently playing track to " + Score50}, 48 | } 49 | 50 | middle := tview.NewTable().SetBorders(true).SetBordersColor(theme.BorderColor) 51 | return &HelpPage{ 52 | kb: kb, 53 | middle: middle, 54 | theme: theme, 55 | } 56 | } 57 | 58 | // Page populates the layout for the help page 59 | func (p *HelpPage) Page(ctx context.Context) tview.Primitive { 60 | p.middle.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 61 | globalInputCapture(event) 62 | 63 | switch event.Key() { 64 | case tcell.KeyESC: 65 | pages.SwitchToPage("tracks") 66 | } 67 | 68 | return event 69 | }) 70 | 71 | p.setupKeyboardShortcuts() 72 | 73 | bottom := tview.NewTextView().SetText("Press escape to go back.") 74 | 75 | main := tview.NewFlex().SetDirection(tview.FlexRow). 76 | AddItem(p.middle, 0, 6, true). 77 | AddItem(bottom, 1, 0, false) 78 | 79 | // Create the layout. 80 | flex := tview.NewFlex(). 81 | AddItem(main, 0, 3, true) 82 | 83 | return flex 84 | } 85 | 86 | // KeyboardShortcut describes a page-specific keyboard shortcut 87 | type KeyboardShortcut struct { 88 | Key string 89 | Description string 90 | } 91 | 92 | func (p *HelpPage) keyboardShortcut(row, column int, key, description string) *tview.TableCell { 93 | return &tview.TableCell{Text: trackIconEmptyText, Color: p.theme.TitleColor, NotSelectable: true} 94 | } 95 | 96 | func (p *HelpPage) setupKeyboardShortcuts() { 97 | for i, kb := range p.kb { 98 | p.middle.SetCell(i, 0, tview.NewTableCell(kb.Key)). 99 | SetCell(i, 1, &tview.TableCell{Text: kb.Description, Color: p.theme.TertiaryTextColor}) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # grump 2 | 3 | ![](screenshot.png) 4 | 5 | ``` 6 | Great but 7 | Really 8 | Ugly 9 | Media 10 | Player 11 | ``` 12 | 13 | A very minimal CLI audio player. 14 | 15 | ## Features 16 | 17 | * cross-platform 18 | * ID3 tag scanning 19 | * Supports 20 | * FLAC 21 | * MP3 22 | * OGG/Vorbis 23 | * WAV 24 | * Tag Editor 25 | * Quick Ratings 26 | * Playback Effects (speed up/down) 27 | 28 | ## Install 29 | 30 | ### Linux 31 | 32 | ```sh 33 | sudo apt install libasound2-dev build-essential 34 | go get github.com/dhulihan/grump 35 | ``` 36 | 37 | ### Mac OSX 38 | 39 | ```sh 40 | brew tap dhulihan/grump 41 | brew install grump 42 | ``` 43 | 44 | Alternatively, you can install the latest (possibly unreleased) version: 45 | 46 | ```sh 47 | go get github.com/dhulihan/grump 48 | ``` 49 | 50 | * You can also download pre-build binaries on the [releases](https://github.com/dhulihan/grump/releases) page. 51 | 52 | ## Usage 53 | 54 | ``` 55 | grump path/to/some/audio/files 56 | ``` 57 | 58 | ## Keyboard Shortcuts 59 | 60 | ``` 61 | ┌───────┬───────────────────────────────────────────────────┐ 62 | │space │pause/unpause │ 63 | ├───────┼───────────────────────────────────────────────────┤ 64 | │escape │stop track │ 65 | ├───────┼───────────────────────────────────────────────────┤ 66 | │d │describe currently playing track │ 67 | ├───────┼───────────────────────────────────────────────────┤ 68 | │e │edit currently playing track │ 69 | ├───────┼───────────────────────────────────────────────────┤ 70 | │delete │delete currently playing track (with prompt) │ 71 | ├───────┼───────────────────────────────────────────────────┤ 72 | │l │view logs page │ 73 | ├───────┼───────────────────────────────────────────────────┤ 74 | │left │seek forward (does not work on flac) │ 75 | ├───────┼───────────────────────────────────────────────────┤ 76 | │right │seek backward (does not work on flac) │ 77 | ├───────┼───────────────────────────────────────────────────┤ 78 | │] │play next track │ 79 | ├───────┼───────────────────────────────────────────────────┤ 80 | │[ │play previous track │ 81 | ├───────┼───────────────────────────────────────────────────┤ 82 | │= │volume up │ 83 | ├───────┼───────────────────────────────────────────────────┤ 84 | │- │volume down │ 85 | ├───────┼───────────────────────────────────────────────────┤ 86 | │+ │speed up │ 87 | ├───────┼───────────────────────────────────────────────────┤ 88 | │_ │speed down │ 89 | ├───────┼───────────────────────────────────────────────────┤ 90 | │q │quit │ 91 | ├───────┼───────────────────────────────────────────────────┤ 92 | │0 │set rating of currently playing track to 🌑 │ 93 | ├───────┼───────────────────────────────────────────────────┤ 94 | │1 │set rating of currently playing track to 🌕 │ 95 | ├───────┼───────────────────────────────────────────────────┤ 96 | │2 │set rating of currently playing track to 🌕🌕 │ 97 | ├───────┼───────────────────────────────────────────────────┤ 98 | │3 │set rating of currently playing track to 🌕🌕🌕 │ 99 | ├───────┼───────────────────────────────────────────────────┤ 100 | │4 │set rating of currently playing track to 🌕🌕🌕🌕 │ 101 | ├───────┼───────────────────────────────────────────────────┤ 102 | │5 │set rating of currently playing track to 🌕🌕🌕🌕🌕│ 103 | ├───────┼───────────────────────────────────────────────────┤ 104 | ``` 105 | 106 | ## Configuration 107 | 108 | grump will load a `~/.grump.yaml` file if present. 109 | 110 | ```yaml 111 | # log level. options: trace, debug, info, warn, error 112 | log_level: info 113 | 114 | # if true, write application logs to a file 115 | log_to_file: false 116 | 117 | # write logs to this file, if enabled 118 | log_file: grump.log 119 | ``` 120 | 121 | ## Development 122 | 123 | ### Building 124 | 125 | ```sh 126 | # build for linux (linux host) 127 | ./scripts/build-linux.sh 128 | 129 | # build for linux (non-linux host) 130 | docker-compose run build-linux 131 | 132 | # build for darwin (darwin host) 133 | ./scripts/build-darwin.sh 134 | ``` 135 | 136 | ### Releasing 137 | 138 | ```sh 139 | VERSION=0.0.0 140 | git tag $VERSION 141 | git push origin $VERSION 142 | goreleaser release --rm-dist 143 | ``` 144 | -------------------------------------------------------------------------------- /ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/dhulihan/grump/library" 9 | "github.com/dhulihan/grump/player" 10 | "github.com/gdamore/tcell" 11 | "github.com/rivo/tview" 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | var ( 16 | app *tview.Application // The tview application. 17 | pages *tview.Pages // The application pages. 18 | finderFocus tview.Primitive // The primitive in the Finder that last had focus. 19 | build BuildInfo 20 | logs *tview.TextView 21 | statusBar *tview.TextView 22 | deleteModal *tview.Modal 23 | editForm *tview.Form 24 | editPage *tview.Flex 25 | theme *tview.Theme 26 | ) 27 | 28 | // BuildInfo contains build-time data for displaying version, etc. 29 | type BuildInfo struct { 30 | Version string 31 | Commit string 32 | } 33 | 34 | // Start starts the ui 35 | func Start(ctx context.Context, b BuildInfo, db *library.Library, musicPlayer player.AudioPlayer, loggers []io.Writer) error { 36 | // hard code first for now 37 | musicLibrary := db.AudioShelves[0] 38 | app = tview.NewApplication() 39 | build = b 40 | start(ctx, musicLibrary, musicPlayer, loggers) 41 | if err := app.Run(); err != nil { 42 | return fmt.Errorf("Error running application: %s", err) 43 | } 44 | 45 | return nil 46 | } 47 | 48 | // start the ui 49 | func start(ctx context.Context, ml library.AudioShelf, pl player.AudioPlayer, loggers []io.Writer) { 50 | theme = defaultTheme() 51 | setupLoggers(loggers) 52 | 53 | // Set up the pages 54 | trackPage := NewTrackPage(ctx, ml, pl) 55 | helpPage := NewHelpPage(ctx) 56 | logsPage := NewLogsPage(ctx) 57 | 58 | editForm = tview.NewForm() 59 | editPage = modalWrapper(editForm, 60, 20) 60 | 61 | deleteModal = tview.NewModal() 62 | 63 | pages = tview.NewPages(). 64 | AddPage("help", helpPage.Page(ctx), true, false). 65 | AddPage("logs", logsPage.Page(ctx), true, false). 66 | AddPage("tracks", trackPage.Page(ctx), true, true). 67 | AddPage("edit", editPage, true, false) 68 | 69 | app.SetRoot(pages, true).SetFocus(trackPage.trackList) 70 | } 71 | 72 | func defaultTheme() *tview.Theme { 73 | return &tview.Theme{ 74 | PrimitiveBackgroundColor: tcell.ColorBlack, // Main background color for primitives. 75 | ContrastBackgroundColor: tcell.ColorBlue, // Background color for contrasting elements. 76 | MoreContrastBackgroundColor: tcell.ColorGreen, // Background color for even more contrasting elements. 77 | BorderColor: tcell.ColorGrey, // Box borders. 78 | TitleColor: tcell.ColorCoral, // Box titles. 79 | GraphicsColor: tcell.ColorFuchsia, // Graphics. 80 | PrimaryTextColor: tcell.ColorWhite, // Primary text. 81 | SecondaryTextColor: tcell.ColorAqua, // Secondary text (e.g. labels). 82 | TertiaryTextColor: tcell.ColorMediumSeaGreen, // Tertiary text (e.g. subtitles, notes). 83 | InverseTextColor: tcell.ColorBlue, // Text on primary-colored backgrounds. 84 | ContrastSecondaryTextColor: tcell.ColorDarkCyan, // Secondary text on ContrastBackgroundColor-colored backgrounds. 85 | } 86 | } 87 | 88 | // globalInputCapture handles input and behavior that is the same across the 89 | // entire application 90 | var globalInputCapture = func(event *tcell.EventKey) *tcell.EventKey { 91 | s := string(event.Rune()) 92 | switch s { 93 | case "l": 94 | pages.SwitchToPage("logs") 95 | case "t": 96 | pages.SwitchToPage("tracks") 97 | case "?": 98 | pages.SwitchToPage("help") 99 | case "q": 100 | log.Info("exiting") 101 | app.Stop() 102 | } 103 | 104 | return event 105 | } 106 | 107 | // setup loggers (status bar, file, logs page) 108 | func setupLoggers(loggers []io.Writer) { 109 | logs = tview.NewTextView(). 110 | SetDynamicColors(true). 111 | SetRegions(true) 112 | 113 | loggers = append(loggers, logs) 114 | 115 | statusBar = tview.NewTextView(). 116 | SetTextColor(theme.BorderColor) 117 | loggers = append(loggers, statusBar) 118 | 119 | // combine our log destinations 120 | log.Trace("creating logger group") 121 | l := io.MultiWriter(loggers...) 122 | log.SetOutput(l) 123 | 124 | formatter := &appLogger{} 125 | log.SetFormatter(formatter) 126 | } 127 | 128 | // appLogger is a log formatter for the application 129 | type appLogger struct{} 130 | 131 | // Format is a custom log formatter that allows write logrus entries to the ui 132 | func (l *appLogger) Format(entry *log.Entry) ([]byte, error) { 133 | // clear out the log box before writing text to it 134 | statusBar.Clear() 135 | 136 | lf := &log.TextFormatter{} 137 | return lf.Format(entry) 138 | } 139 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= 2 | github.com/bogem/id3v2 v1.2.0 h1:hKDF+F1gOgQ5r1QmBCEZUk4MveJbKxCeIDSBU7CQ4oI= 3 | github.com/bogem/id3v2 v1.2.0/go.mod h1:t78PK5AQ56Q47kizpYiV6gtjj3jfxlz87oFpty8DYs8= 4 | github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8 h1:nmFnmD8VZXkjDHIb1Gnfz50cgzUvGN72zLjPRXBW/hU= 9 | github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw= 10 | github.com/faiface/beep v1.0.3-0.20200712202812-d836f29bdc50 h1:nW1/uI5xoR3xXmSR0YewPa+xYNi/YRF2hGjY+VAEeeQ= 11 | github.com/faiface/beep v1.0.3-0.20200712202812-d836f29bdc50/go.mod h1:nv+7LjRrok3sDtIZN8d405o60tIHcrrKlRCtxl41fEU= 12 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= 13 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 14 | github.com/gdamore/tcell v1.3.0 h1:r35w0JBADPZCVQijYebl6YMWWtHRqVEGt7kL2eBADRM= 15 | github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= 16 | github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= 17 | github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= 18 | github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE= 19 | github.com/hajimehoshi/go-mp3 v0.3.0 h1:fTM5DXjp/DL2G74HHAs/aBGiS9Tg7wnp+jkU38bHy4g= 20 | github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= 21 | github.com/hajimehoshi/oto v0.6.1 h1:7cJz/zRQV4aJvMSSRqzN2TImoVVMpE0BCY4nrNJaDOM= 22 | github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= 23 | github.com/icza/bitio v1.0.0 h1:squ/m1SHyFeCA6+6Gyol1AxV9nmPPlJFT8c2vKdj3U8= 24 | github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= 25 | github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= 26 | github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= 27 | github.com/jfreymuth/oggvorbis v1.0.1 h1:NT0eXBgE2WHzu6RT/6zcb2H10Kxj6Fm3PccT0LE6bqw= 28 | github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= 29 | github.com/jfreymuth/vorbis v1.0.0 h1:SmDf783s82lIjGZi8EGUUaS7YxPHgRj4ZXW/h7rUi7U= 30 | github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= 31 | github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= 32 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 33 | github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= 34 | github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= 35 | github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 36 | github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 37 | github.com/mattn/go-runewidth v0.0.8 h1:3tS41NlGYSmhhe/8fhGRzc+z3AYCw1Fe1WAyLuujKs0= 38 | github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 39 | github.com/mewkiz/flac v1.0.6 h1:OnMwCWZPAnjDndjEzLynOZ71Y2U+/QYHoVI4JEKgKkk= 40 | github.com/mewkiz/flac v1.0.6/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU= 41 | github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2 h1:EyTNMdePWaoWsRSGQnXiSoQu0r6RS1eA557AwJhlzHU= 42 | github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA= 43 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 44 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 45 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 46 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 47 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 48 | github.com/rivo/tview v0.0.0-20200404204604-ca37f83cb2e7 h1:Jfm2O5tRzzHt5LeM9F4AuwcNGxCH7erPl8GeVOzJKd0= 49 | github.com/rivo/tview v0.0.0-20200404204604-ca37f83cb2e7/go.mod h1:6lkG1x+13OShEf0EaOCaTQYyB7d5nSbb181KtjlS+84= 50 | github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= 51 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 52 | github.com/sirupsen/logrus v1.5.0 h1:1N5EYkVAPEywqZRJd7cwnRtCb6xJx7NH3T3WUTF980Q= 53 | github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= 54 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 55 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 56 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 57 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 58 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 h1:idBdZTd9UioThJp8KpM/rTSinK/ChZFBE43/WtIy8zg= 59 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 60 | golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 61 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 h1:KYGJGHOQy8oSi1fDlSpcZF0+juKwk/hEMv5SiwHogR0= 62 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 63 | golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 h1:vyLBGJPIl9ZYbcQFM2USFmJBK6KI+t+z6jL0lbwjrnc= 64 | golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= 65 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 66 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 67 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 68 | golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 69 | golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 70 | golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 71 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= 72 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 73 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 74 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 75 | golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= 76 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 77 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 78 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 79 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 80 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 81 | gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= 82 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 83 | -------------------------------------------------------------------------------- /player/beep.go: -------------------------------------------------------------------------------- 1 | package player 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "github.com/dhulihan/grump/library" 9 | "github.com/faiface/beep" 10 | "github.com/faiface/beep/effects" 11 | "github.com/faiface/beep/flac" 12 | "github.com/faiface/beep/mp3" 13 | "github.com/faiface/beep/speaker" 14 | "github.com/faiface/beep/vorbis" 15 | "github.com/faiface/beep/wav" 16 | log "github.com/sirupsen/logrus" 17 | ) 18 | 19 | var ( 20 | speakerInitialized = false 21 | 22 | prevSampleRate beep.SampleRate 23 | ) 24 | 25 | const ( 26 | // beep quality to use for playing audio 27 | quality = 4 28 | ) 29 | 30 | var ( 31 | // maxSampleRate is used for resampling various audio formats. We also set 32 | // the sample rate of the speaker to this, so it essentially controls the 33 | // maximum quality of files played by BeepAudioPlayer. 34 | maxSampleRate beep.SampleRate = 44100 35 | ) 36 | 37 | // BeepAudioPlayer is an audio player implementation that uses beep 38 | type BeepAudioPlayer struct{} 39 | 40 | // BeepController manages playing audio. 41 | // 42 | // TODO: make this an interface. this is fine for now since we're only using 43 | // beep our audio player. 44 | type BeepController struct { 45 | audioPanel *audioPanel 46 | path string 47 | done chan (bool) 48 | } 49 | 50 | // audioPanel is the audio panel for the controller 51 | type audioPanel struct { 52 | sampleRate beep.SampleRate 53 | ctrl *beep.Ctrl 54 | resampler *beep.Resampler 55 | volume *effects.Volume 56 | streamer beep.StreamSeekCloser 57 | finished bool 58 | } 59 | 60 | // newAudioPanel creates a new audio panel. 61 | // 62 | // count - number of times to repeat the track 63 | func newAudioPanel(sampleRate beep.SampleRate, streamer beep.StreamSeekCloser, count int) *audioPanel { 64 | ctrl := &beep.Ctrl{Streamer: beep.Loop(count, streamer)} 65 | 66 | log.WithFields(log.Fields{ 67 | "src": sampleRate, 68 | "dst": maxSampleRate, 69 | }).Debug("resampling") 70 | 71 | resampler := beep.Resample(quality, sampleRate, maxSampleRate, ctrl) 72 | 73 | volume := &effects.Volume{Streamer: resampler, Base: 2} 74 | return &audioPanel{ 75 | sampleRate: sampleRate, 76 | ctrl: ctrl, 77 | resampler: resampler, 78 | volume: volume, 79 | streamer: streamer, 80 | } 81 | } 82 | 83 | // NewBeepAudioPlayer -- 84 | func NewBeepAudioPlayer() (*BeepAudioPlayer, error) { 85 | bmp := BeepAudioPlayer{} 86 | return &bmp, nil 87 | } 88 | 89 | // Play a track and return a controller that lets you perform changes to a running track. 90 | func (bmp *BeepAudioPlayer) Play(track library.Track, repeat bool) (AudioController, error) { 91 | c := BeepController{ 92 | path: track.Path, 93 | done: make(chan (bool)), 94 | } 95 | 96 | f, err := os.Open(track.Path) 97 | if err != nil { 98 | return nil, err 99 | } 100 | // do not close file io, this should get freed up when we close the streamer 101 | //defer f.Close() 102 | 103 | var s beep.StreamSeekCloser 104 | var format beep.Format 105 | 106 | switch track.FileType { 107 | case "MP3": 108 | s, format, err = mp3.Decode(f) 109 | if err != nil { 110 | return nil, err 111 | } 112 | case "FLAC": 113 | s, format, err = flac.Decode(f) 114 | if err != nil { 115 | return nil, err 116 | } 117 | case "OGG": 118 | s, format, err = vorbis.Decode(f) 119 | if err != nil { 120 | return nil, err 121 | } 122 | case "WAV": 123 | s, format, err = wav.Decode(f) 124 | if err != nil { 125 | return nil, err 126 | } 127 | default: 128 | return nil, fmt.Errorf("unsupported file type [%s]", track.FileType) 129 | } 130 | 131 | // number of times to repeat the track 132 | count := 1 133 | if repeat { 134 | count = -1 135 | } 136 | 137 | if !speakerInitialized { 138 | log.WithField("sampleRate", format.SampleRate).Debug("init speaker") 139 | speaker.Init(maxSampleRate, format.SampleRate.N(time.Second/30)) 140 | speakerInitialized = true 141 | } 142 | 143 | c.audioPanel = newAudioPanel(format.SampleRate, s, count) 144 | 145 | // WARNING: speaker.Play is async 146 | speaker.Play(beep.Seq(c.audioPanel.volume, beep.Callback(func() { 147 | log.WithField("path", track.Path).Trace("streamer callback firing") 148 | c.Stop() 149 | }))) 150 | 151 | return &c, nil 152 | } 153 | 154 | // Done returns a done channel 155 | func (c *BeepController) Done() chan (bool) { 156 | return c.done 157 | } 158 | 159 | // PlayState returns the current state of playing audio. 160 | func (c *BeepController) PlayState() (PlayState, error) { 161 | speaker.Lock() 162 | p := c.audioPanel.streamer.Position() 163 | position := c.audioPanel.sampleRate.D(p) 164 | l := c.audioPanel.streamer.Len() 165 | length := c.audioPanel.sampleRate.D(l) 166 | percentageComplete := float32(p) / float32(l) 167 | volume := c.audioPanel.volume.Volume 168 | speed := c.audioPanel.resampler.Ratio() 169 | finished := c.audioPanel.finished 170 | speaker.Unlock() 171 | 172 | positionStatus := fmt.Sprintf("%v / %v", position.Round(time.Second), length.Round(time.Second)) 173 | volumeStatus := fmt.Sprintf("%.1f", volume) 174 | speedStatus := fmt.Sprintf("%.3fx", speed) 175 | 176 | prog := PlayState{ 177 | Progress: percentageComplete, 178 | Position: positionStatus, 179 | Volume: volumeStatus, 180 | Speed: speedStatus, 181 | Finished: finished, 182 | } 183 | return prog, nil 184 | } 185 | 186 | // PauseToggle pauses/unpauses audio. Returns true if currently paused, false if unpaused. 187 | func (c *BeepController) PauseToggle() bool { 188 | speaker.Lock() 189 | defer speaker.Unlock() 190 | 191 | c.audioPanel.ctrl.Paused = !c.audioPanel.ctrl.Paused 192 | return c.audioPanel.ctrl.Paused 193 | } 194 | 195 | // Paused returns current pause state 196 | func (c *BeepController) Paused() bool { 197 | speaker.Lock() 198 | defer speaker.Unlock() 199 | 200 | return c.audioPanel.ctrl.Paused 201 | } 202 | 203 | // VolumeUp the playing track 204 | func (c *BeepController) VolumeUp() { 205 | speaker.Lock() 206 | defer speaker.Unlock() 207 | 208 | c.audioPanel.volume.Volume += 0.1 209 | } 210 | 211 | // VolumeDown the playing track 212 | func (c *BeepController) VolumeDown() { 213 | speaker.Lock() 214 | defer speaker.Unlock() 215 | 216 | c.audioPanel.volume.Volume -= 0.1 217 | } 218 | 219 | // SpeedUp increases speed 220 | func (c *BeepController) SpeedUp() { 221 | speaker.Lock() 222 | defer speaker.Unlock() 223 | 224 | c.audioPanel.resampler.SetRatio(c.audioPanel.resampler.Ratio() * 16 / 15) 225 | } 226 | 227 | // SpeedDown slows down speed 228 | func (c *BeepController) SpeedDown() { 229 | speaker.Lock() 230 | defer speaker.Unlock() 231 | 232 | c.audioPanel.resampler.SetRatio(c.audioPanel.resampler.Ratio() * 15 / 16) 233 | } 234 | 235 | // SeekForward moves progress forward 236 | func (c *BeepController) SeekForward() error { 237 | speaker.Lock() 238 | defer speaker.Unlock() 239 | 240 | newPos := c.audioPanel.streamer.Position() 241 | newPos += c.audioPanel.sampleRate.N(time.Second * SeekSecs) 242 | if newPos < 0 { 243 | newPos = 0 244 | } 245 | if newPos >= c.audioPanel.streamer.Len() { 246 | newPos = c.audioPanel.streamer.Len() - SeekSecs 247 | } 248 | if err := c.audioPanel.streamer.Seek(newPos); err != nil { 249 | return fmt.Errorf("could not seek to new position [%d]: %s", newPos, err) 250 | } 251 | return nil 252 | } 253 | 254 | // SeekBackward moves progress backward 255 | func (c *BeepController) SeekBackward() error { 256 | speaker.Lock() 257 | defer speaker.Unlock() 258 | 259 | newPos := c.audioPanel.streamer.Position() 260 | newPos -= c.audioPanel.sampleRate.N(time.Second * SeekSecs) 261 | if newPos < 0 { 262 | newPos = 0 263 | } 264 | if newPos >= c.audioPanel.streamer.Len() { 265 | newPos = c.audioPanel.streamer.Len() - 1 266 | } 267 | if err := c.audioPanel.streamer.Seek(newPos); err != nil { 268 | return fmt.Errorf("could not seek to new position [%d]: %s", newPos, err) 269 | } 270 | return nil 271 | } 272 | 273 | // Stop must be thread safe 274 | func (c *BeepController) Stop() { 275 | // free up streamer 276 | // NOTE: this will cause the stremer to finish, and the seq callback will 277 | // fire 278 | c.audioPanel.finished = true 279 | 280 | if c.audioPanel.streamer != nil { 281 | log.Trace("closing audioPanel streamer") 282 | c.audioPanel.streamer.Close() 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /library/local_audio.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "math/big" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | "strings" 12 | 13 | "github.com/bogem/id3v2" 14 | "github.com/dhowden/tag" 15 | log "github.com/sirupsen/logrus" 16 | ) 17 | 18 | // LocalAudioShelf contains audio media stored in a local filesystem. 19 | type LocalAudioShelf struct { 20 | directory string 21 | files []string 22 | filePattern *regexp.Regexp 23 | tracks []Track 24 | } 25 | 26 | // NewLocalAudioShelf creates a shelf for a specific directory. 27 | func NewLocalAudioShelf(directory string) (*LocalAudioShelf, error) { 28 | r := regexp.MustCompile(`(.*).(mp3|flac|wav|ogg)$`) 29 | 30 | l := LocalAudioShelf{ 31 | directory: directory, 32 | filePattern: r, 33 | } 34 | 35 | return &l, nil 36 | } 37 | 38 | // LoadTracks searches through library for files to add to the database. 39 | // TODO: add unit tests for this 40 | func (l *LocalAudioShelf) LoadTracks() (uint64, error) { 41 | // look for new files 42 | i, err := l.pathScan() 43 | if err != nil { 44 | return i, err 45 | } 46 | log.WithField("count", i).Debug("paths scanned") 47 | 48 | // scan metadata 49 | i, err = l.loadTracks() 50 | if err != nil { 51 | return i, err 52 | } 53 | log.WithField("count", i).Debug("files scanned for metadata") 54 | return i, nil 55 | } 56 | 57 | // scan library directory for files 58 | func (l *LocalAudioShelf) pathScan() (uint64, error) { 59 | var scanCount uint64 60 | 61 | err := filepath.Walk(l.directory, 62 | func(path string, info os.FileInfo, err error) error { 63 | log.WithField("path", path).Trace("walking path") 64 | if err != nil { 65 | log.WithFields(log.Fields{ 66 | "path": path, 67 | "error": err, 68 | }).Error("could not walk path") 69 | return nil 70 | } 71 | 72 | if info.IsDir() { 73 | return nil 74 | } 75 | 76 | if !l.ShouldInclude(path) { 77 | log.WithField("path", path).Debug("discarding path") 78 | return nil 79 | } 80 | 81 | log.WithField("path", path).Debug("adding path to library") 82 | l.files = append(l.files, path) 83 | scanCount++ 84 | 85 | return nil 86 | }) 87 | 88 | if err != nil { 89 | return scanCount, err 90 | } 91 | 92 | return scanCount, nil 93 | } 94 | 95 | // ShouldInclude checks if we should include the file path in 96 | func (l *LocalAudioShelf) ShouldInclude(path string) bool { 97 | p := strings.ToLower(path) 98 | match := l.filePattern.Find([]byte(p)) 99 | 100 | if match == nil { 101 | return false 102 | } 103 | 104 | return true 105 | } 106 | 107 | func (l *LocalAudioShelf) loadTracks() (uint64, error) { 108 | tracks := []Track{} 109 | ctx := context.Background() 110 | 111 | // TODO: scan for metadata/id3 112 | var scanCount uint64 113 | for _, file := range l.files { 114 | track, err := l.LoadTrack(ctx, file) 115 | if err != nil { 116 | log.WithFields(log.Fields{ 117 | "path": file, 118 | "error": err, 119 | }).Error("could not load track") 120 | 121 | continue 122 | } 123 | tracks = append(tracks, *track) 124 | scanCount++ 125 | } 126 | 127 | l.tracks = tracks 128 | return scanCount, nil 129 | } 130 | 131 | // LoadTrack reads in track metadata 132 | func (l *LocalAudioShelf) LoadTrack(ctx context.Context, path string) (*Track, error) { 133 | h, err := l.handler(ctx, path) 134 | if err != nil { 135 | return nil, err 136 | } 137 | 138 | return h.Load(ctx, path) 139 | } 140 | 141 | // SaveTrack saves track metadata 142 | func (l *LocalAudioShelf) SaveTrack(ctx context.Context, prev, track *Track) (*Track, error) { 143 | h, err := l.handler(ctx, track.Path) 144 | if err != nil { 145 | return nil, err 146 | } 147 | 148 | return h.Save(ctx, track) 149 | } 150 | 151 | // DeleteTrack deletes a track from local audio shelf 152 | func (l *LocalAudioShelf) DeleteTrack(ctx context.Context, track *Track) error { 153 | if track.Path == "" { 154 | return errors.New("track has no path") 155 | } 156 | 157 | err := os.Remove(track.Path) 158 | 159 | if err != nil { 160 | return err 161 | } 162 | 163 | return nil 164 | } 165 | 166 | // handler returns a filetype-specific track handler responsible for 167 | // loading/saving metadata 168 | func (l *LocalAudioShelf) handler(ctx context.Context, path string) (TrackHandler, error) { 169 | ext := strings.ToLower(filepath.Ext(path)) 170 | 171 | switch ext { 172 | case ".mp3": 173 | return &ID3v2Handler{}, nil 174 | case ".flac", ".ogg": 175 | return &TagHandler{}, nil 176 | case ".wav": 177 | return &WAVHandler{}, nil 178 | default: 179 | return nil, fmt.Errorf("unsupported file extension: [%s]: %s", ext, path) 180 | } 181 | } 182 | 183 | // TagHandler uses the tag package 184 | type TagHandler struct{} 185 | 186 | // Load returns metadata for for a track using tag package 187 | func (s *TagHandler) Load(ctx context.Context, path string) (*Track, error) { 188 | f, err := os.Open(path) 189 | if err != nil { 190 | return nil, fmt.Errorf("could not open file [%s]: [%s]", path, err.Error()) 191 | } 192 | defer f.Close() 193 | 194 | m, err := tag.ReadFrom(f) 195 | if err != nil { 196 | return nil, fmt.Errorf("could not read metadata [%s]: [%s]", path, err.Error()) 197 | } 198 | 199 | trackNumber, trackTotal := m.Track() 200 | discNumber, discTotal := m.Disc() 201 | 202 | track := Track{ 203 | Title: m.Title(), 204 | Artist: m.Artist(), 205 | Album: m.Album(), 206 | AlbumArtist: m.AlbumArtist(), 207 | DiscNumber: discNumber, 208 | DiscTotal: discTotal, 209 | TrackNumber: trackNumber, 210 | TrackTotal: trackTotal, 211 | Composer: m.Composer(), 212 | Year: m.Year(), 213 | Genre: m.Genre(), 214 | Lyrics: m.Lyrics(), 215 | Comment: m.Comment(), 216 | FileType: string(m.FileType()), 217 | Path: path, 218 | } 219 | return &track, nil 220 | } 221 | 222 | // Save track metadata 223 | func (s *TagHandler) Save(ctx context.Context, track *Track) (*Track, error) { 224 | return nil, nil 225 | } 226 | 227 | // ID3v2Handler uses the id3v2 package 228 | type ID3v2Handler struct{} 229 | 230 | // Load returns metadata for for a track using id3v2 package 231 | func (s *ID3v2Handler) Load(ctx context.Context, path string) (*Track, error) { 232 | t, err := id3v2.Open(path, id3v2.Options{Parse: true}) 233 | if t == nil || err != nil { 234 | return nil, err 235 | } 236 | defer t.Close() 237 | 238 | track := Track{ 239 | Title: t.Title(), 240 | Artist: t.Artist(), 241 | Album: t.Album(), 242 | //Year: t.Year(), 243 | Genre: t.Genre(), 244 | FileType: "MP3", 245 | Path: path, 246 | RatingEmail: "grump", 247 | } 248 | 249 | // popm 250 | f := t.GetLastFrame(t.CommonID("Popularimeter")) 251 | popm, ok := f.(id3v2.PopularimeterFrame) 252 | if ok { 253 | track.Rating = popm.Rating 254 | track.RatingEmail = popm.Email 255 | } 256 | 257 | return &track, nil 258 | } 259 | 260 | // Save track metadata 261 | func (s *ID3v2Handler) Save(ctx context.Context, track *Track) (*Track, error) { 262 | log.WithFields(log.Fields{ 263 | "track": track, 264 | "artist": track.Artist, 265 | "album": track.Album, 266 | "title": track.Title, 267 | "rating": track.Rating, 268 | }).Debug("saving track") 269 | 270 | // compute diff 271 | tag, err := id3v2.Open(track.Path, id3v2.Options{Parse: true}) 272 | if tag == nil || err != nil { 273 | return nil, fmt.Errorf("could not open file [%s]: [%s]", track.Path, err.Error()) 274 | } 275 | defer tag.Close() 276 | 277 | // Text Tags 278 | tag.SetTitle(track.Title) 279 | tag.SetAlbum(track.Album) 280 | tag.SetArtist(track.Artist) 281 | 282 | // POPM 283 | frame := tag.GetLastFrame(tag.CommonID("Popularimeter")) 284 | popm, ok := frame.(id3v2.PopularimeterFrame) 285 | if ok { 286 | log.WithFields(log.Fields{ 287 | "prevRating": popm.Rating, 288 | "prevEmail": popm.Email, 289 | "path": track.Path, 290 | }).Debug("POPM already set") 291 | } 292 | log.WithFields(log.Fields{ 293 | "rating": track.Rating, 294 | "email": track.RatingEmail, 295 | "path": track.Path, 296 | }).Debug("setting POPM") 297 | 298 | popmFrame := id3v2.PopularimeterFrame{ 299 | Email: track.RatingEmail, 300 | Rating: track.Rating, 301 | Counter: big.NewInt(int64(track.PlayCount)), 302 | } 303 | tag.AddFrame(tag.CommonID("Popularimeter"), popmFrame) 304 | 305 | return track, tag.Save() 306 | } 307 | 308 | // WAVHandler scans wav metadata 309 | type WAVHandler struct{} 310 | 311 | // Load scans wav metadata 312 | func (s *WAVHandler) Load(ctx context.Context, path string) (*Track, error) { 313 | track := Track{ 314 | FileType: "WAV", 315 | Path: path, 316 | } 317 | return &track, nil 318 | } 319 | 320 | // Save track metadata 321 | func (s *WAVHandler) Save(ctx context.Context, track *Track) (*Track, error) { 322 | return nil, nil 323 | } 324 | 325 | // Tracks returns playable audio tracks on the shelf 326 | // TODO scan this 327 | func (l *LocalAudioShelf) Tracks() []Track { 328 | return l.tracks 329 | } 330 | -------------------------------------------------------------------------------- /ui/tracks.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math/rand" 7 | "runtime" 8 | "time" 9 | 10 | "github.com/dhulihan/grump/library" 11 | "github.com/dhulihan/grump/player" 12 | "github.com/gdamore/tcell" 13 | "github.com/rivo/tview" 14 | log "github.com/sirupsen/logrus" 15 | ) 16 | 17 | func init() { 18 | rand.Seed(time.Now().UTC().UnixNano()) 19 | } 20 | 21 | type trackTarget int 22 | 23 | const ( 24 | columnStatus = iota 25 | columnArtist 26 | columnAlbum 27 | columnTrack 28 | columnRating 29 | 30 | // check audio progess at this interval 31 | checkAudioMillis = 500 32 | 33 | // track target types 34 | playing trackTarget = iota 35 | hovered 36 | next 37 | prev 38 | ) 39 | 40 | // TrackPage is a page that displays playable audio tracks 41 | type TrackPage struct { 42 | // TODO: extract this to a something ui-agnostic 43 | shelf library.AudioShelf 44 | tracks []library.Track 45 | player player.AudioPlayer 46 | currentlyPlayingController player.AudioController 47 | currentlyPlayingTrack *library.Track 48 | currentlyPlayingRow int 49 | shuffle bool 50 | 51 | // layout 52 | left *tview.List 53 | center *tview.Flex 54 | logBox *tview.TextView 55 | trackList *tview.Table 56 | playStateBox *tview.Table 57 | statusBox *tview.Table 58 | editForm *tview.Form 59 | } 60 | 61 | // NewTrackPage generates the track page 62 | func NewTrackPage(ctx context.Context, shelf library.AudioShelf, pl player.AudioPlayer) *TrackPage { 63 | 64 | // Create the basic objects. 65 | trackList := tview.NewTable().SetBorders(true).SetBordersColor(theme.BorderColor) 66 | 67 | playStateBox := tview.NewTable() 68 | playStateBox.SetBorder(true).SetBorderColor(theme.BorderColor) 69 | 70 | p := &TrackPage{ 71 | //editForm: form, 72 | shelf: shelf, 73 | tracks: shelf.Tracks(), 74 | player: pl, 75 | logBox: statusBar, 76 | trackList: trackList, 77 | playStateBox: playStateBox, 78 | statusBox: tview.NewTable(), 79 | } 80 | 81 | return p 82 | } 83 | 84 | // Page populates the layout for the track page 85 | func (t *TrackPage) Page(ctx context.Context) tview.Primitive { 86 | t.trackColumns(t.trackList) 87 | 88 | for i, track := range t.tracks { 89 | // incr by one to pass table headers 90 | t.trackCell(t.trackList, i+1, track) 91 | } 92 | 93 | t.trackList. 94 | // fired on Escape, Tab, or Backtab key 95 | SetDoneFunc(func(key tcell.Key) { 96 | log.Debugf("done func firing, key [%v]", key) 97 | }). 98 | SetSelectable(true, false).SetSelectedFunc(t.cellChosen).SetSelectedStyle(theme.SecondaryTextColor, theme.PrimitiveBackgroundColor, tcell.AttrNone) 99 | //t.trackList.SetSelectedStyle(theme.TertiaryTextColor, theme.PrimitiveBackgroundColor, tcell.AttrNone) 100 | 101 | t.trackList.SetInputCapture(t.inputCapture) 102 | 103 | editForm.SetCancelFunc(t.editCancel) 104 | 105 | main := tview.NewFlex().SetDirection(tview.FlexRow). 106 | AddItem(t.trackList, 0, 3, true). 107 | AddItem(t.playStateBox, 5, 1, false). 108 | AddItem(t.statusBox, 1, 1, false). 109 | AddItem(t.logBox, 1, 1, false) 110 | 111 | // Create the layout. 112 | flex := tview.NewFlex(). 113 | AddItem(main, 0, 3, true) 114 | 115 | t.welcome() 116 | 117 | // one outstanding goroutine that tracks audio progress 118 | go t.audioPlaying(ctx) 119 | 120 | return flex 121 | } 122 | 123 | // main key input handler for this page 124 | func (t *TrackPage) inputCapture(event *tcell.EventKey) *tcell.EventKey { 125 | // placeholder nil check for convenience 126 | log.Tracef("input capture firing, name [%s] key [%d] rune [%s]", event.Name(), event.Key(), string(event.Rune())) 127 | 128 | globalInputCapture(event) 129 | 130 | switch event.Key() { 131 | case tcell.KeyRune: 132 | // attempt to use rune as string 133 | s := string(event.Rune()) 134 | switch s { 135 | case "D": 136 | t.describe(hovered) 137 | } 138 | } 139 | 140 | // something is currently playing, handle that 141 | if t.currentlyPlayingController != nil { 142 | return t.currentlyPlayingInputCapture(event) 143 | } 144 | 145 | return event 146 | } 147 | 148 | // track fetches a track 149 | func (t *TrackPage) track(target trackTarget) (*library.Track, error) { 150 | var track *library.Track 151 | 152 | switch target { 153 | case playing: 154 | if t.currentlyPlayingTrack == nil { 155 | return nil, fmt.Errorf("no track currently playing") 156 | } 157 | 158 | track = t.currentlyPlayingTrack 159 | // TODO: hovered does not work yet 160 | case hovered: 161 | row, column := t.trackList.GetOffset() 162 | track = &t.tracks[row] 163 | log.WithFields(log.Fields{"row": row, "column": column}).Debug("currently hovered track") 164 | 165 | return track, nil 166 | default: 167 | return nil, fmt.Errorf("trackTarget not supported: %v", target) 168 | } 169 | 170 | log.WithFields(log.Fields{"track": track}).Debug("track targeted") 171 | return track, nil 172 | } 173 | 174 | func (t *TrackPage) describe(target trackTarget) { 175 | track, err := t.track(target) 176 | if err != nil { 177 | log.WithError(err).Error("could not target track") 178 | return 179 | } 180 | 181 | log.WithFields(log.Fields{ 182 | "title": track.Title, 183 | "album": track.Album, 184 | "artist": track.Artist, 185 | "rating": track.Rating, 186 | "ratingEmail": track.RatingEmail, 187 | "score": Score(track.Rating), 188 | "playCount": track.PlayCount, 189 | }).Info("describing track") 190 | } 191 | 192 | // inputDone is used to enhance to form input movement 193 | func (t *TrackPage) inputDone(key tcell.Key) { 194 | log.Tracef("modal input capture firing, key [%d] %s", key, tcell.KeyNames[key]) 195 | // perform this asynchronously to avoid weird focus state where the 196 | // InputField holds on to focus 197 | go func() { 198 | app.QueueUpdateDraw(func() { 199 | switch key { 200 | case tcell.KeyEnter: 201 | t.save() 202 | pages.HidePage("edit") 203 | editForm.Blur() 204 | case tcell.KeyEscape: 205 | pages.HidePage("edit") 206 | editForm.Blur() 207 | case tcell.KeyUp: 208 | fi, _ := editForm.GetFocusedItemIndex() 209 | index := fi - 1 210 | editForm.SetFocus(index) 211 | app.SetFocus(editForm) 212 | case tcell.KeyDown: 213 | fi, _ := editForm.GetFocusedItemIndex() 214 | index := fi + 1 215 | editForm.SetFocus(index) 216 | app.SetFocus(editForm) 217 | } 218 | }) 219 | }() 220 | } 221 | 222 | func (t *TrackPage) editCancel() { 223 | pages.SwitchToPage("tracks") 224 | 225 | // unpause 226 | if t.currentlyPlayingController.Paused() { 227 | t.pauseToggle() 228 | } 229 | } 230 | 231 | func (t *TrackPage) edit(target trackTarget) { 232 | log.Debug("editing track") 233 | 234 | track, err := t.track(target) 235 | if err != nil { 236 | log.WithError(err).Error("could not target track") 237 | return 238 | } 239 | 240 | // pause 241 | if !t.currentlyPlayingController.Paused() { 242 | t.pauseToggle() 243 | } 244 | 245 | // if blank, use previous album/artist 246 | if track.Album == "" { 247 | track.Album = getFormInputText(editForm, "Album") 248 | } 249 | 250 | if track.Artist == "" { 251 | track.Artist = getFormInputText(editForm, "Artist") 252 | } 253 | 254 | editForm.Clear(true). 255 | AddFormItem(newInputField("Title", track.Title, t.inputDone)). 256 | AddFormItem(newInputField("Album", track.Album, t.inputDone)). 257 | AddFormItem(newInputField("Artist", track.Artist, t.inputDone)). 258 | AddFormItem(newDropDown("Score", Scores, indexOf(Scores, Score(track.Rating)))). 259 | AddButton("Save", t.save). 260 | AddButton("Cancel", t.editCancel) 261 | 262 | editForm.SetBorder(true).SetTitle("Edit Track").SetTitleAlign(tview.AlignLeft) 263 | pages.ShowPage("edit") 264 | app.SetFocus(editForm) 265 | } 266 | 267 | func (t *TrackPage) save() { 268 | log.Debug("saving track") 269 | ctx := context.Background() 270 | 271 | prev, err := t.track(playing) 272 | if err != nil { 273 | log.WithError(err).Error("could not target track") 274 | return 275 | } 276 | row := t.currentlyPlayingRow 277 | track := prev 278 | 279 | _, score := t.dropDown("Score").GetCurrentOption() 280 | 281 | track.Title = inputField(editForm, "Title").GetText() 282 | track.Album = inputField(editForm, "Album").GetText() 283 | track.Artist = inputField(editForm, "Artist").GetText() 284 | track.Rating = Rating(score) 285 | 286 | log.WithFields(log.Fields{ 287 | "title": track.Title, 288 | "album": track.Album, 289 | "artist": track.Artist, 290 | "rating": track.Rating, 291 | "row": row, 292 | }).Debug("collected track data from form") 293 | 294 | _, err = t.shelf.SaveTrack(ctx, prev, track) 295 | if err != nil { 296 | log.WithField("track", track).WithError(err).Error("could not save track") 297 | return 298 | } 299 | 300 | // update track row 301 | t.trackCell(t.trackList, row, *track) 302 | 303 | // update cache 304 | t.tracks[row-1] = *track 305 | 306 | // switch back to tracks page 307 | pages.SwitchToPage("tracks") 308 | 309 | // unpause 310 | if t.currentlyPlayingController.Paused() { 311 | t.pauseToggle() 312 | } 313 | } 314 | 315 | func (t *TrackPage) confirmDelete(ctx context.Context, target trackTarget) { 316 | track, err := t.track(target) 317 | if err != nil { 318 | log.WithError(err).Error("could not target track") 319 | return 320 | } 321 | 322 | if !t.currentlyPlayingController.Paused() { 323 | t.pauseToggle() 324 | } 325 | 326 | msg := fmt.Sprintf(` 327 | Delete? 328 | 329 | Title: %s 330 | Album: %s 331 | Artist: %s 332 | 333 | %s`, 334 | track.Title, 335 | track.Album, 336 | track.Artist, 337 | track.Path, 338 | ) 339 | 340 | deleteModal = tview.NewModal(). 341 | SetText(msg). 342 | AddButtons([]string{"Delete", "Cancel"}). 343 | SetDoneFunc(func(buttonIndex int, buttonLabel string) { 344 | if buttonLabel == "Delete" { 345 | err := t.deleteTrack(ctx) 346 | 347 | if err != nil { 348 | log.WithError(err).Error("could not delete track") 349 | } 350 | } 351 | app.SetRoot(pages, true).SetFocus(t.trackList) 352 | }) 353 | 354 | app.SetRoot(deleteModal, false).SetFocus(deleteModal) 355 | } 356 | 357 | func (t *TrackPage) deleteTrack(ctx context.Context) error { 358 | track, err := t.track(playing) 359 | if err != nil { 360 | return err 361 | } 362 | log.WithField("track", track).Debug("deleting track") 363 | 364 | row := t.currentlyPlayingRow 365 | 366 | // stop playing 367 | t.stopCurrentlyPlaying() 368 | 369 | // delete from library 370 | err = t.shelf.DeleteTrack(ctx, track) 371 | if err != nil { 372 | return err 373 | } 374 | 375 | // delete from cache 376 | removed := t.removeTrackFromCache(row - 1) 377 | log.WithFields(log.Fields{ 378 | "trackRemovedFromCache": removed, 379 | "row": row, 380 | }).Debug("track removed from cache") 381 | 382 | // update ui 383 | t.trackList.RemoveRow(row) 384 | 385 | // log 386 | log.WithFields(log.Fields{ 387 | "track": track, 388 | }).Info("deleted track") 389 | 390 | // play next track 391 | t.cellChosen(row, 0) 392 | 393 | return nil 394 | } 395 | 396 | // remove track from cache and return it 397 | func (t *TrackPage) removeTrackFromCache(i int) library.Track { 398 | track := t.tracks[i] 399 | t.tracks = append(t.tracks[:i], t.tracks[i+1:]...) 400 | return track 401 | 402 | } 403 | 404 | // handle key input while a track is playing 405 | func (t *TrackPage) currentlyPlayingInputCapture(event *tcell.EventKey) *tcell.EventKey { 406 | ctx := context.Background() 407 | 408 | switch event.Key() { 409 | case tcell.KeyESC: 410 | t.stopCurrentlyPlaying() 411 | t.welcome() 412 | case tcell.KeyLeft: 413 | err := t.currentlyPlayingController.SeekBackward() 414 | if err != nil { 415 | log.WithError(err).Error("problem seeking forward") 416 | return event 417 | } 418 | case tcell.KeyRight: 419 | err := t.currentlyPlayingController.SeekForward() 420 | if err != nil { 421 | log.WithError(err).Error("problem seeking forward") 422 | return event 423 | } 424 | case tcell.KeyDelete: 425 | t.confirmDelete(ctx, playing) 426 | return event 427 | case tcell.KeyRune: 428 | // attempt to use rune as string 429 | s := string(event.Rune()) 430 | switch s { 431 | // key - playing 432 | // shift+key - hovered 433 | case "0": 434 | t.SetScore(Score00) 435 | case ")": 436 | t.SetScore(Score05) 437 | case "1": 438 | t.SetScore(Score10) 439 | case "!": 440 | t.SetScore(Score15) 441 | case "2": 442 | t.SetScore(Score20) 443 | case "@": 444 | t.SetScore(Score25) 445 | case "3": 446 | t.SetScore(Score30) 447 | case "#": 448 | t.SetScore(Score35) 449 | case "4": 450 | t.SetScore(Score40) 451 | case "$": 452 | t.SetScore(Score45) 453 | case "5": 454 | t.SetScore(Score50) 455 | case "d": 456 | t.describe(playing) 457 | case "e": 458 | t.edit(playing) 459 | case " ": 460 | t.pauseToggle() 461 | case "=": 462 | // IDEA: flash the label 463 | t.currentlyPlayingController.VolumeUp() 464 | case "-": 465 | t.currentlyPlayingController.VolumeDown() 466 | case "S": 467 | t.shuffleToggle() 468 | case "+": 469 | t.currentlyPlayingController.SpeedUp() 470 | case "_": 471 | t.currentlyPlayingController.SpeedDown() 472 | case "]": 473 | t.skip(1) 474 | case "[": 475 | t.skip(-1) 476 | case "?": 477 | log.Trace("switching to help page") 478 | pages.SwitchToPage("help") 479 | case "q": 480 | app.Stop() 481 | } 482 | } 483 | return event 484 | } 485 | 486 | func (t *TrackPage) shuffleToggle() { 487 | // thread safe? nope! 488 | t.shuffle = !t.shuffle 489 | log.WithField("enabled", t.shuffle).Debug("toggling shuffle") 490 | 491 | if t.shuffle { 492 | t.statusBox.SetCell(0, 0, tview.NewTableCell(shuffleIconOn+" Shuffle: On")) 493 | } else { 494 | t.statusBox.SetCellSimple(0, 0, "") 495 | } 496 | } 497 | 498 | func (t *TrackPage) pauseToggle() { 499 | if t.currentlyPlayingController == nil { 500 | log.Debug("cannot pause, nothing currently playing") 501 | return 502 | } 503 | 504 | log.Debug("pausing currently playing track") 505 | t.currentlyPlayingController.PauseToggle() 506 | 507 | if t.currentlyPlayingRow == 0 { 508 | log.Debug("nothing currently playing, done toggling pause") 509 | return 510 | } 511 | 512 | if t.currentlyPlayingController.Paused() { 513 | t.setTrackRowStyle(t.currentlyPlayingRow, theme.SecondaryTextColor, trackIconPausedText) 514 | } else { 515 | t.setTrackRowStyle(t.currentlyPlayingRow, theme.TertiaryTextColor, trackIconPlayingText) 516 | } 517 | } 518 | 519 | // cellConfirmed is called when a user presses enter on a selected cell. 520 | func (t *TrackPage) cellChosen(row, column int) { 521 | // clear any lingering log messages. 522 | // TODO: maybe fire this off at an interval later 523 | t.logBox.Clear() 524 | 525 | if row == 0 { 526 | log.Info("please select a track") 527 | return 528 | } 529 | 530 | log.Tracef("selecting row %d column %d", row, column) 531 | 532 | if row > len(t.tracks) { 533 | log.Warnf("row out of range %d column %d, length %d", row, column, len(t.tracks)) 534 | return 535 | } 536 | 537 | track := t.tracks[row-1] 538 | 539 | if t.currentlyPlayingRow != 0 && t.currentlyPlayingController != nil && t.currentlyPlayingTrack != nil { 540 | log.WithFields(log.Fields{ 541 | "track": t.currentlyPlayingTrack, 542 | "row": t.currentlyPlayingRow, 543 | }).Debug("stopping currently playing track") 544 | t.stopCurrentlyPlaying() 545 | } 546 | 547 | t.currentlyPlayingRow = row 548 | 549 | // set currently playing row style 550 | t.setTrackRowStyle(t.currentlyPlayingRow, theme.TertiaryTextColor, trackIconPlayingText) 551 | 552 | t.playTrack(&track) 553 | } 554 | 555 | // setTrackRowStyle sets the style of a track row. Used for selection, pausing, 556 | // unpausing, etc. 557 | func (t *TrackPage) setTrackRowStyle(row int, color tcell.Color, statusColumnText string) { 558 | t.trackList.GetCell(row, columnStatus).SetText(statusColumnText) 559 | t.trackList.GetCell(row, columnArtist).SetTextColor(color) 560 | t.trackList.GetCell(row, columnAlbum).SetTextColor(color) 561 | t.trackList.GetCell(row, columnTrack).SetTextColor(color) 562 | } 563 | 564 | func (t *TrackPage) playTrack(track *library.Track) { 565 | log.WithFields(log.Fields{ 566 | "name": track.Title, 567 | "path": track.Path, 568 | }).Debug("playing track") 569 | 570 | controller, err := t.player.Play(*track, false) 571 | if err != nil { 572 | log.WithError(err).Fatal("could not play file") 573 | return 574 | } 575 | 576 | t.currentlyPlayingController = controller 577 | t.currentlyPlayingTrack = track 578 | } 579 | 580 | // audioPlaying is a loop that checks on currently playing track 581 | // progress 582 | func (t *TrackPage) audioPlaying(ctx context.Context) { 583 | for { 584 | select { 585 | case <-ctx.Done(): 586 | log.Debug("context done") 587 | return 588 | default: 589 | t.checkCurrentlyPlaying() 590 | time.Sleep(checkAudioMillis * time.Millisecond) 591 | } 592 | } 593 | } 594 | 595 | func (t *TrackPage) stopCurrentlyPlaying() { 596 | log.WithField("row", t.currentlyPlayingRow).Debug("clearing track style") 597 | t.setTrackRowStyle(t.currentlyPlayingRow, theme.PrimaryTextColor, trackIconEmptyText) 598 | 599 | if t.currentlyPlayingController == nil { 600 | return 601 | } 602 | 603 | t.currentlyPlayingController.Stop() 604 | t.currentlyPlayingController = nil 605 | t.currentlyPlayingTrack = nil 606 | t.currentlyPlayingRow = 0 607 | } 608 | 609 | // if audio is playing, update status, if stopped, clear 610 | func (t *TrackPage) checkCurrentlyPlaying() { 611 | if t.currentlyPlayingController == nil || t.currentlyPlayingTrack == nil { 612 | return 613 | } 614 | 615 | ps, err := t.currentlyPlayingController.PlayState() 616 | if err != nil { 617 | log.WithError(err).Error("could not get audio play state") 618 | } 619 | 620 | t.updatePlayState(ps, t.currentlyPlayingTrack) 621 | 622 | // check if audio has stopped 623 | if ps.Finished { 624 | log.Debug("track has finished playing") 625 | 626 | // move to next track 627 | t.skip(1) 628 | } 629 | } 630 | 631 | // skip skips forward/backward on the playlist. count can be negative to go backward. 632 | // 633 | // TODO: add unit tests for next track logic 634 | func (t *TrackPage) skip(count int) { 635 | // attempt to play the next track available 636 | nextRow := t.currentlyPlayingRow + count 637 | 638 | // if shuffling, choose one at random 639 | if t.shuffle { 640 | nextRow = rand.Intn(len(t.tracks)) 641 | } 642 | 643 | // if skipping too far ahead, go to beginning 644 | if nextRow <= 0 { 645 | nextRow = len(t.tracks) 646 | } 647 | 648 | // if we're at the end of the list, start over 649 | if t.currentlyPlayingRow >= len(t.tracks) && count > 0 { 650 | nextRow = 1 651 | } 652 | 653 | log.WithFields(log.Fields{ 654 | "currentlyPlayingRow": t.currentlyPlayingRow, 655 | "nextRow": nextRow, 656 | "totalTracks": len(t.tracks), 657 | "skip": count, 658 | }).Debug("skipping to next track") 659 | 660 | t.cellChosen(nextRow, columnStatus) 661 | } 662 | 663 | func (t *TrackPage) updatePlayState(ps player.PlayState, track *library.Track) { 664 | percentageComplete := int(ps.Progress * 100) 665 | 666 | log.WithFields(log.Fields{ 667 | "progress": ps.Progress, 668 | "position": ps.Position, 669 | "volume": ps.Volume, 670 | "speed": ps.Speed, 671 | "track": track.Title, 672 | "goroutines": runtime.NumGoroutine(), 673 | }).Trace("play state update") 674 | 675 | app.QueueUpdateDraw(func() { 676 | t.playStateBox.SetCell(0, 0, tview.NewTableCell("Title")) 677 | t.playStateBox.SetCell(0, 1, &tview.TableCell{Text: track.Title, Color: theme.TertiaryTextColor}) 678 | t.playStateBox.SetCell(1, 0, tview.NewTableCell("Album")) 679 | t.playStateBox.SetCell(1, 1, &tview.TableCell{Text: track.Album, Color: theme.TertiaryTextColor}) 680 | t.playStateBox.SetCell(2, 0, tview.NewTableCell("Artist")) 681 | t.playStateBox.SetCell(2, 1, &tview.TableCell{Text: track.Artist, Color: theme.TertiaryTextColor}) 682 | 683 | t.playStateBox.SetCell(0, 2, tview.NewTableCell("Progress")) 684 | t.playStateBox.SetCell(0, 3, &tview.TableCell{Text: fmt.Sprintf("%s %d%%", ps.Position, percentageComplete), Color: theme.TertiaryTextColor}) 685 | t.playStateBox.SetCell(1, 2, &tview.TableCell{Text: "Volume"}) 686 | t.playStateBox.SetCell(1, 2, tview.NewTableCell("Volume")) 687 | t.playStateBox.SetCell(1, 3, &tview.TableCell{Text: ps.Volume, Color: theme.TertiaryTextColor}) 688 | t.playStateBox.SetCell(2, 2, tview.NewTableCell("Speed")) 689 | t.playStateBox.SetCell(2, 3, &tview.TableCell{Text: ps.Speed, Color: theme.TertiaryTextColor}) 690 | }) 691 | } 692 | 693 | func (t *TrackPage) welcome() { 694 | t.playStateBox.Clear(). 695 | SetCell(0, 0, tview.NewTableCell("grump")). 696 | SetCell(0, 1, &tview.TableCell{Text: fmt.Sprintf("%s", build.Version), Color: theme.TitleColor, NotSelectable: true}). 697 | SetCell(1, 0, tview.NewTableCell("files scanned")). 698 | SetCell(1, 1, &tview.TableCell{Text: fmt.Sprintf("%d", len(t.tracks)), Color: theme.SecondaryTextColor, NotSelectable: true}). 699 | SetCell(2, 0, tview.NewTableCell("for help, press")). 700 | SetCell(2, 1, &tview.TableCell{Text: "?", Color: theme.TertiaryTextColor, NotSelectable: true}) 701 | } 702 | 703 | func (t *TrackPage) trackColumns(table *tview.Table) { 704 | table. 705 | SetCell(0, columnStatus, &tview.TableCell{Text: trackIconEmptyText, Color: theme.TitleColor, NotSelectable: true}). 706 | SetCell(0, columnArtist, &tview.TableCell{Text: "Artist", Color: theme.TitleColor, NotSelectable: true}). 707 | SetCell(0, columnAlbum, &tview.TableCell{Text: "Album", Color: theme.TitleColor, NotSelectable: true}). 708 | SetCell(0, columnTrack, &tview.TableCell{Text: "Title", Color: theme.TitleColor, NotSelectable: true}). 709 | SetCell(0, columnRating, &tview.TableCell{Text: "Rating", Color: theme.TitleColor, NotSelectable: true}) 710 | } 711 | 712 | func (t *TrackPage) SetScore(score string) { 713 | ctx := context.Background() 714 | log.WithFields(log.Fields{"score": score}).Debug("setting score") 715 | 716 | track := t.currentlyPlayingTrack 717 | row := t.currentlyPlayingRow 718 | 719 | // convert rating 720 | rating := Rating(score) 721 | track.Rating = rating 722 | _, err := t.shelf.SaveTrack(ctx, nil, track) 723 | if err != nil { 724 | log.WithError(err).WithField("rating", rating).Error("could not set rating on track") 725 | return 726 | } 727 | 728 | // update track row 729 | t.trackCell(t.trackList, row, *track) 730 | 731 | // restore "playing" visual state 732 | t.setTrackRowStyle(t.currentlyPlayingRow, theme.TertiaryTextColor, trackIconPlayingText) 733 | 734 | // update cache 735 | t.tracks[row-1] = *track 736 | } 737 | 738 | func (t *TrackPage) trackCell(table *tview.Table, row int, track library.Track) { 739 | title := track.Title 740 | 741 | // use path if title is empty 742 | if track.Title == "" { 743 | title = track.Path 744 | } 745 | 746 | scoreText := Score(track.Rating) 747 | scoreColor := ScoreColor(scoreText) 748 | 749 | table. 750 | SetCell(row, columnStatus, &tview.TableCell{Text: trackIconEmptyText, Color: theme.PrimaryTextColor}). 751 | SetCell(row, columnArtist, &tview.TableCell{Text: track.Artist, Color: theme.PrimaryTextColor, Expansion: 4, MaxWidth: 8}). 752 | SetCell(row, columnAlbum, &tview.TableCell{Text: track.Album, Color: theme.PrimaryTextColor, Expansion: 4, MaxWidth: 8}). 753 | SetCell(row, columnTrack, &tview.TableCell{Text: title, Color: theme.PrimaryTextColor, Expansion: 10, MaxWidth: 8}). 754 | SetCell(row, columnRating, &tview.TableCell{Text: scoreText, Color: scoreColor}) 755 | } 756 | --------------------------------------------------------------------------------