Allow keybindings to be configured

This commit is contained in:
Trevor Slocum 2020-01-22 14:54:55 -08:00
parent 6a88324acb
commit f6d6abde23
13 changed files with 325 additions and 145 deletions

View file

@ -1,3 +1,7 @@
0.1.3:
- Allow keybindings to be configured
- Add --fd option
0.1.2:
- Allow audio buffer size to be configured
- Add keybinding backspace to focus the current folder after returning to the parent folder

View file

@ -1,15 +1,56 @@
This document covers the [ditty](https://git.sr.ht/~tslocum/ditty) configuration options.
This document covers the [ditty](https://git.sr.ht/~tslocum/ditty)
configuration options and their defaults.
# Options
* **--buffer-size** Audio buffer size (default 500ms)
* **--fd** Write audio in WAV format to the specified file descriptor. This allows ditty to be used over ssh:
* `ssh ditty.rocketnine.space -t 'ditty --fd=2' 2> >(aplay --quiet)`
# Default keybindings
* Browse: J/K, Down/Up and PgDown/PgUp
* Previous: P
* Next: N
* Select: Enter
* Pause: Space
* Volume: -/+/M
* Parent folder, focus current: Backspace
* **Select** Enter
* **Pause** Space
* **Refresh** R
* **Browse parent folder and focus last** Backspace
* **Browse items** J/K, Down/Up and PageDown/PageUp
* **Previous track** P
* **Next track** N
* **Volume** -/+/M
* **Exit** Escape
# config.yaml
# Default ~/.config/ditty/config.yaml
TODO
```yaml
input:
select:
- 'Enter'
pause:
- 'Space'
refresh:
- 'r'
browse-parent:
- 'Backspace'
volume-mute:
- 'm'
volume-down:
- '-'
volume-up:
- '+'
previous-item:
- 'Up'
- 'k'
next-item:
- 'Down'
- 'j'
previous-page:
- 'PageUp'
next-page:
- 'PageDown'
previous-track:
- 'p'
next-track:
- 'n'
exit:
- 'Escape'
```

View file

@ -20,9 +20,13 @@ Choose one of the following methods:
### Compile
```
GO111MODULE=on go get git.sr.ht/~tslocum/ditty
go get git.sr.ht/~tslocum/ditty
```
## Dependencies
ditty is powered by [beep](https://github.com/faiface/beep).
## Documentation
See [CONFIGURATION.md](https://man.sr.ht/~tslocum/ditty/CONFIGURATION.md) for default keybindings.

View file

@ -20,7 +20,7 @@ import (
"github.com/faiface/beep/wav"
)
const DefaultBufferSize = 500 * time.Millisecond
const defaultBufferSize = 500 * time.Millisecond
var (
playingFileName string
@ -40,14 +40,14 @@ var (
audioLock = new(sync.Mutex)
)
type AudioFile struct {
type audioFile struct {
File *os.File
Streamer beep.StreamSeekCloser
Format beep.Format
Metadata *Metadata
Metadata *metadata
}
func openFile(filePath string, metadata *Metadata) (*AudioFile, error) {
func openFile(filePath string, metadata *metadata) (*audioFile, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
@ -81,11 +81,11 @@ func openFile(filePath string, metadata *Metadata) (*AudioFile, error) {
return nil, err
}
a := AudioFile{File: f, Streamer: streamer, Format: format, Metadata: metadata}
a := audioFile{File: f, Streamer: streamer, Format: format, Metadata: metadata}
return &a, nil
}
func play(audioFile *AudioFile) {
func play(audioFile *audioFile) {
audioLock.Lock()
defer audioLock.Unlock()
@ -121,36 +121,48 @@ func play(audioFile *AudioFile) {
speaker.Clear()
}
var (
vol float64
silent bool
)
streamer := beep.Seq(audioFile.Streamer, beep.Callback(func() {
if playingFileID != thisFileID {
return
}
go nextTrack()
}))
speaker.Lock()
if volume != nil {
vol = volume.Volume
silent = volume.Silent
volume.Streamer = streamer
ctrl.Paused = false
} else {
volume = &effects.Volume{
Streamer: streamer,
Base: volumeBase,
Volume: 0.0,
Silent: false,
}
ctrl = &beep.Ctrl{
Streamer: volume,
Paused: false,
}
}
speaker.Unlock()
volume = &effects.Volume{
Streamer: beep.Seq(audioFile.Streamer, beep.Callback(func() {
if playingFileID != thisFileID {
return
}
if streamFdInt >= 0 {
if streamFd == nil {
streamFd = os.NewFile(uintptr(streamFdInt), "out")
go nextTrack()
})),
Base: volumeBase,
Volume: vol,
Silent: silent,
go func() {
for {
_ = wav.Encode(streamFd, ctrl, playingFormat)
time.Sleep(250 * time.Millisecond)
}
}()
}
} else {
speaker.Play(ctrl)
}
ctrl = &beep.Ctrl{
Streamer: volume,
Paused: false,
}
speaker.Play(ctrl)
go app.QueueUpdateDraw(func() {
updateMain()
updateQueue()
@ -276,6 +288,14 @@ func setVolume(vol float64) {
go app.QueueUpdateDraw(updateStatus)
}
func decreaseVolume() {
adjustVolume(-0.5)
}
func increaseVolume() {
adjustVolume(0.5)
}
func volumeUpdated() {
if volume.Volume <= -7.5 {
volume.Volume = -7.5

48
config.go Normal file
View file

@ -0,0 +1,48 @@
package main
import (
"fmt"
"io/ioutil"
"os"
"path"
"gopkg.in/yaml.v2"
)
type appConfig struct {
Input map[string][]string
}
var config = &appConfig{}
func defaultConfigPath() string {
homedir, err := os.UserHomeDir()
if err == nil && homedir != "" {
return path.Join(homedir, ".config", "ditty", "config.yaml")
}
return ""
}
func readConfig(configPath string) error {
if configPath == "" {
configPath = defaultConfigPath()
if configPath == "" {
return nil
} else if _, err := os.Stat(configPath); os.IsNotExist(err) {
return nil
}
}
configData, err := ioutil.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read file: %s", err)
}
err = yaml.Unmarshal(configData, config)
if err != nil {
return fmt.Errorf("failed to parse file: %s", err)
}
return nil
}

3
go.mod
View file

@ -3,7 +3,7 @@ module git.sr.ht/~tslocum/ditty
go 1.13
require (
git.sr.ht/~tslocum/cbind v0.0.0-20200122005705-c4f326764399
git.sr.ht/~tslocum/cbind v0.0.0-20200122224255-ab6e3ebbd35c
git.sr.ht/~tslocum/cview v1.4.1-0.20200117063451-51704b98449e
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8
github.com/faiface/beep v1.0.2
@ -17,4 +17,5 @@ require (
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a // indirect
golang.org/x/image v0.0.0-20200119044424-58c23975cae1 // indirect
golang.org/x/mobile v0.0.0-20200121160505-1d4ecbb920e2 // indirect
gopkg.in/yaml.v2 v2.2.7
)

12
go.sum
View file

@ -1,6 +1,6 @@
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
git.sr.ht/~tslocum/cbind v0.0.0-20200122005705-c4f326764399 h1:r7VQ34sqmzSQzuWS7nrHqaR92ARCN62s032M7cJ9Heo=
git.sr.ht/~tslocum/cbind v0.0.0-20200122005705-c4f326764399/go.mod h1:NE2mliwcbn3v+eT4rCiQsQYEUftKzGXMfii83F385rA=
git.sr.ht/~tslocum/cbind v0.0.0-20200122224255-ab6e3ebbd35c h1:6OaMHWvMXT/jygdKXDbeqEmPRQWRkM2udYypJt1jQrA=
git.sr.ht/~tslocum/cbind v0.0.0-20200122224255-ab6e3ebbd35c/go.mod h1:pwSeemOTUIOImiuZhlY2dWXpvFqI7woKg0nJ2a50XPA=
git.sr.ht/~tslocum/cview v1.4.1-0.20200117063451-51704b98449e h1:gKRY1WnpfiBLT7ypBPz9njkkqBXrjMfru1QfnhFDnG8=
git.sr.ht/~tslocum/cview v1.4.1-0.20200117063451-51704b98449e/go.mod h1:TLTjvAd3pw6MqV6SaBMpxOdOdODW4O2gtQJ3B3H6PoU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
@ -104,8 +104,8 @@ golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200103143344-a1369afcdac7 h1:/W9OPMnnpmFXHYkcp2rQsbFUbRlRzfECQjmAFiOyHE8=
golang.org/x/sys v0.0.0-20200103143344-a1369afcdac7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200121082415-34d275377bf9 h1:N19i1HjUnR7TF7rMt8O4p3dLvqvmYyzB6ifMFmrbY50=
golang.org/x/sys v0.0.0-20200121082415-34d275377bf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82 h1:ywK/j/KkyTHcdyYSZNXGjMwgmDSfjglYZ3vStQ/gSCU=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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=
@ -116,3 +116,7 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0/go.mod h1:OdE7CF6DbADk7lN8LIKRzRJTTZXIjtWgA5THM5lhBAw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

11
gui.go
View file

@ -25,7 +25,7 @@ var (
topstatusbuf *cview.TextView
bottomstatusbuf *cview.TextView
mainBufferFiles []*LibraryEntry
mainBufferFiles []*libraryEntry
mainBufferCursor int
mainBufferDirectory string
mainBufferOrigin int
@ -50,7 +50,6 @@ func initTUI() error {
app.EnableMouse()
setDefaultKeyBinds()
app.SetInputCapture(inputConfig.Capture)
app.SetAfterResizeFunc(handleResize)
@ -94,9 +93,13 @@ func browseFolder(browse string) {
return
}
placeCursorAtTop := mainBufferCursor == 0
mainBufferFiles = scanFolder(browse)
mainBufferCursor = 0
if !placeCursorAtTop && len(mainBufferFiles) > 0 {
mainBufferCursor = 1
} else {
mainBufferCursor = 0
}
mainBufferOrigin = 0
mainBufferDirectory = browse

View file

@ -1,93 +1,100 @@
package main
import (
"fmt"
"strings"
"git.sr.ht/~tslocum/cbind"
"github.com/gdamore/tcell"
)
const (
actionSelect = "select"
actionPause = "pause"
actionRefresh = "refresh"
actionBrowseParent = "browse-parent"
actionVolumeMute = "volume-mute"
actionVolumeDown = "volume-down"
actionVolumeUp = "volume-up"
actionPreviousItem = "previous-item"
actionNextItem = "next-item"
actionPreviousPage = "previous-page"
actionNextPage = "next-page"
actionPreviousTrack = "previous-track"
actionNextTrack = "next-track"
actionExit = "exit"
)
var actionHandlers = map[string]func(){
actionSelect: listSelect,
actionPause: pause,
actionRefresh: listRefresh,
actionBrowseParent: browseParent,
actionVolumeMute: toggleMute,
actionVolumeDown: decreaseVolume,
actionVolumeUp: increaseVolume,
actionPreviousItem: listPrevious,
actionNextItem: listNext,
actionPreviousPage: listPreviousPage,
actionNextPage: listNextPage,
actionPreviousTrack: skipPrevious,
actionNextTrack: skipNext,
actionExit: exit,
}
var inputConfig = cbind.NewConfiguration()
func setDefaultKeyBinds() {
inputConfig.SetKey(tcell.ModNone, tcell.KeyUp, func(ev *tcell.EventKey) *tcell.EventKey {
listPrevious()
func wrapEventHandler(f func()) func(_ *tcell.EventKey) *tcell.EventKey {
return func(_ *tcell.EventKey) *tcell.EventKey {
f()
return nil
})
inputConfig.SetKey(tcell.ModNone, tcell.KeyDown, func(ev *tcell.EventKey) *tcell.EventKey {
listNext()
return nil
})
inputConfig.SetRune(tcell.ModNone, 'k', func(ev *tcell.EventKey) *tcell.EventKey {
listPrevious()
return nil
})
inputConfig.SetRune(tcell.ModNone, 'j', func(ev *tcell.EventKey) *tcell.EventKey {
listNext()
return nil
})
inputConfig.SetKey(tcell.ModNone, tcell.KeyEnter, func(ev *tcell.EventKey) *tcell.EventKey {
go listSelect()
return nil
})
inputConfig.SetRune(tcell.ModNone, ' ', func(ev *tcell.EventKey) *tcell.EventKey {
pause()
return nil
})
inputConfig.SetRune(tcell.ModNone, 'p', func(ev *tcell.EventKey) *tcell.EventKey {
skipPrevious()
return nil
})
inputConfig.SetRune(tcell.ModNone, 'n', func(ev *tcell.EventKey) *tcell.EventKey {
skipNext()
return nil
})
inputConfig.SetRune(tcell.ModNone, '-', func(ev *tcell.EventKey) *tcell.EventKey {
adjustVolume(-0.5)
return nil
})
inputConfig.SetRune(tcell.ModNone, '+', func(ev *tcell.EventKey) *tcell.EventKey {
adjustVolume(0.5)
return nil
})
inputConfig.SetRune(tcell.ModNone, 'm', func(ev *tcell.EventKey) *tcell.EventKey {
toggleMute()
return nil
})
inputConfig.SetKey(tcell.ModNone, tcell.KeyEscape, func(ev *tcell.EventKey) *tcell.EventKey {
done <- true
return nil
})
inputConfig.SetKey(tcell.ModNone, tcell.KeyBackspace, func(ev *tcell.EventKey) *tcell.EventKey {
browseParent()
return nil
})
inputConfig.SetKey(tcell.ModNone, tcell.KeyBackspace2, func(ev *tcell.EventKey) *tcell.EventKey {
browseParent()
return nil
})
inputConfig.SetKey(tcell.ModNone, tcell.KeyPgUp, func(ev *tcell.EventKey) *tcell.EventKey {
listPreviousPage()
return nil
})
inputConfig.SetKey(tcell.ModNone, tcell.KeyPgDn, func(ev *tcell.EventKey) *tcell.EventKey {
listNextPage()
return nil
})
// TODO:
// Queue non-recursively - q
// Queue recursively - Q
}
}
func setKeyBinds() error {
if len(config.Input) == 0 {
setDefaultKeyBinds()
}
for a, keys := range config.Input {
a = strings.ToLower(a)
handler := actionHandlers[a]
if handler == nil {
return fmt.Errorf("failed to set keybind for %s: unknown action", a)
}
for _, k := range keys {
mod, key, ch, err := cbind.Decode(k)
if err != nil {
return fmt.Errorf("failed to set keybind %s for %s: %s", k, a, err)
}
if key == tcell.KeyRune {
inputConfig.SetRune(mod, ch, wrapEventHandler(handler))
} else {
inputConfig.SetKey(mod, key, wrapEventHandler(handler))
}
}
}
return nil
}
func setDefaultKeyBinds() {
config.Input = map[string][]string{
actionSelect: {"Enter"},
actionPause: {"Space"},
actionRefresh: {"r"},
actionBrowseParent: {"Backspace"},
actionVolumeMute: {"m"},
actionVolumeDown: {"-"},
actionVolumeUp: {"+"},
actionPreviousItem: {"Up", "k"},
actionNextItem: {"Down", "j"},
actionPreviousPage: {"PageUp"},
actionNextPage: {"PageDown"},
actionPreviousTrack: {"p"},
actionNextTrack: {"n"},
actionExit: {"Escape"},
}
}

View file

@ -59,11 +59,11 @@ func listSelect() {
go app.QueueUpdateDraw(updateStatus)
}
func selectedEntry() *LibraryEntry {
func selectedEntry() *libraryEntry {
return mainBufferFiles[mainBufferCursor-1]
}
func offsetEntry(offset int) *LibraryEntry {
func offsetEntry(offset int) *libraryEntry {
return mainBufferFiles[(mainBufferCursor-1)+offset]
}
@ -102,3 +102,10 @@ func listNextPage() {
go app.QueueUpdateDraw(updateMain)
}
func listRefresh() {
// TODO Remember cursor position
d := mainBufferDirectory
mainBufferDirectory = ""
browseFolder(d)
}

View file

@ -37,7 +37,18 @@ func handleMouse(event *cview.EventMouse) *cview.EventMouse {
audioLock.Lock()
speaker.Lock()
seekTo := int(float64(playingStreamer.Len()) * (float64(mouseX-seekStart) / float64(seekEnd-seekStart)))
if playingStreamer == nil {
speaker.Unlock()
audioLock.Unlock()
return nil
}
pos := float64(mouseX-seekStart) / float64(seekEnd-seekStart)
if pos > 1 {
pos = 1
}
seekTo := int(float64(playingStreamer.Len()-1) * pos)
_ = playingStreamer.Seek(seekTo) // Ignore seek errors
speaker.Unlock()
audioLock.Unlock()
@ -52,6 +63,13 @@ func handleMouse(event *cview.EventMouse) *cview.EventMouse {
if mouseX-volumeStart <= 3 {
audioLock.Lock()
speaker.Lock()
if volume == nil {
speaker.Unlock()
audioLock.Unlock()
return nil
}
volume.Silent = !volume.Silent
speaker.Unlock()
audioLock.Unlock()

View file

@ -11,15 +11,15 @@ import (
"github.com/dhowden/tag"
)
type Metadata struct {
type metadata struct {
Title string
Artist string
Album string
Track int
}
func readMetadata(f *os.File) *Metadata {
var metadata Metadata
func readMetadata(f *os.File) *metadata {
var metadata metadata
m, err := tag.ReadFrom(f)
if err != nil || m.Title() == "" {
@ -34,12 +34,12 @@ func readMetadata(f *os.File) *Metadata {
return &metadata
}
type LibraryEntry struct {
type libraryEntry struct {
File os.FileInfo
Metadata *Metadata
Metadata *metadata
}
func (e *LibraryEntry) String() string {
func (e *libraryEntry) String() string {
if e.Metadata.Title != "" {
if e.Metadata.Artist != "" {
return e.Metadata.Artist + " - " + e.Metadata.Title
@ -51,16 +51,16 @@ func (e *LibraryEntry) String() string {
return e.File.Name()
}
func scanFolder(scanPath string) []*LibraryEntry {
func scanFolder(scanPath string) []*libraryEntry {
files, err := ioutil.ReadDir(scanPath)
if err != nil {
log.Fatalf("failed to scan %s: %s", scanPath, err)
}
var entries []*LibraryEntry
var entries []*libraryEntry
for _, fileInfo := range files {
if fileInfo.IsDir() {
entries = append(entries, &LibraryEntry{File: fileInfo, Metadata: &Metadata{Title: fileInfo.Name()}})
entries = append(entries, &libraryEntry{File: fileInfo, Metadata: &metadata{Title: fileInfo.Name()}})
continue
} else if !supportedFormat(fileInfo.Name()) {
continue
@ -73,7 +73,7 @@ func scanFolder(scanPath string) []*LibraryEntry {
metadata := readMetadata(f)
f.Close()
entries = append(entries, &LibraryEntry{File: fileInfo, Metadata: metadata})
entries = append(entries, &libraryEntry{File: fileInfo, Metadata: metadata})
}
sort.Slice(entries, func(i, j int) bool {

27
main.go
View file

@ -27,19 +27,28 @@ Copyright (c) 2020 Trevor Slocum <trevor@rocketnine.space>
)
var (
configPath string
printVersionInfo bool
bufferSize time.Duration
debugAddress string
cpuProfile string
streamFdInt int
streamFd *os.File
done = make(chan bool)
)
func exit() {
done <- true
}
func main() {
log.SetFlags(0)
flag.StringVar(&configPath, "config", "", "path to configuration file")
flag.BoolVar(&printVersionInfo, "version", false, "print version information and exit")
flag.DurationVar(&bufferSize, "buffer-size", DefaultBufferSize, "audio buffer size")
flag.IntVar(&streamFdInt, "fd", -1, "stream audio to file descriptor")
flag.DurationVar(&bufferSize, "buffer-size", defaultBufferSize, "audio buffer size")
flag.StringVar(&debugAddress, "debug-address", "", "address to serve debug info")
flag.StringVar(&cpuProfile, "cpu-profile", "", "path to save CPU profiling")
flag.Parse()
@ -68,7 +77,17 @@ func main() {
defer pprof.StopCPUProfile()
}
err := initTUI()
err := readConfig(configPath)
if err != nil {
log.Fatalf("failed to read configuration file: %s", err)
}
err = setKeyBinds()
if err != nil {
log.Fatalf("failed to set keybinds: %s", err)
}
err = initTUI()
if err != nil {
log.Fatalf("failed to initialize terminal user interface: %s", err)
}
@ -136,6 +155,10 @@ func main() {
for {
select {
case <-done:
if streamFd != nil {
streamFd.Close()
}
return
case <-t.C:
app.QueueUpdateDraw(updateStatus)