diff --git a/.gitignore b/.gitignore index 8072836..b359ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ /grump /grump.log +*.prof diff --git a/go.mod b/go.mod index 943c4fa..b2edf1b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/dhulihan/grump -go 1.14 +go 1.19 require ( github.com/bogem/id3v2 v1.2.0 @@ -10,7 +10,28 @@ require ( github.com/rivo/tview v0.0.0-20200404204604-ca37f83cb2e7 github.com/sirupsen/logrus v1.5.0 github.com/stretchr/testify v1.4.0 - golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae // indirect - golang.org/x/text v0.3.3 // indirect gopkg.in/yaml.v2 v2.3.0 ) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gdamore/encoding v1.0.0 // indirect + github.com/hajimehoshi/go-mp3 v0.3.0 // indirect + github.com/hajimehoshi/oto v0.6.1 // indirect + github.com/icza/bitio v1.0.0 // indirect + github.com/jfreymuth/oggvorbis v1.0.1 // indirect + github.com/jfreymuth/vorbis v1.0.0 // indirect + github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.0.3 // indirect + github.com/mattn/go-runewidth v0.0.8 // indirect + github.com/mewkiz/flac v1.0.6 // indirect + github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.1.0 // indirect + golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 // indirect + golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 // indirect + golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 // indirect + golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect + golang.org/x/text v0.3.3 // indirect +) diff --git a/go.sum b/go.sum index 6ce73fb..eaf8e2b 100644 --- a/go.sum +++ b/go.sum @@ -30,11 +30,9 @@ github.com/jfreymuth/vorbis v1.0.0 h1:SmDf783s82lIjGZi8EGUUaS7YxPHgRj4ZXW/h7rUi7 github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/lucasb-eyer/go-colorful v1.0.2 h1:mCMFu6PgSozg9tDNMMK3g18oJBX7oYGrC09mS6CXfO4= github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.8 h1:3tS41NlGYSmhhe/8fhGRzc+z3AYCw1Fe1WAyLuujKs0= github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= @@ -42,7 +40,6 @@ github.com/mewkiz/flac v1.0.6 h1:OnMwCWZPAnjDndjEzLynOZ71Y2U+/QYHoVI4JEKgKkk= github.com/mewkiz/flac v1.0.6/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU= github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2 h1:EyTNMdePWaoWsRSGQnXiSoQu0r6RS1eA557AwJhlzHU= github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA= -github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -55,7 +52,6 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/sirupsen/logrus v1.5.0 h1:1N5EYkVAPEywqZRJd7cwnRtCb6xJx7NH3T3WUTF980Q= github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -71,12 +67,10 @@ golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4 h1:sfkvUWPNGwSV+8/fNqctR5lS2AqCSqYwXdrjCxp/dXo= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/library/audio.go b/library/audio_shelf.go similarity index 92% rename from library/audio.go rename to library/audio_shelf.go index 29bd23b..537cf8c 100644 --- a/library/audio.go +++ b/library/audio_shelf.go @@ -10,9 +10,8 @@ import ( type AudioShelf interface { Tracks() []Track - // LoadTracks searches for new files to add to the library + // LoadTracks fills the shelf with tracks LoadTracks() (count uint64, err error) - LoadTrack(ctx context.Context, location string) (*Track, error) SaveTrack(ctx context.Context, prev, track *Track) (*Track, error) DeleteTrack(ctx context.Context, track *Track) error diff --git a/player/audio.go b/player/audio.go index cb4a438..182a0ae 100644 --- a/player/audio.go +++ b/player/audio.go @@ -18,7 +18,7 @@ type AudioPlayer interface { type AudioController interface { Paused() bool PauseToggle() bool - Progress() (PlayState, error) + PlayState() (PlayState, error) SeekForward() error SeekBackward() error SpeedUp() diff --git a/player/beep.go b/player/beep.go index 7e04e01..4529fee 100644 --- a/player/beep.go +++ b/player/beep.go @@ -156,8 +156,8 @@ func (c *BeepController) Done() chan (bool) { return c.done } -// Progress returns the current state of playing audio. -func (c *BeepController) Progress() (PlayState, error) { +// PlayState returns the current state of playing audio. +func (c *BeepController) PlayState() (PlayState, error) { speaker.Lock() p := c.audioPanel.streamer.Position() position := c.audioPanel.sampleRate.D(p) diff --git a/player/mock.go b/player/mock.go index c914a12..055ab29 100644 --- a/player/mock.go +++ b/player/mock.go @@ -14,19 +14,19 @@ func NewMockAudioPlayer() *MockAudioPlayer { } // Play a track and return a controller that lets you perform changes to a running track. -func (bmp *MockAudioPlayer) Play(track library.Track, repeat bool) (AudioController, error) { +func (bmp *MockAudioPlayer) Play(track library.Track, repeat bool) (*MockAudioController, error) { return &MockAudioController{}, nil } type MockAudioController struct{} -func (p *MockAudioController) Paused() bool { return false } -func (p *MockAudioController) PauseToggle() bool { return true } -func (p *MockAudioController) Progress() (PlayState, error) { return PlayState{}, nil } -func (p *MockAudioController) SeekForward() error { return nil } -func (p *MockAudioController) SeekBackward() error { return nil } -func (p *MockAudioController) SpeedUp() {} -func (p *MockAudioController) SpeedDown() {} -func (p *MockAudioController) Stop() {} -func (p *MockAudioController) VolumeUp() {} -func (p *MockAudioController) VolumeDown() {} +func (p *MockAudioController) Paused() bool { return false } +func (p *MockAudioController) PauseToggle() bool { return true } +func (p *MockAudioController) PlayState() (PlayState, error) { return PlayState{}, nil } +func (p *MockAudioController) SeekForward() error { return nil } +func (p *MockAudioController) SeekBackward() error { return nil } +func (p *MockAudioController) SpeedUp() {} +func (p *MockAudioController) SpeedDown() {} +func (p *MockAudioController) Stop() {} +func (p *MockAudioController) VolumeUp() {} +func (p *MockAudioController) VolumeDown() {} diff --git a/ui/help.go b/ui/help.go index bd4d4b2..411236b 100644 --- a/ui/help.go +++ b/ui/help.go @@ -30,6 +30,7 @@ func NewHelpPage(ctx context.Context) *HelpPage { KeyboardShortcut{"[", "play previous track"}, KeyboardShortcut{"=", "volume up"}, KeyboardShortcut{"-", "volume down"}, + KeyboardShortcut{"S", "toggle shuffle"}, KeyboardShortcut{"+", "speed up"}, KeyboardShortcut{"_", "speed down"}, KeyboardShortcut{"q", "quit"}, diff --git a/ui/icons.go b/ui/icons.go new file mode 100644 index 0000000..37eeb29 --- /dev/null +++ b/ui/icons.go @@ -0,0 +1,10 @@ +package ui + +const ( + trackIconEmptyText = " " + trackIconPlayingText = "🔈" + trackIconPausedText = "🔇" + + shuffleIconOff = " " + shuffleIconOn = "🔀" +) diff --git a/ui/tracks.go b/ui/tracks.go index 56f8c2e..3202ce0 100644 --- a/ui/tracks.go +++ b/ui/tracks.go @@ -3,6 +3,7 @@ package ui import ( "context" "fmt" + "math/rand" "runtime" "time" @@ -13,6 +14,10 @@ import ( log "github.com/sirupsen/logrus" ) +func init() { + rand.Seed(time.Now().UTC().UnixNano()) +} + type trackTarget int const ( @@ -22,10 +27,6 @@ const ( columnTrack columnRating - trackIconEmptyText = " " - trackIconPlayingText = "🔈" - trackIconPausedText = "🔇" - // check audio progess at this interval checkAudioMillis = 500 @@ -38,20 +39,23 @@ const ( // TrackPage is a page that displays playable audio tracks type TrackPage struct { + // TODO: extract this to a something ui-agnostic shelf library.AudioShelf tracks []library.Track player player.AudioPlayer currentlyPlayingController player.AudioController currentlyPlayingTrack *library.Track currentlyPlayingRow int + shuffle bool // layout - left *tview.List - center *tview.Flex - logBox *tview.TextView - trackList *tview.Table - progressBox *tview.Table - editForm *tview.Form + left *tview.List + center *tview.Flex + logBox *tview.TextView + trackList *tview.Table + playStateBox *tview.Table + statusBox *tview.Table + editForm *tview.Form } // NewTrackPage generates the track page @@ -60,17 +64,18 @@ func NewTrackPage(ctx context.Context, shelf library.AudioShelf, pl player.Audio // Create the basic objects. trackList := tview.NewTable().SetBorders(true).SetBordersColor(theme.BorderColor) - progressBox := tview.NewTable() - progressBox.SetBorder(true).SetBorderColor(theme.BorderColor) + playStateBox := tview.NewTable() + playStateBox.SetBorder(true).SetBorderColor(theme.BorderColor) p := &TrackPage{ //editForm: form, - shelf: shelf, - tracks: shelf.Tracks(), - player: pl, - logBox: statusBar, - trackList: trackList, - progressBox: progressBox, + shelf: shelf, + tracks: shelf.Tracks(), + player: pl, + logBox: statusBar, + trackList: trackList, + playStateBox: playStateBox, + statusBox: tview.NewTable(), } return p @@ -99,7 +104,8 @@ func (t *TrackPage) Page(ctx context.Context) tview.Primitive { main := tview.NewFlex().SetDirection(tview.FlexRow). AddItem(t.trackList, 0, 3, true). - AddItem(t.progressBox, 6, 1, false). + AddItem(t.playStateBox, 5, 1, false). + AddItem(t.statusBox, 1, 1, false). AddItem(t.logBox, 1, 1, false) // Create the layout. @@ -457,14 +463,16 @@ func (t *TrackPage) currentlyPlayingInputCapture(event *tcell.EventKey) *tcell.E t.currentlyPlayingController.VolumeUp() case "-": t.currentlyPlayingController.VolumeDown() + case "S": + t.shuffleToggle() case "+": t.currentlyPlayingController.SpeedUp() case "_": t.currentlyPlayingController.SpeedDown() case "]": - t.skipForward(1) + t.skip(1) case "[": - t.skipForward(-1) + t.skip(-1) case "?": log.Trace("switching to help page") pages.SwitchToPage("help") @@ -475,6 +483,18 @@ func (t *TrackPage) currentlyPlayingInputCapture(event *tcell.EventKey) *tcell.E return event } +func (t *TrackPage) shuffleToggle() { + // thread safe? nope! + t.shuffle = !t.shuffle + log.WithField("enabled", t.shuffle).Debug("toggling shuffle") + + if t.shuffle { + t.statusBox.SetCell(0, 0, tview.NewTableCell(shuffleIconOn+" Shuffle: On")) + } else { + t.statusBox.SetCellSimple(0, 0, "") + } +} + func (t *TrackPage) pauseToggle() { if t.currentlyPlayingController == nil { log.Debug("cannot pause, nothing currently playing") @@ -592,29 +612,34 @@ func (t *TrackPage) checkCurrentlyPlaying() { return } - prog, err := t.currentlyPlayingController.Progress() + ps, err := t.currentlyPlayingController.PlayState() if err != nil { - log.WithError(err).Error("could not get audio progress") + log.WithError(err).Error("could not get audio play state") } - t.updateProgress(prog, t.currentlyPlayingTrack) + t.updatePlayState(ps, t.currentlyPlayingTrack) // check if audio has stopped - if prog.Finished { + if ps.Finished { log.Debug("track has finished playing") // move to next track - t.skipForward(1) + t.skip(1) } } -// skipForward skips forward/backward on the playlist. count can be negative to go backward. +// skip skips forward/backward on the playlist. count can be negative to go backward. // // TODO: add unit tests for next track logic -func (t *TrackPage) skipForward(count int) { +func (t *TrackPage) skip(count int) { // attempt to play the next track available nextRow := t.currentlyPlayingRow + count + // if shuffling, choose one at random + if t.shuffle { + nextRow = rand.Intn(len(t.tracks)) + } + // if skipping too far ahead, go to beginning if nextRow <= 0 { nextRow = len(t.tracks) @@ -629,48 +654,44 @@ func (t *TrackPage) skipForward(count int) { "currentlyPlayingRow": t.currentlyPlayingRow, "nextRow": nextRow, "totalTracks": len(t.tracks), - "skipForward": count, - }).Debug("skipping forward") + "skip": count, + }).Debug("skipping to next track") t.cellChosen(nextRow, columnStatus) } -func (t *TrackPage) updateProgress(prog player.PlayState, track *library.Track) { - percentageComplete := int(prog.Progress * 100) +func (t *TrackPage) updatePlayState(ps player.PlayState, track *library.Track) { + percentageComplete := int(ps.Progress * 100) log.WithFields(log.Fields{ - "progress": prog.Progress, - "position": prog.Position, - "volume": prog.Volume, - "speed": prog.Speed, + "progress": ps.Progress, + "position": ps.Position, + "volume": ps.Volume, + "speed": ps.Speed, "track": track.Title, "goroutines": runtime.NumGoroutine(), - }).Trace("progress") + }).Trace("play state update") app.QueueUpdateDraw(func() { - t.progressBox.SetCell(0, 0, tview.NewTableCell("Title")) - t.progressBox.SetCell(0, 1, &tview.TableCell{Text: track.Title, Color: theme.TertiaryTextColor}) - t.progressBox.SetCell(1, 0, tview.NewTableCell("Album")) - t.progressBox.SetCell(1, 1, &tview.TableCell{Text: track.Album, Color: theme.TertiaryTextColor}) - t.progressBox.SetCell(2, 0, tview.NewTableCell("Artist")) - t.progressBox.SetCell(2, 1, &tview.TableCell{Text: track.Artist, Color: theme.TertiaryTextColor}) - - t.progressBox.SetCell(0, 2, tview.NewTableCell("Progress")) - t.progressBox.SetCell(0, 3, &tview.TableCell{Text: fmt.Sprintf("%s %d%%", prog.Position, percentageComplete), Color: theme.TertiaryTextColor}) - t.progressBox.SetCell(1, 2, &tview.TableCell{Text: "Volume"}) - t.progressBox.SetCell(1, 2, tview.NewTableCell("Volume")) - t.progressBox.SetCell(1, 3, &tview.TableCell{Text: prog.Volume, Color: theme.TertiaryTextColor}) - t.progressBox.SetCell(2, 2, tview.NewTableCell("Speed")) - t.progressBox.SetCell(2, 3, &tview.TableCell{Text: prog.Speed, Color: theme.TertiaryTextColor}) - - t.progressBox.SetCell(3, 0, tview.NewTableCell("Path")) - t.progressBox.SetCell(3, 1, &tview.TableCell{Text: track.Path, Color: theme.TertiaryTextColor}) - + t.playStateBox.SetCell(0, 0, tview.NewTableCell("Title")) + t.playStateBox.SetCell(0, 1, &tview.TableCell{Text: track.Title, Color: theme.TertiaryTextColor}) + t.playStateBox.SetCell(1, 0, tview.NewTableCell("Album")) + t.playStateBox.SetCell(1, 1, &tview.TableCell{Text: track.Album, Color: theme.TertiaryTextColor}) + t.playStateBox.SetCell(2, 0, tview.NewTableCell("Artist")) + t.playStateBox.SetCell(2, 1, &tview.TableCell{Text: track.Artist, Color: theme.TertiaryTextColor}) + + t.playStateBox.SetCell(0, 2, tview.NewTableCell("Progress")) + t.playStateBox.SetCell(0, 3, &tview.TableCell{Text: fmt.Sprintf("%s %d%%", ps.Position, percentageComplete), Color: theme.TertiaryTextColor}) + t.playStateBox.SetCell(1, 2, &tview.TableCell{Text: "Volume"}) + t.playStateBox.SetCell(1, 2, tview.NewTableCell("Volume")) + t.playStateBox.SetCell(1, 3, &tview.TableCell{Text: ps.Volume, Color: theme.TertiaryTextColor}) + t.playStateBox.SetCell(2, 2, tview.NewTableCell("Speed")) + t.playStateBox.SetCell(2, 3, &tview.TableCell{Text: ps.Speed, Color: theme.TertiaryTextColor}) }) } func (t *TrackPage) welcome() { - t.progressBox.Clear(). + t.playStateBox.Clear(). SetCell(0, 0, tview.NewTableCell("grump")). SetCell(0, 1, &tview.TableCell{Text: fmt.Sprintf("%s", build.Version), Color: theme.TitleColor, NotSelectable: true}). SetCell(1, 0, tview.NewTableCell("files scanned")).