539 lines
11 KiB
Go
539 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"math/rand"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/gopxl/beep/v2"
|
|
"github.com/gopxl/beep/v2/effects"
|
|
"github.com/gopxl/beep/v2/flac"
|
|
"github.com/gopxl/beep/v2/mp3"
|
|
"github.com/gopxl/beep/v2/speaker"
|
|
"github.com/gopxl/beep/v2/vorbis"
|
|
"github.com/gopxl/beep/v2/wav"
|
|
)
|
|
|
|
var (
|
|
file = flag.String("f", "", "file or dir to play")
|
|
volume = flag.Float64("v", -5, "base volume")
|
|
recurse = flag.Bool("r", false, "recurse into directories")
|
|
sampleRate = flag.Int("s", 44100, "sample rate")
|
|
config = flag.String("c", "$HOME/.config/ply/session.conf", "config file")
|
|
playing = false
|
|
title = true
|
|
vmin = -10.0
|
|
help = false
|
|
playlist []string
|
|
)
|
|
|
|
func drawTextLine(screen tcell.Screen, x, y int, s string, style tcell.Style) {
|
|
for _, r := range s {
|
|
screen.SetContent(x, y, r, nil, style)
|
|
x++
|
|
}
|
|
}
|
|
|
|
type audioPanel struct {
|
|
sampleRate beep.SampleRate
|
|
streamer beep.StreamSeeker
|
|
ctrl *beep.Ctrl
|
|
resampler *beep.Resampler
|
|
volume *effects.Volume
|
|
file string
|
|
prev string
|
|
next string
|
|
paused bool
|
|
done chan bool
|
|
}
|
|
|
|
func decode(file string, f *os.File) (beep.StreamSeekCloser, beep.Format, error) {
|
|
var streamer beep.StreamSeekCloser
|
|
var format beep.Format
|
|
var err error
|
|
switch ftype(file) {
|
|
case "mp3":
|
|
streamer, format, err = mp3.Decode(f)
|
|
case "flac":
|
|
streamer, format, err = flac.Decode(f)
|
|
case "wav":
|
|
streamer, format, err = wav.Decode(f)
|
|
case "ogg":
|
|
streamer, format, err = vorbis.Decode(f)
|
|
}
|
|
return streamer, format, err
|
|
}
|
|
|
|
func ftype(file string) string {
|
|
f, err := os.Stat(file)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if f.IsDir() {
|
|
return "dir"
|
|
}
|
|
switch filepath.Ext(file) {
|
|
case ".mp3":
|
|
return "mp3"
|
|
case ".flac":
|
|
return "flac"
|
|
case ".wav":
|
|
return "wav"
|
|
case ".ogg":
|
|
return "ogg"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func getFiles(dir string) error {
|
|
files, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, file := range files {
|
|
ft := ftype(filepath.Join(dir, file.Name()))
|
|
if ft == "dir" && *recurse {
|
|
err := getFiles(filepath.Join(dir, file.Name()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if ft == "mp3" || ft == "flac" || ft == "wav" || ft == "ogg" {
|
|
playlist = append(playlist, filepath.Join(dir, file.Name()))
|
|
}
|
|
}
|
|
if len(playlist) == 0 {
|
|
return fmt.Errorf("no supported files found in %s", dir)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func newAudioPanel(format beep.SampleRate, streamer beep.StreamSeeker) *audioPanel {
|
|
ctrl := &beep.Ctrl{Streamer: streamer, Paused: false}
|
|
resampler := beep.Resample(6, format, beep.SampleRate(*sampleRate), ctrl)
|
|
volume := &effects.Volume{Streamer: resampler, Base: 2, Volume: *volume}
|
|
return &audioPanel{format, streamer, ctrl, resampler, volume, "", "", "", false, make(chan bool)}
|
|
}
|
|
|
|
func (ap *audioPanel) draw(screen tcell.Screen) {
|
|
terminalWidth, _ := screen.Size()
|
|
center := terminalWidth / 2
|
|
|
|
mainStyle := tcell.StyleDefault.
|
|
Foreground(tcell.NewHexColor(0xFFFFFF))
|
|
statusStyle := mainStyle.
|
|
Foreground(tcell.NewHexColor(0xF012BE)).
|
|
Bold(true)
|
|
|
|
screen.Fill(' ', mainStyle)
|
|
|
|
speaker.Lock()
|
|
position := ap.sampleRate.D(ap.streamer.Position())
|
|
length := ap.sampleRate.D(ap.streamer.Len())
|
|
volume := ap.volume.Volume
|
|
song := filepath.Base(ap.file)
|
|
speaker.Unlock()
|
|
|
|
_, h := screen.Size()
|
|
c := (h - 3) / 2
|
|
if h < 10 {
|
|
c = 0
|
|
}
|
|
|
|
if h > 5 {
|
|
if title {
|
|
title := fmt.Sprintf("PLY - Simple Audio Player")
|
|
drawTextLine(screen, center-len(title)/2, c, title, mainStyle)
|
|
}
|
|
if help {
|
|
binds := fmt.Sprintf("[ARROWS] Volume/Seek | [PGUP/PGDN] Change track | [SPACE] Pause | [S] Shuffle | [T] Hide title")
|
|
drawTextLine(screen, center-len(binds)/2, c+1, binds, mainStyle)
|
|
} else if title {
|
|
binds := fmt.Sprintf("[Q] Quit | [H] Help")
|
|
drawTextLine(screen, center-len(binds)/2, c+1, binds, mainStyle)
|
|
}
|
|
}
|
|
|
|
if ap.paused {
|
|
drawTextLine(screen, 0, h-3, "Paused", mainStyle)
|
|
} else if playing {
|
|
drawTextLine(screen, 0, h-3, "Playing", mainStyle)
|
|
} else {
|
|
drawTextLine(screen, 0, h-3, "Stopped", mainStyle)
|
|
}
|
|
|
|
drawTextLine(screen, center-utf8.RuneCountInString(song)/2, h-3, song, statusStyle)
|
|
|
|
positionStatus := fmt.Sprintf(" %v / %v ", position.Round(time.Second), length.Round(time.Second))
|
|
volumeStatus := fmt.Sprintf("%.2f", volume)
|
|
drawTextLine(screen, terminalWidth-len(volumeStatus)-1, h-3, volumeStatus, statusStyle)
|
|
|
|
next := filepath.Base(ap.next)
|
|
prev := filepath.Base(ap.prev)
|
|
if ap.prev != "null" {
|
|
drawTextLine(screen, 0, h-2, "<", mainStyle)
|
|
drawTextLine(screen, 2, h-2, prev, mainStyle)
|
|
}
|
|
if ap.next != "null" {
|
|
drawTextLine(screen, terminalWidth-1, h-2, ">", mainStyle)
|
|
drawTextLine(screen, terminalWidth-utf8.RuneCountInString(next)-2, h-2, next, mainStyle)
|
|
}
|
|
|
|
// Draw progress bar
|
|
progress := int(float64(terminalWidth)*float64(position)/float64(length) - 1)
|
|
for x := 1; x < progress; x++ {
|
|
drawTextLine(screen, x, h-1, "-", mainStyle)
|
|
}
|
|
drawTextLine(screen, progress, h-1, ">", statusStyle)
|
|
for x := progress + 1; x < terminalWidth; x++ {
|
|
drawTextLine(screen, x, h-1, " ", statusStyle)
|
|
}
|
|
drawTextLine(screen, 0, h-1, "[", statusStyle)
|
|
drawTextLine(screen, terminalWidth-1, h-1, "]", statusStyle)
|
|
|
|
drawTextLine(screen, center-len(positionStatus)/2, h-1, positionStatus, statusStyle)
|
|
}
|
|
|
|
func (ap *audioPanel) handle(event tcell.Event) (action string) {
|
|
switch event := event.(type) {
|
|
case *tcell.EventKey:
|
|
if event.Key() == tcell.KeyLeft || event.Key() == tcell.KeyRight {
|
|
speaker.Lock()
|
|
newPos := ap.streamer.Position()
|
|
if event.Key() == tcell.KeyLeft {
|
|
newPos -= ap.sampleRate.N(time.Second * 5)
|
|
}
|
|
if event.Key() == tcell.KeyRight {
|
|
newPos += ap.sampleRate.N(time.Second * 5)
|
|
}
|
|
newPos = max(newPos, 0)
|
|
newPos = min(newPos, ap.streamer.Len()-1)
|
|
|
|
if err := ap.streamer.Seek(newPos); err != nil {
|
|
fmt.Println(err)
|
|
}
|
|
speaker.Unlock()
|
|
return "seek"
|
|
}
|
|
|
|
if event.Key() == tcell.KeyUp {
|
|
if ap.volume.Volume < -0 {
|
|
speaker.Lock()
|
|
ap.volume.Volume += 0.1
|
|
if ap.volume.Volume > 0 {
|
|
ap.volume.Volume = 0
|
|
}
|
|
*volume = ap.volume.Volume
|
|
if ap.volume.Volume > vmin {
|
|
ap.volume.Silent = false
|
|
}
|
|
speaker.Unlock()
|
|
}
|
|
return "volume"
|
|
}
|
|
|
|
if event.Key() == tcell.KeyDown {
|
|
if ap.volume.Volume > vmin+0.1 {
|
|
speaker.Lock()
|
|
ap.volume.Volume -= 0.1
|
|
*volume = ap.volume.Volume
|
|
if ap.volume.Volume <= vmin+0.1 {
|
|
ap.volume.Volume = vmin
|
|
ap.volume.Silent = true
|
|
}
|
|
speaker.Unlock()
|
|
}
|
|
return "volume"
|
|
}
|
|
|
|
if event.Key() == tcell.KeyPgUp {
|
|
return "next"
|
|
}
|
|
|
|
if event.Key() == tcell.KeyPgDn {
|
|
return "prev"
|
|
}
|
|
|
|
if event.Key() != tcell.KeyRune {
|
|
return ""
|
|
}
|
|
|
|
switch unicode.ToLower(event.Rune()) {
|
|
case 'q':
|
|
return "quit"
|
|
case 's':
|
|
return "shuffle"
|
|
case 't':
|
|
title = !title
|
|
return "title"
|
|
case 'h':
|
|
help = !help
|
|
return "help"
|
|
case ' ':
|
|
speaker.Lock()
|
|
if ap.paused {
|
|
ap.paused = false
|
|
speaker.Resume()
|
|
} else {
|
|
ap.paused = true
|
|
speaker.Suspend()
|
|
}
|
|
speaker.Unlock()
|
|
return "pause"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func getPos(i int) (string, string) {
|
|
var prev, next string
|
|
if i == 0 {
|
|
prev = "null"
|
|
} else {
|
|
prev = playlist[i-1]
|
|
}
|
|
if i == len(playlist)-1 {
|
|
next = playlist[0]
|
|
} else {
|
|
next = playlist[i+1]
|
|
}
|
|
return prev, next
|
|
}
|
|
|
|
func play() error {
|
|
screen, err := tcell.NewScreen()
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
err = screen.Init()
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
defer screen.Fini()
|
|
|
|
events := make(chan tcell.Event)
|
|
go func() {
|
|
for {
|
|
if playing {
|
|
events <- screen.PollEvent()
|
|
}
|
|
time.Sleep(time.Millisecond * 20)
|
|
if screen.HasPendingEvent() {
|
|
screen.PollEvent()
|
|
}
|
|
}
|
|
}()
|
|
|
|
err = speaker.Init(beep.SampleRate(*sampleRate), beep.SampleRate(*sampleRate).N(time.Second/30))
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
defer speaker.Close()
|
|
|
|
for i := 0; i < len(playlist); i++ {
|
|
file := playlist[i]
|
|
f, err := os.Open(file)
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
defer f.Close()
|
|
|
|
streamer, format, err := decode(file, f)
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
defer streamer.Close()
|
|
|
|
ap := newAudioPanel(format.SampleRate, streamer)
|
|
ap.file = file
|
|
ap.prev, ap.next = getPos(i)
|
|
|
|
screen.Clear()
|
|
ap.draw(screen)
|
|
screen.Show()
|
|
|
|
time.Sleep(time.Millisecond * 100)
|
|
|
|
speaker.Play(beep.Seq(ap.volume, beep.Callback(func() {
|
|
close(ap.done)
|
|
})))
|
|
|
|
playing = true
|
|
|
|
seconds := time.Tick(time.Second)
|
|
|
|
loop:
|
|
for {
|
|
select {
|
|
case event := <-events:
|
|
action := ap.handle(event)
|
|
if action == "quit" {
|
|
speaker.Clear()
|
|
screen.Fini()
|
|
return nil
|
|
}
|
|
if action == "shuffle" {
|
|
i = -1
|
|
shufflePlaylist()
|
|
close(ap.done)
|
|
}
|
|
if action == "prev" {
|
|
if i >= 1 {
|
|
i -= 2
|
|
} else {
|
|
i -= 1
|
|
}
|
|
close(ap.done)
|
|
}
|
|
if action == "next" {
|
|
if i >= len(playlist)-1 {
|
|
i = -1
|
|
}
|
|
close(ap.done)
|
|
}
|
|
if action != "" {
|
|
screen.Clear()
|
|
ap.draw(screen)
|
|
screen.Show()
|
|
}
|
|
case <-seconds:
|
|
if ap.paused {
|
|
continue
|
|
}
|
|
screen.Clear()
|
|
ap.draw(screen)
|
|
screen.Show()
|
|
case <-ap.done:
|
|
playing = false
|
|
screen.Clear()
|
|
speaker.Clear()
|
|
if ap.paused {
|
|
ap.paused = false
|
|
speaker.Resume()
|
|
}
|
|
break loop
|
|
}
|
|
}
|
|
if i == len(playlist)-1 {
|
|
i = 0
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func shufflePlaylist() {
|
|
for i := range playlist {
|
|
j := rand.Intn(i + 1)
|
|
playlist[i], playlist[j] = playlist[j], playlist[i]
|
|
}
|
|
}
|
|
|
|
func getConfig() error {
|
|
// Expand $HOME
|
|
*config = os.ExpandEnv(*config)
|
|
|
|
f, err := os.Open(*config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
var v float64
|
|
_, err = fmt.Fscanf(f, "%f", &v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*volume = v
|
|
return nil
|
|
}
|
|
|
|
func writeConfig() error {
|
|
// Expand $HOME
|
|
*config = os.ExpandEnv(*config)
|
|
|
|
// Create config folder if it doesn't exist
|
|
if _, err := os.Stat(filepath.Dir(*config)); os.IsNotExist(err) {
|
|
err := os.MkdirAll(filepath.Dir(*config), 0755)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Create config file
|
|
f, err := os.Create(*config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
_, err = fmt.Fprintf(f, "%f", *volume)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
|
|
if *volume > 0 {
|
|
fmt.Println("volume must be less than 0")
|
|
os.Exit(1)
|
|
}
|
|
|
|
err := getConfig()
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
}
|
|
|
|
if *file == "" {
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
*file = wd
|
|
}
|
|
|
|
if ftype(*file) == "" {
|
|
fmt.Println("unsupported file type")
|
|
os.Exit(1)
|
|
}
|
|
|
|
if ftype(*file) == "dir" {
|
|
err := getFiles(*file)
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
playlist = append(playlist, *file)
|
|
}
|
|
|
|
err = play()
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
err = writeConfig()
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
os.Exit(0)
|
|
}
|