From fdfdcecce7fed13b691a85c26dddd06a18435a0f Mon Sep 17 00:00:00 2001 From: Trevor Slocum Date: Wed, 8 Jan 2020 15:38:41 -0800 Subject: [PATCH] Initial commit --- .builds/amd64_freebsd.yml | 14 ++ .builds/amd64_linux_alpine.yml | 14 ++ .gitignore | 5 + CHANGELOG | 2 + CONFIGURATION.md | 5 + LICENSE | 21 ++ README.md | 32 ++++ audio.go | 199 +++++++++++++++++++ go.mod | 19 ++ go.sum | 109 +++++++++++ goreleaser.yml | 29 +++ gui.go | 341 +++++++++++++++++++++++++++++++++ gui_key.go | 106 ++++++++++ gui_mouse.go | 76 ++++++++ library.go | 92 +++++++++ main.go | 129 +++++++++++++ 16 files changed, 1193 insertions(+) create mode 100644 .builds/amd64_freebsd.yml create mode 100644 .builds/amd64_linux_alpine.yml create mode 100644 .gitignore create mode 100644 CHANGELOG create mode 100644 CONFIGURATION.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 audio.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 goreleaser.yml create mode 100644 gui.go create mode 100644 gui_key.go create mode 100644 gui_mouse.go create mode 100644 library.go create mode 100644 main.go diff --git a/.builds/amd64_freebsd.yml b/.builds/amd64_freebsd.yml new file mode 100644 index 0000000..63773bd --- /dev/null +++ b/.builds/amd64_freebsd.yml @@ -0,0 +1,14 @@ +arch: amd64 +environment: + PROJECT_NAME: 'ditty' + CGO_ENABLED: '1' + GO111MODULE: 'on' +image: freebsd/latest +packages: + - go +sources: + - https://git.sr.ht/~tslocum/ditty +tasks: + - test: | + cd $PROJECT_NAME + go test ./... diff --git a/.builds/amd64_linux_alpine.yml b/.builds/amd64_linux_alpine.yml new file mode 100644 index 0000000..149d1d0 --- /dev/null +++ b/.builds/amd64_linux_alpine.yml @@ -0,0 +1,14 @@ +arch: x86_64 +environment: + PROJECT_NAME: 'ditty' + CGO_ENABLED: '1' + GO111MODULE: 'on' +image: alpine/edge +packages: + - go +sources: + - https://git.sr.ht/~tslocum/ditty +tasks: + - test: | + cd $PROJECT_NAME + go test ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e2aa2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea/ +dist/ +vendor/ +*.sh +ditty diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..8da6a6f --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,2 @@ +0.1.0: +- Initial release diff --git a/CONFIGURATION.md b/CONFIGURATION.md new file mode 100644 index 0000000..15c666a --- /dev/null +++ b/CONFIGURATION.md @@ -0,0 +1,5 @@ +This document covers the [ditty](https://git.sr.ht/~tslocum/ditty) command-line options. + +# TODO + +WIP diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7c20f89 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Trevor Slocum + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..55d55e4 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# ditty +[![GoDoc](https://godoc.org/git.sr.ht/~tslocum/ditty?status.svg)](https://godoc.org/git.sr.ht/~tslocum/ditty) +[![builds.sr.ht status](https://builds.sr.ht/~tslocum/ditty.svg)](https://builds.sr.ht/~tslocum/ditty) +[![Donate](https://img.shields.io/liberapay/receives/rocketnine.space.svg?logo=liberapay)](https://liberapay.com/rocketnine.space) + +Audio player + +## Screenshot + +[![](https://ditty.rocketnine.space/static/screenshot1.png)](https://ditty.rocketnine.space/static/screenshot1.png) + +## Install + +Choose one of the following methods: + +### Download + +[**Download ditty**](https://ditty.rocketnine.space/download/?sort=name&order=desc) + +### Compile + +``` +GO111MODULE=on go get git.sr.ht/~tslocum/ditty +``` + +## Configure + +See [CONFIGURATION.md](https://man.sr.ht/~tslocum/ditty/CONFIGURATION.md) + +## Support + +Please share issues/suggestions [here](https://todo.sr.ht/~tslocum/ditty). diff --git a/audio.go b/audio.go new file mode 100644 index 0000000..5d321bb --- /dev/null +++ b/audio.go @@ -0,0 +1,199 @@ +package main + +import ( + "fmt" + "io" + "log" + "math" + "os" + "path" + "strings" + "sync" + "time" + + "github.com/faiface/beep" + "github.com/faiface/beep/effects" + "github.com/faiface/beep/flac" + "github.com/faiface/beep/mp3" + "github.com/faiface/beep/speaker" + "github.com/faiface/beep/vorbis" + "github.com/faiface/beep/wav" +) + +var ( + playingFileName string + playingFileInfo string + playingFileID int64 + playingStreamer beep.StreamSeekCloser + playingFormat beep.Format + playingSampleRate beep.SampleRate + + nextStreamer beep.StreamSeekCloser + nextFormat beep.Format + nextFileName string + + volume *effects.Volume + ctrl *beep.Ctrl + + audioLock = new(sync.Mutex) +) + +type AudioFile struct { + File *os.File + Streamer beep.StreamSeekCloser + Format beep.Format + Metadata *Metadata +} + +func openFile(filePath string) (*AudioFile, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + + metadata := readMetadata(f) + _, err = f.Seek(0, io.SeekStart) + if err != nil { + log.Fatal(err) + } + + var ( + streamer beep.StreamSeekCloser + format beep.Format + ) + switch strings.ToLower(path.Ext(filePath)) { + case ".wav": + streamer, format, err = wav.Decode(f) + case ".mp3": + streamer, format, err = mp3.Decode(f) + case ".ogg", ".weba", ".webm": + streamer, format, err = vorbis.Decode(f) + case ".flac": + streamer, format, err = flac.Decode(f) + default: + err = fmt.Errorf("unsupported file format") + } + if err != nil { + return nil, err + } + + a := AudioFile{File: f, Streamer: streamer, Format: format, Metadata: metadata} + return &a, nil +} + +func play(audioFile *AudioFile) { + audioLock.Lock() + defer audioLock.Unlock() + + if playingStreamer != nil { + playingStreamer.Close() + } + + thisFileID := time.Now().UnixNano() + + playingFileID = thisFileID + playingStreamer = audioFile.Streamer + playingFormat = audioFile.Format + playingFileName = audioFile.File.Name() + + playingFileInfo = "" + if audioFile.Metadata.Title != "" { + playingFileInfo = audioFile.Metadata.Title + + if audioFile.Metadata.Artist != "" { + playingFileInfo = audioFile.Metadata.Artist + " - " + playingFileInfo + } + } + + if audioFile.Format.SampleRate != playingSampleRate { + err := speaker.Init(audioFile.Format.SampleRate, audioFile.Format.SampleRate.N(time.Second/2)) + if err != nil { + log.Fatalf("failed to initialize audio device: %s", err) + } + } + + var ( + vol float64 + silent bool + ) + speaker.Lock() + if volume != nil { + vol = volume.Volume + silent = volume.Silent + } + speaker.Unlock() + + volume = &effects.Volume{ + Streamer: beep.Seq(audioFile.Streamer, beep.Callback(func() { + if playingFileID != thisFileID { + return + } + + go nextTrack() + })), + Base: volumeBase, + Volume: vol, + Silent: silent, + } + + ctrl = &beep.Ctrl{ + Streamer: volume, + Paused: false, + } + + speaker.Clear() + speaker.Play(ctrl) + app.QueueUpdateDraw(func() { + updateMain() + updateQueue() + updateStatus() + }) +} + +func nextTrack() { + if mainBufferCursor-1 < len(mainBufferFiles)-1 { + mainBufferCursor++ + + audioFile, err := openFile(path.Join(mainBufferDirectory, mainBufferFiles[mainBufferCursor-1].File.Name())) + if err != nil { + return + } + + play(audioFile) + app.QueueUpdateDraw(updateMain) + } +} + +func roundUnit(x, unit float64) float64 { + return math.Round(x/unit) * unit +} + +func supportedFormat(filePath string) bool { + switch strings.ToLower(path.Ext(filePath)) { + case ".wav": + return true + case ".mp3": + return true + case ".ogg", ".weba", ".webm": + return true + case ".flac": + return true + default: + return false + } +} + +func fileFormat(fileName string) string { + switch strings.ToLower(path.Ext(fileName)) { + case ".wav": + return "WAV" + case ".mp3": + return "MP3" + case ".ogg", ".weba", ".webm": + return "OGG" + case ".flac": + return "FLAC" + default: + return "?" + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b60b624 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module git.sr.ht/~tslocum/ditty + +go 1.13 + +require ( + git.sr.ht/~tslocum/cview v0.2.2 + github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8 + github.com/faiface/beep v1.0.2 + github.com/gdamore/tcell v1.3.0 + github.com/hajimehoshi/go-mp3 v0.2.1 // indirect + github.com/hajimehoshi/oto v0.5.4 // indirect + github.com/jfreymuth/oggvorbis v1.0.1 // indirect + github.com/mattn/go-runewidth v0.0.7 + github.com/mewkiz/flac v1.0.6 // indirect + golang.org/x/exp v0.0.0-20191227195350-da58074b4299 // indirect + golang.org/x/image v0.0.0-20191214001246-9130b4cfad52 // indirect + golang.org/x/mobile v0.0.0-20191210151939-1a1fef82734d // indirect + golang.org/x/sys v0.0.0-20200107162124-548cf772de50 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..afc914f --- /dev/null +++ b/go.sum @@ -0,0 +1,109 @@ +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +git.sr.ht/~tslocum/cview v0.2.2 h1:eIN9Wy/DIHP9///qcz9Q7JkMP36duA5iyTP0GJ+WhvY= +git.sr.ht/~tslocum/cview v0.2.2/go.mod h1:TLTjvAd3pw6MqV6SaBMpxOdOdODW4O2gtQJ3B3H6PoU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= +github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8 h1:nmFnmD8VZXkjDHIb1Gnfz50cgzUvGN72zLjPRXBW/hU= +github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw= +github.com/faiface/beep v1.0.2 h1:UB5DiRNmA4erfUYnHbgU4UB6DlBOrsdEFRtcc8sCkdQ= +github.com/faiface/beep v1.0.2/go.mod h1:1yLb5yRdHMsovYYWVqYLioXkVuziCSITW1oarTeduQM= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell v1.1.1/go.mod h1:K1udHkiR3cOtlpKG5tZPD5XxrF7v2y7lDq7Whcj+xkQ= +github.com/gdamore/tcell v1.3.0 h1:r35w0JBADPZCVQijYebl6YMWWtHRqVEGt7kL2eBADRM= +github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= +github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= +github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= +github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/gopherjs/gopherjs v0.0.0-20180628210949-0892b62f0d9f/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20180825215210-0210a2f0f73c h1:16eHWuMGvCjSfgRJKqIzapE78onvvTbdi1rMkU00lZw= +github.com/gopherjs/gopherjs v0.0.0-20180825215210-0210a2f0f73c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherwasm v0.1.1/go.mod h1:kx4n9a+MzHH0BJJhvlsQ65hqLFXDO/m256AsaDPQ+/4= +github.com/gopherjs/gopherwasm v1.0.0 h1:32nge/RlujS1Im4HNCJPp0NbBOAeBXFuT1KonUuLl+Y= +github.com/gopherjs/gopherwasm v1.0.0/go.mod h1:SkZ8z7CWBz5VXbhJel8TxCmAcsQqzgWGR/8nMhyhZSI= +github.com/hajimehoshi/go-mp3 v0.1.1 h1:Y33fAdTma70fkrxnc9u50Uq0lV6eZ+bkAlssdMmCwUc= +github.com/hajimehoshi/go-mp3 v0.1.1/go.mod h1:4i+c5pDNKDrxl1iu9iG90/+fhP37lio6gNhjCx9WBJw= +github.com/hajimehoshi/go-mp3 v0.2.1 h1:DH4ns3cPv39n3cs8MPcAlWqPeAwLCK8iNgqvg0QBWI8= +github.com/hajimehoshi/go-mp3 v0.2.1/go.mod h1:Rr+2P46iH6PwTPVgSsEwBkon0CK5DxCAeX/Rp65DCTE= +github.com/hajimehoshi/oto v0.1.1/go.mod h1:hUiLWeBQnbDu4pZsAhOnGqMI1ZGibS6e2qhQdfpwz04= +github.com/hajimehoshi/oto v0.3.1 h1:cpf/uIv4Q0oc5uf9loQn7PIehv+mZerh+0KKma6gzMk= +github.com/hajimehoshi/oto v0.3.1/go.mod h1:e9eTLBB9iZto045HLbzfHJIc+jP3xaKrjZTghvb6fdM= +github.com/hajimehoshi/oto v0.3.4/go.mod h1:PgjqsBJff0efqL2nlMJidJgVJywLn6M4y8PI4TfeWfA= +github.com/hajimehoshi/oto v0.5.4 h1:Dn+WcYeF310xqStKm0tnvoruYUV5Sce8+sfUaIvWGkE= +github.com/hajimehoshi/oto v0.5.4/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= +github.com/icza/bitio v1.0.0 h1:squ/m1SHyFeCA6+6Gyol1AxV9nmPPlJFT8c2vKdj3U8= +github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= +github.com/jfreymuth/oggvorbis v1.0.0 h1:aOpiihGrFLXpsh2osOlEvTcg5/aluzGQeC7m3uYWOZ0= +github.com/jfreymuth/oggvorbis v1.0.0/go.mod h1:abe6F9QRjuU9l+2jek3gj46lu40N4qlYxh2grqkLEDM= +github.com/jfreymuth/oggvorbis v1.0.1 h1:NT0eXBgE2WHzu6RT/6zcb2H10Kxj6Fm3PccT0LE6bqw= +github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= +github.com/jfreymuth/vorbis v1.0.0 h1:SmDf783s82lIjGZi8EGUUaS7YxPHgRj4ZXW/h7rUi7U= +github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= +github.com/lucasb-eyer/go-colorful v0.0.0-20181028223441-12d3b2882a08/go.mod h1:NXg0ArsFk0Y01623LgUqoqcouGDB+PwCCQlrwrG6xJ4= +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/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54= +github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mewkiz/flac v1.0.5 h1:dHGW/2kf+/KZ2GGqSVayNEhL9pluKn/rr/h/QqD9Ogc= +github.com/mewkiz/flac v1.0.5/go.mod h1:EHZNU32dMF6alpurYyKHDLYpW1lYpBZ5WrXi/VuNIGs= +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/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20180710024300-14dda7b62fcd h1:nLIcFw7GiqKXUS7HiChg6OAYWgASB2H97dZKd1GhDSs= +golang.org/x/exp v0.0.0-20180710024300-14dda7b62fcd/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299 h1:zQpM52jfKHG6II1ISZY1ZcpygvuSFZpLwfluuF89XOg= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81 h1:00VmoueYNlNz/aHIilyyQz/MHSqGoWJzpFv/HW8xpzI= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= +golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20191214001246-9130b4cfad52 h1:2fktqPPvDiVEEVT/vSTeoUPXfmRxRaGy6GU8jypvEn0= +golang.org/x/image v0.0.0-20191214001246-9130b4cfad52/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mobile v0.0.0-20180806140643-507816974b79 h1:t2JRgCWkY7Qaa1J2jal+wqC9OjbyHCHwIA9rVlRUSMo= +golang.org/x/mobile v0.0.0-20180806140643-507816974b79/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mobile v0.0.0-20191210151939-1a1fef82734d h1:LlA9R5JFi974qK4gm9FRK1+qSkduxnQKcrimdzcidyc= +golang.org/x/mobile v0.0.0-20191210151939-1a1fef82734d/go.mod h1:p895TfNkDgPEmEQrNiOtIl3j98d/tGU95djDj7NfyjQ= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/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-20200103143344-a1369afcdac7 h1:/W9OPMnnpmFXHYkcp2rQsbFUbRlRzfECQjmAFiOyHE8= +golang.org/x/sys v0.0.0-20200103143344-a1369afcdac7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200107162124-548cf772de50 h1:YvQ10rzcqWXLlJZ3XCUoO25savxmscf4+SC+ZqiCHhA= +golang.org/x/sys v0.0.0-20200107162124-548cf772de50/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= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190909214602-067311248421/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0/go.mod h1:OdE7CF6DbADk7lN8LIKRzRJTTZXIjtWgA5THM5lhBAw= diff --git a/goreleaser.yml b/goreleaser.yml new file mode 100644 index 0000000..24243c7 --- /dev/null +++ b/goreleaser.yml @@ -0,0 +1,29 @@ +project_name: ditty + +builds: + - + id: ditty + binary: ditty + env: + - CGO_ENABLED=1 + ldflags: + - -s -w -X git.sr.ht/~tslocum/ditty/version={{.Version}} + goos: + - linux + - windows + goarch: + - amd64 +archives: + - + id: ditty + builds: + - ditty + format_overrides: + - goos: windows + format: zip + files: + - ./*.md + - CHANGELOG + - LICENSE +checksum: + name_template: 'checksums.txt' diff --git a/gui.go b/gui.go new file mode 100644 index 0000000..054aaa6 --- /dev/null +++ b/gui.go @@ -0,0 +1,341 @@ +package main + +import ( + "fmt" + "math" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/mattn/go-runewidth" + + "github.com/gdamore/tcell" + + "github.com/faiface/beep" + + "git.sr.ht/~tslocum/cview" + "github.com/faiface/beep/speaker" +) + +var ( + app *cview.Application + mainbuf *cview.TextView + queuebuf *cview.TextView + topstatusbuf *cview.TextView + bottomstatusbuf *cview.TextView + + mainBufferText string + mainBufferFiles []*LibraryEntry + mainBufferCursor int + mainBufferDirectory string + + seekStart, seekEnd int + volumeStart, volumeEnd int + + screenWidth, screenHeight int + mainBufHeight int + + statusText string +) + +func initTUI() error { + app = cview.NewApplication() + + app.EnableMouse() + + app.SetInputCapture(handleKeyPress) + + app.SetAfterResizeFunc(handleResize) + + app.SetMouseCapture(handleMouse) + + grid := cview.NewGrid().SetRows(-2, -1, 1, 1).SetColumns(-1) + + mainbuf = cview.NewTextView().SetDynamicColors(true).SetWrap(true).SetWordWrap(false) + queuebuf = cview.NewTextView().SetDynamicColors(true).SetWrap(true).SetWordWrap(false) + topstatusbuf = cview.NewTextView().SetWrap(false).SetWordWrap(false) + bottomstatusbuf = cview.NewTextView().SetWrap(false).SetWordWrap(false) + + mainbuf.SetBorder(true).SetTitleAlign(cview.AlignLeft) + + queuebuf.SetBorder(true).SetTitleAlign(cview.AlignLeft).SetTitle(" Queue ") + + grid.AddItem(mainbuf, 0, 0, 1, 1, 0, 0, false) + grid.AddItem(queuebuf, 1, 0, 1, 1, 0, 0, false) + grid.AddItem(topstatusbuf, 2, 0, 1, 1, 0, 0, false) + grid.AddItem(bottomstatusbuf, 3, 0, 1, 1, 0, 0, false) + + mainbuf.SetDrawFunc(func(screen tcell.Screen, x, y, width, height int) (i int, i2 int, i3 int, i4 int) { + mainBufHeight = height + return mainbuf.GetInnerRect() + }) + + app.SetRoot(grid, true) + + return nil +} + +func browseFolder(browse string) { + var err error + browse, err = filepath.Abs(browse) + if err != nil { + return + } + + mainBufferFiles = scanFolder(browse) + + var b strings.Builder + b.WriteString("..") + for _, entry := range mainBufferFiles { + b.WriteRune('\n') + + b.WriteString(entry.String()) + } + + if len(mainBufferFiles) > 0 { + mainBufferCursor = 1 + } else { + mainBufferCursor = 0 + } + + mainBufferDirectory = browse + mainBufferText = b.String() + + app.QueueUpdateDraw(updateMain) +} + +func updateMain() { + var titleText string + if statusText != "" { + titleText = statusText + } else { + titleText = mainBufferDirectory + + truncated := false + widthRequirement := 4 + for { + if runewidth.StringWidth(titleText) <= screenWidth-widthRequirement || !strings.ContainsRune(titleText, os.PathSeparator) { + break + } + + titleText = titleText[strings.IndexRune(titleText, '/')+1:] + + truncated = true + widthRequirement = 8 + } + if truncated { + titleText = ".../" + titleText + } + titleText = runewidth.Truncate(titleText, screenWidth-4, "...") + } + mainbuf.SetTitle(" " + titleText + " ") + + var printed int + + var newBufferText string + if mainBufferCursor == 0 { + newBufferText += "[::r]" + } + var line string + if mainBufferDirectory == "/" { + line = "./" + } else { + line = "../" + } + newBufferText += line + for i := len(line); i < screenWidth-2; i++ { + newBufferText += " " + } + if mainBufferCursor == 0 { + newBufferText += "[-]" + } + if len(mainBufferFiles) > 0 { + newBufferText += "\n" + } + printed++ + + for i, entry := range mainBufferFiles { + if i == mainBufferCursor-1 { + newBufferText += "[::r]" + } + var line string + if entry.File.IsDir() { + line = entry.File.Name() + "/" + } else { + line = entry.String() + } + newBufferText += line + for i := runewidth.StringWidth(line); i < screenWidth-2; i++ { + newBufferText += " " + } + if i == mainBufferCursor-1 { + newBufferText += "[-]" + } + + printed++ + if printed == mainBufHeight { + break + } + + if i < len(mainBufferFiles)-1 { + newBufferText += "\n" + } + } + + mainbuf.SetText(newBufferText) +} + +func updateQueue() { + // TODO +} + +func updateStatus() { + var sampleRate beep.SampleRate + var d time.Duration + var l time.Duration + var v float64 + var topStatusExtra string + + speaker.Lock() + if playingStreamer == nil { + topstatusbuf.SetText("") + bottomstatusbuf.SetText("") + speaker.Unlock() + return + } + + sampleRate = playingFormat.SampleRate + d = playingFormat.SampleRate.D(playingStreamer.Position()).Truncate(time.Second) + l = playingFormat.SampleRate.D(playingStreamer.Len()).Truncate(time.Second) + v = volume.Volume + paused := ctrl.Paused + topStatusExtra = fmt.Sprintf("%dHz %s", sampleRate.N(time.Second), fileFormat(playingFileName)) + if paused { + topStatusExtra = "Paused " + topStatusExtra + } + + speaker.Unlock() + + topStatus := " " + if playingFileInfo != "" { + topStatus += playingFileInfo + } else { + topStatus += playingFileName + } + topStatusMaxFileLength := screenWidth - len(topStatusExtra) - 1 + if topStatusMaxFileLength >= 7 { + if len(topStatus) > topStatusMaxFileLength { + topStatus = topStatus[:topStatusMaxFileLength] + } + + padding := screenWidth - runewidth.StringWidth(topStatus) - len(topStatusExtra) - 1 + for i := 0; i < padding; i++ { + topStatus += " " + } + + topStatus += topStatusExtra + } + topstatusbuf.SetText(topStatus) + + var vol string + if volume.Silent { + vol = "Mut " + + for i := -7.5; i < 0.0; i += 0.5 { + vol += string(tcell.RuneHLine) + } + } else { + vol = "Vol " + + for i := -7.5; i < v-0.5; i += 0.5 { + vol += string(tcell.RuneHLine) + } + vol += string(tcell.RuneBlock) + for i := v; i < 0; i += 0.5 { + vol += string(tcell.RuneHLine) + } + } + + bottomStatus := fmt.Sprintf("%s %s", formatDuration(l), vol) + + var durationIndicator string + if paused { + durationIndicator = "||" + } else { + durationIndicator = string(tcell.RuneBlock) + } + + padding := screenWidth - runewidth.StringWidth(bottomStatus) - len(formatDuration(d)) - runewidth.StringWidth(durationIndicator) - 3 + position := int(float64(padding) * (float64(d) / float64(l))) + if position > padding-1 { + position = padding - 1 + } + if paused && position > 0 { + position-- + } + + var durationBar string + for i := 0; i < padding; i++ { + if i == position { + durationBar += durationIndicator + } else { + durationBar += string(tcell.RuneHLine) + } + } + + seekStart = len(formatDuration(d)) + 2 + seekEnd = seekStart + padding - 1 + + volumeStart = seekEnd + len(formatDuration(l)) + 4 + volumeEnd = screenWidth - 2 + + bottomstatusbuf.SetText(" " + formatDuration(d) + " " + durationBar + " " + bottomStatus) +} + +func formatDuration(d time.Duration) string { + minutes := int(math.Floor(float64(d) / float64(time.Minute))) + seconds := int((d % time.Minute) / time.Second) + + return fmt.Sprintf("%02d:%02d", minutes, seconds) +} + +func handleResize(screen tcell.Screen) { + screenWidth, screenHeight = screen.Size() + + updateMain() + updateQueue() + updateStatus() +} + +func selectTrack() { + if mainBufferCursor == 0 { + browseFolder(path.Join(mainBufferDirectory, "..")) + return + } + + nextStreamer = nil + nextFormat = beep.Format{} + + selected := mainBufferFiles[mainBufferCursor-1] + if selected.File.IsDir() { + browseFolder(path.Join(mainBufferDirectory, path.Base(selected.File.Name()))) + return + } + + audioFile, err := openFile(path.Join(mainBufferDirectory, mainBufferFiles[mainBufferCursor-1].File.Name())) + if err != nil { + statusText = err.Error() + go func() { + time.Sleep(5 * time.Second) + statusText = "" + app.QueueUpdateDraw(updateMain) + }() + app.QueueUpdateDraw(updateMain) + return + } + go play(audioFile) + + app.QueueUpdateDraw(updateStatus) +} diff --git a/gui_key.go b/gui_key.go new file mode 100644 index 0000000..dea07e5 --- /dev/null +++ b/gui_key.go @@ -0,0 +1,106 @@ +package main + +import ( + "github.com/faiface/beep/speaker" + "github.com/gdamore/tcell" +) + +func handleKeyPress(event *tcell.EventKey) *tcell.EventKey { + switch event.Rune() { + case '-': + audioLock.Lock() + defer audioLock.Unlock() + + speaker.Lock() + volume.Volume -= 0.5 + if volume.Volume <= -7.5 { + volume.Volume = -7.5 + volume.Silent = true + } + speaker.Unlock() + updateStatus() + return nil + case '+': + audioLock.Lock() + defer audioLock.Unlock() + + speaker.Lock() + volume.Volume += 0.5 + if volume.Volume > 0 { + volume.Volume = 0 + } + volume.Silent = false + speaker.Unlock() + updateStatus() + return nil + case ' ': + audioLock.Lock() + defer audioLock.Unlock() + + speaker.Lock() + ctrl.Paused = !ctrl.Paused + speaker.Unlock() + updateStatus() + return nil + case 'j': + if mainBufferCursor < len(mainBufferFiles) { + mainBufferCursor++ + } + updateMain() + return nil + case 'k': + if mainBufferCursor > 0 { + mainBufferCursor-- + } + updateMain() + return nil + case 'p': + if mainBufferCursor > 1 { + if mainBufferFiles[mainBufferCursor-2].File.IsDir() { + return nil + } + + mainBufferCursor-- + go selectTrack() + } + return nil + case 'n': + if mainBufferCursor < len(mainBufferFiles) { + if mainBufferFiles[mainBufferCursor].File.IsDir() { + return nil + } + + mainBufferCursor++ + go selectTrack() + } + return nil + case 'q': + // Queue non-recursively + return nil + case 'Q': + // Queue recursively + return nil + } + + switch event.Key() { + case tcell.KeyEscape: + done <- true + return nil + case tcell.KeyEnter: + go selectTrack() + return nil + case tcell.KeyUp: + if mainBufferCursor > 0 { + mainBufferCursor-- + } + updateMain() + return nil + case tcell.KeyDown: + if mainBufferCursor < len(mainBufferFiles) { + mainBufferCursor++ + } + updateMain() + return nil + } + return event +} diff --git a/gui_mouse.go b/gui_mouse.go new file mode 100644 index 0000000..f30b63b --- /dev/null +++ b/gui_mouse.go @@ -0,0 +1,76 @@ +package main + +import ( + "path" + "strings" + "time" + + "git.sr.ht/~tslocum/cview" + "github.com/faiface/beep/speaker" + "github.com/gdamore/tcell" +) + +func handleMouse(event *cview.EventMouse) *cview.EventMouse { + if event.Action()&cview.MouseDown != 0 && event.Buttons()&tcell.Button1 != 0 { + mouseX, mouseY := event.Position() + if mouseY > 0 && mouseY < mainBufHeight+1 { + // TODO Delay playing while cursor is moved + if mouseY-1 < len(mainBufferFiles)+1 { + mainBufferCursor = mouseY - 1 + go selectTrack() + } + return nil + } else if mouseY == screenHeight-1 { + if mouseX >= seekStart && mouseX <= seekEnd { + if strings.ToLower(path.Ext(playingFileName)) == ".flac" { + statusText = "Seeking FLAC files is unsupported" + go func() { + time.Sleep(5 * time.Second) + statusText = "" + app.QueueUpdateDraw(updateMain) + }() + app.QueueUpdateDraw(updateMain) + + return nil + } + + speaker.Lock() + seekTo := int(float64(playingStreamer.Len()) * (float64(mouseX-seekStart) / float64(seekEnd-seekStart))) + _ = playingStreamer.Seek(seekTo) // Ignore seek errors + speaker.Unlock() + app.QueueUpdateDraw(updateStatus) + return nil + } else if mouseX >= volumeStart && mouseX <= volumeEnd+1 { + if mouseX > volumeEnd { + mouseX = volumeEnd + } + + if mouseX-volumeStart <= 3 { + speaker.Lock() + volume.Silent = !volume.Silent + speaker.Unlock() + app.QueueUpdateDraw(updateStatus) + } else { + speaker.Lock() + setVolume := -7.5 + float64(7.5)*(float64(mouseX-volumeStart-3)/float64(volumeEnd-volumeStart-3)) + if setVolume < -7.0 { + setVolume = -7.0 + } + + volume.Volume = roundUnit(setVolume, 0.5) + if volume.Volume > 0 { + volume.Volume = 0 + } + + volume.Silent = setVolume <= -7.5 + + speaker.Unlock() + app.QueueUpdateDraw(updateStatus) + } + return nil + } + } + } + + return event +} diff --git a/library.go b/library.go new file mode 100644 index 0000000..eb0026b --- /dev/null +++ b/library.go @@ -0,0 +1,92 @@ +package main + +import ( + "io/ioutil" + "log" + "os" + "path" + "sort" + "strings" + + "github.com/dhowden/tag" +) + +type Metadata struct { + Title string + Artist string + Album string + Track int +} + +func readMetadata(f *os.File) *Metadata { + var metadata Metadata + + m, err := tag.ReadFrom(f) + if err != nil || m.Title() == "" { + metadata.Title = f.Name() + } else { + metadata.Title = m.Title() + metadata.Artist = m.Artist() + metadata.Album = m.Album() + metadata.Track, _ = m.Track() + } + + return &metadata +} + +type LibraryEntry struct { + File os.FileInfo + Metadata *Metadata +} + +func (e *LibraryEntry) String() string { + if e.Metadata.Title != "" { + if e.Metadata.Artist != "" { + return e.Metadata.Artist + " - " + e.Metadata.Title + } + + return e.Metadata.Title + } + + return e.File.Name() +} + +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 + for _, fileInfo := range files { + if fileInfo.IsDir() { + entries = append(entries, &LibraryEntry{File: fileInfo, Metadata: &Metadata{Title: fileInfo.Name()}}) + continue + } else if !supportedFormat(fileInfo.Name()) { + continue + } + + f, err := os.Open(path.Join(scanPath, fileInfo.Name())) + if err != nil { + continue + } + metadata := readMetadata(f) + f.Close() + + entries = append(entries, &LibraryEntry{File: fileInfo, Metadata: metadata}) + } + + sort.Slice(entries, func(i, j int) bool { + if entries[i].File.IsDir() != entries[j].File.IsDir() { + return entries[i].File.IsDir() + } + + if entries[i].Metadata.Album != "" && strings.ToLower(entries[i].Metadata.Album) == strings.ToLower(entries[j].Metadata.Album) && (entries[i].Metadata.Track > 0 || entries[j].Metadata.Track > 0) { + return entries[i].Metadata.Track < entries[j].Metadata.Track + } + + return strings.ToLower(entries[i].Metadata.Album) < strings.ToLower(entries[j].Metadata.Album) && strings.ToLower(entries[i].File.Name()) < strings.ToLower(entries[j].File.Name()) + }) + + return entries +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9a10bf2 --- /dev/null +++ b/main.go @@ -0,0 +1,129 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + _ "net/http/pprof" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" +) + +const ( + volumeBase = 2 + + version = "0.0.0" + versionInfo = `ditty - Audio player - v` + version + ` +https://git.sr.ht/~tslocum/ditty +The MIT License (MIT) +Copyright (c) 2020 Trevor Slocum +` +) + +var ( + printVersionInfo bool + debugAddress string + + done = make(chan bool) +) + +func main() { + log.SetFlags(0) + + flag.BoolVar(&printVersionInfo, "version", false, "print version information and exit") + flag.StringVar(&debugAddress, "debug-address", "", "address to serve debug info") + flag.Parse() + + if printVersionInfo { + fmt.Print(versionInfo) + return + } + + if debugAddress != "" { + go func() { + log.Fatal(http.ListenAndServe(debugAddress, nil)) + }() + } + + err := initTUI() + if err != nil { + log.Fatalf("failed to initialize terminal user interface: %s", err) + } + + sigc := make(chan os.Signal, 1) + signal.Notify(sigc, + syscall.SIGINT, + syscall.SIGTERM) + go func() { + <-sigc + + done <- true + }() + + go func() { + err := app.Run() + if err != nil { + log.Fatal(err) + } + + done <- true + }() + + startPath := strings.Join(flag.Args(), " ") + if startPath == "" { + wd, err := os.Getwd() + if err != nil || wd == "" { + homeDir, err := os.UserHomeDir() + if err == nil && homeDir != "" { + startPath = homeDir + } + } else { + startPath = wd + } + } + if startPath == "" { + log.Fatal("supply a folder to browse initially") + } + fileInfo, err := os.Stat(startPath) + if err != nil { + log.Fatal(err) + } + + if fileInfo.IsDir() { + browseFolder(startPath) + } else { + browseFolder(filepath.Dir(startPath)) + + audioFile, err := openFile(strings.Join(flag.Args(), " ")) + if err != nil { + statusText = err.Error() + app.QueueUpdateDraw(updateMain) + } else { + play(audioFile) + } + } + + defer func() { + if playingStreamer != nil { + playingStreamer.Close() + } + if app != nil { + app.Stop() + } + }() + + t := time.NewTicker(time.Second) + for { + select { + case <-done: + return + case <-t.C: + app.QueueUpdateDraw(updateStatus) + } + } +}