commit fdfdcecce7fed13b691a85c26dddd06a18435a0f Author: Trevor Slocum Date: Wed Jan 8 15:38:41 2020 -0800 Initial commit 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) + } + } +}