├── 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(`^
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 |