Initial commit

This commit is contained in:
Trevor Slocum 2020-01-08 15:38:41 -08:00
commit fdfdcecce7
16 changed files with 1193 additions and 0 deletions

14
.builds/amd64_freebsd.yml Normal file
View File

@ -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 ./...

View File

@ -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 ./...

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.idea/
dist/
vendor/
*.sh
ditty

2
CHANGELOG Normal file
View File

@ -0,0 +1,2 @@
0.1.0:
- Initial release

5
CONFIGURATION.md Normal file
View File

@ -0,0 +1,5 @@
This document covers the [ditty](https://git.sr.ht/~tslocum/ditty) command-line options.
# TODO
WIP

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Trevor Slocum <trevor@rocketnine.space>
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.

32
README.md Normal file
View File

@ -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).

199
audio.go Normal file
View File

@ -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 "?"
}
}

19
go.mod Normal file
View File

@ -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
)

109
go.sum Normal file
View File

@ -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=

29
goreleaser.yml Normal file
View File

@ -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'

341
gui.go Normal file
View File

@ -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)
}

106
gui_key.go Normal file
View File

@ -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
}

76
gui_mouse.go Normal file
View File

@ -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
}

92
library.go Normal file
View File

@ -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
}

129
main.go Normal file
View File

@ -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 <trevor@rocketnine.space>
`
)
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)
}
}
}