├── .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 | ![](media/broken.png) 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 --------------------------------------------------------------------------------