diff --git a/README.md b/README.md index 804e265..de1b36b 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,8 @@ Each panel has it's own additional keybinding. To view the available keybinding ### Scripting -Gomu uses [anko](github.com/mattn/anko) as its scripting language. You can read -more about scripting at our [wiki](github.com/issadarkthing/gomu/wiki) +Gomu uses [anko](https://github.com/mattn/anko) as its scripting language. You can read +more about scripting at our [wiki](https://github.com/issadarkthing/gomu/wiki) ``` go diff --git a/anko/anko.go b/anko/anko.go index 74c9d32..ad2338e 100644 --- a/anko/anko.go +++ b/anko/anko.go @@ -17,7 +17,7 @@ type Anko struct { env *env.Env } -func NewAnko() Anko { +func NewAnko() *Anko { env := core.Import(env.NewEnv()) importToX(env) @@ -45,7 +45,7 @@ func NewAnko() Anko { panic(err) } - return Anko{env} + return &Anko{env} } // Define defines new symbol and value to the Anko env. diff --git a/command.go b/command.go index da6154f..f693856 100644 --- a/command.go +++ b/command.go @@ -1,10 +1,6 @@ package main import ( - "fmt" - "time" - - "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/ztrue/tracerr" ) @@ -66,98 +62,7 @@ func (c Command) defineCommands() { }) c.define("youtube_search", func() { - - popupId := "youtube-search-input-popup" - - input := newInputPopup(popupId, " Youtube Search ", "search: ", "") - - // quick hack to change the autocomplete text color - tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack - input.SetAutocompleteFunc(func(currentText string) (entries []string) { - - if currentText == "" { - return []string{} - } - - suggestions, err := getSuggestions(currentText) - if err != nil { - logError(err) - } - - return suggestions - }) - - input.SetDoneFunc(func(key tcell.Key) { - - switch key { - case tcell.KeyEnter: - search := input.GetText() - defaultTimedPopup(" Youtube Search ", "Searching for "+search) - gomu.pages.RemovePage(popupId) - gomu.popups.pop() - - go func() { - - results, err := getSearchResult(search) - if err != nil { - logError(err) - defaultTimedPopup(" Error ", err.Error()) - return - } - - titles := []string{} - urls := make(map[string]string) - - for _, result := range results { - duration, err := time.ParseDuration(fmt.Sprintf("%ds", result.LengthSeconds)) - if err != nil { - logError(err) - return - } - - durationText := fmt.Sprintf("[ %s ] ", fmtDuration(duration)) - title := durationText + result.Title - - urls[title] = `https://www.youtube.com/watch?v=` + result.VideoId - - titles = append(titles, title) - } - - searchPopup("Youtube Videos", titles, func(title string) { - - audioFile := gomu.playlist.getCurrentFile() - - var dir *tview.TreeNode - - if audioFile.isAudioFile { - dir = audioFile.parent - } else { - dir = audioFile.node - } - - go func() { - url := urls[title] - if err := ytdl(url, dir); err != nil { - logError(err) - } - gomu.playlist.refresh() - }() - gomu.app.SetFocus(gomu.prevPanel.(tview.Primitive)) - }) - - gomu.app.Draw() - }() - - case tcell.KeyEscape: - gomu.pages.RemovePage(popupId) - gomu.popups.pop() - gomu.app.SetFocus(gomu.prevPanel.(tview.Primitive)) - - default: - input.Autocomplete() - } - - }) + ytSearchPopup() }) c.define("download_audio", func() { diff --git a/command_test.go b/command_test.go new file mode 100644 index 0000000..c7f968a --- /dev/null +++ b/command_test.go @@ -0,0 +1,25 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + + +func TestGetFn(t *testing.T) { + + c := newCommand() + + c.define("sample", func() {}) + + f, err := c.getFn("sample") + if err != nil { + t.Error(err) + } + + assert.NotNil(t, f) + + f, err = c.getFn("x") + assert.Error(t, err) +} diff --git a/gomu.go b/gomu.go index 6111987..e4720dd 100644 --- a/gomu.go +++ b/gomu.go @@ -1,9 +1,10 @@ package main import ( + "github.com/issadarkthing/gomu/anko" + "github.com/issadarkthing/gomu/hook" "github.com/rivo/tview" "github.com/ztrue/tracerr" - "github.com/issadarkthing/gomu/anko" ) var VERSION = "N/A" @@ -24,7 +25,8 @@ type Gomu struct { prevPanel Panel panels []Panel args Args - anko anko.Anko + anko *anko.Anko + hook *hook.EventHook } // Creates new instance of gomu with default values @@ -33,6 +35,7 @@ func newGomu() *Gomu { gomu := &Gomu{ command: newCommand(), anko: anko.NewAnko(), + hook: hook.NewEventHook(), } return gomu diff --git a/hook/hook.go b/hook/hook.go new file mode 100644 index 0000000..5e70f74 --- /dev/null +++ b/hook/hook.go @@ -0,0 +1,35 @@ +package hook + +type EventHook struct { + events map[string][]func() +} + +// NewNewEventHook returns new instance of EventHook +func NewEventHook() *EventHook { + return &EventHook{make(map[string][]func())} +} + +// AddHook accepts a function which will be executed when the event is emitted. +func (e *EventHook) AddHook(eventName string, handler func()) { + + hooks, ok := e.events[eventName] + if !ok { + e.events[eventName] = []func(){handler} + return + } + + e.events[eventName] = append(hooks, handler) +} + +// RunHooks executes all hooks installed for an event. +func (e *EventHook) RunHooks(eventName string) { + + hooks, ok := e.events[eventName] + if !ok { + return + } + + for _, hook := range hooks { + hook() + } +} diff --git a/hook/hook_test.go b/hook/hook_test.go new file mode 100644 index 0000000..87dc63a --- /dev/null +++ b/hook/hook_test.go @@ -0,0 +1,57 @@ +package hook + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAddHook(t *testing.T) { + + h := NewEventHook() + + h.AddHook("a", nil) + h.AddHook("a", nil) + h.AddHook("a", nil) + h.AddHook("a", nil) + + assert.Equal(t, 1, len(h.events), "should only contain 1 event") + + hooks := h.events["a"] + assert.Equal(t, 4, len(hooks), "should contain 4 hooks") + + h.AddHook("b", nil) + h.AddHook("c", nil) + + assert.Equal(t, 3, len(h.events), "should contain 3 events") +} + +func TestRunHooks(t *testing.T) { + + h := NewEventHook() + x := 0 + + for i := 0; i < 100; i ++ { + h.AddHook("sample", func() { + x++ + }) + } + + h.AddHook("noop", func() { + x++ + }) + + h.AddHook("noop", func() { + x++ + }) + + assert.Equal(t, x, 0, "should not execute any hook") + + h.RunHooks("x") + + assert.Equal(t, x, 0, "should not execute any hook") + + h.RunHooks("sample") + + assert.Equal(t, x, 100, "should only execute event 'sample'") +} diff --git a/player.go b/player.go index a213d7f..eebfc09 100644 --- a/player.go +++ b/player.go @@ -121,7 +121,10 @@ func (p *Player) run(currSong *AudioFile) error { // sets the volume of previous player volume.Volume += p.volume p._volume = volume + + // starts playing the audio speaker.Play(p._volume) + gomu.hook.RunHooks("new_song") p.isRunning = true @@ -157,6 +160,7 @@ next: // when there are no songs to be played, set currentSong as nil p.currentSong = nil gomu.playingBar.setDefault() + gomu.app.Draw() break next } @@ -190,6 +194,7 @@ next: } func (p *Player) pause() { + gomu.hook.RunHooks("pause") speaker.Lock() p.ctrl.Paused = true p.isRunning = false @@ -197,6 +202,7 @@ func (p *Player) pause() { } func (p *Player) play() { + gomu.hook.RunHooks("play") speaker.Lock() p.ctrl.Paused = false p.isRunning = true @@ -235,7 +241,9 @@ func (p *Player) togglePause() { // skips current song func (p *Player) skip() { - if gomu.queue.GetItemCount() < 1 { + gomu.hook.RunHooks("skip") + + if p.currentSong == nil { return } diff --git a/playingbar.go b/playingbar.go index 61a2697..d9f297a 100644 --- a/playingbar.go +++ b/playingbar.go @@ -38,14 +38,6 @@ func newPlayingBar() *PlayingBar { progress: make(chan int), } - textView.SetChangedFunc(func() { - gomu.app.Draw() - - if !gomu.player.isRunning { - p.setDefault() - } - }) - return p } @@ -84,6 +76,7 @@ func (p *PlayingBar) run() error { progressBar, fmtDuration(end), )) + gomu.app.Draw() } diff --git a/popup.go b/popup.go index ab987f2..67b234d 100644 --- a/popup.go +++ b/popup.go @@ -8,6 +8,7 @@ import ( "path/filepath" "regexp" "strings" + "sync" "time" "unicode/utf8" @@ -53,6 +54,8 @@ func (s *Stack) pop() tview.Primitive { } last := s.popups[len(s.popups)-1] + s.popups[len(s.popups)-1] = nil // avoid memory leak + res := s.popups[:len(s.popups)-1] s.popups = res @@ -611,16 +614,20 @@ func replPopup() { switch event.Key() { case tcell.KeyUp: - input.SetText(history[upCount]) - if upCount < len(history)-1 { + if upCount < len(history) { + input.SetText(history[upCount]) upCount++ } case tcell.KeyDown: if upCount > 0 { - upCount-- + if upCount == len(history) { + upCount -= 2 + } else { + upCount -= 1 + } input.SetText(history[upCount]) } else if upCount == 0 { input.SetText("") @@ -642,15 +649,20 @@ func replPopup() { input.SetText("") + defer func() { + if err := recover(); err != nil { + fmt.Fprintf(textview, "%s%s\n%v\n\n", prompt, text, err) + } + }() + res, err := gomu.anko.Execute(text) if err != nil { fmt.Fprintf(textview, "%s%s\n%v\n\n", prompt, text, err) return nil - } - - if res != nil { + } else { fmt.Fprintf(textview, "%s%s\n%v\n\n", prompt, text, res) } + } return event @@ -671,6 +683,121 @@ func replPopup() { gomu.popups.push(flex) } +func ytSearchPopup() { + + popupId := "youtube-search-input-popup" + + input := newInputPopup(popupId, " Youtube Search ", "search: ", "") + + var mutex sync.Mutex + prefixMap := make(map[string][]string) + + // quick hack to change the autocomplete text color + tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack + input.SetAutocompleteFunc(func(currentText string) []string { + + // Ignore empty text. + prefix := strings.TrimSpace(strings.ToLower(currentText)) + if prefix == "" { + return nil + } + + mutex.Lock() + defer mutex.Unlock() + entries, ok := prefixMap[prefix] + if ok { + return entries + } + + go func() { + suggestions, err := getSuggestions(currentText) + if err != nil { + logError(err) + return + } + + mutex.Lock() + prefixMap[prefix] = suggestions + mutex.Unlock() + + input.Autocomplete() + gomu.app.Draw() + }() + + return nil + }) + + input.SetDoneFunc(func(key tcell.Key) { + + switch key { + case tcell.KeyEnter: + search := input.GetText() + defaultTimedPopup(" Youtube Search ", "Searching for "+search) + gomu.pages.RemovePage(popupId) + gomu.popups.pop() + + go func() { + + results, err := getSearchResult(search) + if err != nil { + logError(err) + defaultTimedPopup(" Error ", err.Error()) + return + } + + titles := []string{} + urls := make(map[string]string) + + for _, result := range results { + duration, err := time.ParseDuration(fmt.Sprintf("%ds", result.LengthSeconds)) + if err != nil { + logError(err) + return + } + + durationText := fmt.Sprintf("[ %s ] ", fmtDuration(duration)) + title := durationText + result.Title + + urls[title] = `https://www.youtube.com/watch?v=` + result.VideoId + + titles = append(titles, title) + } + + searchPopup("Youtube Videos", titles, func(title string) { + + audioFile := gomu.playlist.getCurrentFile() + + var dir *tview.TreeNode + + if audioFile.isAudioFile { + dir = audioFile.parent + } else { + dir = audioFile.node + } + + go func() { + url := urls[title] + if err := ytdl(url, dir); err != nil { + logError(err) + } + gomu.playlist.refresh() + }() + gomu.app.SetFocus(gomu.prevPanel.(tview.Primitive)) + }) + + gomu.app.Draw() + }() + + case tcell.KeyEscape: + gomu.pages.RemovePage(popupId) + gomu.popups.pop() + gomu.app.SetFocus(gomu.prevPanel.(tview.Primitive)) + + } + + }) +} + func tagPopup(node *AudioFile) bool { var tag *id3v2.Tag var err error diff --git a/start.go b/start.go index 8afaec9..edb01c8 100644 --- a/start.go +++ b/start.go @@ -13,6 +13,8 @@ import ( "syscall" "github.com/gdamore/tcell/v2" + "github.com/issadarkthing/gomu/anko" + "github.com/issadarkthing/gomu/hook" "github.com/rivo/tview" "github.com/ztrue/tracerr" ) @@ -62,9 +64,33 @@ func defineBuiltins() { gomu.anko.Define("shell", shell) } -// execInit executes helper modules and default config that should only be +func setupHooks(hook *hook.EventHook, anko *anko.Anko) { + + events := []string{ + "enter", + "new_song", + "skip", + "play", + "pause", + "exit", + } + + for _, event := range events { + name := event + hook.AddHook(name, func() { + src := fmt.Sprintf(`Event.run_hooks("%s")`, name) + _, err := anko.Execute(src) + if err != nil { + err = tracerr.Errorf("error execute hook: %w", err) + logError(err) + } + }) + } +} + +// loadModules executes helper modules and default config that should only be // executed once -func execInit() error { +func loadModules(env *anko.Anko) error { const listModule = ` module List { @@ -94,6 +120,35 @@ module List { return acc } } +` + const eventModule = ` +module Event { + events = {} + + func add_hook(name, f) { + hooks = events[name] + + if hooks == nil { + events[name] = [f] + return + } + + hooks += f + events[name] = hooks + } + + func run_hooks(name) { + hooks = events[name] + + if hooks == nil { + return + } + + for hook in hooks { + hook() + } + } +} ` const keybindModule = ` @@ -115,8 +170,7 @@ module Keybinds { } } ` - - _, err := gomu.anko.Execute(listModule + keybindModule) + _, err := env.Execute(eventModule + listModule + keybindModule) if err != nil { return tracerr.Wrap(err) } @@ -231,7 +285,8 @@ func start(application *tview.Application, args Args) { gomu = newGomu() gomu.command.defineCommands() defineBuiltins() - err := execInit() + + err := loadModules(gomu.anko) if err != nil { die(err) } @@ -241,6 +296,9 @@ func start(application *tview.Application, args Args) { die(err) } + setupHooks(gomu.hook, gomu.anko) + + gomu.hook.RunHooks("enter") gomu.args = args gomu.colors = newColor() @@ -357,15 +415,26 @@ func start(application *tview.Application, args Args) { }) // fix transparent background issue - application.SetBeforeDrawFunc(func(screen tcell.Screen) bool { + gomu.app.SetBeforeDrawFunc(func(screen tcell.Screen) bool { screen.Clear() return false }) + init := false + gomu.app.SetAfterDrawFunc(func(_ tcell.Screen) { + if !init && !gomu.player.isRunning { + gomu.playingBar.setDefault() + init = true + } + }) + go populateAudioLength(gomu.playlist.GetRoot()) + gomu.app.SetRoot(gomu.pages, true).SetFocus(gomu.playlist) + // main loop - if err := application.SetRoot(gomu.pages, true).SetFocus(gomu.playlist).Run(); err != nil { - logError(err) + if err := gomu.app.Run(); err != nil { + die(err) } + gomu.hook.RunHooks("exit") } diff --git a/start_test.go b/start_test.go new file mode 100644 index 0000000..6c302f7 --- /dev/null +++ b/start_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "testing" + + "github.com/issadarkthing/gomu/anko" + "github.com/issadarkthing/gomu/hook" + "github.com/stretchr/testify/assert" +) + + +func TestSetupHooks(t *testing.T) { + + gomu := newGomu() + gomu.anko = anko.NewAnko() + gomu.hook = hook.NewEventHook() + + err := loadModules(gomu.anko) + if err != nil { + t.Error(err) + } + + setupHooks(gomu.hook, gomu.anko) + + const src = ` +i = 0 + +Event.add_hook("skip", func() { + i++ +}) + ` + + _, err = gomu.anko.Execute(src) + if err != nil { + t.Error(err) + } + + gomu.hook.RunHooks("enter") + + for i := 0; i < 12; i++ { + gomu.hook.RunHooks("skip") + } + + got := gomu.anko.GetInt("i") + + assert.Equal(t, 12, got) +}