Initial commit
This commit is contained in:
commit
fdfdcecce7
16 changed files with 1193 additions and 0 deletions
14
.builds/amd64_freebsd.yml
Normal file
14
.builds/amd64_freebsd.yml
Normal 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 ./...
|
14
.builds/amd64_linux_alpine.yml
Normal file
14
.builds/amd64_linux_alpine.yml
Normal 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
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
.idea/
|
||||
dist/
|
||||
vendor/
|
||||
*.sh
|
||||
ditty
|
2
CHANGELOG
Normal file
2
CHANGELOG
Normal file
|
@ -0,0 +1,2 @@
|
|||
0.1.0:
|
||||
- Initial release
|
5
CONFIGURATION.md
Normal file
5
CONFIGURATION.md
Normal 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
21
LICENSE
Normal 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
32
README.md
Normal 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
199
audio.go
Normal 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
19
go.mod
Normal 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
109
go.sum
Normal 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
29
goreleaser.yml
Normal 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
341
gui.go
Normal 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
106
gui_key.go
Normal 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
76
gui_mouse.go
Normal 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
92
library.go
Normal 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
129
main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue