├── .github
└── workflows
│ ├── publish.yml
│ └── release.yml
├── .gitignore
├── Cargo.lock
├── Cargo.toml
├── LICENSE
├── README.md
├── gonk.rdbg
├── gonk
├── Cargo.toml
└── src
│ ├── browser.rs
│ ├── help.rs
│ ├── main.rs
│ ├── playlist.rs
│ ├── queue.rs
│ ├── search.rs
│ └── settings.rs
├── gonk_core
├── Cargo.toml
├── benches
│ └── flac.rs
└── src
│ ├── db.rs
│ ├── flac_decoder.rs
│ ├── index.rs
│ ├── lib.rs
│ ├── log.rs
│ ├── playlist.rs
│ ├── settings.rs
│ ├── strsim.rs
│ └── vdb.rs
├── gonk_player
├── Cargo.toml
└── src
│ ├── decoder.rs
│ ├── lib.rs
│ └── main.rs
└── media
├── broken.png
├── gonk.gif
└── old.gif
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | push:
5 | tags:
6 | - "*.*.*"
7 |
8 | jobs:
9 | release:
10 | name: Publish to Github Releases
11 | outputs:
12 | rc: ${{ steps.check-tag.outputs.rc }}
13 |
14 | strategy:
15 | matrix:
16 | include:
17 | - target: x86_64-unknown-linux-gnu
18 | os: ubuntu-latest
19 | - target: x86_64-pc-windows-msvc
20 | os: windows-latest
21 | runs-on: ${{matrix.os}}
22 |
23 | steps:
24 | - uses: actions/checkout@v2
25 |
26 | - name: Install Rust Toolchain Components
27 | uses: actions-rs/toolchain@v1
28 | with:
29 | override: true
30 | target: ${{ matrix.target }}
31 | toolchain: stable
32 | profile: minimal
33 |
34 | - name: Install dependencies
35 | shell: bash
36 | run: |
37 | if [[ "$RUNNER_OS" != "Windows" ]]; then
38 | sudo apt install -y libasound2-dev libjack-jackd2-dev
39 | fi
40 |
41 | - name: Build
42 | uses: actions-rs/cargo@v1
43 | with:
44 | command: build
45 | args: --release --target=${{ matrix.target }}
46 |
47 | - name: Build Archive
48 | shell: bash
49 | id: package
50 | env:
51 | target: ${{ matrix.target }}
52 | version: ${{ steps.check-tag.outputs.version }}
53 | run: |
54 | set -euxo pipefail
55 | bin=${GITHUB_REPOSITORY##*/}
56 | src=`pwd`
57 | dist=$src/dist
58 | name=$bin-$version-$target
59 | executable=target/$target/release/$bin
60 | if [[ "$RUNNER_OS" == "Windows" ]]; then
61 | executable=$executable.exe
62 | fi
63 | mkdir $dist
64 | cp $executable $dist
65 | cd $dist
66 | if [[ "$RUNNER_OS" == "Windows" ]]; then
67 | archive=$dist/$name.zip
68 | 7z a $archive *
69 | echo "::set-output name=archive::`pwd -W`/$name.zip"
70 | else
71 | archive=$dist/$name.tar.gz
72 | tar czf $archive *
73 | echo "::set-output name=archive::$archive"
74 | fi
75 |
76 | - name: Publish Archive
77 | uses: softprops/action-gh-release@v1
78 | with:
79 | files: ${{ steps.package.outputs.archive }}
80 | generate_release_notes: true
81 | env:
82 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
83 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 |
7 | jobs:
8 | release:
9 | name: Publish to Github Releases
10 | outputs:
11 | rc: ${{ steps.check-tag.outputs.rc }}
12 |
13 | strategy:
14 | matrix:
15 | include:
16 | - target: x86_64-unknown-linux-gnu
17 | os: ubuntu-latest
18 | - target: x86_64-pc-windows-msvc
19 | os: windows-latest
20 | runs-on: ${{matrix.os}}
21 |
22 | steps:
23 | - uses: actions/checkout@v2
24 |
25 | - name: Delete old release
26 | uses: dev-drprasad/delete-tag-and-release@v0.2.0
27 | with:
28 | delete_release: true
29 | tag_name: "latest"
30 | env:
31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
32 |
33 | - name: Install Rust Toolchain Components
34 | uses: actions-rs/toolchain@v1
35 | with:
36 | override: true
37 | target: ${{ matrix.target }}
38 | toolchain: stable
39 | profile: minimal
40 |
41 | - name: Install dependencies
42 | shell: bash
43 | run: |
44 | if [[ "$RUNNER_OS" != "Windows" ]]; then
45 | sudo apt install -y libasound2-dev libjack-jackd2-dev
46 | fi
47 |
48 | - name: Build
49 | uses: actions-rs/cargo@v1
50 | with:
51 | command: build
52 | args: --release --target=${{ matrix.target }}
53 |
54 | - name: Build Archive
55 | shell: bash
56 | id: package
57 | env:
58 | target: ${{ matrix.target }}
59 | version: ${{ steps.check-tag.outputs.version }}
60 | run: |
61 | set -euxo pipefail
62 | bin=${GITHUB_REPOSITORY##*/}
63 | src=`pwd`
64 | dist=$src/dist
65 | name=$bin-$version-$target
66 | executable=target/$target/release/$bin
67 | if [[ "$RUNNER_OS" == "Windows" ]]; then
68 | executable=$executable.exe
69 | fi
70 | mkdir $dist
71 | cp $executable $dist
72 | cd $dist
73 | if [[ "$RUNNER_OS" == "Windows" ]]; then
74 | archive=$dist/$name.zip
75 | 7z a $archive *
76 | echo "::set-output name=archive::`pwd -W`/$name.zip"
77 | else
78 | archive=$dist/$name.tar.gz
79 | tar czf $archive *
80 | echo "::set-output name=archive::$archive"
81 | fi
82 |
83 | - name: Publish Archive
84 | uses: softprops/action-gh-release@v1
85 | with:
86 | name: "Development Build"
87 | tag_name: "latest"
88 | prerelease: true
89 | files: ${{ steps.package.outputs.archive }}
90 | env:
91 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
92 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 | /.vscode
3 |
4 | *.log
5 | *.opt
6 | *.db
--------------------------------------------------------------------------------
/Cargo.lock:
--------------------------------------------------------------------------------
1 | # This file is automatically @generated by Cargo.
2 | # It is not intended for manual editing.
3 | version = 4
4 |
5 | [[package]]
6 | name = "aho-corasick"
7 | version = "1.1.3"
8 | source = "registry+https://github.com/rust-lang/crates.io-index"
9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
10 | dependencies = [
11 | "memchr",
12 | ]
13 |
14 | [[package]]
15 | name = "anes"
16 | version = "0.1.6"
17 | source = "registry+https://github.com/rust-lang/crates.io-index"
18 | checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
19 |
20 | [[package]]
21 | name = "anstyle"
22 | version = "1.0.10"
23 | source = "registry+https://github.com/rust-lang/crates.io-index"
24 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9"
25 |
26 | [[package]]
27 | name = "arrayvec"
28 | version = "0.7.6"
29 | source = "registry+https://github.com/rust-lang/crates.io-index"
30 | checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
31 |
32 | [[package]]
33 | name = "autocfg"
34 | version = "1.4.0"
35 | source = "registry+https://github.com/rust-lang/crates.io-index"
36 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
37 |
38 | [[package]]
39 | name = "bitflags"
40 | version = "1.3.2"
41 | source = "registry+https://github.com/rust-lang/crates.io-index"
42 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
43 |
44 | [[package]]
45 | name = "bitflags"
46 | version = "2.9.0"
47 | source = "registry+https://github.com/rust-lang/crates.io-index"
48 | checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
49 |
50 | [[package]]
51 | name = "bumpalo"
52 | version = "3.17.0"
53 | source = "registry+https://github.com/rust-lang/crates.io-index"
54 | checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
55 |
56 | [[package]]
57 | name = "bytemuck"
58 | version = "1.22.0"
59 | source = "registry+https://github.com/rust-lang/crates.io-index"
60 | checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540"
61 |
62 | [[package]]
63 | name = "cast"
64 | version = "0.3.0"
65 | source = "registry+https://github.com/rust-lang/crates.io-index"
66 | checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
67 |
68 | [[package]]
69 | name = "cfg-if"
70 | version = "1.0.0"
71 | source = "registry+https://github.com/rust-lang/crates.io-index"
72 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
73 |
74 | [[package]]
75 | name = "ciborium"
76 | version = "0.2.2"
77 | source = "registry+https://github.com/rust-lang/crates.io-index"
78 | checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
79 | dependencies = [
80 | "ciborium-io",
81 | "ciborium-ll",
82 | "serde",
83 | ]
84 |
85 | [[package]]
86 | name = "ciborium-io"
87 | version = "0.2.2"
88 | source = "registry+https://github.com/rust-lang/crates.io-index"
89 | checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
90 |
91 | [[package]]
92 | name = "ciborium-ll"
93 | version = "0.2.2"
94 | source = "registry+https://github.com/rust-lang/crates.io-index"
95 | checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
96 | dependencies = [
97 | "ciborium-io",
98 | "half",
99 | ]
100 |
101 | [[package]]
102 | name = "clap"
103 | version = "4.5.37"
104 | source = "registry+https://github.com/rust-lang/crates.io-index"
105 | checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071"
106 | dependencies = [
107 | "clap_builder",
108 | ]
109 |
110 | [[package]]
111 | name = "clap_builder"
112 | version = "4.5.37"
113 | source = "registry+https://github.com/rust-lang/crates.io-index"
114 | checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2"
115 | dependencies = [
116 | "anstyle",
117 | "clap_lex",
118 | ]
119 |
120 | [[package]]
121 | name = "clap_lex"
122 | version = "0.7.4"
123 | source = "registry+https://github.com/rust-lang/crates.io-index"
124 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
125 |
126 | [[package]]
127 | name = "criterion"
128 | version = "0.5.1"
129 | source = "registry+https://github.com/rust-lang/crates.io-index"
130 | checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
131 | dependencies = [
132 | "anes",
133 | "cast",
134 | "ciborium",
135 | "clap",
136 | "criterion-plot",
137 | "is-terminal",
138 | "itertools",
139 | "num-traits",
140 | "once_cell",
141 | "oorandom",
142 | "plotters",
143 | "rayon",
144 | "regex",
145 | "serde",
146 | "serde_derive",
147 | "serde_json",
148 | "tinytemplate",
149 | "walkdir",
150 | ]
151 |
152 | [[package]]
153 | name = "criterion-plot"
154 | version = "0.5.0"
155 | source = "registry+https://github.com/rust-lang/crates.io-index"
156 | checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
157 | dependencies = [
158 | "cast",
159 | "itertools",
160 | ]
161 |
162 | [[package]]
163 | name = "crossbeam-deque"
164 | version = "0.8.6"
165 | source = "registry+https://github.com/rust-lang/crates.io-index"
166 | checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
167 | dependencies = [
168 | "crossbeam-epoch",
169 | "crossbeam-utils",
170 | ]
171 |
172 | [[package]]
173 | name = "crossbeam-epoch"
174 | version = "0.9.18"
175 | source = "registry+https://github.com/rust-lang/crates.io-index"
176 | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
177 | dependencies = [
178 | "crossbeam-utils",
179 | ]
180 |
181 | [[package]]
182 | name = "crossbeam-queue"
183 | version = "0.3.12"
184 | source = "registry+https://github.com/rust-lang/crates.io-index"
185 | checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
186 | dependencies = [
187 | "crossbeam-utils",
188 | ]
189 |
190 | [[package]]
191 | name = "crossbeam-utils"
192 | version = "0.8.21"
193 | source = "registry+https://github.com/rust-lang/crates.io-index"
194 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
195 |
196 | [[package]]
197 | name = "crunchy"
198 | version = "0.2.3"
199 | source = "registry+https://github.com/rust-lang/crates.io-index"
200 | checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929"
201 |
202 | [[package]]
203 | name = "either"
204 | version = "1.15.0"
205 | source = "registry+https://github.com/rust-lang/crates.io-index"
206 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
207 |
208 | [[package]]
209 | name = "encoding_rs"
210 | version = "0.8.35"
211 | source = "registry+https://github.com/rust-lang/crates.io-index"
212 | checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
213 | dependencies = [
214 | "cfg-if",
215 | ]
216 |
217 | [[package]]
218 | name = "gonk"
219 | version = "0.2.0"
220 | dependencies = [
221 | "gonk_core",
222 | "gonk_player",
223 | "mini",
224 | "rayon",
225 | "winter",
226 | ]
227 |
228 | [[package]]
229 | name = "gonk_core"
230 | version = "0.2.0"
231 | dependencies = [
232 | "criterion",
233 | "minbin",
234 | "mini",
235 | "rayon",
236 | "symphonia",
237 | "winwalk",
238 | ]
239 |
240 | [[package]]
241 | name = "gonk_player"
242 | version = "0.2.0"
243 | dependencies = [
244 | "crossbeam-queue",
245 | "gonk_core",
246 | "mini",
247 | "ringbuf",
248 | "symphonia",
249 | "wasapi",
250 | ]
251 |
252 | [[package]]
253 | name = "half"
254 | version = "2.6.0"
255 | source = "registry+https://github.com/rust-lang/crates.io-index"
256 | checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9"
257 | dependencies = [
258 | "cfg-if",
259 | "crunchy",
260 | ]
261 |
262 | [[package]]
263 | name = "hermit-abi"
264 | version = "0.5.0"
265 | source = "registry+https://github.com/rust-lang/crates.io-index"
266 | checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e"
267 |
268 | [[package]]
269 | name = "is-terminal"
270 | version = "0.4.16"
271 | source = "registry+https://github.com/rust-lang/crates.io-index"
272 | checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
273 | dependencies = [
274 | "hermit-abi",
275 | "libc",
276 | "windows-sys",
277 | ]
278 |
279 | [[package]]
280 | name = "itertools"
281 | version = "0.10.5"
282 | source = "registry+https://github.com/rust-lang/crates.io-index"
283 | checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
284 | dependencies = [
285 | "either",
286 | ]
287 |
288 | [[package]]
289 | name = "itoa"
290 | version = "1.0.15"
291 | source = "registry+https://github.com/rust-lang/crates.io-index"
292 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
293 |
294 | [[package]]
295 | name = "js-sys"
296 | version = "0.3.77"
297 | source = "registry+https://github.com/rust-lang/crates.io-index"
298 | checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
299 | dependencies = [
300 | "once_cell",
301 | "wasm-bindgen",
302 | ]
303 |
304 | [[package]]
305 | name = "lazy_static"
306 | version = "1.5.0"
307 | source = "registry+https://github.com/rust-lang/crates.io-index"
308 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
309 |
310 | [[package]]
311 | name = "libc"
312 | version = "0.2.172"
313 | source = "registry+https://github.com/rust-lang/crates.io-index"
314 | checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
315 |
316 | [[package]]
317 | name = "log"
318 | version = "0.4.27"
319 | source = "registry+https://github.com/rust-lang/crates.io-index"
320 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
321 |
322 | [[package]]
323 | name = "memchr"
324 | version = "2.7.4"
325 | source = "registry+https://github.com/rust-lang/crates.io-index"
326 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
327 |
328 | [[package]]
329 | name = "minbin"
330 | version = "0.1.0"
331 | source = "git+https://github.com/zX3no/minbin.git#b7d9578f1f057b949da5dbc081661144ccd4b057"
332 |
333 | [[package]]
334 | name = "mini"
335 | version = "0.1.0"
336 | source = "git+https://github.com/zX3no/mini#7287c86f7503ead33a9e7fae1b112db0fdee652d"
337 |
338 | [[package]]
339 | name = "num-complex"
340 | version = "0.4.6"
341 | source = "registry+https://github.com/rust-lang/crates.io-index"
342 | checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
343 | dependencies = [
344 | "num-traits",
345 | ]
346 |
347 | [[package]]
348 | name = "num-integer"
349 | version = "0.1.46"
350 | source = "registry+https://github.com/rust-lang/crates.io-index"
351 | checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
352 | dependencies = [
353 | "num-traits",
354 | ]
355 |
356 | [[package]]
357 | name = "num-traits"
358 | version = "0.2.19"
359 | source = "registry+https://github.com/rust-lang/crates.io-index"
360 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
361 | dependencies = [
362 | "autocfg",
363 | ]
364 |
365 | [[package]]
366 | name = "once_cell"
367 | version = "1.21.3"
368 | source = "registry+https://github.com/rust-lang/crates.io-index"
369 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
370 |
371 | [[package]]
372 | name = "oorandom"
373 | version = "11.1.5"
374 | source = "registry+https://github.com/rust-lang/crates.io-index"
375 | checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
376 |
377 | [[package]]
378 | name = "plotters"
379 | version = "0.3.7"
380 | source = "registry+https://github.com/rust-lang/crates.io-index"
381 | checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
382 | dependencies = [
383 | "num-traits",
384 | "plotters-backend",
385 | "plotters-svg",
386 | "wasm-bindgen",
387 | "web-sys",
388 | ]
389 |
390 | [[package]]
391 | name = "plotters-backend"
392 | version = "0.3.7"
393 | source = "registry+https://github.com/rust-lang/crates.io-index"
394 | checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
395 |
396 | [[package]]
397 | name = "plotters-svg"
398 | version = "0.3.7"
399 | source = "registry+https://github.com/rust-lang/crates.io-index"
400 | checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
401 | dependencies = [
402 | "plotters-backend",
403 | ]
404 |
405 | [[package]]
406 | name = "portable-atomic"
407 | version = "1.11.0"
408 | source = "registry+https://github.com/rust-lang/crates.io-index"
409 | checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
410 |
411 | [[package]]
412 | name = "portable-atomic-util"
413 | version = "0.2.4"
414 | source = "registry+https://github.com/rust-lang/crates.io-index"
415 | checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
416 | dependencies = [
417 | "portable-atomic",
418 | ]
419 |
420 | [[package]]
421 | name = "primal-check"
422 | version = "0.3.4"
423 | source = "registry+https://github.com/rust-lang/crates.io-index"
424 | checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
425 | dependencies = [
426 | "num-integer",
427 | ]
428 |
429 | [[package]]
430 | name = "proc-macro2"
431 | version = "1.0.95"
432 | source = "registry+https://github.com/rust-lang/crates.io-index"
433 | checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
434 | dependencies = [
435 | "unicode-ident",
436 | ]
437 |
438 | [[package]]
439 | name = "quote"
440 | version = "1.0.40"
441 | source = "registry+https://github.com/rust-lang/crates.io-index"
442 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
443 | dependencies = [
444 | "proc-macro2",
445 | ]
446 |
447 | [[package]]
448 | name = "rayon"
449 | version = "1.10.0"
450 | source = "registry+https://github.com/rust-lang/crates.io-index"
451 | checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa"
452 | dependencies = [
453 | "either",
454 | "rayon-core",
455 | ]
456 |
457 | [[package]]
458 | name = "rayon-core"
459 | version = "1.12.1"
460 | source = "registry+https://github.com/rust-lang/crates.io-index"
461 | checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
462 | dependencies = [
463 | "crossbeam-deque",
464 | "crossbeam-utils",
465 | ]
466 |
467 | [[package]]
468 | name = "regex"
469 | version = "1.11.1"
470 | source = "registry+https://github.com/rust-lang/crates.io-index"
471 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
472 | dependencies = [
473 | "aho-corasick",
474 | "memchr",
475 | "regex-automata",
476 | "regex-syntax",
477 | ]
478 |
479 | [[package]]
480 | name = "regex-automata"
481 | version = "0.4.9"
482 | source = "registry+https://github.com/rust-lang/crates.io-index"
483 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
484 | dependencies = [
485 | "aho-corasick",
486 | "memchr",
487 | "regex-syntax",
488 | ]
489 |
490 | [[package]]
491 | name = "regex-syntax"
492 | version = "0.8.5"
493 | source = "registry+https://github.com/rust-lang/crates.io-index"
494 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
495 |
496 | [[package]]
497 | name = "ringbuf"
498 | version = "0.4.8"
499 | source = "registry+https://github.com/rust-lang/crates.io-index"
500 | checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c"
501 | dependencies = [
502 | "crossbeam-utils",
503 | "portable-atomic",
504 | "portable-atomic-util",
505 | ]
506 |
507 | [[package]]
508 | name = "rustfft"
509 | version = "6.3.0"
510 | source = "registry+https://github.com/rust-lang/crates.io-index"
511 | checksum = "f266ff9b0cfc79de11fd5af76a2bc672fe3ace10c96fa06456740fa70cb1ed49"
512 | dependencies = [
513 | "num-complex",
514 | "num-integer",
515 | "num-traits",
516 | "primal-check",
517 | "strength_reduce",
518 | "transpose",
519 | "version_check",
520 | ]
521 |
522 | [[package]]
523 | name = "rustversion"
524 | version = "1.0.20"
525 | source = "registry+https://github.com/rust-lang/crates.io-index"
526 | checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"
527 |
528 | [[package]]
529 | name = "ryu"
530 | version = "1.0.20"
531 | source = "registry+https://github.com/rust-lang/crates.io-index"
532 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
533 |
534 | [[package]]
535 | name = "same-file"
536 | version = "1.0.6"
537 | source = "registry+https://github.com/rust-lang/crates.io-index"
538 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
539 | dependencies = [
540 | "winapi-util",
541 | ]
542 |
543 | [[package]]
544 | name = "serde"
545 | version = "1.0.219"
546 | source = "registry+https://github.com/rust-lang/crates.io-index"
547 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
548 | dependencies = [
549 | "serde_derive",
550 | ]
551 |
552 | [[package]]
553 | name = "serde_derive"
554 | version = "1.0.219"
555 | source = "registry+https://github.com/rust-lang/crates.io-index"
556 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
557 | dependencies = [
558 | "proc-macro2",
559 | "quote",
560 | "syn",
561 | ]
562 |
563 | [[package]]
564 | name = "serde_json"
565 | version = "1.0.140"
566 | source = "registry+https://github.com/rust-lang/crates.io-index"
567 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
568 | dependencies = [
569 | "itoa",
570 | "memchr",
571 | "ryu",
572 | "serde",
573 | ]
574 |
575 | [[package]]
576 | name = "strength_reduce"
577 | version = "0.2.4"
578 | source = "registry+https://github.com/rust-lang/crates.io-index"
579 | checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
580 |
581 | [[package]]
582 | name = "symphonia"
583 | version = "0.5.4"
584 | source = "git+https://github.com/pdeljanov/Symphonia#ef9bbd8dd147b05cc911dafe0ae3663ae81b692d"
585 | dependencies = [
586 | "lazy_static",
587 | "symphonia-bundle-flac",
588 | "symphonia-bundle-mp3",
589 | "symphonia-codec-vorbis",
590 | "symphonia-core",
591 | "symphonia-format-ogg",
592 | "symphonia-metadata",
593 | ]
594 |
595 | [[package]]
596 | name = "symphonia-bundle-flac"
597 | version = "0.5.4"
598 | source = "git+https://github.com/pdeljanov/Symphonia#ef9bbd8dd147b05cc911dafe0ae3663ae81b692d"
599 | dependencies = [
600 | "log",
601 | "symphonia-core",
602 | "symphonia-metadata",
603 | "symphonia-utils-xiph",
604 | ]
605 |
606 | [[package]]
607 | name = "symphonia-bundle-mp3"
608 | version = "0.5.4"
609 | source = "git+https://github.com/pdeljanov/Symphonia#ef9bbd8dd147b05cc911dafe0ae3663ae81b692d"
610 | dependencies = [
611 | "lazy_static",
612 | "log",
613 | "symphonia-core",
614 | "symphonia-metadata",
615 | ]
616 |
617 | [[package]]
618 | name = "symphonia-codec-vorbis"
619 | version = "0.5.4"
620 | source = "git+https://github.com/pdeljanov/Symphonia#ef9bbd8dd147b05cc911dafe0ae3663ae81b692d"
621 | dependencies = [
622 | "log",
623 | "symphonia-core",
624 | "symphonia-utils-xiph",
625 | ]
626 |
627 | [[package]]
628 | name = "symphonia-core"
629 | version = "0.5.4"
630 | source = "git+https://github.com/pdeljanov/Symphonia#ef9bbd8dd147b05cc911dafe0ae3663ae81b692d"
631 | dependencies = [
632 | "arrayvec",
633 | "bitflags 1.3.2",
634 | "bytemuck",
635 | "lazy_static",
636 | "log",
637 | "rustfft",
638 | ]
639 |
640 | [[package]]
641 | name = "symphonia-format-ogg"
642 | version = "0.5.4"
643 | source = "git+https://github.com/pdeljanov/Symphonia#ef9bbd8dd147b05cc911dafe0ae3663ae81b692d"
644 | dependencies = [
645 | "log",
646 | "symphonia-core",
647 | "symphonia-metadata",
648 | "symphonia-utils-xiph",
649 | ]
650 |
651 | [[package]]
652 | name = "symphonia-metadata"
653 | version = "0.5.4"
654 | source = "git+https://github.com/pdeljanov/Symphonia#ef9bbd8dd147b05cc911dafe0ae3663ae81b692d"
655 | dependencies = [
656 | "encoding_rs",
657 | "lazy_static",
658 | "log",
659 | "symphonia-core",
660 | ]
661 |
662 | [[package]]
663 | name = "symphonia-utils-xiph"
664 | version = "0.5.4"
665 | source = "git+https://github.com/pdeljanov/Symphonia#ef9bbd8dd147b05cc911dafe0ae3663ae81b692d"
666 | dependencies = [
667 | "symphonia-core",
668 | "symphonia-metadata",
669 | ]
670 |
671 | [[package]]
672 | name = "syn"
673 | version = "2.0.101"
674 | source = "registry+https://github.com/rust-lang/crates.io-index"
675 | checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf"
676 | dependencies = [
677 | "proc-macro2",
678 | "quote",
679 | "unicode-ident",
680 | ]
681 |
682 | [[package]]
683 | name = "tinytemplate"
684 | version = "1.2.1"
685 | source = "registry+https://github.com/rust-lang/crates.io-index"
686 | checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
687 | dependencies = [
688 | "serde",
689 | "serde_json",
690 | ]
691 |
692 | [[package]]
693 | name = "transpose"
694 | version = "0.2.3"
695 | source = "registry+https://github.com/rust-lang/crates.io-index"
696 | checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
697 | dependencies = [
698 | "num-integer",
699 | "strength_reduce",
700 | ]
701 |
702 | [[package]]
703 | name = "unicode-ident"
704 | version = "1.0.18"
705 | source = "registry+https://github.com/rust-lang/crates.io-index"
706 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
707 |
708 | [[package]]
709 | name = "unicode-width"
710 | version = "0.1.14"
711 | source = "registry+https://github.com/rust-lang/crates.io-index"
712 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
713 |
714 | [[package]]
715 | name = "version_check"
716 | version = "0.9.5"
717 | source = "registry+https://github.com/rust-lang/crates.io-index"
718 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
719 |
720 | [[package]]
721 | name = "walkdir"
722 | version = "2.5.0"
723 | source = "registry+https://github.com/rust-lang/crates.io-index"
724 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
725 | dependencies = [
726 | "same-file",
727 | "winapi-util",
728 | ]
729 |
730 | [[package]]
731 | name = "wasapi"
732 | version = "0.1.0"
733 | source = "git+https://github.com/zx3no/wasapi#09cc174dc98c0160b1bfa162dabdc7e59aa2815e"
734 |
735 | [[package]]
736 | name = "wasm-bindgen"
737 | version = "0.2.100"
738 | source = "registry+https://github.com/rust-lang/crates.io-index"
739 | checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
740 | dependencies = [
741 | "cfg-if",
742 | "once_cell",
743 | "rustversion",
744 | "wasm-bindgen-macro",
745 | ]
746 |
747 | [[package]]
748 | name = "wasm-bindgen-backend"
749 | version = "0.2.100"
750 | source = "registry+https://github.com/rust-lang/crates.io-index"
751 | checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
752 | dependencies = [
753 | "bumpalo",
754 | "log",
755 | "proc-macro2",
756 | "quote",
757 | "syn",
758 | "wasm-bindgen-shared",
759 | ]
760 |
761 | [[package]]
762 | name = "wasm-bindgen-macro"
763 | version = "0.2.100"
764 | source = "registry+https://github.com/rust-lang/crates.io-index"
765 | checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
766 | dependencies = [
767 | "quote",
768 | "wasm-bindgen-macro-support",
769 | ]
770 |
771 | [[package]]
772 | name = "wasm-bindgen-macro-support"
773 | version = "0.2.100"
774 | source = "registry+https://github.com/rust-lang/crates.io-index"
775 | checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
776 | dependencies = [
777 | "proc-macro2",
778 | "quote",
779 | "syn",
780 | "wasm-bindgen-backend",
781 | "wasm-bindgen-shared",
782 | ]
783 |
784 | [[package]]
785 | name = "wasm-bindgen-shared"
786 | version = "0.2.100"
787 | source = "registry+https://github.com/rust-lang/crates.io-index"
788 | checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
789 | dependencies = [
790 | "unicode-ident",
791 | ]
792 |
793 | [[package]]
794 | name = "web-sys"
795 | version = "0.3.77"
796 | source = "registry+https://github.com/rust-lang/crates.io-index"
797 | checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
798 | dependencies = [
799 | "js-sys",
800 | "wasm-bindgen",
801 | ]
802 |
803 | [[package]]
804 | name = "winapi-util"
805 | version = "0.1.9"
806 | source = "registry+https://github.com/rust-lang/crates.io-index"
807 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
808 | dependencies = [
809 | "windows-sys",
810 | ]
811 |
812 | [[package]]
813 | name = "windows-sys"
814 | version = "0.59.0"
815 | source = "registry+https://github.com/rust-lang/crates.io-index"
816 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
817 | dependencies = [
818 | "windows-targets",
819 | ]
820 |
821 | [[package]]
822 | name = "windows-targets"
823 | version = "0.52.6"
824 | source = "registry+https://github.com/rust-lang/crates.io-index"
825 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
826 | dependencies = [
827 | "windows_aarch64_gnullvm",
828 | "windows_aarch64_msvc",
829 | "windows_i686_gnu",
830 | "windows_i686_gnullvm",
831 | "windows_i686_msvc",
832 | "windows_x86_64_gnu",
833 | "windows_x86_64_gnullvm",
834 | "windows_x86_64_msvc",
835 | ]
836 |
837 | [[package]]
838 | name = "windows_aarch64_gnullvm"
839 | version = "0.52.6"
840 | source = "registry+https://github.com/rust-lang/crates.io-index"
841 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
842 |
843 | [[package]]
844 | name = "windows_aarch64_msvc"
845 | version = "0.52.6"
846 | source = "registry+https://github.com/rust-lang/crates.io-index"
847 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
848 |
849 | [[package]]
850 | name = "windows_i686_gnu"
851 | version = "0.52.6"
852 | source = "registry+https://github.com/rust-lang/crates.io-index"
853 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
854 |
855 | [[package]]
856 | name = "windows_i686_gnullvm"
857 | version = "0.52.6"
858 | source = "registry+https://github.com/rust-lang/crates.io-index"
859 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
860 |
861 | [[package]]
862 | name = "windows_i686_msvc"
863 | version = "0.52.6"
864 | source = "registry+https://github.com/rust-lang/crates.io-index"
865 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
866 |
867 | [[package]]
868 | name = "windows_x86_64_gnu"
869 | version = "0.52.6"
870 | source = "registry+https://github.com/rust-lang/crates.io-index"
871 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
872 |
873 | [[package]]
874 | name = "windows_x86_64_gnullvm"
875 | version = "0.52.6"
876 | source = "registry+https://github.com/rust-lang/crates.io-index"
877 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
878 |
879 | [[package]]
880 | name = "windows_x86_64_msvc"
881 | version = "0.52.6"
882 | source = "registry+https://github.com/rust-lang/crates.io-index"
883 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
884 |
885 | [[package]]
886 | name = "winter"
887 | version = "0.1.0"
888 | source = "git+https://github.com/zX3no/winter#9f7b9090f4e64cfd86ce1122f0db549bbeea3949"
889 | dependencies = [
890 | "bitflags 2.9.0",
891 | "unicode-width",
892 | ]
893 |
894 | [[package]]
895 | name = "winwalk"
896 | version = "0.2.2"
897 | source = "registry+https://github.com/rust-lang/crates.io-index"
898 | checksum = "7be02f8d6df9807ac05b5766ab9ea63f54db3f40bbf45cc9346103429ac6a26c"
899 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [workspace]
2 | resolver = "2"
3 | members = ["gonk", "gonk_core", "gonk_player"]
4 |
5 | [profile.release]
6 | strip = true
7 | debug = true
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | CC0 1.0 Universal
2 |
3 | Statement of Purpose
4 |
5 | The laws of most jurisdictions throughout the world automatically confer
6 | exclusive Copyright and Related Rights (defined below) upon the creator and
7 | subsequent owner(s) (each and all, an "owner") of an original work of
8 | authorship and/or a database (each, a "Work").
9 |
10 | Certain owners wish to permanently relinquish those rights to a Work for the
11 | purpose of contributing to a commons of creative, cultural and scientific
12 | works ("Commons") that the public can reliably and without fear of later
13 | claims of infringement build upon, modify, incorporate in other works, reuse
14 | and redistribute as freely as possible in any form whatsoever and for any
15 | purposes, including without limitation commercial purposes. These owners may
16 | contribute to the Commons to promote the ideal of a free culture and the
17 | further production of creative, cultural and scientific works, or to gain
18 | reputation or greater distribution for their Work in part through the use and
19 | efforts of others.
20 |
21 | For these and/or other purposes and motivations, and without any expectation
22 | of additional consideration or compensation, the person associating CC0 with a
23 | Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
24 | and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
25 | and publicly distribute the Work under its terms, with knowledge of his or her
26 | Copyright and Related Rights in the Work and the meaning and intended legal
27 | effect of CC0 on those rights.
28 |
29 | 1. Copyright and Related Rights. A Work made available under CC0 may be
30 | protected by copyright and related or neighboring rights ("Copyright and
31 | Related Rights"). Copyright and Related Rights include, but are not limited
32 | to, the following:
33 |
34 | i. the right to reproduce, adapt, distribute, perform, display, communicate,
35 | and translate a Work;
36 |
37 | ii. moral rights retained by the original author(s) and/or performer(s);
38 |
39 | iii. publicity and privacy rights pertaining to a person's image or likeness
40 | depicted in a Work;
41 |
42 | iv. rights protecting against unfair competition in regards to a Work,
43 | subject to the limitations in paragraph 4(a), below;
44 |
45 | v. rights protecting the extraction, dissemination, use and reuse of data in
46 | a Work;
47 |
48 | vi. database rights (such as those arising under Directive 96/9/EC of the
49 | European Parliament and of the Council of 11 March 1996 on the legal
50 | protection of databases, and under any national implementation thereof,
51 | including any amended or successor version of such directive); and
52 |
53 | vii. other similar, equivalent or corresponding rights throughout the world
54 | based on applicable law or treaty, and any national implementations thereof.
55 |
56 | 2. Waiver. To the greatest extent permitted by, but not in contravention of,
57 | applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
58 | unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
59 | and Related Rights and associated claims and causes of action, whether now
60 | known or unknown (including existing as well as future claims and causes of
61 | action), in the Work (i) in all territories worldwide, (ii) for the maximum
62 | duration provided by applicable law or treaty (including future time
63 | extensions), (iii) in any current or future medium and for any number of
64 | copies, and (iv) for any purpose whatsoever, including without limitation
65 | commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
66 | the Waiver for the benefit of each member of the public at large and to the
67 | detriment of Affirmer's heirs and successors, fully intending that such Waiver
68 | shall not be subject to revocation, rescission, cancellation, termination, or
69 | any other legal or equitable action to disrupt the quiet enjoyment of the Work
70 | by the public as contemplated by Affirmer's express Statement of Purpose.
71 |
72 | 3. Public License Fallback. Should any part of the Waiver for any reason be
73 | judged legally invalid or ineffective under applicable law, then the Waiver
74 | shall be preserved to the maximum extent permitted taking into account
75 | Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
76 | is so judged Affirmer hereby grants to each affected person a royalty-free,
77 | non transferable, non sublicensable, non exclusive, irrevocable and
78 | unconditional license to exercise Affirmer's Copyright and Related Rights in
79 | the Work (i) in all territories worldwide, (ii) for the maximum duration
80 | provided by applicable law or treaty (including future time extensions), (iii)
81 | in any current or future medium and for any number of copies, and (iv) for any
82 | purpose whatsoever, including without limitation commercial, advertising or
83 | promotional purposes (the "License"). The License shall be deemed effective as
84 | of the date CC0 was applied by Affirmer to the Work. Should any part of the
85 | License for any reason be judged legally invalid or ineffective under
86 | applicable law, such partial invalidity or ineffectiveness shall not
87 | invalidate the remainder of the License, and in such case Affirmer hereby
88 | affirms that he or she will not (i) exercise any of his or her remaining
89 | Copyright and Related Rights in the Work or (ii) assert any associated claims
90 | and causes of action with respect to the Work, in either case contrary to
91 | Affirmer's express Statement of Purpose.
92 |
93 | 4. Limitations and Disclaimers.
94 |
95 | a. No trademark or patent rights held by Affirmer are waived, abandoned,
96 | surrendered, licensed or otherwise affected by this document.
97 |
98 | b. Affirmer offers the Work as-is and makes no representations or warranties
99 | of any kind concerning the Work, express, implied, statutory or otherwise,
100 | including without limitation warranties of title, merchantability, fitness
101 | for a particular purpose, non infringement, or the absence of latent or
102 | other defects, accuracy, or the present or absence of errors, whether or not
103 | discoverable, all to the greatest extent permissible under applicable law.
104 |
105 | c. Affirmer disclaims responsibility for clearing rights of other persons
106 | that may apply to the Work or any use thereof, including without limitation
107 | any person's Copyright and Related Rights in the Work. Further, Affirmer
108 | disclaims responsibility for obtaining any necessary consents, permissions
109 | or other rights required for any use of the Work.
110 |
111 | d. Affirmer understands and acknowledges that Creative Commons is not a
112 | party to this document and has no duty or obligation with respect to this
113 | CC0 or use of the Work.
114 |
115 | For more information, please see
116 |
117 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
Gonk
2 |
3 | A terminal music player.
4 |
5 |
6 |

7 |
8 |
9 | ## ⚠️ Warning
10 |
11 | - This is a place where I test new ideas. I would not recommend using this as your music player.
12 |
13 | ## ✨ Features
14 | - Easy to use
15 | - Plays FLAC, MP3 and OGG
16 | - Fuzzy search
17 | - Vim-style key bindings
18 | - Mouse support
19 |
20 | ## 📦 Installation
21 | > I recommend a font with ligatures for the best experience.
22 |
23 | Download the latest [release](https://github.com/zX3no/gonk/releases/latest) and add some music.
24 |
25 | ```
26 | gonk add ~/Music
27 | ```
28 |
29 | ### Building from Source
30 |
31 | > Linux is currently unsupported.
32 |
33 | ```
34 | git clone https://github.com/zX3no/gonk
35 | cd gonk
36 | cargo install --path gonk
37 | gonk
38 | ```
39 |
40 | ## ⌨️ Key Bindings
41 |
42 | | Command | Key |
43 | | --------------------------- | ----------------- |
44 | | Move Up | `K / Up` |
45 | | Move Down | `J / Down` |
46 | | Move Left | `H / Left` |
47 | | Move Right | `L / Right` |
48 | | Volume Up | `W` |
49 | | Volume Down | `S` |
50 | | Mute | `Z` |
51 | | Play/Pause | `Space` |
52 | | Previous | `A` |
53 | | Next | `D` |
54 | | Seek -10s | `Q` |
55 | | Seek 10s | `E` |
56 | | Clear queue | `C` |
57 | | Clear except playing | `Shift + C` |
58 | | Select All | `Control + A` |
59 | | Add song to queue | `Enter` |
60 | | Add selection to playlist | `Shift + Enter` |
61 | | - | |
62 | | Queue | `1` |
63 | | Browser | `2` |
64 | | Playlists | `3` |
65 | | Settings | `4` |
66 | | Search | `/` |
67 | | Exit Search | `Escape \| Tab` |
68 | | - | |
69 | | Delete song/playlist | `X` |
70 | | Delete without confirmation | `Shift + X` |
71 | | - | |
72 | | Move song margin | `F1 / Shift + F1` |
73 | | Move album margin | `F2 / Shift + F2` |
74 | | Move artist margin | `F3 / Shift + F3` |
75 | | - | |
76 | | Update database | `U` |
77 | | Quit player | `Ctrl + C` |
78 |
79 | ## ⚒️ Troubleshooting
80 |
81 | - Gonk doesn't start after an update.
82 |
83 | Run `gonk reset` to reset your database.
84 | If this doesn't work, you can reset the database by deleting `%appdata%/gonk/` or `~/gonk` on linux.
85 |
86 | - If your music player has broken lines, increase your zoom level or font size.
87 |
88 | 
89 |
90 | ## ❤️ Contributing
91 |
92 | Feel free to open an issue or submit a pull request!
--------------------------------------------------------------------------------
/gonk.rdbg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zX3no/gonk/b7da5d3d4dbec8401a5df751314c9d42b1eb158a/gonk.rdbg
--------------------------------------------------------------------------------
/gonk/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "gonk"
3 | version = "0.2.0"
4 | edition = "2021"
5 | authors = ["Bay"]
6 | description = "A terminal music player"
7 | repository = "https://github.com/zX3no/gonk"
8 | readme = "../README.md"
9 | license = "CC0-1.0"
10 | default-run = "gonk"
11 |
12 | [features]
13 | profile = ["gonk_core/profile"]
14 | simd = ["gonk_core/simd"]
15 | info = ["gonk_player/info", "mini/info"]
16 | warn = ["gonk_player/warn", "mini/warn"]
17 | error = ["gonk_player/error", "mini/error"]
18 |
19 |
20 | [dependencies]
21 | rayon = "1.7.0"
22 | gonk_player = { version = "0.2.0", path = "../gonk_player" }
23 | gonk_core = { version = "0.2.0", path = "../gonk_core" }
24 | mini = { git = "https://github.com/zX3no/mini", version = "0.1.0" }
25 | winter = { version = "0.1.0", git = "https://github.com/zX3no/winter" }
26 | # winter = { version = "0.1.0", path = "../../winter" }
27 |
--------------------------------------------------------------------------------
/gonk/src/browser.rs:
--------------------------------------------------------------------------------
1 | use gonk_core::{vdb::Database, Album};
2 | use gonk_core::{Index, Song};
3 | use winter::*;
4 |
5 | #[derive(PartialEq, Eq)]
6 | pub enum Mode {
7 | Artist,
8 | Album,
9 | Song,
10 | }
11 |
12 | pub struct Browser {
13 | artists: Index,
14 | albums: Index,
15 | ///Title, (disc, track)
16 | songs: Index<(String, (u8, u8))>,
17 | pub mode: Mode,
18 | }
19 |
20 | impl Browser {
21 | pub fn new(db: &Database) -> Self {
22 | let artists = Index::new(db.artists().into_iter().cloned().collect(), Some(0));
23 | let mut albums: Index = Index::default();
24 | let mut songs = Index::default();
25 |
26 | if let Some(artist) = artists.selected() {
27 | albums = Index::from(db.albums_by_artist(artist));
28 | if let Some(album) = albums.selected() {
29 | songs = Index::from(
30 | album
31 | .songs
32 | .iter()
33 | .map(|song| {
34 | (
35 | format!("{}. {}", song.track_number, song.title),
36 | (song.disc_number, song.track_number),
37 | )
38 | })
39 | .collect::>(),
40 | );
41 | }
42 | }
43 |
44 | Self {
45 | artists,
46 | albums,
47 | songs,
48 | mode: Mode::Artist,
49 | }
50 | }
51 | }
52 |
53 | pub fn up(browser: &mut Browser, db: &Database, amount: usize) {
54 | match browser.mode {
55 | Mode::Artist => browser.artists.up_n(amount),
56 | Mode::Album => browser.albums.up_n(amount),
57 | Mode::Song => browser.songs.up_n(amount),
58 | }
59 | update(browser, db);
60 | }
61 |
62 | pub fn down(browser: &mut Browser, db: &Database, amount: usize) {
63 | match browser.mode {
64 | Mode::Artist => browser.artists.down_n(amount),
65 | Mode::Album => browser.albums.down_n(amount),
66 | Mode::Song => browser.songs.down_n(amount),
67 | }
68 | update(browser, db);
69 | }
70 |
71 | pub fn left(browser: &mut Browser) {
72 | match browser.mode {
73 | Mode::Artist => (),
74 | Mode::Album => browser.mode = Mode::Artist,
75 | Mode::Song => browser.mode = Mode::Album,
76 | }
77 | }
78 |
79 | pub fn right(browser: &mut Browser) {
80 | match browser.mode {
81 | Mode::Artist => browser.mode = Mode::Album,
82 | Mode::Album => browser.mode = Mode::Song,
83 | Mode::Song => (),
84 | }
85 | }
86 |
87 | pub fn draw(
88 | browser: &mut Browser,
89 | area: winter::Rect,
90 | buf: &mut winter::Buffer,
91 | mouse: Option<(u16, u16)>,
92 | ) {
93 | let size = area.width / 3;
94 | let rem = area.width % 3;
95 |
96 | let chunks = layout(
97 | area,
98 | Direction::Horizontal,
99 | &[
100 | Constraint::Length(size),
101 | Constraint::Length(size),
102 | Constraint::Length(size + rem),
103 | ],
104 | );
105 |
106 | if let Some((x, y)) = mouse {
107 | let rect = Rect {
108 | x,
109 | y,
110 | ..Default::default()
111 | };
112 | if rect.intersects(chunks[2]) {
113 | browser.mode = Mode::Song;
114 | } else if rect.intersects(chunks[1]) {
115 | browser.mode = Mode::Album;
116 | } else if rect.intersects(chunks[0]) {
117 | browser.mode = Mode::Artist;
118 | }
119 | }
120 |
121 | let artists: Vec<_> = browser.artists.iter().map(|a| lines!(a)).collect();
122 | let albums: Vec<_> = browser.albums.iter().map(|a| lines!(&a.title)).collect();
123 | let songs: Vec<_> = browser.songs.iter().map(|(s, _)| lines!(s)).collect();
124 |
125 | fn list<'a>(title: &'static str, items: Vec>, use_symbol: bool) -> List<'a> {
126 | let block = block().title(title.bold()).title_margin(1);
127 | let symbol = if use_symbol { ">" } else { " " };
128 | winter::list(&items).block(block).symbol(symbol)
129 | }
130 |
131 | let artists = list("Aritst", artists, browser.mode == Mode::Artist);
132 | let albums = list("Album", albums, browser.mode == Mode::Album);
133 | let songs = list("Song", songs, browser.mode == Mode::Song);
134 |
135 | artists.draw(chunks[0], buf, browser.artists.index());
136 | albums.draw(chunks[1], buf, browser.albums.index());
137 | songs.draw(chunks[2], buf, browser.songs.index());
138 | }
139 |
140 | pub fn refresh(browser: &mut Browser, db: &Database) {
141 | browser.mode = Mode::Artist;
142 |
143 | browser.artists = Index::new(db.artists().into_iter().cloned().collect(), Some(0));
144 | browser.albums = Index::default();
145 | browser.songs = Index::default();
146 |
147 | update_albums(browser, db);
148 | }
149 |
150 | pub fn update(browser: &mut Browser, db: &Database) {
151 | match browser.mode {
152 | Mode::Artist => update_albums(browser, db),
153 | Mode::Album => update_songs(browser, db),
154 | Mode::Song => (),
155 | }
156 | }
157 |
158 | pub fn update_albums(browser: &mut Browser, db: &Database) {
159 | //Update the album based on artist selection
160 | if let Some(artist) = browser.artists.selected() {
161 | browser.albums = Index::from(db.albums_by_artist(artist));
162 | update_songs(browser, db);
163 | }
164 | }
165 |
166 | pub fn update_songs(browser: &mut Browser, db: &Database) {
167 | if let Some(artist) = browser.artists.selected() {
168 | if let Some(album) = browser.albums.selected() {
169 | let songs: Vec<(String, (u8, u8))> = db
170 | .album(artist, &album.title)
171 | .songs
172 | .iter()
173 | .map(|song| {
174 | (
175 | format!("{}. {}", song.track_number, song.title),
176 | (song.disc_number, song.track_number),
177 | )
178 | })
179 | .collect();
180 | browser.songs = Index::from(songs);
181 | }
182 | }
183 | }
184 |
185 | pub fn get_selected(browser: &Browser, db: &Database) -> Vec {
186 | if let Some(artist) = browser.artists.selected() {
187 | if let Some(album) = browser.albums.selected() {
188 | if let Some((_, (disc, number))) = browser.songs.selected() {
189 | return match browser.mode {
190 | Mode::Artist => db
191 | .albums_by_artist(artist)
192 | .iter()
193 | .flat_map(|album| album.songs.iter().map(|song| song.clone().clone()))
194 | .collect(),
195 | Mode::Album => db.album(artist, &album.title).songs.to_vec(),
196 | Mode::Song => {
197 | vec![db.song(artist, &album.title, *disc, *number).clone()]
198 | }
199 | };
200 | }
201 | }
202 | }
203 | todo!()
204 | }
205 |
--------------------------------------------------------------------------------
/gonk/src/help.rs:
--------------------------------------------------------------------------------
1 | use crate::JUMP_AMOUNT;
2 | use std::sync::LazyLock;
3 | use winter::*;
4 |
5 | //TODO: Add scrolling to the help menu.
6 | //TODO: Improve visability, it's hard to tell which option matches which command.
7 | //TODO: Do I have a widget for adding lines?
8 | pub static HELP: LazyLock<[Row; 32]> = LazyLock::new(|| {
9 | [
10 | row!["Move Up".fg(Cyan), "K / UP"],
11 | row!["Move Down".fg(Cyan), "J / Down"],
12 | row!["Move Left".fg(Cyan), "H / Left"],
13 | row!["Move Right".fg(Cyan), "L / Right"],
14 | row![text!("Move Up {}", JUMP_AMOUNT).fg(Cyan), "Shift + K / UP"],
15 | row![
16 | text!("Move Down {}", JUMP_AMOUNT).fg(Cyan),
17 | "Shift + J / Down"
18 | ],
19 | row!["Volume Up".fg(Green), "W"],
20 | row!["Volume Down".fg(Green), "S"],
21 | row!["Mute".fg(Green), "Z"],
22 | row!["Play/Pause".fg(Magenta), "Space"],
23 | row!["Previous".fg(Magenta), "A"],
24 | row!["Next".fg(Magenta), "D"],
25 | row!["Seek -10s".fg(Magenta), "Q"],
26 | row!["Seek 10s".fg(Magenta), "E"],
27 | row!["Queue".fg(Blue), "1"],
28 | row!["Browser".fg(Blue), "2"],
29 | row!["Playlists".fg(Blue), "3"],
30 | row!["Settings".fg(Blue), "4"],
31 | row!["Search".fg(Blue), "/"],
32 | row!["Exit Search".fg(Blue), "Escape | Tab"],
33 | row!["Select all".fg(Cyan), "Control + A"],
34 | row!["Add song to queue".fg(Cyan), "Enter"],
35 | row!["Add selection to playlist".fg(Cyan), "Shift + Enter"],
36 | row!["Move song margin".fg(Green), "F1 / Shift + F1"],
37 | row!["Move album margin".fg(Green), "F2 / Shift + F2"],
38 | row!["Move artist margin".fg(Green), "F3 / Shift + F3"],
39 | row!["Update database".fg(Yellow), "U"],
40 | row!["Quit player".fg(Yellow), "Ctrl + C"],
41 | row!["Clear queue".fg(Red), "C"],
42 | row!["Clear except playing".fg(Red), "Shift + C"],
43 | row!["Delete song/playlist".fg(Red), "X"],
44 | row!["Delete without confirmation".fg(Red), "Shift + X"],
45 | ]
46 | });
47 |
--------------------------------------------------------------------------------
/gonk/src/main.rs:
--------------------------------------------------------------------------------
1 | use browser::Browser;
2 | use gonk_core::{vdb::*, *};
3 | use gonk_player::*;
4 | use mini::defer_results;
5 | use playlist::{Mode as PlaylistMode, Playlist};
6 | use queue::Queue;
7 | use search::{Mode as SearchMode, Search};
8 | use settings::Settings;
9 | use std::{
10 | fs,
11 | time::{Duration, Instant},
12 | };
13 | use winter::*;
14 |
15 | mod browser;
16 | mod help;
17 | mod playlist;
18 | mod queue;
19 | mod search;
20 | mod settings;
21 |
22 | const JUMP_AMOUNT: usize = 3;
23 | const FRAME_TIME: f32 = 1000.0 / 300.0;
24 |
25 | const NUMBER: Color = Color::Green;
26 | const TITLE: Color = Color::Cyan;
27 | const ALBUM: Color = Color::Magenta;
28 | const ARTIST: Color = Color::Blue;
29 | const SEEKER: Color = Color::White;
30 |
31 | #[derive(PartialEq, Eq, Clone)]
32 | pub enum Mode {
33 | Browser,
34 | Queue,
35 | Playlist,
36 | Settings,
37 | Search,
38 | }
39 |
40 | fn draw(
41 | winter: &mut Winter,
42 | mode: &Mode,
43 | browser: &mut Browser,
44 | settings: &Settings,
45 | queue: &mut Queue,
46 | playlist: &mut Playlist,
47 | search: &mut Search,
48 | cursor: &mut Option<(u16, u16)>,
49 | songs: &mut Index,
50 | db: &Database,
51 | mouse: Option<(u16, u16)>,
52 | help: bool,
53 | mute: bool,
54 | ) {
55 | let viewport = winter.viewport;
56 | let buf = winter.buffer();
57 | let area = if let Some(msg) = log::last_message() {
58 | let length = 3;
59 | let fill = viewport.height.saturating_sub(length);
60 | let area = layout(viewport, Vertical, &[Length(fill), Length(length)]);
61 | lines!(msg).block(block()).draw(area[1], buf);
62 | area[0]
63 | } else {
64 | viewport
65 | };
66 |
67 | //Hide the cursor when it's not needed.
68 | match mode {
69 | Mode::Search | Mode::Playlist => {}
70 | _ => *cursor = None,
71 | }
72 |
73 | match mode {
74 | Mode::Browser => browser::draw(browser, area, buf, mouse),
75 | Mode::Settings => settings::draw(settings, area, buf),
76 | Mode::Queue => queue::draw(queue, area, buf, mouse, songs, mute),
77 | Mode::Playlist => *cursor = playlist::draw(playlist, area, buf, mouse),
78 | Mode::Search => *cursor = search::draw(search, area, buf, mouse, db),
79 | }
80 |
81 | if help {
82 | if let Ok(area) = area.inner(8, 6) {
83 | let widths = [Constraint::Percentage(50), Constraint::Percentage(50)];
84 |
85 | //TODO: This is hard to read because the gap between command and key is large.
86 | let header = header!["Command".bold(), "Key".bold()];
87 | let table = table(help::HELP.clone(), &widths)
88 | .header(header)
89 | .block(block().title("Help:"));
90 | buf.clear(area);
91 | table.draw(area, buf, None);
92 | }
93 | }
94 | }
95 |
96 | fn path(mut path: String) -> Option {
97 | if path.contains("~") {
98 | path = path.replace("~", &user_profile_directory().unwrap());
99 | }
100 | fs::canonicalize(path).ok()
101 | }
102 |
103 | fn main() {
104 | defer_results!();
105 | let mut persist = gonk_core::settings::Settings::new().unwrap();
106 | let args: Vec = std::env::args().skip(1).collect();
107 | let mut scan_timer = Instant::now();
108 | let mut scan_handle = None;
109 |
110 | if !args.is_empty() {
111 | match args[0].as_str() {
112 | "add" => {
113 | if args.len() == 1 {
114 | return println!("Usage: gonk add ");
115 | }
116 |
117 | match path(args[1].clone()) {
118 | Some(path) if path.exists() => {
119 | persist.music_folder = path.to_string_lossy().to_string();
120 | scan_handle = Some(db::create(&persist.music_folder));
121 | scan_timer = Instant::now();
122 | }
123 | _ => return println!("Invalid path."),
124 | }
125 | }
126 | "reset" => {
127 | return match gonk_core::db::reset() {
128 | Ok(_) => println!("Database reset!"),
129 | Err(e) => println!("Failed to reset database! {e}"),
130 | };
131 | }
132 | "help" | "--help" => {
133 | println!("Usage");
134 | println!(" gonk [ ]");
135 | println!();
136 | println!("Options");
137 | println!(" add Add music to the library");
138 | println!(" reset Reset the database");
139 | println!(" buffer Set a custom ring buffer size");
140 | return;
141 | }
142 | "b" | "buffer" | "--buffer" | "--b" => match args.get(1) {
143 | Some(rb_size) => unsafe {
144 | gonk_player::RB_SIZE = rb_size.parse::().unwrap()
145 | },
146 | None => {
147 | println!("Please enter a valid ring buffer size `buffer `.");
148 | return;
149 | }
150 | },
151 | _ if !args.is_empty() => return println!("Invalid command."),
152 | _ => (),
153 | }
154 | }
155 |
156 | //Prevents panic messages from being hidden.
157 | let orig_hook = std::panic::take_hook();
158 | std::panic::set_hook(Box::new(move |panic_info| {
159 | let mut stdout = std::io::stdout();
160 | let mut stdin = std::io::stdin();
161 | uninit(&mut stdout, &mut stdin);
162 | orig_hook(panic_info);
163 | std::process::exit(1);
164 | }));
165 |
166 | let po = persist.output_device.clone();
167 | let thread = std::thread::spawn(move || {
168 | let device_list = devices();
169 | let default_device = default_device();
170 | let device = device_list
171 | .iter()
172 | .find(|d| d.name == po)
173 | .unwrap_or(&default_device)
174 | .clone();
175 | spawn_audio_threads(device.clone());
176 |
177 | Settings::new(device_list.clone(), device.name.clone())
178 | });
179 |
180 | let mut winter = Winter::new();
181 | let index = (!persist.queue.is_empty()).then_some(persist.index as usize);
182 |
183 | set_volume(persist.volume);
184 |
185 | let mut songs = Index::new(persist.queue.clone(), index);
186 | if let Some(song) = songs.selected() {
187 | play_song(song);
188 | pause();
189 | seek(persist.elapsed);
190 | }
191 |
192 | let mut db = Database::new();
193 | let mut browser = Browser::new(&db);
194 |
195 | //Everything here initialises quickly.
196 | let mut queue = Queue::new(index.unwrap_or(0));
197 | let mut playlist = Playlist::new().unwrap();
198 | let mut search = Search::new();
199 | let mut mode = Mode::Browser;
200 | let mut last_tick = Instant::now();
201 | let mut ft = Instant::now();
202 | let mut dots: usize = 1;
203 | let mut help = false;
204 | let mut prev_mode = Mode::Search; //Used for search.
205 | let mut mute = false;
206 | let mut old_volume = 0;
207 | let mut cursor: Option<(u16, u16)> = None;
208 | let mut shift;
209 | let mut control;
210 |
211 | let mut settings = thread.join().unwrap();
212 |
213 | //If there are songs in the queue and the database isn't scanning, display the queue.
214 | if !songs.is_empty() && scan_handle.is_none() {
215 | mode = Mode::Queue;
216 | }
217 |
218 | macro_rules! up {
219 | () => {{
220 | let amount = if shift { JUMP_AMOUNT } else { 1 };
221 | match mode {
222 | Mode::Browser => browser::up(&mut browser, &db, amount),
223 | Mode::Queue => queue::up(&mut queue, &mut songs, amount),
224 | Mode::Playlist => playlist::up(&mut playlist, amount),
225 | Mode::Settings => settings::up(&mut settings, amount),
226 | Mode::Search => search.results.up_n(amount),
227 | }
228 | }};
229 | }
230 |
231 | macro_rules! down {
232 | () => {{
233 | let amount = if shift { JUMP_AMOUNT } else { 1 };
234 | match mode {
235 | Mode::Browser => browser::down(&mut browser, &db, amount),
236 | Mode::Queue => queue::down(&mut queue, &mut songs, amount),
237 | Mode::Playlist => playlist::down(&mut playlist, amount),
238 | Mode::Settings => settings::down(&mut settings, amount),
239 | Mode::Search => search.results.down_n(amount),
240 | }
241 | }};
242 | }
243 |
244 | macro_rules! left {
245 | () => {
246 | match mode {
247 | Mode::Browser => browser::left(&mut browser),
248 | Mode::Playlist => playlist::left(&mut playlist),
249 | _ => {}
250 | }
251 | };
252 | }
253 |
254 | macro_rules! right {
255 | () => {
256 | match mode {
257 | Mode::Browser => browser::right(&mut browser),
258 | Mode::Playlist => playlist::right(&mut playlist),
259 | _ => {}
260 | }
261 | };
262 | }
263 |
264 | 'outer: loop {
265 | if let Some(handle) = &scan_handle {
266 | if handle.is_finished() {
267 | let handle = scan_handle.take().unwrap();
268 | let result = handle.join().unwrap();
269 |
270 | db = Database::new();
271 | log::clear();
272 |
273 | match result {
274 | db::ScanResult::Completed => {
275 | log!(
276 | "Finished adding {} files in {:.2} seconds.",
277 | db.len,
278 | scan_timer.elapsed().as_secs_f32()
279 | );
280 | }
281 | db::ScanResult::CompletedWithErrors(errors) => {
282 | let dir = "See %appdata%/gonk/gonk.log for details.";
283 | let len = errors.len();
284 | let s = if len == 1 { "" } else { "s" };
285 |
286 | log!(
287 | "Added {} files with {len} error{s}. {dir}",
288 | db.len.saturating_sub(len)
289 | );
290 |
291 | let path = gonk_path().join("gonk.log");
292 | let errors = errors.join("\n");
293 | fs::write(path, errors).unwrap();
294 | }
295 | db::ScanResult::FileInUse => {
296 | log!("Could not update database, file in use.")
297 | }
298 | }
299 |
300 | browser::refresh(&mut browser, &db);
301 | search.results = Index::new(db.search(&search.query), None);
302 |
303 | //No need to reset scan_timer since it's reset with new scans.
304 | scan_handle = None;
305 | }
306 | }
307 |
308 | if last_tick.elapsed() >= Duration::from_millis(150) {
309 | if scan_handle.is_some() {
310 | if dots < 3 {
311 | dots += 1;
312 | } else {
313 | dots = 1;
314 | }
315 | log!(
316 | "Scanning {} for files{}",
317 | //Remove the UNC \\?\ from the path.
318 | &persist.music_folder.replace("\\\\?\\", ""),
319 | ".".repeat(dots)
320 | );
321 | }
322 |
323 | //Update the time elapsed.
324 | persist.index = songs.index().unwrap_or(0) as u16;
325 | persist.elapsed = elapsed().as_secs_f32();
326 | persist.queue = songs.to_vec();
327 | persist.save().unwrap();
328 |
329 | //Update the list of output devices
330 | settings.devices = devices();
331 | let mut index = settings.index.unwrap_or(0);
332 | if index >= settings.devices.len() {
333 | index = settings.devices.len().saturating_sub(1);
334 | settings.index = Some(index);
335 | }
336 |
337 | last_tick = Instant::now();
338 | }
339 |
340 | //Play the next song if the current is finished.
341 | if gonk_player::play_next() && !songs.is_empty() {
342 | songs.down();
343 | if let Some(song) = songs.selected() {
344 | play_song(song);
345 | }
346 | }
347 |
348 | let input_playlist = playlist.mode == PlaylistMode::Popup && mode == Mode::Playlist;
349 | let empty = songs.is_empty();
350 |
351 | draw(
352 | &mut winter,
353 | &mode,
354 | &mut browser,
355 | &settings,
356 | &mut queue,
357 | &mut playlist,
358 | &mut search,
359 | &mut cursor,
360 | &mut songs,
361 | &db,
362 | None,
363 | help,
364 | mute,
365 | );
366 |
367 | 'events: {
368 | let Some((event, state)) = winter.poll() else {
369 | break 'events;
370 | };
371 |
372 | shift = state.shift();
373 | control = state.control();
374 |
375 | match event {
376 | Event::LeftMouse(x, y) if !help => {
377 | draw(
378 | &mut winter,
379 | &mode,
380 | &mut browser,
381 | &settings,
382 | &mut queue,
383 | &mut playlist,
384 | &mut search,
385 | &mut cursor,
386 | &mut songs,
387 | &db,
388 | Some((x, y)),
389 | help,
390 | mute,
391 | );
392 | }
393 | Event::ScrollUp => up!(),
394 | Event::ScrollDown => down!(),
395 | Event::Backspace if mode == Mode::Playlist => {
396 | playlist::on_backspace(&mut playlist, control);
397 | }
398 | Event::Char('c') if control => break 'outer,
399 | Event::Char('?') | Event::Char('/') | Event::Escape if help => help = false,
400 | Event::Char('?') if mode != Mode::Search => help = true,
401 | Event::Char('/') => {
402 | if mode != Mode::Search {
403 | prev_mode = mode;
404 | mode = Mode::Search;
405 | search.query_changed = true;
406 | } else {
407 | match search.mode {
408 | SearchMode::Search if search.query.is_empty() => {
409 | mode = prev_mode.clone();
410 | }
411 | SearchMode::Search => {
412 | search.query.push('/');
413 | search.query_changed = true;
414 | }
415 | SearchMode::Select => {
416 | search.mode = SearchMode::Search;
417 | search.results.select(None);
418 | }
419 | }
420 | }
421 | }
422 | Event::Char('a') if control => {
423 | queue.range = Some(0..songs.len());
424 | }
425 | Event::Backspace if mode == Mode::Search => {
426 | search::on_backspace(&mut search, control, shift);
427 | }
428 | //Handle ^W as control backspace.
429 | Event::Char('w') if control && mode == Mode::Search => {
430 | search::on_backspace(&mut search, control, shift);
431 | }
432 | Event::Char(c) if search.mode == SearchMode::Search && mode == Mode::Search => {
433 | search.query.push(c);
434 | search.query_changed = true;
435 | }
436 | Event::Escape if mode == Mode::Search => {
437 | search.query = String::new();
438 | search.query_changed = true;
439 | search.mode = SearchMode::Search;
440 | mode = prev_mode.clone();
441 | search.results.select(None);
442 | }
443 | Event::Tab if mode == Mode::Search => {
444 | mode = prev_mode.clone();
445 | }
446 | Event::Char(c) if input_playlist => {
447 | if control && c == 'w' {
448 | playlist::on_backspace(&mut playlist, true);
449 | } else {
450 | playlist.changed = true;
451 | playlist.search_query.push(c);
452 | }
453 | }
454 | Event::Char(' ') => toggle_playback(),
455 | Event::Char('C') => {
456 | clear_except_playing(&mut songs);
457 | queue.set_index(0);
458 | }
459 | Event::Char('c') => {
460 | gonk_player::clear(&mut songs);
461 | }
462 | Event::Char('x') => match mode {
463 | Mode::Queue => {
464 | if let Some(i) = queue.index() {
465 | gonk_player::delete(&mut songs, i);
466 |
467 | //Sync the UI index.
468 | let len = songs.len().saturating_sub(1);
469 | if i > len {
470 | queue.set_index(len);
471 | }
472 | }
473 | }
474 | Mode::Playlist => {
475 | playlist::delete(&mut playlist, false);
476 | }
477 | _ => (),
478 | },
479 | //Force delete -> Shift + X.
480 | Event::Char('X') if mode == Mode::Playlist => playlist::delete(&mut playlist, true),
481 | Event::Char('u') if mode == Mode::Browser || mode == Mode::Playlist => {
482 | if scan_handle.is_none() {
483 | if persist.music_folder.is_empty() {
484 | gonk_core::log!("Nothing to scan! Add a folder with 'gonk add /path/'");
485 | } else {
486 | scan_handle = Some(db::create(&persist.music_folder));
487 | scan_timer = Instant::now();
488 | playlist.lists = Index::from(gonk_core::playlist::playlists());
489 | }
490 | }
491 | }
492 | Event::Char('z') => {
493 | if mute {
494 | mute = false;
495 | set_volume(old_volume)
496 | } else {
497 | mute = true;
498 | old_volume = get_volume();
499 | set_volume(0);
500 | }
501 | }
502 | Event::Char('q') => seek_backward(),
503 | Event::Char('e') => seek_foward(),
504 | Event::Char('a') => {
505 | songs.up();
506 | if let Some(song) = songs.selected() {
507 | play_song(song);
508 | }
509 | }
510 | Event::Char('d') => {
511 | songs.down();
512 | if let Some(song) = songs.selected() {
513 | play_song(song);
514 | }
515 | }
516 | Event::Char('w') => {
517 | volume_up();
518 | persist.volume = get_volume();
519 | }
520 | Event::Char('s') => {
521 | volume_down();
522 | persist.volume = get_volume();
523 | }
524 | Event::Escape if mode == Mode::Playlist => {
525 | if playlist.delete {
526 | playlist.yes = true;
527 | playlist.delete = false;
528 | } else if let playlist::Mode::Popup = playlist.mode {
529 | playlist.mode = playlist::Mode::Playlist;
530 | playlist.search_query = String::new();
531 | playlist.changed = true;
532 | }
533 | }
534 | Event::Tab if mode != Mode::Search => {
535 | prev_mode = mode.clone();
536 | mode = Mode::Search;
537 | }
538 | Event::Enter if mode == Mode::Browser && shift => {
539 | playlist::add(&mut playlist, browser::get_selected(&browser, &db));
540 | mode = Mode::Playlist
541 | }
542 | Event::Enter if mode == Mode::Browser => {
543 | songs.extend(browser::get_selected(&browser, &db));
544 | }
545 | Event::Enter if mode == Mode::Queue && shift => {
546 | if let Some(range) = &queue.range {
547 | let mut playlist_songs = Vec::new();
548 |
549 | for index in range.start..=range.end {
550 | if let Some(song) = songs.get(index) {
551 | playlist_songs.push(song.clone());
552 | }
553 | }
554 |
555 | playlist::add(&mut playlist, playlist_songs);
556 | mode = Mode::Playlist;
557 | }
558 | }
559 | Event::Enter if mode == Mode::Queue => {
560 | if let Some(i) = queue.index() {
561 | songs.select(Some(i));
562 | play_song(&songs[i]);
563 | }
564 | }
565 | Event::Enter if mode == Mode::Settings => {
566 | if let Some(device) = settings::selected(&settings) {
567 | let device = device.to_string();
568 | set_output_device(&device);
569 | settings.current_device = device.clone();
570 | persist.output_device = device.clone();
571 | }
572 | }
573 | Event::Enter if mode == Mode::Playlist => {
574 | playlist::on_enter(&mut playlist, &mut songs, shift);
575 | }
576 | Event::Enter if mode == Mode::Search && shift => {
577 | if let Some(songs) = search::on_enter(&mut search, &db) {
578 | playlist::add(
579 | &mut playlist,
580 | songs.iter().map(|song| song.clone().clone()).collect(),
581 | );
582 | mode = Mode::Playlist;
583 | }
584 | }
585 | Event::Enter if mode == Mode::Search => {
586 | if let Some(s) = search::on_enter(&mut search, &db) {
587 | //Swap to the queue so people can see what they added.
588 | mode = Mode::Queue;
589 | songs.extend(s.iter().cloned());
590 | }
591 | }
592 | Event::Char('1') => mode = Mode::Queue,
593 | Event::Char('2') => mode = Mode::Browser,
594 | Event::Char('3') => mode = Mode::Playlist,
595 | Event::Char('4') => mode = Mode::Settings,
596 | Event::Function(1) => queue::constraint(&mut queue, 0, shift),
597 | Event::Function(2) => queue::constraint(&mut queue, 1, shift),
598 | Event::Function(3) => queue::constraint(&mut queue, 2, shift),
599 | Event::Up | Event::Char('k') | Event::Char('K') => up!(),
600 | Event::Down | Event::Char('j') | Event::Char('J') => down!(),
601 | Event::Left | Event::Char('h') | Event::Char('H') => left!(),
602 | Event::Right | Event::Char('l') | Event::Char('L') => right!(),
603 | _ => {}
604 | }
605 | }
606 |
607 | //New songs were added.
608 | if empty && !songs.is_empty() {
609 | queue.set_index(0);
610 | songs.select(Some(0));
611 | if let Some(song) = songs.selected() {
612 | play_song(song);
613 | }
614 | }
615 |
616 | winter.draw();
617 |
618 | //Move cursor
619 | if let Some((x, y)) = cursor {
620 | show_cursor(&mut winter.stdout);
621 | move_to(&mut winter.stdout, x, y);
622 | } else {
623 | hide_cursor(&mut winter.stdout);
624 | }
625 |
626 | winter.flush().unwrap();
627 |
628 | let frame = ft.elapsed().as_secs_f32() * 1000.0;
629 | if frame < FRAME_TIME {
630 | std::thread::sleep(Duration::from_secs_f32((FRAME_TIME - frame) / 1000.0));
631 | ft = Instant::now();
632 | } else {
633 | ft = Instant::now();
634 | }
635 | }
636 |
637 | persist.queue = songs.to_vec();
638 | persist.index = songs.index().unwrap_or(0) as u16;
639 | persist.elapsed = elapsed().as_secs_f32();
640 | persist.save().unwrap();
641 | }
642 |
--------------------------------------------------------------------------------
/gonk/src/playlist.rs:
--------------------------------------------------------------------------------
1 | use crate::{ALBUM, ARTIST, TITLE};
2 | use gonk_core::{Index, Song};
3 | use std::{error::Error, mem};
4 | use winter::*;
5 |
6 | #[derive(PartialEq, Eq)]
7 | pub enum Mode {
8 | Playlist,
9 | Song,
10 | Popup,
11 | }
12 |
13 | pub struct Playlist {
14 | pub mode: Mode,
15 | pub lists: Index,
16 | pub song_buffer: Vec,
17 | pub search_query: String,
18 | pub search_result: Box>,
19 | pub changed: bool,
20 | pub delete: bool,
21 | pub yes: bool,
22 | }
23 |
24 | impl Playlist {
25 | pub fn new() -> std::result::Result> {
26 | Ok(Self {
27 | mode: Mode::Playlist,
28 | lists: Index::from(gonk_core::playlist::playlists()),
29 | song_buffer: Vec::new(),
30 | changed: false,
31 | search_query: String::new(),
32 | search_result: Box::new("Enter a playlist name...".into()),
33 | delete: false,
34 | yes: true,
35 | })
36 | }
37 | }
38 |
39 | pub fn up(playlist: &mut Playlist, amount: usize) {
40 | if !playlist.delete {
41 | match playlist.mode {
42 | Mode::Playlist => {
43 | playlist.lists.up_n(amount);
44 | }
45 | Mode::Song => {
46 | if let Some(selected) = playlist.lists.selected_mut() {
47 | selected.songs.up_n(amount);
48 | }
49 | }
50 | Mode::Popup => (),
51 | }
52 | }
53 | }
54 |
55 | pub fn down(playlist: &mut Playlist, amount: usize) {
56 | if !playlist.delete {
57 | match playlist.mode {
58 | Mode::Playlist => {
59 | playlist.lists.down_n(amount);
60 | }
61 | Mode::Song => {
62 | if let Some(selected) = playlist.lists.selected_mut() {
63 | selected.songs.down_n(amount);
64 | }
65 | }
66 | Mode::Popup => (),
67 | }
68 | }
69 | }
70 |
71 | pub fn left(playlist: &mut Playlist) {
72 | if playlist.delete {
73 | playlist.yes = true;
74 | } else if let Mode::Song = playlist.mode {
75 | playlist.mode = Mode::Playlist;
76 | }
77 | }
78 |
79 | pub fn right(playlist: &mut Playlist) {
80 | if playlist.delete {
81 | playlist.yes = false;
82 | } else {
83 | match playlist.mode {
84 | Mode::Playlist if playlist.lists.selected().is_some() => playlist.mode = Mode::Song,
85 | _ => (),
86 | }
87 | }
88 | }
89 |
90 | pub fn on_backspace(playlist: &mut Playlist, control: bool) {
91 | match playlist.mode {
92 | Mode::Popup => {
93 | playlist.changed = true;
94 | if control {
95 | playlist.search_query.clear();
96 | let trim = playlist.search_query.trim_end();
97 | let end = trim.chars().rev().position(|c| c == ' ');
98 | if let Some(end) = end {
99 | playlist.search_query = trim[..trim.len() - end].to_string();
100 | } else {
101 | playlist.search_query.clear();
102 | }
103 | } else {
104 | playlist.search_query.pop();
105 | }
106 | }
107 | _ => left(playlist),
108 | }
109 | }
110 |
111 | pub fn on_enter_shift(playlist: &mut Playlist) {
112 | match playlist.mode {
113 | Mode::Playlist => {
114 | if let Some(selected) = playlist.lists.selected() {
115 | add(playlist, selected.songs.clone());
116 | }
117 | }
118 | Mode::Song => {
119 | if let Some(selected) = playlist.lists.selected() {
120 | if let Some(song) = selected.songs.selected() {
121 | add(playlist, vec![song.clone()]);
122 | }
123 | }
124 | }
125 | //Do nothing
126 | Mode::Popup => {}
127 | }
128 | }
129 |
130 | pub fn on_enter(playlist: &mut Playlist, songs: &mut Index, shift: bool) {
131 | if shift {
132 | return on_enter_shift(playlist);
133 | }
134 |
135 | //No was selected by the user.
136 | if playlist.delete && !playlist.yes {
137 | playlist.yes = true;
138 | return playlist.delete = false;
139 | }
140 |
141 | match playlist.mode {
142 | Mode::Playlist if playlist.delete => delete_playlist(playlist),
143 | Mode::Song if playlist.delete => delete_song(playlist),
144 | Mode::Playlist => {
145 | if let Some(selected) = playlist.lists.selected() {
146 | songs.extend(selected.songs.clone());
147 | }
148 | }
149 | Mode::Song => {
150 | if let Some(selected) = playlist.lists.selected() {
151 | if let Some(song) = selected.songs.selected() {
152 | songs.push(song.clone());
153 | }
154 | }
155 | }
156 | Mode::Popup if !playlist.song_buffer.is_empty() => {
157 | //Find the index of the playlist
158 | let name = playlist.search_query.trim().to_string();
159 | let pos = playlist.lists.iter().position(|p| p.name() == name);
160 |
161 | let songs = mem::take(&mut playlist.song_buffer);
162 |
163 | //If the playlist exists
164 | if let Some(pos) = pos {
165 | let pl = &mut playlist.lists[pos];
166 | pl.songs.extend(songs);
167 | pl.songs.select(Some(0));
168 | pl.save().unwrap();
169 | playlist.lists.select(Some(pos));
170 | } else {
171 | //If the playlist does not exist create it.
172 | let len = playlist.lists.len();
173 | playlist.lists.push(gonk_core::Playlist::new(&name, songs));
174 | playlist.lists[len].save().unwrap();
175 | playlist.lists.select(Some(len));
176 | }
177 |
178 | //Reset everything.
179 | playlist.search_query = String::new();
180 | playlist.mode = Mode::Playlist;
181 | }
182 | Mode::Popup => (),
183 | }
184 | }
185 |
186 | pub fn draw(
187 | playlist: &mut Playlist,
188 | area: winter::Rect,
189 | buf: &mut winter::Buffer,
190 | mouse: Option<(u16, u16)>,
191 | ) -> Option<(u16, u16)> {
192 | let horizontal = layout(
193 | area,
194 | Direction::Horizontal,
195 | &[Constraint::Percentage(30), Constraint::Percentage(70)],
196 | );
197 |
198 | if let Some((x, y)) = mouse {
199 | let rect = Rect {
200 | x,
201 | y,
202 | ..Default::default()
203 | };
204 |
205 | //Don't let the user change modes while adding songs.
206 | if playlist.mode != Mode::Popup {
207 | if rect.intersects(horizontal[1]) {
208 | playlist.mode = Mode::Song;
209 | } else if rect.intersects(horizontal[0]) {
210 | playlist.mode = Mode::Playlist;
211 | }
212 | }
213 | }
214 |
215 | let items: Vec> = playlist.lists.iter().map(|p| lines!(p.name())).collect();
216 | let symbol = if let Mode::Playlist = playlist.mode {
217 | ">"
218 | } else {
219 | ""
220 | };
221 |
222 | list(&items)
223 | .block(block().title("Playlist").title_margin(1))
224 | .symbol(symbol)
225 | .draw(horizontal[0], buf, playlist.lists.index());
226 |
227 | let song_block = block().title("Songs").title_margin(1);
228 | if let Some(selected) = playlist.lists.selected() {
229 | let rows: Vec<_> = selected
230 | .songs
231 | .iter()
232 | .map(|song| {
233 | row![
234 | song.title.as_str().fg(TITLE),
235 | song.album.as_str().fg(ALBUM),
236 | song.artist.as_str().fg(ARTIST)
237 | ]
238 | })
239 | .collect();
240 |
241 | let symbol = if playlist.mode == Mode::Song { ">" } else { "" };
242 | let table = table(
243 | rows,
244 | &[
245 | Constraint::Percentage(42),
246 | Constraint::Percentage(30),
247 | Constraint::Percentage(28),
248 | ],
249 | )
250 | .symbol(symbol)
251 | .block(song_block);
252 | table.draw(horizontal[1], buf, selected.songs.index());
253 | } else {
254 | song_block.draw(horizontal[1], buf);
255 | }
256 |
257 | if playlist.delete {
258 | if let Ok(area) = area.centered(20, 5) {
259 | let v = layout(
260 | area,
261 | Direction::Vertical,
262 | &[Constraint::Length(3), Constraint::Percentage(90)],
263 | );
264 | let h = layout(
265 | v[1],
266 | Direction::Horizontal,
267 | &[Constraint::Percentage(50), Constraint::Percentage(50)],
268 | );
269 |
270 | let (yes, no) = if playlist.yes {
271 | (underlined(), fg(BrightBlack).dim())
272 | } else {
273 | (fg(BrightBlack).dim().underlined(), underlined())
274 | };
275 |
276 | let delete_msg = if let Mode::Playlist = playlist.mode {
277 | "Delete playlist?"
278 | } else {
279 | "Delete song?"
280 | };
281 |
282 | buf.clear(area);
283 |
284 | lines!(delete_msg)
285 | .block(block().borders(Borders::TOP | Borders::LEFT | Borders::RIGHT))
286 | .align(Center)
287 | .draw(v[0], buf);
288 |
289 | lines!("Yes".style(yes))
290 | .block(block().borders(Borders::LEFT | Borders::BOTTOM))
291 | .align(Center)
292 | .draw(h[0], buf);
293 |
294 | lines!("No".style(no))
295 | .block(block().borders(Borders::RIGHT | Borders::BOTTOM))
296 | .align(Center)
297 | .draw(h[1], buf);
298 | }
299 | } else if let Mode::Popup = playlist.mode {
300 | //TODO: I think I want a different popup.
301 | //It should be a small side bar in the browser.
302 | //There should be a list of existing playlists.
303 | //The first playlist will be the one you just added to
304 | //so it's fast to keep adding things
305 | //The last item will be add a new playlist.
306 | //If there are no playlists it will prompt you to create on.
307 | //This should be similar to foobar on android.
308 |
309 | //TODO: Renaming
310 | //Move items around in lists
311 | //There should be a hotkey to add to most recent playlist
312 | //And a message should show up in the bottom bar saying
313 | //"[name] has been has been added to [playlist name]"
314 | //or
315 | //"25 songs have been added to [playlist name]"
316 |
317 | let Ok(area) = area.centered(45, 6) else {
318 | return None;
319 | };
320 |
321 | buf.clear(area);
322 |
323 | block()
324 | .title("Add to playlist")
325 | .title_margin(1)
326 | .draw(area, buf);
327 |
328 | let v = layout_margin(area, Direction::Vertical, &[Length(3), Length(1)], (1, 1)).unwrap();
329 |
330 | lines!(playlist.search_query.as_str())
331 | .block(block())
332 | .scroll()
333 | .draw(v[0], buf);
334 |
335 | if playlist.changed {
336 | playlist.changed = false;
337 | let target_playlist = playlist.lists.iter().find_map(|p| {
338 | if p.name().to_ascii_lowercase() == playlist.search_query.to_ascii_lowercase() {
339 | Some(p.name())
340 | } else {
341 | None
342 | }
343 | });
344 |
345 | let add_line = if let Some(target_playlist) = target_playlist {
346 | lines!(
347 | "Add to ",
348 | "existing".underlined(),
349 | format!(" playlist: {}", target_playlist)
350 | )
351 | } else if playlist.search_query.is_empty() {
352 | "Enter a playlist name...".into()
353 | } else {
354 | lines!(
355 | "Add to ",
356 | "new".underlined(),
357 | format!(" playlist: {}", playlist.search_query)
358 | )
359 | };
360 |
361 | playlist.search_result = Box::new(add_line);
362 | }
363 |
364 | if let Ok(area) = v[1].inner(1, 0) {
365 | playlist.search_result.draw(area, buf);
366 | }
367 |
368 | //Draw the cursor.
369 | let (x, y) = (v[0].x + 2, v[0].y + 2);
370 | if playlist.search_query.is_empty() {
371 | return Some((x, y));
372 | } else {
373 | let width = v[0].width.saturating_sub(3);
374 | if playlist.search_query.len() < width as usize {
375 | return Some((x + (playlist.search_query.len() as u16), y));
376 | } else {
377 | return Some((x + width, y));
378 | }
379 | }
380 | }
381 |
382 | None
383 | }
384 |
385 | pub fn add(playlist: &mut Playlist, songs: Vec) {
386 | playlist.song_buffer = songs;
387 | playlist.mode = Mode::Popup;
388 | }
389 |
390 | fn delete_song(playlist: &mut Playlist) {
391 | if let Some(i) = playlist.lists.index() {
392 | let selected = &mut playlist.lists[i];
393 |
394 | if let Some(j) = selected.songs.index() {
395 | selected.songs.remove(j);
396 | selected.save().unwrap();
397 |
398 | //If there are no songs left delete the playlist.
399 | if selected.songs.is_empty() {
400 | selected.delete();
401 | playlist.lists.remove_and_move(i);
402 | playlist.mode = Mode::Playlist;
403 | }
404 | }
405 | playlist.delete = false;
406 | }
407 | }
408 |
409 | fn delete_playlist(playlist: &mut Playlist) {
410 | if let Some(index) = playlist.lists.index() {
411 | playlist.lists[index].delete();
412 | playlist.lists.remove_and_move(index);
413 | playlist.delete = false;
414 | }
415 | }
416 |
417 | pub fn delete(playlist: &mut Playlist, shift: bool) {
418 | match playlist.mode {
419 | Mode::Playlist if shift => delete_playlist(playlist),
420 | Mode::Song if shift => delete_song(playlist),
421 | Mode::Playlist | Mode::Song => {
422 | playlist.delete = true;
423 | }
424 | Mode::Popup => (),
425 | }
426 | }
427 |
--------------------------------------------------------------------------------
/gonk/src/queue.rs:
--------------------------------------------------------------------------------
1 | use crate::{ALBUM, ARTIST, NUMBER, SEEKER, TITLE};
2 | use core::ops::Range;
3 | use gonk_core::{log, Index, Song};
4 | use winter::*;
5 |
6 | pub struct Queue {
7 | pub constraint: [u16; 4],
8 | //TODO: This doesn't remember the previous index after a selection.
9 | //So if you had song 5 selected, pressed selected all, then pressed down.
10 | //It would selected song 2, not song 6 like it should.
11 | //Select all should be a temporay operation.
12 | pub range: Option>,
13 | }
14 |
15 | impl Queue {
16 | pub fn set_index(&mut self, index: usize) {
17 | self.range = Some(index..index);
18 | }
19 | pub fn index(&self) -> Option {
20 | match &self.range {
21 | Some(range) => Some(range.start),
22 | None => None,
23 | }
24 | }
25 | pub fn new(index: usize) -> Self {
26 | Self {
27 | constraint: [6, 37, 31, 26],
28 | range: Some(index..index),
29 | }
30 | }
31 | }
32 |
33 | #[cfg(test)]
34 | mod tests {
35 | use gonk_core::*;
36 |
37 | #[test]
38 | fn test() {
39 | //index is zero indexed, length is not.
40 | assert_eq!(up(10, 1, 1), 0);
41 | assert_eq!(up(8, 7, 5), 2);
42 |
43 | //7, 6, 5, 4, 3
44 | assert_eq!(up(8, 0, 5), 3);
45 |
46 | assert_eq!(down(8, 7, 5), 4);
47 |
48 | assert_eq!(down(8, 1, 5), 6);
49 | }
50 | }
51 |
52 | pub fn up(queue: &mut Queue, songs: &mut Index, amount: usize) {
53 | if let Some(range) = &mut queue.range {
54 | if range.start != range.end && range.start == 0 {
55 | //If the user selectes every song.
56 | //The range.start will be 0 so moving up once will go to the end.
57 | //This is not really the desired behaviour.
58 | //Just set the index to 0 when finished with selection.
59 | *range = 0..0;
60 | return;
61 | };
62 |
63 | let index = range.start;
64 | let new_index = gonk_core::up(songs.len(), index, amount);
65 |
66 | //This will override and ranges and just set the position
67 | //to a single index.
68 | *range = new_index..new_index;
69 | }
70 | }
71 |
72 | pub fn down(queue: &mut Queue, songs: &Index, amount: usize) {
73 | if let Some(range) = &mut queue.range {
74 | let index = range.start;
75 | let new_index = gonk_core::down(songs.len(), index, amount);
76 |
77 | //This will override and ranges and just set the position
78 | //to a single index.
79 | *range = new_index..new_index;
80 | }
81 | }
82 |
83 | pub fn draw(
84 | queue: &mut Queue,
85 | viewport: winter::Rect,
86 | buf: &mut winter::Buffer,
87 | mouse: Option<(u16, u16)>,
88 | songs: &mut Index,
89 | mute: bool,
90 | ) {
91 | let fill = viewport.height.saturating_sub(3 + 3);
92 | let area = layout(
93 | viewport,
94 | Direction::Vertical,
95 | &[
96 | Constraint::Length(3),
97 | Constraint::Length(fill),
98 | Constraint::Length(3),
99 | // Constraint::Length(3),
100 | ],
101 | );
102 |
103 | //Header
104 | block()
105 | .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
106 | .title(if songs.is_empty() {
107 | "Stopped"
108 | } else if gonk_player::is_paused() {
109 | "Paused"
110 | } else {
111 | "Playing"
112 | })
113 | .title_margin(1)
114 | .draw(area[0], buf);
115 |
116 | if !songs.is_empty() {
117 | //Title
118 | if let Some(song) = songs.selected() {
119 | let mut artist = song.artist.trim_end().to_string();
120 | let mut album = song.album.trim_end().to_string();
121 | let mut title = song.title.trim_end().to_string();
122 | let max_width = area[0].width.saturating_sub(30) as usize;
123 | let separator_width = "-| - |-".width();
124 |
125 | if max_width == 0 || max_width < separator_width {
126 | return;
127 | }
128 |
129 | while artist.width() + album.width() + separator_width > max_width {
130 | if artist.width() > album.width() {
131 | artist.pop();
132 | } else {
133 | album.pop();
134 | }
135 | }
136 |
137 | while title.width() > max_width {
138 | title.pop();
139 | }
140 |
141 | let n = title
142 | .width()
143 | .saturating_sub(artist.width() + album.width() + 3);
144 | let rem = n % 2;
145 | let pad_front = " ".repeat(n / 2);
146 | let pad_back = " ".repeat(n / 2 + rem);
147 |
148 | let top = lines![
149 | text!("─│ {}", pad_front),
150 | artist.fg(ARTIST),
151 | " ─ ",
152 | album.fg(ALBUM),
153 | text!("{} │─", pad_back)
154 | ];
155 | top.align(Center).draw(area[0], buf);
156 |
157 | let bottom = lines!(title.fg(TITLE));
158 | let mut area = area[0];
159 | if area.height > 1 {
160 | area.y += 1;
161 | bottom.align(Center).draw(area, buf)
162 | }
163 | }
164 | }
165 |
166 | let volume: Line<'_> = if mute {
167 | "Mute─╮".into()
168 | } else {
169 | text!("Vol: {}%─╮", gonk_player::get_volume()).into()
170 | };
171 | volume.align(Right).draw(area[0], buf);
172 |
173 | let mut row_bounds = None;
174 |
175 | //Body
176 | if songs.is_empty() {
177 | let block = if log::last_message().is_some() {
178 | block().borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
179 | } else {
180 | block().borders(Borders::LEFT | Borders::RIGHT)
181 | };
182 | block.draw(area[1], buf);
183 | } else {
184 | let mut rows: Vec = songs
185 | .iter()
186 | .map(|song| {
187 | row![
188 | text!(),
189 | song.track_number.to_string().fg(NUMBER),
190 | song.title.as_str().fg(TITLE),
191 | song.album.as_str().fg(ALBUM),
192 | song.artist.as_str().fg(ARTIST)
193 | ]
194 | })
195 | .collect();
196 |
197 | 'selection: {
198 | let Some(playing_index) = songs.index() else {
199 | break 'selection;
200 | };
201 |
202 | let Some(song) = songs.get(playing_index) else {
203 | break 'selection;
204 | };
205 |
206 | let Some(user_range) = &queue.range else {
207 | break 'selection;
208 | };
209 |
210 | if playing_index != user_range.start {
211 | //Currently playing song and not selected.
212 | //Has arrow and standard colors.
213 | rows[playing_index] = row![
214 | ">>".fg(White).dim().bold(),
215 | song.track_number.to_string().fg(NUMBER),
216 | song.title.as_str().fg(TITLE),
217 | song.album.as_str().fg(ALBUM),
218 | song.artist.as_str().fg(ARTIST)
219 | ];
220 | }
221 |
222 | for index in user_range.start..=user_range.end {
223 | let Some(song) = songs.get(index) else {
224 | continue;
225 | };
226 | if index == playing_index {
227 | //Currently playing and currently selected.
228 | //Has arrow and inverted colors.
229 | rows[index] = row![
230 | ">>".fg(White).dim().bold(),
231 | song.track_number.to_string().bg(NUMBER).fg(Black).dim(),
232 | song.title.as_str().bg(TITLE).fg(Black).dim(),
233 | song.album.as_str().bg(ALBUM).fg(Black).dim(),
234 | song.artist.as_str().bg(ARTIST).fg(Black).dim()
235 | ];
236 | } else {
237 | rows[index] = row![
238 | text!(),
239 | song.track_number.to_string().fg(Black).bg(NUMBER).dim(),
240 | song.title.as_str().fg(Black).bg(TITLE).dim(),
241 | song.album.as_str().fg(Black).bg(ALBUM).dim(),
242 | song.artist.as_str().fg(Black).bg(ARTIST).dim()
243 | ];
244 | }
245 | }
246 | }
247 |
248 | let con = [
249 | Constraint::Length(2),
250 | Constraint::Percentage(queue.constraint[0]),
251 | Constraint::Percentage(queue.constraint[1]),
252 | Constraint::Percentage(queue.constraint[2]),
253 | Constraint::Percentage(queue.constraint[3]),
254 | ];
255 | let block = block().borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM);
256 | let header = header![
257 | text!(),
258 | "#".bold(),
259 | "Title".bold(),
260 | "Album".bold(),
261 | "Artist".bold()
262 | ];
263 | let table = table(rows, &con).header(header).block(block).spacing(1);
264 | table.draw(area[1], buf, queue.index());
265 | row_bounds = Some(table.get_row_bounds(queue.index(), table.get_row_height(area[1])));
266 | };
267 |
268 | if log::last_message().is_none() {
269 | //Seeker
270 | if songs.is_empty() {
271 | return block()
272 | .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
273 | .draw(area[2], buf);
274 | }
275 |
276 | let elapsed = gonk_player::elapsed().as_secs_f32();
277 | let duration = gonk_player::duration().as_secs_f32();
278 |
279 | if duration != 0.0 {
280 | let seeker = format!(
281 | "{:02}:{:02}/{:02}:{:02}",
282 | (elapsed / 60.0).floor(),
283 | (elapsed % 60.0) as u64,
284 | (duration / 60.0).floor(),
285 | (duration % 60.0) as u64,
286 | );
287 |
288 | let ratio = elapsed.floor() / duration;
289 | let ratio = if ratio.is_nan() {
290 | 0.0
291 | } else {
292 | ratio.clamp(0.0, 1.0)
293 | };
294 |
295 | guage(Some(block()), ratio, seeker.into(), bg(SEEKER), style()).draw(area[2], buf);
296 | } else {
297 | guage(
298 | Some(block()),
299 | 0.0,
300 | "00:00/00:00".into(),
301 | bg(SEEKER),
302 | style(),
303 | )
304 | .draw(area[2], buf);
305 | }
306 | }
307 |
308 | //Don't handle mouse input when the queue is empty.
309 | if songs.is_empty() {
310 | return;
311 | }
312 |
313 | //Handle mouse input.
314 | if let Some((x, y)) = mouse {
315 | let header_height = 5;
316 | let size = viewport;
317 |
318 | //Mouse support for the seek bar.
319 | if (size.height - 3 == y || size.height - 2 == y || size.height - 1 == y)
320 | && size.height > 15
321 | {
322 | let ratio = x as f32 / size.width as f32;
323 | let duration = gonk_player::duration().as_secs_f32();
324 | gonk_player::seek(duration * ratio);
325 | }
326 |
327 | //Mouse support for the queue.
328 | if let Some((start, _)) = row_bounds {
329 | //Check if you clicked on the header.
330 | if y >= header_height {
331 | let index = (y - header_height) as usize + start;
332 |
333 | //Make sure you didn't click on the seek bar
334 | //and that the song index exists.
335 | if index < songs.len()
336 | && ((size.height < 15 && y < size.height.saturating_sub(1))
337 | || y < size.height.saturating_sub(3))
338 | {
339 | queue.range = Some(index..index);
340 | }
341 | }
342 | }
343 | }
344 | }
345 |
346 | pub fn constraint(queue: &mut Queue, row: usize, shift: bool) {
347 | if shift && queue.constraint[row] != 0 {
348 | //Move row back.
349 | queue.constraint[row + 1] += 1;
350 | queue.constraint[row] = queue.constraint[row].saturating_sub(1);
351 | } else if queue.constraint[row + 1] != 0 {
352 | //Move row forward.
353 | queue.constraint[row] += 1;
354 | queue.constraint[row + 1] = queue.constraint[row + 1].saturating_sub(1);
355 | }
356 |
357 | debug_assert!(
358 | queue.constraint.iter().sum::() == 100,
359 | "Constraint went out of bounds: {:?}",
360 | queue.constraint
361 | );
362 | }
363 |
--------------------------------------------------------------------------------
/gonk/src/search.rs:
--------------------------------------------------------------------------------
1 | use crate::{ALBUM, ARTIST, TITLE};
2 | use gonk_core::{
3 | vdb::{Database, Item},
4 | Index, Song,
5 | };
6 | use winter::*;
7 |
8 | #[derive(PartialEq, Eq, Debug)]
9 | pub enum Mode {
10 | Search,
11 | Select,
12 | }
13 |
14 | pub struct Search {
15 | pub query: String,
16 | pub query_changed: bool,
17 | pub mode: Mode,
18 | pub results: Index- ,
19 | }
20 |
21 | impl Search {
22 | pub fn new() -> Self {
23 | Self {
24 | query: String::new(),
25 | query_changed: false,
26 | mode: Mode::Search,
27 | results: Index::default(),
28 | }
29 | }
30 | }
31 |
32 | //TODO: Artist and albums colors aren't quite right.
33 | pub fn draw(
34 | search: &mut Search,
35 | area: winter::Rect,
36 | buf: &mut winter::Buffer,
37 | mouse: Option<(u16, u16)>,
38 | db: &Database,
39 | ) -> Option<(u16, u16)> {
40 | if search.query_changed {
41 | search.query_changed = !search.query_changed;
42 | *search.results = db.search(&search.query);
43 | }
44 |
45 | let v = layout(area, Vertical, &[Length(3), Fill]);
46 |
47 | if let Some((x, y)) = mouse {
48 | let rect = Rect {
49 | x,
50 | y,
51 | ..Default::default()
52 | };
53 | if rect.intersects(v[0]) {
54 | search.mode = Mode::Search;
55 | search.results.select(None);
56 | } else if rect.intersects(v[1]) && !search.results.is_empty() {
57 | search.mode = Mode::Select;
58 | search.results.select(Some(0));
59 | }
60 | }
61 |
62 | lines!(search.query.as_str())
63 | .block(block().title("Search:"))
64 | .scroll()
65 | .draw(v[0], buf);
66 |
67 | let rows: Vec
= search
68 | .results
69 | .iter()
70 | .enumerate()
71 | .map(|(i, item)| {
72 | let Some(s) = search.results.index() else {
73 | return cell(item, false);
74 | };
75 | if s == i {
76 | cell(item, true)
77 | } else {
78 | cell(item, false)
79 | }
80 | })
81 | .collect();
82 |
83 | let table = table(
84 | rows,
85 | &[
86 | Constraint::Length(1),
87 | Constraint::Percentage(50),
88 | Constraint::Percentage(30),
89 | Constraint::Percentage(20),
90 | ],
91 | )
92 | .header(header![
93 | text!(),
94 | "Name".italic(),
95 | "Album".italic(),
96 | "Artist".italic()
97 | ])
98 | .block(block());
99 |
100 | table.draw(v[1], buf, search.results.index());
101 |
102 | let layout_margin = 1;
103 | let x = 1 + layout_margin;
104 | let y = 1 + layout_margin;
105 |
106 | if let Mode::Search = search.mode {
107 | if search.results.index().is_none() && search.query.is_empty() {
108 | Some((x, y))
109 | } else {
110 | let len = search.query.len() as u16;
111 | let max_width = area.width.saturating_sub(3);
112 | if len >= max_width {
113 | Some((x - 1 + max_width, y))
114 | } else {
115 | Some((x + len, y))
116 | }
117 | }
118 | } else {
119 | None
120 | }
121 | }
122 |
123 | //Items have a lifetime of 'search because they live in the Search struct.
124 | fn cell(item: &Item, selected: bool) -> Row<'_> {
125 | let selected_cell = if selected { ">" } else { "" };
126 |
127 | match item {
128 | Item::Song((artist, album, name, _, _)) => row![
129 | selected_cell,
130 | name.as_str().fg(TITLE),
131 | album.as_str().fg(ALBUM),
132 | artist.as_str().fg(ARTIST)
133 | ],
134 | Item::Album((artist, album)) => row![
135 | selected_cell,
136 | lines!(text!("{album} - ").fg(ALBUM), "Album".fg(ALBUM).italic()),
137 | "-",
138 | artist.fg(ARTIST)
139 | ],
140 | Item::Artist(artist) => row![
141 | selected_cell,
142 | lines!(
143 | text!("{artist} - ").fg(ARTIST),
144 | "Artist".fg(ARTIST).italic()
145 | ),
146 | "-",
147 | "-"
148 | ],
149 | }
150 | }
151 |
152 | pub fn on_backspace(search: &mut Search, control: bool, shift: bool) {
153 | match search.mode {
154 | Mode::Search if !search.query.is_empty() => {
155 | if shift && control {
156 | search.query.clear();
157 | } else if control {
158 | let trim = search.query.trim_end();
159 | let end = trim.chars().rev().position(|c| c == ' ');
160 | if let Some(end) = end {
161 | search.query = trim[..trim.len() - end].to_string();
162 | } else {
163 | search.query.clear();
164 | }
165 | } else {
166 | search.query.pop();
167 | }
168 |
169 | search.query_changed = true;
170 | }
171 | Mode::Search => {}
172 | Mode::Select => {
173 | search.results.select(None);
174 | search.mode = Mode::Search;
175 | }
176 | }
177 | }
178 |
179 | pub fn on_enter(search: &mut Search, db: &Database) -> Option> {
180 | match search.mode {
181 | Mode::Search => {
182 | if !search.results.is_empty() {
183 | search.mode = Mode::Select;
184 | search.results.select(Some(0));
185 | }
186 | None
187 | }
188 | Mode::Select => search.results.selected().map(|item| match item {
189 | Item::Song((artist, album, _, disc, number)) => {
190 | vec![db.song(artist, album, *disc, *number).clone()]
191 | }
192 | Item::Album((artist, album)) => db.album(artist, album).songs.clone(),
193 | Item::Artist(artist) => db
194 | .albums_by_artist(artist)
195 | .iter()
196 | .flat_map(|album| album.songs.clone())
197 | .collect(),
198 | }),
199 | }
200 | }
201 |
--------------------------------------------------------------------------------
/gonk/src/settings.rs:
--------------------------------------------------------------------------------
1 | use gonk_player::*;
2 | use winter::*;
3 |
4 | pub struct Settings {
5 | pub devices: Vec,
6 | pub index: Option,
7 | pub current_device: String,
8 | }
9 |
10 | impl Settings {
11 | pub fn new(devices: Vec, current_device: String) -> Self {
12 | Self {
13 | index: if devices.is_empty() { None } else { Some(0) },
14 | devices,
15 | current_device,
16 | }
17 | }
18 | }
19 |
20 | pub fn selected(settings: &Settings) -> Option<&str> {
21 | if let Some(index) = settings.index {
22 | if let Some(device) = settings.devices.get(index) {
23 | return Some(&device.name);
24 | }
25 | }
26 | None
27 | }
28 |
29 | pub fn up(settings: &mut Settings, amount: usize) {
30 | if settings.devices.is_empty() {
31 | return;
32 | }
33 | let Some(index) = settings.index else { return };
34 | settings.index = Some(gonk_core::up(settings.devices.len(), index, amount));
35 | }
36 |
37 | pub fn down(settings: &mut Settings, amount: usize) {
38 | if settings.devices.is_empty() {
39 | return;
40 | }
41 | let Some(index) = settings.index else { return };
42 | settings.index = Some(gonk_core::down(settings.devices.len(), index, amount));
43 | }
44 |
45 | //TODO: I liked the old item menu bold selections instead of white background.
46 | //It doesn't work on most terminals though :(
47 | pub fn draw(settings: &Settings, area: winter::Rect, buf: &mut winter::Buffer) {
48 | let mut items = Vec::new();
49 | for device in &settings.devices {
50 | let item = if device.name == settings.current_device {
51 | lines!(">> ".dim(), &device.name)
52 | } else {
53 | lines!(" ", &device.name)
54 | };
55 | items.push(item);
56 | }
57 |
58 | if let Some(index) = settings.index {
59 | items[index].style = Some(fg(Black).bg(White));
60 | }
61 |
62 | let list = list(&items).block(block().title("Output Device").title_margin(1));
63 | list.draw(area, buf, settings.index);
64 | }
65 |
--------------------------------------------------------------------------------
/gonk_core/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "gonk_core"
3 | version = "0.2.0"
4 | edition = "2021"
5 |
6 | [features]
7 | profile = ["mini/profile"]
8 | simd = ["symphonia/opt-simd"]
9 |
10 | [dependencies]
11 | minbin = { git = "https://github.com/zX3no/minbin.git", version = "0.1.0" }
12 | mini = { git = "https://github.com/zX3no/mini", version = "0.1.0" }
13 | rayon = "1.7.0"
14 | symphonia = { git = "https://github.com/pdeljanov/Symphonia", default-features = false, features = [
15 | "flac",
16 | "mp3",
17 | "ogg",
18 | "vorbis",
19 | ] }
20 | winwalk = "0.2.2"
21 |
22 | [dev-dependencies]
23 | criterion = "0.5.1"
24 |
25 | [[bench]]
26 | name = "flac"
27 | harness = false
28 |
--------------------------------------------------------------------------------
/gonk_core/benches/flac.rs:
--------------------------------------------------------------------------------
1 | use criterion::{black_box, criterion_group, criterion_main, Criterion};
2 | use gonk_core::{read_metadata, read_metadata_old, Song};
3 | use winwalk::DirEntry;
4 |
5 | fn custom(files: &[DirEntry]) -> Vec> {
6 | files
7 | .iter()
8 | .map(|file| match read_metadata(&file.path) {
9 | Ok(song) => Ok(song),
10 | Err(err) => Err(format!("Error: ({err}) @ {}", file.path)),
11 | })
12 | .collect()
13 | }
14 |
15 | fn custom_old(files: &[DirEntry]) -> Vec> {
16 | files
17 | .iter()
18 | .map(|file| match read_metadata_old(&file.path) {
19 | Ok(metadata) => {
20 | let track_number = metadata
21 | .get("TRACKNUMBER")
22 | .unwrap_or(&String::from("1"))
23 | .parse()
24 | .unwrap_or(1);
25 |
26 | let disc_number = metadata
27 | .get("DISCNUMBER")
28 | .unwrap_or(&String::from("1"))
29 | .parse()
30 | .unwrap_or(1);
31 |
32 | let mut gain = 0.0;
33 | if let Some(db) = metadata.get("REPLAYGAIN_TRACK_GAIN") {
34 | let g = db.replace(" dB", "");
35 | if let Ok(db) = g.parse::() {
36 | gain = 10.0f32.powf(db / 20.0);
37 | }
38 | }
39 |
40 | let artist = match metadata.get("ALBUMARTIST") {
41 | Some(artist) => artist.as_str(),
42 | None => match metadata.get("ARTIST") {
43 | Some(artist) => artist.as_str(),
44 | None => "Unknown Artist",
45 | },
46 | };
47 |
48 | let album = match metadata.get("ALBUM") {
49 | Some(album) => album.as_str(),
50 | None => "Unknown Album",
51 | };
52 |
53 | let title = match metadata.get("TITLE") {
54 | Some(title) => title.as_str(),
55 | None => "Unknown Title",
56 | };
57 |
58 | Ok(Song {
59 | title: title.to_string(),
60 | album: album.to_string(),
61 | artist: artist.to_string(),
62 | disc_number,
63 | track_number,
64 | path: file.path.clone(),
65 | gain,
66 | })
67 | }
68 | Err(err) => Err(format!("Error: ({err}) @ {}", file.path)),
69 | })
70 | .collect()
71 | }
72 |
73 | fn symphonia(files: &[DirEntry]) -> Vec> {
74 | use std::fs::File;
75 | use symphonia::{
76 | core::{formats::FormatOptions, io::*, meta::*, probe::Hint},
77 | default::get_probe,
78 | };
79 | files
80 | .iter()
81 | .map(|entry| {
82 | let file = match File::open(&entry.path) {
83 | Ok(file) => file,
84 | Err(err) => return Err(format!("Error: ({err}) @ {}", entry.path)),
85 | };
86 |
87 | let mss = MediaSourceStream::new(Box::new(file), MediaSourceStreamOptions::default());
88 |
89 | let mut probe = match get_probe().format(
90 | &Hint::new(),
91 | mss,
92 | &FormatOptions::default(),
93 | &MetadataOptions {
94 | limit_visual_bytes: Limit::Maximum(1),
95 | ..Default::default()
96 | },
97 | ) {
98 | Ok(probe) => probe,
99 | Err(err) => return Err(format!("Error: ({err}) @ {}", entry.path))?,
100 | };
101 |
102 | let mut title = String::from("Unknown Title");
103 | let mut album = String::from("Unknown Album");
104 | let mut artist = String::from("Unknown Artist");
105 | let mut track_number = 1;
106 | let mut disc_number = 1;
107 | let mut gain = 0.0;
108 |
109 | let mut metadata_revision = probe.format.metadata();
110 | let mut metadata = probe.metadata.get();
111 | let mut m = None;
112 |
113 | if let Some(metadata) = metadata_revision.skip_to_latest() {
114 | m = Some(metadata);
115 | };
116 |
117 | if let Some(metadata) = &mut metadata {
118 | if let Some(metadata) = metadata.skip_to_latest() {
119 | m = Some(metadata)
120 | };
121 | }
122 |
123 | if let Some(metadata) = m {
124 | for tag in metadata.tags() {
125 | if let Some(std_key) = tag.std_key {
126 | match std_key {
127 | StandardTagKey::AlbumArtist => artist = tag.value.to_string(),
128 | StandardTagKey::Artist if artist == "Unknown Artist" => {
129 | artist = tag.value.to_string()
130 | }
131 | StandardTagKey::Album => album = tag.value.to_string(),
132 | StandardTagKey::TrackTitle => title = tag.value.to_string(),
133 | StandardTagKey::TrackNumber => {
134 | let num = tag.value.to_string();
135 | if let Some((num, _)) = num.split_once('/') {
136 | track_number = num.parse().unwrap_or(1);
137 | } else {
138 | track_number = num.parse().unwrap_or(1);
139 | }
140 | }
141 | StandardTagKey::DiscNumber => {
142 | let num = tag.value.to_string();
143 | if let Some((num, _)) = num.split_once('/') {
144 | disc_number = num.parse().unwrap_or(1);
145 | } else {
146 | disc_number = num.parse().unwrap_or(1);
147 | }
148 | }
149 | StandardTagKey::ReplayGainTrackGain => {
150 | let tag = tag.value.to_string();
151 | let (_, value) =
152 | tag.split_once(' ').ok_or("Invalid replay gain.")?;
153 | let db = value.parse().unwrap_or(0.0);
154 | gain = 10.0f32.powf(db / 20.0);
155 | }
156 | _ => (),
157 | }
158 | }
159 | }
160 | }
161 |
162 | Ok(Song {
163 | title,
164 | album,
165 | artist,
166 | disc_number,
167 | track_number,
168 | path: entry.path.clone(),
169 | gain,
170 | })
171 | })
172 | .collect()
173 | }
174 |
175 | const PATH: &str = "D:\\OneDrive\\Music";
176 |
177 | fn flac(c: &mut Criterion) {
178 | let mut group = c.benchmark_group("flac");
179 | group.sample_size(10);
180 |
181 | let paths: Vec = winwalk::walkdir(PATH, 0)
182 | .into_iter()
183 | .flatten()
184 | .filter(|entry| match entry.extension() {
185 | Some(ex) => {
186 | matches!(ex.to_str(), Some("flac"))
187 | }
188 | None => false,
189 | })
190 | .collect();
191 |
192 | group.bench_function("custom new", |b| {
193 | b.iter(|| {
194 | custom(black_box(&paths));
195 | });
196 | });
197 |
198 | group.bench_function("custom old", |b| {
199 | b.iter(|| {
200 | custom_old(black_box(&paths));
201 | });
202 | });
203 |
204 | group.bench_function("symphonia", |b| {
205 | b.iter(|| {
206 | symphonia(black_box(&paths));
207 | });
208 | });
209 |
210 | group.finish();
211 | }
212 |
213 | criterion_group!(benches, flac);
214 | criterion_main!(benches);
215 |
--------------------------------------------------------------------------------
/gonk_core/src/db.rs:
--------------------------------------------------------------------------------
1 | use crate::*;
2 | use rayon::prelude::{IntoParallelIterator, ParallelIterator};
3 | use std::{
4 | fs::File,
5 | io::{BufWriter, Write},
6 | thread::{self, JoinHandle},
7 | };
8 |
9 | #[derive(Debug, Clone, PartialEq)]
10 | pub struct Song {
11 | pub title: String,
12 | pub album: String,
13 | pub artist: String,
14 | pub disc_number: u8,
15 | pub track_number: u8,
16 | pub path: String,
17 | pub gain: f32,
18 | }
19 |
20 | impl Serialize for Song {
21 | fn serialize(&self) -> String {
22 | use std::fmt::Write;
23 |
24 | let mut buffer = String::new();
25 | let gain = if self.gain == 0.0 {
26 | "0.0".to_string()
27 | } else {
28 | self.gain.to_string()
29 | };
30 |
31 | let result = writeln!(
32 | &mut buffer,
33 | "{}\t{}\t{}\t{}\t{}\t{}\t{}",
34 | escape(&self.title),
35 | escape(&self.album),
36 | escape(&self.artist),
37 | self.disc_number,
38 | self.track_number,
39 | escape(&self.path),
40 | gain,
41 | );
42 |
43 | match result {
44 | Ok(_) => buffer,
45 | Err(err) => panic!("{err} failed to write song: {:?}", self),
46 | }
47 | }
48 | }
49 |
50 | impl Deserialize for Song {
51 | type Error = Box;
52 |
53 | fn deserialize(s: &str) -> Result {
54 | if s.is_empty() {
55 | return Err("Empty song")?;
56 | }
57 |
58 | //`file.lines()` will not include newlines
59 | //but song.to_string() will.
60 | let s = if s.as_bytes().last() == Some(&b'\n') {
61 | &s[..s.len() - 1]
62 | } else {
63 | s
64 | };
65 |
66 | let mut parts = s.split('\t');
67 | Ok(Song {
68 | title: parts.next().ok_or("Missing title")?.to_string(),
69 | album: parts.next().ok_or("Missing album")?.to_string(),
70 | artist: parts.next().ok_or("Missing artist")?.to_string(),
71 | disc_number: parts.next().ok_or("Missing disc_number")?.parse::()?,
72 | track_number: parts.next().ok_or("Missing track_number")?.parse::()?,
73 | path: parts.next().ok_or("Missing path")?.to_string(),
74 | gain: parts.next().ok_or("Missing gain")?.parse::()?,
75 | })
76 | }
77 | }
78 |
79 | impl Serialize for Vec {
80 | fn serialize(&self) -> String {
81 | let mut buffer = String::new();
82 | for song in self {
83 | buffer.push_str(&song.serialize());
84 | }
85 | buffer
86 | }
87 | }
88 |
89 | impl Deserialize for Vec {
90 | type Error = Box;
91 |
92 | fn deserialize(s: &str) -> Result {
93 | s.trim().split('\n').map(Song::deserialize).collect()
94 | }
95 | }
96 |
97 | pub const UNKNOWN_TITLE: &str = "Unknown Title";
98 | pub const UNKNOWN_ALBUM: &str = "Unknown Album";
99 | pub const UNKNOWN_ARTIST: &str = "Unknown Artist";
100 |
101 | impl Song {
102 | pub fn default() -> Self {
103 | Self {
104 | title: UNKNOWN_TITLE.to_string(),
105 | album: UNKNOWN_ALBUM.to_string(),
106 | artist: UNKNOWN_ARTIST.to_string(),
107 | disc_number: 1,
108 | track_number: 1,
109 | path: String::new(),
110 | gain: 0.0,
111 | }
112 | }
113 | pub fn example() -> Self {
114 | Self {
115 | title: "title".to_string(),
116 | album: "album".to_string(),
117 | artist: "artist".to_string(),
118 | disc_number: 1,
119 | track_number: 1,
120 | path: "path".to_string(),
121 | gain: 1.0,
122 | }
123 | }
124 | }
125 |
126 | #[derive(Debug, Default, Clone)]
127 | pub struct Album {
128 | pub title: String,
129 | pub songs: Vec,
130 | }
131 |
132 | #[derive(Debug, Default)]
133 | pub struct Artist {
134 | pub albums: Vec,
135 | }
136 |
137 | impl TryFrom<&Path> for Song {
138 | type Error = String;
139 |
140 | fn try_from(path: &Path) -> Result {
141 | let extension = path.extension().ok_or("Path is not audio")?;
142 |
143 | if extension != "flac" {
144 | use symphonia::{
145 | core::{formats::FormatOptions, io::*, meta::*, probe::Hint},
146 | default::get_probe,
147 | };
148 |
149 | let file = match File::open(path) {
150 | Ok(file) => file,
151 | Err(err) => return Err(format!("Error: ({err}) @ {}", path.to_string_lossy())),
152 | };
153 |
154 | let mss = MediaSourceStream::new(Box::new(file), MediaSourceStreamOptions::default());
155 |
156 | let mut probe = match get_probe().format(
157 | &Hint::new(),
158 | mss,
159 | &FormatOptions::default(),
160 | &MetadataOptions {
161 | limit_visual_bytes: Limit::Maximum(1),
162 | ..Default::default()
163 | },
164 | ) {
165 | Ok(probe) => probe,
166 | Err(err) => return Err(format!("Error: ({err}) @ {}", path.to_string_lossy()))?,
167 | };
168 |
169 | let mut title = String::from("Unknown Title");
170 | let mut album = String::from("Unknown Album");
171 | let mut artist = String::from("Unknown Artist");
172 | let mut track_number = 1;
173 | let mut disc_number = 1;
174 | let mut gain = 0.0;
175 |
176 | let mut metadata_revision = probe.format.metadata();
177 | let mut metadata = probe.metadata.get();
178 | let mut m = None;
179 |
180 | if let Some(metadata) = metadata_revision.skip_to_latest() {
181 | m = Some(metadata);
182 | };
183 |
184 | if let Some(metadata) = &mut metadata {
185 | if let Some(metadata) = metadata.skip_to_latest() {
186 | m = Some(metadata)
187 | };
188 | }
189 |
190 | if let Some(metadata) = m {
191 | for tag in metadata.tags() {
192 | if let Some(std_key) = tag.std_key {
193 | match std_key {
194 | StandardTagKey::AlbumArtist => artist = tag.value.to_string(),
195 | StandardTagKey::Artist if artist == "Unknown Artist" => {
196 | artist = tag.value.to_string()
197 | }
198 | StandardTagKey::Album => album = tag.value.to_string(),
199 | StandardTagKey::TrackTitle => title = tag.value.to_string(),
200 | StandardTagKey::TrackNumber => {
201 | let num = tag.value.to_string();
202 | if let Some((num, _)) = num.split_once('/') {
203 | track_number = num.parse().unwrap_or(1);
204 | } else {
205 | track_number = num.parse().unwrap_or(1);
206 | }
207 | }
208 | StandardTagKey::DiscNumber => {
209 | let num = tag.value.to_string();
210 | if let Some((num, _)) = num.split_once('/') {
211 | disc_number = num.parse().unwrap_or(1);
212 | } else {
213 | disc_number = num.parse().unwrap_or(1);
214 | }
215 | }
216 | StandardTagKey::ReplayGainTrackGain => {
217 | let tag = tag.value.to_string();
218 | let (_, value) =
219 | tag.split_once(' ').ok_or("Invalid replay gain.")?;
220 | let db = value.parse().unwrap_or(0.0);
221 | gain = 10.0f32.powf(db / 20.0);
222 | }
223 | _ => (),
224 | }
225 | }
226 | }
227 | }
228 |
229 | Ok(Song {
230 | title,
231 | album,
232 | artist,
233 | disc_number,
234 | track_number,
235 | path: path.to_str().ok_or("Invalid UTF-8 in path.")?.to_string(),
236 | gain,
237 | })
238 | } else {
239 | read_metadata(path)
240 | .map_err(|err| format!("Error: ({err}) @ {}", path.to_string_lossy()))
241 | }
242 | }
243 | }
244 |
245 | #[derive(Debug)]
246 | pub enum ScanResult {
247 | Completed,
248 | CompletedWithErrors(Vec),
249 | FileInUse,
250 | }
251 |
252 | pub fn reset() -> Result<(), Box> {
253 | fs::remove_file(settings_path())?;
254 | if database_path().exists() {
255 | fs::remove_file(database_path())?;
256 | }
257 | Ok(())
258 | }
259 |
260 | pub fn create(path: &str) -> JoinHandle {
261 | let path = path.to_string();
262 | thread::spawn(move || {
263 | let mut db_path = database_path().to_path_buf();
264 | db_path.pop();
265 | db_path.push("temp.db");
266 |
267 | match File::create(&db_path) {
268 | Ok(file) => {
269 | let paths: Vec = winwalk::walkdir(path, 0)
270 | .into_iter()
271 | .flatten()
272 | .filter(|entry| match entry.extension() {
273 | Some(ex) => {
274 | matches!(ex.to_str(), Some("flac" | "mp3" | "ogg"))
275 | }
276 | None => false,
277 | })
278 | .collect();
279 |
280 | let songs: Vec<_> = paths
281 | .into_par_iter()
282 | .map(|entry| Song::try_from(Path::new(&entry.path)))
283 | .collect();
284 |
285 | let errors: Vec = songs
286 | .iter()
287 | .filter_map(|song| {
288 | if let Err(err) = song {
289 | Some(err.clone())
290 | } else {
291 | None
292 | }
293 | })
294 | .collect();
295 |
296 | let songs: Vec = songs.into_iter().flatten().collect();
297 | let mut writer = BufWriter::new(&file);
298 | writer.write_all(&songs.serialize().into_bytes()).unwrap();
299 | writer.flush().unwrap();
300 |
301 | //Remove old database and replace it with new.
302 | fs::rename(db_path, database_path()).unwrap();
303 |
304 | // let _db = vdb::create().unwrap();
305 |
306 | if errors.is_empty() {
307 | ScanResult::Completed
308 | } else {
309 | ScanResult::CompletedWithErrors(errors)
310 | }
311 | }
312 | Err(_) => ScanResult::FileInUse,
313 | }
314 | })
315 | }
316 |
317 | #[cfg(test)]
318 | mod tests {
319 | use std::{str::from_utf8_unchecked, time::Duration};
320 |
321 | use super::*;
322 |
323 | #[test]
324 | fn string() {
325 | let song = Song::example();
326 | let string = song.serialize();
327 | assert_eq!(Song::deserialize(&string).unwrap(), song);
328 | }
329 |
330 | #[test]
331 | fn path() {
332 | let path = PathBuf::from(
333 | r"D:\OneDrive\Music\Mouse On The Keys\an anxious object\04. dirty realism.flac",
334 | );
335 | let _ = Song::try_from(path.as_path()).unwrap();
336 | }
337 |
338 | #[test]
339 | fn database() {
340 | let handle = create("D:\\OneDrive\\Music");
341 |
342 | while !handle.is_finished() {
343 | thread::sleep(Duration::from_millis(1));
344 | }
345 | handle.join().unwrap();
346 | let bytes = fs::read(database_path()).unwrap();
347 | let db: Result, Box> = unsafe { from_utf8_unchecked(&bytes) }
348 | .lines()
349 | .map(Song::deserialize)
350 | .collect();
351 | let _ = db.unwrap();
352 | }
353 | }
354 |
--------------------------------------------------------------------------------
/gonk_core/src/flac_decoder.rs:
--------------------------------------------------------------------------------
1 | use crate::{db::UNKNOWN_ARTIST, Song};
2 | use std::{
3 | collections::HashMap,
4 | error::Error,
5 | fs::File,
6 | io::{BufReader, Read},
7 | path::Path,
8 | str::from_utf8_unchecked,
9 | };
10 |
11 | #[inline]
12 | pub fn u24_be(reader: &mut BufReader) -> u32 {
13 | let mut triple = [0; 4];
14 | reader.read_exact(&mut triple[0..3]).unwrap();
15 | u32::from_be_bytes(triple) >> 8
16 | }
17 |
18 | #[inline]
19 | pub fn u32_le(reader: &mut BufReader) -> u32 {
20 | let mut buffer = [0; 4];
21 | reader.read_exact(&mut buffer).unwrap();
22 | u32::from_le_bytes(buffer)
23 | }
24 |
25 | pub fn read_metadata_old>(
26 | path: P,
27 | ) -> Result, Box> {
28 | let file = File::open(path)?;
29 | let mut reader = BufReader::new(file);
30 |
31 | let mut flac = [0; 4];
32 | reader.read_exact(&mut flac)?;
33 |
34 | if unsafe { from_utf8_unchecked(&flac) } != "fLaC" {
35 | Err("File is not FLAC.")?;
36 | }
37 |
38 | let mut tags = HashMap::new();
39 |
40 | loop {
41 | let mut flag = [0; 1];
42 | reader.read_exact(&mut flag)?;
43 |
44 | // First bit of the header indicates if this is the last metadata block.
45 | let is_last = (flag[0] & 0x80) == 0x80;
46 |
47 | // The next 7 bits of the header indicates the block type.
48 | let block_type = flag[0] & 0x7f;
49 | let block_len = u24_be(&mut reader);
50 |
51 | //VorbisComment https://www.xiph.org/vorbis/doc/v-comment.html
52 | if block_type == 4 {
53 | let vendor_length = u32_le(&mut reader);
54 | reader.seek_relative(vendor_length as i64)?;
55 |
56 | let comment_list_length = u32_le(&mut reader);
57 | for _ in 0..comment_list_length {
58 | let length = u32_le(&mut reader) as usize;
59 | let mut buffer = vec![0; length as usize];
60 | reader.read_exact(&mut buffer)?;
61 |
62 | let tag = core::str::from_utf8(&buffer).unwrap();
63 | let (k, v) = match tag.split_once('=') {
64 | Some((left, right)) => (left, right),
65 | None => (tag, ""),
66 | };
67 |
68 | tags.insert(k.to_ascii_uppercase(), v.to_string());
69 | }
70 |
71 | return Ok(tags);
72 | }
73 |
74 | reader.seek_relative(block_len as i64)?;
75 |
76 | // Exit when the last header is read.
77 | if is_last {
78 | break;
79 | }
80 | }
81 |
82 | Err("Could not parse metadata.")?
83 | }
84 |
85 | pub fn read_metadata>(path: P) -> Result> {
86 | let file = File::open(&path)?;
87 | let mut reader = BufReader::new(file);
88 |
89 | let mut flac = [0; 4];
90 | reader.read_exact(&mut flac)?;
91 |
92 | if unsafe { from_utf8_unchecked(&flac) } != "fLaC" {
93 | Err("File is not FLAC.")?;
94 | }
95 |
96 | let mut song: Song = Song::default();
97 | song.path = path.as_ref().to_string_lossy().to_string();
98 |
99 | let mut flag = [0; 1];
100 |
101 | loop {
102 | reader.read_exact(&mut flag)?;
103 |
104 | // First bit of the header indicates if this is the last metadata block.
105 | let is_last = (flag[0] & 0x80) == 0x80;
106 |
107 | // The next 7 bits of the header indicates the block type.
108 | let block_type = flag[0] & 0x7f;
109 | let block_len = u24_be(&mut reader);
110 |
111 | //VorbisComment https://www.xiph.org/vorbis/doc/v-comment.html
112 | if block_type == 4 {
113 | let vendor_length = u32_le(&mut reader);
114 | reader.seek_relative(vendor_length as i64)?;
115 |
116 | let comment_list_length = u32_le(&mut reader);
117 | for _ in 0..comment_list_length {
118 | let length = u32_le(&mut reader) as usize;
119 | let mut buffer = vec![0; length as usize];
120 | reader.read_exact(&mut buffer)?;
121 |
122 | let tag = core::str::from_utf8(&buffer).unwrap();
123 | let (k, v) = match tag.split_once('=') {
124 | Some((left, right)) => (left, right),
125 | None => (tag, ""),
126 | };
127 |
128 | match k.to_ascii_lowercase().as_str() {
129 | "albumartist" => song.artist = v.to_string(),
130 | "artist" if song.artist == UNKNOWN_ARTIST => song.artist = v.to_string(),
131 | "title" => song.title = v.to_string(),
132 | "album" => song.album = v.to_string(),
133 | "tracknumber" => song.track_number = v.parse().unwrap_or(1),
134 | "discnumber" => song.disc_number = v.parse().unwrap_or(1),
135 | "replaygain_track_gain" => {
136 | //Remove the trailing " dB" from "-5.39 dB".
137 | if let Some(slice) = v.get(..v.len() - 3) {
138 | if let Ok(db) = slice.parse::() {
139 | song.gain = 10.0f32.powf(db / 20.0);
140 | }
141 | }
142 | }
143 | _ => {}
144 | }
145 | }
146 |
147 | return Ok(song);
148 | }
149 |
150 | reader.seek_relative(block_len as i64)?;
151 |
152 | // Exit when the last header is read.
153 | if is_last {
154 | break;
155 | }
156 | }
157 |
158 | Err("Could not parse metadata.")?
159 | }
160 |
161 | #[cfg(test)]
162 | mod tests {
163 | use crate::*;
164 |
165 | #[test]
166 | fn test() {
167 | const PATH: &str = "D:\\OneDrive\\Music";
168 |
169 | let paths: Vec = winwalk::walkdir(PATH, 0)
170 | .into_iter()
171 | .flatten()
172 | .filter(|entry| match entry.extension() {
173 | Some(ex) => {
174 | matches!(ex.to_str(), Some("flac"))
175 | }
176 | None => false,
177 | })
178 | .collect();
179 |
180 | let songs: Vec> = paths
181 | .iter()
182 | .map(|file| {
183 | read_metadata(&file.path)
184 | .map_err(|err| format!("Error: ({err}) @ {}", file.path.to_string()))
185 | })
186 | .collect();
187 |
188 | dbg!(&songs[0].as_ref().unwrap());
189 | }
190 | }
191 |
--------------------------------------------------------------------------------
/gonk_core/src/index.rs:
--------------------------------------------------------------------------------
1 | use std::ops::{Deref, DerefMut};
2 |
3 | pub fn up(len: usize, index: usize, amt: usize) -> usize {
4 | (index + len - amt % len) % len
5 | }
6 |
7 | pub fn down(len: usize, index: usize, amt: usize) -> usize {
8 | (index + amt) % len
9 | }
10 |
11 | #[derive(Debug, PartialEq)]
12 | pub struct Index {
13 | data: Vec,
14 | index: Option,
15 | }
16 |
17 | impl Index {
18 | pub const fn new(data: Vec, index: Option) -> Self {
19 | Self { data, index }
20 | }
21 | pub fn up(&mut self) {
22 | if self.data.is_empty() {
23 | return;
24 | }
25 |
26 | match self.index {
27 | Some(0) => self.index = Some(self.data.len() - 1),
28 | Some(n) => self.index = Some(n - 1),
29 | None => (),
30 | }
31 | }
32 | pub fn down(&mut self) {
33 | if self.data.is_empty() {
34 | return;
35 | }
36 |
37 | match self.index {
38 | Some(n) if n + 1 < self.data.len() => self.index = Some(n + 1),
39 | Some(_) => self.index = Some(0),
40 | None => (),
41 | }
42 | }
43 | pub fn up_n(&mut self, n: usize) {
44 | if self.data.is_empty() {
45 | return;
46 | }
47 | let Some(index) = self.index else { return };
48 | self.index = Some(up(self.data.len(), index, n));
49 | }
50 | pub fn down_n(&mut self, n: usize) {
51 | if self.data.is_empty() {
52 | return;
53 | }
54 | let Some(index) = self.index else { return };
55 | self.index = Some(down(self.data.len(), index, n));
56 | }
57 | pub fn selected(&self) -> Option<&T> {
58 | let Some(index) = self.index else {
59 | return None;
60 | };
61 | self.data.get(index)
62 | }
63 | pub fn selected_mut(&mut self) -> Option<&mut T> {
64 | let Some(index) = self.index else {
65 | return None;
66 | };
67 | self.data.get_mut(index)
68 | }
69 | pub fn index(&self) -> Option {
70 | self.index
71 | }
72 | pub fn select(&mut self, i: Option) {
73 | self.index = i;
74 | }
75 | pub fn remove_and_move(&mut self, index: usize) {
76 | self.data.remove(index);
77 | let len = self.data.len();
78 | if let Some(selected) = self.index {
79 | if index == len && selected == len {
80 | self.index = Some(len.saturating_sub(1));
81 | } else if index == 0 && selected == 0 {
82 | self.index = Some(0);
83 | } else if len == 0 {
84 | self.index = None;
85 | }
86 | }
87 | }
88 | }
89 |
90 | impl From> for Index {
91 | fn from(vec: Vec) -> Self {
92 | let index = if vec.is_empty() { None } else { Some(0) };
93 | Self { data: vec, index }
94 | }
95 | }
96 |
97 | impl<'a, T> From<&'a [T]> for Index<&'a T> {
98 | fn from(slice: &'a [T]) -> Self {
99 | let data: Vec<&T> = slice.iter().collect();
100 | let index = if data.is_empty() { None } else { Some(0) };
101 | Self { data, index }
102 | }
103 | }
104 |
105 | impl From<&[T]> for Index {
106 | fn from(slice: &[T]) -> Self {
107 | let index = if slice.is_empty() { None } else { Some(0) };
108 | Self {
109 | data: slice.to_vec(),
110 | index,
111 | }
112 | }
113 | }
114 |
115 | impl Default for Index {
116 | fn default() -> Self {
117 | Self {
118 | data: Vec::new(),
119 | index: None,
120 | }
121 | }
122 | }
123 |
124 | impl Deref for Index {
125 | type Target = Vec;
126 |
127 | fn deref(&self) -> &Self::Target {
128 | &self.data
129 | }
130 | }
131 |
132 | impl DerefMut for Index {
133 | fn deref_mut(&mut self) -> &mut Self::Target {
134 | &mut self.data
135 | }
136 | }
137 |
138 | impl crate::Serialize for Index {
139 | fn serialize(&self) -> String {
140 | self.data.serialize()
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/gonk_core/src/lib.rs:
--------------------------------------------------------------------------------
1 | #![allow(static_mut_refs)]
2 | //! The physical database is a file on disk that stores song information.
3 | //! This information includes the artist, album, title, disc number, track number, path and replay gain.
4 | //!
5 | //! The virtual database stores key value pairs.
6 | //! It is used for quering artists, albums and songs.
7 | //!
8 | //! `Index` is a wrapper over a `Vec` plus an index. Kind of like a circular buffer but the data is usually constant.
9 | //! It's useful for moving up and down the selection of a UI element.
10 | use std::{
11 | borrow::Cow,
12 | env,
13 | error::Error,
14 | fs::{self},
15 | mem::MaybeUninit,
16 | path::{Path, PathBuf},
17 | sync::Once,
18 | };
19 |
20 | pub use crate::{
21 | db::{Album, Artist, Song},
22 | playlist::Playlist,
23 | };
24 | pub use flac_decoder::*;
25 | pub use index::*;
26 |
27 | pub mod db;
28 | pub mod flac_decoder;
29 | pub mod index;
30 | pub mod log;
31 | pub mod playlist;
32 | pub mod settings;
33 | pub mod strsim;
34 | pub mod vdb;
35 |
36 | ///Escape potentially problematic strings.
37 | pub fn escape(input: &str) -> Cow {
38 | if input.contains(['\n', '\t']) {
39 | Cow::Owned(input.replace('\n', "").replace('\t', " "))
40 | } else {
41 | Cow::Borrowed(input)
42 | }
43 | }
44 |
45 | static mut GONK: MaybeUninit = MaybeUninit::uninit();
46 | static mut SETTINGS: MaybeUninit = MaybeUninit::uninit();
47 | static mut DATABASE: MaybeUninit = MaybeUninit::uninit();
48 | static mut ONCE: Once = Once::new();
49 |
50 | pub fn user_profile_directory() -> Option {
51 | env::var("USERPROFILE").ok()
52 | }
53 |
54 | #[inline(always)]
55 | fn once() {
56 | unsafe {
57 | ONCE.call_once(|| {
58 | let gonk = if cfg!(windows) {
59 | PathBuf::from(&env::var("APPDATA").unwrap())
60 | } else {
61 | PathBuf::from(&env::var("HOME").unwrap()).join(".config")
62 | }
63 | .join("gonk");
64 |
65 | if !gonk.exists() {
66 | fs::create_dir_all(&gonk).unwrap();
67 | }
68 |
69 | let settings = gonk.join("settings.db");
70 |
71 | //Backwards compatibility for older versions of gonk
72 | let old_db = gonk.join("gonk_new.db");
73 | let db = gonk.join("gonk.db");
74 |
75 | if old_db.exists() {
76 | fs::rename(old_db, &db).unwrap();
77 | }
78 |
79 | GONK = MaybeUninit::new(gonk);
80 | SETTINGS = MaybeUninit::new(settings);
81 | DATABASE = MaybeUninit::new(db);
82 | });
83 | }
84 | }
85 |
86 | pub fn gonk_path() -> &'static Path {
87 | once();
88 | unsafe { GONK.assume_init_ref() }
89 | }
90 |
91 | pub fn settings_path() -> &'static Path {
92 | once();
93 | unsafe { SETTINGS.assume_init_ref() }
94 | }
95 |
96 | pub fn database_path() -> &'static Path {
97 | once();
98 | unsafe { DATABASE.assume_init_ref() }
99 | }
100 |
101 | trait Serialize {
102 | fn serialize(&self) -> String;
103 | }
104 |
105 | trait Deserialize
106 | where
107 | Self: Sized,
108 | {
109 | type Error;
110 |
111 | fn deserialize(s: &str) -> Result;
112 | }
113 |
--------------------------------------------------------------------------------
/gonk_core/src/log.rs:
--------------------------------------------------------------------------------
1 | //! TODO: Cleanup
2 | //!
3 | //!
4 | use std::{
5 | sync::Once,
6 | time::{Duration, Instant},
7 | };
8 |
9 | #[doc(hidden)]
10 | pub static ONCE: Once = Once::new();
11 |
12 | #[doc(hidden)]
13 | pub static mut LOG: Log = Log::new();
14 |
15 | #[doc(hidden)]
16 | pub const MESSAGE_COOLDOWN: Duration = Duration::from_millis(1500);
17 |
18 | #[doc(hidden)]
19 | #[derive(Debug)]
20 | pub struct Log {
21 | pub messages: Vec<(String, Instant)>,
22 | }
23 |
24 | impl Log {
25 | pub const fn new() -> Self {
26 | Self {
27 | messages: Vec::new(),
28 | }
29 | }
30 | }
31 |
32 | #[macro_export]
33 | macro_rules! log {
34 | ($($arg:tt)*) => {{
35 | use $crate::log::{LOG, ONCE, MESSAGE_COOLDOWN};
36 | use std::time::{Instant, Duration};
37 | use std::thread;
38 |
39 | ONCE.call_once(|| {
40 | thread::spawn(|| loop {
41 | thread::sleep(Duration::from_millis(16));
42 |
43 | if let Some((_, instant)) = unsafe { LOG.messages.last() } {
44 | if instant.elapsed() >= MESSAGE_COOLDOWN {
45 | unsafe { LOG.messages.pop() };
46 |
47 | //Reset the next messages since they run paralell.
48 | //Not a good way of doing this.
49 | if let Some((_, instant)) = unsafe { LOG.messages.last_mut() } {
50 | *instant = Instant::now();
51 | }
52 | }
53 | }
54 | });
55 | });
56 |
57 | unsafe {
58 | LOG.messages.push((format_args!($($arg)*).to_string(), Instant::now()));
59 | }
60 | }
61 | };
62 | }
63 |
64 | pub fn clear() {
65 | unsafe {
66 | LOG.messages = Vec::new();
67 | }
68 | }
69 |
70 | pub fn last_message() -> Option<&'static str> {
71 | if let Some((message, _)) = unsafe { LOG.messages.last() } {
72 | Some(message.as_str())
73 | } else {
74 | None
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/gonk_core/src/playlist.rs:
--------------------------------------------------------------------------------
1 | //! Music Playlists
2 | //!
3 | //! Each playlist has it's own file.
4 | //!
5 | use crate::{escape, gonk_path, Deserialize, Index, Serialize, Song};
6 | use std::{
7 | fs::{self},
8 | path::PathBuf,
9 | };
10 |
11 | #[derive(Debug, Default, PartialEq)]
12 | pub struct Playlist {
13 | name: String,
14 | path: PathBuf,
15 |
16 | pub songs: Index,
17 | }
18 |
19 | impl Playlist {
20 | pub fn new(name: &str, songs: Vec) -> Self {
21 | let name = escape(name);
22 | Self {
23 | path: gonk_path().join(format!("{name}.playlist")),
24 | name: String::from(name),
25 | songs: Index::from(songs),
26 | }
27 | }
28 | pub fn name(&self) -> &str {
29 | &self.name
30 | }
31 | pub fn save(&self) -> std::io::Result<()> {
32 | fs::write(&self.path, self.serialize())
33 | }
34 | pub fn delete(&self) {
35 | minbin::trash(&self.path).unwrap();
36 | }
37 | }
38 |
39 | impl Serialize for Playlist {
40 | fn serialize(&self) -> String {
41 | let mut buffer = String::new();
42 | buffer.push_str(&self.name);
43 | buffer.push('\t');
44 | buffer.push_str(self.path.to_str().unwrap());
45 | buffer.push('\n');
46 | buffer.push_str(&self.songs.serialize());
47 | buffer
48 | }
49 | }
50 |
51 | impl Deserialize for Playlist {
52 | type Error = Box;
53 |
54 | fn deserialize(s: &str) -> Result {
55 | let (start, end) = s.split_once('\n').ok_or("Invalid playlist")?;
56 | let (name, path) = start.split_once('\t').ok_or("Invalid playlsit")?;
57 |
58 | Ok(Self {
59 | name: name.to_string(),
60 | path: PathBuf::from(path),
61 | songs: Index::from(Vec::::deserialize(end)?),
62 | })
63 | }
64 | }
65 |
66 | pub fn playlists() -> Vec {
67 | winwalk::walkdir(gonk_path().to_str().unwrap(), 0)
68 | .into_iter()
69 | .flatten()
70 | .filter(|entry| match entry.extension() {
71 | Some(ex) => {
72 | matches!(ex.to_str(), Some("playlist"))
73 | }
74 | None => false,
75 | })
76 | .flat_map(|entry| fs::read_to_string(entry.path))
77 | .map(|string| Playlist::deserialize(&string).unwrap())
78 | .collect()
79 | }
80 |
81 | #[cfg(test)]
82 | mod tests {
83 | use super::*;
84 |
85 | #[test]
86 | fn playlist() {
87 | let playlist = Playlist::new("name", vec![Song::example(), Song::example()]);
88 | let string = playlist.serialize();
89 | let p = Playlist::deserialize(&string).unwrap();
90 | assert_eq!(playlist, p);
91 | }
92 |
93 | #[test]
94 | fn save() {
95 | let playlist = Playlist::new(
96 | "test",
97 | vec![
98 | Song::example(),
99 | Song::example(),
100 | Song::example(),
101 | Song::example(),
102 | Song::example(),
103 | Song::example(),
104 | Song::example(),
105 | Song::example(),
106 | Song::example(),
107 | Song::example(),
108 | ],
109 | );
110 | playlist.save().unwrap();
111 | let playlists = playlists();
112 | assert!(!playlists.is_empty());
113 | playlist.delete();
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/gonk_core/src/settings.rs:
--------------------------------------------------------------------------------
1 | //! Music player settings
2 | //!
3 | //! Stores the volume, state of the queue and output device
4 | //!
5 | //! TODO: Rework to a modified toml format and add volume reduction and audio packet size.
6 | use crate::*;
7 | use std::{
8 | fs::File,
9 | io::{BufWriter, Read, Seek, Write},
10 | };
11 |
12 | #[derive(Debug)]
13 | pub struct Settings {
14 | pub volume: u8,
15 | pub index: u16,
16 | pub elapsed: f32,
17 | pub output_device: String,
18 | pub music_folder: String,
19 | pub queue: Vec,
20 | pub file: Option,
21 | }
22 |
23 | impl Serialize for Settings {
24 | fn serialize(&self) -> String {
25 | let mut buffer = String::new();
26 | buffer.push_str(&self.volume.to_string());
27 | buffer.push('\t');
28 | buffer.push_str(&self.index.to_string());
29 | buffer.push('\t');
30 | buffer.push_str(&self.elapsed.to_string());
31 | buffer.push('\t');
32 | buffer.push_str(&escape(&self.output_device));
33 | buffer.push('\t');
34 | buffer.push_str(&escape(&self.music_folder));
35 | buffer.push('\n');
36 | buffer.push_str(&self.queue.serialize());
37 | buffer
38 | }
39 | }
40 |
41 | impl Deserialize for Settings {
42 | type Error = Box;
43 |
44 | fn deserialize(s: &str) -> Result {
45 | let (start, end) = s.split_once('\n').ok_or("Invalid settings")?;
46 | let split: Vec<&str> = start.split('\t').collect();
47 | let music_folder = if split.len() == 4 {
48 | String::new()
49 | } else {
50 | split[4].to_string()
51 | };
52 |
53 | let queue = if end.is_empty() {
54 | Vec::new()
55 | } else {
56 | Vec::::deserialize(end)?
57 | };
58 |
59 | Ok(Self {
60 | volume: split[0].parse::()?,
61 | index: split[1].parse::()?,
62 | elapsed: split[2].parse::()?,
63 | output_device: split[3].to_string(),
64 | music_folder,
65 | queue,
66 | file: None,
67 | })
68 | }
69 | }
70 |
71 | impl Default for Settings {
72 | fn default() -> Self {
73 | Self {
74 | volume: 15,
75 | index: Default::default(),
76 | elapsed: Default::default(),
77 | output_device: Default::default(),
78 | music_folder: Default::default(),
79 | queue: Default::default(),
80 | file: None,
81 | }
82 | }
83 | }
84 |
85 | impl Settings {
86 | pub fn new() -> Result {
87 | let mut file = File::options()
88 | .read(true)
89 | .write(true)
90 | .create(true)
91 | .open(settings_path())
92 | .unwrap();
93 | let mut string = String::new();
94 | file.read_to_string(&mut string)?;
95 | let mut settings = Settings::deserialize(&string).unwrap_or_default();
96 | settings.file = Some(file);
97 | Ok(settings)
98 | }
99 |
100 | pub fn save(&self) -> std::io::Result<()> {
101 | let mut file = self.file.as_ref().unwrap();
102 | file.set_len(0)?;
103 | file.rewind()?;
104 | let mut writer = BufWriter::new(file);
105 | writer.write_all(self.serialize().as_bytes())?;
106 | writer.flush()
107 | }
108 | }
109 |
110 | #[cfg(test)]
111 | mod tests {
112 | use super::*;
113 |
114 | #[test]
115 | fn settings() {
116 | Settings::new().unwrap();
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/gonk_core/src/strsim.rs:
--------------------------------------------------------------------------------
1 | //! Ripped from
2 | use std::cmp::{max, min};
3 |
4 | pub fn jaro_winkler(a: &str, b: &str) -> f64 {
5 | let jaro_distance = generic_jaro(a, b);
6 |
7 | // Don't limit the length of the common prefix
8 | let prefix_length = a
9 | .chars()
10 | .zip(b.chars())
11 | .take_while(|(a_elem, b_elem)| a_elem == b_elem)
12 | .count();
13 |
14 | let jaro_winkler_distance =
15 | jaro_distance + (0.08 * prefix_length as f64 * (1.0 - jaro_distance));
16 |
17 | jaro_winkler_distance.clamp(0.0, 1.0)
18 | }
19 |
20 | pub fn generic_jaro(a: &str, b: &str) -> f64 {
21 | let a_len = a.chars().count();
22 | let b_len = b.chars().count();
23 |
24 | // The check for lengths of one here is to prevent integer overflow when
25 | // calculating the search range.
26 | if a_len == 0 && b_len == 0 {
27 | return 1.0;
28 | } else if a_len == 0 || b_len == 0 {
29 | return 0.0;
30 | } else if a_len == 1 && b_len == 1 {
31 | return if a.chars().eq(b.chars()) { 1.0 } else { 0.0 };
32 | }
33 |
34 | let search_range = (max(a_len, b_len) / 2) - 1;
35 |
36 | let mut b_consumed = vec![false; b_len];
37 | let mut matches = 0.0;
38 |
39 | let mut transpositions = 0.0;
40 | let mut b_match_index = 0;
41 |
42 | for (i, a_elem) in a.chars().enumerate() {
43 | let min_bound =
44 | // prevent integer wrapping
45 | if i > search_range {
46 | max(0, i - search_range)
47 | } else {
48 | 0
49 | };
50 |
51 | let max_bound = min(b_len - 1, i + search_range);
52 |
53 | if min_bound > max_bound {
54 | continue;
55 | }
56 |
57 | for (j, b_elem) in b.chars().enumerate() {
58 | if min_bound <= j && j <= max_bound && a_elem == b_elem && !b_consumed[j] {
59 | b_consumed[j] = true;
60 | matches += 1.0;
61 |
62 | if j < b_match_index {
63 | transpositions += 1.0;
64 | }
65 | b_match_index = j;
66 |
67 | break;
68 | }
69 | }
70 | }
71 |
72 | if matches == 0.0 {
73 | 0.0
74 | } else {
75 | (1.0 / 3.0)
76 | * ((matches / a_len as f64)
77 | + (matches / b_len as f64)
78 | + ((matches - transpositions) / matches))
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/gonk_core/src/vdb.rs:
--------------------------------------------------------------------------------
1 | //! Virtual database
2 | //!
3 | //! Songs are taken from the physical database and stored in a `BTreeMap`
4 | //!
5 | //! Also contains code for querying artists, albums and songs.
6 | //!
7 | use crate::db::{Album, Song};
8 | use crate::{database_path, strsim, Deserialize};
9 | use std::collections::BTreeMap;
10 | use std::{cmp::Ordering, fs, str::from_utf8_unchecked};
11 |
12 | #[cfg(test)]
13 | mod tests {
14 | use super::*;
15 |
16 | #[test]
17 | fn db() {
18 | let db = Database::new();
19 | dbg!(db.artists());
20 | dbg!(db.search("test"));
21 | }
22 | }
23 |
24 | const MIN_ACCURACY: f64 = 0.70;
25 |
26 | #[derive(Clone, Debug, PartialEq, Eq)]
27 | pub enum Item {
28 | ///(Artist, Album, Name, Disc Number, Track Number)
29 | Song((String, String, String, u8, u8)),
30 | ///(Artist, Album)
31 | Album((String, String)),
32 | ///(Artist)
33 | Artist(String),
34 | }
35 |
36 | ///https://en.wikipedia.org/wiki/Jaro%E2%80%93Winkler_distance
37 | fn jaro(query: &str, input: Item) -> Result<(Item, f64), (Item, f64)> {
38 | let str = match input {
39 | Item::Artist(ref artist) => artist,
40 | Item::Album((_, ref album)) => album,
41 | Item::Song((_, _, ref song, _, _)) => song,
42 | };
43 | let acc = strsim::jaro_winkler(query, &str.to_lowercase());
44 | if acc > MIN_ACCURACY {
45 | Ok((input, acc))
46 | } else {
47 | Err((input, acc))
48 | }
49 | }
50 |
51 | //I feel like Box<[String, Box]> might have been a better choice.
52 | pub struct Database {
53 | btree: BTreeMap>,
54 | pub len: usize,
55 | }
56 |
57 | impl Database {
58 | ///Read the database from disk and load it into memory.
59 | pub fn new() -> Self {
60 | let bytes = match fs::read(database_path()) {
61 | Ok(bytes) => bytes,
62 | Err(error) => match error.kind() {
63 | std::io::ErrorKind::NotFound => Vec::new(),
64 | _ => panic!("{error}"),
65 | },
66 | };
67 | let songs: Vec = unsafe { from_utf8_unchecked(&bytes) }
68 | .lines()
69 | .flat_map(Song::deserialize)
70 | .collect();
71 |
72 | let len = songs.len();
73 | let mut btree: BTreeMap> = BTreeMap::new();
74 | let mut albums: BTreeMap<(String, String), Vec> = BTreeMap::new();
75 |
76 | //Add songs to albums.
77 | for song in songs.into_iter() {
78 | albums
79 | .entry((song.artist.clone(), song.album.clone()))
80 | .or_default()
81 | .push(song);
82 | }
83 |
84 | //Sort songs.
85 | albums.iter_mut().for_each(|(_, album)| {
86 | album.sort_unstable_by(|a, b| {
87 | if a.disc_number == b.disc_number {
88 | a.track_number.cmp(&b.track_number)
89 | } else {
90 | a.disc_number.cmp(&b.disc_number)
91 | }
92 | });
93 | });
94 |
95 | //Add albums to artists.
96 | for ((artist, title), songs) in albums {
97 | btree
98 | .entry(artist)
99 | .or_default()
100 | .push(Album { title, songs });
101 | }
102 |
103 | //Sort albums.
104 | btree.iter_mut().for_each(|(_, albums)| {
105 | albums.sort_unstable_by_key(|album| album.title.to_ascii_lowercase());
106 | });
107 |
108 | Self { btree, len }
109 | }
110 |
111 | ///Get all artist names.
112 | pub fn artists(&self) -> Vec<&String> {
113 | let mut v: Vec<_> = self.btree.keys().collect();
114 | v.sort_unstable_by_key(|artist| artist.to_ascii_lowercase());
115 | v
116 | }
117 |
118 | ///Get all albums by an artist.
119 | pub fn albums_by_artist(&self, artist: &str) -> &[Album] {
120 | self.btree.get(artist).unwrap()
121 | }
122 |
123 | ///Get an album by artist and album name.
124 | pub fn album(&self, artist: &str, album: &str) -> &Album {
125 | if let Some(albums) = self.btree.get(artist) {
126 | for al in albums {
127 | if album == al.title {
128 | return al;
129 | }
130 | }
131 | }
132 | panic!("Could not find album {} {}", artist, album);
133 | }
134 |
135 | ///Get an individual song in the database.
136 | pub fn song(&self, artist: &str, album: &str, disc: u8, number: u8) -> &Song {
137 | for al in self.btree.get(artist).unwrap() {
138 | if al.title == album {
139 | for song in &al.songs {
140 | if song.disc_number == disc && song.track_number == number {
141 | return song;
142 | }
143 | }
144 | }
145 | }
146 | unreachable!();
147 | }
148 |
149 | ///Search the database and return the 25 most accurate matches.
150 | pub fn search(&self, query: &str) -> Vec- {
151 | const MAX: usize = 40;
152 |
153 | let query = query.to_lowercase();
154 | let mut results = Vec::new();
155 |
156 | for (artist, albums) in self.btree.iter() {
157 | for album in albums.iter() {
158 | for song in album.songs.iter() {
159 | results.push(jaro(
160 | &query,
161 | Item::Song((
162 | song.artist.clone(),
163 | song.album.clone(),
164 | song.title.clone(),
165 | song.disc_number,
166 | song.track_number,
167 | )),
168 | ));
169 | }
170 | results.push(jaro(
171 | &query,
172 | Item::Album((artist.clone(), album.title.clone())),
173 | ));
174 | }
175 | results.push(jaro(&query, Item::Artist(artist.clone())));
176 | }
177 |
178 | if query.is_empty() {
179 | return results
180 | .into_iter()
181 | .take(MAX)
182 | .map(|item| match item {
183 | Ok((item, _)) => item,
184 | Err((item, _)) => item,
185 | })
186 | .collect();
187 | }
188 |
189 | let mut results: Vec<(Item, f64)> = results.into_iter().flatten().collect();
190 |
191 | //Sort results by score.
192 | results.sort_unstable_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap());
193 |
194 | if results.len() > MAX {
195 | //Remove the less accurate results.
196 | unsafe {
197 | results.set_len(MAX);
198 | }
199 | }
200 |
201 | results.sort_unstable_by(|(item_1, score_1), (item_2, score_2)| {
202 | if score_1 == score_2 {
203 | match item_1 {
204 | Item::Artist(_) => match item_2 {
205 | Item::Song(_) | Item::Album(_) => Ordering::Less,
206 | Item::Artist(_) => Ordering::Equal,
207 | },
208 | Item::Album(_) => match item_2 {
209 | Item::Song(_) => Ordering::Less,
210 | Item::Album(_) => Ordering::Equal,
211 | Item::Artist(_) => Ordering::Greater,
212 | },
213 | Item::Song((_, _, _, disc_a, number_a)) => match item_2 {
214 | Item::Song((_, _, _, disc_b, number_b)) => match disc_a.cmp(disc_b) {
215 | Ordering::Less => Ordering::Less,
216 | Ordering::Equal => number_a.cmp(number_b),
217 | Ordering::Greater => Ordering::Greater,
218 | },
219 | Item::Album(_) | Item::Artist(_) => Ordering::Greater,
220 | },
221 | }
222 | } else if score_2 > score_1 {
223 | Ordering::Equal
224 | } else {
225 | Ordering::Less
226 | }
227 | });
228 |
229 | results.into_iter().map(|(item, _)| item).collect()
230 | }
231 | }
232 |
--------------------------------------------------------------------------------
/gonk_player/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "gonk_player"
3 | version = "0.2.0"
4 | edition = "2021"
5 | description = "Music playback library for gonk"
6 | repository = "https://github.com/zX3no/gonk"
7 | readme = "../README.md"
8 | license = "CC0-1.0"
9 |
10 | [lib]
11 | name = "gonk_player"
12 | path = "src/lib.rs"
13 |
14 | [features]
15 | profile = ["gonk_core/profile", "mini/profile"]
16 | info = ["mini/info"]
17 | warn = ["mini/warn"]
18 | error = ["mini/error"]
19 |
20 | [dependencies]
21 | crossbeam-queue = "0.3.1"
22 | gonk_core = { version = "0.2.0", path = "../gonk_core" }
23 | mini = { git = "https://github.com/zX3no/mini", version = "0.1.0" }
24 | ringbuf = "0.4.1"
25 | symphonia = { git = "https://github.com/pdeljanov/Symphonia", default-features = false, features = [
26 | "flac",
27 | "mp3",
28 | "ogg",
29 | "vorbis",
30 | "opt-simd",
31 | ] }
32 | wasapi = { git = "https://github.com/zx3no/wasapi", version = "0.1.0" }
33 | # wasapi = { version = "0.1.0", path = "../../wasapi" }
34 |
--------------------------------------------------------------------------------
/gonk_player/src/decoder.rs:
--------------------------------------------------------------------------------
1 | //! Decoder for audio files.
2 | use std::io::ErrorKind;
3 | use std::time::Duration;
4 | use std::{fs::File, path::Path};
5 | use symphonia::core::errors::Error;
6 | use symphonia::core::formats::{FormatReader, Track};
7 | use symphonia::{
8 | core::{
9 | audio::SampleBuffer,
10 | codecs,
11 | formats::{FormatOptions, SeekMode, SeekTo},
12 | io::MediaSourceStream,
13 | meta::MetadataOptions,
14 | probe::Hint,
15 | units::Time,
16 | },
17 | default::get_probe,
18 | };
19 |
20 | pub struct Symphonia {
21 | pub format_reader: Box,
22 | pub decoder: Box,
23 | pub track: Track,
24 | pub elapsed: u64,
25 | pub duration: u64,
26 | pub error_count: u8,
27 | pub done: bool,
28 | }
29 |
30 | impl Symphonia {
31 | pub fn new>(path: P) -> Result> {
32 | let file = File::open(path)?;
33 | let mss = MediaSourceStream::new(Box::new(file), Default::default());
34 | let probed = get_probe().format(
35 | &Hint::default(),
36 | mss,
37 | &FormatOptions {
38 | prebuild_seek_index: true,
39 | seek_index_fill_rate: 1,
40 | enable_gapless: false,
41 | },
42 | &MetadataOptions::default(),
43 | )?;
44 |
45 | let track = probed.format.default_track().ok_or("track")?.to_owned();
46 | let n_frames = track.codec_params.n_frames.ok_or("n_frames")?;
47 | let duration = track.codec_params.start_ts + n_frames;
48 | let decoder = symphonia::default::get_codecs()
49 | .make(&track.codec_params, &codecs::DecoderOptions::default())?;
50 |
51 | Ok(Self {
52 | format_reader: probed.format,
53 | decoder,
54 | track,
55 | duration,
56 | elapsed: 0,
57 | error_count: 0,
58 | done: false,
59 | })
60 | }
61 | pub fn elapsed(&self) -> Duration {
62 | let tb = self.track.codec_params.time_base.unwrap();
63 | let time = tb.calc_time(self.elapsed);
64 | Duration::from_secs(time.seconds) + Duration::from_secs_f64(time.frac)
65 | }
66 | pub fn duration(&self) -> Duration {
67 | let tb = self.track.codec_params.time_base.unwrap();
68 | let time = tb.calc_time(self.duration);
69 | Duration::from_secs(time.seconds) + Duration::from_secs_f64(time.frac)
70 | }
71 | pub fn sample_rate(&self) -> u32 {
72 | self.track.codec_params.sample_rate.unwrap()
73 | }
74 | //TODO: I would like seeking out of bounds to play the next song.
75 | //I can't trust symphonia to provide accurate errors so it's not worth the hassle.
76 | //I could use pos + elapsed > duration but the duration isn't accurate.
77 | pub fn seek(&mut self, pos: f32) {
78 | let pos = Duration::from_secs_f32(pos);
79 |
80 | //Ignore errors.
81 | let _ = self.format_reader.seek(
82 | SeekMode::Coarse,
83 | SeekTo::Time {
84 | time: Time::new(pos.as_secs(), pos.subsec_nanos() as f64 / 1_000_000_000.0),
85 | track_id: None,
86 | },
87 | );
88 | }
89 |
90 | pub fn next_packet(&mut self) -> Option> {
91 | if self.error_count > 2 || self.done {
92 | return None;
93 | }
94 |
95 | let next_packet = match self.format_reader.next_packet() {
96 | Ok(next_packet) => {
97 | self.error_count = 0;
98 | next_packet
99 | }
100 | Err(err) => match err {
101 | Error::IoError(e) if e.kind() == ErrorKind::UnexpectedEof => {
102 | //Just in case my 250ms addition is not enough.
103 | if self.elapsed() + Duration::from_secs(1) > self.duration() {
104 | self.done = true;
105 | return None;
106 | } else {
107 | self.error_count += 1;
108 | return self.next_packet();
109 | }
110 | }
111 | _ => {
112 | gonk_core::log!("{}", err);
113 | self.error_count += 1;
114 | return self.next_packet();
115 | }
116 | },
117 | };
118 |
119 | self.elapsed = next_packet.ts();
120 |
121 | //HACK: Sometimes the end of file error does not indicate the end of the file?
122 | //The duration is a little bit longer than the maximum elapsed??
123 | //The final packet will make the elapsed time move backwards???
124 | if self.elapsed() + Duration::from_millis(250) > self.duration() {
125 | self.done = true;
126 | return None;
127 | }
128 |
129 | match self.decoder.decode(&next_packet) {
130 | Ok(decoded) => {
131 | let mut buffer =
132 | SampleBuffer::::new(decoded.capacity() as u64, *decoded.spec());
133 | buffer.copy_interleaved_ref(decoded);
134 | Some(buffer)
135 | }
136 | Err(err) => {
137 | gonk_core::log!("{}", err);
138 | self.error_count += 1;
139 | self.next_packet()
140 | }
141 | }
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/gonk_player/src/lib.rs:
--------------------------------------------------------------------------------
1 | #![allow(static_mut_refs)]
2 | //! TODO: Describe the audio backend
3 | use crossbeam_queue::SegQueue;
4 | use decoder::Symphonia;
5 | use gonk_core::{Index, Song};
6 | use mini::*;
7 | use ringbuf::{
8 | traits::{Consumer, Observer, Producer, Split},
9 | HeapRb,
10 | };
11 | use std::mem::MaybeUninit;
12 | use std::{
13 | path::{Path, PathBuf},
14 | sync::Once,
15 | thread,
16 | time::Duration,
17 | };
18 | use symphonia::core::audio::SampleBuffer;
19 | use wasapi::*;
20 |
21 | mod decoder;
22 |
23 | //TODO: These should be configurable.
24 | const VOLUME_REDUCTION: f32 = 75.0;
25 |
26 | //Foobar uses a buffer size of 1000ms by default.
27 | pub static mut RB_SIZE: usize = 4096 * 4;
28 | // const RB_SIZE: usize = 4096 * 4;
29 |
30 | const COMMON_SAMPLE_RATES: [u32; 13] = [
31 | 5512, 8000, 11025, 16000, 22050, 32000, 44100, 48000, 64000, 88200, 96000, 176400, 192000,
32 | ];
33 |
34 | static mut EVENTS: SegQueue = SegQueue::new();
35 | static mut ELAPSED: Duration = Duration::from_secs(0);
36 | static mut DURATION: Duration = Duration::from_secs(0);
37 | static mut VOLUME: f32 = 15.0 / VOLUME_REDUCTION;
38 | static mut GAIN: Option = None;
39 | static mut OUTPUT_DEVICE: Option = None;
40 | static mut PAUSED: bool = false;
41 |
42 | //Safety: Only written on decoder thread.
43 | static mut NEXT: bool = false;
44 | static mut SAMPLE_RATE: Option = None;
45 |
46 | static ONCE: Once = Once::new();
47 | static mut ENUMERATOR: MaybeUninit = MaybeUninit::uninit();
48 |
49 | pub unsafe fn init_com() {
50 | ONCE.call_once(|| {
51 | CoInitializeEx(ConcurrencyModel::MultiThreaded).unwrap();
52 | ENUMERATOR = MaybeUninit::new(IMMDeviceEnumerator::new().unwrap());
53 | });
54 | }
55 |
56 | #[derive(Debug, PartialEq)]
57 | enum Event {
58 | Stop,
59 | //Path, Gain
60 | Song(PathBuf, f32),
61 | Seek(f32),
62 | SeekBackward,
63 | SeekForward,
64 | }
65 |
66 | #[derive(PartialEq, Eq, Debug, Clone)]
67 | pub struct Device {
68 | pub inner: IMMDevice,
69 | pub name: String,
70 | }
71 |
72 | unsafe impl Send for Device {}
73 | unsafe impl Sync for Device {}
74 |
75 | //https://www.youtube.com/watch?v=zrWYJ6FdOFQ
76 |
77 | ///Get a list of output devices.
78 | pub fn devices() -> Vec {
79 | unsafe {
80 | init_com();
81 | let collection = ENUMERATOR
82 | .assume_init_mut()
83 | .EnumAudioEndpoints(DataFlow::Render, DeviceState::Active)
84 | .unwrap();
85 |
86 | (0..collection.GetCount().unwrap())
87 | .map(|i| {
88 | let device = collection.Item(i).unwrap();
89 | Device {
90 | name: device.name(),
91 | inner: device,
92 | }
93 | })
94 | .collect()
95 | }
96 | }
97 |
98 | ///Get the default output device.
99 | pub fn default_device() -> Device {
100 | unsafe {
101 | init_com();
102 | let device = ENUMERATOR
103 | .assume_init_mut()
104 | .GetDefaultAudioEndpoint(DataFlow::Render, Role::Console)
105 | .unwrap();
106 | Device {
107 | name: device.name(),
108 | inner: device,
109 | }
110 | }
111 | }
112 |
113 | pub unsafe fn create_wasapi(
114 | device: &Device,
115 | sample_rate: Option,
116 | ) -> (
117 | IAudioClient,
118 | IAudioRenderClient,
119 | WAVEFORMATEXTENSIBLE,
120 | *mut c_void,
121 | ) {
122 | let client: IAudioClient = device.inner.Activate(ExecutionContext::All).unwrap();
123 | let mut format =
124 | (client.GetMixFormat().unwrap() as *const _ as *const WAVEFORMATEXTENSIBLE).read();
125 |
126 | if format.Format.nChannels < 2 {
127 | todo!("Support mono devices.");
128 | }
129 |
130 | //Update format to desired sample rate.
131 | if let Some(sample_rate) = sample_rate {
132 | assert!(COMMON_SAMPLE_RATES.contains(&sample_rate));
133 | format.Format.nSamplesPerSec = sample_rate;
134 | format.Format.nAvgBytesPerSec = sample_rate * format.Format.nBlockAlign as u32;
135 | }
136 |
137 | let (default, _min) = client.GetDevicePeriod().unwrap();
138 |
139 | client
140 | .Initialize(
141 | ShareMode::Shared,
142 | AUDCLNT_STREAMFLAGS_EVENTCALLBACK
143 | | AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
144 | | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY,
145 | default,
146 | default,
147 | &format as *const _ as *const WAVEFORMATEX,
148 | None,
149 | )
150 | .unwrap();
151 |
152 | //This must be set for some reason.
153 | let event = CreateEventA(core::ptr::null_mut(), 0, 0, core::ptr::null_mut());
154 | assert!(!event.is_null());
155 | client.SetEventHandle(event as isize).unwrap();
156 |
157 | let render_client: IAudioRenderClient = client.GetService().unwrap();
158 | client.Start().unwrap();
159 |
160 | (client, render_client, format, event)
161 | }
162 |
163 | //0.016384MB, no stack overflow here.
164 | // static mut QUEUE: [f32; RB_SIZE] = [0.0; RB_SIZE];
165 |
166 | //Should probably just write my own queue.
167 |
168 | pub fn spawn_audio_threads(device: Device) {
169 | unsafe {
170 | let rb: HeapRb = HeapRb::new(RB_SIZE);
171 | let (mut prod, mut cons) = rb.split();
172 |
173 | thread::spawn(move || {
174 | info!("Spawned decoder thread!");
175 |
176 | let mut sym: Option = None;
177 | let mut leftover_packet: Option> = None;
178 | let mut i = 0;
179 | let mut finished = true;
180 |
181 | loop {
182 | //TODO: This thread spinlocks. The whole player should be re-written honestly.
183 | std::thread::sleep(std::time::Duration::from_millis(1));
184 |
185 | match EVENTS.pop() {
186 | Some(Event::Song(new_path, gain)) => {
187 | // info!("{} paused: {}", new_path.display(), PAUSED);
188 | // info!("Gain: {} prod capacity: {}", gain, prod.capacity());
189 | let s = match Symphonia::new(&new_path) {
190 | Ok(s) => s,
191 | Err(e) => {
192 | gonk_core::log!(
193 | "Failed to play: {}, Error: {e}",
194 | new_path.to_string_lossy()
195 | );
196 | warn!("Failed to play: {}, Error: {e}", new_path.to_string_lossy(),);
197 | NEXT = true;
198 | continue;
199 | }
200 | };
201 |
202 | //We don't set the playback state here because it might be delayed.
203 | SAMPLE_RATE = Some(s.sample_rate());
204 | DURATION = s.duration();
205 |
206 | //Set the decoder for the new song.
207 | sym = Some(s);
208 |
209 | //Remove the leftovers.
210 | leftover_packet = None;
211 | //Start the playback
212 | finished = false;
213 |
214 | //Set the gain
215 | GAIN = Some(gain);
216 | }
217 | Some(Event::Stop) => {
218 | info!("Stopping playback.");
219 | //Stop the decoder and remove the extra packet.
220 | sym = None;
221 | leftover_packet = None;
222 |
223 | //Remove any excess packets from the queue.
224 | //If this isn't done, the user can clear the queue
225 | //and resume and they will hear the remaining few packets.
226 | prod.advance_write_index(prod.occupied_len());
227 | }
228 | Some(Event::Seek(pos)) => {
229 | if let Some(sym) = &mut sym {
230 | info!(
231 | "Seeking {} / {} paused: {}",
232 | pos as u32,
233 | DURATION.as_secs_f32() as u32,
234 | PAUSED
235 | );
236 | sym.seek(pos);
237 | }
238 | }
239 | Some(Event::SeekForward) => {
240 | if let Some(sym) = &mut sym {
241 | info!(
242 | "Seeking {} / {}",
243 | sym.elapsed().as_secs_f32() + 10.0,
244 | sym.duration().as_secs_f32()
245 | );
246 | sym.seek((sym.elapsed().as_secs_f32() + 10.0).clamp(0.0, f32::MAX))
247 | }
248 | }
249 | Some(Event::SeekBackward) => {
250 | if let Some(sym) = &mut sym {
251 | info!(
252 | "Seeking {} / {}",
253 | sym.elapsed().as_secs_f32() - 10.0,
254 | sym.duration().as_secs_f32()
255 | );
256 | sym.seek((sym.elapsed().as_secs_f32() - 10.0).clamp(0.0, f32::MAX))
257 | }
258 | }
259 | None => {}
260 | }
261 |
262 | if PAUSED {
263 | continue;
264 | }
265 |
266 | let Some(sym) = &mut sym else {
267 | continue;
268 | };
269 |
270 | if let Some(p) = &mut leftover_packet {
271 | //Note: this has caused a crash before.
272 | //This may not work as intended.
273 | //Really need to write some unit tests for song playback.
274 | //Stability has taken a huge hit since I stopped using it as my primary music player.
275 |
276 | //Push as many samples as will fit.
277 | if let Some(samples) = p.samples().get(i..) {
278 | i += prod.push_slice(&samples);
279 | } else {
280 | i = 0;
281 | }
282 |
283 | //Did we push all the samples?
284 | if i == p.len() {
285 | i = 0;
286 | leftover_packet = None;
287 | }
288 | } else {
289 | leftover_packet = sym.next_packet();
290 | ELAPSED = sym.elapsed();
291 |
292 | //It's important that finished is used as a guard.
293 | //If next is used it can be changed by a different thread.
294 | //This may be an excessive amount of conditions :/
295 | if leftover_packet.is_none() && !PAUSED && !finished && !NEXT {
296 | finished = true;
297 | NEXT = true;
298 | info!("Playback ended.");
299 | }
300 | }
301 | }
302 | });
303 |
304 | thread::spawn(move || {
305 | info!("Spawned WASAPI thread!");
306 | init_com();
307 | set_pro_audio_thread();
308 |
309 | let (mut audio, mut render, mut format, mut event) = create_wasapi(&device, None);
310 | let mut block_align = format.Format.nBlockAlign as u32;
311 | let mut sample_rate = format.Format.nSamplesPerSec;
312 | let mut gain = 0.5;
313 |
314 | loop {
315 | //Block until the output device is ready for new samples.
316 | if WaitForSingleObject(event, u32::MAX) != WAIT_OBJECT_0 {
317 | unreachable!();
318 | }
319 |
320 | if PAUSED {
321 | continue;
322 | }
323 |
324 | if let Some(device) = OUTPUT_DEVICE.take() {
325 | info!("Changing output device to: {}", device.name);
326 | //Set the new audio device.
327 | audio.Stop().unwrap();
328 | (audio, render, format, event) = create_wasapi(&device, Some(sample_rate));
329 | //Different devices have different block alignments.
330 | block_align = format.Format.nBlockAlign as u32;
331 | }
332 |
333 | if let Some(sr) = SAMPLE_RATE {
334 | if sr != sample_rate {
335 | info!("Changing sample rate to {}", sr);
336 | let device = OUTPUT_DEVICE.as_ref().unwrap_or(&device);
337 | sample_rate = sr;
338 |
339 | //Set the new sample rate.
340 | audio.Stop().unwrap();
341 | (audio, render, format, event) = create_wasapi(device, Some(sample_rate));
342 | //Doesn't need to be set since it's the same device.
343 | //I just did this to avoid any issues.
344 | block_align = format.Format.nBlockAlign as u32;
345 | }
346 | }
347 |
348 | if let Some(g) = GAIN.take() {
349 | if gain != g {
350 | gain = g;
351 | }
352 | //Make sure there are no old samples before dramatically increasing the volume.
353 | //Without this there were some serious jumps in volume when skipping songs.
354 | cons.clear();
355 | debug_assert!(cons.is_empty())
356 | }
357 |
358 | //Sample-rate probably changed if this fails.
359 | let padding = audio.GetCurrentPadding().unwrap();
360 | let buffer_size = audio.GetBufferSize().unwrap();
361 |
362 | let n_frames = buffer_size - 1 - padding;
363 | debug_assert!(n_frames < buffer_size - padding);
364 |
365 | let size = (n_frames * block_align) as usize;
366 |
367 | if size == 0 {
368 | continue;
369 | }
370 |
371 | let b = render.GetBuffer(n_frames).unwrap();
372 | let output = std::slice::from_raw_parts_mut(b, size);
373 | let channels = format.Format.nChannels as usize;
374 | let volume = VOLUME * gain;
375 |
376 | let mut iter = cons.pop_iter();
377 | for bytes in output.chunks_mut(std::mem::size_of::() * channels) {
378 | let sample = iter.next().unwrap_or_default();
379 | bytes[0..4].copy_from_slice(&(sample * volume).to_le_bytes());
380 |
381 | if channels > 1 {
382 | let sample = iter.next().unwrap_or_default();
383 | bytes[4..8].copy_from_slice(&(sample * volume).to_le_bytes());
384 | }
385 | }
386 |
387 | render.ReleaseBuffer(n_frames, 0).unwrap();
388 | }
389 | });
390 | }
391 | }
392 |
393 | pub fn toggle_playback() {
394 | unsafe { PAUSED = !PAUSED };
395 | }
396 |
397 | pub fn play() {
398 | unsafe { PAUSED = false };
399 | }
400 |
401 | pub fn pause() {
402 | unsafe { PAUSED = true };
403 | }
404 |
405 | pub fn get_volume() -> u8 {
406 | unsafe { (VOLUME * VOLUME_REDUCTION) as u8 }
407 | }
408 |
409 | pub fn set_volume(volume: u8) {
410 | unsafe {
411 | VOLUME = volume as f32 / VOLUME_REDUCTION;
412 | }
413 | }
414 |
415 | pub fn volume_up() {
416 | unsafe {
417 | VOLUME = (VOLUME * VOLUME_REDUCTION + 5.0).clamp(0.0, 100.0) / VOLUME_REDUCTION;
418 | }
419 | }
420 |
421 | pub fn volume_down() {
422 | unsafe {
423 | VOLUME = (VOLUME * VOLUME_REDUCTION - 5.0).clamp(0.0, 100.0) / VOLUME_REDUCTION;
424 | }
425 | }
426 |
427 | pub fn seek(pos: f32) {
428 | unsafe {
429 | EVENTS.push(Event::Seek(pos));
430 | ELAPSED = Duration::from_secs_f32(pos);
431 | }
432 | }
433 |
434 | pub fn seek_foward() {
435 | unsafe { EVENTS.push(Event::SeekForward) };
436 | }
437 |
438 | pub fn seek_backward() {
439 | unsafe { EVENTS.push(Event::SeekBackward) };
440 | }
441 |
442 | //This is mainly for testing.
443 | pub fn play_path>(path: P) {
444 | unsafe {
445 | PAUSED = false;
446 | ELAPSED = Duration::from_secs(0);
447 | EVENTS.push(Event::Song(path.as_ref().to_path_buf(), 0.5));
448 | }
449 | }
450 |
451 | pub fn play_song(song: &Song) {
452 | unsafe {
453 | PAUSED = false;
454 | ELAPSED = Duration::from_secs(0);
455 | EVENTS.push(Event::Song(
456 | PathBuf::from(&song.path),
457 | if song.gain == 0.0 { 0.5 } else { song.gain },
458 | ));
459 | }
460 | }
461 |
462 | pub fn set_output_device(device: &str) {
463 | let d = devices();
464 | unsafe {
465 | match d.iter().find(|d| d.name == device) {
466 | Some(device) => OUTPUT_DEVICE = Some(device.clone()),
467 | None => panic!(
468 | "Could not find {} in {:?}",
469 | device,
470 | d.into_iter().map(|d| d.name).collect::>()
471 | ),
472 | }
473 | }
474 | }
475 |
476 | pub fn play_index(songs: &mut Index, i: usize) {
477 | songs.select(Some(i));
478 | if let Some(song) = songs.selected() {
479 | play_song(song);
480 | }
481 | }
482 |
483 | pub fn delete(songs: &mut Index, index: usize) {
484 | if songs.is_empty() {
485 | return;
486 | }
487 |
488 | songs.remove(index);
489 |
490 | if let Some(playing) = songs.index() {
491 | let len = songs.len();
492 | if len == 0 {
493 | *songs = Index::default();
494 | unsafe { EVENTS.push(Event::Stop) };
495 | } else if index == playing && index == 0 {
496 | songs.select(Some(0));
497 | if let Some(song) = songs.selected() {
498 | play_song(song);
499 | }
500 | } else if index == playing && index == len {
501 | songs.select(Some(len - 1));
502 | if let Some(song) = songs.selected() {
503 | play_song(song);
504 | }
505 | } else if index < playing {
506 | songs.select(Some(playing - 1));
507 | }
508 | };
509 | }
510 |
511 | pub fn clear(songs: &mut Index) {
512 | unsafe { EVENTS.push(Event::Stop) };
513 | songs.clear();
514 | }
515 |
516 | pub fn clear_except_playing(songs: &mut Index) {
517 | if let Some(index) = songs.index() {
518 | let playing = songs.remove(index);
519 | *songs = Index::new(vec![playing], Some(0));
520 | }
521 | }
522 |
523 | pub fn is_paused() -> bool {
524 | unsafe { PAUSED }
525 | }
526 |
527 | //This function should only return `true` after every song has finshed.
528 | pub fn play_next() -> bool {
529 | unsafe {
530 | if NEXT {
531 | NEXT = false;
532 | true
533 | } else {
534 | false
535 | }
536 | }
537 | }
538 |
539 | pub fn elapsed() -> Duration {
540 | unsafe { ELAPSED }
541 | }
542 |
543 | pub fn duration() -> Duration {
544 | unsafe { DURATION }
545 | }
546 |
--------------------------------------------------------------------------------
/gonk_player/src/main.rs:
--------------------------------------------------------------------------------
1 | pub use gonk_core::*;
2 | use gonk_player::*;
3 |
4 | fn main() {
5 | let orig_hook = std::panic::take_hook();
6 | std::panic::set_hook(Box::new(move |panic_info| {
7 | orig_hook(panic_info);
8 | std::process::exit(1);
9 | }));
10 |
11 | let device = default_device();
12 | spawn_audio_threads(device);
13 | set_volume(5);
14 | play_path(r"D:\Downloads\test.flac");
15 |
16 | std::thread::park();
17 | }
18 |
--------------------------------------------------------------------------------
/media/broken.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zX3no/gonk/b7da5d3d4dbec8401a5df751314c9d42b1eb158a/media/broken.png
--------------------------------------------------------------------------------
/media/gonk.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zX3no/gonk/b7da5d3d4dbec8401a5df751314c9d42b1eb158a/media/gonk.gif
--------------------------------------------------------------------------------
/media/old.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zX3no/gonk/b7da5d3d4dbec8401a5df751314c9d42b1eb158a/media/old.gif
--------------------------------------------------------------------------------