git.sophuwu.com > melgody
started from scratch, made a TUI with nicer interface and more features.
now can pause/play
sophuwu sophie@sophuwu.com
Sun, 07 Sep 2025 09:32:12 +0200
commit

5feb09dac6774a8edd939377194a763f7ce5b0ba

parent

ae16405d04cf1b12430cba656e9d21968c25ed8b

4 files changed, 298 insertions(+), 96 deletions(-)

jump to
M .gitignore.gitignore

@@ -1,5 +1,3 @@

.idea -melgody -melgody.sig.txt build
M go.modgo.mod

@@ -1,6 +1,6 @@

module melgody -go 1.20 +go 1.23.0 require github.com/faiface/beep v1.1.0

@@ -11,5 +11,6 @@ github.com/pkg/errors v0.9.1 // indirect

golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 // indirect golang.org/x/image v0.0.0-20220302094943-723b81ca9867 // indirect golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 // indirect - golang.org/x/sys v0.0.0-20220315194320-039c03cc5b86 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect )
M go.sumgo.sum

@@ -37,6 +37,10 @@ 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-20220315194320-039c03cc5b86 h1:A9i04dxx7Cribqbs8jf3FQLogkL/CV2YN7hj9KWJCkc= golang.org/x/sys v0.0.0-20220315194320-039c03cc5b86/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
M main.gomain.go

@@ -2,142 +2,341 @@ package main

import ( "fmt" + "time" + + "errors" "github.com/faiface/beep" "github.com/faiface/beep/mp3" "github.com/faiface/beep/speaker" + "golang.org/x/term" + "math/rand" "os" + "path/filepath" "strings" - "time" ) -var ( - curdir string - done = make(chan bool) -) +var ErrInvalidFile = errors.New("invalid or inaccessible file") +var ErrUnsupportedFileType = errors.New("unsupported file type") -func getallfiles() []string { - var files []string - f, err := os.Open(curdir) +type MusicFile struct { + Name string + Path string + Type int8 +} +type Playlist []MusicFile + +func GetMF(path string) (*MusicFile, error) { + ext := filepath.Ext(path) + if ext != ".mp3" { + return nil, ErrUnsupportedFileType + } + return &MusicFile{ + Name: strings.TrimSuffix(filepath.Base(path), ext), + Path: path, + }, nil +} + +func (pl *Playlist) addMp3(path string) error { + mf, err := GetMF(path) if err != nil { - fmt.Println(err) - os.Exit(1) + return err } - files, err = f.Readdirnames(0) + var st os.FileInfo + st, err = os.Stat(mf.Path) + if err != nil || st.IsDir() || !st.Mode().IsRegular() { + return ErrInvalidFile + } + *pl = append(*pl, *mf) + return nil +} + +func (pl *Playlist) addDir(dir string) error { + entries, err := os.ReadDir(dir) if err != nil { - fmt.Println(err) - os.Exit(1) + return err } - f.Close() - var songs []string - for _, file := range files { - if file[len(file)-4:] == ".mp3" { - songs = append(songs, file) + for _, entry := range entries { + if entry.IsDir() { + continue } + pl.addMp3(filepath.Join(dir, entry.Name())) } - return songs + return nil } -func getargsongs() []string { - var songs []string - for _, song := range os.Args[1:] { - info, err := os.Stat(song) - if err == nil && len(song) > 4 && song[len(song)-4:] == ".mp3" && !info.IsDir() && info.Mode().IsRegular() { - songs = append(songs, song) - } else { - fmt.Println(song, "is not a valid mp3 file.") +func (pl *Playlist) AddFiles(paths ...string) { + var fi os.FileInfo + var err error + for _, path := range paths { + fi, err = os.Stat(path) + if err != nil { + continue + } + if fi.IsDir() { + _ = pl.addDir(path) + } else if fi.Mode().IsRegular() { + _ = pl.addMp3(path) } } - return songs } -func showqueue(songs []string) { - if len(songs) == 0 { - fmt.Println("\nEmpty queue.") - os.Exit(0) +func (pl *Playlist) Len() int { + return len(*pl) +} + +func (pl *Playlist) IsEmpty() bool { + return len(*pl) == 0 +} + +type Queue struct { + Shuffle bool + Repeat bool + UpNext []MusicFile + Source *Playlist +} + +func (q *Queue) Fill() { + l := q.Source.Len() + q.UpNext = make([]MusicFile, l) + if q.Shuffle { + for i, v := range rand.Perm(l) { + q.UpNext[v] = (*q.Source)[i] + } + } else { + copy(q.UpNext, *q.Source) } - var tmpstr string - tmpstr = strings.Split(songs[0], "/")[len(strings.Split(songs[0], "/"))-1] - tmpstr = tmpstr[:len(tmpstr)-4] - fmt.Printf("\nNow Playing:\n%s\n\n", tmpstr) - if len(songs) == 1 { - return +} + +func (pl *Playlist) Player(shuffle, repeat bool) *Player { + player = &Player{ + Q: &Queue{ + Shuffle: shuffle, + Repeat: repeat, + Source: pl, + }, + nx: make(chan struct{}), + end: make(chan struct{}), } - fmt.Println("Up Next:") - var loopmax int = 5 - if len(songs) < loopmax { - loopmax = len(songs) + return player +} + +func (q *Queue) Next() *MusicFile { + if len(q.UpNext) == 0 { + if q.Repeat { + q.Fill() + } else { + return nil + } } - for i := 1; i < loopmax; i++ { - tmpstr = strings.Split(songs[i], "/")[len(strings.Split(songs[i], "/"))-1] - tmpstr = tmpstr[:len(tmpstr)-4] - fmt.Printf("%d: %s\n", i, tmpstr) + next := q.UpNext[0] + q.UpNext = q.UpNext[1:] + return &next +} + +type Player struct { + Q *Queue + Np *MusicFile + skip func() + ctrl beep.Ctrl + nx, end chan struct{} +} + +func (p *Player) TogglePause() { + if p.ctrl.Paused { + p.ctrl.Paused = false + } else { + p.ctrl.Paused = true } - fmt.Println("") } -func shuffle(songs *[]string) { - var tmpstr string - var tmpint int - for i := 0; i < len(*songs); i++ { - tmpint = rand.Intn(len(*songs)) - tmpstr = (*songs)[i] - (*songs)[i] = (*songs)[tmpint] - (*songs)[tmpint] = tmpstr +func (p *Player) Skip() { + if p.skip != nil { + p.skip() } } -func playlist(songs []string) { - for i := range songs { - showqueue(songs[i:]) - play(songs[i]) +func (p *Player) play(song *MusicFile) (err error) { + speaker.Clear() + + wait := make(chan struct{}) + var streamer beep.StreamSeekCloser + var format beep.Format + + file, err := os.Open(song.Path) + if err != nil { + goto ret + } + + streamer, format, err = mp3.Decode(file) + if err != nil { + goto retOne } -} -func play(song string) { - file, _ := os.Open(song) - streamer, format, _ := mp3.Decode(file) - speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/10)) - done = make(chan bool) - speaker.Play(beep.Seq(streamer, beep.Callback(func() { - done <- true - }))) - <-done + err = speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/10)) + if err != nil { + goto retTwo + } + + p.skip = func() { + p.skip = nil + wait <- struct{}{} + close(wait) + } + + p.ctrl = beep.Ctrl{Streamer: streamer, Paused: p.ctrl.Paused} + speaker.Play(beep.Seq(&p.ctrl, beep.Callback(p.skip))) + + <-wait + speaker.Clear() + +retTwo: streamer.Close() +retOne: file.Close() +ret: + return err } -func skipsong() { - var input string +func (p *Player) Play(nx chan struct{}) (err error) { + p.Q.Fill() + p.end = make(chan struct{}) for { - time.Sleep(200 * time.Millisecond) - fmt.Print("Enter 'skip' to skip the current song: ") - fmt.Scanln(&input) - if input == "skip" { - speaker.Clear() - done <- true + p.Np = p.Q.Next() + if p.Np == nil { + break + } + nx <- struct{}{} + err = p.play(p.Np) + if err != nil { + return err } } + close(nx) + p.end <- struct{}{} + close(p.end) + return nil +} + +func (p *Player) Quit() { + p.Q.Repeat = false + p.Q.UpNext = []MusicFile{} + p.Np = nil + p.Skip() + <-p.end +} + +var player *Player + +func safeExit(code int) { + player.Quit() + DisableRawMode() + time.Sleep(20 * time.Millisecond) + os.Exit(code) } func main() { - tmpstr, err := os.Getwd() + var err error + if len(os.Args) < 2 { + fmt.Printf("Usage: %s <path>...\n", filepath.Base(os.Args[0])) + fmt.Printf("Files must be mp3, directories will be scanned for mp3 files.\n") + os.Exit(1) + } + + var playlist Playlist + playlist.AddFiles(os.Args[1:]...) + + if len(playlist) == 0 { + fmt.Println("No supported music files found in the directory.") + os.Exit(0) + } + + playlist.Player(true, true) + EnableRawMode() + defer DisableRawMode() + + go func() { + var er error + var n int + buf := make([]byte, 4) + for { + if n, er = os.Stdin.Read(buf); er != nil { + safeExit(1) + } + switch string(buf[:n]) { + case "q", "Q", "\x03", "\x04": + player.Quit() + case " ": + player.TogglePause() + case "s", "S": + player.Skip() + } + } + }() + + nx := make(chan struct{}) + go func() { + for ok := true; ok; _, ok = <-nx { + fmt.Print("\033[2J\033[1;1H") // Clear screen + fmt.Print("Controls: [space] Pause/Play | [s] Skip | [q] Quit\n\r") + if player.Np == nil { + fmt.Print("No song is currently playing.\n\r") + } else { + fmt.Printf("Now Playing: %s\n\r", player.Np.Name) + } + fmt.Print("Up Next:\n\r") + if len(player.Q.UpNext) == 0 { + fmt.Print(" (queue is empty)\n\r") + continue + } + for i, song := range player.Q.UpNext { + if i >= 5 { + fmt.Printf(" ...and %d more\n\r", len(player.Q.UpNext)-i) + break + } + fmt.Printf(" %d. %s\n\r", i+1, song.Name) + } + } + }() + + err = player.Play(nx) + if err != nil { + fmt.Println("An error occured during playback:", err.Error()) + safeExit(1) + } + + safeExit(0) + +} + +var oldState *term.State +var rawEnabled bool + +func EnableRawMode() { + if rawEnabled { + return + } + rawEnabled = true + var err error + oldState, err = term.MakeRaw(int(os.Stdin.Fd())) if err != nil { - fmt.Println("An error occured while getting the directory.") + fmt.Println("An error occured while setting terminal to raw mode.") os.Exit(1) } - curdir = tmpstr - var songs []string - if len(os.Args) <= 1 { - songs = getallfiles() - shuffle(&songs) + fmt.Print("\033[?1049h") +} + +func DisableRawMode() { + if !rawEnabled { + return + } + rawEnabled = false + if r := recover(); r != nil { + term.Restore(int(os.Stdin.Fd()), oldState) // restore terminal when program exits + fmt.Print("\033[?1049l") + panic(r) } else { - songs = getargsongs() + term.Restore(int(os.Stdin.Fd()), oldState) // restore terminal when program exits + fmt.Print("\033[?1049l") } - if len(songs) == 0 { - fmt.Println("No songs found.") - os.Exit(0) - } - go playlist(songs) - skipsong() -}+}