Merge branch 'master' into youtube-search

This commit is contained in:
raziman 2021-02-06 11:14:26 +08:00
commit f2f225a058
12 changed files with 475 additions and 169 deletions

View File

@ -15,6 +15,7 @@ Gomu is a Terminal User Interface **TUI** music player to play mp3 files from yo
- [youtube-dl](https://github.com/ytdl-org/youtube-dl) integration - [youtube-dl](https://github.com/ytdl-org/youtube-dl) integration
- audio file management - audio file management
- customizable - customizable
- find music from youtube
## Dependencies ## Dependencies
If you are using ubuntu, you need to install alsa and required dependencies If you are using ubuntu, you need to install alsa and required dependencies
@ -64,10 +65,19 @@ general:
confirm_on_exit: true confirm_on_exit: true
load_prev_queue: true load_prev_queue: true
music_dir: ~/music music_dir: ~/music
history_path: ~/.local/share/gomu/urls
popup_timeout: 5s popup_timeout: 5s
volume: 100 volume: 100
emoji: true emoji: false
fzf: false fzf: false
emoji:
playlist: 
file: 
loop: ﯩ
noloop: 
# vi:ft=yaml
``` ```
## Fzf ## Fzf
@ -81,28 +91,51 @@ edit this line `fzf: false` to change it into `true` in `~/.config/gomu/config`.
## Keybindings ## Keybindings
Each panel has it's own additional keybinding. To view the available keybinding for the specific panel use `?` Each panel has it's own additional keybinding. To view the available keybinding for the specific panel use `?`
| Key | Description | | Key (General) | Description |
|:----------------|-----------------------:| |:----------------|--------------------------------:|
| j | down |
| k | up |
| tab | change panel | | tab | change panel |
| space | toggle play/pause | | space | toggle play/pause |
| esc | close popup | | esc | close popup |
| n | skip | | n | skip |
| q | quit | | q | quit |
| l (lowercase L) | add song to queue |
| L | add playlist to queue |
| h | close node in playlist |
| d | remove from queue |
| D | delete playlist |
| + | volume up | | + | volume up |
| - | volume down | | - | volume down |
| y | search youtube | | f | forward 10 seconds |
| Y | download audio | | F | forward 60 seconds |
| a | create playlist | | b | rewind 10 seconds |
| B | rewind 60 seconds |
| ? | toggle help | | ? | toggle help |
| Key (Playlist) | Description |
|:----------------|--------------------------------:|
| j | down |
| k | up |
| h | close node in playlist |
| a | create playlist |
| l (lowercase L) | add song to queue |
| L | add playlist to queue |
| d | delete file from filesystemd |
| D | delete playlist from filesystem |
| Y | download audio |
| r | refresh |
| R | rename |
| y | yank file |
| p | paste file |
| / | find in playlist |
| s | search audio from youtube |
| Key (Queue) | Description |
|:----------------|--------------------------------:|
| j | down |
| k | up |
| l (lowercase L) | play selected song |
| d | remove from queue |
| D | delete playlist |
| z | toggle loop |
| s | shuffle |
| f | find in queue |
## Project Background ## Project Background
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: 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:
@ -111,5 +144,6 @@ I just wanted to implement my own music player with a programming language i'm c
- skip - skip
- play - play
- pause - pause
- forward and rewind
Seeking and more advanced stuff has not yet been implemented; feel free to contribute :) Seeking and more advanced stuff has not yet been implemented; feel free to contribute :)

View File

@ -68,7 +68,7 @@ func (c Command) defineCommands() {
popupId := "youtube-search-input-popup" popupId := "youtube-search-input-popup"
input := newInputPopup(popupId, " Youtube Search ", "search: ") input := newInputPopup(popupId, " Youtube Search ", "search: ", "")
// quick hack to change the autocomplete text color // quick hack to change the autocomplete text color
tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack
@ -163,8 +163,9 @@ func (c Command) defineCommands() {
audioFile := gomu.playlist.getCurrentFile() audioFile := gomu.playlist.getCurrentFile()
currNode := gomu.playlist.GetCurrentNode() currNode := gomu.playlist.GetCurrentNode()
if gomu.pages.HasPage("download-popup") { if gomu.pages.HasPage("download-input-popup") {
gomu.pages.RemovePage("download-popup") gomu.pages.RemovePage("download-input-popup")
gomu.popups.pop()
return return
} }
// this ensures it downloads to // this ensures it downloads to
@ -280,6 +281,7 @@ func (c Command) defineCommands() {
}) })
c.define("play_selected", func() { c.define("play_selected", func() {
if gomu.queue.GetItemCount() != 0 && gomu.queue.GetCurrentItem() != -1 {
a, err := gomu.queue.deleteItem(gomu.queue.GetCurrentItem()) a, err := gomu.queue.deleteItem(gomu.queue.GetCurrentItem())
if err != nil { if err != nil {
logError(err) logError(err)
@ -287,20 +289,12 @@ func (c Command) defineCommands() {
gomu.queue.pushFront(a) gomu.queue.pushFront(a)
gomu.player.skip() gomu.player.skip()
}
}) })
c.define("toggle_loop", func() { c.define("toggle_loop", func() {
gomu.queue.isLoop = gomu.player.toggleLoop()
isLoop := gomu.player.toggleLoop() gomu.queue.updateTitle()
var msg string
if isLoop {
msg = "Looping current queue"
} else {
msg = "Stopped looping current queue"
}
defaultTimedPopup(" Loop ", msg)
}) })
c.define("shuffle_queue", func() { c.define("shuffle_queue", func() {
@ -399,4 +393,65 @@ func (c Command) defineCommands() {
} }
}) })
}) })
c.define("forward", func() {
if gomu.player.isRunning && !gomu.player.ctrl.Paused {
position := gomu.playingBar._progress + 10
if position < gomu.playingBar.full {
gomu.player.seek(position)
gomu.playingBar._progress = position - 1
}
}
})
c.define("rewind", func() {
if gomu.player.isRunning && !gomu.player.ctrl.Paused {
position := gomu.playingBar._progress - 10
if position-1 > 0 {
gomu.player.seek(position)
gomu.playingBar._progress = position - 1
} else {
gomu.player.seek(0)
gomu.playingBar._progress = 0
}
}
})
c.define("forward_fast", func() {
if gomu.player.isRunning && !gomu.player.ctrl.Paused {
position := gomu.playingBar._progress + 60
if position < gomu.playingBar.full {
gomu.player.seek(position)
gomu.playingBar._progress = position - 1
}
}
})
c.define("rewind_fast", func() {
if gomu.player.isRunning && !gomu.player.ctrl.Paused {
position := gomu.playingBar._progress - 60
if position-1 > 0 {
gomu.player.seek(position)
gomu.playingBar._progress = position - 1
} else {
gomu.player.seek(0)
gomu.playingBar._progress = 0
}
}
})
c.define("yank", func() {
err := gomu.playlist.yank()
if err != nil {
logError(err)
}
})
c.define("paste", func() {
err := gomu.playlist.paste()
if err != nil {
logError(err)
}
})
} }

4
go.mod
View File

@ -5,7 +5,7 @@ go 1.14
require ( require (
github.com/faiface/beep v1.0.2 github.com/faiface/beep v1.0.2
github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/fsnotify/fsnotify v1.4.9 // indirect
github.com/gdamore/tcell/v2 v2.0.1-0.20201017141208-acf90d56d591 github.com/gdamore/tcell/v2 v2.1.0
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect
github.com/hajimehoshi/go-mp3 v0.3.1 // indirect github.com/hajimehoshi/go-mp3 v0.3.1 // indirect
github.com/hajimehoshi/oto v0.7.1 // indirect github.com/hajimehoshi/oto v0.7.1 // indirect
@ -15,7 +15,7 @@ require (
github.com/mitchellh/mapstructure v1.4.1 // indirect github.com/mitchellh/mapstructure v1.4.1 // indirect
github.com/pelletier/go-toml v1.8.1 // indirect github.com/pelletier/go-toml v1.8.1 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/tview v0.0.0-20210117162420-745e4ceeb711 github.com/rivo/tview v0.0.0-20210125085121-dbc1f32bb1d0
github.com/sahilm/fuzzy v0.1.0 github.com/sahilm/fuzzy v0.1.0
github.com/spf13/afero v1.5.1 // indirect github.com/spf13/afero v1.5.1 // indirect
github.com/spf13/cast v1.3.1 // indirect github.com/spf13/cast v1.3.1 // indirect

4
go.sum
View File

@ -51,6 +51,8 @@ github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU=
github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0= github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0=
github.com/gdamore/tcell/v2 v2.0.1-0.20201017141208-acf90d56d591 h1:0WWUDZ1oxq7NxVyGo8M3KI5jbkiwNAdZFFzAdC68up4= github.com/gdamore/tcell/v2 v2.0.1-0.20201017141208-acf90d56d591 h1:0WWUDZ1oxq7NxVyGo8M3KI5jbkiwNAdZFFzAdC68up4=
github.com/gdamore/tcell/v2 v2.0.1-0.20201017141208-acf90d56d591/go.mod h1:vSVL/GV5mCSlPC6thFP5kfOFdM9MGZcalipmpTxTgQA= github.com/gdamore/tcell/v2 v2.0.1-0.20201017141208-acf90d56d591/go.mod h1:vSVL/GV5mCSlPC6thFP5kfOFdM9MGZcalipmpTxTgQA=
github.com/gdamore/tcell/v2 v2.1.0 h1:UnSmozHgBkQi2PGsFr+rpdXuAPRRucMegpQp3Z3kDro=
github.com/gdamore/tcell/v2 v2.1.0/go.mod h1:vSVL/GV5mCSlPC6thFP5kfOFdM9MGZcalipmpTxTgQA=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@ -202,6 +204,8 @@ github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7z
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rivo/tview v0.0.0-20210117162420-745e4ceeb711 h1:9xC0sXenoeJK2jP8LK24H4FwcCcPwK8ZNCxgURhn52c= github.com/rivo/tview v0.0.0-20210117162420-745e4ceeb711 h1:9xC0sXenoeJK2jP8LK24H4FwcCcPwK8ZNCxgURhn52c=
github.com/rivo/tview v0.0.0-20210117162420-745e4ceeb711/go.mod h1:1QW7hX7RQzOqyGgx8O64bRPQBrFtPflioPPX5gFPV3A= github.com/rivo/tview v0.0.0-20210117162420-745e4ceeb711/go.mod h1:1QW7hX7RQzOqyGgx8O64bRPQBrFtPflioPPX5gFPV3A=
github.com/rivo/tview v0.0.0-20210125085121-dbc1f32bb1d0 h1:WCfp+Jq9Mx156zIf9X6Frd6F19rf7wIRlm54UPxUfcU=
github.com/rivo/tview v0.0.0-20210125085121-dbc1f32bb1d0/go.mod h1:1QW7hX7RQzOqyGgx8O64bRPQBrFtPflioPPX5gFPV3A=
github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=

22
gomu.go
View File

@ -90,6 +90,24 @@ func (g *Gomu) cyclePanels() Panel {
return first return first
} }
func (g *Gomu) cyclePanels2() Panel {
first := g.panels[0]
second := g.panels[1]
if first.HasFocus() {
g.setFocusPanel(second)
g.prevPanel = second
return second
} else if second.HasFocus() {
g.setFocusPanel(first)
g.prevPanel = first
return first
} else {
g.setFocusPanel(first)
g.prevPanel = first
return first
}
}
// Changes title and border color when focusing panel // Changes title and border color when focusing panel
// and changes color of the previous panel as well // and changes color of the previous panel as well
func (g *Gomu) setFocusPanel(panel Panel) { func (g *Gomu) setFocusPanel(panel Panel) {
@ -102,8 +120,10 @@ func (g *Gomu) setFocusPanel(panel Panel) {
return return
} }
if g.prevPanel != panel {
g.setUnfocusPanel(g.prevPanel) g.setUnfocusPanel(g.prevPanel)
} }
}
// Safely write the IsSuspend state, IsSuspend is used to indicate if we // Safely write the IsSuspend state, IsSuspend is used to indicate if we
// are going to suspend the app. This should be used to widgets or // are going to suspend the app. This should be used to widgets or
@ -141,7 +161,7 @@ func (g *Gomu) setUnfocusPanel(panel Panel) {
func (g *Gomu) quit(args Args) error { func (g *Gomu) quit(args Args) error {
if !*args.empty { if !*args.empty {
err := gomu.queue.saveQueue() err := gomu.queue.saveQueue(true)
if err != nil { if err != nil {
return tracerr.Wrap(err) return tracerr.Wrap(err)
} }

View File

@ -30,7 +30,6 @@ func setupLog() {
log.Fatalf("Error opening file %s", logFile) log.Fatalf("Error opening file %s", logFile)
} }
log.SetOutput(file) log.SetOutput(file)
log.SetFlags(log.Ldate | log.Ltime | log.Llongfile) log.SetFlags(log.Ldate | log.Ltime | log.Llongfile)
} }

View File

@ -31,6 +31,9 @@ type Player struct {
position time.Duration position time.Duration
length time.Duration length time.Duration
currentSong *AudioFile currentSong *AudioFile
streamSeekCloser beep.StreamSeekCloser
// is used to send progress
i int
} }
func newPlayer() *Player { func newPlayer() *Player {
@ -58,21 +61,24 @@ func (p *Player) run(currSong *AudioFile) error {
defer f.Close() defer f.Close()
streamer, format, err := mp3.Decode(f) stream, format, err := mp3.Decode(f)
p.streamSeekCloser = stream
p.format = &format
if err != nil { if err != nil {
return tracerr.Wrap(err) return tracerr.Wrap(err)
} }
defer streamer.Close() defer stream.Close()
// song duration // song duration
p.length = format.SampleRate.D(streamer.Len()) p.length = p.format.SampleRate.D(p.streamSeekCloser.Len())
if !p.hasInit { if !p.hasInit {
err := speaker. err := speaker.
Init(format.SampleRate, format.SampleRate.N(time.Second/10)) Init(p.format.SampleRate, p.format.SampleRate.N(time.Second/10))
if err != nil { if err != nil {
return tracerr.Wrap(err) return tracerr.Wrap(err)
@ -81,7 +87,6 @@ func (p *Player) run(currSong *AudioFile) error {
p.hasInit = true p.hasInit = true
} }
p.format = &format
p.currentSong = currSong p.currentSong = currSong
popupMessage := fmt.Sprintf("%s\n\n[ %s ]", popupMessage := fmt.Sprintf("%s\n\n[ %s ]",
@ -92,7 +97,7 @@ func (p *Player) run(currSong *AudioFile) error {
done := make(chan bool, 1) done := make(chan bool, 1)
p.done = done p.done = done
sstreamer := beep.Seq(streamer, beep.Callback(func() { sstreamer := beep.Seq(p.streamSeekCloser, beep.Callback(func() {
done <- true done <- true
})) }))
@ -118,18 +123,9 @@ func (p *Player) run(currSong *AudioFile) error {
p._volume = volume p._volume = volume
speaker.Play(p._volume) speaker.Play(p._volume)
position := func() time.Duration { p.position = p.getPosition()
return format.SampleRate.D(streamer.Position())
}
p.position = position()
p.isRunning = true p.isRunning = true
if p.isLoop {
gomu.queue.enqueue(currSong)
gomu.app.Draw()
}
gomu.playingBar.newProgress(currSong.name, int(p.length.Seconds())) gomu.playingBar.newProgress(currSong.name, int(p.length.Seconds()))
go func() { go func() {
@ -139,13 +135,18 @@ func (p *Player) run(currSong *AudioFile) error {
}() }()
// is used to send progress // is used to send progress
i := 0 p.i = 0
next: next:
for { for {
select { select {
case <-done: case <-done:
if p.isLoop {
gomu.queue.enqueue(currSong)
gomu.app.Draw()
}
close(done) close(done)
p.position = 0 p.position = 0
p.isRunning = false p.isRunning = false
@ -173,14 +174,14 @@ next:
continue continue
} }
i++ p.i++
gomu.playingBar.progress <- 1 gomu.playingBar.progress <- 1
speaker.Lock() speaker.Lock()
p.position = position() p.position = p.getPosition()
speaker.Unlock() speaker.Unlock()
if i > gomu.playingBar.full { if p.i > gomu.playingBar.full {
break next break next
} }
@ -248,6 +249,7 @@ func (p *Player) skip() {
} }
p.ctrl.Streamer = nil p.ctrl.Streamer = nil
p.streamSeekCloser.Close()
p.done <- true p.done <- true
} }
@ -290,3 +292,16 @@ func volToHuman(volume float64) int {
func absVolume(volume int) float64 { func absVolume(volume int) float64 {
return (float64(volume) - 100) / 10 return (float64(volume) - 100) / 10
} }
func (p *Player) getPosition() time.Duration {
return p.format.SampleRate.D(p.streamSeekCloser.Position())
}
//seek is the function to move forward and rewind
func (p *Player) seek(pos int) error {
speaker.Lock()
defer speaker.Unlock()
err := p.streamSeekCloser.Seek(pos * int(p.format.SampleRate))
p.i = pos - 1
return err
}

View File

@ -21,7 +21,7 @@ import (
"github.com/ztrue/tracerr" "github.com/ztrue/tracerr"
) )
// Playlist and mp3 files are represented with this struct // AudioFile is representing directories and mp3 files
// if isAudioFile equals to false it is a directory // if isAudioFile equals to false it is a directory
type AudioFile struct { type AudioFile struct {
name string name string
@ -43,6 +43,11 @@ type Playlist struct {
done chan struct{} done chan struct{}
} }
var (
yankFile *AudioFile
isYanked bool
)
func (p *Playlist) help() []string { func (p *Playlist) help() []string {
return []string{ return []string{
@ -58,6 +63,8 @@ func (p *Playlist) help() []string {
"y query audio from youtube and download", "y query audio from youtube and download",
"r refresh", "r refresh",
"R rename", "R rename",
"y yank file",
"p paste file",
"/ find in playlist", "/ find in playlist",
} }
@ -146,6 +153,8 @@ func newPlaylist(args Args) *Playlist {
'h': "close_node", 'h': "close_node",
'r': "refresh", 'r': "refresh",
'R': "rename", 'R': "rename",
'y': "yank",
'p': "paste",
'/': "playlist_search", '/': "playlist_search",
} }
@ -192,6 +201,7 @@ func (p *Playlist) deleteSong(audioFile *AudioFile) (err error) {
return return
} }
audioName := getName(audioFile.path)
err := os.Remove(audioFile.path) err := os.Remove(audioFile.path)
if err != nil { if err != nil {
@ -204,8 +214,18 @@ func (p *Playlist) deleteSong(audioFile *AudioFile) (err error) {
defaultTimedPopup(" Success ", defaultTimedPopup(" Success ",
audioFile.name+"\nhas been deleted successfully") audioFile.name+"\nhas been deleted successfully")
p.refresh() p.refresh()
//Here we remove the song from queue
songPaths := gomu.queue.getItems()
if audioName == getName(gomu.player.currentSong.name) {
gomu.player.skip()
}
for i, songPath := range songPaths {
if strings.Contains(songPath, audioName) {
gomu.queue.deleteItem(i)
}
}
} }
}) })
@ -269,8 +289,12 @@ func (p *Playlist) addAllToQueue(root *tview.TreeNode) {
for _, v := range childrens { for _, v := range childrens {
currNode := v.GetReference().(*AudioFile) currNode := v.GetReference().(*AudioFile)
if currNode.isAudioFile {
if currNode != gomu.player.currentSong {
gomu.queue.enqueue(currNode) gomu.queue.enqueue(currNode)
} }
}
}
} }
@ -285,7 +309,7 @@ func (p *Playlist) refresh() {
populate(root, root.GetReference().(*AudioFile).path) populate(root, root.GetReference().(*AudioFile).path)
root.Walk(func(node, parent *tview.TreeNode) bool { root.Walk(func(node, _ *tview.TreeNode) bool {
// to preserve previously highlighted node // to preserve previously highlighted node
if node.GetText() == prevFileName { if node.GetText() == prevFileName {
@ -504,6 +528,11 @@ func (p *Playlist) rename(newName string) error {
return tracerr.Wrap(err) return tracerr.Wrap(err)
} }
audio.path = newPath
gomu.queue.saveQueue(false)
gomu.queue.clearQueue()
gomu.queue.loadQueue()
return nil return nil
} }
@ -592,18 +621,15 @@ func ytdl(url string, selPlaylist *tview.TreeNode) error {
return tracerr.Wrap(err) return tracerr.Wrap(err)
} }
dir := viper.GetString("general.music_dir")
selAudioFile := selPlaylist.GetReference().(*AudioFile) selAudioFile := selPlaylist.GetReference().(*AudioFile)
selPlaylistName := selAudioFile.name dir := selAudioFile.path
defaultTimedPopup(" Ytdl ", "Downloading") defaultTimedPopup(" Ytdl ", "Downloading")
// specify the output path for ytdl // specify the output path for ytdl
outputDir := fmt.Sprintf( outputDir := fmt.Sprintf(
"%s/%s/%%(title)s.%%(ext)s", "%s/%%(title)s.%%(ext)s",
dir, dir)
selPlaylistName)
args := []string{ args := []string{
"--extract-audio", "--extract-audio",
@ -611,6 +637,8 @@ func ytdl(url string, selPlaylist *tview.TreeNode) error {
"mp3", "mp3",
"--output", "--output",
outputDir, outputDir,
// "--cookies",
// "~/Downloads/youtube.com_cookies.txt",
url, url,
} }
@ -632,7 +660,7 @@ func ytdl(url string, selPlaylist *tview.TreeNode) error {
return tracerr.Wrap(err) return tracerr.Wrap(err)
} }
playlistPath := path.Join(expandTilde(dir), selPlaylistName) playlistPath := dir
audioPath := extractFilePath(stdout.Bytes(), playlistPath) audioPath := extractFilePath(stdout.Bytes(), playlistPath)
err = appendFile(expandTilde(viper.GetString("general.history_path")), url+"\n") err = appendFile(expandTilde(viper.GetString("general.history_path")), url+"\n")
@ -645,10 +673,9 @@ func ytdl(url string, selPlaylist *tview.TreeNode) error {
return tracerr.Wrap(err) return tracerr.Wrap(err)
} }
downloadFinishedMessage := fmt.Sprintf("Finished downloading\n%s", downloadFinishedMessage := fmt.Sprintf("Finished downloading\n%s", getName(audioPath))
getName(audioPath))
defaultTimedPopup(" Ytdl ", downloadFinishedMessage) defaultTimedPopup(" Ytdl ", downloadFinishedMessage)
gomu.app.Draw()
return nil return nil
} }
@ -664,19 +691,21 @@ func populate(root *tview.TreeNode, rootPath string) error {
for _, file := range files { for _, file := range files {
path := filepath.Join(rootPath, file.Name()) path, err := filepath.EvalSymlinks(filepath.Join(rootPath, file.Name()))
f, err := os.Open(path)
if err != nil { if err != nil {
continue continue
} }
defer f.Close()
songName := getName(file.Name()) songName := getName(file.Name())
child := tview.NewTreeNode(songName) child := tview.NewTreeNode(songName)
if !file.IsDir() { if file.Mode().IsRegular() {
f, err := os.Open(path)
if err != nil {
continue
}
defer f.Close()
filetype, err := getFileContentType(f) filetype, err := getFileContentType(f)
@ -689,17 +718,10 @@ func populate(root *tview.TreeNode, rootPath string) error {
continue continue
} }
audioLength, err := getLength(path)
if err != nil {
continue
}
audioFile := &AudioFile{ audioFile := &AudioFile{
name: songName, name: songName,
path: path, path: path,
isAudioFile: true, isAudioFile: true,
length: audioLength,
node: child, node: child,
parent: root, parent: root,
} }
@ -716,11 +738,12 @@ func populate(root *tview.TreeNode, rootPath string) error {
} }
if file.IsDir() { if file.IsDir() || file.Mode()&os.ModeSymlink != 0 {
audioFile := &AudioFile{ audioFile := &AudioFile{
name: songName, name: songName,
path: path, path: path,
isAudioFile: false,
node: child, node: child,
parent: root, parent: root,
} }
@ -743,3 +766,81 @@ func populate(root *tview.TreeNode, rootPath string) error {
return nil return nil
} }
func (p *Playlist) yank() error {
yankFile = p.getCurrentFile()
if yankFile == nil {
isYanked = false
defaultTimedPopup(" Error! ", "No file has been yanked.")
return nil
}
if yankFile.node == p.GetRoot() {
isYanked = false
defaultTimedPopup(" Error! ", "Please don't yank the root directory.")
return nil
}
isYanked = true
defaultTimedPopup(" Success ", yankFile.name+"\n has been yanked successfully.")
return nil
}
func (p *Playlist) paste() error {
if isYanked {
isYanked = false
oldPathDir, oldPathFileName := filepath.Split(yankFile.path)
pasteFile := p.getCurrentFile()
if pasteFile.isAudioFile {
newPathDir, _ := filepath.Split(pasteFile.path)
if oldPathDir == newPathDir {
return nil
} else {
newPathFull := filepath.Join(newPathDir, oldPathFileName)
err := os.Rename(yankFile.path, newPathFull)
if err != nil {
defaultTimedPopup(" Error ", yankFile.name+"\n has not been pasted.")
return tracerr.Wrap(err)
}
defaultTimedPopup(" Success ", yankFile.name+"\n has been pasted to\n"+pasteFile.name)
}
} else {
newPathDir := pasteFile.path
if oldPathDir == newPathDir {
return nil
} else {
newPathFull := filepath.Join(newPathDir, oldPathFileName)
err := os.Rename(yankFile.path, newPathFull)
if err != nil {
defaultTimedPopup(" Error ", yankFile.name+"\n has not been pasted.")
return tracerr.Wrap(err)
}
defaultTimedPopup(" Success ", yankFile.name+"\n has been pasted to\n"+pasteFile.name)
}
}
p.refresh()
gomu.queue.updateQueueNames()
}
return nil
}
//populateAudioLength is the most time consuming part of startup,
//so here we initialize it separately
func populateAudioLength(root *tview.TreeNode) error {
root.Walk(func(node *tview.TreeNode, _ *tview.TreeNode) bool {
audioFile := node.GetReference().(*AudioFile)
if audioFile.isAudioFile {
audioLength, err := getLength(audioFile.path)
if err != nil {
logError(err)
return false
}
audioFile.length = audioLength
}
return true
})
gomu.queue.updateTitle()
return nil
}

View File

@ -163,15 +163,15 @@ func timedPopup(
box := tview.NewFrame(textView).SetBorders(1, 0, 0, 0, 0, 0) box := tview.NewFrame(textView).SetBorders(1, 0, 0, 0, 0, 0)
box.SetTitle(title).SetBorder(true).SetBackgroundColor(gomu.colors.popup) box.SetTitle(title).SetBorder(true).SetBackgroundColor(gomu.colors.popup)
popupId := fmt.Sprintf("%s %d", "timeout-popup", popupCounter) popupID := fmt.Sprintf("%s %d", "timeout-popup", popupCounter)
popupCounter++ popupCounter++
gomu.pages.AddPage(popupId, topRight(box, width, height), true, true) gomu.pages.AddPage(popupID, topRight(box, width, height), true, true)
gomu.app.SetFocus(gomu.prevPanel.(tview.Primitive)) gomu.app.SetFocus(gomu.prevPanel.(tview.Primitive))
go func() { go func() {
time.Sleep(timeout) time.Sleep(timeout)
gomu.pages.RemovePage(popupId) gomu.pages.RemovePage(popupID)
gomu.app.Draw() gomu.app.Draw()
// timed popup shouldn't get focused // timed popup shouldn't get focused
@ -225,6 +225,10 @@ func helpPopup(panel Panel) {
"q quit", "q quit",
"+ volume up", "+ volume up",
"- volume down", "- volume down",
"f forward 10 seconds",
"F forward 60 seconds",
"b rewind 10 seconds",
"B rewind 60 seconds",
"? toggle help", "? toggle help",
} }
@ -279,8 +283,8 @@ func downloadMusicPopup(selPlaylist *tview.TreeNode) {
re := regexp.MustCompile(`^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$`) re := regexp.MustCompile(`^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$`)
popupId := "download-input-popup" popupID := "download-input-popup"
input := newInputPopup(popupId, " Download ", "Url: ") input := newInputPopup(popupID, " Download ", "Url: ", "")
input.SetDoneFunc(func(key tcell.Key) { input.SetDoneFunc(func(key tcell.Key) {
@ -316,8 +320,8 @@ func downloadMusicPopup(selPlaylist *tview.TreeNode) {
// Input popup that takes the name of directory to be created // Input popup that takes the name of directory to be created
func createPlaylistPopup() { func createPlaylistPopup() {
popupId := "mkdir-input-popup" popupID := "mkdir-input-popup"
input := newInputPopup(popupId, " New Playlist ", "Enter playlist name: ") input := newInputPopup(popupID, " New Playlist ", "Enter playlist name: ", "")
input.SetDoneFunc(func(key tcell.Key) { input.SetDoneFunc(func(key tcell.Key) {
@ -408,7 +412,6 @@ func searchPopup(stringsToMatch []string, handler func(selected string)) {
} }
}) })
input.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey { input.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey {
switch e.Key() { switch e.Key() {
@ -462,7 +465,7 @@ func searchPopup(stringsToMatch []string, handler func(selected string)) {
} }
// Creates new popup widget with default settings // Creates new popup widget with default settings
func newInputPopup(popupId, title, label string) *tview.InputField { func newInputPopup(popupID, title, label string, text string) *tview.InputField {
inputField := tview.NewInputField(). inputField := tview.NewInputField().
SetLabel(label). SetLabel(label).
@ -476,8 +479,10 @@ func newInputPopup(popupId, title, label string) *tview.InputField {
SetBorder(true). SetBorder(true).
SetBorderPadding(1, 0, 2, 2) SetBorderPadding(1, 0, 2, 2)
inputField.SetText(text)
gomu.pages. gomu.pages.
AddPage(popupId, center(inputField, 60, 5), true, true) AddPage(popupID, center(inputField, 60, 5), true, true)
gomu.popups.push(inputField) gomu.popups.push(inputField)
@ -486,8 +491,8 @@ func newInputPopup(popupId, title, label string) *tview.InputField {
func renamePopup(node *AudioFile) { func renamePopup(node *AudioFile) {
popupId := "rename-input-popup" popupID := "rename-input-popup"
input := newInputPopup(popupId, " Rename ", "New name: ") input := newInputPopup(popupID, " Rename ", "New name: ", node.name)
input.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey { input.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey {
switch e.Key() { switch e.Key() {
@ -501,12 +506,26 @@ func renamePopup(node *AudioFile) {
defaultTimedPopup(" Error ", err.Error()) defaultTimedPopup(" Error ", err.Error())
logError(err) logError(err)
} }
gomu.pages.RemovePage(popupId) gomu.pages.RemovePage(popupID)
gomu.popups.pop() gomu.popups.pop()
gomu.playlist.refresh() gomu.playlist.refresh()
// gomu.queue.saveQueue()
// gomu.queue.clearQueue()
// gomu.queue.loadQueue()
gomu.queue.updateQueueNames()
gomu.setFocusPanel(gomu.playlist)
gomu.prevPanel = gomu.playlist
// gomu.playlist.setHighlight(node.node)
root := gomu.playlist.GetRoot()
root.Walk(func(node, _ *tview.TreeNode) bool {
if strings.Contains(node.GetText(), newName) {
gomu.playlist.setHighlight(node)
}
return true
})
case tcell.KeyEsc: case tcell.KeyEsc:
gomu.pages.RemovePage(popupId) gomu.pages.RemovePage(popupID)
gomu.popups.pop() gomu.popups.pop()
} }

View File

@ -16,6 +16,7 @@ import (
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
"github.com/spf13/viper"
"github.com/ztrue/tracerr" "github.com/ztrue/tracerr"
) )
@ -95,8 +96,25 @@ func (q *Queue) updateTitle() string {
count = "song" count = "song"
} }
title := fmt.Sprintf("─ Queue ───┤ %d %s | %s ├", var loop string
len(q.items), count, fmtTime) isEmoji := viper.GetBool("general.emoji")
if q.isLoop {
if isEmoji {
loop = viper.GetString("emoji.loop")
} else {
loop = "Loop"
}
} else {
if isEmoji {
loop = viper.GetString("emoji.noloop")
} else {
loop = "No loop"
}
}
title := fmt.Sprintf("─ Queue ───┤ %d %s | %s | %s ├",
len(q.items), count, fmtTime, loop)
q.SetTitle(title) q.SetTitle(title)
@ -136,7 +154,9 @@ func (q *Queue) dequeue() (*AudioFile, error) {
// Add item to the list and returns the length of the queue // Add item to the list and returns the length of the queue
func (q *Queue) enqueue(audioFile *AudioFile) (int, error) { func (q *Queue) enqueue(audioFile *AudioFile) (int, error) {
if !gomu.player.isRunning && "false" == os.Getenv("TEST") { //this is to fix the problem bulk_add when paused
if !gomu.player.hasInit {
if !gomu.player.isRunning && os.Getenv("TEST") == "false" {
gomu.player.isRunning = true gomu.player.isRunning = true
@ -151,6 +171,11 @@ func (q *Queue) enqueue(audioFile *AudioFile) (int, error) {
return q.GetItemCount(), nil return q.GetItemCount(), nil
} }
}
if !audioFile.isAudioFile {
return q.GetItemCount(), nil
}
q.items = append(q.items, audioFile) q.items = append(q.items, audioFile)
songLength, err := getLength(audioFile.path) songLength, err := getLength(audioFile.path)
@ -186,11 +211,25 @@ func (q *Queue) getItems() []string {
} }
// Save the current queue // Save the current queue
func (q *Queue) saveQueue() error { func (q *Queue) saveQueue(isQuit bool) error {
songPaths := q.getItems() songPaths := q.getItems()
var content strings.Builder var content strings.Builder
if gomu.player.hasInit && isQuit {
currentSongPath := gomu.player.currentSong.path
currentSongInQueue := false
for _, songPath := range songPaths {
if getName(songPath) == getName(currentSongPath) {
currentSongInQueue = true
}
}
if !currentSongInQueue {
hashed := sha1Hex(getName(currentSongPath))
content.WriteString(hashed + "\n")
}
}
for _, songPath := range songPaths { for _, songPath := range songPaths {
// hashed song name is easier to search through // hashed song name is easier to search through
hashed := sha1Hex(getName(songPath)) hashed := sha1Hex(getName(songPath))
@ -360,7 +399,7 @@ func (q *Queue) shuffle() {
q.AddItem(queueText, v.path, 0, nil) q.AddItem(queueText, v.path, 0, nil)
} }
q.updateTitle() // q.updateTitle()
} }
@ -421,3 +460,11 @@ func sha1Hex(input string) string {
h.Write([]byte(input)) h.Write([]byte(input))
return hex.EncodeToString(h.Sum(nil)) return hex.EncodeToString(h.Sum(nil))
} }
//Modify the title of songs in queue
func (q *Queue) updateQueueNames() error {
q.saveQueue(false)
q.clearQueue()
q.loadQueue()
return nil
}

View File

@ -18,7 +18,7 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
) )
// Created so we can keep track of childrens in slices // Panel is used to keep track of childrens in slices
type Panel interface { type Panel interface {
HasFocus() bool HasFocus() bool
SetBorderColor(color tcell.Color) *tview.Box SetBorderColor(color tcell.Color) *tview.Box
@ -29,9 +29,9 @@ type Panel interface {
} }
const ( const (
CONFIG_PATH = ".config/gomu/config" configPath = ".config/gomu/config"
HISTORY_PATH = "~/.local/share/gomu/urls" historyPath = "~/.local/share/gomu/urls"
MUSIC_PATH = "~/music" musicPath = "~/music"
) )
// Reads config file and sets the options // Reads config file and sets the options
@ -43,6 +43,7 @@ general:
confirm_bulk_add: true confirm_bulk_add: true
confirm_on_exit: true confirm_on_exit: true
load_prev_queue: true load_prev_queue: true
queue_loop: true
# change this to directory that contains mp3 files # change this to directory that contains mp3 files
music_dir: ~/music music_dir: ~/music
# url history of downloaded audio will be saved here # url history of downloaded audio will be saved here
@ -60,7 +61,7 @@ general:
# if you experiencing error using this invidious instance, you can change it # if you experiencing error using this invidious instance, you can change it
# to another instance from this list: # to another instance from this list:
# https://github.com/iv-org/documentation/blob/master/Invidious-Instances.md # https://github.com/iv-org/documentation/blob/master/Invidious-Instances.md
invidious_instance: "https://invidious.namazso.eu" invidious_instance: "https://vid.puffyan.us"
# not all colors can be reproducible in terminal # not all colors can be reproducible in terminal
# changing hex colors may or may not produce expected result # changing hex colors may or may not produce expected result
@ -79,6 +80,8 @@ color:
emoji: emoji:
playlist: playlist:
file: file:
loop:
noloop:
# vi:ft=yaml # vi:ft=yaml
` `
@ -91,7 +94,7 @@ emoji:
logError(err) logError(err)
} }
defaultPath := path.Join(home, CONFIG_PATH) defaultPath := path.Join(home, configPath)
if err != nil { if err != nil {
logError(err) logError(err)
@ -103,19 +106,18 @@ emoji:
viper.AddConfigPath("$HOME/.config/gomu") viper.AddConfigPath("$HOME/.config/gomu")
// General config // General config
viper.SetDefault("general.music_dir", MUSIC_PATH) viper.SetDefault("general.music_dir", musicPath)
viper.SetDefault("general.history_path", HISTORY_PATH) viper.SetDefault("general.history_path", historyPath)
viper.SetDefault("general.confirm_on_exit", true) viper.SetDefault("general.confirm_on_exit", true)
viper.SetDefault("general.confirm_bulk_add", true) viper.SetDefault("general.confirm_bulk_add", true)
viper.SetDefault("general.popup_timeout", "5s") viper.SetDefault("general.popup_timeout", "5s")
viper.SetDefault("general.volume", 100) viper.SetDefault("general.volume", 100)
viper.SetDefault("general.load_prev_queue", true) viper.SetDefault("general.load_prev_queue", true)
viper.SetDefault("general.use_emoji", false) viper.SetDefault("general.use_emoji", false)
viper.SetDefault("general.invidious_instance", "https://invidious.namazso.eu") viper.SetDefault("general.invidious_instance", "https://vid.puffyan.us")
if err := viper.ReadInConfig(); err != nil { if err := viper.ReadInConfig(); err != nil {
// creates gomu config dir if does not exist // creates gomu config dir if does not exist
if _, err := os.Stat(defaultPath); err != nil { if _, err := os.Stat(defaultPath); err != nil {
if err := os.MkdirAll(home+"/.config/gomu", 0755); err != nil { if err := os.MkdirAll(home+"/.config/gomu", 0755); err != nil {
@ -146,9 +148,9 @@ type Args struct {
func getArgs() Args { func getArgs() Args {
ar := Args{ ar := Args{
config: flag.String("config", CONFIG_PATH, "Specify config file"), config: flag.String("config", configPath, "Specify config file"),
empty: flag.Bool("empty", false, "Open gomu with empty queue. Does not override previous queue"), empty: flag.Bool("empty", false, "Open gomu with empty queue. Does not override previous queue"),
music: flag.String("music", MUSIC_PATH, "Specify music directory"), music: flag.String("music", musicPath, "Specify music directory"),
version: flag.Bool("version", false, "Print gomu version"), version: flag.Bool("version", false, "Print gomu version"),
} }
flag.Parse() flag.Parse()
@ -199,6 +201,9 @@ func start(application *tview.Application, args Args) {
gomu.setFocusPanel(gomu.playlist) gomu.setFocusPanel(gomu.playlist)
gomu.prevPanel = gomu.playlist gomu.prevPanel = gomu.playlist
gomu.player.isLoop = viper.GetBool("general.queue_loop")
gomu.queue.isLoop = gomu.player.isLoop
if !*args.empty && viper.GetBool("general.load_prev_queue") { if !*args.empty && viper.GetBool("general.load_prev_queue") {
// load saved queue from previous session // load saved queue from previous session
if err := gomu.queue.loadQueue(); err != nil { if err := gomu.queue.loadQueue(); err != nil {
@ -207,14 +212,14 @@ func start(application *tview.Application, args Args) {
} }
sigs := make(chan os.Signal, 1) sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGKILL) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() { go func() {
sig := <-sigs sig := <-sigs
errMsg := fmt.Sprintf("Received %s. Exiting program", sig.String()) errMsg := fmt.Sprintf("Received %s. Exiting program", sig.String())
logError(errors.New(errMsg)) logError(errors.New(errMsg))
err := gomu.quit(args) err := gomu.quit(args)
if err != nil { if err != nil {
logError(errors.New("Unable to quit program")) logError(errors.New("unable to quit program"))
} }
}() }()
@ -234,17 +239,23 @@ func start(application *tview.Application, args Args) {
if strings.Contains(popupName, "confirmation-") { if strings.Contains(popupName, "confirmation-") {
return e return e
} }
gomu.cyclePanels() gomu.cyclePanels2()
} }
cmds := map[rune]string{ cmds := map[rune]string{
'q': "quit", 'q': "quit",
' ': "toggle_pause", ' ': "toggle_pause",
'+': "volume_up", '+': "volume_up",
'=': "volume_up",
'-': "volume_down", '-': "volume_down",
'_': "volume_down",
'n': "skip", 'n': "skip",
':': "command_search", ':': "command_search",
'?': "toggle_help", '?': "toggle_help",
'f': "forward",
'F': "forward_fast",
'b': "rewind",
'B': "rewind_fast",
} }
for key, cmd := range cmds { for key, cmd := range cmds {
@ -268,8 +279,10 @@ func start(application *tview.Application, args Args) {
return false return false
}) })
go populateAudioLength(gomu.playlist.GetRoot())
// main loop // main loop
if err := application.SetRoot(gomu.pages, true).SetFocus(gomu.playlist).Run(); err != nil { if err := application.SetRoot(gomu.pages, true).SetFocus(gomu.playlist).Run(); err != nil {
logError(err) logError(err)
} }
} }

View File

@ -21,7 +21,6 @@ func logError(err error) {
log.Println(tracerr.Sprint(err)) log.Println(tracerr.Sprint(err))
} }
// Formats duration to my desired output mm:ss // Formats duration to my desired output mm:ss
func fmtDuration(input time.Duration) string { func fmtDuration(input time.Duration) string {