├── LICENSE ├── README.md ├── files.go ├── main.go ├── sound.go └── ui.go /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Andriy Kaminskyy 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 | # GoPlayer 2 | A terminal based audio player 3 | 4 | ![screenshot](../assets/screenshot.png) 5 | 6 | ## Features 7 | 8 | * Supports mp3, flac, wav formats 9 | * Displays metadata 10 | * Volume controls 11 | * Ability to rewind and fast-forward audio 12 | 13 | ## Installation 14 | 15 | go get github.com/And678/goPlayer 16 | 17 | This will install goPlayer to $GOPATH/bin folder. 18 | Also you can download binaries from 'Releases' tab. 19 | 20 | ## Usage 21 | 22 | To open all audio files in folder: 23 | 24 | goPlayer /path/to/folder/ 25 | 26 | To open one specific file: 27 | 28 | goPlayer /path/to/file.mp3 29 | 30 | If used without path parameter, goPlayer will assume default music folder: `~/Music/` 31 | 32 | ## Used libraries 33 | 34 | * [termui](https://github.com/gizak/termui/) 35 | * [beep](https://github.com/faiface/beep) 36 | * [tag](https://github.com/dhowden/tag/) 37 | 38 | ## License 39 | MIT 40 | -------------------------------------------------------------------------------- /files.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | func getSongList(input string) ([]string, error) { 9 | 10 | result := make([]string, 0) 11 | addPath := func(path string, info os.FileInfo, err error) error { 12 | if err != nil { 13 | return err 14 | } 15 | 16 | if !info.IsDir() && Contains(supportedFormats, filepath.Ext(path)) { 17 | result = append(result, path) 18 | } 19 | return nil 20 | } 21 | err := filepath.Walk(input, addPath) 22 | 23 | return result, err 24 | 25 | } 26 | 27 | func Contains(arr []string, input string) bool { 28 | for _, v := range arr { 29 | if v == input { 30 | return true 31 | } 32 | } 33 | return false 34 | } 35 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/dhowden/tag" 5 | "github.com/mitchellh/go-homedir" 6 | "log" 7 | "os" 8 | ) 9 | 10 | type Song struct { 11 | tag.Metadata 12 | path string 13 | } 14 | 15 | func main() { 16 | var songDir string 17 | var err error 18 | if len(os.Args) > 1 { 19 | songDir = os.Args[1] 20 | } else { 21 | songDir, err = homedir.Expand("~/Music/") 22 | if err != nil { 23 | log.Fatal("Can't open ~/Music directory") 24 | } 25 | } 26 | 27 | fileList, err := getSongList(songDir) 28 | if err != nil { 29 | log.Fatal("Can't get song list") 30 | } 31 | songs := make([]Song, 0, len(fileList)) 32 | 33 | for _, fileName := range fileList { 34 | currentFile, err := os.Open(fileName) 35 | if err == nil { 36 | metadata, _ := tag.ReadFrom(currentFile) 37 | songs = append(songs, Song{ 38 | Metadata: metadata, 39 | path: fileName, 40 | }) 41 | } 42 | currentFile.Close() 43 | } 44 | if len(songs) == 0 { 45 | log.Fatal("Could find any songs to play") 46 | } 47 | userInterface, err := NewUi(songs, len(songDir)) 48 | if err != nil { 49 | log.Fatal(err) 50 | } 51 | userInterface.OnSelect = playSong 52 | userInterface.OnPause = pauseSong 53 | userInterface.OnSeek = seek 54 | userInterface.OnVolume = setVolue 55 | userInterface.Start() 56 | defer userInterface.Close() 57 | } 58 | -------------------------------------------------------------------------------- /sound.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/faiface/beep" 5 | "github.com/faiface/beep/effects" 6 | "github.com/faiface/beep/flac" 7 | "github.com/faiface/beep/mp3" 8 | "github.com/faiface/beep/speaker" 9 | "github.com/faiface/beep/wav" 10 | "os" 11 | "path/filepath" 12 | "time" 13 | ) 14 | 15 | var supportedFormats = []string{".mp3", ".wav", ".flac"} 16 | var mainCtrl *beep.Ctrl 17 | var s beep.StreamSeekCloser 18 | var format beep.Format 19 | var volume = &effects.Volume{ 20 | Base: 2, 21 | } 22 | 23 | func playSong(input Song) (int, error) { 24 | f, err := os.Open(input.path) 25 | if err != nil { 26 | return 0, err 27 | } 28 | 29 | switch fileExt := filepath.Ext(input.path); fileExt { 30 | case ".mp3": 31 | s, format, err = mp3.Decode(f) 32 | case ".wav": 33 | s, format, err = wav.Decode(f) 34 | case ".flac": 35 | s, format, err = flac.Decode(f) 36 | } 37 | if err != nil { 38 | return 0, err 39 | } 40 | volume.Streamer = s 41 | mainCtrl = &beep.Ctrl{Streamer: volume} 42 | speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/10)) 43 | speaker.Play(mainCtrl) 44 | return int(float32(s.Len()) / float32(format.SampleRate)), nil 45 | } 46 | 47 | func pauseSong(state bool) { 48 | speaker.Lock() 49 | mainCtrl.Paused = state 50 | speaker.Unlock() 51 | } 52 | 53 | func seek(pos int) error { 54 | speaker.Lock() 55 | err := s.Seek(pos * int(format.SampleRate)) 56 | speaker.Unlock() 57 | return err 58 | } 59 | 60 | func setVolue(percent int) { 61 | if percent == 0 { 62 | volume.Silent = true 63 | } else { 64 | volume.Silent = false 65 | volume.Volume = -float64(100-percent) / 100.0 * 5 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /ui.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gizak/termui" 6 | ) 7 | 8 | type uiState int 9 | 10 | const ( 11 | Stopped uiState = iota 12 | Playing 13 | Paused 14 | ) 15 | 16 | type selectCallback func(Song) (int, error) 17 | type pauseCallback func(bool) 18 | type seekCallback func(int) error 19 | type volumeCallback func(int) 20 | 21 | type Ui struct { 22 | infoList *termui.List 23 | playList *termui.List 24 | scrollerGauge *termui.Gauge 25 | volumeGauge *termui.Gauge 26 | controlsPar *termui.Par 27 | 28 | songs []Song 29 | songNames []string 30 | 31 | volume int 32 | 33 | songNum int 34 | 35 | songSel int 36 | songPos int 37 | songLen int 38 | 39 | OnSelect selectCallback 40 | OnPause pauseCallback 41 | OnSeek seekCallback 42 | OnVolume volumeCallback 43 | 44 | state uiState 45 | } 46 | 47 | func NewUi(songList []Song, pathPrefix int) (*Ui, error) { 48 | err := termui.Init() 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | ui := new(Ui) 54 | 55 | ui.volume = 100 56 | 57 | ui.songs = songList 58 | ui.songNum = -1 59 | ui.infoList = termui.NewList() 60 | ui.infoList.BorderLabel = "Song info" 61 | ui.infoList.BorderFg = termui.ColorGreen 62 | ui.infoList.Overflow = "wrap" 63 | 64 | ui.playList = termui.NewList() 65 | ui.playList.BorderLabel = "Playlist" 66 | ui.playList.BorderFg = termui.ColorGreen 67 | 68 | ui.scrollerGauge = termui.NewGauge() 69 | ui.scrollerGauge.BorderLabel = "Stopped" 70 | ui.scrollerGauge.Height = 3 71 | 72 | ui.volumeGauge = termui.NewGauge() 73 | ui.volumeGauge.BorderLabel = "Volume" 74 | ui.volumeGauge.Height = 3 75 | ui.volumeGauge.Percent = ui.volume 76 | 77 | ui.controlsPar = termui.NewPar( 78 | "[ Enter ](fg-black,bg-white)[Select](fg-black,bg-green) " + 79 | "[ p ](fg-black,bg-white)[Play/Pause](fg-black,bg-green) " + 80 | "[Esc](fg-black,bg-white)[Stop](fg-black,bg-green) " + 81 | "[Right](fg-black,bg-white)[+10s](fg-black,bg-green) " + 82 | "[Left](fg-black,bg-white)[-10s](fg-black,bg-green) " + 83 | "[ + ](fg-black,bg-white)[+Volume](fg-black,bg-green) " + 84 | "[ - ](fg-black,bg-white)[-Volume](fg-black,bg-green) " + 85 | "[ q ](fg-black,bg-white)[Exit](fg-black,bg-green) ") 86 | ui.controlsPar.Border = false 87 | ui.controlsPar.Height = 1 88 | 89 | termui.Body.AddRows( 90 | termui.NewRow( 91 | termui.NewCol(6, 0, ui.infoList, ui.scrollerGauge, ui.volumeGauge), 92 | termui.NewCol(6, 0, ui.playList)), 93 | termui.NewRow( 94 | termui.NewCol(12, 0, ui.controlsPar))) 95 | 96 | ui.realign() 97 | 98 | termui.Handle("/sys/kbd/q", func(termui.Event) { 99 | termui.StopLoop() 100 | }) 101 | 102 | termui.Handle("/sys/kbd/p", func(termui.Event) { 103 | if ui.songNum != -1 { 104 | if ui.state == Playing { 105 | ui.OnPause(true) 106 | ui.state = Paused 107 | } else { 108 | ui.OnPause(false) 109 | ui.state = Playing 110 | 111 | } 112 | ui.renderStatus() 113 | } 114 | }) 115 | termui.Handle("timer/1s", func(termui.Event) { 116 | if ui.state == Playing { 117 | ui.songPos++ 118 | if ui.songLen != 0 { 119 | ui.scrollerGauge.Percent = int(float32(ui.songPos) / float32(ui.songLen) * 100) 120 | ui.scrollerGauge.Label = fmt.Sprintf("%d:%.2d / %d:%.2d", ui.songPos/60, ui.songPos%60, ui.songLen/60, ui.songLen%60) 121 | if ui.scrollerGauge.Percent >= 100 { 122 | ui.songNum++ 123 | if ui.songNum >= len(ui.songs) { 124 | ui.songNum = 0 125 | } 126 | ui.playSong(ui.songNum) 127 | } 128 | termui.Clear() 129 | termui.Render(termui.Body) 130 | } 131 | } else if ui.state == Stopped { 132 | ui.songPos = 0 133 | } 134 | }) 135 | 136 | termui.Handle("/sys/kbd/", func(termui.Event) { 137 | if ui.songNum != -1 { 138 | ui.songPos += 10 139 | ui.OnSeek(ui.songPos) 140 | } 141 | }) 142 | 143 | termui.Handle("/sys/kbd/", func(termui.Event) { 144 | if ui.songNum != -1 { 145 | ui.songPos -= 10 146 | if ui.songPos < 0 { 147 | ui.songPos = 0 148 | } 149 | ui.OnSeek(ui.songPos) 150 | } 151 | }) 152 | 153 | termui.Handle("/sys/kbd/", func(termui.Event) { 154 | ui.playSong(ui.songNum) 155 | ui.OnPause(true) 156 | ui.state = Stopped 157 | ui.scrollerGauge.Percent = 0 158 | ui.scrollerGauge.Label = "0:00 / 0:00" 159 | ui.renderStatus() 160 | }) 161 | 162 | termui.Handle("/sys/kbd/", func(termui.Event) { 163 | ui.songNum = ui.songSel 164 | ui.playSong(ui.songNum) 165 | }) 166 | 167 | termui.Handle("/sys/kbd/", func(termui.Event) { 168 | ui.songUp() 169 | termui.Clear() 170 | termui.Render(termui.Body) 171 | }) 172 | 173 | termui.Handle("/sys/kbd/=", func(termui.Event) { 174 | ui.volumeUp() 175 | }) 176 | 177 | termui.Handle("/sys/kbd/+", func(termui.Event) { 178 | ui.volumeUp() 179 | }) 180 | 181 | termui.Handle("/sys/kbd/-", func(termui.Event) { 182 | ui.volumeDown() 183 | }) 184 | 185 | termui.Handle("/sys/kbd/_", func(termui.Event) { 186 | ui.volumeDown() 187 | }) 188 | 189 | termui.Handle("/sys/kbd/", func(termui.Event) { 190 | ui.songDown() 191 | termui.Clear() 192 | termui.Render(termui.Body) 193 | }) 194 | 195 | termui.Handle("/sys/wnd/resize", func(termui.Event) { 196 | ui.realign() 197 | }) 198 | 199 | ui.songNames = make([]string, len(ui.songs)) 200 | for i, v := range ui.songs { 201 | if v.Metadata != nil { 202 | ui.songNames[i] = fmt.Sprintf("[%d] %s - %s", i+1, v.Artist(), v.Title()) 203 | } else { 204 | ui.songNames[i] = fmt.Sprintf("[%d] %s", i+1, v.path[pathPrefix:]) 205 | } 206 | } 207 | ui.playList.Items = ui.songNames 208 | ui.setSong(0, false) 209 | 210 | return ui, nil 211 | } 212 | 213 | func (ui *Ui) Start() { 214 | termui.Loop() 215 | } 216 | 217 | func (ui *Ui) Close() { 218 | termui.Close() 219 | } 220 | 221 | func (ui *Ui) playSong(number int) { 222 | ui.songPos = 0 223 | var err error 224 | ui.songLen, err = ui.OnSelect(ui.songs[number]) 225 | if err == nil { 226 | ui.state = Playing 227 | ui.renderSong() 228 | ui.renderStatus() 229 | } 230 | } 231 | 232 | // Rendering 233 | 234 | func (ui *Ui) realign() { 235 | termHeight := termui.TermHeight() 236 | ui.playList.Height = termHeight - ui.controlsPar.Height 237 | ui.infoList.Height = termHeight - ui.controlsPar.Height - ui.scrollerGauge.Height - ui.volumeGauge.Height 238 | termui.Body.Width = termui.TermWidth() 239 | termui.Body.Align() 240 | termui.Clear() 241 | termui.Render(termui.Body) 242 | } 243 | 244 | func (ui *Ui) renderSong() { 245 | if ui.songSel != -1 { 246 | lyrics := ui.songs[ui.songSel].Lyrics() 247 | trackNum, _ := ui.songs[ui.songSel].Track() 248 | ui.infoList.Items = []string{ 249 | "[Artist:](fg-green) " + ui.songs[ui.songSel].Artist(), 250 | "[Title:](fg-green) " + ui.songs[ui.songSel].Title(), 251 | "[Album:](fg-green) " + ui.songs[ui.songSel].Album(), 252 | fmt.Sprintf("[Track:](fg-green) %d", trackNum), 253 | "[Genre:](fg-green) " + ui.songs[ui.songSel].Genre(), 254 | fmt.Sprintf("[Year:](fg-green) %d", ui.songs[ui.songSel].Year()), 255 | } 256 | if lyrics != "" { 257 | ui.infoList.Items = append(ui.infoList.Items, "Lyrics: "+lyrics) 258 | } 259 | } else { 260 | ui.infoList.Items = []string{} 261 | } 262 | termui.Clear() 263 | termui.Render(termui.Body) 264 | } 265 | 266 | func (ui *Ui) renderStatus() { 267 | var status string 268 | switch ui.state { 269 | case Playing: 270 | status = "[(Playing)](fg-black,bg-green)" 271 | case Paused: 272 | status = "[(Paused)](fg-black,bg-yellow)" 273 | case Stopped: 274 | status = "[(Stopped)](fg-black,bg-red)" 275 | } 276 | ui.scrollerGauge.BorderLabel = status 277 | termui.Clear() 278 | termui.Render(termui.Body) 279 | } 280 | 281 | //Song selection 282 | 283 | func (ui *Ui) songDown() { 284 | if ui.songSel < len(ui.songNames)-1 { 285 | ui.setSong(ui.songSel+1, true) 286 | } 287 | } 288 | 289 | func (ui *Ui) songUp() { 290 | if ui.songSel > 0 { 291 | ui.setSong(ui.songSel-1, true) 292 | } 293 | } 294 | 295 | func (ui *Ui) volumeUp() { 296 | if ui.volume < 100 { 297 | ui.volume += 5 298 | } 299 | ui.volumeGauge.Percent = ui.volume 300 | ui.OnVolume(ui.volume) 301 | termui.Clear() 302 | termui.Render(termui.Body) 303 | } 304 | 305 | func (ui *Ui) volumeDown() { 306 | if ui.volume > 0 { 307 | ui.volume -= 5 308 | } 309 | ui.volumeGauge.Percent = ui.volume 310 | ui.OnVolume(ui.volume) 311 | termui.Clear() 312 | termui.Render(termui.Body) 313 | } 314 | 315 | func (ui *Ui) setSong(num int, unset bool) { 316 | skip := 0 317 | for num-skip >= ui.playList.Height-2 { 318 | skip += ui.playList.Height - 2 319 | } 320 | if unset { 321 | ui.songNames[ui.songSel] = ui.songNames[ui.songSel][1 : len(ui.songNames[ui.songSel])-20] 322 | } 323 | ui.songSel = num 324 | ui.songNames[num] = fmt.Sprintf("[%s](fg-black,bg-green)", ui.songNames[num]) 325 | ui.playList.Items = ui.songNames[skip:] 326 | } 327 | --------------------------------------------------------------------------------