├── .gitignore ├── LICENSE ├── README.md ├── audio.go ├── config.go ├── go.mod ├── go.sum └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Aritra Sen 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # teamus 2 | 3 | Dead simple terminal music player written in go. 4 | 5 | https://github.com/aretrosen/teamus/assets/125266845/54d2c00a-dd53-49da-a0b1-4c4cfb5b147f 6 | 7 | This shows the mouse, seek, play/pause and other features. 8 | 9 | (I had to search for music with no copyright. The two music played are Rumors by Neffex, and Cold Funk by Kevin MacLeod. The recording app is Kooha.) 10 | 11 | 12 | ## Purpose 13 | 14 | _Pretty Keyboard centric Terminal Music Player in Go using the popular Charm TUI, with Vim Keybindings_ 15 | 16 | Most Terminal Music Player today is either not pretty, or have the most unintuitive key bindings. This one is meant to be pretty, without being much resource intensive. Written in Go, it can search for all audio files in your Music Folder, and then Play the songs on `enter`. Check out the project, and click the question mark `?` to get more help. I love `cmus`, but it isn't pretty, so now we get `teamus`. 17 | 18 | ## Build Instructions 19 | 20 | For now, you have to have the golang binary installed. For that, check out, check out ["How to Build and Install Go Programs"](https://www.digitalocean.com/community/tutorials/how-to-build-and-install-go-programs). After that just clone this repository, and type `go run .`. Or maybe you can build the repository using `go build`. Also, you can test this repo by using the command `go run github.com/aretrosen/teamus@latest`. 21 | 22 | ~**NOTE:** This was made in a Linux environment, with a Music directory. There are plans to add directories via json/yaml file later, but for now you **need** to have the `$HOME/Music` directory.~ 23 | As mentioned by @LeonardsonCC, this works perfectly fine on Windows, and now it does have the capability to scan other directories which is configured as shown below. 24 | 25 | ## Configuration 26 | 27 | You can specify a list of song directories by creating a JSON file at `$HOME/.config/teamus/teamus.json` or `$HOME/.teamus.json`. 28 | File example: 29 | 30 | ```json 31 | { 32 | "directories": ["/home/user/Music", "/home/user/My_Musics"] 33 | } 34 | ``` 35 | 36 | ## To-dos 37 | 38 | - [x] Seamless Playing 39 | - [ ] Shuffle 40 | - [x] Repeat Music / Playlist 41 | - [x] Choose files from multiple directories, specified in json. 42 | - [ ] Load from database, refresh only on key press. 43 | 44 | ### Additional Features 45 | 46 | - [ ] show lyrics on key press. 47 | - [x] basic mouse support added now. If more support is needed, I might consider using bubblezone module. 48 | - [ ] play `m4a` audio. Currently, even not all `opus` files run correctly. I might have to write many of the audio frameworks from scratch. 49 | 50 | ## Collaboration 51 | 52 | You're welcome to write features and report issues for this project. 53 | -------------------------------------------------------------------------------- /audio.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "time" 7 | 8 | "github.com/hajimehoshi/ebiten/v2/audio" 9 | "github.com/hajimehoshi/ebiten/v2/audio/mp3" 10 | "github.com/hajimehoshi/ebiten/v2/audio/vorbis" 11 | ) 12 | 13 | const ( 14 | sampleRate = 48000 15 | bytesPerSample = 4 16 | ) 17 | 18 | type musicType int 19 | 20 | const ( 21 | typeOgg musicType = iota 22 | typeMP3 23 | typeWav 24 | typeFlac 25 | ) 26 | 27 | type Player struct { 28 | audioContext *audio.Context 29 | audioPlayer *audio.Player 30 | totalStr string 31 | total time.Duration 32 | volume128 int 33 | musicType musicType 34 | } 35 | 36 | func NewPlayer(f *os.File, audioContext *audio.Context, musicType musicType) (*Player, error) { 37 | type audioStream interface { 38 | io.ReadSeeker 39 | Length() int64 40 | } 41 | var s audioStream 42 | 43 | switch musicType { 44 | case typeOgg: 45 | var err error 46 | s, err = vorbis.DecodeWithSampleRate(sampleRate, f) 47 | if err != nil { 48 | return nil, err 49 | } 50 | case typeMP3: 51 | var err error 52 | s, err = mp3.DecodeWithSampleRate(sampleRate, f) 53 | if err != nil { 54 | return nil, err 55 | } 56 | default: 57 | panic("not reached") 58 | } 59 | 60 | p, err := audioContext.NewPlayer(s) 61 | if err != nil { 62 | return nil, err 63 | } 64 | player := &Player{ 65 | audioContext: audioContext, 66 | audioPlayer: p, 67 | total: time.Second * time.Duration(s.Length()) / bytesPerSample / sampleRate, 68 | volume128: 128, 69 | musicType: musicType, 70 | } 71 | if player.total == 0 { 72 | player.total = 1 73 | } 74 | player.totalStr = player.total.Truncate(time.Second).String() 75 | player.audioPlayer.Play() 76 | return player, nil 77 | } 78 | 79 | func (p *Player) Close() error { 80 | return p.audioPlayer.Close() 81 | } 82 | 83 | func (p *Player) TogglePause() string { 84 | if p.audioPlayer.IsPlaying() { 85 | p.audioPlayer.Pause() 86 | return "  " 87 | } 88 | p.audioPlayer.Play() 89 | return "  " 90 | } 91 | 92 | // HACK: I cannot rewind the stream while paused, so I seek the file manually to 93 | // start. 94 | func (p *Player) Rewind(idx int) { 95 | if p.audioPlayer.IsPlaying() { 96 | p.audioPlayer.Rewind() 97 | p.Close() 98 | } else { 99 | fileList[idx].file.Seek(0, 0) 100 | } 101 | } 102 | 103 | func (p *Player) SetVolume(chg int) { 104 | p.volume128 += chg 105 | if 128 < p.volume128 { 106 | p.volume128 = 128 107 | } 108 | if p.volume128 < 0 { 109 | p.volume128 = 0 110 | } 111 | p.audioPlayer.SetVolume(float64(p.volume128) / 128) 112 | } 113 | 114 | func (p *Player) SeekTo(frac float64) error { 115 | pos := time.Duration(float64(p.total.Milliseconds()) * frac * float64(time.Millisecond)) 116 | if pos > p.total { 117 | pos = p.total 118 | } 119 | if pos < 0 { 120 | pos = 0 121 | } 122 | if err := p.audioPlayer.Seek(pos); err != nil { 123 | return err 124 | } 125 | return nil 126 | } 127 | 128 | func (p *Player) Seek(chg int) error { 129 | pos := p.audioPlayer.Current() + time.Second*time.Duration(chg) 130 | if pos > p.total { 131 | pos = p.total 132 | } 133 | if pos < 0 { 134 | pos = 0 135 | } 136 | if err := p.audioPlayer.Seek(pos); err != nil { 137 | return err 138 | } 139 | return nil 140 | } 141 | 142 | func (p *Player) Update() (string, float64) { 143 | current := p.audioPlayer.Current() 144 | timeFrac := float64(current.Milliseconds()) / float64(p.total.Milliseconds()) 145 | return current.Truncate(time.Second).String() + " / " + p.totalStr, timeFrac 146 | } 147 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "os" 7 | "path" 8 | ) 9 | 10 | type Config struct { 11 | Directories []string `json:"directories"` 12 | } 13 | 14 | func LoadConfig() *Config { 15 | content := findConfigFile() 16 | if content == nil { 17 | return defaultConfig() 18 | } 19 | 20 | conf := new(Config) 21 | err := json.Unmarshal(content, conf) 22 | if err != nil { 23 | return defaultConfig() 24 | } 25 | 26 | return conf 27 | } 28 | 29 | func findConfigFile() []byte { 30 | // searches in $HOME/.config 31 | configPath := path.Join(mustUserHomeDir(), ".config", "teamus", "teamus.json") 32 | if _, err := os.Stat(configPath); !os.IsNotExist(err) { 33 | content, err := os.ReadFile(configPath) 34 | if err != nil { 35 | log.Fatal("Failed to read Config file" + err.Error()) 36 | } 37 | return content 38 | } 39 | 40 | // searches in $HOME for config file 41 | configPath = path.Join(mustUserHomeDir(), ".teamus.json") 42 | if _, err := os.Stat(configPath); !os.IsNotExist(err) { 43 | content, err := os.ReadFile(configPath) 44 | if err != nil { 45 | log.Fatal("Failed to read Config file" + err.Error()) 46 | } 47 | return content 48 | } 49 | 50 | return nil 51 | } 52 | 53 | func defaultConfig() *Config { 54 | home := mustUserHomeDir() 55 | dirs := []string{ 56 | path.Join(home, "Music"), 57 | } 58 | return &Config{ 59 | Directories: dirs, 60 | } 61 | } 62 | 63 | func mustUserHomeDir() string { 64 | home, err := os.UserHomeDir() 65 | if err != nil { 66 | log.Fatal("Cannot find Home Directory" + err.Error()) 67 | } 68 | 69 | return home 70 | } 71 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aretrosen/teamus 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.16.1 7 | github.com/charmbracelet/bubbletea v0.24.2 8 | github.com/charmbracelet/lipgloss v0.7.1 9 | github.com/dhowden/tag v0.0.0-20220618230019-adf36e896086 10 | github.com/hajimehoshi/ebiten/v2 v2.5.4 11 | ) 12 | 13 | require ( 14 | github.com/atotto/clipboard v0.1.4 // indirect 15 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 16 | github.com/charmbracelet/harmonica v0.2.0 // indirect 17 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect 18 | github.com/ebitengine/purego v0.3.2 // indirect 19 | github.com/hajimehoshi/go-mp3 v0.3.4 // indirect 20 | github.com/hajimehoshi/oto/v2 v2.4.0 // indirect 21 | github.com/jfreymuth/oggvorbis v1.0.5 // indirect 22 | github.com/jfreymuth/vorbis v1.0.2 // indirect 23 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 24 | github.com/mattn/go-isatty v0.0.19 // indirect 25 | github.com/mattn/go-localereader v0.0.1 // indirect 26 | github.com/mattn/go-runewidth v0.0.14 // indirect 27 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 28 | github.com/muesli/cancelreader v0.2.2 // indirect 29 | github.com/muesli/reflow v0.3.0 // indirect 30 | github.com/muesli/termenv v0.15.1 // indirect 31 | github.com/rivo/uniseg v0.4.4 // indirect 32 | github.com/sahilm/fuzzy v0.1.0 // indirect 33 | golang.org/x/sync v0.2.0 // indirect 34 | golang.org/x/sys v0.8.0 // indirect 35 | golang.org/x/term v0.8.0 // indirect 36 | golang.org/x/text v0.9.0 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 5 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 6 | github.com/charmbracelet/bubbles v0.15.0 h1:c5vZ3woHV5W2b8YZI1q7v4ZNQaPetfHuoHzx+56Z6TI= 7 | github.com/charmbracelet/bubbles v0.15.0/go.mod h1:Y7gSFbBzlMpUDR/XM9MhZI374Q+1p1kluf1uLl8iK74= 8 | github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= 9 | github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= 10 | github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU= 11 | github.com/charmbracelet/bubbletea v0.24.0 h1:l8PHrft/GIeikDPCUhQe53AJrDD8xGSn0Agirh8xbe8= 12 | github.com/charmbracelet/bubbletea v0.24.0/go.mod h1:rK3g/2+T8vOSEkNHvtq40umJpeVYDn6bLaqbgzhL/hg= 13 | github.com/charmbracelet/bubbletea v0.24.1 h1:LpdYfnu+Qc6XtvMz6d/6rRY71yttHTP5HtrjMgWvixc= 14 | github.com/charmbracelet/bubbletea v0.24.1/go.mod h1:rK3g/2+T8vOSEkNHvtq40umJpeVYDn6bLaqbgzhL/hg= 15 | github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= 16 | github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= 17 | github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= 18 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 19 | github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= 20 | github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= 21 | github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= 22 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 23 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= 24 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 25 | github.com/dhowden/tag v0.0.0-20220618230019-adf36e896086 h1:ORubSQoKnncsBnR4zD9CuYFJCPOCuSNEpWEZrDdBXkc= 26 | github.com/dhowden/tag v0.0.0-20220618230019-adf36e896086/go.mod h1:Z3Lomva4pyMWYezjMAU5QWRh0p1VvO4199OHlFnyKkM= 27 | github.com/ebitengine/purego v0.3.2 h1:+pV+tskAkn/bxEcUzGtDfw2VAe3bRQ26kdzFjPPrCww= 28 | github.com/ebitengine/purego v0.3.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 29 | github.com/hajimehoshi/ebiten/v2 v2.5.4 h1:NvUU6LvVc6oc+u+rD9KfHMjruRdpNwbpalVUINNXufU= 30 | github.com/hajimehoshi/ebiten/v2 v2.5.4/go.mod h1:mnHSOVysTr/nUZrN1lBTRqhK4NG+T9NR3JsJP2rCppk= 31 | github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= 32 | github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= 33 | github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= 34 | github.com/hajimehoshi/oto/v2 v2.4.0 h1:2A8QvGJZ7nXwcfIIthaqWdzDn9Ul/er6oASiKcsfiLg= 35 | github.com/hajimehoshi/oto/v2 v2.4.0/go.mod h1:74bRBgfJaEDpP3NyVyHIYBJE4DgzJ2IP5l/st5qcJog= 36 | github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ= 37 | github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII= 38 | github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE= 39 | github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ= 40 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 41 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 42 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 43 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 44 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 45 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 46 | github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= 47 | github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 48 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 49 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 50 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 51 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 52 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 53 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 54 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 55 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 56 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 57 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 58 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 59 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 60 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 61 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 62 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 63 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 64 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 65 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 66 | github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= 67 | github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= 68 | github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= 69 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 70 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 71 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 72 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 73 | github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= 74 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 75 | golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= 76 | golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 77 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 78 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 79 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 80 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 81 | golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 82 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 83 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 84 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 85 | golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= 86 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 87 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 88 | golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= 89 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 90 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 91 | golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= 92 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 93 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 94 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/fs" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/charmbracelet/bubbles/key" 11 | "github.com/charmbracelet/bubbles/list" 12 | "github.com/charmbracelet/bubbles/progress" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/lipgloss" 15 | "github.com/dhowden/tag" 16 | "github.com/hajimehoshi/ebiten/v2/audio" 17 | ) 18 | 19 | type track struct { 20 | title string 21 | description string 22 | } 23 | 24 | func (t track) Title() string { return t.title } 25 | func (t track) FilterValue() string { return t.title + " " + t.description } 26 | func (t track) Description() string { return t.description } 27 | 28 | type audiofile struct { 29 | file *os.File 30 | filename string 31 | audioformat musicType 32 | } 33 | 34 | type ( 35 | chgVol int 36 | seekPos int 37 | ) 38 | 39 | type audioKeyMap struct { 40 | Play key.Binding 41 | TogglePause key.Binding 42 | ToggleRepeat key.Binding 43 | SeekRight key.Binding 44 | SeekLeft key.Binding 45 | Delete key.Binding 46 | MoveUp key.Binding 47 | MoveDown key.Binding 48 | VolumeUp key.Binding 49 | VolumeDown key.Binding 50 | } 51 | 52 | type model struct { 53 | list list.Model 54 | err error 55 | keys *audioKeyMap 56 | audioContext *audio.Context 57 | progress progress.Model 58 | repeat bool 59 | volumechg int 60 | seekchg int 61 | initWinWidth int 62 | } 63 | 64 | type tickMsg time.Time 65 | 66 | type mouseBtnPress int 67 | 68 | const ( 69 | mouseBtnLeft mouseBtnPress = iota 70 | mouseBtnRight 71 | mouseBtnMiddle 72 | mouseBtnNone 73 | ) 74 | 75 | var ( 76 | player *Player 77 | fileList []audiofile 78 | playstatus string 79 | wasMousePressed mouseBtnPress 80 | progressStr string 81 | currIdx int 82 | ) 83 | 84 | func newAudioKeyMap() *audioKeyMap { 85 | return &audioKeyMap{ 86 | Play: key.NewBinding( 87 | key.WithKeys("enter"), 88 | key.WithHelp("enter", "select & play"), 89 | ), 90 | TogglePause: key.NewBinding( 91 | key.WithKeys(" "), 92 | key.WithHelp("space", "toggle pause"), 93 | ), 94 | ToggleRepeat: key.NewBinding( 95 | key.WithKeys("r"), 96 | key.WithHelp("r", "toggle repeat"), 97 | ), 98 | SeekRight: key.NewBinding( 99 | key.WithKeys("right"), 100 | key.WithHelp("→", "seek right"), 101 | ), 102 | SeekLeft: key.NewBinding( 103 | key.WithKeys("left"), 104 | key.WithHelp("←", "seek left"), 105 | ), 106 | Delete: key.NewBinding( 107 | key.WithKeys("x"), 108 | key.WithHelp("x", "delete"), 109 | ), 110 | MoveUp: key.NewBinding( 111 | key.WithKeys("p"), 112 | key.WithHelp("p", "move up"), 113 | ), 114 | MoveDown: key.NewBinding( 115 | key.WithKeys("n"), 116 | key.WithHelp("n", "move later"), 117 | ), 118 | VolumeUp: key.NewBinding( 119 | key.WithKeys("up"), 120 | key.WithHelp("↑", "increase volume"), 121 | ), 122 | VolumeDown: key.NewBinding( 123 | key.WithKeys("down"), 124 | key.WithHelp("↓", "decrease volume"), 125 | ), 126 | } 127 | } 128 | 129 | func newModel(trackList []list.Item) *model { 130 | listKeys := newAudioKeyMap() 131 | newmodel := model{keys: listKeys} 132 | 133 | newmodel.list = list.New(trackList, list.NewDefaultDelegate(), 0, 0) 134 | newmodel.list.Title = "Tracks" 135 | 136 | // I needed to change default behavior as otherwise we would need to use 137 | // unintuitive keybindings 138 | newmodel.list.KeyMap.CursorDown = key.NewBinding( 139 | key.WithKeys("j"), 140 | key.WithHelp("j", "down"), 141 | ) 142 | newmodel.list.KeyMap.CursorUp = key.NewBinding( 143 | key.WithKeys("k"), 144 | key.WithHelp("k", "up"), 145 | ) 146 | newmodel.list.KeyMap.PrevPage = key.NewBinding( 147 | key.WithKeys("h", "pgup"), 148 | key.WithHelp("h/pgup", "prev page"), 149 | ) 150 | newmodel.list.KeyMap.NextPage = key.NewBinding( 151 | key.WithKeys("l", "pgdown"), 152 | key.WithHelp("h/pgdn", "next page"), 153 | ) 154 | newmodel.list.AdditionalFullHelpKeys = func() []key.Binding { 155 | return []key.Binding{ 156 | listKeys.Play, 157 | listKeys.TogglePause, 158 | listKeys.ToggleRepeat, 159 | listKeys.SeekRight, 160 | listKeys.SeekLeft, 161 | listKeys.Delete, 162 | listKeys.MoveUp, 163 | listKeys.MoveDown, 164 | listKeys.VolumeUp, 165 | listKeys.VolumeDown, 166 | } 167 | } 168 | 169 | newmodel.progress = progress.New(progress.WithDefaultGradient(), progress.WithoutPercentage()) 170 | playstatus = "  " 171 | 172 | newmodel.audioContext = audio.NewContext(sampleRate) 173 | return &newmodel 174 | } 175 | 176 | func (m model) Init() tea.Cmd { 177 | return m.tickUpd(0.0) 178 | } 179 | 180 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 181 | switch msg := msg.(type) { 182 | case tea.WindowSizeMsg: 183 | m.initWinWidth = msg.Width - 2 - len(playstatus) 184 | m.progress.Width = m.initWinWidth 185 | m.list.SetSize(msg.Width, msg.Height-2) 186 | return m, nil 187 | 188 | case tea.KeyMsg: 189 | if m.list.FilterState() == list.Filtering { 190 | break 191 | } 192 | switch { 193 | case key.Matches(msg, list.DefaultKeyMap().Quit): 194 | if player != nil { 195 | player.Close() 196 | } 197 | return m, tea.Quit 198 | 199 | case key.Matches(msg, m.keys.Play): 200 | currIdx = m.list.Index() 201 | 202 | if player != nil { 203 | player.Rewind(currIdx) 204 | } 205 | 206 | var err error 207 | player, err = NewPlayer(fileList[currIdx].file, m.audioContext, fileList[currIdx].audioformat) 208 | if err != nil { 209 | m.list.NewStatusMessage("Cannot Play Audio: " + err.Error()) 210 | progressStr = "--/--" 211 | m.progress.Width = m.initWinWidth - len(progressStr) 212 | } else { 213 | playstatus = "  " 214 | m.list.Title = m.list.SelectedItem().(track).title 215 | } 216 | 217 | return m, nil 218 | 219 | case key.Matches(msg, m.keys.VolumeUp): 220 | if player != nil { 221 | m.volumechg++ 222 | return m, tea.Tick(100*time.Millisecond, func(_ time.Time) tea.Msg { 223 | return chgVol(m.volumechg) 224 | }) 225 | } 226 | return m, nil 227 | 228 | case key.Matches(msg, m.keys.VolumeDown): 229 | if player != nil { 230 | m.volumechg-- 231 | return m, tea.Tick(100*time.Millisecond, func(_ time.Time) tea.Msg { 232 | return chgVol(m.volumechg) 233 | }) 234 | } 235 | return m, nil 236 | 237 | case key.Matches(msg, m.keys.SeekRight): 238 | if player != nil { 239 | m.seekchg++ 240 | return m, tea.Tick(100*time.Millisecond, func(_ time.Time) tea.Msg { 241 | return seekPos(m.seekchg) 242 | }) 243 | } 244 | return m, nil 245 | 246 | case key.Matches(msg, m.keys.SeekLeft): 247 | if player != nil { 248 | m.seekchg-- 249 | return m, tea.Tick(100*time.Millisecond, func(_ time.Time) tea.Msg { 250 | return seekPos(m.seekchg) 251 | }) 252 | } 253 | return m, nil 254 | 255 | case key.Matches(msg, m.keys.TogglePause): 256 | if player != nil { 257 | playstatus = player.TogglePause() 258 | } 259 | return m, nil 260 | 261 | case key.Matches(msg, m.keys.ToggleRepeat): 262 | m.repeat = !m.repeat 263 | return m, nil 264 | } 265 | 266 | case tea.MouseMsg: 267 | mouseEvent := tea.MouseEvent(msg) 268 | switch mouseEvent.Type { 269 | case tea.MouseLeft: 270 | wasMousePressed = mouseBtnLeft 271 | case tea.MouseRelease: 272 | switch wasMousePressed { 273 | case mouseBtnLeft: 274 | if player != nil && mouseEvent.Y == 0 && mouseEvent.X >= 1 && mouseEvent.X <= m.progress.Width { 275 | player.SeekTo(float64(mouseEvent.X-1) / float64(m.progress.Width)) 276 | return m, nil 277 | } 278 | if player != nil && mouseEvent.Y == 0 && mouseEvent.X > m.progress.Width { 279 | playstatus = player.TogglePause() 280 | return m, nil 281 | } 282 | } 283 | wasMousePressed = mouseBtnNone 284 | } 285 | 286 | case tickMsg: 287 | fracDone := 0.0 288 | if player != nil { 289 | progressStr, fracDone = player.Update() 290 | m.progress.Width = m.initWinWidth - len(progressStr) 291 | } 292 | cmd := m.progress.SetPercent(fracDone) 293 | return m, tea.Batch(m.tickUpd(fracDone), cmd) 294 | 295 | case progress.FrameMsg: 296 | progressModel, cmd := m.progress.Update(msg) 297 | m.progress = progressModel.(progress.Model) 298 | return m, cmd 299 | 300 | case chgVol: 301 | if int(msg) == m.volumechg { 302 | player.SetVolume(m.volumechg) 303 | m.volumechg = 0 304 | return m, nil 305 | } 306 | 307 | case seekPos: 308 | if int(msg) == m.seekchg { 309 | player.Seek(m.seekchg) 310 | m.seekchg = 0 311 | return m, nil 312 | } 313 | 314 | default: 315 | return m, nil 316 | } 317 | 318 | var cmd tea.Cmd 319 | m.list, cmd = m.list.Update(msg) 320 | return m, cmd 321 | } 322 | 323 | func (m *model) tickUpd(fracDone float64) tea.Cmd { 324 | if fracDone == 1.0 { 325 | m.nextSong() 326 | } 327 | return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { 328 | return tickMsg(t) 329 | }) 330 | } 331 | 332 | func (m *model) nextSong() { 333 | if player != nil { 334 | player.Rewind(currIdx) 335 | } 336 | 337 | var err error 338 | noOfFiles := len(fileList) 339 | 340 | for { 341 | if !m.repeat { 342 | currIdx++ 343 | currIdx %= noOfFiles 344 | } 345 | player, err = NewPlayer(fileList[currIdx].file, m.audioContext, fileList[currIdx].audioformat) 346 | if err == nil { 347 | m.list.Select(currIdx) 348 | playstatus = "  " 349 | m.list.Title = m.list.SelectedItem().(track).title 350 | break 351 | } 352 | m.list.NewStatusMessage("Cannot Play Audio: " + err.Error()) 353 | progressStr = "--/--" 354 | m.progress.Width = m.initWinWidth - len(progressStr) 355 | if m.repeat { 356 | break 357 | } 358 | } 359 | } 360 | 361 | func (m model) View() string { 362 | progressive := " " + m.progress.View() + playstatus + progressStr + " \n" 363 | return lipgloss.JoinVertical(lipgloss.Left, progressive, m.list.View()) 364 | } 365 | 366 | func listPaths(roots []string) { 367 | for _, root := range roots { 368 | filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { 369 | if err != nil { 370 | return err 371 | } 372 | for _, ext := range []string{".mp3", ".wav", ".oga", ".ogg", ".spx", ".opus", ".flac"} { 373 | if filepath.Ext(d.Name()) == ext { 374 | fileList = append(fileList, audiofile{ 375 | filename: path, 376 | }) 377 | break 378 | } 379 | } 380 | return nil 381 | }) 382 | } 383 | } 384 | 385 | func main() { 386 | config := LoadConfig() 387 | 388 | listPaths(config.Directories) 389 | var trackList []list.Item 390 | 391 | noOfFiles := len(fileList) 392 | 393 | for i := 0; i < noOfFiles; i++ { 394 | file, err := os.Open(fileList[i].filename) 395 | if err != nil { 396 | log.Fatal(err) 397 | } 398 | 399 | tr, err := tag.ReadFrom(file) 400 | if err != nil { 401 | file.Close() 402 | continue 403 | } 404 | 405 | var audioformat musicType 406 | switch tr.FileType() { 407 | case "MP3": 408 | audioformat = typeMP3 409 | case "OGG": 410 | audioformat = typeOgg 411 | case "FLAC": 412 | audioformat = typeFlac 413 | default: 414 | audioformat = typeWav 415 | } 416 | 417 | fileList[i].file, fileList[i].audioformat = file, audioformat 418 | trackList = append(trackList, track{ 419 | title: tr.Title(), 420 | description: tr.Album() + "⊚ " + tr.Artist(), 421 | }) 422 | } 423 | 424 | m := newModel(trackList) 425 | t := tea.NewProgram(*m, tea.WithAltScreen(), tea.WithMouseAllMotion()) 426 | 427 | if _, err := t.Run(); err != nil { 428 | log.Fatal(err) 429 | } 430 | 431 | if player != nil { 432 | player.Close() 433 | } 434 | 435 | for i := 0; i < noOfFiles; i++ { 436 | fileList[i].file.Close() 437 | } 438 | } 439 | --------------------------------------------------------------------------------