├── test ├── pop │ └── arbitrary_file.txt ├── rap │ ├── audio_test.mp3 │ ├── audio_test1.mp3 │ └── audio_test2.mp3 ├── config-test └── config ├── .travis.yml ├── aur-build.sh ├── command_test.go ├── .gitignore ├── lyric ├── lyric_test.go ├── lyric.go ├── lyric_en.go ├── sample-clean.lrc ├── sample-unclean.lrc ├── lyric_cn.go └── lrc.go ├── main.go ├── hook ├── hook.go └── hook_test.go ├── playingbar_test.go ├── Makefile ├── go.mod ├── anko ├── convert.go ├── anko.go └── anko_test.go ├── playlist_test.go ├── start_test.go ├── invidious └── invidious.go ├── colors.go ├── gomu.go ├── player ├── audiofile.go └── player.go ├── utils_test.go ├── queue_test.go ├── README.md ├── utils.go ├── playingbar.go ├── command.go ├── start.go ├── tageditor.go ├── queue.go ├── LICENSE └── go.sum /test/pop/arbitrary_file.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/rap/audio_test.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raziman18/gomu/HEAD/test/rap/audio_test.mp3 -------------------------------------------------------------------------------- /test/rap/audio_test1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raziman18/gomu/HEAD/test/rap/audio_test1.mp3 -------------------------------------------------------------------------------- /test/rap/audio_test2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raziman18/gomu/HEAD/test/rap/audio_test2.mp3 -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - '1.14.x' 4 | - '1.13.x' 5 | 6 | before_install: 7 | - sudo apt-get update 8 | - sudo apt-get -y install libasound2-dev 9 | 10 | script: 11 | - go test ./... 12 | -------------------------------------------------------------------------------- /aur-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | VERSION=$(git describe --abbrev=0 --tags) 4 | VERSION=${VERSION#v} 5 | 6 | cd ./dist/gomu 7 | 8 | CHECKSUM=$(makepkg -g) 9 | 10 | sed -i "s/^pkgver.*/pkgver=$VERSION/" PKGBUILD 11 | sed -i "s/^md5sums.*/$CHECKSUM/" PKGBUILD 12 | makepkg --printsrcinfo > .SRCINFO 13 | -------------------------------------------------------------------------------- /command_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetFn(t *testing.T) { 10 | 11 | c := newCommand() 12 | 13 | c.define("sample", func() {}) 14 | 15 | f, err := c.getFn("sample") 16 | if err != nil { 17 | t.Error(err) 18 | } 19 | 20 | assert.NotNil(t, f) 21 | 22 | f, err = c.getFn("x") 23 | assert.Error(t, err) 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | gomu 8 | 9 | # Test binary, built with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | # Dependency directories (remove the comment below to include it) 16 | # vendor/ 17 | music/ 18 | message.log 19 | race.txt 20 | dist/ 21 | bin/ 22 | 23 | coverage.html -------------------------------------------------------------------------------- /lyric/lyric_test.go: -------------------------------------------------------------------------------- 1 | package lyric 2 | 3 | import ( 4 | "io/ioutil" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestCleanHTML(t *testing.T) { 11 | 12 | clean, err := ioutil.ReadFile("./sample-clean.lrc") 13 | if err != nil { 14 | t.Error(err) 15 | } 16 | 17 | unclean, err := ioutil.ReadFile("./sample-unclean.lrc") 18 | if err != nil { 19 | t.Error(err) 20 | } 21 | 22 | got := cleanHTML(string(unclean)) 23 | 24 | assert.Equal(t, string(clean), got) 25 | 26 | var lyric Lyric 27 | err = lyric.NewFromLRC(got) 28 | if err != nil { 29 | t.Error(err) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 Raziman 2 | 3 | package main 4 | 5 | import ( 6 | "log" 7 | "os" 8 | "path" 9 | 10 | "github.com/rivo/tview" 11 | ) 12 | 13 | func main() { 14 | setupLog() 15 | os.Setenv("TEST", "false") 16 | args := getArgs() 17 | 18 | app := tview.NewApplication() 19 | 20 | // main loop 21 | start(app, args) 22 | } 23 | 24 | func setupLog() { 25 | tmpDir := os.TempDir() 26 | logFile := path.Join(tmpDir, "gomu.log") 27 | file, e := os.OpenFile(logFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) 28 | if e != nil { 29 | log.Fatalf("Error opening file %s", logFile) 30 | } 31 | 32 | log.SetOutput(file) 33 | log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) 34 | } 35 | -------------------------------------------------------------------------------- /hook/hook.go: -------------------------------------------------------------------------------- 1 | // Package hook is handling event hookds 2 | package hook 3 | 4 | type EventHook struct { 5 | events map[string][]func() 6 | } 7 | 8 | // NewEventHook returns new instance of EventHook 9 | func NewEventHook() *EventHook { 10 | return &EventHook{make(map[string][]func())} 11 | } 12 | 13 | // AddHook accepts a function which will be executed when the event is emitted. 14 | func (e *EventHook) AddHook(eventName string, handler func()) { 15 | 16 | hooks, ok := e.events[eventName] 17 | if !ok { 18 | e.events[eventName] = []func(){handler} 19 | return 20 | } 21 | 22 | e.events[eventName] = append(hooks, handler) 23 | } 24 | 25 | // RunHooks executes all hooks installed for an event. 26 | func (e *EventHook) RunHooks(eventName string) { 27 | 28 | hooks, ok := e.events[eventName] 29 | if !ok { 30 | return 31 | } 32 | 33 | for _, hook := range hooks { 34 | hook() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /playingbar_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/issadarkthing/gomu/player" 7 | ) 8 | 9 | const ( 10 | testConfigPath = "./test/config-test" 11 | ) 12 | 13 | func Test_NewPlayingBar(t *testing.T) { 14 | 15 | gomu = newGomu() 16 | err := execConfig(expandFilePath(testConfigPath)) 17 | if err != nil { 18 | t.Error(err) 19 | } 20 | 21 | gomu.colors = newColor() 22 | 23 | p := newPlayingBar() 24 | 25 | if p.update == nil { 26 | t.Errorf("chan int == nil") 27 | } 28 | 29 | } 30 | 31 | func Test_NewProgress(t *testing.T) { 32 | 33 | p := newPlayingBar() 34 | full := 100 35 | audio := new(player.AudioFile) 36 | audio.SetPath("./test/rap/audio_test.mp3") 37 | 38 | p.newProgress(audio, full) 39 | 40 | if p.full != int32(full) { 41 | t.Errorf("Expected %d; got %d", full, p.full) 42 | } 43 | 44 | if p.progress != 0 { 45 | t.Errorf("Expected %d; got %d", 0, p.progress) 46 | } 47 | 48 | } 49 | 50 | func Test_Stop(t *testing.T) { 51 | 52 | p := newPlayingBar() 53 | 54 | p.stop() 55 | 56 | if p.skip == false { 57 | t.Errorf("Expected %t; got %t", true, p.skip) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /hook/hook_test.go: -------------------------------------------------------------------------------- 1 | package hook 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestAddHook(t *testing.T) { 10 | 11 | h := NewEventHook() 12 | 13 | h.AddHook("a", nil) 14 | h.AddHook("a", nil) 15 | h.AddHook("a", nil) 16 | h.AddHook("a", nil) 17 | 18 | assert.Equal(t, 1, len(h.events), "should only contain 1 event") 19 | 20 | hooks := h.events["a"] 21 | assert.Equal(t, 4, len(hooks), "should contain 4 hooks") 22 | 23 | h.AddHook("b", nil) 24 | h.AddHook("c", nil) 25 | 26 | assert.Equal(t, 3, len(h.events), "should contain 3 events") 27 | } 28 | 29 | func TestRunHooks(t *testing.T) { 30 | 31 | h := NewEventHook() 32 | x := 0 33 | 34 | for i := 0; i < 100; i++ { 35 | h.AddHook("sample", func() { 36 | x++ 37 | }) 38 | } 39 | 40 | h.AddHook("noop", func() { 41 | x++ 42 | }) 43 | 44 | h.AddHook("noop", func() { 45 | x++ 46 | }) 47 | 48 | assert.Equal(t, x, 0, "should not execute any hook") 49 | 50 | h.RunHooks("x") 51 | 52 | assert.Equal(t, x, 0, "should not execute any hook") 53 | 54 | h.RunHooks("sample") 55 | 56 | assert.Equal(t, x, 100, "should only execute event 'sample'") 57 | } 58 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test build format install release 2 | 3 | GIT_PATH = github.com/issadarkthing 4 | BIN_NAME = gomu 5 | REPO_NAME = gomu 6 | BIN_DIR := $(CURDIR)/bin 7 | INSTALL_DIR := $${HOME}/.local/bin 8 | CONFIG_DIR := $${HOME}/.config/gomu 9 | VERSION = $(shell git describe --abbrev=0 --tags) 10 | GIT_COMMIT = $(shell git rev-parse HEAD) 11 | BUILD_DATE = $(shell date '+%Y-%m-%d-%H:%M:%S') 12 | GO = go 13 | 14 | default: build 15 | 16 | test: 17 | @echo === TESTING === 18 | go test ./... 19 | 20 | format: 21 | @echo === FORMATTING === 22 | go fmt ./... 23 | 24 | $(BIN_DIR): 25 | mkdir -p $@ 26 | 27 | $(INSTALL_DIR): 28 | mkdir -p $@ 29 | 30 | $(CONFIG_DIR): 31 | mkdir -p $@ 32 | 33 | build: $(BIN_DIR) 34 | @echo === BUILDING === 35 | ${GO} build -ldflags "-X main.VERSION=${VERSION}" -v -o $(BIN_DIR)/$(BIN_NAME) 36 | 37 | run: build $(BIN_DIR) 38 | bin/gomu -config ./test/config 39 | 40 | install: build $(INSTALL_DIR) $(CONFIG_DIR) 41 | @echo === INSTALLING === 42 | cp ${BIN_DIR}/${BIN_NAME} ${INSTALL_DIR}/${BIN_NAME} 43 | cp test/config ${CONFIG_DIR}/config 44 | 45 | release: build 46 | @echo === RELEASING === 47 | mkdir -p dist 48 | tar czf dist/gomu-${VERSION}-amd64.tar.gz bin/${BIN_NAME} 49 | ./aur-build.sh 50 | -------------------------------------------------------------------------------- /lyric/lyric.go: -------------------------------------------------------------------------------- 1 | package lyric 2 | 3 | import ( 4 | "html" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | // SongTag is the tag information for songs 10 | type SongTag struct { 11 | Artist string 12 | Title string 13 | Album string 14 | URL string 15 | TitleForPopup string 16 | LangExt string 17 | ServiceProvider string 18 | SongID string // SongID and LyricID is returned from cn server. It's not guaranteed to be identical 19 | LyricID string 20 | } 21 | 22 | // LyricFetcher is the interface to get lyrics via different language 23 | type LyricFetcher interface { 24 | LyricFetch(songTag *SongTag) (string, error) 25 | LyricOptions(search string) ([]*SongTag, error) 26 | } 27 | 28 | // cleanHTML parses html text to valid utf-8 text 29 | func cleanHTML(input string) string { 30 | 31 | content := html.UnescapeString(input) 32 | // delete heading tag 33 | re := regexp.MustCompile(`^

.*`) 34 | content = re.ReplaceAllString(content, "") 35 | content = strings.ReplaceAll(content, "\r\n", "") 36 | content = strings.ReplaceAll(content, "\n", "") 37 | content = strings.ReplaceAll(content, "
", "\n") 38 | // remove non-utf8 character 39 | re = regexp.MustCompile(`‚`) 40 | content = re.ReplaceAllString(content, ",") 41 | content = strings.ToValidUTF8(content, " ") 42 | content = strings.Map(func(r rune) rune { 43 | if r == 160 { 44 | return 32 45 | } 46 | return r 47 | }, content) 48 | 49 | return content 50 | } 51 | -------------------------------------------------------------------------------- /test/config-test: -------------------------------------------------------------------------------- 1 | # confirmation popup to add the whole playlist to the queue 2 | confirm_bulk_add = true 3 | confirm_on_exit = true 4 | queue_loop = false 5 | load_prev_queue = true 6 | popup_timeout = "5s" 7 | # change this to directory that contains mp3 files 8 | music_dir = "~/Music" 9 | # url history of downloaded audio will be saved here 10 | history_path = "~/.local/share/gomu/urls" 11 | # some of the terminal supports unicode character 12 | # you can set this to true to enable emojis 13 | use_emoji = true 14 | # initial volume when gomu starts up 15 | volume = 80 16 | # if you experiencing error using this invidious instance, you can change it 17 | # to another instance from this list: 18 | # https://github.com/iv-org/documentation/blob/master/Invidious-Instances.md 19 | invidious_instance = "https://vid.puffyan.us" 20 | 21 | # default emoji here is using awesome-terminal-fonts 22 | # you can change these to your liking 23 | emoji_playlist = "" 24 | emoji_file = "" 25 | emoji_loop = "ﯩ" 26 | emoji_noloop = "" 27 | 28 | # not all colors can be reproducible in terminal 29 | # changing hex colors may or may not produce expected result 30 | color_accent = "#008B8B" 31 | color_background = "none" 32 | color_foreground = "#FFFFFF" 33 | color_now_playing_title = "#017702" 34 | color_playlist = "#008B8B" 35 | color_popup = "#0A0F14" 36 | 37 | # vim: ft=anko cindent 38 | -------------------------------------------------------------------------------- /lyric/lyric_en.go: -------------------------------------------------------------------------------- 1 | package lyric 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | "github.com/gocolly/colly" 9 | ) 10 | 11 | type LyricFetcherEn struct{} 12 | 13 | // LyricFetch should receive SongTag that was returned from GetLyricOptions, and 14 | // returns lyric of the queried song. 15 | func (en LyricFetcherEn) LyricFetch(songTag *SongTag) (string, error) { 16 | 17 | var lyric string 18 | c := colly.NewCollector() 19 | 20 | c.OnHTML("span#ctl00_ContentPlaceHolder1_lbllyrics", func(e *colly.HTMLElement) { 21 | content, err := e.DOM.Html() 22 | if err != nil { 23 | panic(err) 24 | } 25 | 26 | lyric = cleanHTML(content) 27 | }) 28 | 29 | err := c.Visit(songTag.URL + "&type=lrc") 30 | if err != nil { 31 | return "", err 32 | } 33 | if lyric == "" { 34 | return "", fmt.Errorf("no lyric available") 35 | } 36 | if looksLikeLRC(lyric) { 37 | return lyric, nil 38 | } 39 | return "", fmt.Errorf("lyric not compatible") 40 | } 41 | 42 | // LyricOptions queries available song lyrics. It returns slice of SongTag 43 | func (en LyricFetcherEn) LyricOptions(search string) ([]*SongTag, error) { 44 | 45 | var songTags []*SongTag 46 | 47 | c := colly.NewCollector() 48 | 49 | c.OnHTML("#tablecontainer td a", func(e *colly.HTMLElement) { 50 | link := e.Request.AbsoluteURL(e.Attr("href")) 51 | title := strings.TrimSpace(e.Text) 52 | songTag := &SongTag{ 53 | URL: link, 54 | TitleForPopup: title, 55 | LangExt: "en", 56 | } 57 | songTags = append(songTags, songTag) 58 | }) 59 | 60 | query := url.QueryEscape(search) 61 | err := c.Visit("https://www.rentanadviser.com/en/subtitles/subtitles4songs.aspx?src=" + query) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | return songTags, err 67 | } 68 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/issadarkthing/gomu 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc // indirect 7 | github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 // indirect 8 | github.com/PuerkitoBio/goquery v1.8.0 // indirect 9 | github.com/antchfx/htmlquery v1.2.6 // indirect 10 | github.com/antchfx/xmlquery v1.3.14 // indirect 11 | github.com/bogem/id3v2 v1.2.0 //indirect 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/disintegration/imaging v1.6.2 14 | github.com/faiface/beep v1.1.0 15 | github.com/gdamore/tcell v1.4.0 // indirect 16 | github.com/gdamore/tcell/v2 v2.5.4 17 | github.com/gobwas/glob v0.2.3 // indirect 18 | github.com/gocolly/colly v1.2.0 19 | github.com/golang/protobuf v1.5.2 // indirect 20 | github.com/hajimehoshi/go-mp3 v0.3.4 // indirect 21 | github.com/hajimehoshi/oto v1.0.1 // indirect 22 | github.com/kennygrant/sanitize v1.2.4 // indirect 23 | github.com/kylelemons/godebug v1.1.0 // indirect 24 | github.com/logrusorgru/aurora v2.0.3+incompatible // indirect 25 | github.com/mattn/anko v0.1.9 26 | github.com/rivo/tview v0.0.0-20230104153304-892d1a2eb0da 27 | github.com/rivo/uniseg v0.4.3 // indirect 28 | github.com/sahilm/fuzzy v0.1.0 29 | github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect 30 | github.com/stretchr/testify v1.7.0 31 | github.com/temoto/robotstxt v1.1.2 // indirect 32 | github.com/tj/go-spin v1.1.0 33 | github.com/tramhao/id3v2 v1.2.1 34 | github.com/ztrue/tracerr v0.3.0 35 | gitlab.com/diamondburned/ueberzug-go v0.0.0-20190521043425-7c15a5f63b06 36 | golang.org/x/image v0.10.0 // indirect 37 | golang.org/x/mobile v0.0.0-20221110043201-43a038452099 // indirect 38 | google.golang.org/appengine v1.6.7 // indirect 39 | google.golang.org/protobuf v1.28.1 // indirect 40 | ) 41 | -------------------------------------------------------------------------------- /anko/convert.go: -------------------------------------------------------------------------------- 1 | package anko 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/mattn/anko/env" 10 | ) 11 | 12 | // importToX defines type coercion functions 13 | func importToX(e *env.Env) { 14 | 15 | e.Define("bool", func(v interface{}) bool { 16 | rv := reflect.ValueOf(v) 17 | if !rv.IsValid() { 18 | return false 19 | } 20 | nt := reflect.TypeOf(true) 21 | if rv.Type().ConvertibleTo(nt) { 22 | return rv.Convert(nt).Bool() 23 | } 24 | if rv.Type().ConvertibleTo(reflect.TypeOf(1.0)) && rv.Convert(reflect.TypeOf(1.0)).Float() > 0.0 { 25 | return true 26 | } 27 | if rv.Kind() == reflect.String { 28 | s := strings.ToLower(v.(string)) 29 | if s == "y" || s == "yes" { 30 | return true 31 | } 32 | b, err := strconv.ParseBool(s) 33 | if err == nil { 34 | return b 35 | } 36 | } 37 | return false 38 | }) 39 | 40 | e.Define("string", func(v interface{}) string { 41 | if b, ok := v.([]byte); ok { 42 | return string(b) 43 | } 44 | return fmt.Sprint(v) 45 | }) 46 | 47 | e.Define("int", func(v interface{}) int64 { 48 | rv := reflect.ValueOf(v) 49 | if !rv.IsValid() { 50 | return 0 51 | } 52 | nt := reflect.TypeOf(1) 53 | if rv.Type().ConvertibleTo(nt) { 54 | return rv.Convert(nt).Int() 55 | } 56 | if rv.Kind() == reflect.String { 57 | i, err := strconv.ParseInt(v.(string), 10, 64) 58 | if err == nil { 59 | return i 60 | } 61 | f, err := strconv.ParseFloat(v.(string), 64) 62 | if err == nil { 63 | return int64(f) 64 | } 65 | } 66 | if rv.Kind() == reflect.Bool { 67 | if v.(bool) { 68 | return 1 69 | } 70 | } 71 | return 0 72 | }) 73 | 74 | e.Define("float", func(v interface{}) float64 { 75 | rv := reflect.ValueOf(v) 76 | if !rv.IsValid() { 77 | return 0 78 | } 79 | nt := reflect.TypeOf(1.0) 80 | if rv.Type().ConvertibleTo(nt) { 81 | return rv.Convert(nt).Float() 82 | } 83 | if rv.Kind() == reflect.String { 84 | f, err := strconv.ParseFloat(v.(string), 64) 85 | if err == nil { 86 | return f 87 | } 88 | } 89 | if rv.Kind() == reflect.Bool { 90 | if v.(bool) { 91 | return 1.0 92 | } 93 | } 94 | return 0.0 95 | }) 96 | 97 | e.Define("char", func(s rune) string { 98 | return string(s) 99 | }) 100 | 101 | e.Define("rune", func(s string) rune { 102 | if len(s) == 0 { 103 | return 0 104 | } 105 | return []rune(s)[0] 106 | }) 107 | } 108 | -------------------------------------------------------------------------------- /playlist_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/issadarkthing/gomu/player" 10 | "github.com/rivo/tview" 11 | ) 12 | 13 | // Prepares for test 14 | func prepareTest() *Gomu { 15 | 16 | gomu := newGomu() 17 | gomu.player = player.New(0) 18 | gomu.queue = newQueue() 19 | gomu.playlist = &Playlist{ 20 | TreeView: tview.NewTreeView(), 21 | } 22 | gomu.app = tview.NewApplication() 23 | 24 | err := execConfig(expandFilePath(testConfigPath)) 25 | if err != nil { 26 | panic(err) 27 | } 28 | 29 | gomu.colors = newColor() 30 | 31 | rootDir, err := filepath.Abs("./test") 32 | if err != nil { 33 | panic(err) 34 | } 35 | 36 | root := tview.NewTreeNode("music") 37 | rootAudioFile := new(player.AudioFile) 38 | rootAudioFile.SetName(root.GetText()) 39 | rootAudioFile.SetPath(rootDir) 40 | 41 | root.SetReference(rootAudioFile) 42 | populate(root, rootDir, false) 43 | gomu.playlist.SetRoot(root) 44 | 45 | return gomu 46 | } 47 | 48 | func TestPopulate(t *testing.T) { 49 | 50 | gomu = newGomu() 51 | err := execConfig(expandFilePath(testConfigPath)) 52 | if err != nil { 53 | t.Error(err) 54 | } 55 | gomu.colors = newColor() 56 | 57 | rootDir, err := filepath.Abs("./test") 58 | 59 | if err != nil { 60 | panic(err) 61 | } 62 | 63 | expected := 0 64 | walkFn := func(path string, info os.FileInfo, err error) error { 65 | 66 | if info.IsDir() { 67 | expected++ 68 | return nil 69 | } 70 | 71 | f, e := os.Open(path) 72 | if e != nil { 73 | return e 74 | } 75 | 76 | defer f.Close() 77 | 78 | expected++ 79 | 80 | return nil 81 | } 82 | 83 | // calculate the amount of mp3 files and directories 84 | filepath.Walk(rootDir, walkFn) 85 | 86 | root := tview.NewTreeNode(path.Base(rootDir)) 87 | 88 | rootAudioFile := new(player.AudioFile) 89 | rootAudioFile.SetName("Music") 90 | rootAudioFile.SetIsAudioFile(false) 91 | 92 | populate(root, rootDir, false) 93 | gotItems := 0 94 | root.Walk(func(node, _ *tview.TreeNode) bool { 95 | gotItems++ 96 | return true 97 | }) 98 | 99 | // ignore config, config.test and arbitrary_file.txt 100 | gotItems += 3 101 | 102 | if gotItems != expected { 103 | t.Errorf("Invalid amount of file; expected %d got %d", expected, gotItems) 104 | } 105 | 106 | } 107 | 108 | func TestAddAllToQueue(t *testing.T) { 109 | 110 | gomu = prepareTest() 111 | var songs []*tview.TreeNode 112 | 113 | gomu.playlist.GetRoot().Walk(func(node, parent *tview.TreeNode) bool { 114 | 115 | if node.GetReference().(*player.AudioFile).Name() == "rap" { 116 | gomu.playlist.addAllToQueue(node) 117 | } 118 | 119 | return true 120 | }) 121 | 122 | queue := gomu.queue.getItems() 123 | 124 | for i, song := range songs { 125 | 126 | audioFile := song.GetReference().(*player.AudioFile) 127 | 128 | // strips the path of the song in the queue 129 | s := filepath.Base(queue[i]) 130 | 131 | if audioFile.Name() != s { 132 | t.Errorf("Expected \"%s\", got \"%s\"", audioFile.Name(), s) 133 | } 134 | 135 | } 136 | 137 | } 138 | -------------------------------------------------------------------------------- /lyric/sample-clean.lrc: -------------------------------------------------------------------------------- 1 | [00:00.00]by RentAnAdviser.com 2 | [00:00.00] 3 | [00:15.91]We were both young when I first saw you 4 | [00:19.81] 5 | [00:19.91]I close my eyes and the flashback starts 6 | [00:23.41] 7 | [00:23.52]I am standing there 8 | [00:25.72] 9 | [00:26.70]On a balcony in summer air 10 | [00:30.20] 11 | [00:32.11]See the lights see the party the ball gowns 12 | [00:36.11] 13 | [00:36.20]See you make your way through the crowd 14 | [00:39.70] 15 | [00:39.82]And say Hello 16 | [00:41.41] 17 | [00:43.22]le did I know 18 | [00:46.32] 19 | [00:47.92]That you were Romeo you were throwing pebbles 20 | [00:51.92] 21 | [00:52.01]And my daddy said Stay away from Juliet 22 | [00:55.61] 23 | [00:55.71]And I was crying on the staircase 24 | [00:58.40] 25 | [00:58.50]Begging you Please don′t go and I said 26 | [01:04.60] 27 | [01:04.72]Romeo take me somewhere we can be alone 28 | [01:08.72] 29 | [01:08.80]I will be waiting all there′s left to do is run 30 | [01:12.50] 31 | [01:12.62]You′ll be the prince and I will be the princess 32 | [01:16.62] 33 | [01:16.71]It is a love story baby just say Yes 34 | [01:21.91] 35 | [01:24.02]So I sneak out to the garden to see you 36 | [01:28.41] 37 | [01:28.51]We keep quiet ′cause We are dead if they knew 38 | [01:32.01] 39 | [01:32.11]So close your eyes 40 | [01:34.70] 41 | [01:34.91]Escape this town for a le while oh oh 42 | [01:40.11] 43 | [01:40.22]′Cause you were Romeo I was a scarlet letter 44 | [01:44.31] 45 | [01:44.42]And my daddy said Stay away from Juliet 46 | [01:47.92] 47 | [01:48.02]But you were everything to me 48 | [01:50.62] 49 | [01:50.71]I was begging you Please don′t go and I said 50 | [01:57.11] 51 | [01:57.22]Romeo take me somewhere we can be alone 52 | [02:01.12] 53 | [02:01.22]I will be waiting all there′s left to do is run 54 | [02:05.02] 55 | [02:05.12]You′ll be the prince and I will be the princess 56 | [02:09.02] 57 | [02:09.12]It is a love story baby just say Yes 58 | [02:13.02] 59 | [02:13.11]Romeo save me They are trying to tell me how to feel 60 | [02:17.01] 61 | [02:17.12]This love is difficult but It is real 62 | [02:21.02] 63 | [02:21.11]Don′t be afraid we′ll make it out of this mess 64 | [02:25.11] 65 | [02:25.22]It is a love story baby just say Yes 66 | [02:30.12] 67 | [02:44.21]And I got tired of waiting 68 | [02:48.11] 69 | [02:48.42]Wondering if you were ever coming around 70 | [02:52.02] 71 | [02:52.11]My faith in you was fading 72 | [02:56.81] 73 | [02:56.92]When I met you on the outskirts of town 74 | [03:00.12] 75 | [03:00.21]And I said Romeo save me I have been feeling so alone 76 | [03:05.50] 77 | [03:05.61]I keep waiting for you but you never come 78 | [03:09.41] 79 | [03:09.51]Is this in my head I don′t know what to think 80 | [03:12.91] 81 | [03:13.01]He knelt to the ground and pulled out a ring and said 82 | [03:17.51] 83 | [03:17.61]Marry me Juliet you′ll never have to be alone 84 | [03:21.51] 85 | [03:21.61]I love you and that′s all I really know 86 | [03:25.61] 87 | [03:25.71]I talked to your dad go pick out a white dress 88 | [03:29.60] 89 | [03:29.71]It is a love story baby just say ′Yes′ 90 | [03:36.41] 91 | [03:45.61]′Cause we were both young when I first saw you 92 | [03:51.81] 93 | [03:52.81]by RentAnAdviser.com 94 | [04:01.81] 95 | -------------------------------------------------------------------------------- /start_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strconv" 9 | "testing" 10 | 11 | "github.com/issadarkthing/gomu/anko" 12 | "github.com/issadarkthing/gomu/hook" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | // Test default case 17 | func TestGetArgsDefaults(t *testing.T) { 18 | args := getArgs() 19 | assert.Equal(t, *args.config, "~/.config/gomu/config") 20 | assert.Equal(t, *args.empty, false) 21 | assert.Equal(t, *args.music, "~/music") 22 | assert.Equal(t, *args.version, false) 23 | } 24 | 25 | // Test non-standard flags/the empty/version flags 26 | func TestGetArgs(t *testing.T) { 27 | home, err := os.UserHomeDir() 28 | if err != nil { 29 | t.Error(err) 30 | t.FailNow() 31 | } 32 | cfgDir, err := os.UserConfigDir() 33 | if err != nil { 34 | t.Error(err) 35 | t.FailNow() 36 | } 37 | 38 | // Test setting config flag 39 | testConfig := filepath.Join(cfgDir, ".tmp", "gomu") 40 | _, err = os.Stat(testConfig) 41 | if os.IsNotExist(err) { 42 | os.MkdirAll(testConfig, 0755) 43 | } 44 | defer os.RemoveAll(testConfig) 45 | //create a temporary config file 46 | tmpCfgf, err := os.CreateTemp(testConfig, "config") 47 | if err != nil { 48 | t.Error(err) 49 | t.FailNow() 50 | } 51 | 52 | testMusic := filepath.Join(home, ".tmp", "gomu") 53 | _, err = os.Stat(testMusic) 54 | if os.IsNotExist(err) { 55 | os.MkdirAll(testMusic, 0755) 56 | } 57 | defer os.RemoveAll(testMusic) 58 | 59 | boolChecks := []struct { 60 | name string 61 | arg bool 62 | want bool 63 | }{ 64 | {"empty", true, true}, 65 | {"version", true, true}, 66 | } 67 | for _, check := range boolChecks { 68 | t.Run("testing bool flag "+check.name, func(t *testing.T) { 69 | flag.CommandLine.Set(check.name, strconv.FormatBool(check.arg)) 70 | flag.CommandLine.Parse(os.Args[1:]) 71 | assert.Equal(t, check.arg, check.want) 72 | }) 73 | } 74 | strChecks := []struct { 75 | name string 76 | arg string 77 | want string 78 | }{ 79 | {"config", tmpCfgf.Name(), tmpCfgf.Name()}, 80 | {"music", testMusic, testMusic}, 81 | } 82 | for _, check := range strChecks { 83 | t.Run("testing string flag "+check.name, func(t *testing.T) { 84 | flag.CommandLine.Set(check.name, check.arg) 85 | flag.CommandLine.Parse(os.Args[1:]) 86 | fmt.Println("flag value: ", check.arg) 87 | assert.Equal(t, check.arg, check.want) 88 | }) 89 | } 90 | } 91 | 92 | func TestSetupHooks(t *testing.T) { 93 | 94 | gomu := newGomu() 95 | gomu.anko = anko.NewAnko() 96 | gomu.hook = hook.NewEventHook() 97 | 98 | err := loadModules(gomu.anko) 99 | if err != nil { 100 | t.Error(err) 101 | } 102 | 103 | setupHooks(gomu.hook, gomu.anko) 104 | 105 | const src = ` 106 | i = 0 107 | 108 | Event.add_hook("skip", func() { 109 | i++ 110 | }) 111 | ` 112 | 113 | _, err = gomu.anko.Execute(src) 114 | if err != nil { 115 | t.Error(err) 116 | } 117 | 118 | gomu.hook.RunHooks("enter") 119 | 120 | for i := 0; i < 12; i++ { 121 | gomu.hook.RunHooks("skip") 122 | } 123 | 124 | got := gomu.anko.GetInt("i") 125 | 126 | assert.Equal(t, 12, got) 127 | } 128 | -------------------------------------------------------------------------------- /lyric/sample-unclean.lrc: -------------------------------------------------------------------------------- 1 |

Taylor Swift - Love Story (Taylor's Version) (Official Lyric Video) Lyrics (.LRC) Advanced

2 | [00:00.00]by RentAnAdviser.com
[00:00.00]
[00:15.91]We were both young when I first saw you
[00:19.81]
[00:19.91]I close my eyes and the flashback starts
[00:23.41]
[00:23.52]I am standing there
[00:25.72]
[00:26.70]On a balcony in summer air
[00:30.20]
[00:32.11]See the lights see the party the ball gowns
[00:36.11]
[00:36.20]See you make your way through the crowd
[00:39.70]
[00:39.82]And say Hello
[00:41.41]
[00:43.22]le did I know
[00:46.32]
[00:47.92]That you were Romeo you were throwing pebbles
[00:51.92]
[00:52.01]And my daddy said Stay away from Juliet
[00:55.61]
[00:55.71]And I was crying on the staircase
[00:58.40]
[00:58.50]Begging you Please don′t go and I said
[01:04.60]
[01:04.72]Romeo take me somewhere we can be alone
[01:08.72]
[01:08.80]I will be waiting all there′s left to do is run
[01:12.50]
[01:12.62]You′ll be the prince and I will be the princess
[01:16.62]
[01:16.71]It is a love story baby just say Yes
[01:21.91]
[01:24.02]So I sneak out to the garden to see you
[01:28.41]
[01:28.51]We keep quiet ′cause We are dead if they knew
[01:32.01]
[01:32.11]So close your eyes
[01:34.70]
[01:34.91]Escape this town for a le while oh oh
[01:40.11]
[01:40.22]′Cause you were Romeo I was a scarlet letter
[01:44.31]
[01:44.42]And my daddy said Stay away from Juliet
[01:47.92]
[01:48.02]But you were everything to me
[01:50.62]
[01:50.71]I was begging you Please don′t go and I said
[01:57.11]
[01:57.22]Romeo take me somewhere we can be alone
[02:01.12]
[02:01.22]I will be waiting all there′s left to do is run
[02:05.02]
[02:05.12]You′ll be the prince and I will be the princess
[02:09.02]
[02:09.12]It is a love story baby just say Yes
[02:13.02]
[02:13.11]Romeo save me They are trying to tell me how to feel
[02:17.01]
[02:17.12]This love is difficult but It is real
[02:21.02]
[02:21.11]Don′t be afraid we′ll make it out of this mess
[02:25.11]
[02:25.22]It is a love story baby just say Yes
[02:30.12]
[02:44.21]And I got tired of waiting
[02:48.11]
[02:48.42]Wondering if you were ever coming around
[02:52.02]
[02:52.11]My faith in you was fading
[02:56.81]
[02:56.92]When I met you on the outskirts of town
[03:00.12]
[03:00.21]And I said Romeo save me I have been feeling so alone
[03:05.50]
[03:05.61]I keep waiting for you but you never come
[03:09.41]
[03:09.51]Is this in my head I don′t know what to think
[03:12.91]
[03:13.01]He knelt to the ground and pulled out a ring and said
[03:17.51]
[03:17.61]Marry me Juliet you′ll never have to be alone
[03:21.51]
[03:21.61]I love you and that′s all I really know
[03:25.61]
[03:25.71]I talked to your dad go pick out a white dress
[03:29.60]
[03:29.71]It is a love story baby just say ′Yes′
[03:36.41]
[03:45.61]′Cause we were both young when I first saw you
[03:51.81]
[03:52.81]by RentAnAdviser.com
[04:01.81]
3 | 4 | -------------------------------------------------------------------------------- /invidious/invidious.go: -------------------------------------------------------------------------------- 1 | package invidious 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | 9 | "github.com/ztrue/tracerr" 10 | ) 11 | 12 | type Invidious struct { 13 | // Domain of invidious instance which you get from this list: 14 | // https://github.com/iv-org/documentation/blob/master/Invidious-Instances.md 15 | Domain string 16 | } 17 | 18 | type ResponseError struct { 19 | Code string `json:"code"` 20 | Message string `json:"message"` 21 | } 22 | 23 | func (r *ResponseError) Error() string { 24 | return r.Message 25 | } 26 | 27 | type YoutubeVideo struct { 28 | Title string `json:"title"` 29 | LengthSeconds int `json:"lengthSeconds"` 30 | VideoId string `json:"videoId"` 31 | } 32 | 33 | // GetSearchQuery fetches query result from an Invidious instance. 34 | func (i *Invidious) GetSearchQuery(query string) ([]YoutubeVideo, error) { 35 | 36 | query = url.QueryEscape(query) 37 | 38 | targetUrl := i.Domain + `/api/v1/search?q=` + query 39 | yt := []YoutubeVideo{} 40 | 41 | err := getRequest(targetUrl, &yt) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | return yt, nil 47 | } 48 | 49 | // GetSuggestions returns video suggestions based on prefix strings. This is the 50 | // same result as youtube search autocomplete. 51 | func (_ *Invidious) GetSuggestions(prefix string) ([]string, error) { 52 | 53 | query := url.QueryEscape(prefix) 54 | targetUrl := 55 | `http://suggestqueries.google.com/complete/search?client=firefox&ds=yt&q=` + query 56 | 57 | res := []json.RawMessage{} 58 | err := getRequest(targetUrl, &res) 59 | if err != nil { 60 | return nil, tracerr.Wrap(err) 61 | } 62 | 63 | suggestions := []string{} 64 | err = json.Unmarshal(res[1], &suggestions) 65 | if err != nil { 66 | return nil, tracerr.Wrap(err) 67 | } 68 | 69 | return suggestions, nil 70 | } 71 | 72 | // GetTrendingMusic fetch music trending based on region. 73 | // Region (ISO 3166 country code) can be provided in the argument. 74 | func (i *Invidious) GetTrendingMusic(region string) ([]YoutubeVideo, error) { 75 | 76 | params := fmt.Sprintf("type=music®ion=%s", region) 77 | targetUrl := i.Domain + "/api/v1/trending?" + params 78 | 79 | yt := []YoutubeVideo{} 80 | 81 | err := getRequest(targetUrl, &yt) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | return yt, nil 87 | } 88 | 89 | // getRequest is a helper function that simplifies GET request and parsing the 90 | // json payload. 91 | func getRequest(url string, v interface{}) error { 92 | 93 | client := &http.Client{} 94 | 95 | req, err := http.NewRequest("GET", url, nil) 96 | if err != nil { 97 | return tracerr.Wrap(err) 98 | } 99 | 100 | res, err := client.Do(req) 101 | if err != nil { 102 | return tracerr.Wrap(err) 103 | } 104 | 105 | defer res.Body.Close() 106 | 107 | if res.StatusCode != 200 { 108 | var resErr ResponseError 109 | 110 | err = json.NewDecoder(res.Body).Decode(&resErr) 111 | if err != nil { 112 | return tracerr.Wrap(err) 113 | } 114 | 115 | return &resErr 116 | } 117 | 118 | err = json.NewDecoder(res.Body).Decode(&v) 119 | if err != nil { 120 | return tracerr.Wrap(err) 121 | } 122 | 123 | return nil 124 | } 125 | -------------------------------------------------------------------------------- /colors.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/gdamore/tcell/v2" 8 | "github.com/rivo/tview" 9 | ) 10 | 11 | // Colors are the configurable colors used in gomu 12 | type Colors struct { 13 | accent tcell.Color 14 | background tcell.Color 15 | foreground tcell.Color 16 | // title refers to now_playing_title in config file 17 | title tcell.Color 18 | popup tcell.Color 19 | playlistHi tcell.Color 20 | playlistDir tcell.Color 21 | queueHi tcell.Color 22 | subtitle string 23 | } 24 | 25 | func init() { 26 | tcell.ColorNames["none"] = tcell.ColorDefault 27 | } 28 | 29 | func newColor() *Colors { 30 | 31 | defaultColors := map[string]string{ 32 | "Color.accent": "darkcyan", 33 | "Color.background": "none", 34 | "Color.foreground": "white", 35 | "Color.popup": "black", 36 | "Color.playlist_directory": "darkcyan", 37 | "Color.playlist_highlight": "darkcyan", 38 | "Color.queue_highlight": "darkcyan", 39 | "Color.now_playing_title": "darkgreen", 40 | "Color.subtitle": "darkgoldenrod", 41 | } 42 | 43 | anko := gomu.anko 44 | 45 | // checks for invalid color and set default fallback 46 | for k, v := range defaultColors { 47 | 48 | // color from the config file 49 | cfgColor := anko.GetString(k) 50 | 51 | if _, ok := tcell.ColorNames[cfgColor]; !ok { 52 | // use default value if invalid hex color was given 53 | anko.Set(k, v) 54 | } 55 | } 56 | 57 | accent := anko.GetString("Color.accent") 58 | background := anko.GetString("Color.background") 59 | foreground := anko.GetString("Color.foreground") 60 | popup := anko.GetString("Color.popup") 61 | playlistDir := anko.GetString("Color.playlist_directory") 62 | playlistHi := anko.GetString("Color.playlist_highlight") 63 | queueHi := anko.GetString("Color.queue_highlight") 64 | title := anko.GetString("Color.now_playing_title") 65 | subtitle := anko.GetString("Color.subtitle") 66 | 67 | color := &Colors{ 68 | accent: tcell.ColorNames[accent], 69 | foreground: tcell.ColorNames[foreground], 70 | background: tcell.ColorNames[background], 71 | popup: tcell.ColorNames[popup], 72 | playlistDir: tcell.ColorNames[playlistDir], 73 | playlistHi: tcell.ColorNames[playlistHi], 74 | queueHi: tcell.ColorNames[queueHi], 75 | title: tcell.ColorNames[title], 76 | subtitle: subtitle, 77 | } 78 | return color 79 | } 80 | 81 | func colorsPopup() tview.Primitive { 82 | 83 | textView := tview.NewTextView(). 84 | SetWrap(true). 85 | SetDynamicColors(true). 86 | SetWrap(true). 87 | SetWordWrap(true) 88 | 89 | textView. 90 | SetBorder(true). 91 | SetTitle(" Colors "). 92 | SetBorderPadding(1, 1, 2, 2) 93 | 94 | i := 0 95 | colorPad := strings.Repeat(" ", 5) 96 | 97 | for name := range tcell.ColorNames { 98 | fmt.Fprintf(textView, "%20s [:%s]%s[:-] ", name, name, colorPad) 99 | 100 | if i == 2 { 101 | fmt.Fprint(textView, "\n") 102 | i = 0 103 | continue 104 | } 105 | i++ 106 | } 107 | 108 | textView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 109 | switch event.Key() { 110 | case tcell.KeyEsc: 111 | gomu.pages.RemovePage("show-color-popup") 112 | gomu.popups.pop() 113 | } 114 | return event 115 | }) 116 | 117 | return textView 118 | } 119 | -------------------------------------------------------------------------------- /gomu.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/rivo/tview" 5 | "github.com/ztrue/tracerr" 6 | 7 | "github.com/issadarkthing/gomu/anko" 8 | "github.com/issadarkthing/gomu/hook" 9 | "github.com/issadarkthing/gomu/player" 10 | ) 11 | 12 | // VERSION is version information of gomu 13 | var VERSION = "N/A" 14 | 15 | var gomu *Gomu 16 | 17 | // Gomu is the application from tview 18 | type Gomu struct { 19 | app *tview.Application 20 | playingBar *PlayingBar 21 | queue *Queue 22 | playlist *Playlist 23 | player *player.Player 24 | pages *tview.Pages 25 | colors *Colors 26 | command Command 27 | // popups is used to manage focus between popups and panels 28 | popups Stack 29 | prevPanel Panel 30 | panels []Panel 31 | args Args 32 | anko *anko.Anko 33 | hook *hook.EventHook 34 | } 35 | 36 | // Creates new instance of gomu with default values 37 | func newGomu() *Gomu { 38 | 39 | gomu := &Gomu{ 40 | command: newCommand(), 41 | anko: anko.NewAnko(), 42 | hook: hook.NewEventHook(), 43 | } 44 | 45 | return gomu 46 | } 47 | 48 | // Initialize childrens/panels this is seperated from 49 | // constructor function `newGomu` so that we can 50 | // test independently 51 | func (g *Gomu) initPanels(app *tview.Application, args Args) { 52 | g.app = app 53 | g.playingBar = newPlayingBar() 54 | g.queue = newQueue() 55 | g.playlist = newPlaylist(args) 56 | g.player = player.New(g.anko.GetInt("General.volume")) 57 | g.pages = tview.NewPages() 58 | g.panels = []Panel{g.playlist, g.queue, g.playingBar} 59 | } 60 | 61 | // Cycle between panels 62 | func (g *Gomu) cyclePanels() Panel { 63 | 64 | var anyChildHasFocus bool 65 | 66 | for i, child := range g.panels { 67 | 68 | if child.HasFocus() { 69 | 70 | anyChildHasFocus = true 71 | 72 | var nextChild Panel 73 | 74 | // if its the last element set the child back to one 75 | if i == len(g.panels)-1 { 76 | nextChild = g.panels[0] 77 | } else { 78 | nextChild = g.panels[i+1] 79 | } 80 | 81 | g.setFocusPanel(nextChild) 82 | 83 | g.prevPanel = nextChild 84 | return nextChild 85 | } 86 | } 87 | 88 | first := g.panels[0] 89 | 90 | if !anyChildHasFocus { 91 | g.setFocusPanel(first) 92 | } 93 | 94 | g.prevPanel = first 95 | return first 96 | } 97 | 98 | func (g *Gomu) cyclePanels2() Panel { 99 | first := g.panels[0] 100 | second := g.panels[1] 101 | if first.HasFocus() { 102 | g.setFocusPanel(second) 103 | g.prevPanel = second 104 | return second 105 | } else if second.HasFocus() { 106 | g.setFocusPanel(first) 107 | g.prevPanel = first 108 | return first 109 | } else { 110 | g.setFocusPanel(first) 111 | g.prevPanel = first 112 | return first 113 | } 114 | } 115 | 116 | // Changes title and border color when focusing panel 117 | // and changes color of the previous panel as well 118 | func (g *Gomu) setFocusPanel(panel Panel) { 119 | 120 | g.app.SetFocus(panel.(tview.Primitive)) 121 | panel.SetBorderColor(g.colors.accent) 122 | panel.SetTitleColor(g.colors.accent) 123 | 124 | if g.prevPanel == nil { 125 | return 126 | } 127 | 128 | if g.prevPanel != panel { 129 | g.setUnfocusPanel(g.prevPanel) 130 | } 131 | } 132 | 133 | // Removes the color of the given panel 134 | func (g *Gomu) setUnfocusPanel(panel Panel) { 135 | g.prevPanel.SetBorderColor(g.colors.foreground) 136 | g.prevPanel.SetTitleColor(g.colors.foreground) 137 | } 138 | 139 | // Quit the application and do the neccessary clean up 140 | func (g *Gomu) quit(args Args) error { 141 | 142 | if !*args.empty { 143 | err := gomu.queue.saveQueue() 144 | if err != nil { 145 | return tracerr.Wrap(err) 146 | } 147 | } 148 | 149 | gomu.app.Stop() 150 | 151 | return nil 152 | } 153 | -------------------------------------------------------------------------------- /test/config: -------------------------------------------------------------------------------- 1 | 2 | module General { 3 | # confirmation popup to add the whole playlist to the queue 4 | confirm_bulk_add = true 5 | confirm_on_exit = true 6 | queue_loop = false 7 | load_prev_queue = true 8 | popup_timeout = "5s" 9 | sort_by_mtime = false 10 | # change this to directory that contains mp3 files 11 | music_dir = "~/Music" 12 | # url history of downloaded audio will be saved here 13 | history_path = "~/.local/share/gomu/urls" 14 | # some of the terminal supports unicode character 15 | # you can set this to true to enable emojis 16 | use_emoji = true 17 | # initial volume when gomu starts up 18 | volume = 80 19 | # if you experiencing error using this invidious instance, you can change it 20 | # to another instance from this list: 21 | # https://github.com/iv-org/documentation/blob/master/Invidious-Instances.md 22 | invidious_instance = "https://vid.puffyan.us" 23 | } 24 | 25 | module Emoji { 26 | # default emoji here is using awesome-terminal-fonts 27 | # you can change these to your liking 28 | playlist = "" 29 | file = "" 30 | loop = "ﯩ" 31 | noloop = "" 32 | } 33 | 34 | module Color { 35 | # you may choose colors by pressing 'c' 36 | accent = "darkcyan" 37 | background = "none" 38 | foreground = "white" 39 | popup = "black" 40 | 41 | playlist_directory = "darkcyan" 42 | playlist_highlight = "darkcyan" 43 | 44 | queue_highlight = "darkcyan" 45 | 46 | now_playing_title = "darkgreen" 47 | subtitle = "darkgoldenrod" 48 | } 49 | 50 | func fib(x) { 51 | if x <= 1 { 52 | return 1 53 | } 54 | 55 | return fib(x - 1) + fib(x - 2) 56 | } 57 | 58 | module List { 59 | 60 | func collect(l, f) { 61 | result = [] 62 | for x in l { 63 | result += f(x) 64 | } 65 | return result 66 | } 67 | 68 | func filter(l, f) { 69 | result = [] 70 | for x in l { 71 | if f(x) { 72 | result += x 73 | } 74 | } 75 | return result 76 | } 77 | 78 | func reduce(l, f, acc) { 79 | for x in l { 80 | acc = f(acc, x) 81 | } 82 | return acc 83 | } 84 | } 85 | 86 | Keybinds.def_g("b", func() { 87 | # execute shell function and capture stdout and stderr 88 | out, err = shell(`echo "bruhh"`) 89 | if err != nil { 90 | debug_popup("an error occurred") 91 | return 92 | } 93 | debug_popup(out) 94 | }) 95 | 96 | Keybinds.def_g("c", command_search) 97 | 98 | Keybinds.def_g("v", func() { 99 | input_popup("fib calculator", func(result) { 100 | x = int(result) 101 | result = fib(x) 102 | debug_popup(string(result)) 103 | }) 104 | }) 105 | 106 | Keybinds.def_g("m", repl) 107 | Keybinds.def_g("alt_r", reload_config) 108 | 109 | Keybinds.def_p("ctrl_x", func() { 110 | val = 10 + 10 111 | debug_popup(string(val)) 112 | }) 113 | 114 | # override default loop keybinding 115 | Keybinds.def_q("o", toggle_loop) 116 | 117 | Keybinds.def_q("i", func() { 118 | search_popup("test", ["a", "b", "c"], func(x) { 119 | debug_popup(x) 120 | }) 121 | }) 122 | 123 | Keybinds.def_g("c", show_colors) 124 | 125 | # better rename command which does not change the mtime of file 126 | Keybinds.def_g("R", func() { 127 | exec = import("os/exec") 128 | os = import("os") 129 | 130 | file = Playlist.get_focused() 131 | dir = file.GetParent().Path() 132 | 133 | input_popup("New name", file.Name(), func(new_name) { 134 | cmd = exec.Command("cp", "-p", file.Path(), dir + "/" + new_name) 135 | cmd.Run() 136 | os.Remove(file.Path()) 137 | refresh() 138 | Playlist.focus(new_name) 139 | }) 140 | }) 141 | 142 | # you can get the syntax highlighting for this language here: 143 | # https://github.com/mattn/anko/tree/master/misc/vim 144 | # vim: ft=anko 145 | -------------------------------------------------------------------------------- /player/audiofile.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 Raziman 2 | 3 | package player 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "sort" 9 | "time" 10 | 11 | "github.com/rivo/tview" 12 | "github.com/tramhao/id3v2" 13 | "github.com/ztrue/tracerr" 14 | ) 15 | 16 | // AudioFile represents directories and mp3 files 17 | // isAudioFile equals to false if it is a directory 18 | type AudioFile struct { 19 | name string 20 | path string 21 | isAudioFile bool 22 | length time.Duration 23 | node *tview.TreeNode 24 | parent *tview.TreeNode 25 | } 26 | 27 | // Name return the name of AudioFile 28 | func (a *AudioFile) Name() string { 29 | return a.name 30 | } 31 | 32 | // SetName set the name of AudioFile 33 | func (a *AudioFile) SetName(name string) { 34 | if name == "" { 35 | return 36 | } 37 | a.name = name 38 | } 39 | 40 | // Path return the path of AudioFile 41 | func (a *AudioFile) Path() string { 42 | return a.path 43 | } 44 | 45 | // SetPath return the path of AudioFile 46 | func (a *AudioFile) SetPath(path string) { 47 | a.path = path 48 | } 49 | 50 | // IsAudioFile check if the file is song or directory 51 | func (a *AudioFile) IsAudioFile() bool { 52 | return a.isAudioFile 53 | } 54 | 55 | // SetIsAudioFile check if the file is song or directory 56 | func (a *AudioFile) SetIsAudioFile(isAudioFile bool) { 57 | a.isAudioFile = isAudioFile 58 | } 59 | 60 | // Len return the length of AudioFile 61 | func (a *AudioFile) Len() time.Duration { 62 | return a.length 63 | } 64 | 65 | // SetLen set the length of AudioFile 66 | func (a *AudioFile) SetLen(length time.Duration) { 67 | a.length = length 68 | } 69 | 70 | // Parent return the parent directory of AudioFile 71 | func (a *AudioFile) Parent() *AudioFile { 72 | if a.parent == nil { 73 | return nil 74 | } 75 | return a.parent.GetReference().(*AudioFile) 76 | } 77 | 78 | // SetParentNode return the parent directory of AudioFile 79 | func (a *AudioFile) SetParentNode(parentNode *tview.TreeNode) { 80 | if parentNode == nil { 81 | return 82 | } 83 | a.parent = parentNode 84 | } 85 | 86 | // ParentNode return the parent node of AudioFile 87 | func (a *AudioFile) ParentNode() *tview.TreeNode { 88 | if a.parent == nil { 89 | return nil 90 | } 91 | return a.parent 92 | } 93 | 94 | // Node return the current node of AudioFile 95 | func (a *AudioFile) Node() *tview.TreeNode { 96 | if a.node == nil { 97 | return nil 98 | } 99 | return a.node 100 | } 101 | 102 | // SetNode return the current node of AudioFile 103 | func (a *AudioFile) SetNode(node *tview.TreeNode) { 104 | a.node = node 105 | } 106 | 107 | // String return the string of AudioFile 108 | func (a *AudioFile) String() string { 109 | if a == nil { 110 | return "nil" 111 | } 112 | return fmt.Sprintf("%#v", a) 113 | } 114 | 115 | // LoadTagMap will load from tag and return a map of langExt to lyrics 116 | func (a *AudioFile) LoadTagMap() (tag *id3v2.Tag, popupLyricMap map[string]string, options []string, err error) { 117 | 118 | popupLyricMap = make(map[string]string) 119 | 120 | if a.isAudioFile { 121 | tag, err = id3v2.Open(a.path, id3v2.Options{Parse: true}) 122 | if err != nil { 123 | return nil, nil, nil, tracerr.Wrap(err) 124 | } 125 | defer tag.Close() 126 | } else { 127 | return nil, nil, nil, fmt.Errorf("not an audio file") 128 | } 129 | usltFrames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription")) 130 | 131 | for _, f := range usltFrames { 132 | uslf, ok := f.(id3v2.UnsynchronisedLyricsFrame) 133 | if !ok { 134 | return nil, nil, nil, errors.New("USLT error") 135 | } 136 | res := uslf.Lyrics 137 | popupLyricMap[uslf.ContentDescriptor] = res 138 | } 139 | for option := range popupLyricMap { 140 | options = append(options, option) 141 | } 142 | sort.Strings(options) 143 | 144 | return tag, popupLyricMap, options, err 145 | } 146 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/issadarkthing/gomu/lyric" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/tramhao/id3v2" 12 | ) 13 | 14 | func TestFmtDuration(t *testing.T) { 15 | 16 | samples := map[time.Duration]string{ 17 | time.Second * 5: "00:05", 18 | time.Hour * 2: "02:00:00", 19 | time.Minute*4 + time.Second*15: "04:15", 20 | time.Minute * 0: "00:00", 21 | time.Millisecond * 5: "00:00", 22 | } 23 | 24 | for k, v := range samples { 25 | 26 | got := fmtDuration(k) 27 | 28 | if got != v { 29 | t.Errorf("fmtDuration(%s); Expected %s got %s", k, v, got) 30 | } 31 | 32 | } 33 | 34 | } 35 | 36 | func TestGetName(t *testing.T) { 37 | 38 | samples := map[string]string{ 39 | "hello.mp3": "hello", 40 | "~/music/fl.mp3": "fl", 41 | "/home/terra/Music/pop/hola na.mp3": "hola na", 42 | "~/macklemary - (ft jello) extreme!! .mp3": "macklemary - (ft jello) extreme!! ", 43 | } 44 | 45 | for k, v := range samples { 46 | 47 | got := getName(k) 48 | 49 | if got != v { 50 | t.Errorf("GetName(%s); Expected %s got %s", k, v, got) 51 | } 52 | } 53 | } 54 | 55 | func TestDownloadedFilePath(t *testing.T) { 56 | 57 | sample := `[youtube] jJPMnTXl63E: Downloading webpage 58 | [download] Destination: /tmp/Powfu - death bed (coffee for your head) (Official Video) ft. beabadoobee.webm 59 | [download] 100%% of 2.54MiB in 00:0213MiB/s ETA 00:002 60 | [ffmpeg] Destination: /tmp/Powfu - death bed (coffee for your head) (Official Video) ft. beabadoobee.mp3 61 | Deleting original file /tmp/Powfu - death bed (coffee for your head) (Official Video) ft. beabadoobee.webm (pass -k to keep)` 62 | 63 | result := "/tmp/Powfu - death bed (coffee for your head) (Official Video) ft. beabadoobee.mp3" 64 | 65 | got := extractFilePath([]byte(sample), "/tmp") 66 | 67 | if got != result { 68 | t.Errorf("downloadedFilePath(%s); expected %s got %s", sample, result, got) 69 | } 70 | 71 | } 72 | 73 | func TestEscapeBackSlash(t *testing.T) { 74 | 75 | sample := map[string]string{ 76 | "/home/terra": "\\/home\\/terra", 77 | "~/Documents/memes": "~\\/Documents\\/memes", 78 | } 79 | 80 | for k, v := range sample { 81 | 82 | got := escapeBackSlash(k) 83 | 84 | if got != v { 85 | t.Errorf("escapeBackSlash(%s); expected %s, got %s", k, v, got) 86 | } 87 | } 88 | } 89 | 90 | func TestExpandTilde(t *testing.T) { 91 | 92 | homeDir, err := os.UserHomeDir() 93 | if err != nil { 94 | t.Errorf("Unable to get home dir: %e", err) 95 | } 96 | 97 | sample := map[string]string{ 98 | "~/music": homeDir + "/music", 99 | homeDir + "/Music": homeDir + "/Music", 100 | } 101 | 102 | for k, v := range sample { 103 | 104 | got := expandTilde(k) 105 | 106 | if got != v { 107 | t.Errorf("expected %s; got %s", v, got) 108 | } 109 | } 110 | } 111 | 112 | func TestEmbedLyric(t *testing.T) { 113 | 114 | testFile := "./test/sample" 115 | lyricString := "[offset:1000]\n[00:12.000]Lyrics beginning ...\n[00:15.300]Some more lyrics ...\n" 116 | descriptor := "en" 117 | 118 | f, err := os.Create(testFile) 119 | if err != nil { 120 | t.Error(err) 121 | } 122 | f.Close() 123 | 124 | defer func() { 125 | err := os.Remove(testFile) 126 | if err != nil { 127 | t.Error(err) 128 | } 129 | }() 130 | var lyric lyric.Lyric 131 | err = lyric.NewFromLRC(lyricString) 132 | if err != nil { 133 | t.Error(err) 134 | } 135 | fmt.Println(lyric) 136 | lyric.LangExt = descriptor 137 | 138 | err = embedLyric(testFile, &lyric, false) 139 | if err != nil { 140 | t.Error(err) 141 | } 142 | 143 | tag, err := id3v2.Open(testFile, id3v2.Options{Parse: true}) 144 | if err != nil { 145 | t.Error(err) 146 | } else if tag == nil { 147 | t.Error("unable to read tag") 148 | } 149 | 150 | usltFrames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription")) 151 | frame, ok := usltFrames[0].(id3v2.UnsynchronisedLyricsFrame) 152 | if !ok { 153 | t.Error("invalid type") 154 | } 155 | 156 | assert.Equal(t, lyricString, frame.Lyrics) 157 | assert.Equal(t, descriptor, frame.ContentDescriptor) 158 | } 159 | -------------------------------------------------------------------------------- /lyric/lyric_cn.go: -------------------------------------------------------------------------------- 1 | package lyric 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/ztrue/tracerr" 13 | ) 14 | 15 | // tagNetease is the tag get from netease 16 | type tagNetease struct { 17 | Album string `json:"album"` 18 | Artist []string `json:"artist"` 19 | ID int64 `json:"id"` 20 | LyricID int64 `json:"lyric_id"` 21 | Name string `json:"name"` 22 | PicID string `json:"pic_id"` 23 | Source string `json:"source"` 24 | URLID int64 `json:"url_id"` 25 | } 26 | 27 | // tagKugou is the tag get from kugou 28 | type tagKugou struct { 29 | Album string `json:"album"` 30 | Artist []string `json:"artist"` 31 | ID string `json:"id"` 32 | LyricID string `json:"lyric_id"` 33 | Name string `json:"name"` 34 | PicID string `json:"pic_id"` 35 | Source string `json:"source"` 36 | URLID string `json:"url_id"` 37 | } 38 | 39 | // tagLyric is the lyric json get from both netease and kugou 40 | type tagLyric struct { 41 | Lyric string `json:"lyric"` 42 | Tlyric string `json:"tlyric"` 43 | } 44 | 45 | type LyricFetcherCn struct{} 46 | 47 | // LyricOptions queries available song lyrics. It returns slice of SongTag 48 | func (cn LyricFetcherCn) LyricOptions(search string) ([]*SongTag, error) { 49 | 50 | serviceProvider := "netease" 51 | results, err := getLyricOptionsCnByProvider(search, serviceProvider) 52 | if err != nil { 53 | return nil, tracerr.Wrap(err) 54 | } 55 | serviceProvider = "kugou" 56 | results2, err := getLyricOptionsCnByProvider(search, serviceProvider) 57 | if err != nil { 58 | return nil, tracerr.Wrap(err) 59 | } 60 | 61 | results = append(results, results2...) 62 | 63 | return results, err 64 | } 65 | 66 | // LyricFetch should receive songTag that was returned from getLyricOptions 67 | // and returns lyric of the queried song. 68 | func (cn LyricFetcherCn) LyricFetch(songTag *SongTag) (lyricString string, err error) { 69 | 70 | urlSearch := "http://api.sunyj.xyz" 71 | 72 | params := url.Values{} 73 | params.Add("site", songTag.ServiceProvider) 74 | params.Add("lyric", songTag.LyricID) 75 | resp, err := http.Get(urlSearch + "?" + params.Encode()) 76 | if err != nil { 77 | return "", tracerr.Wrap(err) 78 | } 79 | defer resp.Body.Close() 80 | 81 | if resp.StatusCode != 200 { 82 | return "", fmt.Errorf("http response error: %d", resp.StatusCode) 83 | } 84 | 85 | var tagLyric tagLyric 86 | err = json.NewDecoder(resp.Body).Decode(&tagLyric) 87 | if err != nil { 88 | return "", tracerr.Wrap(err) 89 | } 90 | lyricString = tagLyric.Lyric 91 | if lyricString == "" { 92 | return "", errors.New("no lyric available") 93 | } 94 | 95 | if looksLikeLRC(lyricString) { 96 | lyricString = cleanLRC(lyricString) 97 | return lyricString, nil 98 | } 99 | return "", errors.New("lyric not compatible") 100 | } 101 | 102 | // getLyricOptionsCnByProvider do the query by provider 103 | func getLyricOptionsCnByProvider(search string, serviceProvider string) (resultTags []*SongTag, err error) { 104 | 105 | urlSearch := "http://api.sunyj.xyz" 106 | 107 | params := url.Values{} 108 | params.Add("site", serviceProvider) 109 | params.Add("search", search) 110 | resp, err := http.Get(urlSearch + "?" + params.Encode()) 111 | if err != nil { 112 | return nil, tracerr.Wrap(err) 113 | } 114 | defer resp.Body.Close() 115 | 116 | if resp.StatusCode != 200 { 117 | return nil, fmt.Errorf("http response error: %d", resp.StatusCode) 118 | } 119 | 120 | switch serviceProvider { 121 | case "kugou": 122 | var tagKugou []tagKugou 123 | err = json.NewDecoder(resp.Body).Decode(&tagKugou) 124 | if err != nil { 125 | return nil, tracerr.Wrap(err) 126 | } 127 | 128 | for _, v := range tagKugou { 129 | resultArtist := strings.Join(v.Artist, " ") 130 | songName := v.Name 131 | resultAlbum := v.Album 132 | songTitleForPopup := fmt.Sprintf("%s - %s : %s", resultArtist, songName, resultAlbum) 133 | songTag := &SongTag{ 134 | Artist: resultArtist, 135 | Title: v.Name, 136 | Album: v.Album, 137 | TitleForPopup: songTitleForPopup, 138 | LangExt: "zh-CN", 139 | ServiceProvider: serviceProvider, 140 | SongID: v.ID, 141 | LyricID: v.LyricID, 142 | } 143 | resultTags = append(resultTags, songTag) 144 | } 145 | 146 | case "netease": 147 | var tagNetease []tagNetease 148 | err = json.NewDecoder(resp.Body).Decode(&tagNetease) 149 | if err != nil { 150 | return nil, tracerr.Wrap(err) 151 | } 152 | 153 | for _, v := range tagNetease { 154 | resultArtist := strings.Join(v.Artist, " ") 155 | songName := v.Name 156 | resultAlbum := v.Album 157 | songTitleForPopup := fmt.Sprintf("%s - %s : %s", resultArtist, songName, resultAlbum) 158 | songTag := &SongTag{ 159 | Artist: resultArtist, 160 | Title: v.Name, 161 | Album: v.Album, 162 | URL: "", 163 | TitleForPopup: songTitleForPopup, 164 | LangExt: "zh-CN", 165 | ServiceProvider: serviceProvider, 166 | SongID: strconv.FormatInt(v.ID, 10), 167 | LyricID: strconv.FormatInt(v.LyricID, 10), 168 | } 169 | resultTags = append(resultTags, songTag) 170 | } 171 | 172 | } 173 | 174 | return resultTags, nil 175 | } 176 | -------------------------------------------------------------------------------- /queue_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/issadarkthing/gomu/player" 7 | "github.com/rivo/tview" 8 | ) 9 | 10 | var sample = map[string]string{ 11 | "a": "1", 12 | "b": "2", 13 | "c": "3", 14 | "d": "4", 15 | "e": "5", 16 | } 17 | 18 | func TestQueueNext(t *testing.T) { 19 | 20 | q := newQueue() 21 | 22 | for _, v := range sample { 23 | q.AddItem(v, "", 0, nil) 24 | } 25 | 26 | q.SetCurrentItem(0) 27 | q.next() 28 | 29 | got := q.GetCurrentItem() 30 | 31 | if got != 1 { 32 | t.Errorf("Expected %d got %d", 1, got) 33 | } 34 | 35 | } 36 | 37 | func TestQueuePrev(t *testing.T) { 38 | 39 | q := newQueue() 40 | 41 | for _, v := range sample { 42 | q.AddItem(v, "", 0, nil) 43 | } 44 | 45 | q.SetCurrentItem(3) 46 | q.prev() 47 | 48 | got := q.GetCurrentItem() 49 | 50 | if got != 2 { 51 | t.Errorf("Expected %d got %d", 1, got) 52 | } 53 | 54 | } 55 | 56 | func TestQueueDeleteItem(t *testing.T) { 57 | 58 | q := newQueue() 59 | 60 | for _, v := range sample { 61 | q.AddItem(v, "", 0, nil) 62 | } 63 | 64 | initLen := q.GetItemCount() 65 | q.deleteItem(-1) 66 | finalLen := q.GetItemCount() 67 | 68 | if initLen != finalLen { 69 | t.Errorf("Item removed when -1 index was given") 70 | } 71 | 72 | } 73 | 74 | func TestUpdateTitle(t *testing.T) { 75 | 76 | gomu := prepareTest() 77 | audioFiles := gomu.playlist.getAudioFiles() 78 | 79 | for _, v := range audioFiles { 80 | gomu.queue.enqueue(v) 81 | } 82 | 83 | expected := gomu.queue.updateTitle() 84 | got := gomu.queue.GetTitle() 85 | 86 | if expected != got { 87 | t.Errorf("Expected %s; got %s", expected, got) 88 | } 89 | } 90 | 91 | func TestPushFront(t *testing.T) { 92 | 93 | gomu = prepareTest() 94 | rapPlaylist := gomu.playlist.GetRoot().GetChildren()[1] 95 | 96 | gomu.playlist.addAllToQueue(rapPlaylist) 97 | 98 | selSong, err := gomu.queue.deleteItem(2) 99 | if err != nil { 100 | t.Error(err) 101 | } 102 | 103 | gomu.queue.pushFront(selSong) 104 | 105 | for i, v := range gomu.queue.items { 106 | 107 | if v == selSong && i != 0 { 108 | t.Errorf("Item does not move to the 0th index") 109 | } 110 | 111 | } 112 | 113 | } 114 | 115 | func TestDequeue(t *testing.T) { 116 | 117 | gomu := prepareTest() 118 | 119 | audioFiles := gomu.playlist.getAudioFiles() 120 | 121 | for _, v := range audioFiles { 122 | gomu.queue.enqueue(v) 123 | } 124 | 125 | initLen := len(gomu.queue.items) 126 | 127 | gomu.queue.dequeue() 128 | 129 | finalLen := len(gomu.queue.items) 130 | 131 | if initLen-1 != finalLen { 132 | t.Errorf("Expected %d got %d", initLen-1, finalLen) 133 | } 134 | 135 | } 136 | 137 | func TestEnqueue(t *testing.T) { 138 | 139 | gomu = prepareTest() 140 | 141 | var audioFiles []*player.AudioFile 142 | 143 | gomu.playlist.GetRoot().Walk(func(node, _ *tview.TreeNode) bool { 144 | 145 | audioFile := node.GetReference().(*player.AudioFile) 146 | 147 | if audioFile.IsAudioFile() { 148 | audioFiles = append(audioFiles, audioFile) 149 | return false 150 | } 151 | 152 | return true 153 | }) 154 | 155 | for _, v := range audioFiles { 156 | gomu.queue.enqueue(v) 157 | } 158 | 159 | queue := gomu.queue.getItems() 160 | 161 | for i, audioFile := range audioFiles { 162 | 163 | if queue[i] != audioFile.Path() { 164 | t.Errorf("Invalid path; expected %s got %s", audioFile.Path(), queue[i]) 165 | } 166 | } 167 | 168 | queueLen := gomu.queue.GetItemCount() 169 | 170 | if queueLen != len(audioFiles) { 171 | t.Errorf("Invalid count in queue; expected %d, got %d", len(audioFiles), queueLen) 172 | } 173 | 174 | } 175 | 176 | func TestQueueGetItems(t *testing.T) { 177 | 178 | q := newQueue() 179 | 180 | for k, v := range sample { 181 | q.AddItem(k, v, 0, nil) 182 | } 183 | 184 | got := q.getItems() 185 | 186 | if len(got) != len(sample) { 187 | t.Errorf("GetItems does not return correct items length") 188 | } 189 | 190 | sampleValues := []string{} 191 | 192 | for _, v := range sample { 193 | sampleValues = append(sampleValues, v) 194 | } 195 | 196 | for _, v := range got { 197 | if !SliceHas(v, sampleValues) { 198 | t.Error("GetItems does not return correct items") 199 | } 200 | } 201 | 202 | } 203 | 204 | func TestClearQueue(t *testing.T) { 205 | 206 | gomu = prepareTest() 207 | rapPlaylist := gomu.playlist.GetRoot().GetChildren()[1] 208 | gomu.playlist.addAllToQueue(rapPlaylist) 209 | 210 | gomu.queue.clearQueue() 211 | 212 | queueLen := len(gomu.queue.items) 213 | if queueLen != 0 { 214 | t.Errorf("Expected %d; got %d", 0, queueLen) 215 | } 216 | 217 | listLen := len(gomu.queue.getItems()) 218 | if listLen != 0 { 219 | t.Errorf("Expected %d; got %d", 0, listLen) 220 | } 221 | 222 | } 223 | 224 | func TestShuffle(t *testing.T) { 225 | 226 | gomu = prepareTest() 227 | 228 | root := gomu.playlist.GetRoot() 229 | rapDir := root.GetChildren()[1] 230 | 231 | gomu.playlist.addAllToQueue(rapDir) 232 | 233 | sameCounter := 0 234 | const limit int = 10 235 | 236 | for i := 0; i < limit; i++ { 237 | items := gomu.queue.getItems() 238 | 239 | gomu.queue.shuffle() 240 | 241 | got := gomu.queue.getItems() 242 | 243 | if Equal(items, got) { 244 | sameCounter++ 245 | } 246 | } 247 | 248 | if sameCounter == limit { 249 | t.Error("Items in queue are not changed") 250 | } 251 | 252 | } 253 | 254 | // Equal tells whether a and b contain the same elements. 255 | // A nil argument is equivalent to an empty slice. 256 | func Equal(a, b []string) bool { 257 | if len(a) != len(b) { 258 | return false 259 | } 260 | for i, v := range a { 261 | if v != b[i] { 262 | return false 263 | } 264 | } 265 | return true 266 | } 267 | 268 | // utility function to check elem in a slice 269 | func SliceHas(item string, s []string) bool { 270 | 271 | for _, v := range s { 272 | if v == item { 273 | return true 274 | } 275 | } 276 | 277 | return false 278 | } 279 | -------------------------------------------------------------------------------- /anko/anko.go: -------------------------------------------------------------------------------- 1 | package anko 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/gdamore/tcell/v2" 9 | "github.com/mattn/anko/core" 10 | "github.com/mattn/anko/env" 11 | _ "github.com/mattn/anko/packages" 12 | "github.com/mattn/anko/parser" 13 | "github.com/mattn/anko/vm" 14 | ) 15 | 16 | type Anko struct { 17 | env *env.Env 18 | } 19 | 20 | func NewAnko() *Anko { 21 | 22 | env := core.Import(env.NewEnv()) 23 | importToX(env) 24 | 25 | t, err := env.Get("typeOf") 26 | if err != nil { 27 | panic(err) 28 | } 29 | 30 | k, err := env.Get("kindOf") 31 | if err != nil { 32 | panic(err) 33 | } 34 | 35 | env.DeleteGlobal("typeOf") 36 | env.DeleteGlobal("kindOf") 37 | 38 | err = env.Define("type_of", t) 39 | if err != nil { 40 | panic(err) 41 | } 42 | 43 | err = env.Define("kind_of", k) 44 | if err != nil { 45 | panic(err) 46 | } 47 | 48 | return &Anko{env} 49 | } 50 | 51 | // DefineGlobal defines new symbol and value to the Anko env. 52 | func (a *Anko) DefineGlobal(symbol string, value interface{}) error { 53 | return a.env.DefineGlobal(symbol, value) 54 | } 55 | 56 | func (a *Anko) NewModule(name string) (*Anko, error) { 57 | env, err := a.env.NewModule(name) 58 | if err != nil { 59 | return nil, err 60 | } 61 | return &Anko{env}, nil 62 | } 63 | 64 | func (a *Anko) Define(name string, value interface{}) error { 65 | return a.env.Define(name, value) 66 | } 67 | 68 | // Set sets new value to existing symbol. Use this when change value under an 69 | // existing symbol. 70 | func (a *Anko) Set(symbol string, value interface{}) error { 71 | return a.env.Set(symbol, value) 72 | } 73 | 74 | // Get gets value from anko env, returns error if symbol is not found. 75 | func (a *Anko) Get(symbol string) (interface{}, error) { 76 | return a.env.Get(symbol) 77 | } 78 | 79 | // GetInt gets int value from symbol, returns golang default value if not found. 80 | func (a *Anko) GetInt(symbol string) int { 81 | v, err := a.Execute(symbol) 82 | if err != nil { 83 | return 0 84 | } 85 | 86 | switch val := v.(type) { 87 | case int: 88 | return val 89 | case int64: 90 | return int(val) 91 | } 92 | 93 | return 0 94 | } 95 | 96 | // GetString gets string value from symbol, returns golang default value if not 97 | // found. 98 | func (a *Anko) GetString(symbol string) string { 99 | v, err := a.Execute(symbol) 100 | if err != nil { 101 | return "" 102 | } 103 | 104 | val, ok := v.(string) 105 | if !ok { 106 | return "" 107 | } 108 | 109 | return val 110 | } 111 | 112 | // GetBool gets bool value from symbol, returns golang default value if not 113 | // found. 114 | func (a *Anko) GetBool(symbol string) bool { 115 | v, err := a.Execute(symbol) 116 | if err != nil { 117 | return false 118 | } 119 | 120 | val, ok := v.(bool) 121 | if !ok { 122 | return false 123 | } 124 | 125 | return val 126 | } 127 | 128 | // Execute executes anko script. 129 | func (a *Anko) Execute(src string) (interface{}, error) { 130 | parser.EnableErrorVerbose() 131 | stmts, err := parser.ParseSrc(src) 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | val, err := vm.Run(a.env, nil, stmts) 137 | if err != nil { 138 | if e, ok := err.(*vm.Error); ok { 139 | err = fmt.Errorf("error on line %d column %d: %s\n", 140 | e.Pos.Line, e.Pos.Column, err) 141 | } else if e, ok := err.(*parser.Error); ok { 142 | err = fmt.Errorf("error on line %d column %d: %s\n", 143 | e.Pos.Line, e.Pos.Column, err) 144 | } 145 | 146 | return nil, err 147 | } 148 | 149 | return val, nil 150 | } 151 | 152 | // KeybindExists checks if keybinding is defined. 153 | func (a *Anko) KeybindExists(panel string, eventKey *tcell.EventKey) bool { 154 | var src string 155 | name := eventKey.Name() 156 | 157 | if strings.Contains(name, "Ctrl") { 158 | key, ok := extractCtrlRune(name) 159 | if !ok { 160 | return false 161 | } 162 | src = fmt.Sprintf("Keybinds.%s[\"ctrl_%s\"]", 163 | panel, strings.ToLower(string(key))) 164 | 165 | } else if strings.Contains(name, "Alt") { 166 | key, ok := extractAltRune(name) 167 | if !ok { 168 | return false 169 | } 170 | src = fmt.Sprintf("Keybinds.%s[\"alt_%c\"]", panel, key) 171 | 172 | } else if strings.Contains(name, "Rune") { 173 | src = fmt.Sprintf("Keybinds.%s[\"%c\"]", panel, eventKey.Rune()) 174 | 175 | } else { 176 | src = fmt.Sprintf("Keybinds.%s[\"%s\"]", panel, strings.ToLower(name)) 177 | 178 | } 179 | 180 | val, err := a.Execute(src) 181 | if err != nil { 182 | return false 183 | } 184 | 185 | return val != nil 186 | } 187 | 188 | // ExecKeybind executes function bounded by the keybinding. 189 | func (a *Anko) ExecKeybind(panel string, eventKey *tcell.EventKey) error { 190 | 191 | var src string 192 | name := eventKey.Name() 193 | 194 | if strings.Contains(name, "Ctrl") { 195 | key, ok := extractCtrlRune(name) 196 | if !ok { 197 | return nil 198 | } 199 | src = fmt.Sprintf("Keybinds.%s[\"ctrl_%s\"]()", 200 | panel, strings.ToLower(string(key))) 201 | 202 | } else if strings.Contains(name, "Alt") { 203 | key, ok := extractAltRune(name) 204 | if !ok { 205 | return nil 206 | } 207 | src = fmt.Sprintf("Keybinds.%s[\"alt_%c\"]()", panel, key) 208 | 209 | } else if strings.Contains(name, "Rune") { 210 | src = fmt.Sprintf("Keybinds.%s[\"%c\"]()", panel, eventKey.Rune()) 211 | 212 | } else { 213 | src = fmt.Sprintf("Keybinds.%s[\"%s\"]()", panel, strings.ToLower(name)) 214 | 215 | } 216 | 217 | _, err := a.Execute(src) 218 | if err != nil { 219 | return err 220 | } 221 | 222 | return nil 223 | } 224 | 225 | func extractCtrlRune(str string) (rune, bool) { 226 | re := regexp.MustCompile(`\+(.)$`) 227 | x := re.FindStringSubmatch(str) 228 | if len(x) == 0 { 229 | return rune(' '), false 230 | } 231 | return rune(x[0][1]), true 232 | } 233 | 234 | func extractAltRune(str string) (rune, bool) { 235 | re := regexp.MustCompile(`\[(.)\]`) 236 | x := re.FindStringSubmatch(str) 237 | if len(x) == 0 { 238 | return rune(' '), false 239 | } 240 | return rune(x[0][1]), true 241 | } 242 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Gomu (Go Music Player) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/issadarkthing/gomu)](https://goreportcard.com/report/github.com/issadarkthing/gomu) [![Build Status](https://travis-ci.com/issadarkthing/gomu.svg?branch=master)](https://travis-ci.com/issadarkthing/gomu) 4 | Buy Me A Coffee 5 | 6 | Gomu is intuitive, powerful CLI music player. It has embedded scripting language 7 | and event hook to enable user to customize their config extensively. 8 | 9 | ![gomu](https://user-images.githubusercontent.com/50593529/107107772-37fdc000-686e-11eb-8c0f-c7d7f43f3c80.png) 10 | 11 | ### Features 12 | - lightweight 13 | - simple 14 | - fast 15 | - show audio files as tree 16 | - queue cache 17 | - [vim](https://github.com/vim/vim) keybindings 18 | - [youtube-dl](https://github.com/ytdl-org/youtube-dl) integration 19 | - audio file management 20 | - customizable 21 | - find music from youtube 22 | - scriptable config 23 | - download lyric 24 | - id3v2 tag editor 25 | 26 | ### Dependencies 27 | If you are using ubuntu, you need to install alsa and required dependencies 28 | ```sh 29 | $ sudo apt install libasound2-dev go 30 | ``` 31 | Optional dependencies can be installed by this command 32 | ```sh 33 | $ sudo apt install youtube-dl 34 | ``` 35 | 36 | ### Installation 37 | 38 | ```sh 39 | $ make install 40 | ``` 41 | 42 | For arch users, you can install from the AUR 43 | 44 | using [yay](https://github.com/Jguer/yay): 45 | ```sh 46 | $ yay -S gomu 47 | ``` 48 | using [aura](https://github.com/fosskers/aura): 49 | ```sh 50 | $ sudo aura -A gomu 51 | ``` 52 | 53 | 54 | ### Configuration 55 | By default, gomu will look for audio files in `~/music` directory. If you wish to change to your desired location, edit `~/.config/gomu/config` file 56 | and change `music_dir = path/to/your/musicDir`. 57 | 58 | 59 | ### Keybindings 60 | Each panel has it's own additional keybinding. To view the available keybinding for the specific panel use `?` 61 | 62 | | Key (General) | Description | 63 | |:----------------|--------------------------------:| 64 | | tab | change panel | 65 | | space | toggle play/pause | 66 | | esc | close popup | 67 | | n | skip | 68 | | q | quit | 69 | | + | volume up | 70 | | - | volume down | 71 | | f/F | forward 10/60 seconds | 72 | | b/B | rewind 10/60 seconds | 73 | | ? | toggle help | 74 | | m | open repl | 75 | | T | switch lyrics | 76 | | c | show colors | 77 | 78 | 79 | | Key (Playlist) | Description | 80 | |:----------------|--------------------------------:| 81 | | j | down | 82 | | k | up | 83 | | h | close node in playlist | 84 | | a | create playlist | 85 | | l (lowercase L) | add song to queue | 86 | | L | add playlist to queue | 87 | | d | delete file from filesystemd | 88 | | D | delete playlist from filesystem | 89 | | Y | download audio | 90 | | r | refresh | 91 | | R | rename | 92 | | y/p | yank/paste file | 93 | | / | find in playlist | 94 | | s | search audio from youtube | 95 | | t | edit mp3 tags | 96 | | 1/2 | find lyric if available | 97 | 98 | | Key (Queue) | Description | 99 | |:----------------|--------------------------------:| 100 | | j | down | 101 | | k | up | 102 | | l (lowercase L) | play selected song | 103 | | d | remove from queue | 104 | | D | delete playlist | 105 | | z | toggle loop | 106 | | s | shuffle | 107 | | / | find in queue | 108 | | t | lyric delay increase 0.5 second | 109 | | r | lyric delay decrease 0.5 second | 110 | 111 | ### Scripting 112 | 113 | Gomu uses [anko](https://github.com/mattn/anko) as its scripting language. You can read 114 | more about scripting at our [wiki](https://github.com/issadarkthing/gomu/wiki) 115 | 116 | ``` go 117 | 118 | Keybinds.def_g("ctrl_x", func() { 119 | out, err = shell(`echo "hello world"`) 120 | if err != nil { 121 | debug_popup("an error occured") 122 | } 123 | info_popup(out) 124 | }) 125 | 126 | ``` 127 | 128 | ### Project Background 129 | I just wanted to implement my own music player with a programming language i'm currently learning ([Go](https://golang.org/)). Gomu might not be stable as it in constant development. For now, it can fulfill basic music player functions such as: 130 | - add and delete songs from queue 131 | - create playlists 132 | - skip 133 | - play 134 | - pause 135 | - forward and rewind 136 | 137 | ### Similar Projects 138 | - [termusic](https://github.com/tramhao/termusic) (Written in rust and well maintained) 139 | 140 | ### Album Photo 141 | For songs downloaded by Gomu, the thumbnail will be embeded as Album cover. If you're not satisfied with the cover, you can edit it with kid3 and attach an image as album cover. Jpeg is tested, but other formats should work as well. 142 | 143 | ### Donation 144 | Hi! If you guys think the project is cool, you can buy me a coffee ;) 145 | 146 | Buy Me A Coffee 147 | 148 | Seeking and more advanced stuff has not yet been implemented; feel free to contribute. 149 | -------------------------------------------------------------------------------- /anko/anko_test.go: -------------------------------------------------------------------------------- 1 | package anko 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/gdamore/tcell/v2" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestDefine(t *testing.T) { 12 | a := NewAnko() 13 | err := a.DefineGlobal("x", 12) 14 | if err != nil { 15 | t.Error(err) 16 | } 17 | } 18 | 19 | func TestSet(t *testing.T) { 20 | a := NewAnko() 21 | err := a.DefineGlobal("x", 12) 22 | if err != nil { 23 | t.Error(err) 24 | } 25 | 26 | err = a.Set("x", 12) 27 | if err != nil { 28 | t.Error(err) 29 | } 30 | } 31 | 32 | func TestGet(t *testing.T) { 33 | a := NewAnko() 34 | 35 | expect := 12 36 | err := a.DefineGlobal("x", expect) 37 | if err != nil { 38 | t.Error(err) 39 | } 40 | 41 | got, err := a.Get("x") 42 | if err != nil { 43 | t.Error(err) 44 | } 45 | 46 | assert.Equal(t, expect, got) 47 | 48 | if err != nil { 49 | t.Error(err) 50 | } 51 | } 52 | 53 | func TestGetInt(t *testing.T) { 54 | expect := 10 55 | a := NewAnko() 56 | 57 | _, err := a.Execute(`x = 10`) 58 | if err != nil { 59 | t.Error(err) 60 | } 61 | 62 | got := a.GetInt("x") 63 | assert.Equal(t, expect, got) 64 | 65 | _, err = a.Execute(`module S { x = 10 }`) 66 | if err != nil { 67 | t.Error(err) 68 | } 69 | 70 | got = a.GetInt("S.x") 71 | assert.Equal(t, expect, got) 72 | 73 | got = a.GetInt("S.y") 74 | assert.Equal(t, 0, got) 75 | 76 | a.DefineGlobal("z", expect) 77 | val := a.GetInt("z") 78 | 79 | assert.Equal(t, expect, val) 80 | } 81 | 82 | func TestGetString(t *testing.T) { 83 | expect := "bruhh" 84 | a := NewAnko() 85 | 86 | _, err := a.Execute(`x = "bruhh"`) 87 | if err != nil { 88 | t.Error(err) 89 | } 90 | 91 | got := a.GetString("x") 92 | assert.Equal(t, expect, got) 93 | 94 | _, err = a.Execute(`module S { x = "bruhh" }`) 95 | if err != nil { 96 | t.Error(err) 97 | } 98 | 99 | got = a.GetString("S.x") 100 | assert.Equal(t, expect, got) 101 | 102 | got = a.GetString("S.y") 103 | assert.Equal(t, "", got) 104 | 105 | a.DefineGlobal("z", expect) 106 | val := a.GetString("z") 107 | 108 | assert.Equal(t, expect, val) 109 | } 110 | 111 | func TestGetBool(t *testing.T) { 112 | expect := true 113 | a := NewAnko() 114 | a.DefineGlobal("x", expect) 115 | 116 | _, err := a.Execute(`module S { x = true }`) 117 | if err != nil { 118 | t.Error(err) 119 | } 120 | 121 | got := a.GetBool("S.x") 122 | assert.Equal(t, expect, got) 123 | 124 | got = a.GetBool("S.y") 125 | assert.Equal(t, false, got) 126 | 127 | result := a.GetBool("x") 128 | assert.Equal(t, expect, result) 129 | } 130 | 131 | func TestExecute(t *testing.T) { 132 | expect := 12 133 | a := NewAnko() 134 | 135 | _, err := a.Execute(`x = 6 + 6`) 136 | if err != nil { 137 | t.Error(err) 138 | } 139 | 140 | got := a.GetInt("x") 141 | assert.Equal(t, expect, got) 142 | } 143 | 144 | func TestExtractCtrlRune(t *testing.T) { 145 | tests := []struct { 146 | in string 147 | out rune 148 | ok bool 149 | }{ 150 | {in: "Ctrl+x", out: 'x', ok: true}, 151 | {in: "Ctrl+]", out: ']', ok: true}, 152 | {in: "Ctrl+%", out: '%', ok: true}, 153 | {in: "Ctrl+^", out: '^', ok: true}, 154 | {in: "Ctrl+7", out: '7', ok: true}, 155 | {in: "Ctrl+B", out: 'B', ok: true}, 156 | {in: "Ctrl+Down", out: ' ', ok: false}, 157 | {in: "Ctrl+Left", out: ' ', ok: false}, 158 | } 159 | 160 | for _, test := range tests { 161 | got, ok := extractCtrlRune(test.in) 162 | assert.Equal(t, test.out, got) 163 | assert.Equal(t, test.ok, ok) 164 | } 165 | } 166 | 167 | func TestExtractAltRune(t *testing.T) { 168 | tests := []struct { 169 | in string 170 | out rune 171 | ok bool 172 | }{ 173 | {in: "Alt+Rune[x]", out: 'x', ok: true}, 174 | {in: "Alt+Rune[]]", out: ']', ok: true}, 175 | {in: "Alt+Rune[%]", out: '%', ok: true}, 176 | {in: "Alt+Rune[^]", out: '^', ok: true}, 177 | {in: "Alt+Rune[7]", out: '7', ok: true}, 178 | {in: "Alt+Rune[B]", out: 'B', ok: true}, 179 | } 180 | 181 | for _, test := range tests { 182 | got, ok := extractAltRune(test.in) 183 | assert.Equal(t, test.out, got) 184 | assert.Equal(t, test.ok, ok) 185 | } 186 | } 187 | 188 | func TestKeybindExists(t *testing.T) { 189 | 190 | tests := []struct { 191 | panel string 192 | key *tcell.EventKey 193 | exists bool 194 | }{ 195 | { 196 | panel: "global", 197 | key: tcell.NewEventKey(tcell.KeyRune, 'b', tcell.ModNone), 198 | exists: true, 199 | }, 200 | { 201 | panel: "global", 202 | key: tcell.NewEventKey(tcell.KeyRune, 'x', tcell.ModNone), 203 | exists: false, 204 | }, 205 | { 206 | panel: "global", 207 | key: tcell.NewEventKey(tcell.KeyRune, ']', tcell.ModNone), 208 | exists: true, 209 | }, 210 | { 211 | panel: "global", 212 | key: tcell.NewEventKey(tcell.KeyRune, '[', tcell.ModNone), 213 | exists: false, 214 | }, 215 | { 216 | panel: "global", 217 | key: tcell.NewEventKey(tcell.KeyCtrlB, 'b', tcell.ModCtrl), 218 | exists: true, 219 | }, 220 | { 221 | panel: "global", 222 | key: tcell.NewEventKey(tcell.KeyCtrlC, 'c', tcell.ModCtrl), 223 | exists: false, 224 | }, 225 | { 226 | panel: "playlist", 227 | key: tcell.NewEventKey(tcell.KeyRune, '!', tcell.ModAlt), 228 | exists: true, 229 | }, 230 | { 231 | panel: "playlist", 232 | key: tcell.NewEventKey(tcell.KeyRune, '>', tcell.ModAlt), 233 | exists: false, 234 | }, 235 | { 236 | panel: "playlist", 237 | key: tcell.NewEventKey(tcell.KeyCtrlCarat, '^', tcell.ModCtrl), 238 | exists: true, 239 | }, 240 | { 241 | panel: "queue", 242 | key: tcell.NewEventKey(tcell.KeyRune, '>', tcell.ModAlt), 243 | exists: true, 244 | }, 245 | { 246 | panel: "queue", 247 | key: tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone), 248 | exists: true, 249 | }, 250 | } 251 | 252 | src := ` 253 | module Keybinds { 254 | global = {} 255 | playlist = {} 256 | queue = {} 257 | 258 | global["b"] = func() { return 0 } 259 | global["]"] = func() { return 0 } 260 | global["ctrl_b"] = func() { return 0 } 261 | global["alt_b"] = func() { return 0 } 262 | 263 | playlist["alt_!"] = func() { return 0 } 264 | playlist["ctrl_^"] = func() { return 0 } 265 | 266 | queue["alt_>"] = func() { return 0 } 267 | queue["enter"] = func() { return 0 } 268 | } 269 | ` 270 | a := NewAnko() 271 | 272 | _, err := a.Execute(src) 273 | if err != nil { 274 | t.Error(err) 275 | } 276 | 277 | for i, test := range tests { 278 | got := a.KeybindExists(test.panel, test.key) 279 | msg := fmt.Sprintf("error on test %d", i+1) 280 | assert.Equal(t, test.exists, got, msg) 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /player/player.go: -------------------------------------------------------------------------------- 1 | // Package player is the place actually play the music 2 | package player 3 | 4 | import ( 5 | "os" 6 | "sync" 7 | "time" 8 | 9 | "github.com/faiface/beep" 10 | "github.com/faiface/beep/effects" 11 | "github.com/faiface/beep/mp3" 12 | "github.com/faiface/beep/speaker" 13 | "github.com/ztrue/tracerr" 14 | ) 15 | 16 | type Audio interface { 17 | Name() string 18 | Path() string 19 | } 20 | 21 | type Player struct { 22 | hasInit bool 23 | isRunning bool 24 | volume float64 25 | 26 | vol *effects.Volume 27 | ctrl *beep.Ctrl 28 | format *beep.Format 29 | length time.Duration 30 | currentSong Audio 31 | streamSeekCloser beep.StreamSeekCloser 32 | 33 | songFinish func(Audio) 34 | songStart func(Audio) 35 | songSkip func(Audio) 36 | mu sync.Mutex 37 | } 38 | 39 | // New returns new Player instance. 40 | func New(volume int) *Player { 41 | 42 | // Read initial volume from config 43 | initVol := AbsVolume(volume) 44 | 45 | // making sure user does not give invalid volume 46 | if volume > 100 || volume < 0 { 47 | initVol = 0 48 | } 49 | 50 | return &Player{volume: initVol} 51 | } 52 | 53 | // SetSongFinish accepts callback which will be executed when the song finishes. 54 | func (p *Player) SetSongFinish(f func(Audio)) { 55 | p.songFinish = f 56 | } 57 | 58 | // SetSongStart accepts callback which will be executed when the song starts. 59 | func (p *Player) SetSongStart(f func(Audio)) { 60 | p.songStart = f 61 | } 62 | 63 | // SetSongSkip accepts callback which will be executed when the song is skipped. 64 | func (p *Player) SetSongSkip(f func(Audio)) { 65 | p.songSkip = f 66 | } 67 | 68 | // executes songFinish callback. 69 | func (p *Player) execSongFinish(a Audio) { 70 | if p.songFinish != nil { 71 | p.songFinish(a) 72 | } 73 | } 74 | 75 | // executes songStart callback. 76 | func (p *Player) execSongStart(a Audio) { 77 | if p.songStart != nil { 78 | p.songStart(a) 79 | } 80 | } 81 | 82 | // executes songFinish callback. 83 | func (p *Player) execSongSkip(a Audio) { 84 | if p.songSkip != nil { 85 | p.songSkip(a) 86 | } 87 | } 88 | 89 | // Run plays the passed Audio. 90 | func (p *Player) Run(currSong Audio) error { 91 | 92 | p.isRunning = true 93 | p.execSongStart(currSong) 94 | 95 | f, err := os.Open(currSong.Path()) 96 | if err != nil { 97 | return tracerr.Wrap(err) 98 | } 99 | 100 | stream, format, err := mp3.Decode(f) 101 | if err != nil { 102 | return tracerr.Wrap(err) 103 | } 104 | 105 | p.streamSeekCloser = stream 106 | 107 | // song duration 108 | p.length = format.SampleRate.D(p.streamSeekCloser.Len()) 109 | 110 | sr := beep.SampleRate(48000) 111 | if !p.hasInit { 112 | 113 | // p.mu.Lock() 114 | err := speaker.Init(sr, sr.N(time.Second/10)) 115 | // p.mu.Unlock() 116 | 117 | if err != nil { 118 | return tracerr.Wrap(err) 119 | } 120 | 121 | p.hasInit = true 122 | } 123 | 124 | p.currentSong = currSong 125 | 126 | // resample to adapt to sample rate of new songs 127 | resampled := beep.Resample(4, format.SampleRate, sr, p.streamSeekCloser) 128 | 129 | sstreamer := beep.Seq(resampled, beep.Callback(func() { 130 | p.isRunning = false 131 | p.format = nil 132 | p.streamSeekCloser.Close() 133 | go p.execSongFinish(currSong) 134 | })) 135 | 136 | ctrl := &beep.Ctrl{ 137 | Streamer: sstreamer, 138 | Paused: false, 139 | } 140 | 141 | p.mu.Lock() 142 | p.format = &format 143 | p.ctrl = ctrl 144 | p.mu.Unlock() 145 | resampler := beep.ResampleRatio(4, 1, ctrl) 146 | 147 | volume := &effects.Volume{ 148 | Streamer: resampler, 149 | Base: 2, 150 | Volume: 0, 151 | Silent: false, 152 | } 153 | 154 | // sets the volume of previous player 155 | volume.Volume += p.volume 156 | p.vol = volume 157 | 158 | // starts playing the audio 159 | speaker.Play(p.vol) 160 | 161 | return nil 162 | } 163 | 164 | // Pause pauses Player. 165 | func (p *Player) Pause() { 166 | speaker.Lock() 167 | p.ctrl.Paused = true 168 | p.isRunning = false 169 | speaker.Unlock() 170 | } 171 | 172 | // Play unpauses Player. 173 | func (p *Player) Play() { 174 | speaker.Lock() 175 | p.ctrl.Paused = false 176 | p.isRunning = true 177 | speaker.Unlock() 178 | } 179 | 180 | // SetVolume set volume up and volume down using -0.5 or +0.5. 181 | func (p *Player) SetVolume(v float64) float64 { 182 | 183 | // check if no songs playing currently 184 | if p.vol == nil { 185 | p.volume += v 186 | return p.volume 187 | } 188 | 189 | speaker.Lock() 190 | p.vol.Volume += v 191 | p.volume = p.vol.Volume 192 | speaker.Unlock() 193 | return p.volume 194 | } 195 | 196 | // TogglePause toggles the pause state. 197 | func (p *Player) TogglePause() { 198 | 199 | if p.ctrl == nil { 200 | return 201 | } 202 | 203 | if p.ctrl.Paused { 204 | p.Play() 205 | } else { 206 | p.Pause() 207 | } 208 | } 209 | 210 | // Skip current song. 211 | func (p *Player) Skip() { 212 | 213 | p.execSongSkip(p.currentSong) 214 | 215 | if p.currentSong == nil { 216 | return 217 | } 218 | 219 | // drain the stream 220 | speaker.Lock() 221 | p.ctrl.Streamer = nil 222 | p.streamSeekCloser.Close() 223 | p.isRunning = false 224 | p.format = nil 225 | speaker.Unlock() 226 | 227 | p.execSongFinish(p.currentSong) 228 | } 229 | 230 | // GetPosition returns the current position of audio file. 231 | func (p *Player) GetPosition() time.Duration { 232 | 233 | p.mu.Lock() 234 | speaker.Lock() 235 | defer speaker.Unlock() 236 | defer p.mu.Unlock() 237 | if p.format == nil || p.streamSeekCloser == nil { 238 | return 1 239 | } 240 | 241 | return p.format.SampleRate.D(p.streamSeekCloser.Position()) 242 | } 243 | 244 | // Seek is the function to move forward and rewind 245 | func (p *Player) Seek(pos int) error { 246 | p.mu.Lock() 247 | speaker.Lock() 248 | defer speaker.Unlock() 249 | defer p.mu.Unlock() 250 | err := p.streamSeekCloser.Seek(pos * int(p.format.SampleRate)) 251 | return err 252 | } 253 | 254 | // IsPaused is used to distinguish the player between pause and stop 255 | func (p *Player) IsPaused() bool { 256 | p.mu.Lock() 257 | speaker.Lock() 258 | defer speaker.Unlock() 259 | defer p.mu.Unlock() 260 | if p.ctrl == nil { 261 | return false 262 | } 263 | 264 | return p.ctrl.Paused 265 | } 266 | 267 | // GetVolume returns current volume. 268 | func (p *Player) GetVolume() float64 { 269 | return p.volume 270 | } 271 | 272 | // GetCurrentSong returns current song. 273 | func (p *Player) GetCurrentSong() Audio { 274 | return p.currentSong 275 | } 276 | 277 | // HasInit checks if the speaker has been initialized or not. Speaker 278 | // initialization will only happen once. 279 | func (p *Player) HasInit() bool { 280 | return p.hasInit 281 | } 282 | 283 | // IsRunning returns true if Player is running an audio. 284 | func (p *Player) IsRunning() bool { 285 | return p.isRunning 286 | } 287 | 288 | // GetLength return the length of the song in the queue 289 | func GetLength(audioPath string) (time.Duration, error) { 290 | f, err := os.Open(audioPath) 291 | 292 | if err != nil { 293 | return 0, tracerr.Wrap(err) 294 | } 295 | 296 | defer f.Close() 297 | 298 | streamer, format, err := mp3.Decode(f) 299 | 300 | if err != nil { 301 | return 0, tracerr.Wrap(err) 302 | } 303 | 304 | defer streamer.Close() 305 | return format.SampleRate.D(streamer.Len()), nil 306 | } 307 | 308 | // VolToHuman converts float64 volume that is used by audio library to human 309 | // readable form (0 - 100) 310 | func VolToHuman(volume float64) int { 311 | return int(volume*10) + 100 312 | } 313 | 314 | // AbsVolume converts human readable form volume (0 - 100) to float64 volume 315 | // that is used by the audio library 316 | func AbsVolume(volume int) float64 { 317 | return (float64(volume) - 100) / 10 318 | } 319 | -------------------------------------------------------------------------------- /lyric/lrc.go: -------------------------------------------------------------------------------- 1 | // Package lyric package download lyrics from different website and embed them into mp3 file. 2 | // lrc file is used to parse lrc file into subtitle. Similar to subtitles package 3 | // [al:''Album where the song is from''] 4 | // [ar:''Lyrics artist''] 5 | // [by:''Creator of the LRC file''] 6 | // [offset:''+/- Overall timestamp adjustment in milliseconds, + shifts time up, - shifts down''] 7 | // [re:''The player or editor that creates LRC file''] 8 | // [ti:''Lyrics (song) title''] 9 | // [ve:''version of program''] 10 | // [ti:Let's Twist Again] 11 | // [ar:Chubby Checker oppure Beatles, The] 12 | // [au:Written by Kal Mann / Dave Appell, 1961] 13 | // [al:Hits Of The 60's - Vol. 2 – Oldies] 14 | // [00:12.00]Lyrics beginning ... 15 | // [00:15.30]Some more lyrics ... 16 | package lyric 17 | 18 | import ( 19 | "errors" 20 | "fmt" 21 | "regexp" 22 | "runtime" 23 | "sort" 24 | "strconv" 25 | "strings" 26 | "time" 27 | 28 | "github.com/tramhao/id3v2" 29 | "github.com/ztrue/tracerr" 30 | ) 31 | 32 | // Lyric contains UnsyncedCaptions and SyncedCaptions 33 | type Lyric struct { 34 | Album string 35 | Artist string 36 | ByCreator string // Creator of LRC file 37 | Offset int32 // positive means delay lyric 38 | RePlayerEditor string // Player or Editor to create this LRC file 39 | Title string 40 | VersionPlayerEditor string // Version of player or editor 41 | LangExt string 42 | UnsyncedCaptions []UnsyncedCaption // USLT captions 43 | SyncedCaptions []id3v2.SyncedText // SYLT captions 44 | } 45 | 46 | // UnsyncedCaption is only showing in tageditor 47 | type UnsyncedCaption struct { 48 | Timestamp uint32 49 | Text string 50 | } 51 | 52 | // Eol is the end of line characters to use when writing .srt data 53 | var eol = "\n" 54 | 55 | func init() { 56 | if runtime.GOOS == "windows" { 57 | eol = "\r\n" 58 | } 59 | } 60 | 61 | func looksLikeLRC(s string) bool { 62 | if s != "" { 63 | if s[0] == 239 || s[0] == 91 { 64 | return true 65 | } 66 | } 67 | return false 68 | } 69 | 70 | // NewFromLRC parses a .lrc text into Subtitle, assumes s is a clean utf8 string 71 | func (lyric *Lyric) NewFromLRC(s string) (err error) { 72 | s = cleanLRC(s) 73 | lines := strings.Split(s, "\n") 74 | 75 | for i := 0; i < len(lines)-1; i++ { 76 | seq := strings.Trim(lines[i], "\r ") 77 | if seq == "" { 78 | continue 79 | } 80 | 81 | if strings.HasPrefix(seq, "[offset") { 82 | tmpString := strings.TrimPrefix(seq, "[offset:") 83 | tmpString = strings.TrimSuffix(tmpString, "]") 84 | tmpString = strings.ReplaceAll(tmpString, " ", "") 85 | var intOffset int 86 | intOffset, err = strconv.Atoi(tmpString) 87 | if err != nil { 88 | return tracerr.Wrap(err) 89 | } 90 | lyric.Offset = int32(intOffset) 91 | } 92 | 93 | timestampPattern := regexp.MustCompile(`(?U)^\[[0-9].*\]`) 94 | matchTimestamp := timestampPattern.FindStringSubmatch(lines[i]) 95 | 96 | if len(matchTimestamp) < 1 { 97 | // Here we continue to parse the subtitle and ignore the lines have no timestamp 98 | continue 99 | } 100 | 101 | var o UnsyncedCaption 102 | 103 | o.Timestamp, err = parseLrcTime(matchTimestamp[0]) 104 | if err != nil { 105 | err = fmt.Errorf("lrc: start error at line %d: %v", i, err) 106 | break 107 | } 108 | 109 | r2 := regexp.MustCompile(`^\[.*\]`) 110 | s2 := r2.ReplaceAllString(lines[i], "$1") 111 | s3 := strings.Trim(s2, "\r") 112 | s3 = strings.Trim(s3, "\n") 113 | s3 = strings.TrimSpace(s3) 114 | singleSpacePattern := regexp.MustCompile(`\s+`) 115 | s3 = singleSpacePattern.ReplaceAllString(s3, " ") 116 | o.Text = s3 117 | lyric.UnsyncedCaptions = append(lyric.UnsyncedCaptions, o) 118 | } 119 | 120 | // we sort the cpations by Timestamp. This is to fix some lyrics downloaded are not sorted 121 | sort.SliceStable(lyric.UnsyncedCaptions, func(i, j int) bool { 122 | return lyric.UnsyncedCaptions[i].Timestamp < lyric.UnsyncedCaptions[j].Timestamp 123 | }) 124 | 125 | lyric.mergeLRC() 126 | 127 | // add synced lyric by calculating offset of unsynced lyric 128 | for _, v := range lyric.UnsyncedCaptions { 129 | var s id3v2.SyncedText 130 | s.Text = v.Text 131 | if lyric.Offset <= 0 { 132 | s.Timestamp = v.Timestamp + uint32(-lyric.Offset) 133 | } else { 134 | if v.Timestamp > uint32(lyric.Offset) { 135 | s.Timestamp = v.Timestamp - uint32(lyric.Offset) 136 | } else { 137 | s.Timestamp = 0 138 | } 139 | } 140 | lyric.SyncedCaptions = append(lyric.SyncedCaptions, s) 141 | } 142 | 143 | // merge again because timestamp 0 could overlap if offset is negative 144 | lyric.mergeSyncLRC() 145 | return 146 | } 147 | 148 | // parseLrcTime parses a lrc subtitle time (ms since start of song) 149 | func parseLrcTime(in string) (uint32, error) { 150 | in = strings.TrimPrefix(in, "[") 151 | in = strings.TrimSuffix(in, "]") 152 | // . and , to : 153 | in = strings.Replace(in, ",", ":", -1) 154 | in = strings.Replace(in, ".", ":", -1) 155 | 156 | if strings.Count(in, ":") == 2 { 157 | in += ":000" 158 | } 159 | 160 | r1 := regexp.MustCompile("([0-9]+):([0-9]+):([0-9]+):([0-9]+)") 161 | matches := r1.FindStringSubmatch(in) 162 | if len(matches) < 5 { 163 | return 0, fmt.Errorf("[lrc] Regexp didnt match: %s", in) 164 | } 165 | m, err := strconv.Atoi(matches[1]) 166 | if err != nil { 167 | return 0, err 168 | } 169 | s, err := strconv.Atoi(matches[2]) 170 | if err != nil { 171 | return 0, err 172 | } 173 | ms, err := strconv.Atoi(matches[3]) 174 | if err != nil { 175 | return 0, err 176 | } 177 | 178 | timeStamp := m*60*1000 + s*1000 + ms 179 | if timeStamp < 0 { 180 | timeStamp = 0 181 | } 182 | 183 | return uint32(timeStamp), nil 184 | } 185 | 186 | // cleanLRC clean the string download 187 | func cleanLRC(s string) (cleanLyric string) { 188 | // Clean ' to ' 189 | s = strings.ToValidUTF8(s, " ") 190 | s = strings.Replace(s, "'", "'", -1) 191 | // It's weird that sometimes there are two adjacent ''. 192 | // Replace it anyway 193 | cleanLyric = strings.Replace(s, "''", "'", -1) 194 | 195 | return cleanLyric 196 | } 197 | 198 | // mergeLRC merge lyric if the time between two captions is less than 2 seconds 199 | func (lyric *Lyric) mergeLRC() { 200 | 201 | lenLyric := len(lyric.UnsyncedCaptions) 202 | for i := 0; i < lenLyric-1; i++ { 203 | if lyric.UnsyncedCaptions[i].Timestamp+2000 > lyric.UnsyncedCaptions[i+1].Timestamp && lyric.UnsyncedCaptions[i].Text != "" { 204 | lyric.UnsyncedCaptions[i].Text = lyric.UnsyncedCaptions[i].Text + " " + lyric.UnsyncedCaptions[i+1].Text 205 | lyric.UnsyncedCaptions = removeUnsynced(lyric.UnsyncedCaptions, i+1) 206 | i-- 207 | lenLyric-- 208 | } 209 | } 210 | } 211 | 212 | // mergeSyncLRC merge lyric if the time between two captions is less than 2 seconds 213 | // this is specially useful when offset is negative and several timestamp 0 in synced lyric 214 | func (lyric *Lyric) mergeSyncLRC() { 215 | 216 | lenLyric := len(lyric.SyncedCaptions) 217 | for i := 0; i < lenLyric-1; i++ { 218 | if lyric.SyncedCaptions[i].Timestamp+2000 > lyric.SyncedCaptions[i+1].Timestamp && lyric.SyncedCaptions[i].Text != "" { 219 | lyric.SyncedCaptions[i].Text = lyric.SyncedCaptions[i].Text + " " + lyric.SyncedCaptions[i+1].Text 220 | lyric.SyncedCaptions = removeSynced(lyric.SyncedCaptions, i+1) 221 | i-- 222 | lenLyric-- 223 | } 224 | } 225 | } 226 | 227 | func removeUnsynced(slice []UnsyncedCaption, s int) []UnsyncedCaption { 228 | return append(slice[:s], slice[s+1:]...) 229 | } 230 | 231 | func removeSynced(slice []id3v2.SyncedText, s int) []id3v2.SyncedText { 232 | return append(slice[:s], slice[s+1:]...) 233 | } 234 | 235 | // AsLRC renders the sub in .lrc format 236 | func (lyric *Lyric) AsLRC() (res string) { 237 | if lyric.Offset != 0 { 238 | stringOffset := strconv.Itoa(int(lyric.Offset)) 239 | res += "[offset:" + stringOffset + "]" + eol 240 | } 241 | 242 | for _, cap := range lyric.UnsyncedCaptions { 243 | res += cap.asLRC() 244 | } 245 | return 246 | } 247 | 248 | // asLRC renders the caption as one line in lrc 249 | func (cap UnsyncedCaption) asLRC() string { 250 | res := "[" + timeLRC(cap.Timestamp) + "]" 251 | res += cap.Text + eol 252 | return res 253 | } 254 | 255 | // timeLRC renders a timestamp for use in lrc 256 | func timeLRC(t uint32) string { 257 | tDuration := time.Duration(t) * time.Millisecond 258 | h := tDuration / time.Hour 259 | tDuration -= h * time.Hour 260 | m := tDuration / time.Minute 261 | tDuration -= m * time.Minute 262 | s := tDuration / time.Second 263 | tDuration -= s * time.Second 264 | ms := tDuration / time.Millisecond 265 | 266 | res := fmt.Sprintf("%02d:%02d.%03d", m, s, ms) 267 | return res 268 | } 269 | 270 | // GetText will fetch lyric by time in seconds 271 | func (lyric *Lyric) GetText(time int) (string, error) { 272 | 273 | if len(lyric.SyncedCaptions) == 0 { 274 | return "", errors.New("no synced lyric found") 275 | } 276 | 277 | // here we want to show lyric 1 second earlier 278 | time = time*1000 + 1000 279 | 280 | text := lyric.SyncedCaptions[0].Text 281 | 282 | for _, v := range lyric.SyncedCaptions { 283 | if time >= int(v.Timestamp) { 284 | text = v.Text 285 | } else { 286 | break 287 | } 288 | } 289 | 290 | return text, nil 291 | } 292 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 Raziman 2 | 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "errors" 8 | "fmt" 9 | "log" 10 | "net/http" 11 | "os" 12 | "os/exec" 13 | "path" 14 | "path/filepath" 15 | "regexp" 16 | "strconv" 17 | "strings" 18 | "time" 19 | 20 | "github.com/tramhao/id3v2" 21 | "github.com/ztrue/tracerr" 22 | 23 | "github.com/issadarkthing/gomu/lyric" 24 | "github.com/issadarkthing/gomu/player" 25 | ) 26 | 27 | // logError logs the error message. 28 | func logError(err error) { 29 | log.Println("[ERROR]", tracerr.Sprint(err)) 30 | } 31 | 32 | func logDebug(msg string) { 33 | log.Println("[DEBUG]", msg) 34 | } 35 | 36 | // die logs the error message and call os.Exit(1) 37 | // prefer this instead of panic 38 | func die(err error) { 39 | logError(err) 40 | fmt.Fprintln(os.Stderr, err) 41 | os.Exit(1) 42 | } 43 | 44 | // Formats duration to my desired output mm:ss 45 | func fmtDuration(input time.Duration) string { 46 | 47 | val := input.Round(time.Second).String() 48 | 49 | if !strings.Contains(val, "m") { 50 | val = "0m" + val 51 | } 52 | val = strings.ReplaceAll(val, "h", ":") 53 | val = strings.ReplaceAll(val, "m", ":") 54 | val = strings.ReplaceAll(val, "s", "") 55 | var result []string 56 | 57 | for _, v := range strings.Split(val, ":") { 58 | 59 | if len(v) < 2 { 60 | result = append(result, "0"+v) 61 | } else { 62 | result = append(result, v) 63 | } 64 | 65 | } 66 | 67 | return strings.Join(result, ":") 68 | } 69 | 70 | // fmtDurationH returns the formatted duration `x hr x min` 71 | func fmtDurationH(input time.Duration) string { 72 | 73 | re := regexp.MustCompile(`\d+s`) 74 | val := input.Round(time.Second).String() 75 | 76 | // remove seconds 77 | result := re.ReplaceAllString(val, "") 78 | result = strings.Replace(result, "h", " hr ", 1) 79 | result = strings.Replace(result, "m", " min", 1) 80 | 81 | if result == "" { 82 | return "0 hr 0 min" 83 | } 84 | 85 | return result 86 | } 87 | 88 | // Expands relative path to absolute path and tilde to /home/(user) 89 | func expandFilePath(path string) string { 90 | p := expandTilde(path) 91 | 92 | if filepath.IsAbs(p) { 93 | return p 94 | } 95 | 96 | p, err := filepath.Abs(p) 97 | if err != nil { 98 | die(err) 99 | } 100 | 101 | return p 102 | } 103 | 104 | // Expands tilde alias to /home/user 105 | func expandTilde(_path string) string { 106 | if !strings.HasPrefix(_path, "~") { 107 | return _path 108 | } 109 | 110 | home, err := os.UserHomeDir() 111 | 112 | if err != nil { 113 | die(err) 114 | } 115 | 116 | return path.Join(home, strings.TrimPrefix(_path, "~")) 117 | } 118 | 119 | // Detects the filetype of file 120 | func getFileContentType(out *os.File) (string, error) { 121 | 122 | buffer := make([]byte, 512) 123 | 124 | _, err := out.Read(buffer) 125 | if err != nil { 126 | return "", tracerr.Wrap(err) 127 | } 128 | 129 | contentType := http.DetectContentType(buffer) 130 | 131 | return strings.SplitAfter(contentType, "/")[1], nil 132 | } 133 | 134 | // Gets the file name by removing extension and path 135 | func getName(fn string) string { 136 | return strings.TrimSuffix(path.Base(fn), ".mp3") 137 | } 138 | 139 | // This just parsing the output from the ytdl to get the audio path 140 | // This is used because we need to get the song name 141 | // example ~/path/to/song/song.mp3 142 | func extractFilePath(output []byte, dir string) string { 143 | 144 | regexSearch := fmt.Sprintf(`\[ffmpeg\] Destination: %s\/.*.mp3`, 145 | escapeBackSlash(dir)) 146 | 147 | parseAudioPathOnly := regexp.MustCompile(`\/.*mp3$`) 148 | 149 | re := regexp.MustCompile(regexSearch) 150 | 151 | return string(parseAudioPathOnly.Find(re.Find(output))) 152 | 153 | } 154 | 155 | func escapeBackSlash(input string) string { 156 | return strings.ReplaceAll(input, "/", `\/`) 157 | } 158 | 159 | // progresStr creates a simple progress bar 160 | // example: =====----- 161 | func progresStr(progress, maxProgress, maxLength int, 162 | fill, empty string) string { 163 | 164 | currLength := maxLength * progress / maxProgress 165 | 166 | return fmt.Sprintf("%s%s", 167 | strings.Repeat(fill, currLength), 168 | strings.Repeat(empty, maxLength-currLength), 169 | ) 170 | } 171 | 172 | // padHex pad the neccessary 0 to create six hex digit 173 | func padHex(r, g, b int32) string { 174 | 175 | var result strings.Builder 176 | 177 | for _, v := range []int32{r, g, b} { 178 | hex := fmt.Sprintf("%x", v) 179 | 180 | if len(hex) == 1 { 181 | result.WriteString(fmt.Sprintf("0%s", hex)) 182 | } else { 183 | result.WriteString(hex) 184 | } 185 | } 186 | 187 | return result.String() 188 | } 189 | 190 | func validHexColor(color string) bool { 191 | reg := regexp.MustCompile(`^#([A-Fa-f0-9]{6})$`) 192 | return reg.MatchString(color) 193 | } 194 | 195 | func contains(needle int, haystack []int) bool { 196 | for _, i := range haystack { 197 | if needle == i { 198 | return true 199 | } 200 | } 201 | return false 202 | } 203 | 204 | // appendFile appends to a file, create the file if not exists 205 | func appendFile(path string, content string) error { 206 | f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 207 | if err != nil { 208 | if err != os.ErrNotExist { 209 | return tracerr.Wrap(err) 210 | } 211 | // create the neccessary parent directory 212 | err = os.MkdirAll(filepath.Dir(expandFilePath(path)), os.ModePerm) 213 | if err != nil { 214 | return tracerr.Wrap(err) 215 | } 216 | } 217 | defer f.Close() 218 | if _, err := f.WriteString(content); err != nil { 219 | return tracerr.Wrap(err) 220 | } 221 | return nil 222 | } 223 | 224 | func shell(input string) (string, error) { 225 | 226 | args := strings.Split(input, " ") 227 | for i, arg := range args { 228 | args[i] = strings.Trim(arg, " ") 229 | } 230 | 231 | cmd := exec.Command(args[0], args[1:]...) 232 | 233 | var stdout, stderr bytes.Buffer 234 | cmd.Stdout = &stdout 235 | cmd.Stderr = &stderr 236 | 237 | err := cmd.Run() 238 | if err != nil { 239 | return "", err 240 | } 241 | 242 | if stderr.Len() != 0 { 243 | return "", errors.New(stderr.String()) 244 | } 245 | 246 | return stdout.String(), nil 247 | } 248 | 249 | func embedLyric(songPath string, lyricTobeWritten *lyric.Lyric, isDelete bool) (err error) { 250 | 251 | var tag *id3v2.Tag 252 | tag, err = id3v2.Open(songPath, id3v2.Options{Parse: true}) 253 | if err != nil { 254 | return tracerr.Wrap(err) 255 | } 256 | defer tag.Close() 257 | usltFrames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription")) 258 | tag.DeleteFrames(tag.CommonID("Unsynchronised lyrics/text transcription")) 259 | // We delete the lyric frame with same language by delete all and add others back 260 | for _, f := range usltFrames { 261 | uslf, ok := f.(id3v2.UnsynchronisedLyricsFrame) 262 | if !ok { 263 | die(errors.New("uslt error")) 264 | } 265 | if uslf.ContentDescriptor == lyricTobeWritten.LangExt { 266 | continue 267 | } 268 | tag.AddUnsynchronisedLyricsFrame(uslf) 269 | } 270 | syltFrames := tag.GetFrames(tag.CommonID("Synchronised lyrics/text")) 271 | tag.DeleteFrames(tag.CommonID("Synchronised lyrics/text")) 272 | for _, f := range syltFrames { 273 | sylf, ok := f.(id3v2.SynchronisedLyricsFrame) 274 | if !ok { 275 | die(errors.New("sylt error")) 276 | } 277 | if strings.Contains(sylf.ContentDescriptor, lyricTobeWritten.LangExt) { 278 | continue 279 | } 280 | tag.AddSynchronisedLyricsFrame(sylf) 281 | } 282 | 283 | if !isDelete { 284 | tag.AddUnsynchronisedLyricsFrame(id3v2.UnsynchronisedLyricsFrame{ 285 | Encoding: id3v2.EncodingUTF8, 286 | Language: "eng", 287 | ContentDescriptor: lyricTobeWritten.LangExt, 288 | Lyrics: lyricTobeWritten.AsLRC(), 289 | }) 290 | var lyric lyric.Lyric 291 | err := lyric.NewFromLRC(lyricTobeWritten.AsLRC()) 292 | if err != nil { 293 | return tracerr.Wrap(err) 294 | } 295 | tag.AddSynchronisedLyricsFrame(id3v2.SynchronisedLyricsFrame{ 296 | Encoding: id3v2.EncodingUTF8, 297 | Language: "eng", 298 | TimestampFormat: 2, 299 | ContentType: 1, 300 | ContentDescriptor: lyricTobeWritten.LangExt, 301 | SynchronizedTexts: lyric.SyncedCaptions, 302 | }) 303 | 304 | } 305 | 306 | err = tag.Save() 307 | if err != nil { 308 | return tracerr.Wrap(err) 309 | } 310 | 311 | return err 312 | 313 | } 314 | 315 | func embedLength(songPath string) (time.Duration, error) { 316 | tag, err := id3v2.Open(songPath, id3v2.Options{Parse: true}) 317 | if err != nil { 318 | return 0, tracerr.Wrap(err) 319 | } 320 | defer tag.Close() 321 | 322 | var lengthSongTimeDuration time.Duration 323 | lengthSongTimeDuration, err = player.GetLength(songPath) 324 | if err != nil { 325 | return 0, tracerr.Wrap(err) 326 | } 327 | 328 | lengthSongString := strconv.FormatInt(lengthSongTimeDuration.Milliseconds(), 10) 329 | lengthFrame := id3v2.UserDefinedTextFrame{ 330 | Encoding: id3v2.EncodingUTF8, 331 | Description: "TLEN", 332 | Value: lengthSongString, 333 | } 334 | tag.AddUserDefinedTextFrame(lengthFrame) 335 | 336 | err = tag.Save() 337 | if err != nil { 338 | return 0, tracerr.Wrap(err) 339 | } 340 | return lengthSongTimeDuration, err 341 | } 342 | 343 | func getTagLength(songPath string) (songLength time.Duration, err error) { 344 | var tag *id3v2.Tag 345 | tag, err = id3v2.Open(songPath, id3v2.Options{Parse: true}) 346 | if err != nil { 347 | return 0, tracerr.Wrap(err) 348 | } 349 | defer tag.Close() 350 | tlenFrames := tag.GetFrames(tag.CommonID("User defined text information frame")) 351 | if tlenFrames == nil { 352 | songLength, err = embedLength(songPath) 353 | if err != nil { 354 | return 0, tracerr.Wrap(err) 355 | } 356 | return songLength, nil 357 | } 358 | for _, tlenFrame := range tlenFrames { 359 | if tlenFrame.(id3v2.UserDefinedTextFrame).Description == "TLEN" { 360 | songLengthString := tlenFrame.(id3v2.UserDefinedTextFrame).Value 361 | songLengthInt64, err := strconv.ParseInt(songLengthString, 10, 64) 362 | if err != nil { 363 | return 0, tracerr.Wrap(err) 364 | } 365 | songLength = (time.Duration)(songLengthInt64) * time.Millisecond 366 | break 367 | } 368 | } 369 | if songLength != 0 { 370 | return songLength, nil 371 | } 372 | songLength, err = embedLength(songPath) 373 | if err != nil { 374 | return 0, tracerr.Wrap(err) 375 | } 376 | 377 | return songLength, err 378 | } 379 | -------------------------------------------------------------------------------- /playingbar.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 Raziman 2 | 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "errors" 8 | "fmt" 9 | "image" 10 | "strconv" 11 | "strings" 12 | "sync/atomic" 13 | "syscall" 14 | "time" 15 | "unsafe" 16 | 17 | "github.com/disintegration/imaging" 18 | "github.com/rivo/tview" 19 | "github.com/tramhao/id3v2" 20 | "github.com/ztrue/tracerr" 21 | ugo "gitlab.com/diamondburned/ueberzug-go" 22 | 23 | "github.com/issadarkthing/gomu/lyric" 24 | "github.com/issadarkthing/gomu/player" 25 | ) 26 | 27 | // PlayingBar shows song name, progress and lyric 28 | type PlayingBar struct { 29 | *tview.Frame 30 | full int32 31 | update chan struct{} 32 | progress int32 33 | skip bool 34 | text *tview.TextView 35 | hasTag bool 36 | tag *id3v2.Tag 37 | subtitle *lyric.Lyric 38 | subtitles []*lyric.Lyric 39 | albumPhoto *ugo.Image 40 | albumPhotoSource image.Image 41 | colrowPixel int32 42 | } 43 | 44 | func (p *PlayingBar) help() []string { 45 | return []string{} 46 | } 47 | 48 | // Playing bar shows progress of the song and the title of the song 49 | func newPlayingBar() *PlayingBar { 50 | 51 | textView := tview.NewTextView().SetTextAlign(tview.AlignCenter) 52 | textView.SetBackgroundColor(gomu.colors.background) 53 | textView.SetDynamicColors(true) 54 | 55 | frame := tview.NewFrame(textView).SetBorders(1, 1, 1, 1, 1, 1) 56 | frame.SetBorder(true).SetTitle(" Now Playing ") 57 | frame.SetBackgroundColor(gomu.colors.background) 58 | 59 | p := &PlayingBar{ 60 | Frame: frame, 61 | text: textView, 62 | update: make(chan struct{}), 63 | } 64 | 65 | return p 66 | } 67 | 68 | // Start processing progress bar 69 | func (p *PlayingBar) run() error { 70 | 71 | for { 72 | 73 | // stop progressing if song ends or skipped 74 | progress := p.getProgress() 75 | full := p.getFull() 76 | 77 | if progress > full || p.skip { 78 | p.skip = false 79 | p.setProgress(0) 80 | break 81 | } 82 | 83 | if gomu.player.IsPaused() { 84 | time.Sleep(1 * time.Second) 85 | continue 86 | } 87 | 88 | // p.progress = int(gomu.player.GetPosition().Seconds()) 89 | p.setProgress(int(gomu.player.GetPosition().Seconds())) 90 | 91 | start, err := time.ParseDuration(strconv.Itoa(progress) + "s") 92 | if err != nil { 93 | return tracerr.Wrap(err) 94 | } 95 | 96 | end, err := time.ParseDuration(strconv.Itoa(full) + "s") 97 | 98 | if err != nil { 99 | return tracerr.Wrap(err) 100 | } 101 | var width, colrowPixel int 102 | gomu.app.QueueUpdate(func() { 103 | _, _, width, _ = p.GetInnerRect() 104 | cols, rows, windowWidth, windowHeight := getConsoleSize() 105 | rowPixel := windowHeight / rows 106 | colPixel := windowWidth / cols 107 | colrowPixel = rowPixel + colPixel 108 | }) 109 | 110 | progressBar := progresStr(progress, full, width/2, "█", "━") 111 | if p.getColRowPixel() != colrowPixel { 112 | p.updatePhoto() 113 | p.setColRowPixel(colrowPixel) 114 | } 115 | // our progress bar 116 | var lyricText string 117 | if p.subtitle != nil { 118 | lyricText, err = p.subtitle.GetText(progress) 119 | if err != nil { 120 | return tracerr.Wrap(err) 121 | } 122 | } 123 | 124 | gomu.app.QueueUpdateDraw(func() { 125 | p.text.SetText(fmt.Sprintf("%s ┃%s┫ %s\n\n[%s]%v[-]", 126 | fmtDuration(start), 127 | progressBar, 128 | fmtDuration(end), 129 | gomu.colors.subtitle, 130 | lyricText, 131 | )) 132 | }) 133 | 134 | <-time.After(time.Second) 135 | } 136 | 137 | return nil 138 | } 139 | 140 | // Updates song title 141 | func (p *PlayingBar) setSongTitle(title string) { 142 | p.Clear() 143 | titleColor := gomu.colors.title 144 | p.AddText(title, true, tview.AlignCenter, titleColor) 145 | 146 | } 147 | 148 | // Resets progress bar, ready for execution 149 | func (p *PlayingBar) newProgress(currentSong *player.AudioFile, full int) { 150 | p.setFull(full) 151 | p.setProgress(0) 152 | p.hasTag = false 153 | p.tag = nil 154 | p.subtitles = nil 155 | p.subtitle = nil 156 | if p.albumPhoto != nil { 157 | p.albumPhoto.Clear() 158 | p.albumPhoto.Destroy() 159 | p.albumPhoto = nil 160 | } 161 | 162 | err := p.loadLyrics(currentSong.Path()) 163 | if err != nil { 164 | errorPopup(err) 165 | return 166 | } 167 | langLyricFromConfig := gomu.anko.GetString("General.lang_lyric") 168 | if langLyricFromConfig == "" { 169 | langLyricFromConfig = "en" 170 | } 171 | if p.hasTag && p.subtitles != nil { 172 | // First we check if the lyric language preferred is presented 173 | for _, v := range p.subtitles { 174 | if strings.Contains(langLyricFromConfig, v.LangExt) { 175 | p.subtitle = v 176 | break 177 | } 178 | } 179 | 180 | // Secondly we check if english lyric is available 181 | if p.subtitle == nil { 182 | for _, v := range p.subtitles { 183 | if v.LangExt == "en" { 184 | p.subtitle = v 185 | break 186 | } 187 | } 188 | } 189 | 190 | // Finally we display the first lyric 191 | if p.subtitle == nil { 192 | p.subtitle = p.subtitles[0] 193 | } 194 | } 195 | p.setSongTitle(currentSong.Name()) 196 | 197 | } 198 | 199 | // Sets default title and progress bar 200 | func (p *PlayingBar) setDefault() { 201 | p.setSongTitle("---------:---------") 202 | _, _, width, _ := p.GetInnerRect() 203 | text := fmt.Sprintf( 204 | "%s ┣%s┫ %s", "00:00", strings.Repeat("━", width/2), "00:00", 205 | ) 206 | p.text.SetText(text) 207 | if p.albumPhoto != nil { 208 | p.albumPhoto.Clear() 209 | } 210 | } 211 | 212 | // Skips the current playing song 213 | func (p *PlayingBar) stop() { 214 | p.skip = true 215 | } 216 | 217 | // When switch lyrics, we reload the lyrics from mp3 to reflect changes 218 | func (p *PlayingBar) switchLyrics() { 219 | 220 | err := p.loadLyrics(gomu.player.GetCurrentSong().Path()) 221 | if err != nil { 222 | errorPopup(err) 223 | return 224 | } 225 | // no subtitle just ignore 226 | if len(p.subtitles) == 0 { 227 | defaultTimedPopup(" Warning ", "No embed lyric found") 228 | p.subtitle = nil 229 | return 230 | } 231 | 232 | // only 1 subtitle, prompt to the user and select this one 233 | if len(p.subtitles) == 1 { 234 | p.subtitle = p.subtitles[0] 235 | defaultTimedPopup(" Warning ", p.subtitle.LangExt+" lyric is the only lyric available") 236 | return 237 | } 238 | 239 | // more than 1 subtitle, cycle through them and select next 240 | var langIndex int 241 | for i, v := range p.subtitles { 242 | if p.subtitle.LangExt == v.LangExt { 243 | langIndex = i + 1 244 | break 245 | } 246 | } 247 | 248 | if langIndex >= len(p.subtitles) { 249 | langIndex = 0 250 | } 251 | 252 | p.subtitle = p.subtitles[langIndex] 253 | 254 | defaultTimedPopup(" Success ", p.subtitle.LangExt+" lyric switched successfully.") 255 | } 256 | 257 | func (p *PlayingBar) delayLyric(lyricDelay int) (err error) { 258 | 259 | if p.subtitle != nil { 260 | p.subtitle.Offset -= int32(lyricDelay) 261 | err = embedLyric(gomu.player.GetCurrentSong().Path(), p.subtitle, false) 262 | if err != nil { 263 | return tracerr.Wrap(err) 264 | } 265 | err = p.loadLyrics(gomu.player.GetCurrentSong().Path()) 266 | if err != nil { 267 | return tracerr.Wrap(err) 268 | } 269 | for _, v := range p.subtitles { 270 | if strings.Contains(v.LangExt, p.subtitle.LangExt) { 271 | p.subtitle = v 272 | break 273 | } 274 | } 275 | } 276 | return nil 277 | } 278 | 279 | func (p *PlayingBar) loadLyrics(currentSongPath string) error { 280 | p.subtitles = nil 281 | 282 | var tag *id3v2.Tag 283 | var err error 284 | tag, err = id3v2.Open(currentSongPath, id3v2.Options{Parse: true}) 285 | if err != nil { 286 | return tracerr.Wrap(err) 287 | } 288 | defer tag.Close() 289 | 290 | if tag == nil { 291 | return nil 292 | } 293 | p.hasTag = true 294 | p.tag = tag 295 | 296 | if p.albumPhoto != nil { 297 | p.albumPhoto.Clear() 298 | p.albumPhoto.Destroy() 299 | p.albumPhoto = nil 300 | } 301 | 302 | syltFrames := tag.GetFrames(tag.CommonID("Synchronised lyrics/text")) 303 | usltFrames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription")) 304 | 305 | for _, f := range syltFrames { 306 | sylf, ok := f.(id3v2.SynchronisedLyricsFrame) 307 | if !ok { 308 | return fmt.Errorf("sylt error") 309 | } 310 | for _, u := range usltFrames { 311 | uslf, ok := u.(id3v2.UnsynchronisedLyricsFrame) 312 | if !ok { 313 | return errors.New("USLT error") 314 | } 315 | if sylf.ContentDescriptor == uslf.ContentDescriptor { 316 | var lyric lyric.Lyric 317 | err := lyric.NewFromLRC(uslf.Lyrics) 318 | if err != nil { 319 | return tracerr.Wrap(err) 320 | } 321 | lyric.SyncedCaptions = sylf.SynchronizedTexts 322 | lyric.LangExt = sylf.ContentDescriptor 323 | p.subtitles = append(p.subtitles, &lyric) 324 | } 325 | } 326 | } 327 | 328 | pictures := tag.GetFrames(tag.CommonID("Attached picture")) 329 | for _, f := range pictures { 330 | pic, ok := f.(id3v2.PictureFrame) 331 | if !ok { 332 | return errors.New("picture frame error") 333 | } 334 | 335 | // Do something with picture frame. 336 | imgTmp, err := imaging.Decode(bytes.NewReader(pic.Picture)) 337 | if err != nil { 338 | return tracerr.Wrap(err) 339 | } 340 | 341 | p.albumPhotoSource = imgTmp 342 | p.setColRowPixel(0) 343 | } 344 | 345 | return nil 346 | } 347 | 348 | func (p *PlayingBar) getProgress() int { 349 | return int(atomic.LoadInt32(&p.progress)) 350 | } 351 | 352 | func (p *PlayingBar) setProgress(progress int) { 353 | atomic.StoreInt32(&p.progress, int32(progress)) 354 | } 355 | 356 | func (p *PlayingBar) getFull() int { 357 | return int(atomic.LoadInt32(&p.full)) 358 | } 359 | 360 | func (p *PlayingBar) setFull(full int) { 361 | atomic.StoreInt32(&p.full, int32(full)) 362 | } 363 | 364 | func (p *PlayingBar) getColRowPixel() int { 365 | return int(atomic.LoadInt32(&p.colrowPixel)) 366 | } 367 | 368 | func (p *PlayingBar) setColRowPixel(colrowPixel int) { 369 | atomic.StoreInt32(&p.colrowPixel, int32(colrowPixel)) 370 | } 371 | 372 | func getConsoleSize() (int, int, int, int) { 373 | var sz struct { 374 | rows uint16 375 | cols uint16 376 | xpixels uint16 377 | ypixels uint16 378 | } 379 | _, _, _ = syscall.Syscall(syscall.SYS_IOCTL, 380 | uintptr(syscall.Stdout), uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&sz))) 381 | return int(sz.cols), int(sz.rows), int(sz.xpixels), int(sz.ypixels) 382 | } 383 | 384 | // updatePhoto finish two tasks: 1. resize photo based on room left for photo 385 | // 2. register photo in the correct position 386 | func (p *PlayingBar) updatePhoto() { 387 | // Put the whole block in goroutine, in order not to block the whole apps 388 | // also to avoid data race by adding QueueUpdateDraw 389 | go gomu.app.QueueUpdateDraw(func() { 390 | if p.albumPhotoSource == nil { 391 | return 392 | } 393 | 394 | if p.albumPhoto != nil { 395 | p.albumPhoto.Clear() 396 | p.albumPhoto.Destroy() 397 | p.albumPhoto = nil 398 | } 399 | x, y, width, height := gomu.queue.GetInnerRect() 400 | 401 | cols, rows, windowWidth, windowHeight := getConsoleSize() 402 | 403 | colPixel := windowWidth / cols 404 | rowPixel := windowHeight / rows 405 | imageWidth := width * colPixel / 3 406 | 407 | // resize the photo according to space left for x and y axis 408 | dstImage := imaging.Resize(p.albumPhotoSource, imageWidth, 0, imaging.Lanczos) 409 | var err error 410 | positionX := x*colPixel + width*colPixel - dstImage.Rect.Dx() 411 | positionY := y*rowPixel + height*rowPixel - dstImage.Rect.Dy() 412 | // register new image 413 | p.albumPhoto, err = ugo.NewImage(dstImage, positionX, positionY) 414 | if err != nil { 415 | errorPopup(err) 416 | } 417 | p.albumPhoto.Show() 418 | }) 419 | } 420 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/issadarkthing/gomu/player" 7 | "github.com/rivo/tview" 8 | "github.com/ztrue/tracerr" 9 | ) 10 | 11 | // Command map string to actual command function 12 | type Command struct { 13 | commands map[string]func() 14 | } 15 | 16 | func newCommand() Command { 17 | return Command{ 18 | commands: make(map[string]func()), 19 | } 20 | } 21 | 22 | func (c *Command) define(name string, callBack func()) { 23 | c.commands[name] = callBack 24 | } 25 | 26 | func (c Command) getFn(name string) (func(), error) { 27 | fn, ok := c.commands[name] 28 | if !ok { 29 | return nil, tracerr.New("command not found") 30 | } 31 | return fn, nil 32 | } 33 | 34 | func (c Command) defineCommands() { 35 | 36 | anko := gomu.anko 37 | 38 | /* Playlist */ 39 | 40 | c.define("create_playlist", func() { 41 | name, _ := gomu.pages.GetFrontPage() 42 | if name != "mkdir-popup" { 43 | createPlaylistPopup() 44 | } 45 | }) 46 | 47 | c.define("delete_playlist", func() { 48 | audioFile := gomu.playlist.getCurrentFile() 49 | if audioFile.IsAudioFile() { 50 | return 51 | } 52 | err := confirmDeleteAllPopup(audioFile.Node()) 53 | if err != nil { 54 | errorPopup(err) 55 | } 56 | }) 57 | 58 | c.define("delete_file", func() { 59 | audioFile := gomu.playlist.getCurrentFile() 60 | // prevent from deleting a directory 61 | if !audioFile.IsAudioFile() { 62 | return 63 | } 64 | 65 | gomu.playlist.deleteSong(audioFile) 66 | 67 | }) 68 | 69 | c.define("youtube_search", func() { 70 | ytSearchPopup() 71 | }) 72 | 73 | c.define("download_audio", func() { 74 | 75 | audioFile := gomu.playlist.getCurrentFile() 76 | currNode := gomu.playlist.GetCurrentNode() 77 | if gomu.pages.HasPage("download-input-popup") { 78 | gomu.pages.RemovePage("download-input-popup") 79 | gomu.popups.pop() 80 | return 81 | } 82 | // this ensures it downloads to 83 | // the correct dir 84 | if audioFile.IsAudioFile() { 85 | downloadMusicPopup(audioFile.ParentNode()) 86 | } else { 87 | downloadMusicPopup(currNode) 88 | } 89 | }) 90 | 91 | c.define("add_queue", func() { 92 | audioFile := gomu.playlist.getCurrentFile() 93 | currNode := gomu.playlist.GetCurrentNode() 94 | if audioFile.IsAudioFile() { 95 | gomu.queue.pushFront(audioFile) 96 | if len(gomu.queue.items) == 1 && !gomu.player.IsRunning() { 97 | err := gomu.queue.playQueue() 98 | if err != nil { 99 | errorPopup(err) 100 | } 101 | } 102 | } else { 103 | currNode.SetExpanded(true) 104 | } 105 | }) 106 | 107 | c.define("close_node", func() { 108 | audioFile := gomu.playlist.getCurrentFile() 109 | currNode := gomu.playlist.GetCurrentNode() 110 | // if closing node with no children 111 | // close the node's parent 112 | // remove the color of the node 113 | 114 | if audioFile.IsAudioFile() { 115 | parent := audioFile.ParentNode() 116 | gomu.playlist.setHighlight(parent) 117 | parent.SetExpanded(false) 118 | } 119 | currNode.Collapse() 120 | }) 121 | 122 | c.define("bulk_add", func() { 123 | currNode := gomu.playlist.GetCurrentNode() 124 | bulkAdd := anko.GetBool("General.confirm_bulk_add") 125 | 126 | if !bulkAdd { 127 | gomu.playlist.addAllToQueue(currNode) 128 | if len(gomu.queue.items) > 0 && !gomu.player.IsRunning() { 129 | err := gomu.queue.playQueue() 130 | if err != nil { 131 | errorPopup(err) 132 | } 133 | } 134 | return 135 | } 136 | 137 | confirmationPopup( 138 | "Are you sure to add this whole directory into queue?", 139 | func(_ int, label string) { 140 | 141 | if label == "yes" { 142 | gomu.playlist.addAllToQueue(currNode) 143 | if len(gomu.queue.items) > 0 && !gomu.player.IsRunning() { 144 | err := gomu.queue.playQueue() 145 | if err != nil { 146 | errorPopup(err) 147 | } 148 | } 149 | } 150 | 151 | }) 152 | }) 153 | 154 | c.define("refresh", func() { 155 | gomu.playlist.refresh() 156 | }) 157 | 158 | c.define("rename", func() { 159 | audioFile := gomu.playlist.getCurrentFile() 160 | renamePopup(audioFile) 161 | }) 162 | 163 | c.define("playlist_search", func() { 164 | 165 | files := make([]string, len(gomu.playlist.getAudioFiles())) 166 | 167 | for i, file := range gomu.playlist.getAudioFiles() { 168 | files[i] = file.Name() 169 | } 170 | 171 | searchPopup("Search", files, func(text string) { 172 | 173 | audio, err := gomu.playlist.findAudioFile(sha1Hex(text)) 174 | if err != nil { 175 | logError(err) 176 | } 177 | 178 | gomu.playlist.setHighlight(audio.Node()) 179 | gomu.playlist.refresh() 180 | }) 181 | }) 182 | 183 | c.define("reload_config", func() { 184 | cfg := expandFilePath(*gomu.args.config) 185 | err := execConfig(cfg) 186 | if err != nil { 187 | errorPopup(err) 188 | } 189 | 190 | infoPopup("successfully reload config file") 191 | }) 192 | 193 | /* Queue */ 194 | 195 | c.define("move_down", func() { 196 | gomu.queue.next() 197 | }) 198 | 199 | c.define("move_up", func() { 200 | gomu.queue.prev() 201 | }) 202 | 203 | c.define("delete_item", func() { 204 | gomu.queue.deleteItem(gomu.queue.GetCurrentItem()) 205 | }) 206 | 207 | c.define("clear_queue", func() { 208 | confirmationPopup("Are you sure to clear the queue?", 209 | func(_ int, label string) { 210 | if label == "yes" { 211 | gomu.queue.clearQueue() 212 | } 213 | }) 214 | }) 215 | 216 | c.define("play_selected", func() { 217 | if gomu.queue.GetItemCount() != 0 && gomu.queue.GetCurrentItem() != -1 { 218 | a, err := gomu.queue.deleteItem(gomu.queue.GetCurrentItem()) 219 | if err != nil { 220 | logError(err) 221 | } 222 | 223 | gomu.queue.pushFront(a) 224 | 225 | if gomu.player.IsRunning() { 226 | gomu.player.Skip() 227 | } else { 228 | gomu.queue.playQueue() 229 | } 230 | } 231 | }) 232 | 233 | c.define("toggle_loop", func() { 234 | gomu.queue.isLoop = !gomu.queue.isLoop 235 | gomu.queue.updateTitle() 236 | }) 237 | 238 | c.define("shuffle_queue", func() { 239 | gomu.queue.shuffle() 240 | }) 241 | 242 | c.define("queue_search", func() { 243 | 244 | queue := gomu.queue 245 | 246 | audios := make([]string, 0, len(queue.items)) 247 | for _, file := range queue.items { 248 | audios = append(audios, file.Name()) 249 | } 250 | 251 | searchPopup("Songs", audios, func(selected string) { 252 | 253 | index := 0 254 | for i, v := range queue.items { 255 | if v.Name() == selected { 256 | index = i 257 | } 258 | } 259 | 260 | queue.SetCurrentItem(index) 261 | }) 262 | }) 263 | 264 | /* Global */ 265 | c.define("quit", func() { 266 | 267 | confirmOnExit := anko.GetBool("General.confirm_on_exit") 268 | 269 | if !confirmOnExit { 270 | err := gomu.quit(gomu.args) 271 | if err != nil { 272 | logError(err) 273 | } 274 | } 275 | exitConfirmation(gomu.args) 276 | }) 277 | 278 | c.define("toggle_pause", func() { 279 | gomu.player.TogglePause() 280 | }) 281 | 282 | c.define("volume_up", func() { 283 | v := player.VolToHuman(gomu.player.GetVolume()) 284 | if v < 100 { 285 | vol := gomu.player.SetVolume(0.5) 286 | volumePopup(vol) 287 | } 288 | }) 289 | 290 | c.define("volume_down", func() { 291 | v := player.VolToHuman(gomu.player.GetVolume()) 292 | if v > 0 { 293 | vol := gomu.player.SetVolume(-0.5) 294 | volumePopup(vol) 295 | } 296 | }) 297 | 298 | c.define("skip", func() { 299 | gomu.player.Skip() 300 | }) 301 | 302 | c.define("toggle_help", func() { 303 | name, _ := gomu.pages.GetFrontPage() 304 | 305 | if name == "help-page" { 306 | gomu.pages.RemovePage(name) 307 | gomu.app.SetFocus(gomu.prevPanel.(tview.Primitive)) 308 | } else { 309 | helpPopup(gomu.prevPanel) 310 | } 311 | }) 312 | 313 | c.define("command_search", func() { 314 | 315 | names := make([]string, 0, len(c.commands)) 316 | for commandName := range c.commands { 317 | names = append(names, commandName) 318 | } 319 | searchPopup("Commands", names, func(selected string) { 320 | 321 | for name, fn := range c.commands { 322 | if name == selected { 323 | fn() 324 | } 325 | } 326 | }) 327 | }) 328 | 329 | c.define("forward", func() { 330 | if gomu.player.IsRunning() && !gomu.player.IsPaused() { 331 | position := gomu.playingBar.getProgress() + 10 332 | if position < gomu.playingBar.getFull() { 333 | err := gomu.player.Seek(position) 334 | if err != nil { 335 | errorPopup(err) 336 | } 337 | gomu.playingBar.setProgress(position) 338 | } 339 | } 340 | }) 341 | 342 | c.define("rewind", func() { 343 | if gomu.player.IsRunning() && !gomu.player.IsPaused() { 344 | position := gomu.playingBar.getProgress() - 10 345 | if position-1 > 0 { 346 | err := gomu.player.Seek(position) 347 | if err != nil { 348 | errorPopup(err) 349 | } 350 | gomu.playingBar.setProgress(position) 351 | } else { 352 | err := gomu.player.Seek(0) 353 | if err != nil { 354 | errorPopup(err) 355 | } 356 | gomu.playingBar.setProgress(0) 357 | } 358 | } 359 | }) 360 | 361 | c.define("forward_fast", func() { 362 | if gomu.player.IsRunning() && !gomu.player.IsPaused() { 363 | position := gomu.playingBar.getProgress() + 60 364 | if position < gomu.playingBar.getFull() { 365 | err := gomu.player.Seek(position) 366 | if err != nil { 367 | errorPopup(err) 368 | } 369 | gomu.playingBar.setProgress(position) 370 | } 371 | } 372 | }) 373 | 374 | c.define("rewind_fast", func() { 375 | if gomu.player.IsRunning() && !gomu.player.IsPaused() { 376 | position := gomu.playingBar.getProgress() - 60 377 | if position-1 > 0 { 378 | err := gomu.player.Seek(position) 379 | if err != nil { 380 | errorPopup(err) 381 | } 382 | gomu.playingBar.setProgress(position) 383 | } else { 384 | err := gomu.player.Seek(0) 385 | if err != nil { 386 | errorPopup(err) 387 | } 388 | gomu.playingBar.setProgress(0) 389 | } 390 | } 391 | }) 392 | 393 | c.define("yank", func() { 394 | err := gomu.playlist.yank() 395 | if err != nil { 396 | errorPopup(err) 397 | } 398 | }) 399 | 400 | c.define("paste", func() { 401 | err := gomu.playlist.paste() 402 | if err != nil { 403 | errorPopup(err) 404 | } 405 | }) 406 | 407 | c.define("repl", func() { 408 | replPopup() 409 | }) 410 | 411 | c.define("edit_tags", func() { 412 | audioFile := gomu.playlist.getCurrentFile() 413 | err := tagPopup(audioFile) 414 | if err != nil { 415 | errorPopup(err) 416 | } 417 | }) 418 | 419 | c.define("switch_lyric", func() { 420 | gomu.playingBar.switchLyrics() 421 | }) 422 | 423 | c.define("fetch_lyric", func() { 424 | audioFile := gomu.playlist.getCurrentFile() 425 | lang := "en" 426 | 427 | var wg sync.WaitGroup 428 | wg.Add(1) 429 | if audioFile.IsAudioFile() { 430 | go func() { 431 | err := lyricPopup(lang, audioFile, &wg) 432 | if err != nil { 433 | errorPopup(err) 434 | } 435 | }() 436 | } 437 | }) 438 | 439 | c.define("fetch_lyric_cn2", func() { 440 | audioFile := gomu.playlist.getCurrentFile() 441 | lang := "zh-CN" 442 | 443 | var wg sync.WaitGroup 444 | wg.Add(1) 445 | if audioFile.IsAudioFile() { 446 | go func() { 447 | err := lyricPopup(lang, audioFile, &wg) 448 | if err != nil { 449 | errorPopup(err) 450 | } 451 | }() 452 | } 453 | }) 454 | 455 | c.define("lyric_delay_increase", func() { 456 | err := gomu.playingBar.delayLyric(500) 457 | if err != nil { 458 | errorPopup(err) 459 | } 460 | }) 461 | 462 | c.define("lyric_delay_decrease", func() { 463 | err := gomu.playingBar.delayLyric(-500) 464 | if err != nil { 465 | errorPopup(err) 466 | } 467 | }) 468 | 469 | c.define("show_colors", func() { 470 | cp := colorsPopup() 471 | gomu.pages.AddPage("show-color-popup", center(cp, 95, 40), true, true) 472 | gomu.popups.push(cp) 473 | }) 474 | 475 | for name, cmd := range c.commands { 476 | err := gomu.anko.DefineGlobal(name, cmd) 477 | if err != nil { 478 | logError(err) 479 | } 480 | } 481 | 482 | } 483 | -------------------------------------------------------------------------------- /start.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 Raziman 2 | 3 | package main 4 | 5 | import ( 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "os" 10 | "os/signal" 11 | "path/filepath" 12 | "strings" 13 | "sync" 14 | "syscall" 15 | 16 | "github.com/gdamore/tcell/v2" 17 | "github.com/rivo/tview" 18 | "github.com/ztrue/tracerr" 19 | 20 | "github.com/issadarkthing/gomu/anko" 21 | "github.com/issadarkthing/gomu/hook" 22 | "github.com/issadarkthing/gomu/player" 23 | ) 24 | 25 | // Panel is used to keep track of childrens in slices 26 | type Panel interface { 27 | HasFocus() bool 28 | SetBorderColor(color tcell.Color) *tview.Box 29 | SetTitleColor(color tcell.Color) *tview.Box 30 | SetTitle(s string) *tview.Box 31 | GetTitle() string 32 | help() []string 33 | } 34 | 35 | // Args is the args for gomu executable 36 | type Args struct { 37 | config *string 38 | empty *bool 39 | music *string 40 | version *bool 41 | } 42 | 43 | func getArgs() Args { 44 | cfd, err := os.UserConfigDir() 45 | if err != nil { 46 | logError(tracerr.Wrap(err)) 47 | } 48 | configPath := filepath.Join(cfd, "gomu", "config") 49 | configFlag := flag.String("config", configPath, "Specify config file") 50 | emptyFlag := flag.Bool("empty", false, "Open gomu with empty queue. Does not override previous queue") 51 | home, err := os.UserHomeDir() 52 | if err != nil { 53 | logError(tracerr.Wrap(err)) 54 | } 55 | musicPath := filepath.Join(home, "Music") 56 | musicFlag := flag.String("music", musicPath, "Specify music directory") 57 | versionFlag := flag.Bool("version", false, "Print gomu version") 58 | flag.Parse() 59 | return Args{ 60 | config: configFlag, 61 | empty: emptyFlag, 62 | music: musicFlag, 63 | version: versionFlag, 64 | } 65 | } 66 | 67 | // built-in functions 68 | func defineBuiltins() { 69 | gomu.anko.DefineGlobal("debug_popup", debugPopup) 70 | gomu.anko.DefineGlobal("info_popup", infoPopup) 71 | gomu.anko.DefineGlobal("input_popup", inputPopup) 72 | gomu.anko.DefineGlobal("show_popup", defaultTimedPopup) 73 | gomu.anko.DefineGlobal("search_popup", searchPopup) 74 | gomu.anko.DefineGlobal("shell", shell) 75 | } 76 | 77 | func defineInternals() { 78 | playlist, _ := gomu.anko.NewModule("Playlist") 79 | playlist.Define("get_focused", gomu.playlist.getCurrentFile) 80 | playlist.Define("focus", func(filepath string) { 81 | 82 | root := gomu.playlist.GetRoot() 83 | root.Walk(func(node, _ *tview.TreeNode) bool { 84 | 85 | if node.GetReference().(*player.AudioFile).Path() == filepath { 86 | gomu.playlist.setHighlight(node) 87 | return false 88 | } 89 | 90 | return true 91 | }) 92 | }) 93 | 94 | queue, _ := gomu.anko.NewModule("Queue") 95 | queue.Define("get_focused", func() *player.AudioFile { 96 | index := gomu.queue.GetCurrentItem() 97 | if index < 0 || index > len(gomu.queue.items)-1 { 98 | return nil 99 | } 100 | item := gomu.queue.items[index] 101 | return item 102 | }) 103 | 104 | player, _ := gomu.anko.NewModule("Player") 105 | player.Define("current_audio", gomu.player.GetCurrentSong) 106 | } 107 | 108 | func setupHooks(hook *hook.EventHook, anko *anko.Anko) { 109 | 110 | events := []string{ 111 | "enter", 112 | "new_song", 113 | "skip", 114 | "play", 115 | "pause", 116 | "exit", 117 | } 118 | 119 | for _, event := range events { 120 | name := event 121 | hook.AddHook(name, func() { 122 | src := fmt.Sprintf(`Event.run_hooks("%s")`, name) 123 | _, err := anko.Execute(src) 124 | if err != nil { 125 | err = tracerr.Errorf("error execute hook: %w", err) 126 | logError(err) 127 | } 128 | }) 129 | } 130 | } 131 | 132 | // loadModules executes helper modules and default config that should only be 133 | // executed once 134 | func loadModules(env *anko.Anko) error { 135 | 136 | const listModule = ` 137 | module List { 138 | 139 | func collect(l, f) { 140 | result = [] 141 | for x in l { 142 | result += f(x) 143 | } 144 | return result 145 | } 146 | 147 | func filter(l, f) { 148 | result = [] 149 | for x in l { 150 | if f(x) { 151 | result += x 152 | } 153 | } 154 | return result 155 | } 156 | 157 | func reduce(l, f, acc) { 158 | for x in l { 159 | acc = f(acc, x) 160 | } 161 | return acc 162 | } 163 | } 164 | ` 165 | const eventModule = ` 166 | module Event { 167 | events = {} 168 | 169 | func add_hook(name, f) { 170 | hooks = events[name] 171 | 172 | if hooks == nil { 173 | events[name] = [f] 174 | return 175 | } 176 | 177 | hooks += f 178 | events[name] = hooks 179 | } 180 | 181 | func run_hooks(name) { 182 | hooks = events[name] 183 | 184 | if hooks == nil { 185 | return 186 | } 187 | 188 | for hook in hooks { 189 | hook() 190 | } 191 | } 192 | } 193 | ` 194 | 195 | const keybindModule = ` 196 | module Keybinds { 197 | global = {} 198 | playlist = {} 199 | queue = {} 200 | 201 | func def_g(kb, f) { 202 | global[kb] = f 203 | } 204 | 205 | func def_p(kb, f) { 206 | playlist[kb] = f 207 | } 208 | 209 | func def_q(kb, f) { 210 | queue[kb] = f 211 | } 212 | } 213 | ` 214 | _, err := env.Execute(eventModule + listModule + keybindModule) 215 | if err != nil { 216 | return tracerr.Wrap(err) 217 | } 218 | 219 | return nil 220 | } 221 | 222 | // executes user config with default config is executed first in order to apply 223 | // default values 224 | func execConfig(config string) error { 225 | 226 | const defaultConfig = ` 227 | 228 | module General { 229 | # confirmation popup to add the whole playlist to the queue 230 | confirm_bulk_add = true 231 | confirm_on_exit = true 232 | queue_loop = false 233 | load_prev_queue = true 234 | popup_timeout = "5s" 235 | sort_by_mtime = false 236 | # change this to directory that contains mp3 files 237 | music_dir = "~/Music" 238 | # url history of downloaded audio will be saved here 239 | history_path = "~/.local/share/gomu/urls" 240 | # some of the terminal supports unicode character 241 | # you can set this to true to enable emojis 242 | use_emoji = true 243 | # initial volume when gomu starts up 244 | volume = 80 245 | # if you experiencing error using this invidious instance, you can change it 246 | # to another instance from this list: 247 | # https://github.com/iv-org/documentation/blob/master/Invidious-Instances.md 248 | invidious_instance = "https://vid.puffyan.us" 249 | # Prefered language for lyrics to be displayed, if not available, english version 250 | # will be displayed. 251 | # Available tags: en,el,ko,es,th,vi,zh-Hans,zh-Hant,zh-CN and can be separated with comma. 252 | # find more tags: youtube-dl --skip-download --list-subs "url" 253 | lang_lyric = "en" 254 | # When save tag, could rename the file by tag info: artist-songname-album 255 | rename_bytag = false 256 | } 257 | 258 | module Emoji { 259 | # default emoji here is using awesome-terminal-fonts 260 | # you can change these to your liking 261 | playlist = "" 262 | file = "" 263 | loop = "ﯩ" 264 | noloop = "" 265 | } 266 | 267 | module Color { 268 | # you may choose colors by pressing 'c' 269 | accent = "darkcyan" 270 | background = "none" 271 | foreground = "white" 272 | popup = "black" 273 | 274 | playlist_directory = "darkcyan" 275 | playlist_highlight = "darkcyan" 276 | 277 | queue_highlight = "darkcyan" 278 | 279 | now_playing_title = "darkgreen" 280 | subtitle = "darkgoldenrod" 281 | } 282 | 283 | # you can get the syntax highlighting for this language here: 284 | # https://github.com/mattn/anko/tree/master/misc/vim 285 | # vim: ft=anko 286 | ` 287 | 288 | cfg := expandTilde(config) 289 | 290 | _, err := os.Stat(cfg) 291 | if os.IsNotExist(err) { 292 | err = appendFile(cfg, defaultConfig) 293 | if err != nil { 294 | return tracerr.Wrap(err) 295 | } 296 | } 297 | 298 | content, err := os.ReadFile(cfg) 299 | if err != nil { 300 | return tracerr.Wrap(err) 301 | } 302 | 303 | // execute default config 304 | _, err = gomu.anko.Execute(defaultConfig) 305 | if err != nil { 306 | return tracerr.Wrap(err) 307 | } 308 | 309 | // execute user config 310 | _, err = gomu.anko.Execute(string(content)) 311 | if err != nil { 312 | return tracerr.Wrap(err) 313 | } 314 | 315 | return nil 316 | } 317 | 318 | // Sets the layout of the application 319 | func layout(gomu *Gomu) *tview.Flex { 320 | flex := tview.NewFlex(). 321 | AddItem(gomu.playlist, 0, 1, false). 322 | AddItem(tview.NewFlex().SetDirection(tview.FlexRow). 323 | AddItem(gomu.queue, 0, 5, false). 324 | AddItem(gomu.playingBar, 9, 0, false), 0, 2, false) 325 | 326 | return flex 327 | } 328 | 329 | // Initialize 330 | func start(application *tview.Application, args Args) { 331 | 332 | // Print version and exit 333 | if *args.version { 334 | fmt.Printf("Gomu %s\n", VERSION) 335 | return 336 | } 337 | 338 | // Assigning to global variable gomu 339 | gomu = newGomu() 340 | gomu.command.defineCommands() 341 | defineBuiltins() 342 | 343 | err := loadModules(gomu.anko) 344 | if err != nil { 345 | die(err) 346 | } 347 | 348 | err = execConfig(expandFilePath(*args.config)) 349 | if err != nil { 350 | die(err) 351 | } 352 | 353 | setupHooks(gomu.hook, gomu.anko) 354 | 355 | gomu.hook.RunHooks("enter") 356 | gomu.args = args 357 | gomu.colors = newColor() 358 | 359 | // override default border 360 | // change double line border to one line border when focused 361 | tview.Borders.HorizontalFocus = tview.Borders.Horizontal 362 | tview.Borders.VerticalFocus = tview.Borders.Vertical 363 | tview.Borders.TopLeftFocus = tview.Borders.TopLeft 364 | tview.Borders.TopRightFocus = tview.Borders.TopRight 365 | tview.Borders.BottomLeftFocus = tview.Borders.BottomLeft 366 | tview.Borders.BottomRightFocus = tview.Borders.BottomRight 367 | tview.Styles.PrimitiveBackgroundColor = gomu.colors.popup 368 | 369 | gomu.initPanels(application, args) 370 | defineInternals() 371 | 372 | gomu.player.SetSongStart(func(audio player.Audio) { 373 | 374 | duration, err := getTagLength(audio.Path()) 375 | if err != nil || duration == 0 { 376 | duration, err = player.GetLength(audio.Path()) 377 | if err != nil { 378 | logError(err) 379 | return 380 | } 381 | } 382 | 383 | audioFile := audio.(*player.AudioFile) 384 | 385 | gomu.playingBar.newProgress(audioFile, int(duration.Seconds())) 386 | 387 | name := audio.Name() 388 | var description string 389 | 390 | if len(gomu.playingBar.subtitles) == 0 { 391 | description = name 392 | } else { 393 | lang := gomu.playingBar.subtitle.LangExt 394 | 395 | description = fmt.Sprintf("%s \n\n %s lyric loaded", name, lang) 396 | } 397 | 398 | defaultTimedPopup(" Now Playing ", description) 399 | 400 | go func() { 401 | err := gomu.playingBar.run() 402 | if err != nil { 403 | logError(err) 404 | } 405 | }() 406 | 407 | }) 408 | 409 | gomu.player.SetSongFinish(func(currAudio player.Audio) { 410 | 411 | gomu.playingBar.subtitles = nil 412 | var mu sync.Mutex 413 | mu.Lock() 414 | gomu.playingBar.subtitle = nil 415 | mu.Unlock() 416 | if gomu.queue.isLoop { 417 | _, err = gomu.queue.enqueue(currAudio.(*player.AudioFile)) 418 | if err != nil { 419 | logError(err) 420 | } 421 | } 422 | 423 | if len(gomu.queue.items) > 0 { 424 | err := gomu.queue.playQueue() 425 | if err != nil { 426 | logError(err) 427 | } 428 | } else { 429 | gomu.playingBar.setDefault() 430 | } 431 | }) 432 | 433 | flex := layout(gomu) 434 | gomu.pages.AddPage("main", flex, true, true) 435 | 436 | // sets the first focused panel 437 | gomu.setFocusPanel(gomu.playlist) 438 | gomu.prevPanel = gomu.playlist 439 | 440 | gomu.playingBar.setDefault() 441 | 442 | gomu.queue.isLoop = gomu.anko.GetBool("General.queue_loop") 443 | 444 | loadQueue := gomu.anko.GetBool("General.load_prev_queue") 445 | 446 | if !*args.empty && loadQueue { 447 | // load saved queue from previous session 448 | if err := gomu.queue.loadQueue(); err != nil { 449 | logError(err) 450 | } 451 | } 452 | 453 | if len(gomu.queue.items) > 0 { 454 | if err := gomu.queue.playQueue(); err != nil { 455 | logError(err) 456 | } 457 | } 458 | 459 | sigs := make(chan os.Signal, 1) 460 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 461 | go func() { 462 | sig := <-sigs 463 | errMsg := fmt.Sprintf("Received %s. Exiting program", sig.String()) 464 | logError(errors.New(errMsg)) 465 | err := gomu.quit(args) 466 | if err != nil { 467 | logError(errors.New("unable to quit program")) 468 | } 469 | }() 470 | 471 | cmds := map[rune]string{ 472 | 'q': "quit", 473 | ' ': "toggle_pause", 474 | '+': "volume_up", 475 | '=': "volume_up", 476 | '-': "volume_down", 477 | '_': "volume_down", 478 | 'n': "skip", 479 | ':': "command_search", 480 | '?': "toggle_help", 481 | 'f': "forward", 482 | 'F': "forward_fast", 483 | 'b': "rewind", 484 | 'B': "rewind_fast", 485 | 'm': "repl", 486 | 'T': "switch_lyric", 487 | 'c': "show_colors", 488 | } 489 | 490 | for key, cmdName := range cmds { 491 | src := fmt.Sprintf(`Keybinds.def_g("%c", %s)`, key, cmdName) 492 | gomu.anko.Execute(src) 493 | } 494 | 495 | // global keybindings are handled here 496 | application.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey { 497 | 498 | if gomu.pages.HasPage("repl-input-popup") { 499 | return e 500 | } 501 | 502 | if gomu.pages.HasPage("tag-editor-input-popup") { 503 | return e 504 | } 505 | 506 | popupName, _ := gomu.pages.GetFrontPage() 507 | 508 | // disables keybindings when writing in input fields 509 | if strings.Contains(popupName, "-input-") { 510 | return e 511 | } 512 | 513 | switch e.Key() { 514 | // cycle through each section 515 | case tcell.KeyTAB: 516 | if strings.Contains(popupName, "confirmation-") { 517 | return e 518 | } 519 | gomu.cyclePanels2() 520 | } 521 | 522 | if gomu.anko.KeybindExists("global", e) { 523 | 524 | err := gomu.anko.ExecKeybind("global", e) 525 | if err != nil { 526 | errorPopup(err) 527 | } 528 | 529 | return nil 530 | } 531 | 532 | return e 533 | }) 534 | 535 | // fix transparent background issue 536 | gomu.app.SetBeforeDrawFunc(func(screen tcell.Screen) bool { 537 | screen.Clear() 538 | return false 539 | }) 540 | 541 | init := false 542 | gomu.app.SetAfterDrawFunc(func(_ tcell.Screen) { 543 | if !init && len(gomu.queue.items) == 0 { 544 | gomu.playingBar.setDefault() 545 | init = true 546 | } 547 | }) 548 | 549 | gomu.app.SetRoot(gomu.pages, true).SetFocus(gomu.playlist) 550 | 551 | // main loop 552 | if err := gomu.app.Run(); err != nil { 553 | die(err) 554 | } 555 | 556 | gomu.hook.RunHooks("exit") 557 | } 558 | -------------------------------------------------------------------------------- /tageditor.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 Raziman 2 | 3 | package main 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/gdamore/tcell/v2" 12 | "github.com/rivo/tview" 13 | "github.com/tramhao/id3v2" 14 | "github.com/ztrue/tracerr" 15 | 16 | "github.com/issadarkthing/gomu/lyric" 17 | "github.com/issadarkthing/gomu/player" 18 | ) 19 | 20 | // lyricFlex extend the flex control to modify the Focus item 21 | type lyricFlex struct { 22 | *tview.Flex 23 | FocusedItem tview.Primitive 24 | inputs []tview.Primitive 25 | box *tview.Box 26 | } 27 | 28 | // tagPopup is used to edit tag, delete and fetch lyrics 29 | func tagPopup(node *player.AudioFile) (err error) { 30 | 31 | popupID := "tag-editor-input-popup" 32 | tag, popupLyricMap, options, err := node.LoadTagMap() 33 | if err != nil { 34 | return tracerr.Wrap(err) 35 | } 36 | 37 | var ( 38 | artistInputField *tview.InputField = tview.NewInputField() 39 | titleInputField *tview.InputField = tview.NewInputField() 40 | albumInputField *tview.InputField = tview.NewInputField() 41 | getTagButton *tview.Button = tview.NewButton("Get Tag") 42 | saveTagButton *tview.Button = tview.NewButton("Save Tag") 43 | lyricDropDown *tview.DropDown = tview.NewDropDown() 44 | deleteLyricButton *tview.Button = tview.NewButton("Delete Lyric") 45 | getLyricDropDown *tview.DropDown = tview.NewDropDown() 46 | getLyricButton *tview.Button = tview.NewButton("Fetch Lyric") 47 | lyricTextView *tview.TextView = tview.NewTextView() 48 | leftGrid *tview.Grid = tview.NewGrid() 49 | rightFlex *tview.Flex = tview.NewFlex() 50 | ) 51 | 52 | artistInputField.SetLabel("Artist: "). 53 | SetFieldWidth(20). 54 | SetText(tag.Artist()). 55 | SetFieldBackgroundColor(gomu.colors.popup) 56 | 57 | titleInputField.SetLabel("Title: "). 58 | SetFieldWidth(20). 59 | SetText(tag.Title()). 60 | SetFieldBackgroundColor(gomu.colors.popup) 61 | 62 | albumInputField.SetLabel("Album: "). 63 | SetFieldWidth(20). 64 | SetText(tag.Album()). 65 | SetFieldBackgroundColor(gomu.colors.popup) 66 | 67 | leftBox := tview.NewBox(). 68 | SetBorder(true). 69 | SetTitle(node.Name()). 70 | SetBackgroundColor(gomu.colors.popup). 71 | SetBorderColor(gomu.colors.accent). 72 | SetTitleColor(gomu.colors.accent). 73 | SetBorderPadding(1, 1, 2, 2) 74 | 75 | getTagButton.SetSelectedFunc(func() { 76 | var titles []string 77 | audioFile := node 78 | go func() { 79 | var lyricFetcher lyric.LyricFetcherCn 80 | results, err := lyricFetcher.LyricOptions(audioFile.Name()) 81 | if err != nil { 82 | errorPopup(err) 83 | return 84 | } 85 | for _, v := range results { 86 | titles = append(titles, v.TitleForPopup) 87 | } 88 | 89 | go func() { 90 | searchPopup(" Song Tags ", titles, func(selected string) { 91 | if selected == "" { 92 | return 93 | } 94 | 95 | var selectedIndex int 96 | for i, v := range results { 97 | if v.TitleForPopup == selected { 98 | selectedIndex = i 99 | break 100 | } 101 | } 102 | 103 | newTag := results[selectedIndex] 104 | artistInputField.SetText(newTag.Artist) 105 | titleInputField.SetText(newTag.Title) 106 | albumInputField.SetText(newTag.Album) 107 | 108 | tag, err = id3v2.Open(node.Path(), id3v2.Options{Parse: true}) 109 | if err != nil { 110 | errorPopup(err) 111 | return 112 | } 113 | defer tag.Close() 114 | tag.SetArtist(newTag.Artist) 115 | tag.SetTitle(newTag.Title) 116 | tag.SetAlbum(newTag.Album) 117 | err = tag.Save() 118 | if err != nil { 119 | errorPopup(err) 120 | return 121 | } 122 | if gomu.anko.GetBool("General.rename_bytag") { 123 | newName := fmt.Sprintf("%s-%s", newTag.Artist, newTag.Title) 124 | err = gomu.playlist.rename(newName) 125 | if err != nil { 126 | errorPopup(err) 127 | return 128 | } 129 | gomu.playlist.refresh() 130 | leftBox.SetTitle(newName) 131 | 132 | // update queue 133 | err = gomu.playlist.refreshAfterRename(node, newName) 134 | if err != nil { 135 | errorPopup(err) 136 | return 137 | } 138 | node = gomu.playlist.getCurrentFile() 139 | } 140 | defaultTimedPopup(" Success ", "Tag update successfully") 141 | }) 142 | gomu.app.Draw() 143 | }() 144 | }() 145 | }). 146 | SetBackgroundColorActivated(gomu.colors.popup). 147 | SetLabelColorActivated(gomu.colors.accent). 148 | SetBorder(true). 149 | SetBackgroundColor(gomu.colors.popup). 150 | SetTitleColor(gomu.colors.accent) 151 | 152 | saveTagButton.SetSelectedFunc(func() { 153 | tag, err = id3v2.Open(node.Path(), id3v2.Options{ 154 | Parse: true, 155 | ParseFrames: []string{}, 156 | }) 157 | if err != nil { 158 | errorPopup(err) 159 | return 160 | } 161 | defer tag.Close() 162 | newArtist := artistInputField.GetText() 163 | newTitle := titleInputField.GetText() 164 | newAlbum := albumInputField.GetText() 165 | tag.SetArtist(newArtist) 166 | tag.SetTitle(newTitle) 167 | tag.SetAlbum(newAlbum) 168 | err = tag.Save() 169 | if err != nil { 170 | errorPopup(err) 171 | return 172 | } 173 | if gomu.anko.GetBool("General.rename_bytag") { 174 | newName := fmt.Sprintf("%s-%s", newArtist, newTitle) 175 | err = gomu.playlist.rename(newName) 176 | if err != nil { 177 | errorPopup(err) 178 | return 179 | } 180 | gomu.playlist.refresh() 181 | leftBox.SetTitle(newName) 182 | 183 | // update queue 184 | err = gomu.playlist.refreshAfterRename(node, newName) 185 | if err != nil { 186 | errorPopup(err) 187 | return 188 | } 189 | node = gomu.playlist.getCurrentFile() 190 | } 191 | 192 | defaultTimedPopup(" Success ", "Tag update successfully") 193 | 194 | }). 195 | SetBackgroundColorActivated(gomu.colors.popup). 196 | SetLabelColorActivated(gomu.colors.accent). 197 | SetBorder(true). 198 | SetBackgroundColor(gomu.colors.popup). 199 | SetTitleColor(gomu.colors.foreground) 200 | 201 | lyricDropDown.SetOptions(options, nil). 202 | SetCurrentOption(0). 203 | SetFieldBackgroundColor(gomu.colors.popup). 204 | SetFieldTextColor(gomu.colors.accent). 205 | SetPrefixTextColor(gomu.colors.accent). 206 | SetSelectedFunc(func(text string, _ int) { 207 | lyricTextView.SetText(popupLyricMap[text]). 208 | SetTitle(" " + text + " lyric preview ") 209 | }). 210 | SetLabel("Embeded Lyrics: ") 211 | lyricDropDown.SetBackgroundColor(gomu.colors.popup) 212 | 213 | deleteLyricButton.SetSelectedFunc(func() { 214 | _, langExt := lyricDropDown.GetCurrentOption() 215 | lyric := &lyric.Lyric{ 216 | LangExt: langExt, 217 | } 218 | if len(options) > 0 { 219 | err := embedLyric(node.Path(), lyric, true) 220 | if err != nil { 221 | errorPopup(err) 222 | return 223 | } 224 | infoPopup(langExt + " lyric deleted successfully.") 225 | 226 | // Update map 227 | delete(popupLyricMap, langExt) 228 | 229 | // Update dropdown options 230 | var newOptions []string 231 | for _, v := range options { 232 | if v == langExt { 233 | continue 234 | } 235 | newOptions = append(newOptions, v) 236 | } 237 | options = newOptions 238 | lyricDropDown.SetOptions(newOptions, nil). 239 | SetCurrentOption(0). 240 | SetSelectedFunc(func(text string, _ int) { 241 | lyricTextView.SetText(popupLyricMap[text]). 242 | SetTitle(" " + text + " lyric preview ") 243 | }) 244 | 245 | // Update lyric preview 246 | if len(newOptions) > 0 { 247 | _, langExt = lyricDropDown.GetCurrentOption() 248 | lyricTextView.SetText(popupLyricMap[langExt]). 249 | SetTitle(" " + langExt + " lyric preview ") 250 | } else { 251 | langExt = "" 252 | lyricTextView.SetText("No lyric embeded."). 253 | SetTitle(" " + langExt + " lyric preview ") 254 | } 255 | } else { 256 | infoPopup("No lyric embeded.") 257 | } 258 | }). 259 | SetBackgroundColorActivated(gomu.colors.popup). 260 | SetLabelColorActivated(gomu.colors.accent). 261 | SetBorder(true). 262 | SetBackgroundColor(gomu.colors.popup). 263 | SetTitleColor(gomu.colors.accent) 264 | 265 | getLyricDropDownOptions := []string{"en", "zh-CN"} 266 | getLyricDropDown.SetOptions(getLyricDropDownOptions, nil). 267 | SetCurrentOption(0). 268 | SetFieldBackgroundColor(gomu.colors.popup). 269 | SetFieldTextColor(gomu.colors.accent). 270 | SetPrefixTextColor(gomu.colors.accent). 271 | SetLabel("Fetch Lyrics: "). 272 | SetBackgroundColor(gomu.colors.popup) 273 | 274 | langLyricFromConfig := gomu.anko.GetString("General.lang_lyric") 275 | if strings.Contains(langLyricFromConfig, "zh-CN") { 276 | getLyricDropDown.SetCurrentOption(1) 277 | } 278 | 279 | getLyricButton.SetSelectedFunc(func() { 280 | 281 | audioFile := gomu.playlist.getCurrentFile() 282 | _, lang := getLyricDropDown.GetCurrentOption() 283 | 284 | if !audioFile.IsAudioFile() { 285 | errorPopup(errors.New("not an audio file")) 286 | return 287 | } 288 | 289 | var wg sync.WaitGroup 290 | 291 | wg.Add(1) 292 | 293 | go func() { 294 | err := lyricPopup(lang, audioFile, &wg) 295 | if err != nil { 296 | errorPopup(err) 297 | return 298 | } 299 | }() 300 | 301 | go func() { 302 | // This is to ensure that the above go routine finish. 303 | wg.Wait() 304 | _, popupLyricMap, newOptions, err := audioFile.LoadTagMap() 305 | if err != nil { 306 | errorPopup(err) 307 | gomu.app.Draw() 308 | return 309 | } 310 | 311 | options = newOptions 312 | // Update dropdown options 313 | gomu.app.QueueUpdateDraw(func() { 314 | lyricDropDown.SetOptions(newOptions, nil). 315 | SetCurrentOption(0). 316 | SetSelectedFunc(func(text string, _ int) { 317 | lyricTextView.SetText(popupLyricMap[text]). 318 | SetTitle(" " + text + " lyric preview ") 319 | }) 320 | 321 | // Update lyric preview 322 | if len(newOptions) > 0 { 323 | _, langExt := lyricDropDown.GetCurrentOption() 324 | lyricTextView.SetText(popupLyricMap[langExt]). 325 | SetTitle(" " + langExt + " lyric preview ") 326 | } else { 327 | lyricTextView.SetText("No lyric embeded."). 328 | SetTitle(" lyric preview ") 329 | } 330 | }) 331 | }() 332 | }). 333 | SetBackgroundColorActivated(gomu.colors.popup). 334 | SetLabelColorActivated(gomu.colors.accent). 335 | SetBorder(true). 336 | SetTitleColor(gomu.colors.accent). 337 | SetBackgroundColor(gomu.colors.popup) 338 | 339 | var lyricText string 340 | _, langExt := lyricDropDown.GetCurrentOption() 341 | lyricText = popupLyricMap[langExt] 342 | if lyricText == "" { 343 | lyricText = "No lyric embeded." 344 | langExt = "" 345 | } 346 | 347 | lyricTextView. 348 | SetDynamicColors(true). 349 | SetRegions(true). 350 | SetScrollable(true). 351 | SetTitle(" " + langExt + " lyric preview "). 352 | SetBorder(true) 353 | 354 | lyricTextView.SetText(lyricText). 355 | SetScrollable(true). 356 | SetWordWrap(true). 357 | SetWrap(true). 358 | SetBorder(true) 359 | lyricTextView.SetChangedFunc(func() { 360 | gomu.app.QueueUpdate(func() { 361 | lyricTextView.ScrollToBeginning() 362 | }) 363 | }) 364 | 365 | leftGrid.SetRows(3, 1, 2, 2, 2, 3, 0, 3, 3, 1, 3, 3). 366 | SetColumns(30). 367 | AddItem(getTagButton, 0, 0, 1, 3, 1, 10, true). 368 | AddItem(artistInputField, 2, 0, 1, 3, 1, 10, true). 369 | AddItem(titleInputField, 3, 0, 1, 3, 1, 10, true). 370 | AddItem(albumInputField, 4, 0, 1, 3, 1, 10, true). 371 | AddItem(saveTagButton, 5, 0, 1, 3, 1, 10, true). 372 | AddItem(getLyricDropDown, 7, 0, 1, 3, 1, 20, true). 373 | AddItem(getLyricButton, 8, 0, 1, 3, 1, 10, true). 374 | AddItem(lyricDropDown, 10, 0, 1, 3, 1, 10, true). 375 | AddItem(deleteLyricButton, 11, 0, 1, 3, 1, 10, true) 376 | 377 | rightFlex.SetDirection(tview.FlexColumn). 378 | AddItem(lyricTextView, 0, 1, true) 379 | 380 | lyricFlex := &lyricFlex{ 381 | tview.NewFlex().SetDirection(tview.FlexColumn). 382 | AddItem(leftGrid, 0, 2, true). 383 | AddItem(rightFlex, 0, 3, true), 384 | nil, 385 | nil, 386 | leftBox, 387 | } 388 | 389 | leftGrid.Box = lyricFlex.box 390 | 391 | lyricFlex.inputs = []tview.Primitive{ 392 | getTagButton, 393 | artistInputField, 394 | titleInputField, 395 | albumInputField, 396 | saveTagButton, 397 | getLyricDropDown, 398 | getLyricButton, 399 | lyricDropDown, 400 | deleteLyricButton, 401 | lyricTextView, 402 | } 403 | 404 | if gomu.playingBar.albumPhoto != nil { 405 | gomu.playingBar.albumPhoto.Clear() 406 | } 407 | 408 | gomu.pages.AddPage(popupID, center(lyricFlex, 90, 30), true, true) 409 | gomu.popups.push(lyricFlex) 410 | 411 | lyricFlex.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey { 412 | switch e.Key() { 413 | case tcell.KeyEnter: 414 | 415 | case tcell.KeyEsc: 416 | gomu.pages.RemovePage(popupID) 417 | gomu.popups.pop() 418 | case tcell.KeyTab, tcell.KeyCtrlN, tcell.KeyCtrlJ: 419 | lyricFlex.cycleFocus(gomu.app, false) 420 | case tcell.KeyBacktab, tcell.KeyCtrlP, tcell.KeyCtrlK: 421 | lyricFlex.cycleFocus(gomu.app, true) 422 | case tcell.KeyDown: 423 | lyricFlex.cycleFocus(gomu.app, false) 424 | case tcell.KeyUp: 425 | lyricFlex.cycleFocus(gomu.app, true) 426 | } 427 | 428 | switch e.Rune() { 429 | case 'q': 430 | if artistInputField.HasFocus() || titleInputField.HasFocus() || albumInputField.HasFocus() { 431 | return e 432 | } 433 | gomu.pages.RemovePage(popupID) 434 | gomu.popups.pop() 435 | } 436 | return e 437 | }) 438 | 439 | return err 440 | } 441 | 442 | // This is a hack to cycle Focus in a flex 443 | func (f *lyricFlex) cycleFocus(app *tview.Application, reverse bool) { 444 | for i, el := range f.inputs { 445 | if !el.HasFocus() { 446 | continue 447 | } 448 | 449 | if reverse { 450 | i = i - 1 451 | if i < 0 { 452 | i = len(f.inputs) - 1 453 | } 454 | } else { 455 | i = i + 1 456 | i = i % len(f.inputs) 457 | } 458 | 459 | app.SetFocus(f.inputs[i]) 460 | f.FocusedItem = f.inputs[i] 461 | // below code is setting the border highlight of left and right flex 462 | if f.inputs[9].HasFocus() { 463 | f.inputs[9].(*tview.TextView).SetBorderColor(gomu.colors.accent). 464 | SetTitleColor(gomu.colors.accent) 465 | f.box.SetBorderColor(gomu.colors.background). 466 | SetTitleColor(gomu.colors.background) 467 | } else { 468 | f.inputs[9].(*tview.TextView).SetBorderColor(gomu.colors.background). 469 | SetTitleColor(gomu.colors.background) 470 | f.box.SetBorderColor(gomu.colors.accent). 471 | SetTitleColor(gomu.colors.accent) 472 | } 473 | return 474 | } 475 | } 476 | 477 | // Focus is an override of Focus function in tview.flex. 478 | // This is to ensure that the focus of flex remain unchanged 479 | // when returning from popups or search lists 480 | func (f *lyricFlex) Focus(delegate func(p tview.Primitive)) { 481 | if f.FocusedItem != nil { 482 | gomu.app.SetFocus(f.FocusedItem) 483 | } else { 484 | f.Flex.Focus(delegate) 485 | } 486 | } 487 | -------------------------------------------------------------------------------- /queue.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 Raziman 2 | 3 | package main 4 | 5 | import ( 6 | "bufio" 7 | "crypto/sha1" 8 | "encoding/hex" 9 | "fmt" 10 | "io/ioutil" 11 | "math/rand" 12 | "os" 13 | "path" 14 | "path/filepath" 15 | "strings" 16 | "time" 17 | 18 | "github.com/gdamore/tcell/v2" 19 | "github.com/rivo/tview" 20 | "github.com/ztrue/tracerr" 21 | 22 | "github.com/issadarkthing/gomu/player" 23 | ) 24 | 25 | // Queue shows queued songs for playing 26 | type Queue struct { 27 | *tview.List 28 | savedQueuePath string 29 | items []*player.AudioFile 30 | isLoop bool 31 | } 32 | 33 | // Highlight the next item in the queue 34 | func (q *Queue) next() { 35 | currIndex := q.GetCurrentItem() 36 | idx := currIndex + 1 37 | if currIndex == q.GetItemCount()-1 { 38 | idx = 0 39 | } 40 | q.SetCurrentItem(idx) 41 | } 42 | 43 | // Highlight the previous item in the queue 44 | func (q *Queue) prev() { 45 | currIndex := q.GetCurrentItem() 46 | q.SetCurrentItem(currIndex - 1) 47 | } 48 | 49 | // Usually used with GetCurrentItem which can return -1 if 50 | // no item highlighted 51 | func (q *Queue) deleteItem(index int) (*player.AudioFile, error) { 52 | 53 | if index > len(q.items)-1 { 54 | return nil, tracerr.New("Index out of range") 55 | } 56 | 57 | // deleted audio file 58 | var dAudio *player.AudioFile 59 | 60 | if index != -1 { 61 | q.RemoveItem(index) 62 | 63 | var nItems []*player.AudioFile 64 | 65 | for i, v := range q.items { 66 | 67 | if i == index { 68 | dAudio = v 69 | continue 70 | } 71 | 72 | nItems = append(nItems, v) 73 | } 74 | 75 | q.items = nItems 76 | // here we move to next item if not at the end 77 | if index < len(q.items) { 78 | q.next() 79 | } 80 | q.updateTitle() 81 | 82 | } 83 | 84 | return dAudio, nil 85 | } 86 | 87 | // Update queue title which shows number of items and total length 88 | func (q *Queue) updateTitle() string { 89 | 90 | var totalLength time.Duration 91 | 92 | for _, v := range q.items { 93 | totalLength += v.Len() 94 | } 95 | 96 | fmtTime := fmtDurationH(totalLength) 97 | 98 | var count string 99 | 100 | if len(q.items) > 1 { 101 | count = "songs" 102 | } else { 103 | count = "song" 104 | } 105 | 106 | var loop string 107 | 108 | isEmoji := gomu.anko.GetBool("General.use_emoji") 109 | 110 | if q.isLoop { 111 | if isEmoji { 112 | loop = gomu.anko.GetString("Emoji.loop") 113 | } else { 114 | loop = "Loop" 115 | } 116 | } else { 117 | if isEmoji { 118 | loop = gomu.anko.GetString("Emoji.noloop") 119 | } else { 120 | loop = "No loop" 121 | } 122 | } 123 | 124 | title := fmt.Sprintf("─ Queue ───┤ %d %s | %s | %s ├", 125 | len(q.items), count, fmtTime, loop) 126 | 127 | q.SetTitle(title) 128 | 129 | return title 130 | } 131 | 132 | // Add item to the front of the queue 133 | func (q *Queue) pushFront(audioFile *player.AudioFile) { 134 | 135 | q.items = append([]*player.AudioFile{audioFile}, q.items...) 136 | 137 | songLength := audioFile.Len() 138 | 139 | queueItemView := fmt.Sprintf( 140 | "[ %s ] %s", fmtDuration(songLength), getName(audioFile.Name()), 141 | ) 142 | 143 | q.InsertItem(0, queueItemView, audioFile.Path(), 0, nil) 144 | q.updateTitle() 145 | } 146 | 147 | // gets the first item and remove it from the queue 148 | // app.Draw() must be called after calling this function 149 | func (q *Queue) dequeue() (*player.AudioFile, error) { 150 | 151 | if q.GetItemCount() == 0 { 152 | return nil, tracerr.New("Empty list") 153 | } 154 | 155 | first := q.items[0] 156 | q.deleteItem(0) 157 | q.updateTitle() 158 | 159 | return first, nil 160 | } 161 | 162 | // Add item to the list and returns the length of the queue 163 | func (q *Queue) enqueue(audioFile *player.AudioFile) (int, error) { 164 | 165 | if !audioFile.IsAudioFile() { 166 | return q.GetItemCount(), nil 167 | } 168 | 169 | q.items = append(q.items, audioFile) 170 | songLength, err := getTagLength(audioFile.Path()) 171 | 172 | if err != nil { 173 | return 0, tracerr.Wrap(err) 174 | } 175 | 176 | queueItemView := fmt.Sprintf( 177 | "[ %s ] %s", fmtDuration(songLength), getName(audioFile.Name()), 178 | ) 179 | q.AddItem(queueItemView, audioFile.Path(), 0, nil) 180 | q.updateTitle() 181 | 182 | return q.GetItemCount(), nil 183 | } 184 | 185 | // getItems is used to get the secondary text 186 | // which is used to store the path of the audio file 187 | // this is for the sake of convenience 188 | func (q *Queue) getItems() []string { 189 | 190 | items := []string{} 191 | 192 | for i := 0; i < q.GetItemCount(); i++ { 193 | 194 | _, second := q.GetItemText(i) 195 | 196 | items = append(items, second) 197 | } 198 | 199 | return items 200 | } 201 | 202 | // Save the current queue 203 | func (q *Queue) saveQueue() error { 204 | 205 | songPaths := q.getItems() 206 | var content strings.Builder 207 | 208 | if gomu.player.HasInit() && gomu.player.GetCurrentSong() != nil { 209 | currentSongPath := gomu.player.GetCurrentSong().Path() 210 | currentSongInQueue := false 211 | for _, songPath := range songPaths { 212 | if getName(songPath) == getName(currentSongPath) { 213 | currentSongInQueue = true 214 | } 215 | } 216 | if !currentSongInQueue && len(q.items) != 0 { 217 | hashed := sha1Hex(getName(currentSongPath)) 218 | content.WriteString(hashed + "\n") 219 | } 220 | } 221 | 222 | for _, songPath := range songPaths { 223 | // hashed song name is easier to search through 224 | hashed := sha1Hex(getName(songPath)) 225 | content.WriteString(hashed + "\n") 226 | } 227 | 228 | savedPath := expandTilde(q.savedQueuePath) 229 | err := ioutil.WriteFile(savedPath, []byte(content.String()), 0644) 230 | 231 | if err != nil { 232 | return tracerr.Wrap(err) 233 | } 234 | 235 | return nil 236 | 237 | } 238 | 239 | // Clears current queue 240 | func (q *Queue) clearQueue() { 241 | 242 | q.items = []*player.AudioFile{} 243 | q.Clear() 244 | q.updateTitle() 245 | 246 | } 247 | 248 | // Loads previously saved list 249 | func (q *Queue) loadQueue() error { 250 | 251 | songs, err := q.getSavedQueue() 252 | 253 | if err != nil { 254 | return tracerr.Wrap(err) 255 | } 256 | 257 | for _, v := range songs { 258 | 259 | audioFile, err := gomu.playlist.findAudioFile(v) 260 | 261 | if err != nil { 262 | logError(err) 263 | continue 264 | } 265 | 266 | q.enqueue(audioFile) 267 | } 268 | 269 | return nil 270 | } 271 | 272 | // Get saved queue, if not exist, create it 273 | func (q *Queue) getSavedQueue() ([]string, error) { 274 | 275 | queuePath := expandTilde(q.savedQueuePath) 276 | 277 | if _, err := os.Stat(queuePath); os.IsNotExist(err) { 278 | 279 | dir, _ := path.Split(queuePath) 280 | err := os.MkdirAll(dir, 0744) 281 | if err != nil { 282 | return nil, tracerr.Wrap(err) 283 | } 284 | 285 | _, err = os.Create(queuePath) 286 | if err != nil { 287 | return nil, tracerr.Wrap(err) 288 | } 289 | 290 | return []string{}, nil 291 | 292 | } 293 | 294 | f, err := os.Open(queuePath) 295 | if err != nil { 296 | return nil, tracerr.Wrap(err) 297 | } 298 | 299 | records := []string{} 300 | scanner := bufio.NewScanner(f) 301 | 302 | for scanner.Scan() { 303 | records = append(records, scanner.Text()) 304 | } 305 | 306 | if err := scanner.Err(); err != nil { 307 | return nil, tracerr.Wrap(err) 308 | } 309 | 310 | return records, nil 311 | } 312 | 313 | func (q *Queue) help() []string { 314 | 315 | return []string{ 316 | "j down", 317 | "k up", 318 | "l play selected song", 319 | "d remove from queue", 320 | "D clear queue", 321 | "z toggle loop", 322 | "s shuffle", 323 | "/ find in queue", 324 | "t lyric delay increase 0.5 second", 325 | "r lyric delay decrease 0.5 second", 326 | } 327 | 328 | } 329 | 330 | // Shuffles the queue 331 | func (q *Queue) shuffle() { 332 | 333 | rand.Seed(time.Now().UnixNano()) 334 | rand.Shuffle(len(q.items), func(i, j int) { 335 | q.items[i], q.items[j] = q.items[j], q.items[i] 336 | }) 337 | 338 | q.Clear() 339 | 340 | for _, v := range q.items { 341 | audioLen, err := getTagLength(v.Path()) 342 | if err != nil { 343 | logError(err) 344 | } 345 | 346 | queueText := fmt.Sprintf("[ %s ] %s", fmtDuration(audioLen), v.Name()) 347 | q.AddItem(queueText, v.Path(), 0, nil) 348 | } 349 | 350 | // q.updateTitle() 351 | 352 | } 353 | 354 | // Initiliaze new queue with default values 355 | func newQueue() *Queue { 356 | 357 | list := tview.NewList() 358 | cacheDir, err := os.UserCacheDir() 359 | if err != nil { 360 | logError(err) 361 | } 362 | cacheQueuePath := filepath.Join(cacheDir, "gomu", "queue.cache") 363 | queue := &Queue{ 364 | List: list, 365 | savedQueuePath: cacheQueuePath, 366 | } 367 | 368 | cmds := map[rune]string{ 369 | 'j': "move_down", 370 | 'k': "move_up", 371 | 'd': "delete_item", 372 | 'D': "clear_queue", 373 | 'l': "play_selected", 374 | 'z': "toggle_loop", 375 | 's': "shuffle_queue", 376 | '/': "queue_search", 377 | 't': "lyric_delay_increase", 378 | 'r': "lyric_delay_decrease", 379 | } 380 | 381 | for key, cmdName := range cmds { 382 | src := fmt.Sprintf(`Keybinds.def_q("%c", %s)`, key, cmdName) 383 | gomu.anko.Execute(src) 384 | } 385 | 386 | queue.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey { 387 | 388 | if gomu.anko.KeybindExists("queue", e) { 389 | 390 | err := gomu.anko.ExecKeybind("queue", e) 391 | if err != nil { 392 | errorPopup(err) 393 | } 394 | 395 | } 396 | 397 | return nil 398 | }) 399 | 400 | queue.updateTitle() 401 | 402 | queue. 403 | ShowSecondaryText(false). 404 | SetSelectedBackgroundColor(gomu.colors.queueHi). 405 | SetSelectedTextColor(gomu.colors.foreground). 406 | SetHighlightFullLine(true) 407 | 408 | queue. 409 | SetBorder(true). 410 | SetTitleAlign(tview.AlignLeft). 411 | SetBorderPadding(0, 0, 1, 1). 412 | SetBorderColor(gomu.colors.foreground). 413 | SetBackgroundColor(gomu.colors.background) 414 | 415 | return queue 416 | } 417 | 418 | // Convert string to sha1. 419 | func sha1Hex(input string) string { 420 | h := sha1.New() 421 | h.Write([]byte(input)) 422 | return hex.EncodeToString(h.Sum(nil)) 423 | } 424 | 425 | // Modify the title of songs in queue 426 | func (q *Queue) renameItem(oldAudio *player.AudioFile, newAudio *player.AudioFile) error { 427 | for i, v := range q.items { 428 | if v.Name() != oldAudio.Name() { 429 | continue 430 | } 431 | err := q.insertItem(i, newAudio) 432 | if err != nil { 433 | return tracerr.Wrap(err) 434 | } 435 | _, err = q.deleteItem(i + 1) 436 | if err != nil { 437 | return tracerr.Wrap(err) 438 | } 439 | 440 | } 441 | return nil 442 | } 443 | 444 | // playQueue play the first item in the queue 445 | func (q *Queue) playQueue() error { 446 | 447 | audioFile, err := q.dequeue() 448 | if err != nil { 449 | return tracerr.Wrap(err) 450 | } 451 | err = gomu.player.Run(audioFile) 452 | if err != nil { 453 | return tracerr.Wrap(err) 454 | } 455 | 456 | return nil 457 | } 458 | 459 | func (q *Queue) insertItem(index int, audioFile *player.AudioFile) error { 460 | 461 | if index > len(q.items)-1 { 462 | return tracerr.New("Index out of range") 463 | } 464 | 465 | if index != -1 { 466 | songLength, err := getTagLength(audioFile.Path()) 467 | if err != nil { 468 | return tracerr.Wrap(err) 469 | } 470 | queueItemView := fmt.Sprintf( 471 | "[ %s ] %s", fmtDuration(songLength), getName(audioFile.Name()), 472 | ) 473 | 474 | q.InsertItem(index, queueItemView, audioFile.Path(), 0, nil) 475 | 476 | var nItems []*player.AudioFile 477 | 478 | for i, v := range q.items { 479 | 480 | if i == index { 481 | nItems = append(nItems, audioFile) 482 | } 483 | 484 | nItems = append(nItems, v) 485 | } 486 | 487 | q.items = nItems 488 | q.updateTitle() 489 | 490 | } 491 | 492 | return nil 493 | } 494 | 495 | // update the path information in queue 496 | func (q *Queue) updateQueuePath() { 497 | 498 | var songs []string 499 | if len(q.items) < 1 { 500 | return 501 | } 502 | for _, v := range q.items { 503 | song := sha1Hex(getName(v.Name())) 504 | songs = append(songs, song) 505 | } 506 | 507 | q.clearQueue() 508 | for _, v := range songs { 509 | 510 | audioFile, err := gomu.playlist.findAudioFile(v) 511 | 512 | if err != nil { 513 | continue 514 | } 515 | q.enqueue(audioFile) 516 | } 517 | 518 | q.updateTitle() 519 | } 520 | 521 | // update current playing song name to reflect the changes during rename and paste 522 | func (q *Queue) updateCurrentSongName(oldAudio *player.AudioFile, newAudio *player.AudioFile) error { 523 | 524 | if !gomu.player.IsRunning() && !gomu.player.IsPaused() { 525 | return nil 526 | } 527 | 528 | currentSong := gomu.player.GetCurrentSong() 529 | position := gomu.playingBar.getProgress() 530 | paused := gomu.player.IsPaused() 531 | 532 | if oldAudio.Name() != currentSong.Name() { 533 | return nil 534 | } 535 | 536 | // we insert it in the first of queue, then play it 537 | gomu.queue.pushFront(newAudio) 538 | tmpLoop := q.isLoop 539 | q.isLoop = false 540 | gomu.player.Skip() 541 | gomu.player.Seek(position) 542 | if paused { 543 | gomu.player.TogglePause() 544 | } 545 | q.isLoop = tmpLoop 546 | q.updateTitle() 547 | 548 | return nil 549 | } 550 | 551 | // update current playing song path to reflect the changes during rename and paste 552 | func (q *Queue) updateCurrentSongPath(oldAudio *player.AudioFile, newAudio *player.AudioFile) error { 553 | 554 | if !gomu.player.IsRunning() && !gomu.player.IsPaused() { 555 | return nil 556 | } 557 | 558 | currentSong := gomu.player.GetCurrentSong() 559 | position := gomu.playingBar.getProgress() 560 | paused := gomu.player.IsPaused() 561 | 562 | // Here we check the situation when currentsong is under oldAudio folder 563 | if !strings.Contains(currentSong.Path(), oldAudio.Path()) { 564 | return nil 565 | } 566 | 567 | // Here is the handling of folder rename and paste 568 | currentSongAudioFile, err := gomu.playlist.findAudioFile(sha1Hex(getName(currentSong.Name()))) 569 | if err != nil { 570 | return tracerr.Wrap(err) 571 | } 572 | gomu.queue.pushFront(currentSongAudioFile) 573 | tmpLoop := q.isLoop 574 | q.isLoop = false 575 | gomu.player.Skip() 576 | gomu.player.Seek(position) 577 | if paused { 578 | gomu.player.TogglePause() 579 | } 580 | q.isLoop = tmpLoop 581 | 582 | q.updateTitle() 583 | return nil 584 | 585 | } 586 | 587 | // update current playing song simply delete it 588 | func (q *Queue) updateCurrentSongDelete(oldAudio *player.AudioFile) { 589 | if !gomu.player.IsRunning() && !gomu.player.IsPaused() { 590 | return 591 | } 592 | 593 | currentSong := gomu.player.GetCurrentSong() 594 | paused := gomu.player.IsPaused() 595 | 596 | var delete bool 597 | if oldAudio.IsAudioFile() { 598 | if oldAudio.Name() == currentSong.Name() { 599 | delete = true 600 | } 601 | } else { 602 | if strings.Contains(currentSong.Path(), oldAudio.Path()) { 603 | delete = true 604 | } 605 | } 606 | 607 | if !delete { 608 | return 609 | } 610 | 611 | tmpLoop := q.isLoop 612 | q.isLoop = false 613 | gomu.player.Skip() 614 | if paused { 615 | gomu.player.TogglePause() 616 | } 617 | q.isLoop = tmpLoop 618 | q.updateTitle() 619 | 620 | } 621 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | sourc code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298 h1:1qlsVAQJXZHsaM8b6OLVo6muQUQd4CwkH/D3fnnbHXA= 2 | github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298/go.mod h1:D+QujdIlUNfa0igpNMk6UIvlb6C252URs4yupRUV4lQ= 3 | github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 h1:lTG4HQym5oPKjL7nGs+csTgiDna685ZXjxijkne828g= 4 | github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966/go.mod h1:Mid70uvE93zn9wgF92A/r5ixgnvX8Lh68fxp9KQBaI0= 5 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 6 | github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc h1:7D+Bh06CRPCJO3gr2F7h1sriovOZ8BMhca2Rg85c2nk= 7 | github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 8 | github.com/BurntSushi/xgbutil v0.0.0-20160919175755-f7c97cef3b4e/go.mod h1:uw9h2sd4WWHOPdJ13MQpwK5qYWKYDumDqxWWIknEQ+k= 9 | github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 h1:O/r2Sj+8QcMF7V5IcmiE2sMFV2q3J47BEirxbXJAdzA= 10 | github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046/go.mod h1:uw9h2sd4WWHOPdJ13MQpwK5qYWKYDumDqxWWIknEQ+k= 11 | github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= 12 | github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= 13 | github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= 14 | github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= 15 | github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= 16 | github.com/antchfx/htmlquery v1.2.6 h1:Ee7+vpVb7qbgQ4QffP6TVZrw+XMjCbth0pVKv7jqpB8= 17 | github.com/antchfx/htmlquery v1.2.6/go.mod h1:kYx/LosPyRriF4TVOAYmKrBgi1mfAhrwJExTcwKg530= 18 | github.com/antchfx/xmlquery v1.3.14 h1:JVLQF1UIstQytN6MVES7D8gCiqIazZA+A2NWryaHwYk= 19 | github.com/antchfx/xmlquery v1.3.14/go.mod h1:yPRBXRdd2Xqz9c2Z61qvMKbK+u3NXXydp6nqEfw4VdI= 20 | github.com/antchfx/xpath v1.2.2 h1:fsKX4sHfxhsGpDMYjsvCmGC0EGdiT7XA0af/6PP6Oa0= 21 | github.com/antchfx/xpath v1.2.2/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= 22 | github.com/bogem/id3v2 v1.2.0 h1:hKDF+F1gOgQ5r1QmBCEZUk4MveJbKxCeIDSBU7CQ4oI= 23 | github.com/bogem/id3v2 v1.2.0/go.mod h1:t78PK5AQ56Q47kizpYiV6gtjj3jfxlz87oFpty8DYs8= 24 | github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= 25 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 26 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 27 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 28 | github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= 29 | github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= 30 | github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c= 31 | github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4= 32 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= 33 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 34 | github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= 35 | github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU= 36 | github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0= 37 | github.com/gdamore/tcell/v2 v2.5.3/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo= 38 | github.com/gdamore/tcell/v2 v2.5.4 h1:TGU4tSjD3sCL788vFNeJnTdzpNKIw1H5dgLnJRQVv/k= 39 | github.com/gdamore/tcell/v2 v2.5.4/go.mod h1:dZgRy5v4iMobMEcWNYBtREnDZAT9DYmfqIkrgEMxLyw= 40 | github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= 41 | github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= 42 | github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE= 43 | github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 44 | github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 45 | github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI= 46 | github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA= 47 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 48 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 49 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 50 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 51 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 52 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 53 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 54 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 55 | github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= 56 | github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= 57 | github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= 58 | github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= 59 | github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos= 60 | github.com/hajimehoshi/oto v1.0.1 h1:8AMnq0Yr2YmzaiqTg/k1Yzd6IygUGk2we9nmjgbgPn4= 61 | github.com/hajimehoshi/oto v1.0.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos= 62 | github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= 63 | github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= 64 | github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= 65 | github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= 66 | github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= 67 | github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= 68 | github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= 69 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 70 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 71 | github.com/logrusorgru/aurora v0.0.0-20181002194514-a7b3b318ed4e/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= 72 | github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= 73 | github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= 74 | github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= 75 | github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 76 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 77 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 78 | github.com/mattn/anko v0.1.9 h1:OpdRDvjOY/FQXoGXkN0n7b0br4a4fHB5hhsaq0Cd3ds= 79 | github.com/mattn/anko v0.1.9/go.mod h1:gjrudvzf1t7FWTZo1Nbywnr75g3uDnGjXdp2nkguBjQ= 80 | github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 81 | github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 82 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 83 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 84 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 85 | github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU= 86 | github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA= 87 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 88 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 89 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 90 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 91 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 92 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 93 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 94 | github.com/rivo/tview v0.0.0-20230104153304-892d1a2eb0da h1:3Mh+tcC2KqetuHpWMurDeF+yOgyt4w4qtLIpwSQ3uqo= 95 | github.com/rivo/tview v0.0.0-20230104153304-892d1a2eb0da/go.mod h1:lBUy/T5kyMudFzWUH/C2moN+NlU5qF505vzOyINXuUQ= 96 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 97 | github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 98 | github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= 99 | github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 100 | github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= 101 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 102 | github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= 103 | github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= 104 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 105 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 106 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 107 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 108 | github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg= 109 | github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= 110 | github.com/tj/go-spin v1.1.0 h1:lhdWZsvImxvZ3q1C5OIB7d72DuOwP4O2NdBg9PyzNds= 111 | github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4= 112 | github.com/tramhao/id3v2 v1.2.1 h1:h8tXj1ReHoGwIIZEDp+fiDhGhf2wpCyrxEKLKVmhQw8= 113 | github.com/tramhao/id3v2 v1.2.1/go.mod h1:4jmC9bwoDhtGTsDkEBwSUlUgJq/D+8w4626jvM1Oo1k= 114 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 115 | github.com/ztrue/tracerr v0.3.0 h1:lDi6EgEYhPYPnKcjsYzmWw4EkFEoA/gfe+I9Y5f+h6Y= 116 | github.com/ztrue/tracerr v0.3.0/go.mod h1:qEalzze4VN9O8tnhBXScfCrmoJo10o8TN5ciKjm6Mww= 117 | gitlab.com/diamondburned/ueberzug-go v0.0.0-20190521043425-7c15a5f63b06 h1:lGu8YGHgq9ABb00JDQewrqhKIvku+/1uFsnq/QUeiU8= 118 | gitlab.com/diamondburned/ueberzug-go v0.0.0-20190521043425-7c15a5f63b06/go.mod h1:UKSsoWKXcGQXxWYkXFB4z/UqJtIF3pZT6fHm6beLPKM= 119 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 120 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 121 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 122 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 123 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU= 124 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= 125 | golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 126 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 127 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 128 | golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 129 | golang.org/x/image v0.10.0 h1:gXjUUtwtx5yOE0VKWq1CH4IJAClq4UGgUA3i+rpON9M= 130 | golang.org/x/image v0.10.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0= 131 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 132 | golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= 133 | golang.org/x/mobile v0.0.0-20221110043201-43a038452099 h1:aIu0lKmfdgtn2uTj7JI2oN4TUrQvgB+wzTPO23bCKt8= 134 | golang.org/x/mobile v0.0.0-20221110043201-43a038452099/go.mod h1:aAjjkJNdrh3PMckS4B10TGS2nag27cbKR1y2BpUxsiY= 135 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 136 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 137 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 138 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 139 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 140 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 141 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 142 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 143 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 144 | golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 145 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 146 | golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= 147 | golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q= 148 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 149 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 150 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 151 | golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 152 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 153 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 154 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 155 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 156 | golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 157 | golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 158 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 159 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 160 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 161 | golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 162 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 163 | golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 164 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 165 | golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 166 | golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= 167 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 168 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 169 | golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 170 | golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 171 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 172 | golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= 173 | golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= 174 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 175 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 176 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 177 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 178 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 179 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 180 | golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 181 | golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 182 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 183 | golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= 184 | golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 185 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 186 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 187 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 188 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 189 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 190 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 191 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 192 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 193 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 194 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 195 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 196 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 197 | google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= 198 | google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 199 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 200 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 201 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 202 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 203 | --------------------------------------------------------------------------------