Files
ply/ply.go
tchivert eaa2e3f219
build / build (push) Successful in 2m8s
add playlist repeat
2024-08-22 23:25:23 +02:00

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