A music player that connects to your cloud/distributed storage.

refactor: The audio engine, artwork downloading and the larger part of all the other JS code. (#422)

authored by Steven Vandevelde and committed by GitHub e4c3d067 681f189a

+6
CHANGELOG.md
··· 1 # Changelog 2 3 ## 3.4.0 4 5 - **Improved audio metadata parsing**.
··· 1 # Changelog 2 3 + ## 3.5.0 4 + 5 + - **Improve audio playback and error handling**. 6 + - Minor improvements/fixes to the artwork downloading process. 7 + 8 + 9 ## 3.4.0 10 11 - **Improved audio metadata parsing**.
+13 -7
Justfile
··· 115 --inject:./system/Js/node-shims.js 116 117 # Main 118 - {{ESBUILD}} ./src/Javascript/index.ts \ 119 --outdir={{BUILD_DIR}}/js/ui/ \ 120 --define:BUILD_TIMESTAMP=$build_timestamp \ 121 --splitting ··· 144 --inject:./system/Js/node-shims.js 145 146 # Main 147 - {{ESBUILD}} ./src/Javascript/index.ts \ 148 --outdir={{BUILD_DIR}}/js/ui/ \ 149 --define:BUILD_TIMESTAMP=$build_timestamp \ 150 --splitting \ ··· 180 ) 181 182 183 - @elm-housekeeping: 184 - echo "> Running elm-format" 185 - {{NPM_DIR}}/.bin/elm-format {{SRC_DIR}} --yes 186 - echo "> Running elm-review" 187 - {{ELM_REVIEW}} --fix-all 188 189 190 @quality: check-versions
··· 115 --inject:./system/Js/node-shims.js 116 117 # Main 118 + {{ESBUILD}} ./src/Javascript/UI/index.ts \ 119 --outdir={{BUILD_DIR}}/js/ui/ \ 120 --define:BUILD_TIMESTAMP=$build_timestamp \ 121 --splitting ··· 144 --inject:./system/Js/node-shims.js 145 146 # Main 147 + {{ESBUILD}} ./src/Javascript/UI/index.ts \ 148 --outdir={{BUILD_DIR}}/js/ui/ \ 149 --define:BUILD_TIMESTAMP=$build_timestamp \ 150 --splitting \ ··· 180 ) 181 182 183 + @elm-format: 184 + echo "> Running elm-format" 185 + {{NPM_DIR}}/.bin/elm-format {{SRC_DIR}} --yes 186 + 187 + 188 + @elm-housekeeping: elm-format elm-review 189 + 190 + 191 + @elm-review: 192 + echo "> Running elm-review" 193 + {{ELM_REVIEW}} --fix-all 194 195 196 @quality: check-versions
+2 -1
elm.json
··· 49 "truqu/elm-base64": "2.0.4", 50 "truqu/elm-md5": "1.1.0", 51 "wernerdegroot/listzipper": "4.0.0", 52 - "ymtszw/elm-xml-decode": "3.2.1" 53 }, 54 "indirect": { 55 "elm/bytes": "1.0.8", 56 "elm/parser": "1.1.0", 57 "fredcy/elm-parseint": "2.0.1", 58 "pzp1997/assoc-list": "1.0.0", 59 "zwilias/elm-utf-tools": "2.0.1" 60 }
··· 49 "truqu/elm-base64": "2.0.4", 50 "truqu/elm-md5": "1.1.0", 51 "wernerdegroot/listzipper": "4.0.0", 52 + "ymtszw/elm-xml-decode": "3.2.2" 53 }, 54 "indirect": { 55 "elm/bytes": "1.0.8", 56 "elm/parser": "1.1.0", 57 "fredcy/elm-parseint": "2.0.1", 58 + "miniBill/elm-xml-parser": "1.0.1", 59 "pzp1997/assoc-list": "1.0.0", 60 "zwilias/elm-utf-tools": "2.0.1" 61 }
+1 -1
gren.json
··· 9 "gren-lang/node": "3.0.1", 10 "icidasset/html-gren": "4.1.0", 11 "icidasset/markdown-gren": "3.1.0", 12 - "icidasset/shikensu-gren": "5.0.1" 13 }, 14 "indirect": { 15 "gren-lang/parser": "3.0.1",
··· 9 "gren-lang/node": "3.0.1", 10 "icidasset/html-gren": "4.1.0", 11 "icidasset/markdown-gren": "3.1.0", 12 + "icidasset/shikensu-gren": "5.1.0" 13 }, 14 "indirect": { 15 "gren-lang/parser": "3.0.1",
+304 -266
package-lock.json
··· 1 { 2 "name": "diffuse", 3 - "version": "3.4.0", 4 "lockfileVersion": 2, 5 "requires": true, 6 "packages": { 7 "": { 8 "name": "diffuse", 9 - "version": "3.4.0", 10 "license": "SEE LICENSE IN LICENSE", 11 "dependencies": { 12 "@oddjs/odd": "^0.37.2", ··· 15 "encoding-japanese": "^2.0.0", 16 "fast-text-encoding": "^1.0.6", 17 "file-saver": "^2.0.2", 18 - "jschardet": "^3.0.0", 19 "jszip": "^3.7.1", 20 "load-script2": "^2.0.5", 21 "localforage": "^1.10.0", 22 "lunr": "^2.3.8", 23 - "mediainfo.js": "^0.2.1", 24 "music-metadata-browser": "^2.5.10", 25 "readable-stream": "^4.5.2", 26 "remotestoragejs": "^2.0.0-beta.6", ··· 36 "@tauri-apps/plugin-dialog": "^2.0.0-beta.0", 37 "@tauri-apps/plugin-fs": "^2.0.0-beta.0", 38 "@tauri-apps/plugin-shell": "^2.0.0-beta.0", 39 "@typescript-eslint/eslint-plugin": "^6.21.0", 40 "@typescript-eslint/parser": "^6.21.0", 41 "assert": "^2.1.0", 42 - "autoprefixer": "^10.4.17", 43 "buffer": "^6.0.3", 44 "elm": "0.19.1-6", 45 "elm-format": "^0.8.7", 46 "elm-review": "^2.10.3", 47 - "esbuild": "^0.20.0", 48 "esbuild-plugin-wasm": "^1.1.0", 49 "eslint": "^8.56.0", 50 "events": "^3.3.0", ··· 276 ] 277 }, 278 "node_modules/@esbuild/aix-ppc64": { 279 - "version": "0.20.0", 280 - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.0.tgz", 281 - "integrity": "sha512-fGFDEctNh0CcSwsiRPxiaqX0P5rq+AqE0SRhYGZ4PX46Lg1FNR6oCxJghf8YgY0WQEgQuh3lErUFE4KxLeRmmw==", 282 "cpu": [ 283 "ppc64" 284 ], ··· 292 } 293 }, 294 "node_modules/@esbuild/android-arm": { 295 - "version": "0.20.0", 296 - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.0.tgz", 297 - "integrity": "sha512-3bMAfInvByLHfJwYPJRlpTeaQA75n8C/QKpEaiS4HrFWFiJlNI0vzq/zCjBrhAYcPyVPG7Eo9dMrcQXuqmNk5g==", 298 "cpu": [ 299 "arm" 300 ], ··· 308 } 309 }, 310 "node_modules/@esbuild/android-arm64": { 311 - "version": "0.20.0", 312 - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.0.tgz", 313 - "integrity": "sha512-aVpnM4lURNkp0D3qPoAzSG92VXStYmoVPOgXveAUoQBWRSuQzt51yvSju29J6AHPmwY1BjH49uR29oyfH1ra8Q==", 314 "cpu": [ 315 "arm64" 316 ], ··· 324 } 325 }, 326 "node_modules/@esbuild/android-x64": { 327 - "version": "0.20.0", 328 - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.0.tgz", 329 - "integrity": "sha512-uK7wAnlRvjkCPzh8jJ+QejFyrP8ObKuR5cBIsQZ+qbMunwR8sbd8krmMbxTLSrDhiPZaJYKQAU5Y3iMDcZPhyQ==", 330 "cpu": [ 331 "x64" 332 ], ··· 340 } 341 }, 342 "node_modules/@esbuild/darwin-arm64": { 343 - "version": "0.20.0", 344 - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.0.tgz", 345 - "integrity": "sha512-AjEcivGAlPs3UAcJedMa9qYg9eSfU6FnGHJjT8s346HSKkrcWlYezGE8VaO2xKfvvlZkgAhyvl06OJOxiMgOYQ==", 346 "cpu": [ 347 "arm64" 348 ], ··· 356 } 357 }, 358 "node_modules/@esbuild/darwin-x64": { 359 - "version": "0.20.0", 360 - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.0.tgz", 361 - "integrity": "sha512-bsgTPoyYDnPv8ER0HqnJggXK6RyFy4PH4rtsId0V7Efa90u2+EifxytE9pZnsDgExgkARy24WUQGv9irVbTvIw==", 362 "cpu": [ 363 "x64" 364 ], ··· 372 } 373 }, 374 "node_modules/@esbuild/freebsd-arm64": { 375 - "version": "0.20.0", 376 - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.0.tgz", 377 - "integrity": "sha512-kQ7jYdlKS335mpGbMW5tEe3IrQFIok9r84EM3PXB8qBFJPSc6dpWfrtsC/y1pyrz82xfUIn5ZrnSHQQsd6jebQ==", 378 "cpu": [ 379 "arm64" 380 ], ··· 388 } 389 }, 390 "node_modules/@esbuild/freebsd-x64": { 391 - "version": "0.20.0", 392 - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.0.tgz", 393 - "integrity": "sha512-uG8B0WSepMRsBNVXAQcHf9+Ko/Tr+XqmK7Ptel9HVmnykupXdS4J7ovSQUIi0tQGIndhbqWLaIL/qO/cWhXKyQ==", 394 "cpu": [ 395 "x64" 396 ], ··· 404 } 405 }, 406 "node_modules/@esbuild/linux-arm": { 407 - "version": "0.20.0", 408 - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.0.tgz", 409 - "integrity": "sha512-2ezuhdiZw8vuHf1HKSf4TIk80naTbP9At7sOqZmdVwvvMyuoDiZB49YZKLsLOfKIr77+I40dWpHVeY5JHpIEIg==", 410 "cpu": [ 411 "arm" 412 ], ··· 420 } 421 }, 422 "node_modules/@esbuild/linux-arm64": { 423 - "version": "0.20.0", 424 - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.0.tgz", 425 - "integrity": "sha512-uTtyYAP5veqi2z9b6Gr0NUoNv9F/rOzI8tOD5jKcCvRUn7T60Bb+42NDBCWNhMjkQzI0qqwXkQGo1SY41G52nw==", 426 "cpu": [ 427 "arm64" 428 ], ··· 436 } 437 }, 438 "node_modules/@esbuild/linux-ia32": { 439 - "version": "0.20.0", 440 - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.0.tgz", 441 - "integrity": "sha512-c88wwtfs8tTffPaoJ+SQn3y+lKtgTzyjkD8NgsyCtCmtoIC8RDL7PrJU05an/e9VuAke6eJqGkoMhJK1RY6z4w==", 442 "cpu": [ 443 "ia32" 444 ], ··· 452 } 453 }, 454 "node_modules/@esbuild/linux-loong64": { 455 - "version": "0.20.0", 456 - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.0.tgz", 457 - "integrity": "sha512-lR2rr/128/6svngnVta6JN4gxSXle/yZEZL3o4XZ6esOqhyR4wsKyfu6qXAL04S4S5CgGfG+GYZnjFd4YiG3Aw==", 458 "cpu": [ 459 "loong64" 460 ], ··· 468 } 469 }, 470 "node_modules/@esbuild/linux-mips64el": { 471 - "version": "0.20.0", 472 - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.0.tgz", 473 - "integrity": "sha512-9Sycc+1uUsDnJCelDf6ZNqgZQoK1mJvFtqf2MUz4ujTxGhvCWw+4chYfDLPepMEvVL9PDwn6HrXad5yOrNzIsQ==", 474 "cpu": [ 475 "mips64el" 476 ], ··· 484 } 485 }, 486 "node_modules/@esbuild/linux-ppc64": { 487 - "version": "0.20.0", 488 - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.0.tgz", 489 - "integrity": "sha512-CoWSaaAXOZd+CjbUTdXIJE/t7Oz+4g90A3VBCHLbfuc5yUQU/nFDLOzQsN0cdxgXd97lYW/psIIBdjzQIwTBGw==", 490 "cpu": [ 491 "ppc64" 492 ], ··· 500 } 501 }, 502 "node_modules/@esbuild/linux-riscv64": { 503 - "version": "0.20.0", 504 - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.0.tgz", 505 - "integrity": "sha512-mlb1hg/eYRJUpv8h/x+4ShgoNLL8wgZ64SUr26KwglTYnwAWjkhR2GpoKftDbPOCnodA9t4Y/b68H4J9XmmPzA==", 506 "cpu": [ 507 "riscv64" 508 ], ··· 516 } 517 }, 518 "node_modules/@esbuild/linux-s390x": { 519 - "version": "0.20.0", 520 - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.0.tgz", 521 - "integrity": "sha512-fgf9ubb53xSnOBqyvWEY6ukBNRl1mVX1srPNu06B6mNsNK20JfH6xV6jECzrQ69/VMiTLvHMicQR/PgTOgqJUQ==", 522 "cpu": [ 523 "s390x" 524 ], ··· 532 } 533 }, 534 "node_modules/@esbuild/linux-x64": { 535 - "version": "0.20.0", 536 - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.0.tgz", 537 - "integrity": "sha512-H9Eu6MGse++204XZcYsse1yFHmRXEWgadk2N58O/xd50P9EvFMLJTQLg+lB4E1cF2xhLZU5luSWtGTb0l9UeSg==", 538 "cpu": [ 539 "x64" 540 ], ··· 548 } 549 }, 550 "node_modules/@esbuild/netbsd-x64": { 551 - "version": "0.20.0", 552 - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.0.tgz", 553 - "integrity": "sha512-lCT675rTN1v8Fo+RGrE5KjSnfY0x9Og4RN7t7lVrN3vMSjy34/+3na0q7RIfWDAj0e0rCh0OL+P88lu3Rt21MQ==", 554 "cpu": [ 555 "x64" 556 ], ··· 564 } 565 }, 566 "node_modules/@esbuild/openbsd-x64": { 567 - "version": "0.20.0", 568 - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.0.tgz", 569 - "integrity": "sha512-HKoUGXz/TOVXKQ+67NhxyHv+aDSZf44QpWLa3I1lLvAwGq8x1k0T+e2HHSRvxWhfJrFxaaqre1+YyzQ99KixoA==", 570 "cpu": [ 571 "x64" 572 ], ··· 580 } 581 }, 582 "node_modules/@esbuild/sunos-x64": { 583 - "version": "0.20.0", 584 - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.0.tgz", 585 - "integrity": "sha512-GDwAqgHQm1mVoPppGsoq4WJwT3vhnz/2N62CzhvApFD1eJyTroob30FPpOZabN+FgCjhG+AgcZyOPIkR8dfD7g==", 586 "cpu": [ 587 "x64" 588 ], ··· 596 } 597 }, 598 "node_modules/@esbuild/win32-arm64": { 599 - "version": "0.20.0", 600 - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.0.tgz", 601 - "integrity": "sha512-0vYsP8aC4TvMlOQYozoksiaxjlvUcQrac+muDqj1Fxy6jh9l9CZJzj7zmh8JGfiV49cYLTorFLxg7593pGldwQ==", 602 "cpu": [ 603 "arm64" 604 ], ··· 612 } 613 }, 614 "node_modules/@esbuild/win32-ia32": { 615 - "version": "0.20.0", 616 - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.0.tgz", 617 - "integrity": "sha512-p98u4rIgfh4gdpV00IqknBD5pC84LCub+4a3MO+zjqvU5MVXOc3hqR2UgT2jI2nh3h8s9EQxmOsVI3tyzv1iFg==", 618 "cpu": [ 619 "ia32" 620 ], ··· 628 } 629 }, 630 "node_modules/@esbuild/win32-x64": { 631 - "version": "0.20.0", 632 - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.0.tgz", 633 - "integrity": "sha512-NgJnesu1RtWihtTtXGFMU5YSE6JyyHPMxCwBZK7a6/8d31GuSo9l0Ss7w1Jw5QnKUawG6UEehs883kcXf5fYwg==", 634 "cpu": [ 635 "x64" 636 ], ··· 1654 "@types/responselike": "^1.0.0" 1655 } 1656 }, 1657 "node_modules/@types/http-cache-semantics": { 1658 "version": "4.0.1", 1659 "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", ··· 1675 "@types/node": "*" 1676 } 1677 }, 1678 "node_modules/@types/node": { 1679 "version": "18.16.3", 1680 "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.3.tgz", ··· 1693 "version": "7.5.6", 1694 "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", 1695 "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", 1696 "dev": true 1697 }, 1698 "node_modules/@types/tv4": { ··· 2150 } 2151 }, 2152 "node_modules/autoprefixer": { 2153 - "version": "10.4.17", 2154 - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", 2155 - "integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==", 2156 "dev": true, 2157 "funding": [ 2158 { ··· 2169 } 2170 ], 2171 "dependencies": { 2172 - "browserslist": "^4.22.2", 2173 - "caniuse-lite": "^1.0.30001578", 2174 "fraction.js": "^4.3.7", 2175 "normalize-range": "^0.1.2", 2176 "picocolors": "^1.0.0", ··· 2493 } 2494 }, 2495 "node_modules/browserslist": { 2496 - "version": "4.22.3", 2497 - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz", 2498 - "integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==", 2499 "dev": true, 2500 "funding": [ 2501 { ··· 2512 } 2513 ], 2514 "dependencies": { 2515 - "caniuse-lite": "^1.0.30001580", 2516 - "electron-to-chromium": "^1.4.648", 2517 "node-releases": "^2.0.14", 2518 - "update-browserslist-db": "^1.0.13" 2519 }, 2520 "bin": { 2521 "browserslist": "cli.js" ··· 2650 } 2651 }, 2652 "node_modules/caniuse-lite": { 2653 - "version": "1.0.30001584", 2654 - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001584.tgz", 2655 - "integrity": "sha512-LOz7CCQ9M1G7OjJOF9/mzmqmj3jE/7VOmrfw6Mgs0E8cjOsbRXQJHsPBfmBOXDskXKrHLyyW3n7kpDW/4BsfpQ==", 2656 "dev": true, 2657 "funding": [ 2658 { ··· 3265 "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" 3266 }, 3267 "node_modules/electron-to-chromium": { 3268 - "version": "1.4.657", 3269 - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.657.tgz", 3270 - "integrity": "sha512-On2ymeleg6QbRuDk7wNgDdXtNqlJLM2w4Agx1D/RiTmItiL+a9oq5p7HUa2ZtkAtGBe/kil2dq/7rPfkbe0r5w==", 3271 "dev": true 3272 }, 3273 "node_modules/elm": { ··· 3464 } 3465 }, 3466 "node_modules/esbuild": { 3467 - "version": "0.20.0", 3468 - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.0.tgz", 3469 - "integrity": "sha512-6iwE3Y2RVYCME1jLpBqq7LQWK3MW6vjV2bZy6gt/WrqkY+WE74Spyc0ThAOYpMtITvnjX09CrC6ym7A/m9mebA==", 3470 "dev": true, 3471 "hasInstallScript": true, 3472 "bin": { ··· 3476 "node": ">=12" 3477 }, 3478 "optionalDependencies": { 3479 - "@esbuild/aix-ppc64": "0.20.0", 3480 - "@esbuild/android-arm": "0.20.0", 3481 - "@esbuild/android-arm64": "0.20.0", 3482 - "@esbuild/android-x64": "0.20.0", 3483 - "@esbuild/darwin-arm64": "0.20.0", 3484 - "@esbuild/darwin-x64": "0.20.0", 3485 - "@esbuild/freebsd-arm64": "0.20.0", 3486 - "@esbuild/freebsd-x64": "0.20.0", 3487 - "@esbuild/linux-arm": "0.20.0", 3488 - "@esbuild/linux-arm64": "0.20.0", 3489 - "@esbuild/linux-ia32": "0.20.0", 3490 - "@esbuild/linux-loong64": "0.20.0", 3491 - "@esbuild/linux-mips64el": "0.20.0", 3492 - "@esbuild/linux-ppc64": "0.20.0", 3493 - "@esbuild/linux-riscv64": "0.20.0", 3494 - "@esbuild/linux-s390x": "0.20.0", 3495 - "@esbuild/linux-x64": "0.20.0", 3496 - "@esbuild/netbsd-x64": "0.20.0", 3497 - "@esbuild/openbsd-x64": "0.20.0", 3498 - "@esbuild/sunos-x64": "0.20.0", 3499 - "@esbuild/win32-arm64": "0.20.0", 3500 - "@esbuild/win32-ia32": "0.20.0", 3501 - "@esbuild/win32-x64": "0.20.0" 3502 } 3503 }, 3504 "node_modules/esbuild-plugin-wasm": { ··· 5187 "js-yaml": "bin/js-yaml.js" 5188 } 5189 }, 5190 - "node_modules/jschardet": { 5191 - "version": "3.0.0", 5192 - "resolved": "https://registry.npmjs.org/jschardet/-/jschardet-3.0.0.tgz", 5193 - "integrity": "sha512-lJH6tJ77V8Nzd5QWRkFYCLc13a3vADkh3r/Fi8HupZGWk2OVVDfnZP8V/VgQgZ+lzW0kG2UGb5hFgt3V3ndotQ==", 5194 - "engines": { 5195 - "node": ">=0.1.90" 5196 - } 5197 - }, 5198 "node_modules/json-buffer": { 5199 "version": "3.0.1", 5200 "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", ··· 5549 } 5550 }, 5551 "node_modules/mediainfo.js": { 5552 - "version": "0.2.1", 5553 - "resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.2.1.tgz", 5554 - "integrity": "sha512-xbTstvy34gDmxNLVytixbY8Uw4DGKKsQIMvX7q1K8FwIk/gwAVLd30EVvPh/g+QHVscATRuqrNtbTb7XUjDeyw==", 5555 "dependencies": { 5556 "yargs": "^17.7.2" 5557 }, ··· 5559 "mediainfo.js": "dist/esm/cli.js" 5560 }, 5561 "engines": { 5562 - "node": ">=14.16" 5563 } 5564 }, 5565 "node_modules/merge-options": { ··· 6252 "dev": true 6253 }, 6254 "node_modules/picocolors": { 6255 - "version": "1.0.0", 6256 - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", 6257 - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", 6258 "dev": true 6259 }, 6260 "node_modules/picomatch": { ··· 7590 } 7591 }, 7592 "node_modules/update-browserslist-db": { 7593 - "version": "1.0.13", 7594 - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", 7595 - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", 7596 "dev": true, 7597 "funding": [ 7598 { ··· 7609 } 7610 ], 7611 "dependencies": { 7612 - "escalade": "^3.1.1", 7613 - "picocolors": "^1.0.0" 7614 }, 7615 "bin": { 7616 "update-browserslist-db": "cli.js" ··· 8053 "optional": true 8054 }, 8055 "@esbuild/aix-ppc64": { 8056 - "version": "0.20.0", 8057 - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.0.tgz", 8058 - "integrity": "sha512-fGFDEctNh0CcSwsiRPxiaqX0P5rq+AqE0SRhYGZ4PX46Lg1FNR6oCxJghf8YgY0WQEgQuh3lErUFE4KxLeRmmw==", 8059 "dev": true, 8060 "optional": true 8061 }, 8062 "@esbuild/android-arm": { 8063 - "version": "0.20.0", 8064 - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.0.tgz", 8065 - "integrity": "sha512-3bMAfInvByLHfJwYPJRlpTeaQA75n8C/QKpEaiS4HrFWFiJlNI0vzq/zCjBrhAYcPyVPG7Eo9dMrcQXuqmNk5g==", 8066 "dev": true, 8067 "optional": true 8068 }, 8069 "@esbuild/android-arm64": { 8070 - "version": "0.20.0", 8071 - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.0.tgz", 8072 - "integrity": "sha512-aVpnM4lURNkp0D3qPoAzSG92VXStYmoVPOgXveAUoQBWRSuQzt51yvSju29J6AHPmwY1BjH49uR29oyfH1ra8Q==", 8073 "dev": true, 8074 "optional": true 8075 }, 8076 "@esbuild/android-x64": { 8077 - "version": "0.20.0", 8078 - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.0.tgz", 8079 - "integrity": "sha512-uK7wAnlRvjkCPzh8jJ+QejFyrP8ObKuR5cBIsQZ+qbMunwR8sbd8krmMbxTLSrDhiPZaJYKQAU5Y3iMDcZPhyQ==", 8080 "dev": true, 8081 "optional": true 8082 }, 8083 "@esbuild/darwin-arm64": { 8084 - "version": "0.20.0", 8085 - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.0.tgz", 8086 - "integrity": "sha512-AjEcivGAlPs3UAcJedMa9qYg9eSfU6FnGHJjT8s346HSKkrcWlYezGE8VaO2xKfvvlZkgAhyvl06OJOxiMgOYQ==", 8087 "dev": true, 8088 "optional": true 8089 }, 8090 "@esbuild/darwin-x64": { 8091 - "version": "0.20.0", 8092 - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.0.tgz", 8093 - "integrity": "sha512-bsgTPoyYDnPv8ER0HqnJggXK6RyFy4PH4rtsId0V7Efa90u2+EifxytE9pZnsDgExgkARy24WUQGv9irVbTvIw==", 8094 "dev": true, 8095 "optional": true 8096 }, 8097 "@esbuild/freebsd-arm64": { 8098 - "version": "0.20.0", 8099 - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.0.tgz", 8100 - "integrity": "sha512-kQ7jYdlKS335mpGbMW5tEe3IrQFIok9r84EM3PXB8qBFJPSc6dpWfrtsC/y1pyrz82xfUIn5ZrnSHQQsd6jebQ==", 8101 "dev": true, 8102 "optional": true 8103 }, 8104 "@esbuild/freebsd-x64": { 8105 - "version": "0.20.0", 8106 - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.0.tgz", 8107 - "integrity": "sha512-uG8B0WSepMRsBNVXAQcHf9+Ko/Tr+XqmK7Ptel9HVmnykupXdS4J7ovSQUIi0tQGIndhbqWLaIL/qO/cWhXKyQ==", 8108 "dev": true, 8109 "optional": true 8110 }, 8111 "@esbuild/linux-arm": { 8112 - "version": "0.20.0", 8113 - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.0.tgz", 8114 - "integrity": "sha512-2ezuhdiZw8vuHf1HKSf4TIk80naTbP9At7sOqZmdVwvvMyuoDiZB49YZKLsLOfKIr77+I40dWpHVeY5JHpIEIg==", 8115 "dev": true, 8116 "optional": true 8117 }, 8118 "@esbuild/linux-arm64": { 8119 - "version": "0.20.0", 8120 - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.0.tgz", 8121 - "integrity": "sha512-uTtyYAP5veqi2z9b6Gr0NUoNv9F/rOzI8tOD5jKcCvRUn7T60Bb+42NDBCWNhMjkQzI0qqwXkQGo1SY41G52nw==", 8122 "dev": true, 8123 "optional": true 8124 }, 8125 "@esbuild/linux-ia32": { 8126 - "version": "0.20.0", 8127 - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.0.tgz", 8128 - "integrity": "sha512-c88wwtfs8tTffPaoJ+SQn3y+lKtgTzyjkD8NgsyCtCmtoIC8RDL7PrJU05an/e9VuAke6eJqGkoMhJK1RY6z4w==", 8129 "dev": true, 8130 "optional": true 8131 }, 8132 "@esbuild/linux-loong64": { 8133 - "version": "0.20.0", 8134 - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.0.tgz", 8135 - "integrity": "sha512-lR2rr/128/6svngnVta6JN4gxSXle/yZEZL3o4XZ6esOqhyR4wsKyfu6qXAL04S4S5CgGfG+GYZnjFd4YiG3Aw==", 8136 "dev": true, 8137 "optional": true 8138 }, 8139 "@esbuild/linux-mips64el": { 8140 - "version": "0.20.0", 8141 - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.0.tgz", 8142 - "integrity": "sha512-9Sycc+1uUsDnJCelDf6ZNqgZQoK1mJvFtqf2MUz4ujTxGhvCWw+4chYfDLPepMEvVL9PDwn6HrXad5yOrNzIsQ==", 8143 "dev": true, 8144 "optional": true 8145 }, 8146 "@esbuild/linux-ppc64": { 8147 - "version": "0.20.0", 8148 - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.0.tgz", 8149 - "integrity": "sha512-CoWSaaAXOZd+CjbUTdXIJE/t7Oz+4g90A3VBCHLbfuc5yUQU/nFDLOzQsN0cdxgXd97lYW/psIIBdjzQIwTBGw==", 8150 "dev": true, 8151 "optional": true 8152 }, 8153 "@esbuild/linux-riscv64": { 8154 - "version": "0.20.0", 8155 - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.0.tgz", 8156 - "integrity": "sha512-mlb1hg/eYRJUpv8h/x+4ShgoNLL8wgZ64SUr26KwglTYnwAWjkhR2GpoKftDbPOCnodA9t4Y/b68H4J9XmmPzA==", 8157 "dev": true, 8158 "optional": true 8159 }, 8160 "@esbuild/linux-s390x": { 8161 - "version": "0.20.0", 8162 - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.0.tgz", 8163 - "integrity": "sha512-fgf9ubb53xSnOBqyvWEY6ukBNRl1mVX1srPNu06B6mNsNK20JfH6xV6jECzrQ69/VMiTLvHMicQR/PgTOgqJUQ==", 8164 "dev": true, 8165 "optional": true 8166 }, 8167 "@esbuild/linux-x64": { 8168 - "version": "0.20.0", 8169 - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.0.tgz", 8170 - "integrity": "sha512-H9Eu6MGse++204XZcYsse1yFHmRXEWgadk2N58O/xd50P9EvFMLJTQLg+lB4E1cF2xhLZU5luSWtGTb0l9UeSg==", 8171 "dev": true, 8172 "optional": true 8173 }, 8174 "@esbuild/netbsd-x64": { 8175 - "version": "0.20.0", 8176 - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.0.tgz", 8177 - "integrity": "sha512-lCT675rTN1v8Fo+RGrE5KjSnfY0x9Og4RN7t7lVrN3vMSjy34/+3na0q7RIfWDAj0e0rCh0OL+P88lu3Rt21MQ==", 8178 "dev": true, 8179 "optional": true 8180 }, 8181 "@esbuild/openbsd-x64": { 8182 - "version": "0.20.0", 8183 - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.0.tgz", 8184 - "integrity": "sha512-HKoUGXz/TOVXKQ+67NhxyHv+aDSZf44QpWLa3I1lLvAwGq8x1k0T+e2HHSRvxWhfJrFxaaqre1+YyzQ99KixoA==", 8185 "dev": true, 8186 "optional": true 8187 }, 8188 "@esbuild/sunos-x64": { 8189 - "version": "0.20.0", 8190 - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.0.tgz", 8191 - "integrity": "sha512-GDwAqgHQm1mVoPppGsoq4WJwT3vhnz/2N62CzhvApFD1eJyTroob30FPpOZabN+FgCjhG+AgcZyOPIkR8dfD7g==", 8192 "dev": true, 8193 "optional": true 8194 }, 8195 "@esbuild/win32-arm64": { 8196 - "version": "0.20.0", 8197 - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.0.tgz", 8198 - "integrity": "sha512-0vYsP8aC4TvMlOQYozoksiaxjlvUcQrac+muDqj1Fxy6jh9l9CZJzj7zmh8JGfiV49cYLTorFLxg7593pGldwQ==", 8199 "dev": true, 8200 "optional": true 8201 }, 8202 "@esbuild/win32-ia32": { 8203 - "version": "0.20.0", 8204 - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.0.tgz", 8205 - "integrity": "sha512-p98u4rIgfh4gdpV00IqknBD5pC84LCub+4a3MO+zjqvU5MVXOc3hqR2UgT2jI2nh3h8s9EQxmOsVI3tyzv1iFg==", 8206 "dev": true, 8207 "optional": true 8208 }, 8209 "@esbuild/win32-x64": { 8210 - "version": "0.20.0", 8211 - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.0.tgz", 8212 - "integrity": "sha512-NgJnesu1RtWihtTtXGFMU5YSE6JyyHPMxCwBZK7a6/8d31GuSo9l0Ss7w1Jw5QnKUawG6UEehs883kcXf5fYwg==", 8213 "dev": true, 8214 "optional": true 8215 }, ··· 8921 "@types/responselike": "^1.0.0" 8922 } 8923 }, 8924 "@types/http-cache-semantics": { 8925 "version": "4.0.1", 8926 "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", ··· 8942 "@types/node": "*" 8943 } 8944 }, 8945 "@types/node": { 8946 "version": "18.16.3", 8947 "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.3.tgz", ··· 8960 "version": "7.5.6", 8961 "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", 8962 "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", 8963 "dev": true 8964 }, 8965 "@types/tv4": { ··· 9261 "dev": true 9262 }, 9263 "autoprefixer": { 9264 - "version": "10.4.17", 9265 - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", 9266 - "integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==", 9267 "dev": true, 9268 "requires": { 9269 - "browserslist": "^4.22.2", 9270 - "caniuse-lite": "^1.0.30001578", 9271 "fraction.js": "^4.3.7", 9272 "normalize-range": "^0.1.2", 9273 "picocolors": "^1.0.0", ··· 9478 } 9479 }, 9480 "browserslist": { 9481 - "version": "4.22.3", 9482 - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz", 9483 - "integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==", 9484 "dev": true, 9485 "requires": { 9486 - "caniuse-lite": "^1.0.30001580", 9487 - "electron-to-chromium": "^1.4.648", 9488 "node-releases": "^2.0.14", 9489 - "update-browserslist-db": "^1.0.13" 9490 } 9491 }, 9492 "buffer": { ··· 9568 "dev": true 9569 }, 9570 "caniuse-lite": { 9571 - "version": "1.0.30001584", 9572 - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001584.tgz", 9573 - "integrity": "sha512-LOz7CCQ9M1G7OjJOF9/mzmqmj3jE/7VOmrfw6Mgs0E8cjOsbRXQJHsPBfmBOXDskXKrHLyyW3n7kpDW/4BsfpQ==", 9574 "dev": true 9575 }, 9576 "catering": { ··· 9997 "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" 9998 }, 9999 "electron-to-chromium": { 10000 - "version": "1.4.657", 10001 - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.657.tgz", 10002 - "integrity": "sha512-On2ymeleg6QbRuDk7wNgDdXtNqlJLM2w4Agx1D/RiTmItiL+a9oq5p7HUa2ZtkAtGBe/kil2dq/7rPfkbe0r5w==", 10003 "dev": true 10004 }, 10005 "elm": { ··· 10147 "dev": true 10148 }, 10149 "esbuild": { 10150 - "version": "0.20.0", 10151 - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.0.tgz", 10152 - "integrity": "sha512-6iwE3Y2RVYCME1jLpBqq7LQWK3MW6vjV2bZy6gt/WrqkY+WE74Spyc0ThAOYpMtITvnjX09CrC6ym7A/m9mebA==", 10153 "dev": true, 10154 "requires": { 10155 - "@esbuild/aix-ppc64": "0.20.0", 10156 - "@esbuild/android-arm": "0.20.0", 10157 - "@esbuild/android-arm64": "0.20.0", 10158 - "@esbuild/android-x64": "0.20.0", 10159 - "@esbuild/darwin-arm64": "0.20.0", 10160 - "@esbuild/darwin-x64": "0.20.0", 10161 - "@esbuild/freebsd-arm64": "0.20.0", 10162 - "@esbuild/freebsd-x64": "0.20.0", 10163 - "@esbuild/linux-arm": "0.20.0", 10164 - "@esbuild/linux-arm64": "0.20.0", 10165 - "@esbuild/linux-ia32": "0.20.0", 10166 - "@esbuild/linux-loong64": "0.20.0", 10167 - "@esbuild/linux-mips64el": "0.20.0", 10168 - "@esbuild/linux-ppc64": "0.20.0", 10169 - "@esbuild/linux-riscv64": "0.20.0", 10170 - "@esbuild/linux-s390x": "0.20.0", 10171 - "@esbuild/linux-x64": "0.20.0", 10172 - "@esbuild/netbsd-x64": "0.20.0", 10173 - "@esbuild/openbsd-x64": "0.20.0", 10174 - "@esbuild/sunos-x64": "0.20.0", 10175 - "@esbuild/win32-arm64": "0.20.0", 10176 - "@esbuild/win32-ia32": "0.20.0", 10177 - "@esbuild/win32-x64": "0.20.0" 10178 } 10179 }, 10180 "esbuild-plugin-wasm": { ··· 11337 "argparse": "^2.0.1" 11338 } 11339 }, 11340 - "jschardet": { 11341 - "version": "3.0.0", 11342 - "resolved": "https://registry.npmjs.org/jschardet/-/jschardet-3.0.0.tgz", 11343 - "integrity": "sha512-lJH6tJ77V8Nzd5QWRkFYCLc13a3vADkh3r/Fi8HupZGWk2OVVDfnZP8V/VgQgZ+lzW0kG2UGb5hFgt3V3ndotQ==" 11344 - }, 11345 "json-buffer": { 11346 "version": "3.0.1", 11347 "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", ··· 11630 "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" 11631 }, 11632 "mediainfo.js": { 11633 - "version": "0.2.1", 11634 - "resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.2.1.tgz", 11635 - "integrity": "sha512-xbTstvy34gDmxNLVytixbY8Uw4DGKKsQIMvX7q1K8FwIk/gwAVLd30EVvPh/g+QHVscATRuqrNtbTb7XUjDeyw==", 11636 "requires": { 11637 "yargs": "^17.7.2" 11638 } ··· 12103 "dev": true 12104 }, 12105 "picocolors": { 12106 - "version": "1.0.0", 12107 - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", 12108 - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", 12109 "dev": true 12110 }, 12111 "picomatch": { ··· 13030 "dev": true 13031 }, 13032 "update-browserslist-db": { 13033 - "version": "1.0.13", 13034 - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", 13035 - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", 13036 "dev": true, 13037 "requires": { 13038 - "escalade": "^3.1.1", 13039 - "picocolors": "^1.0.0" 13040 } 13041 }, 13042 "update-check": {
··· 1 { 2 "name": "diffuse", 3 + "version": "3.5.0", 4 "lockfileVersion": 2, 5 "requires": true, 6 "packages": { 7 "": { 8 "name": "diffuse", 9 + "version": "3.5.0", 10 "license": "SEE LICENSE IN LICENSE", 11 "dependencies": { 12 "@oddjs/odd": "^0.37.2", ··· 15 "encoding-japanese": "^2.0.0", 16 "fast-text-encoding": "^1.0.6", 17 "file-saver": "^2.0.2", 18 "jszip": "^3.7.1", 19 "load-script2": "^2.0.5", 20 "localforage": "^1.10.0", 21 "lunr": "^2.3.8", 22 + "mediainfo.js": "^0.3.1", 23 "music-metadata-browser": "^2.5.10", 24 "readable-stream": "^4.5.2", 25 "remotestoragejs": "^2.0.0-beta.6", ··· 35 "@tauri-apps/plugin-dialog": "^2.0.0-beta.0", 36 "@tauri-apps/plugin-fs": "^2.0.0-beta.0", 37 "@tauri-apps/plugin-shell": "^2.0.0-beta.0", 38 + "@types/elm": "^0.19.3", 39 + "@types/file-saver": "^2.0.7", 40 + "@types/lunr": "^2.3.7", 41 + "@types/throttle-debounce": "^5.0.2", 42 "@typescript-eslint/eslint-plugin": "^6.21.0", 43 "@typescript-eslint/parser": "^6.21.0", 44 "assert": "^2.1.0", 45 + "autoprefixer": "^10.4.19", 46 "buffer": "^6.0.3", 47 "elm": "0.19.1-6", 48 "elm-format": "^0.8.7", 49 "elm-review": "^2.10.3", 50 + "esbuild": "^0.20.2", 51 "esbuild-plugin-wasm": "^1.1.0", 52 "eslint": "^8.56.0", 53 "events": "^3.3.0", ··· 279 ] 280 }, 281 "node_modules/@esbuild/aix-ppc64": { 282 + "version": "0.20.2", 283 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", 284 + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", 285 "cpu": [ 286 "ppc64" 287 ], ··· 295 } 296 }, 297 "node_modules/@esbuild/android-arm": { 298 + "version": "0.20.2", 299 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", 300 + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", 301 "cpu": [ 302 "arm" 303 ], ··· 311 } 312 }, 313 "node_modules/@esbuild/android-arm64": { 314 + "version": "0.20.2", 315 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", 316 + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", 317 "cpu": [ 318 "arm64" 319 ], ··· 327 } 328 }, 329 "node_modules/@esbuild/android-x64": { 330 + "version": "0.20.2", 331 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", 332 + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", 333 "cpu": [ 334 "x64" 335 ], ··· 343 } 344 }, 345 "node_modules/@esbuild/darwin-arm64": { 346 + "version": "0.20.2", 347 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", 348 + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", 349 "cpu": [ 350 "arm64" 351 ], ··· 359 } 360 }, 361 "node_modules/@esbuild/darwin-x64": { 362 + "version": "0.20.2", 363 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", 364 + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", 365 "cpu": [ 366 "x64" 367 ], ··· 375 } 376 }, 377 "node_modules/@esbuild/freebsd-arm64": { 378 + "version": "0.20.2", 379 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", 380 + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", 381 "cpu": [ 382 "arm64" 383 ], ··· 391 } 392 }, 393 "node_modules/@esbuild/freebsd-x64": { 394 + "version": "0.20.2", 395 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", 396 + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", 397 "cpu": [ 398 "x64" 399 ], ··· 407 } 408 }, 409 "node_modules/@esbuild/linux-arm": { 410 + "version": "0.20.2", 411 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", 412 + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", 413 "cpu": [ 414 "arm" 415 ], ··· 423 } 424 }, 425 "node_modules/@esbuild/linux-arm64": { 426 + "version": "0.20.2", 427 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", 428 + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", 429 "cpu": [ 430 "arm64" 431 ], ··· 439 } 440 }, 441 "node_modules/@esbuild/linux-ia32": { 442 + "version": "0.20.2", 443 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", 444 + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", 445 "cpu": [ 446 "ia32" 447 ], ··· 455 } 456 }, 457 "node_modules/@esbuild/linux-loong64": { 458 + "version": "0.20.2", 459 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", 460 + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", 461 "cpu": [ 462 "loong64" 463 ], ··· 471 } 472 }, 473 "node_modules/@esbuild/linux-mips64el": { 474 + "version": "0.20.2", 475 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", 476 + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", 477 "cpu": [ 478 "mips64el" 479 ], ··· 487 } 488 }, 489 "node_modules/@esbuild/linux-ppc64": { 490 + "version": "0.20.2", 491 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", 492 + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", 493 "cpu": [ 494 "ppc64" 495 ], ··· 503 } 504 }, 505 "node_modules/@esbuild/linux-riscv64": { 506 + "version": "0.20.2", 507 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", 508 + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", 509 "cpu": [ 510 "riscv64" 511 ], ··· 519 } 520 }, 521 "node_modules/@esbuild/linux-s390x": { 522 + "version": "0.20.2", 523 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", 524 + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", 525 "cpu": [ 526 "s390x" 527 ], ··· 535 } 536 }, 537 "node_modules/@esbuild/linux-x64": { 538 + "version": "0.20.2", 539 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", 540 + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", 541 "cpu": [ 542 "x64" 543 ], ··· 551 } 552 }, 553 "node_modules/@esbuild/netbsd-x64": { 554 + "version": "0.20.2", 555 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", 556 + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", 557 "cpu": [ 558 "x64" 559 ], ··· 567 } 568 }, 569 "node_modules/@esbuild/openbsd-x64": { 570 + "version": "0.20.2", 571 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", 572 + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", 573 "cpu": [ 574 "x64" 575 ], ··· 583 } 584 }, 585 "node_modules/@esbuild/sunos-x64": { 586 + "version": "0.20.2", 587 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", 588 + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", 589 "cpu": [ 590 "x64" 591 ], ··· 599 } 600 }, 601 "node_modules/@esbuild/win32-arm64": { 602 + "version": "0.20.2", 603 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", 604 + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", 605 "cpu": [ 606 "arm64" 607 ], ··· 615 } 616 }, 617 "node_modules/@esbuild/win32-ia32": { 618 + "version": "0.20.2", 619 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", 620 + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", 621 "cpu": [ 622 "ia32" 623 ], ··· 631 } 632 }, 633 "node_modules/@esbuild/win32-x64": { 634 + "version": "0.20.2", 635 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", 636 + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", 637 "cpu": [ 638 "x64" 639 ], ··· 1657 "@types/responselike": "^1.0.0" 1658 } 1659 }, 1660 + "node_modules/@types/elm": { 1661 + "version": "0.19.3", 1662 + "resolved": "https://registry.npmjs.org/@types/elm/-/elm-0.19.3.tgz", 1663 + "integrity": "sha512-1DnHZiIHvDyjL6MHrePqbD3ooLLix13k6ow8gEydFOAXImkcvbzQX0Ri+WJOM7RvgPfmyUe6uQ2Acupb1oL+GA==", 1664 + "dev": true 1665 + }, 1666 + "node_modules/@types/file-saver": { 1667 + "version": "2.0.7", 1668 + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", 1669 + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", 1670 + "dev": true 1671 + }, 1672 "node_modules/@types/http-cache-semantics": { 1673 "version": "4.0.1", 1674 "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", ··· 1690 "@types/node": "*" 1691 } 1692 }, 1693 + "node_modules/@types/lunr": { 1694 + "version": "2.3.7", 1695 + "resolved": "https://registry.npmjs.org/@types/lunr/-/lunr-2.3.7.tgz", 1696 + "integrity": "sha512-Tb/kUm38e8gmjahQzdCKhbdsvQ9/ppzHFfsJ0dMs3ckqQsRj+P5IkSAwFTBrBxdyr3E/LoMUUrZngjDYAjiE3A==", 1697 + "dev": true 1698 + }, 1699 "node_modules/@types/node": { 1700 "version": "18.16.3", 1701 "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.3.tgz", ··· 1714 "version": "7.5.6", 1715 "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", 1716 "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", 1717 + "dev": true 1718 + }, 1719 + "node_modules/@types/throttle-debounce": { 1720 + "version": "5.0.2", 1721 + "resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-5.0.2.tgz", 1722 + "integrity": "sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==", 1723 "dev": true 1724 }, 1725 "node_modules/@types/tv4": { ··· 2177 } 2178 }, 2179 "node_modules/autoprefixer": { 2180 + "version": "10.4.19", 2181 + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", 2182 + "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", 2183 "dev": true, 2184 "funding": [ 2185 { ··· 2196 } 2197 ], 2198 "dependencies": { 2199 + "browserslist": "^4.23.0", 2200 + "caniuse-lite": "^1.0.30001599", 2201 "fraction.js": "^4.3.7", 2202 "normalize-range": "^0.1.2", 2203 "picocolors": "^1.0.0", ··· 2520 } 2521 }, 2522 "node_modules/browserslist": { 2523 + "version": "4.23.1", 2524 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", 2525 + "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", 2526 "dev": true, 2527 "funding": [ 2528 { ··· 2539 } 2540 ], 2541 "dependencies": { 2542 + "caniuse-lite": "^1.0.30001629", 2543 + "electron-to-chromium": "^1.4.796", 2544 "node-releases": "^2.0.14", 2545 + "update-browserslist-db": "^1.0.16" 2546 }, 2547 "bin": { 2548 "browserslist": "cli.js" ··· 2677 } 2678 }, 2679 "node_modules/caniuse-lite": { 2680 + "version": "1.0.30001636", 2681 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz", 2682 + "integrity": "sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg==", 2683 "dev": true, 2684 "funding": [ 2685 { ··· 3292 "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" 3293 }, 3294 "node_modules/electron-to-chromium": { 3295 + "version": "1.4.806", 3296 + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.806.tgz", 3297 + "integrity": "sha512-nkoEX2QIB8kwCOtvtgwhXWy2IHVcOLQZu9Qo36uaGB835mdX/h8uLRlosL6QIhLVUnAiicXRW00PwaPZC74Nrg==", 3298 "dev": true 3299 }, 3300 "node_modules/elm": { ··· 3491 } 3492 }, 3493 "node_modules/esbuild": { 3494 + "version": "0.20.2", 3495 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", 3496 + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", 3497 "dev": true, 3498 "hasInstallScript": true, 3499 "bin": { ··· 3503 "node": ">=12" 3504 }, 3505 "optionalDependencies": { 3506 + "@esbuild/aix-ppc64": "0.20.2", 3507 + "@esbuild/android-arm": "0.20.2", 3508 + "@esbuild/android-arm64": "0.20.2", 3509 + "@esbuild/android-x64": "0.20.2", 3510 + "@esbuild/darwin-arm64": "0.20.2", 3511 + "@esbuild/darwin-x64": "0.20.2", 3512 + "@esbuild/freebsd-arm64": "0.20.2", 3513 + "@esbuild/freebsd-x64": "0.20.2", 3514 + "@esbuild/linux-arm": "0.20.2", 3515 + "@esbuild/linux-arm64": "0.20.2", 3516 + "@esbuild/linux-ia32": "0.20.2", 3517 + "@esbuild/linux-loong64": "0.20.2", 3518 + "@esbuild/linux-mips64el": "0.20.2", 3519 + "@esbuild/linux-ppc64": "0.20.2", 3520 + "@esbuild/linux-riscv64": "0.20.2", 3521 + "@esbuild/linux-s390x": "0.20.2", 3522 + "@esbuild/linux-x64": "0.20.2", 3523 + "@esbuild/netbsd-x64": "0.20.2", 3524 + "@esbuild/openbsd-x64": "0.20.2", 3525 + "@esbuild/sunos-x64": "0.20.2", 3526 + "@esbuild/win32-arm64": "0.20.2", 3527 + "@esbuild/win32-ia32": "0.20.2", 3528 + "@esbuild/win32-x64": "0.20.2" 3529 } 3530 }, 3531 "node_modules/esbuild-plugin-wasm": { ··· 5214 "js-yaml": "bin/js-yaml.js" 5215 } 5216 }, 5217 "node_modules/json-buffer": { 5218 "version": "3.0.1", 5219 "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", ··· 5568 } 5569 }, 5570 "node_modules/mediainfo.js": { 5571 + "version": "0.3.1", 5572 + "resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.3.1.tgz", 5573 + "integrity": "sha512-qUehPOCsqmEn0SmTaEOTgyaIiN9LZrDFYyDibsx2rpe8QaxWA+Dzr/fPMTMaHDt5L6J4Jm7pmcEhREN0N0ewrA==", 5574 "dependencies": { 5575 "yargs": "^17.7.2" 5576 }, ··· 5578 "mediainfo.js": "dist/esm/cli.js" 5579 }, 5580 "engines": { 5581 + "node": ">=18.0.0" 5582 } 5583 }, 5584 "node_modules/merge-options": { ··· 6271 "dev": true 6272 }, 6273 "node_modules/picocolors": { 6274 + "version": "1.0.1", 6275 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", 6276 + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", 6277 "dev": true 6278 }, 6279 "node_modules/picomatch": { ··· 7609 } 7610 }, 7611 "node_modules/update-browserslist-db": { 7612 + "version": "1.0.16", 7613 + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", 7614 + "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", 7615 "dev": true, 7616 "funding": [ 7617 { ··· 7628 } 7629 ], 7630 "dependencies": { 7631 + "escalade": "^3.1.2", 7632 + "picocolors": "^1.0.1" 7633 }, 7634 "bin": { 7635 "update-browserslist-db": "cli.js" ··· 8072 "optional": true 8073 }, 8074 "@esbuild/aix-ppc64": { 8075 + "version": "0.20.2", 8076 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", 8077 + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", 8078 "dev": true, 8079 "optional": true 8080 }, 8081 "@esbuild/android-arm": { 8082 + "version": "0.20.2", 8083 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", 8084 + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", 8085 "dev": true, 8086 "optional": true 8087 }, 8088 "@esbuild/android-arm64": { 8089 + "version": "0.20.2", 8090 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", 8091 + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", 8092 "dev": true, 8093 "optional": true 8094 }, 8095 "@esbuild/android-x64": { 8096 + "version": "0.20.2", 8097 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", 8098 + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", 8099 "dev": true, 8100 "optional": true 8101 }, 8102 "@esbuild/darwin-arm64": { 8103 + "version": "0.20.2", 8104 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", 8105 + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", 8106 "dev": true, 8107 "optional": true 8108 }, 8109 "@esbuild/darwin-x64": { 8110 + "version": "0.20.2", 8111 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", 8112 + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", 8113 "dev": true, 8114 "optional": true 8115 }, 8116 "@esbuild/freebsd-arm64": { 8117 + "version": "0.20.2", 8118 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", 8119 + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", 8120 "dev": true, 8121 "optional": true 8122 }, 8123 "@esbuild/freebsd-x64": { 8124 + "version": "0.20.2", 8125 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", 8126 + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", 8127 "dev": true, 8128 "optional": true 8129 }, 8130 "@esbuild/linux-arm": { 8131 + "version": "0.20.2", 8132 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", 8133 + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", 8134 "dev": true, 8135 "optional": true 8136 }, 8137 "@esbuild/linux-arm64": { 8138 + "version": "0.20.2", 8139 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", 8140 + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", 8141 "dev": true, 8142 "optional": true 8143 }, 8144 "@esbuild/linux-ia32": { 8145 + "version": "0.20.2", 8146 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", 8147 + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", 8148 "dev": true, 8149 "optional": true 8150 }, 8151 "@esbuild/linux-loong64": { 8152 + "version": "0.20.2", 8153 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", 8154 + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", 8155 "dev": true, 8156 "optional": true 8157 }, 8158 "@esbuild/linux-mips64el": { 8159 + "version": "0.20.2", 8160 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", 8161 + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", 8162 "dev": true, 8163 "optional": true 8164 }, 8165 "@esbuild/linux-ppc64": { 8166 + "version": "0.20.2", 8167 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", 8168 + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", 8169 "dev": true, 8170 "optional": true 8171 }, 8172 "@esbuild/linux-riscv64": { 8173 + "version": "0.20.2", 8174 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", 8175 + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", 8176 "dev": true, 8177 "optional": true 8178 }, 8179 "@esbuild/linux-s390x": { 8180 + "version": "0.20.2", 8181 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", 8182 + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", 8183 "dev": true, 8184 "optional": true 8185 }, 8186 "@esbuild/linux-x64": { 8187 + "version": "0.20.2", 8188 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", 8189 + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", 8190 "dev": true, 8191 "optional": true 8192 }, 8193 "@esbuild/netbsd-x64": { 8194 + "version": "0.20.2", 8195 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", 8196 + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", 8197 "dev": true, 8198 "optional": true 8199 }, 8200 "@esbuild/openbsd-x64": { 8201 + "version": "0.20.2", 8202 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", 8203 + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", 8204 "dev": true, 8205 "optional": true 8206 }, 8207 "@esbuild/sunos-x64": { 8208 + "version": "0.20.2", 8209 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", 8210 + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", 8211 "dev": true, 8212 "optional": true 8213 }, 8214 "@esbuild/win32-arm64": { 8215 + "version": "0.20.2", 8216 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", 8217 + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", 8218 "dev": true, 8219 "optional": true 8220 }, 8221 "@esbuild/win32-ia32": { 8222 + "version": "0.20.2", 8223 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", 8224 + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", 8225 "dev": true, 8226 "optional": true 8227 }, 8228 "@esbuild/win32-x64": { 8229 + "version": "0.20.2", 8230 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", 8231 + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", 8232 "dev": true, 8233 "optional": true 8234 }, ··· 8940 "@types/responselike": "^1.0.0" 8941 } 8942 }, 8943 + "@types/elm": { 8944 + "version": "0.19.3", 8945 + "resolved": "https://registry.npmjs.org/@types/elm/-/elm-0.19.3.tgz", 8946 + "integrity": "sha512-1DnHZiIHvDyjL6MHrePqbD3ooLLix13k6ow8gEydFOAXImkcvbzQX0Ri+WJOM7RvgPfmyUe6uQ2Acupb1oL+GA==", 8947 + "dev": true 8948 + }, 8949 + "@types/file-saver": { 8950 + "version": "2.0.7", 8951 + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", 8952 + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", 8953 + "dev": true 8954 + }, 8955 "@types/http-cache-semantics": { 8956 "version": "4.0.1", 8957 "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", ··· 8973 "@types/node": "*" 8974 } 8975 }, 8976 + "@types/lunr": { 8977 + "version": "2.3.7", 8978 + "resolved": "https://registry.npmjs.org/@types/lunr/-/lunr-2.3.7.tgz", 8979 + "integrity": "sha512-Tb/kUm38e8gmjahQzdCKhbdsvQ9/ppzHFfsJ0dMs3ckqQsRj+P5IkSAwFTBrBxdyr3E/LoMUUrZngjDYAjiE3A==", 8980 + "dev": true 8981 + }, 8982 "@types/node": { 8983 "version": "18.16.3", 8984 "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.3.tgz", ··· 8997 "version": "7.5.6", 8998 "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", 8999 "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", 9000 + "dev": true 9001 + }, 9002 + "@types/throttle-debounce": { 9003 + "version": "5.0.2", 9004 + "resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-5.0.2.tgz", 9005 + "integrity": "sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==", 9006 "dev": true 9007 }, 9008 "@types/tv4": { ··· 9304 "dev": true 9305 }, 9306 "autoprefixer": { 9307 + "version": "10.4.19", 9308 + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", 9309 + "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", 9310 "dev": true, 9311 "requires": { 9312 + "browserslist": "^4.23.0", 9313 + "caniuse-lite": "^1.0.30001599", 9314 "fraction.js": "^4.3.7", 9315 "normalize-range": "^0.1.2", 9316 "picocolors": "^1.0.0", ··· 9521 } 9522 }, 9523 "browserslist": { 9524 + "version": "4.23.1", 9525 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", 9526 + "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", 9527 "dev": true, 9528 "requires": { 9529 + "caniuse-lite": "^1.0.30001629", 9530 + "electron-to-chromium": "^1.4.796", 9531 "node-releases": "^2.0.14", 9532 + "update-browserslist-db": "^1.0.16" 9533 } 9534 }, 9535 "buffer": { ··· 9611 "dev": true 9612 }, 9613 "caniuse-lite": { 9614 + "version": "1.0.30001636", 9615 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz", 9616 + "integrity": "sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg==", 9617 "dev": true 9618 }, 9619 "catering": { ··· 10040 "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" 10041 }, 10042 "electron-to-chromium": { 10043 + "version": "1.4.806", 10044 + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.806.tgz", 10045 + "integrity": "sha512-nkoEX2QIB8kwCOtvtgwhXWy2IHVcOLQZu9Qo36uaGB835mdX/h8uLRlosL6QIhLVUnAiicXRW00PwaPZC74Nrg==", 10046 "dev": true 10047 }, 10048 "elm": { ··· 10190 "dev": true 10191 }, 10192 "esbuild": { 10193 + "version": "0.20.2", 10194 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", 10195 + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", 10196 "dev": true, 10197 "requires": { 10198 + "@esbuild/aix-ppc64": "0.20.2", 10199 + "@esbuild/android-arm": "0.20.2", 10200 + "@esbuild/android-arm64": "0.20.2", 10201 + "@esbuild/android-x64": "0.20.2", 10202 + "@esbuild/darwin-arm64": "0.20.2", 10203 + "@esbuild/darwin-x64": "0.20.2", 10204 + "@esbuild/freebsd-arm64": "0.20.2", 10205 + "@esbuild/freebsd-x64": "0.20.2", 10206 + "@esbuild/linux-arm": "0.20.2", 10207 + "@esbuild/linux-arm64": "0.20.2", 10208 + "@esbuild/linux-ia32": "0.20.2", 10209 + "@esbuild/linux-loong64": "0.20.2", 10210 + "@esbuild/linux-mips64el": "0.20.2", 10211 + "@esbuild/linux-ppc64": "0.20.2", 10212 + "@esbuild/linux-riscv64": "0.20.2", 10213 + "@esbuild/linux-s390x": "0.20.2", 10214 + "@esbuild/linux-x64": "0.20.2", 10215 + "@esbuild/netbsd-x64": "0.20.2", 10216 + "@esbuild/openbsd-x64": "0.20.2", 10217 + "@esbuild/sunos-x64": "0.20.2", 10218 + "@esbuild/win32-arm64": "0.20.2", 10219 + "@esbuild/win32-ia32": "0.20.2", 10220 + "@esbuild/win32-x64": "0.20.2" 10221 } 10222 }, 10223 "esbuild-plugin-wasm": { ··· 11380 "argparse": "^2.0.1" 11381 } 11382 }, 11383 "json-buffer": { 11384 "version": "3.0.1", 11385 "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", ··· 11668 "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" 11669 }, 11670 "mediainfo.js": { 11671 + "version": "0.3.1", 11672 + "resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.3.1.tgz", 11673 + "integrity": "sha512-qUehPOCsqmEn0SmTaEOTgyaIiN9LZrDFYyDibsx2rpe8QaxWA+Dzr/fPMTMaHDt5L6J4Jm7pmcEhREN0N0ewrA==", 11674 "requires": { 11675 "yargs": "^17.7.2" 11676 } ··· 12141 "dev": true 12142 }, 12143 "picocolors": { 12144 + "version": "1.0.1", 12145 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", 12146 + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", 12147 "dev": true 12148 }, 12149 "picomatch": { ··· 13068 "dev": true 13069 }, 13070 "update-browserslist-db": { 13071 + "version": "1.0.16", 13072 + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", 13073 + "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", 13074 "dev": true, 13075 "requires": { 13076 + "escalade": "^3.1.2", 13077 + "picocolors": "^1.0.1" 13078 } 13079 }, 13080 "update-check": {
+8 -5
package.json
··· 1 { 2 "name": "diffuse", 3 "description": "A music player that connects to your cloud/distributed storage", 4 - "version": "3.4.0", 5 "author": "Steven Vandevelde <icid.asset@gmail.com>", 6 "homepage": "https://diffuse.sh", 7 "repository": "github:icidasset/diffuse", ··· 12 "@tauri-apps/plugin-dialog": "^2.0.0-beta.0", 13 "@tauri-apps/plugin-fs": "^2.0.0-beta.0", 14 "@tauri-apps/plugin-shell": "^2.0.0-beta.0", 15 "@typescript-eslint/eslint-plugin": "^6.21.0", 16 "@typescript-eslint/parser": "^6.21.0", 17 "assert": "^2.1.0", 18 - "autoprefixer": "^10.4.17", 19 "buffer": "^6.0.3", 20 "elm": "0.19.1-6", 21 "elm-format": "^0.8.7", 22 "elm-review": "^2.10.3", 23 - "esbuild": "^0.20.0", 24 "esbuild-plugin-wasm": "^1.1.0", 25 "eslint": "^8.56.0", 26 "events": "^3.3.0", ··· 42 "encoding-japanese": "^2.0.0", 43 "fast-text-encoding": "^1.0.6", 44 "file-saver": "^2.0.2", 45 - "jschardet": "^3.0.0", 46 "jszip": "^3.7.1", 47 "load-script2": "^2.0.5", 48 "localforage": "^1.10.0", 49 "lunr": "^2.3.8", 50 - "mediainfo.js": "^0.2.1", 51 "music-metadata-browser": "^2.5.10", 52 "readable-stream": "^4.5.2", 53 "remotestoragejs": "^2.0.0-beta.6",
··· 1 { 2 "name": "diffuse", 3 "description": "A music player that connects to your cloud/distributed storage", 4 + "version": "3.5.0", 5 "author": "Steven Vandevelde <icid.asset@gmail.com>", 6 "homepage": "https://diffuse.sh", 7 "repository": "github:icidasset/diffuse", ··· 12 "@tauri-apps/plugin-dialog": "^2.0.0-beta.0", 13 "@tauri-apps/plugin-fs": "^2.0.0-beta.0", 14 "@tauri-apps/plugin-shell": "^2.0.0-beta.0", 15 + "@types/elm": "^0.19.3", 16 + "@types/file-saver": "^2.0.7", 17 + "@types/lunr": "^2.3.7", 18 + "@types/throttle-debounce": "^5.0.2", 19 "@typescript-eslint/eslint-plugin": "^6.21.0", 20 "@typescript-eslint/parser": "^6.21.0", 21 "assert": "^2.1.0", 22 + "autoprefixer": "^10.4.19", 23 "buffer": "^6.0.3", 24 "elm": "0.19.1-6", 25 "elm-format": "^0.8.7", 26 "elm-review": "^2.10.3", 27 + "esbuild": "^0.20.2", 28 "esbuild-plugin-wasm": "^1.1.0", 29 "eslint": "^8.56.0", 30 "events": "^3.3.0", ··· 46 "encoding-japanese": "^2.0.0", 47 "fast-text-encoding": "^1.0.6", 48 "file-saver": "^2.0.2", 49 "jszip": "^3.7.1", 50 "load-script2": "^2.0.5", 51 "localforage": "^1.10.0", 52 "lunr": "^2.3.8", 53 + "mediainfo.js": "^0.3.1", 54 "music-metadata-browser": "^2.5.10", 55 "readable-stream": "^4.5.2", 56 "remotestoragejs": "^2.0.0-beta.6",
+1 -1
src-tauri/Cargo.toml
··· 1 [package] 2 name = "diffuse" 3 - version = "3.4.0" 4 description = "A music player that connects to your cloud/distributed storage" 5 authors = ["Steven Vandevelde"] 6 edition = "2021"
··· 1 [package] 2 name = "diffuse" 3 + version = "3.5.0" 4 description = "A music player that connects to your cloud/distributed storage" 5 authors = ["Steven Vandevelde"] 6 edition = "2021"
+1 -1
src-tauri/tauri.conf.json
··· 1 { 2 "productName": "Diffuse", 3 - "version": "3.4.0", 4 "identifier": "com.icidasset.diffuse", 5 "build": { 6 "beforeDevCommand": "",
··· 1 { 2 "productName": "Diffuse", 3 + "version": "3.5.0", 4 "identifier": "com.icidasset.diffuse", 5 "build": { 6 "beforeDevCommand": "",
+12 -3
src/Applications/Brain/Other/State.elm
··· 3 import Alien 4 import Brain.Common.State as Common 5 import Brain.Ports as Ports 6 import Brain.Types exposing (..) 7 import Dict 8 import Json.Decode as Json ··· 56 Return.singleton { model | currentTime = time } 57 58 59 toCache : Json.Value -> Manager 60 toCache data = 61 case Json.decodeValue Alien.hostDecoder data of 62 Ok alienEvent -> 63 - alienEvent 64 - |> Ports.toCache 65 - |> Return.communicate 66 67 Err err -> 68 err
··· 3 import Alien 4 import Brain.Common.State as Common 5 import Brain.Ports as Ports 6 + import Brain.Task.Ports 7 import Brain.Types exposing (..) 8 import Dict 9 import Json.Decode as Json ··· 57 Return.singleton { model | currentTime = time } 58 59 60 + {-| Save alien data to cache. 61 + -} 62 toCache : Json.Value -> Manager 63 toCache data = 64 case Json.decodeValue Alien.hostDecoder data of 65 Ok alienEvent -> 66 + case Alien.tagFromString alienEvent.tag of 67 + Just tag -> 68 + alienEvent.data 69 + |> Brain.Task.Ports.toCache tag 70 + |> Common.attemptPortTask (always Bypass) 71 + |> Return.communicate 72 + 73 + Nothing -> 74 + Common.reportUI Alien.ToCache "Failed to decode alien tag" 75 76 Err err -> 77 err
-9
src/Applications/Brain/Ports.elm
··· 12 port downloadTracks : Json.Value -> Cmd msg 13 14 15 - port removeCache : Alien.Event -> Cmd msg 16 - 17 - 18 port removeTracksFromCache : Json.Value -> Cmd msg 19 - 20 - 21 - port requestCache : Alien.Event -> Cmd msg 22 23 24 port requestSearch : String -> Cmd msg ··· 31 32 33 port syncTags : ContextForTagsSync -> Cmd msg 34 - 35 - 36 - port toCache : Alien.Event -> Cmd msg 37 38 39 port toUI : Alien.Event -> Cmd msg
··· 12 port downloadTracks : Json.Value -> Cmd msg 13 14 15 port removeTracksFromCache : Json.Value -> Cmd msg 16 17 18 port requestSearch : String -> Cmd msg ··· 25 26 27 port syncTags : ContextForTagsSync -> Cmd msg 28 29 30 port toUI : Alien.Event -> Cmd msg
-2
src/Applications/Brain/Tracks/State.elm
··· 119 makeTrackUrl model.currentTime trackPath maybeSource 120 in 121 dict 122 - |> Dict.remove "trackPath" 123 - |> Dict.remove "trackSourceId" 124 |> Dict.insert "trackGetUrl" (mkTrackUrl Get) 125 |> Dict.insert "trackHeadUrl" (mkTrackUrl Head) 126 |> Json.Encode.dict identity Json.Encode.string
··· 119 makeTrackUrl model.currentTime trackPath maybeSource 120 in 121 dict 122 |> Dict.insert "trackGetUrl" (mkTrackUrl Get) 123 |> Dict.insert "trackHeadUrl" (mkTrackUrl Head) 124 |> Json.Encode.dict identity Json.Encode.string
+25 -18
src/Applications/Brain/User/State.elm
··· 99 |> User.decodeHypaethralData 100 |> Result.map 101 (\hypaethralData -> 102 - ( hypaethralJson 103 - , hypaethralData 104 - ) 105 ) 106 - |> Result.withDefault 107 - ( User.encodeHypaethralData User.emptyHypaethralData 108 - , User.emptyHypaethralData 109 - ) 110 - |> Commence maybeMethod initialUrl 111 - |> UserMsg 112 ) 113 114 ··· 345 unsetSyncMethod model = 346 -- 💀 347 -- Unset & remove stored method. 348 - [ Ports.removeCache (Alien.trigger Alien.SyncMethod) 349 - , Ports.removeCache (Alien.trigger Alien.SecretKey) 350 351 -- 352 , case model.userSyncMethod of ··· 380 381 retrieveEnclosedData : Manager 382 retrieveEnclosedData = 383 - Alien.EnclosedData 384 - |> Alien.trigger 385 - |> Ports.requestCache 386 |> Return.communicate 387 388 389 saveEnclosedData : Json.Value -> Manager 390 saveEnclosedData json = 391 json 392 - |> Alien.broadcast Alien.EnclosedData 393 - |> Ports.toCache 394 |> Return.communicate 395 396 ··· 668 saveMethod method model = 669 method 670 |> encodeMethod 671 - |> Alien.broadcast Alien.SyncMethod 672 - |> Ports.toCache 673 |> return { model | userSyncMethod = Just method } 674 675
··· 99 |> User.decodeHypaethralData 100 |> Result.map 101 (\hypaethralData -> 102 + Commence 103 + maybeMethod 104 + initialUrl 105 + ( hypaethralJson 106 + , hypaethralData 107 + ) 108 ) 109 + |> Result.mapError Decode.errorToString 110 + |> Common.reportErrorToUI UserMsg 111 ) 112 113 ··· 344 unsetSyncMethod model = 345 -- 💀 346 -- Unset & remove stored method. 347 + [ Common.attemptPortTask (always Brain.Bypass) (Brain.Task.Ports.removeCache Alien.SyncMethod) 348 + , Common.attemptPortTask (always Brain.Bypass) (Brain.Task.Ports.removeCache Alien.SecretKey) 349 350 -- 351 , case model.userSyncMethod of ··· 379 380 retrieveEnclosedData : Manager 381 retrieveEnclosedData = 382 + Decode.value 383 + |> Brain.Task.Ports.fromCache Alien.EnclosedData 384 + |> Common.attemptPortTask 385 + (\maybe -> 386 + case maybe of 387 + Just json -> 388 + Brain.UserMsg (EnclosedDataRetrieved json) 389 + 390 + Nothing -> 391 + Brain.Bypass 392 + ) 393 |> Return.communicate 394 395 396 saveEnclosedData : Json.Value -> Manager 397 saveEnclosedData json = 398 json 399 + |> Brain.Task.Ports.toCache Alien.EnclosedData 400 + |> Common.attemptPortTask (always Brain.Bypass) 401 |> Return.communicate 402 403 ··· 675 saveMethod method model = 676 method 677 |> encodeMethod 678 + |> Brain.Task.Ports.toCache Alien.SyncMethod 679 + |> Common.attemptPortTask (always Brain.Bypass) 680 |> return { model | userSyncMethod = Just method } 681 682
+47 -28
src/Applications/UI.elm
··· 117 ----------------------------------------- 118 -- Audio 119 ----------------------------------------- 120 - , audioDuration = 0 121 - , audioHasStalled = False 122 - , audioIsLoading = False 123 - , audioIsPlaying = False 124 - , audioPosition = 0 125 , progress = Dict.empty 126 , rememberProgress = True 127 ··· 136 ----------------------------------------- 137 -- Debouncing 138 ----------------------------------------- 139 , resizeDebouncer = 140 0.25 141 |> Debouncer.fromSeconds ··· 174 -- Queue 175 ----------------------------------------- 176 , dontPlay = [] 177 - , nowPlaying = Nothing 178 , playedPreviously = [] 179 , playingNext = [] 180 , selectedQueueItem = Nothing ··· 273 ----------------------------------------- 274 -- Audio 275 ----------------------------------------- 276 NoteProgress a -> 277 Audio.noteProgress a 278 279 Pause -> 280 Audio.pause ··· 284 285 Seek a -> 286 Audio.seek a 287 - 288 - SetAudioDuration a -> 289 - Audio.setDuration a 290 - 291 - SetAudioHasStalled a -> 292 - Audio.setHasStalled a 293 - 294 - SetAudioIsLoading a -> 295 - Audio.setIsLoading a 296 - 297 - SetAudioIsPlaying a -> 298 - Audio.setIsPlaying a 299 - 300 - SetAudioPosition a -> 301 - Audio.setPosition a 302 303 Stop -> 304 Audio.stop ··· 562 ----------------------------------------- 563 -- Audio 564 ----------------------------------------- 565 - , Ports.noteProgress NoteProgress 566 , Ports.requestPause (always Pause) 567 , Ports.requestPlay (always Play) 568 , Ports.requestPlayPause (always TogglePlay) 569 , Ports.requestStop (always Stop) 570 - , Ports.setAudioDuration SetAudioDuration 571 - , Ports.setAudioHasStalled SetAudioHasStalled 572 - , Ports.setAudioIsLoading SetAudioIsLoading 573 - , Ports.setAudioIsPlaying SetAudioIsPlaying 574 - , Ports.setAudioPosition SetAudioPosition 575 576 ----------------------------------------- 577 -- Backdrop ··· 591 ----------------------------------------- 592 -- Queue 593 ----------------------------------------- 594 - , Ports.activeQueueItemEnded (QueueMsg << always Queue.Shift) 595 , Ports.requestNext (\_ -> QueueMsg Queue.Shift) 596 , Ports.requestPrevious (\_ -> QueueMsg Queue.Rewind) 597
··· 117 ----------------------------------------- 118 -- Audio 119 ----------------------------------------- 120 + , audioElements = [] 121 + , nowPlaying = Nothing 122 , progress = Dict.empty 123 , rememberProgress = True 124 ··· 133 ----------------------------------------- 134 -- Debouncing 135 ----------------------------------------- 136 + , preloadDebouncer = 137 + 30 138 + |> Debouncer.fromSeconds 139 + |> Debouncer.debounce 140 + |> Debouncer.toDebouncer 141 + , progressDebouncer = 142 + 30 143 + |> Debouncer.fromSeconds 144 + |> Debouncer.throttle 145 + |> Debouncer.emitWhenUnsettled Nothing 146 + |> Debouncer.toDebouncer 147 , resizeDebouncer = 148 0.25 149 |> Debouncer.fromSeconds ··· 182 -- Queue 183 ----------------------------------------- 184 , dontPlay = [] 185 , playedPreviously = [] 186 , playingNext = [] 187 , selectedQueueItem = Nothing ··· 280 ----------------------------------------- 281 -- Audio 282 ----------------------------------------- 283 + AudioDurationChange a -> 284 + Audio.durationChange a 285 + 286 + AudioEnded a -> 287 + Audio.ended a 288 + 289 + AudioError a -> 290 + Audio.error a 291 + 292 + AudioHasLoaded a -> 293 + Audio.hasLoaded a 294 + 295 + AudioIsLoading a -> 296 + Audio.isLoading a 297 + 298 + AudioPlaybackStateChanged a -> 299 + Audio.playbackStateChanged a 300 + 301 + AudioPreloadDebounce a -> 302 + Audio.preloadDebounce update a 303 + 304 + AudioTimeUpdated a -> 305 + Audio.timeUpdated a 306 + 307 NoteProgress a -> 308 Audio.noteProgress a 309 + 310 + NoteProgressDebounce a -> 311 + Audio.noteProgressDebounce update a 312 313 Pause -> 314 Audio.pause ··· 318 319 Seek a -> 320 Audio.seek a 321 322 Stop -> 323 Audio.stop ··· 581 ----------------------------------------- 582 -- Audio 583 ----------------------------------------- 584 + , Ports.audioDurationChange AudioDurationChange 585 + , Ports.audioEnded AudioEnded 586 + , Ports.audioError AudioError 587 + , Ports.audioPlaybackStateChanged AudioPlaybackStateChanged 588 + , Ports.audioIsLoading AudioIsLoading 589 + , Ports.audioHasLoaded AudioHasLoaded 590 + , Ports.audioTimeUpdated AudioTimeUpdated 591 , Ports.requestPause (always Pause) 592 , Ports.requestPlay (always Play) 593 , Ports.requestPlayPause (always TogglePlay) 594 , Ports.requestStop (always Stop) 595 596 ----------------------------------------- 597 -- Backdrop ··· 611 ----------------------------------------- 612 -- Queue 613 ----------------------------------------- 614 , Ports.requestNext (\_ -> QueueMsg Queue.Shift) 615 , Ports.requestPrevious (\_ -> QueueMsg Queue.Rewind) 616
+6 -6
src/Applications/UI/Adjunct.elm
··· 63 [ Keyboard.Character "]", Keyboard.Control ] -> 64 Queue.shift m 65 66 - [ Keyboard.Character "{", Keyboard.Shift, Keyboard.Control ] -> 67 - Audio.seek ((m.audioPosition - 10) / m.audioDuration) m 68 - 69 - [ Keyboard.Character "}", Keyboard.Shift, Keyboard.Control ] -> 70 - Audio.seek ((m.audioPosition + 10) / m.audioDuration) m 71 - 72 -- Meta key 73 -- 74 [ Keyboard.Character "K", Keyboard.Meta ] ->
··· 63 [ Keyboard.Character "]", Keyboard.Control ] -> 64 Queue.shift m 65 66 + -- TODO: 67 + -- [ Keyboard.Character "{", Keyboard.Shift, Keyboard.Control ] -> 68 + -- Audio.seek ((m.audioPosition - 10) / m.audioDuration) m 69 + -- 70 + -- [ Keyboard.Character "}", Keyboard.Shift, Keyboard.Control ] -> 71 + -- Audio.seek ((m.audioPosition + 10) / m.audioDuration) m 72 -- Meta key 73 -- 74 [ Keyboard.Character "K", Keyboard.Meta ] ->
+311 -61
src/Applications/UI/Audio/State.elm
··· 1 module UI.Audio.State exposing (..) 2 3 import Dict 4 import LastFm 5 - import Maybe.Extra as Maybe 6 import Return exposing (return) 7 import Return.Ext as Return exposing (communicate) 8 import UI.Ports as Ports 9 import UI.Queue.State as Queue 10 - import UI.Types as UI exposing (Manager) 11 import UI.User.State.Export as User 12 13 14 15 - -- 📣 16 17 18 - noteProgress : { trackId : String, progress : Float } -> Manager 19 - noteProgress { trackId, progress } model = 20 - let 21 - updatedProgressTable = 22 - if not model.rememberProgress then 23 - model.progress 24 25 - else if progress > 0.975 then 26 - Dict.remove trackId model.progress 27 28 else 29 - Dict.insert trackId progress model.progress 30 - in 31 - if model.rememberProgress then 32 - User.saveProgress { model | progress = updatedProgressTable } 33 34 - else 35 - Return.singleton model 36 37 38 pause : Manager 39 pause model = 40 - return model (Ports.pause ()) 41 42 43 playPause : Manager 44 playPause model = 45 - if Maybe.isNothing model.nowPlaying then 46 - Queue.shift model 47 48 - else if model.audioIsPlaying then 49 - communicate (Ports.pause ()) model 50 51 - else 52 - communicate (Ports.play ()) model 53 54 55 play : Manager 56 play model = 57 - if Maybe.isNothing model.nowPlaying then 58 - Queue.shift model 59 60 - else 61 - return model (Ports.play ()) 62 63 64 - seek : Float -> Manager 65 - seek percentage = 66 - Return.communicate (Ports.seek percentage) 67 68 69 - setDuration : Float -> Manager 70 - setDuration duration model = 71 - let 72 - cmd = 73 - case Maybe.map .identifiedTrack model.nowPlaying of 74 - Just ( _, track ) -> 75 - LastFm.nowPlaying model.lastFm 76 - { duration = round duration 77 - , msg = UI.Bypass 78 - , track = track 79 - } 80 81 - Nothing -> 82 - Cmd.none 83 - in 84 - return { model | audioDuration = duration } cmd 85 86 87 - setHasStalled : Bool -> Manager 88 - setHasStalled hasStalled model = 89 - Return.singleton { model | audioHasStalled = hasStalled } 90 91 92 - setIsLoading : Bool -> Manager 93 - setIsLoading isLoading model = 94 - Return.singleton { model | audioIsLoading = isLoading } 95 96 97 - setIsPlaying : Bool -> Manager 98 - setIsPlaying isPlaying model = 99 - Return.singleton { model | audioIsPlaying = isPlaying } 100 101 102 - setPosition : Float -> Manager 103 - setPosition position model = 104 - Return.singleton { model | audioPosition = position } 105 106 107 - stop : Manager 108 - stop = 109 - communicate (Ports.pause ()) 110 111 112 toggleRememberProgress : Manager 113 toggleRememberProgress model = 114 User.saveSettings { model | rememberProgress = not model.rememberProgress }
··· 1 module UI.Audio.State exposing (..) 2 3 + import Base64 4 + import Common exposing (boolToString) 5 + import Debouncer.Basic as Debouncer 6 import Dict 7 import LastFm 8 + import List.Extra as List 9 + import MediaSession 10 import Return exposing (return) 11 import Return.Ext as Return exposing (communicate) 12 + import Tracks 13 + import UI.Audio.Types exposing (..) 14 + import UI.Common.State as Common 15 + import UI.Common.Types exposing (DebounceManager) 16 import UI.Ports as Ports 17 import UI.Queue.State as Queue 18 + import UI.Types as UI exposing (Manager, Msg(..)) 19 import UI.User.State.Export as User 20 21 22 23 + -- 📣 ░░ EVENTS 24 + 25 + 26 + durationChange : DurationChangeEvent -> Manager 27 + durationChange { trackId, duration } = 28 + onlyIfMatchesNowPlaying 29 + { trackId = trackId } 30 + (\nowPlaying model -> 31 + let 32 + ( identifiers, track ) = 33 + nowPlaying.item.identifiedTrack 34 + 35 + maybeCover = 36 + List.find 37 + (\c -> List.member trackId c.trackIds) 38 + model.covers.arranged 39 + 40 + coverPrep = 41 + Maybe.map 42 + (\cover -> 43 + { cacheKey = Base64.encode (Tracks.coverKey cover.variousArtists track) 44 + , trackFilename = identifiers.filename 45 + , trackPath = track.path 46 + , trackSourceId = track.sourceId 47 + , variousArtists = boolToString cover.variousArtists 48 + } 49 + ) 50 + maybeCover 51 + 52 + coverLoaded = 53 + case ( maybeCover, model.cachedCovers ) of 54 + ( Just cover, Just cachedCovers ) -> 55 + let 56 + key = 57 + Base64.encode (Tracks.coverKey cover.variousArtists track) 58 + in 59 + Dict.member key cachedCovers 60 61 + _ -> 62 + False 63 64 + metadata = 65 + { album = track.tags.album 66 + , artist = track.tags.artist 67 + , title = track.tags.title 68 69 + -- 70 + , coverPrep = coverPrep 71 + } 72 + in 73 + model 74 + |> replaceNowPlaying { nowPlaying | coverLoaded = coverLoaded, duration = Just duration } 75 + |> Return.command (Ports.setMediaSessionMetadata metadata) 76 + |> Return.command (Ports.resetScrobbleTimer { duration = duration, trackId = trackId }) 77 + |> Return.andThen (notifyScrobblersOfTrackPlaying { duration = duration }) 78 + ) 79 + 80 + 81 + error : ErrorAudioEvent -> Manager 82 + error { trackId, code } = 83 + onlyIfMatchesNowPlaying 84 + { trackId = trackId } 85 + (\nowPlaying -> 86 + replaceNowPlaying 87 + (case code of 88 + 2 -> 89 + { nowPlaying | loadingState = NetworkError } 90 + 91 + 3 -> 92 + { nowPlaying | loadingState = DecodeError } 93 + 94 + 4 -> 95 + { nowPlaying | loadingState = NotSupportedError } 96 + 97 + _ -> 98 + nowPlaying 99 + ) 100 + ) 101 + 102 + 103 + ended : GenericAudioEvent -> Manager 104 + ended { trackId } = 105 + onlyIfMatchesNowPlaying 106 + { trackId = trackId } 107 + (\nowPlaying model -> 108 + if model.repeat then 109 + Return.command 110 + (case nowPlaying.duration of 111 + Just duration -> 112 + Ports.resetScrobbleTimer { duration = duration, trackId = trackId } 113 + 114 + Nothing -> 115 + Cmd.none 116 + ) 117 + (play model) 118 119 else 120 + Return.andThen 121 + (if Maybe.map (\d -> Tracks.shouldNoteProgress { duration = d }) nowPlaying.duration == Just True then 122 + noteProgress { trackId = trackId, progress = 1.0 } 123 + 124 + else 125 + Return.singleton 126 + ) 127 + (Queue.shift model) 128 + ) 129 + 130 + 131 + hasLoaded : GenericAudioEvent -> Manager 132 + hasLoaded { trackId } = 133 + onlyIfMatchesNowPlaying 134 + { trackId = trackId } 135 + (\nowPlaying -> 136 + replaceNowPlaying { nowPlaying | loadingState = Loaded } 137 + ) 138 + 139 + 140 + isLoading : GenericAudioEvent -> Manager 141 + isLoading { trackId } = 142 + onlyIfMatchesNowPlaying 143 + { trackId = trackId } 144 + (\nowPlaying -> 145 + replaceNowPlaying { nowPlaying | loadingState = Loading } 146 + ) 147 148 + 149 + playbackStateChanged : PlaybackStateEvent -> Manager 150 + playbackStateChanged { trackId, isPlaying } = 151 + onlyIfMatchesNowPlaying 152 + { trackId = trackId } 153 + (\nowPlaying model -> 154 + { model | nowPlaying = Just { nowPlaying | isPlaying = isPlaying } } 155 + |> Return.singleton 156 + |> Return.command 157 + (if isPlaying then 158 + Ports.startScrobbleTimer () 159 + 160 + else 161 + Ports.pauseScrobbleTimer () 162 + ) 163 + |> Return.command 164 + (Ports.setMediaSessionPlaybackState 165 + (if isPlaying then 166 + MediaSession.states.playing 167 + 168 + else 169 + MediaSession.states.paused 170 + ) 171 + ) 172 + ) 173 + 174 + 175 + timeUpdated : TimeUpdatedEvent -> Manager 176 + timeUpdated { trackId, currentTime, duration } = 177 + onlyIfMatchesNowPlaying 178 + { trackId = trackId } 179 + (\nowPlaying model -> 180 + let 181 + dur = 182 + Maybe.withDefault 0 duration 183 + in 184 + { model | nowPlaying = Just { nowPlaying | duration = duration, playbackPosition = currentTime } } 185 + |> (if Tracks.shouldNoteProgress { duration = dur } then 186 + { trackId = trackId 187 + , progress = currentTime / dur 188 + } 189 + |> NoteProgress 190 + |> Debouncer.provideInput 191 + |> NoteProgressDebounce 192 + |> Return.task 193 + |> Return.communicate 194 + 195 + else 196 + Return.singleton 197 + ) 198 + |> Return.command 199 + (case duration of 200 + Just d -> 201 + Ports.setMediaSessionPositionState 202 + { currentTime = currentTime 203 + , duration = d 204 + } 205 + 206 + Nothing -> 207 + Cmd.none 208 + ) 209 + ) 210 + 211 + 212 + 213 + -- 📣 ░░ COMMANDS 214 215 216 pause : Manager 217 pause model = 218 + case model.nowPlaying of 219 + Just { item } -> 220 + communicate 221 + (Ports.pause 222 + { trackId = (Tuple.second item.identifiedTrack).id 223 + } 224 + ) 225 + model 226 + 227 + Nothing -> 228 + Return.singleton model 229 230 231 playPause : Manager 232 playPause model = 233 + case model.nowPlaying of 234 + Just { isPlaying } -> 235 + if isPlaying then 236 + pause model 237 238 + else 239 + play model 240 241 + Nothing -> 242 + play model 243 244 245 play : Manager 246 play model = 247 + case model.nowPlaying of 248 + Just { item } -> 249 + communicate 250 + (Ports.play 251 + { trackId = (Tuple.second item.identifiedTrack).id 252 + , volume = model.eqSettings.volume 253 + } 254 + ) 255 + model 256 257 + Nothing -> 258 + Queue.shift model 259 260 261 + seek : { trackId : String, progress : Float } -> Manager 262 + seek { trackId, progress } = 263 + { percentage = progress, trackId = trackId } 264 + |> Ports.seek 265 + |> Return.communicate 266 267 268 + stop : Manager 269 + stop model = 270 + model.audioElements 271 + |> List.filter (.isPreload >> (==) True) 272 + |> (\a -> { model | audioElements = a }) 273 + |> Queue.changeActiveItem Nothing 274 + |> Return.effect_ 275 + (\m -> 276 + Ports.renderAudioElements 277 + { items = m.audioElements 278 + , play = Nothing 279 + , volume = m.eqSettings.volume 280 + } 281 + ) 282 283 284 285 + -- 📣 286 + 287 + 288 + noteProgress : { trackId : String, progress : Float } -> Manager 289 + noteProgress { trackId, progress } model = 290 + let 291 + updatedProgressTable = 292 + if not model.rememberProgress then 293 + model.progress 294 + 295 + else if progress > 0.975 then 296 + Dict.remove trackId model.progress 297 298 + else 299 + Dict.insert trackId progress model.progress 300 + in 301 + if model.rememberProgress then 302 + User.saveProgress { model | progress = updatedProgressTable } 303 304 + else 305 + Return.singleton model 306 307 308 + noteProgressDebounce : DebounceManager 309 + noteProgressDebounce = 310 + Common.debounce 311 + .progressDebouncer 312 + (\d m -> { m | progressDebouncer = d }) 313 + UI.NoteProgressDebounce 314 315 316 + notifyScrobblersOfTrackPlaying : { duration : Float } -> Manager 317 + notifyScrobblersOfTrackPlaying { duration } model = 318 + case model.nowPlaying of 319 + Just { item } -> 320 + { duration = round duration 321 + , msg = UI.Bypass 322 + , track = Tuple.second item.identifiedTrack 323 + } 324 + |> LastFm.nowPlaying model.lastFm 325 + |> return model 326 + 327 + Nothing -> 328 + Return.singleton model 329 330 331 + preloadDebounce : DebounceManager 332 + preloadDebounce = 333 + Common.debounce 334 + .preloadDebouncer 335 + (\d m -> { m | preloadDebouncer = d }) 336 + UI.AudioPreloadDebounce 337 338 339 toggleRememberProgress : Manager 340 toggleRememberProgress model = 341 User.saveSettings { model | rememberProgress = not model.rememberProgress } 342 + 343 + 344 + 345 + -- 🛠️ 346 + 347 + 348 + onlyIfMatchesNowPlaying : { trackId : String } -> (NowPlaying -> Manager) -> Manager 349 + onlyIfMatchesNowPlaying { trackId } fn model = 350 + case model.nowPlaying of 351 + Just ({ item } as nowPlaying) -> 352 + if trackId == (Tuple.second item.identifiedTrack).id then 353 + fn nowPlaying model 354 + 355 + else 356 + Return.singleton model 357 + 358 + Nothing -> 359 + Return.singleton model 360 + 361 + 362 + replaceNowPlaying : NowPlaying -> Manager 363 + replaceNowPlaying np model = 364 + Return.singleton { model | nowPlaying = Just np }
+69
src/Applications/UI/Audio/Types.elm
···
··· 1 + module UI.Audio.Types exposing (..) 2 + 3 + import Queue 4 + import Tracks exposing (IdentifiedTrack) 5 + 6 + 7 + 8 + -- 🌳 9 + 10 + 11 + type AudioLoadingState 12 + = Loading 13 + | Loaded 14 + -- Errors 15 + | DecodeError 16 + | NetworkError 17 + | NotSupportedError 18 + 19 + 20 + type alias CoverPrep = 21 + { cacheKey : String 22 + , trackFilename : String 23 + , trackPath : String 24 + , trackSourceId : String 25 + , variousArtists : String 26 + } 27 + 28 + 29 + type alias NowPlaying = 30 + { coverLoaded : Bool 31 + , duration : Maybe Float 32 + , isPlaying : Bool 33 + , item : Queue.Item 34 + , loadingState : AudioLoadingState 35 + , playbackPosition : Float 36 + } 37 + 38 + 39 + 40 + -- 🌳 ░░ EVENTS 41 + 42 + 43 + type alias DurationChangeEvent = 44 + { trackId : String, duration : Float } 45 + 46 + 47 + type alias ErrorAudioEvent = 48 + { trackId : String, code : Int } 49 + 50 + 51 + type alias GenericAudioEvent = 52 + { trackId : String } 53 + 54 + 55 + type alias PlaybackStateEvent = 56 + { trackId : String, isPlaying : Bool } 57 + 58 + 59 + type alias TimeUpdatedEvent = 60 + { trackId : String, currentTime : Float, duration : Maybe Float } 61 + 62 + 63 + 64 + -- 🛠️ 65 + 66 + 67 + nowPlayingIdentifiedTrack : NowPlaying -> IdentifiedTrack 68 + nowPlayingIdentifiedTrack { item } = 69 + item.identifiedTrack
+4 -4
src/Applications/UI/Commands/Alfred.elm
··· 70 nowPlayingCommands : UI.Model -> List (Item UI.Msg) 71 nowPlayingCommands model = 72 case model.nowPlaying of 73 - Just queueItem -> 74 let 75 ( queueItemIdentifiers, _ ) = 76 - queueItem.identifiedTrack 77 78 identifiedTrack = 79 model.tracks.harvested 80 |> List.getAt queueItemIdentifiers.indexInList 81 - |> Maybe.withDefault queueItem.identifiedTrack 82 83 ( identifiers, track ) = 84 identifiedTrack ··· 116 117 118 playbackCommands model = 119 - [ if model.audioIsPlaying then 120 { icon = Just (Icons.pause 16) 121 , title = "Pause" 122 , value = Command UI.TogglePlay
··· 70 nowPlayingCommands : UI.Model -> List (Item UI.Msg) 71 nowPlayingCommands model = 72 case model.nowPlaying of 73 + Just { item } -> 74 let 75 ( queueItemIdentifiers, _ ) = 76 + item.identifiedTrack 77 78 identifiedTrack = 79 model.tracks.harvested 80 |> List.getAt queueItemIdentifiers.indexInList 81 + |> Maybe.withDefault item.identifiedTrack 82 83 ( identifiers, track ) = 84 identifiedTrack ··· 116 117 118 playbackCommands model = 119 + [ if Maybe.map .isPlaying model.nowPlaying == Just True then 120 { icon = Just (Icons.pause 16) 121 , title = "Pause" 122 , value = Command UI.TogglePlay
+80 -36
src/Applications/UI/Console.elm
··· 8 import Json.Decode as Decode 9 import Material.Icons.Round as Icons 10 import Material.Icons.Types exposing (Coloring(..)) 11 - import Queue 12 import UI.Queue.Types as Queue 13 import UI.Tracks.Types as Tracks 14 import UI.Types exposing (Msg(..)) ··· 19 20 21 view : 22 - Maybe Queue.Item 23 -> Bool 24 -> Bool 25 - -> { stalled : Bool, loading : Bool, playing : Bool } 26 - -> ( Float, Float ) 27 -> Html Msg 28 - view activeQueueItem repeat shuffle { stalled, loading, playing } ( position, duration ) = 29 chunk 30 [ "antialiased" 31 , "mt-1" ··· 45 , "py-4" 46 , "text-white" 47 ] 48 - [ if stalled then 49 - text "Audio connection got interrupted, trying to reconnect ..." 50 51 - else if loading then 52 - text "Loading track ..." 53 54 - else 55 - case Maybe.map .identifiedTrack activeQueueItem of 56 - Just ( _, { tags } ) -> 57 - slab 58 - Html.span 59 - [ onClick (TracksMsg Tracks.ScrollToNowPlaying) 60 - , title "Scroll to track" 61 - ] 62 - [ "cursor-pointer" ] 63 - [ case tags.artist of 64 - Just artist -> 65 - text (artist ++ " - " ++ tags.title) 66 67 - Nothing -> 68 - text tags.title 69 - ] 70 71 - Nothing -> 72 - text "Diffuse" 73 ] 74 75 ----------------------------------------- 76 -- Progress Bar 77 ----------------------------------------- 78 , let 79 progress = 80 - if duration <= 0 then 81 - 0 82 83 - else 84 - (position / duration) 85 - |> (*) 100 86 - |> min 100 87 - |> max 0 88 in 89 brick 90 - [ on "click" (clickLocationDecoder Seek) ] 91 [ "cursor-pointer" 92 , "py-1" 93 ] ··· 137 (QueueMsg Queue.Rewind) 138 139 -- 140 - , button 141 "" 142 - (largeLight playing) 143 play 144 TogglePlay 145
··· 8 import Json.Decode as Decode 9 import Material.Icons.Round as Icons 10 import Material.Icons.Types exposing (Coloring(..)) 11 + import Maybe.Extra as Maybe 12 + import UI.Audio.Types exposing (AudioLoadingState(..), NowPlaying, nowPlayingIdentifiedTrack) 13 import UI.Queue.Types as Queue 14 import UI.Tracks.Types as Tracks 15 import UI.Types exposing (Msg(..)) ··· 20 21 22 view : 23 + Maybe NowPlaying 24 -> Bool 25 -> Bool 26 -> Html Msg 27 + view nowPlaying repeat shuffle = 28 chunk 29 [ "antialiased" 30 , "mt-1" ··· 44 , "py-4" 45 , "text-white" 46 ] 47 + [ case Maybe.map .loadingState nowPlaying of 48 + Nothing -> 49 + text "Diffuse" 50 51 + Just Loading -> 52 + text "Loading track ..." 53 54 + Just Loaded -> 55 + case Maybe.map nowPlayingIdentifiedTrack nowPlaying of 56 + Just ( _, { tags } ) -> 57 + slab 58 + Html.span 59 + [ onClick (TracksMsg Tracks.ScrollToNowPlaying) 60 + , title "Scroll to track" 61 + ] 62 + [ "cursor-pointer" ] 63 + [ case tags.artist of 64 + Just artist -> 65 + text (artist ++ " - " ++ tags.title) 66 + 67 + Nothing -> 68 + text tags.title 69 + ] 70 71 + Nothing -> 72 + text "Diffuse" 73 74 + ----------------------------------------- 75 + -- Errors 76 + ----------------------------------------- 77 + Just DecodeError -> 78 + text "(!) An error occurred while decoding the audio" 79 + 80 + Just NetworkError -> 81 + text "Waiting until your internet connection comes back online ..." 82 + 83 + Just NotSupportedError -> 84 + text "(!) Your browser does not support playing this type of audio" 85 + 86 + -- Just NotSupportedOrMissing -> 87 + -- text "The audio is missing or is in a format not supported by your browser." 88 ] 89 90 ----------------------------------------- 91 -- Progress Bar 92 ----------------------------------------- 93 , let 94 + maybeDuration = 95 + Maybe.andThen .duration nowPlaying 96 + 97 + maybePosition = 98 + Maybe.map .playbackPosition nowPlaying 99 + 100 progress = 101 + case ( maybeDuration, maybePosition ) of 102 + ( Just duration, Just position ) -> 103 + if duration <= 0 then 104 + 0 105 106 + else 107 + (position / duration) 108 + |> (*) 100 109 + |> min 100 110 + |> max 0 111 + 112 + _ -> 113 + 0 114 in 115 brick 116 + (case nowPlaying of 117 + Just { item } -> 118 + item.identifiedTrack 119 + |> Tuple.second 120 + |> .id 121 + |> (\id -> 122 + \float -> Seek { progress = float, trackId = id } 123 + ) 124 + |> clickLocationDecoder 125 + |> on "click" 126 + |> List.singleton 127 + 128 + Nothing -> 129 + [] 130 + ) 131 [ "cursor-pointer" 132 , "py-1" 133 ] ··· 177 (QueueMsg Queue.Rewind) 178 179 -- 180 + , let 181 + isPlaying = 182 + Maybe.unwrap False .isPlaying nowPlaying 183 + in 184 + button 185 "" 186 + (largeLight isPlaying) 187 play 188 TogglePlay 189
+1
src/Applications/UI/Notifications.elm
··· 95 96 -- 97 , "absolute" 98 , "bottom-0" 99 , "flex" 100 , "flex-col"
··· 95 96 -- 97 , "absolute" 98 + , "break-all" 99 , "bottom-0" 100 , "flex" 101 , "flex-col"
+31 -4
src/Applications/UI/Other/State.elm
··· 2 3 import Alien 4 import Common exposing (ServiceWorkerStatus(..)) 5 import Notifications 6 import Return exposing (return) 7 import Time ··· 41 42 setIsOnline : Bool -> Manager 43 setIsOnline bool model = 44 - if bool then 45 - syncHypaethralData { model | isOnline = bool } 46 47 - else 48 - Return.singleton { model | isOnline = bool } 49 50 51 setCurrentTime : Time.Posix -> Manager
··· 2 3 import Alien 4 import Common exposing (ServiceWorkerStatus(..)) 5 + import Dict 6 import Notifications 7 import Return exposing (return) 8 import Time ··· 42 43 setIsOnline : Bool -> Manager 44 setIsOnline bool model = 45 + { model | isOnline = bool } 46 + |> Return.singleton 47 + |> Return.command 48 + (case model.nowPlaying of 49 + Just { isPlaying, item } -> 50 + let 51 + trackId = 52 + (Tuple.second item.identifiedTrack).id 53 + in 54 + Ports.reloadAudioNodeIfNeeded 55 + { play = isPlaying 56 + , progress = 57 + if model.rememberProgress then 58 + Dict.get trackId model.progress 59 60 + else 61 + Nothing 62 + , trackId = trackId 63 + } 64 + 65 + Nothing -> 66 + Cmd.none 67 + ) 68 + |> Return.andThen 69 + (case ( model.isOnline, bool ) of 70 + ( False, True ) -> 71 + syncHypaethralData 72 + 73 + _ -> 74 + Return.singleton 75 + ) 76 77 78 setCurrentTime : Time.Posix -> Manager
+54 -23
src/Applications/UI/Ports.elm
··· 3 import Alien 4 import Json.Encode as Json 5 import Queue 6 7 8 ··· 33 port openUrlOnNewPage : String -> Cmd msg 34 35 36 - port pause : () -> Cmd msg 37 38 39 port pickAverageBackgroundColor : String -> Cmd msg 40 41 42 - port play : () -> Cmd msg 43 44 45 port preloadAudio : Queue.EngineItem -> Cmd msg ··· 48 port reloadApp : () -> Cmd msg 49 50 51 - port seek : Float -> Cmd msg 52 53 54 - port setRepeat : Bool -> Cmd msg 55 56 57 port toBrain : Alien.Event -> Cmd msg ··· 61 -- 📰 62 63 64 - port activeQueueItemEnded : (() -> msg) -> Sub msg 65 66 67 port collectedFissionCapabilities : (() -> msg) -> Sub msg ··· 88 port installingNewServiceWorker : (() -> msg) -> Sub msg 89 90 91 - port noteProgress : ({ trackId : String, progress : Float } -> msg) -> Sub msg 92 - 93 - 94 port refreshedAccessToken : (Json.Value -> msg) -> Sub msg 95 96 97 port preferredColorSchemaChanged : ({ dark : Bool } -> msg) -> Sub msg 98 99 100 port requestNext : (() -> msg) -> Sub msg ··· 116 117 118 port scrobble : ({ duration : Int, timestamp : Int, trackId : String } -> msg) -> Sub msg 119 - 120 - 121 - port setAudioPosition : (Float -> msg) -> Sub msg 122 - 123 - 124 - port setAudioDuration : (Float -> msg) -> Sub msg 125 - 126 - 127 - port setAudioIsLoading : (Bool -> msg) -> Sub msg 128 - 129 - 130 - port setAudioIsPlaying : (Bool -> msg) -> Sub msg 131 - 132 - 133 - port setAudioHasStalled : (Bool -> msg) -> Sub msg 134 135 136 port setAverageBackgroundColor : ({ r : Int, g : Int, b : Int } -> msg) -> Sub msg
··· 3 import Alien 4 import Json.Encode as Json 5 import Queue 6 + import UI.Audio.Types as Audio 7 8 9 ··· 34 port openUrlOnNewPage : String -> Cmd msg 35 36 37 + port pause : { trackId : String } -> Cmd msg 38 + 39 + 40 + port pauseScrobbleTimer : () -> Cmd msg 41 42 43 port pickAverageBackgroundColor : String -> Cmd msg 44 45 46 + port play : { trackId : String, volume : Float } -> Cmd msg 47 + 48 + 49 + port reloadAudioNodeIfNeeded : { play : Bool, progress : Maybe Float, trackId : String } -> Cmd msg 50 51 52 port preloadAudio : Queue.EngineItem -> Cmd msg ··· 55 port reloadApp : () -> Cmd msg 56 57 58 + port renderAudioElements : { items : List Queue.EngineItem, play : Maybe String, volume : Float } -> Cmd msg 59 + 60 + 61 + port resetScrobbleTimer : { duration : Float, trackId : String } -> Cmd msg 62 + 63 + 64 + port seek : { percentage : Float, trackId : String } -> Cmd msg 65 + 66 + 67 + port sendTask : Json.Value -> Cmd msg 68 + 69 + 70 + port setMediaSessionArtwork : { blobUrl : String, imageType : String } -> Cmd msg 71 + 72 + 73 + port setMediaSessionMetadata : { album : Maybe String, artist : Maybe String, title : String, coverPrep : Maybe Audio.CoverPrep } -> Cmd msg 74 + 75 + 76 + port setMediaSessionPlaybackState : String -> Cmd msg 77 + 78 + 79 + port setMediaSessionPositionState : { currentTime : Float, duration : Float } -> Cmd msg 80 81 82 + port startScrobbleTimer : () -> Cmd msg 83 84 85 port toBrain : Alien.Event -> Cmd msg ··· 89 -- 📰 90 91 92 + port audioDurationChange : (Audio.DurationChangeEvent -> msg) -> Sub msg 93 + 94 + 95 + port audioEnded : (Audio.GenericAudioEvent -> msg) -> Sub msg 96 + 97 + 98 + port audioError : (Audio.ErrorAudioEvent -> msg) -> Sub msg 99 + 100 + 101 + port audioPlaybackStateChanged : (Audio.PlaybackStateEvent -> msg) -> Sub msg 102 + 103 + 104 + port audioIsLoading : (Audio.GenericAudioEvent -> msg) -> Sub msg 105 + 106 + 107 + port audioHasLoaded : (Audio.GenericAudioEvent -> msg) -> Sub msg 108 + 109 + 110 + port audioTimeUpdated : (Audio.TimeUpdatedEvent -> msg) -> Sub msg 111 112 113 port collectedFissionCapabilities : (() -> msg) -> Sub msg ··· 134 port installingNewServiceWorker : (() -> msg) -> Sub msg 135 136 137 port refreshedAccessToken : (Json.Value -> msg) -> Sub msg 138 139 140 port preferredColorSchemaChanged : ({ dark : Bool } -> msg) -> Sub msg 141 + 142 + 143 + port receiveTask : (Json.Value -> msg) -> Sub msg 144 145 146 port requestNext : (() -> msg) -> Sub msg ··· 162 163 164 port scrobble : ({ duration : Int, timestamp : Int, trackId : String } -> msg) -> Sub msg 165 166 167 port setAverageBackgroundColor : ({ r : Int, g : Int, b : Int } -> msg) -> Sub msg
+91 -14
src/Applications/UI/Queue/State.elm
··· 1 module UI.Queue.State exposing (..) 2 3 import Coordinates 4 import Dict 5 import Html.Events.Extra.Mouse as Mouse 6 import List.Extra as List 7 import Notifications 8 import Queue exposing (..) 9 - import Return exposing (andThen, return) 10 import Tracks exposing (..) 11 import UI.Common.State as Common 12 import UI.Ports as Ports 13 import UI.Queue.ContextMenu as Queue ··· 26 case msg of 27 Clear -> 28 clear 29 30 Reset -> 31 reset ··· 85 86 changeActiveItem : Maybe Item -> Manager 87 changeActiveItem maybeItem model = 88 maybeItem 89 - |> Maybe.map 90 - (.identifiedTrack >> Tuple.second) 91 |> Maybe.map 92 (Queue.makeEngineItem 93 model.currentTime 94 model.sources 95 model.cachedTracks ··· 100 Dict.empty 101 ) 102 ) 103 - |> Ports.activeQueueItemChanged 104 - |> return { model | nowPlaying = maybeItem } 105 |> andThen fill 106 107 ··· 145 else 146 let 147 fillState = 148 - { activeItem = m.nowPlaying 149 , future = m.playingNext 150 , ignored = m.dontPlay 151 , past = m.playedPreviously ··· 158 else 159 { m | playingNext = Fill.ordered timestamp nonMissingTracks fillState } 160 ) 161 - |> preloadNext 162 163 164 preloadNext : Manager ··· 169 |> .identifiedTrack 170 |> Tuple.second 171 |> Queue.makeEngineItem 172 model.currentTime 173 model.sources 174 model.cachedTracks ··· 178 else 179 Dict.empty 180 ) 181 - |> Ports.preloadAudio 182 - |> return model 183 184 Nothing -> 185 Return.singleton model ··· 192 { model 193 | playingNext = 194 model.nowPlaying 195 - |> Maybe.map (\item -> item :: model.playingNext) 196 |> Maybe.withDefault model.playingNext 197 , playedPreviously = 198 model.playedPreviously ··· 226 |> List.drop 1 227 , playedPreviously = 228 model.nowPlaying 229 - |> Maybe.map List.singleton 230 |> Maybe.map (List.append model.playedPreviously) 231 |> Maybe.withDefault model.playedPreviously 232 } ··· 273 274 toggleRepeat : Manager 275 toggleRepeat model = 276 - { model | repeat = not model.repeat } 277 - |> saveEnclosedUserData 278 - |> Return.effect_ (.repeat >> Ports.setRepeat) 279 280 281 toggleShuffle : Manager
··· 1 module UI.Queue.State exposing (..) 2 3 import Coordinates 4 + import Debouncer.Basic as Debouncer 5 import Dict 6 import Html.Events.Extra.Mouse as Mouse 7 import List.Extra as List 8 import Notifications 9 import Queue exposing (..) 10 + import Return exposing (andThen) 11 + import Return.Ext as Return 12 import Tracks exposing (..) 13 + import UI.Audio.Types exposing (AudioLoadingState(..)) 14 import UI.Common.State as Common 15 import UI.Ports as Ports 16 import UI.Queue.ContextMenu as Queue ··· 29 case msg of 30 Clear -> 31 clear 32 + 33 + PreloadNext -> 34 + preloadNext 35 36 Reset -> 37 reset ··· 91 92 changeActiveItem : Maybe Item -> Manager 93 changeActiveItem maybeItem model = 94 + let 95 + maybeNowPlaying = 96 + Maybe.map 97 + (\item -> 98 + { coverLoaded = False 99 + , duration = Nothing 100 + , isPlaying = False 101 + , item = item 102 + , loadingState = Loading 103 + , playbackPosition = 0 104 + } 105 + ) 106 + maybeItem 107 + in 108 maybeItem 109 + |> Maybe.map (.identifiedTrack >> Tuple.second) 110 |> Maybe.map 111 (Queue.makeEngineItem 112 + False 113 model.currentTime 114 model.sources 115 model.cachedTracks ··· 120 Dict.empty 121 ) 122 ) 123 + |> Maybe.map insertTrack 124 + |> Maybe.withDefault Return.singleton 125 + |> (\fn -> fn { model | nowPlaying = maybeNowPlaying }) 126 |> andThen fill 127 128 ··· 166 else 167 let 168 fillState = 169 + { activeItem = Maybe.map .item m.nowPlaying 170 , future = m.playingNext 171 , ignored = m.dontPlay 172 , past = m.playedPreviously ··· 179 else 180 { m | playingNext = Fill.ordered timestamp nonMissingTracks fillState } 181 ) 182 + |> Return.communicate 183 + (Queue.PreloadNext 184 + |> QueueMsg 185 + |> Debouncer.provideInput 186 + |> AudioPreloadDebounce 187 + |> Return.task 188 + ) 189 + 190 + 191 + insertTrack : EngineItem -> Manager 192 + insertTrack item model = 193 + item 194 + |> (\engineItem -> 195 + if 196 + List.any 197 + (\a -> engineItem.trackId == a.trackId) 198 + model.audioElements 199 + then 200 + List.map 201 + (\a -> 202 + if engineItem.trackId == a.trackId then 203 + { a | isPreload = False } 204 + 205 + else 206 + a 207 + ) 208 + model.audioElements 209 + 210 + else 211 + model.audioElements ++ [ engineItem ] 212 + ) 213 + |> List.filter 214 + (\a -> 215 + if item.isPreload then 216 + True 217 + 218 + else if a.trackId /= item.trackId && not a.isPreload then 219 + False 220 + 221 + else 222 + True 223 + ) 224 + |> (\a -> { model | audioElements = a }) 225 + |> Return.singleton 226 + |> Return.effect_ 227 + (\m -> 228 + Ports.renderAudioElements 229 + { items = m.audioElements 230 + , play = 231 + if item.isPreload then 232 + Nothing 233 + 234 + else 235 + Just item.trackId 236 + , volume = m.eqSettings.volume 237 + } 238 + ) 239 240 241 preloadNext : Manager ··· 246 |> .identifiedTrack 247 |> Tuple.second 248 |> Queue.makeEngineItem 249 + True 250 model.currentTime 251 model.sources 252 model.cachedTracks ··· 256 else 257 Dict.empty 258 ) 259 + |> (\engineItem -> 260 + insertTrack engineItem model 261 + ) 262 263 Nothing -> 264 Return.singleton model ··· 271 { model 272 | playingNext = 273 model.nowPlaying 274 + |> Maybe.map (\{ item } -> item :: model.playingNext) 275 |> Maybe.withDefault model.playingNext 276 , playedPreviously = 277 model.playedPreviously ··· 305 |> List.drop 1 306 , playedPreviously = 307 model.nowPlaying 308 + |> Maybe.map (.item >> List.singleton) 309 |> Maybe.map (List.append model.playedPreviously) 310 |> Maybe.withDefault model.playedPreviously 311 } ··· 352 353 toggleRepeat : Manager 354 toggleRepeat model = 355 + saveEnclosedUserData { model | repeat = not model.repeat } 356 357 358 toggleShuffle : Manager
+1
src/Applications/UI/Queue/Types.elm
··· 11 12 type Msg 13 = Clear 14 | Reset 15 | Rewind 16 | Select Item
··· 11 12 type Msg 13 = Clear 14 + | PreloadNext 15 | Reset 16 | Rewind 17 | Select Item
+1 -1
src/Applications/UI/Services/State.elm
··· 56 57 scrobble : { duration : Int, timestamp : Int, trackId : String } -> Manager 58 scrobble { duration, timestamp, trackId } model = 59 - case Maybe.map .identifiedTrack model.nowPlaying of 60 Just ( _, track ) -> 61 if trackId == track.id then 62 ( model
··· 56 57 scrobble : { duration : Int, timestamp : Int, trackId : String } -> Manager 58 scrobble { duration, timestamp, trackId } model = 59 + case Maybe.map (.item >> .identifiedTrack) model.nowPlaying of 60 Just ( _, track ) -> 61 if trackId == track.id then 62 ( model
+3 -4
src/Applications/UI/Tracks/Scene/Covers.elm
··· 15 import Material.Icons.Round as Icons 16 import Material.Icons.Types exposing (Coloring(..)) 17 import Maybe.Extra as Maybe 18 - import Queue 19 import Task 20 import Tracks exposing (..) 21 import UI.Tracks.Scene as Scene ··· 36 , favouritesOnly : Bool 37 , infiniteList : InfiniteList.Model 38 , isVisible : Bool 39 - , nowPlaying : Maybe Queue.Item 40 , selectedCover : Maybe Cover 41 , selectedTrackIndexes : List Int 42 , sortBy : SortBy ··· 50 { cachedCovers : Maybe (Dict String String) 51 , columns : Int 52 , containerWidth : Int 53 - , nowPlaying : Maybe Queue.Item 54 , sortBy : SortBy 55 } 56 ··· 664 coverView { clickable, horizontal } { cachedCovers, nowPlaying } cover = 665 let 666 nowPlayingId = 667 - Maybe.unwrap "" (.identifiedTrack >> Tuple.second >> .id) nowPlaying 668 669 missingTracks = 670 List.any
··· 15 import Material.Icons.Round as Icons 16 import Material.Icons.Types exposing (Coloring(..)) 17 import Maybe.Extra as Maybe 18 import Task 19 import Tracks exposing (..) 20 import UI.Tracks.Scene as Scene ··· 35 , favouritesOnly : Bool 36 , infiniteList : InfiniteList.Model 37 , isVisible : Bool 38 + , nowPlaying : Maybe IdentifiedTrack 39 , selectedCover : Maybe Cover 40 , selectedTrackIndexes : List Int 41 , sortBy : SortBy ··· 49 { cachedCovers : Maybe (Dict String String) 50 , columns : Int 51 , containerWidth : Int 52 + , nowPlaying : Maybe IdentifiedTrack 53 , sortBy : SortBy 54 } 55 ··· 663 coverView { clickable, horizontal } { cachedCovers, nowPlaying } cover = 664 let 665 nowPlayingId = 666 + Maybe.unwrap "" (Tuple.second >> .id) nowPlaying 667 668 missingTracks = 669 List.any
+6 -7
src/Applications/UI/Tracks/Scene/List.elm
··· 16 import Material.Icons.Round as Icons 17 import Material.Icons.Types exposing (Coloring(..)) 18 import Maybe.Extra as Maybe 19 - import Queue 20 import Task 21 import Tracks exposing (..) 22 import UI.DnD as DnD ··· 48 } 49 50 51 - view : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe Queue.Item -> Maybe String -> SortBy -> SortDirection -> List Int -> Maybe (DnD.Model Int) -> Html Msg 52 view deps harvest infiniteList favouritesOnly nowPlaying searchTerm sortBy sortDirection selectedTrackIndexes maybeDnD = 53 brick 54 (tabindex (ifThenElse deps.isVisible 0 -1) :: viewAttributes) ··· 261 -- INFINITE LIST 262 263 264 - infiniteListView : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe String -> ( Maybe Queue.Item, List Int ) -> Maybe (DnD.Model Int) -> Html Msg 265 infiniteListView deps harvest infiniteList favouritesOnly searchTerm ( nowPlaying, selectedTrackIndexes ) maybeDnD = 266 let 267 derivedColors = ··· 364 defaultItemView : 365 { derivedColors : DerivedColors 366 , favouritesOnly : Bool 367 - , nowPlaying : Maybe Queue.Item 368 , roundedCorners : Bool 369 , selectedTrackIndexes : List Int 370 , showAlbum : Bool ··· 394 395 rowIdentifiers = 396 { isMissing = identifiers.isMissing 397 - , isNowPlaying = Maybe.unwrap False (.identifiedTrack >> isNowPlaying identifiedTrack) nowPlaying 398 , isSelected = isSelected 399 } 400 ··· 480 ] 481 482 483 - playlistItemView : Bool -> Maybe Queue.Item -> Maybe String -> List Int -> DnD.Model Int -> Bool -> Bool -> DerivedColors -> Int -> Int -> IdentifiedTrack -> Html Msg 484 playlistItemView favouritesOnly nowPlaying _ selectedTrackIndexes dnd showAlbum darkMode derivedColors _ idx identifiedTrack = 485 let 486 ( identifiers, track ) = ··· 502 503 rowIdentifiers = 504 { isMissing = identifiers.isMissing 505 - , isNowPlaying = Maybe.unwrap False (.identifiedTrack >> isNowPlaying identifiedTrack) nowPlaying 506 , isSelected = isSelected 507 } 508
··· 16 import Material.Icons.Round as Icons 17 import Material.Icons.Types exposing (Coloring(..)) 18 import Maybe.Extra as Maybe 19 import Task 20 import Tracks exposing (..) 21 import UI.DnD as DnD ··· 47 } 48 49 50 + view : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe IdentifiedTrack -> Maybe String -> SortBy -> SortDirection -> List Int -> Maybe (DnD.Model Int) -> Html Msg 51 view deps harvest infiniteList favouritesOnly nowPlaying searchTerm sortBy sortDirection selectedTrackIndexes maybeDnD = 52 brick 53 (tabindex (ifThenElse deps.isVisible 0 -1) :: viewAttributes) ··· 260 -- INFINITE LIST 261 262 263 + infiniteListView : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe String -> ( Maybe IdentifiedTrack, List Int ) -> Maybe (DnD.Model Int) -> Html Msg 264 infiniteListView deps harvest infiniteList favouritesOnly searchTerm ( nowPlaying, selectedTrackIndexes ) maybeDnD = 265 let 266 derivedColors = ··· 363 defaultItemView : 364 { derivedColors : DerivedColors 365 , favouritesOnly : Bool 366 + , nowPlaying : Maybe IdentifiedTrack 367 , roundedCorners : Bool 368 , selectedTrackIndexes : List Int 369 , showAlbum : Bool ··· 393 394 rowIdentifiers = 395 { isMissing = identifiers.isMissing 396 + , isNowPlaying = Maybe.unwrap False (isNowPlaying identifiedTrack) nowPlaying 397 , isSelected = isSelected 398 } 399 ··· 479 ] 480 481 482 + playlistItemView : Bool -> Maybe IdentifiedTrack -> Maybe String -> List Int -> DnD.Model Int -> Bool -> Bool -> DerivedColors -> Int -> Int -> IdentifiedTrack -> Html Msg 483 playlistItemView favouritesOnly nowPlaying _ selectedTrackIndexes dnd showAlbum darkMode derivedColors _ idx identifiedTrack = 484 let 485 ( identifiers, track ) = ··· 501 502 rowIdentifiers = 503 { isMissing = identifiers.isMissing 504 + , isNowPlaying = Maybe.unwrap False (isNowPlaying identifiedTrack) nowPlaying 505 , isSelected = isSelected 506 } 507
+44 -10
src/Applications/UI/Tracks/State.elm
··· 1 module UI.Tracks.State exposing (..) 2 3 import Alien 4 import Common exposing (..) 5 import ContextMenu 6 import Coordinates exposing (Coordinates) ··· 362 let 363 cachedCovers = 364 Maybe.withDefault Dict.empty model.cachedCovers 365 in 366 - json 367 - |> Json.decodeValue 368 - (Json.map2 369 - Tuple.pair 370 - (Json.field "key" Json.string) 371 - (Json.field "url" Json.string) 372 - ) 373 - |> Result.map (\( key, url ) -> Dict.insert key url cachedCovers) 374 |> Result.map (\dict -> { model | cachedCovers = Just dict }) 375 |> Result.withDefault model 376 - |> Return.singleton 377 378 379 groupBy : Tracks.Grouping -> Manager ··· 727 scrollToNowPlaying model = 728 model.nowPlaying 729 |> Maybe.map 730 - (.identifiedTrack >> Tuple.second >> .id) 731 |> Maybe.andThen 732 (\id -> 733 List.find
··· 1 module UI.Tracks.State exposing (..) 2 3 import Alien 4 + import Base64 5 import Common exposing (..) 6 import ContextMenu 7 import Coordinates exposing (Coordinates) ··· 363 let 364 cachedCovers = 365 Maybe.withDefault Dict.empty model.cachedCovers 366 + 367 + decodedValue = 368 + Json.decodeValue 369 + (Json.map3 370 + (\i k u -> ( i, k, u )) 371 + (Json.field "imageType" Json.string) 372 + (Json.field "key" Json.string) 373 + (Json.field "url" Json.string) 374 + ) 375 + json 376 in 377 + decodedValue 378 + |> Result.map (\( _, key, url ) -> Dict.insert key url cachedCovers) 379 |> Result.map (\dict -> { model | cachedCovers = Just dict }) 380 |> Result.withDefault model 381 + |> (\m -> 382 + case ( m.nowPlaying, decodedValue ) of 383 + ( Just nowPlaying, Ok val ) -> 384 + let 385 + ( imageType, key, url ) = 386 + val 387 + 388 + ( _, track ) = 389 + nowPlaying.item.identifiedTrack 390 + 391 + hasntLoadedYet = 392 + nowPlaying.coverLoaded == False 393 + 394 + ( keyA, keyB ) = 395 + ( Base64.encode (Tracks.coverKey False track) 396 + , Base64.encode (Tracks.coverKey True track) 397 + ) 398 + 399 + keyMatches = 400 + keyA == key || keyB == key 401 + in 402 + if hasntLoadedYet && keyMatches then 403 + ( m, Ports.setMediaSessionArtwork { blobUrl = url, imageType = imageType } ) 404 + 405 + else 406 + Return.singleton m 407 + 408 + _ -> 409 + Return.singleton m 410 + ) 411 412 413 groupBy : Tracks.Grouping -> Manager ··· 761 scrollToNowPlaying model = 762 model.nowPlaying 763 |> Maybe.map 764 + (.item >> .identifiedTrack >> Tuple.second >> .id) 765 |> Maybe.andThen 766 (\id -> 767 List.find
+3 -2
src/Applications/UI/Tracks/View.elm
··· 15 import Maybe.Extra as Maybe 16 import Playlists exposing (Playlist) 17 import Tracks exposing (..) 18 import UI.Kit 19 import UI.Navigation exposing (..) 20 import UI.Page as Page ··· 95 , favouritesOnly = model.favouritesOnly 96 , infiniteList = model.infiniteList 97 , isVisible = isOnIndexPage 98 - , nowPlaying = model.nowPlaying 99 , selectedCover = model.selectedCover 100 , selectedTrackIndexes = model.selectedTrackIndexes 101 , sortBy = model.sortBy ··· 126 model.tracks.harvested 127 model.infiniteList 128 model.favouritesOnly 129 - model.nowPlaying 130 model.searchTerm 131 model.sortBy 132 model.sortDirection
··· 15 import Maybe.Extra as Maybe 16 import Playlists exposing (Playlist) 17 import Tracks exposing (..) 18 + import UI.Audio.Types exposing (nowPlayingIdentifiedTrack) 19 import UI.Kit 20 import UI.Navigation exposing (..) 21 import UI.Page as Page ··· 96 , favouritesOnly = model.favouritesOnly 97 , infiniteList = model.infiniteList 98 , isVisible = isOnIndexPage 99 + , nowPlaying = Maybe.map nowPlayingIdentifiedTrack model.nowPlaying 100 , selectedCover = model.selectedCover 101 , selectedTrackIndexes = model.selectedTrackIndexes 102 , sortBy = model.sortBy ··· 127 model.tracks.harvested 128 model.infiniteList 129 model.favouritesOnly 130 + (Maybe.map nowPlayingIdentifiedTrack model.nowPlaying) 131 model.searchTerm 132 model.sortBy 133 model.sortDirection
+15 -12
src/Applications/UI/Types.elm
··· 26 import Sources exposing (Source) 27 import Time 28 import Tracks exposing (..) 29 import UI.DnD as DnD 30 import UI.Page exposing (Page) 31 import UI.Queue.Types as Queue ··· 83 ----------------------------------------- 84 -- Audio 85 ----------------------------------------- 86 - , audioDuration : Float 87 - , audioHasStalled : Bool 88 - , audioIsLoading : Bool 89 - , audioIsPlaying : Bool 90 - , audioPosition : Float 91 , progress : Dict String Float 92 , rememberProgress : Bool 93 ··· 102 ----------------------------------------- 103 -- Debouncing 104 ----------------------------------------- 105 , resizeDebouncer : Debouncer Msg Msg 106 , searchDebouncer : Debouncer Msg Msg 107 ··· 132 -- Queue 133 ----------------------------------------- 134 , dontPlay : List Queue.Item 135 - , nowPlaying : Maybe Queue.Item 136 , playedPreviously : List Queue.Item 137 , playingNext : List Queue.Item 138 , selectedQueueItem : Maybe Queue.Item ··· 199 ----------------------------------------- 200 -- Audio 201 ----------------------------------------- 202 | NoteProgress { trackId : String, progress : Float } 203 | Pause 204 | Play 205 - | Seek Float 206 - | SetAudioDuration Float 207 - | SetAudioHasStalled Bool 208 - | SetAudioIsLoading Bool 209 - | SetAudioIsPlaying Bool 210 - | SetAudioPosition Float 211 | Stop 212 | TogglePlay 213 | ToggleRememberProgress
··· 26 import Sources exposing (Source) 27 import Time 28 import Tracks exposing (..) 29 + import UI.Audio.Types exposing (DurationChangeEvent, ErrorAudioEvent, GenericAudioEvent, NowPlaying, PlaybackStateEvent, TimeUpdatedEvent) 30 import UI.DnD as DnD 31 import UI.Page exposing (Page) 32 import UI.Queue.Types as Queue ··· 84 ----------------------------------------- 85 -- Audio 86 ----------------------------------------- 87 + , audioElements : List Queue.EngineItem 88 + , nowPlaying : Maybe NowPlaying 89 , progress : Dict String Float 90 , rememberProgress : Bool 91 ··· 100 ----------------------------------------- 101 -- Debouncing 102 ----------------------------------------- 103 + , preloadDebouncer : Debouncer Msg Msg 104 + , progressDebouncer : Debouncer Msg Msg 105 , resizeDebouncer : Debouncer Msg Msg 106 , searchDebouncer : Debouncer Msg Msg 107 ··· 132 -- Queue 133 ----------------------------------------- 134 , dontPlay : List Queue.Item 135 , playedPreviously : List Queue.Item 136 , playingNext : List Queue.Item 137 , selectedQueueItem : Maybe Queue.Item ··· 198 ----------------------------------------- 199 -- Audio 200 ----------------------------------------- 201 + | AudioDurationChange DurationChangeEvent 202 + | AudioError ErrorAudioEvent 203 + | AudioEnded GenericAudioEvent 204 + | AudioHasLoaded GenericAudioEvent 205 + | AudioIsLoading GenericAudioEvent 206 + | AudioPlaybackStateChanged PlaybackStateEvent 207 + | AudioPreloadDebounce (Debouncer.Msg Msg) 208 + | AudioTimeUpdated TimeUpdatedEvent 209 | NoteProgress { trackId : String, progress : Float } 210 + | NoteProgressDebounce (Debouncer.Msg Msg) 211 | Pause 212 | Play 213 + | Seek { trackId : String, progress : Float } 214 | Stop 215 | TogglePlay 216 | ToggleRememberProgress
+1 -5
src/Applications/UI/User/State/Import.elm
··· 18 import UI.Equalizer.State as Equalizer 19 import UI.Page as Page 20 import UI.Playlists.Directory 21 - import UI.Ports as Ports 22 import UI.Sources.State as Sources 23 import UI.Tracks.State as Tracks 24 import UI.Types as UI exposing (..) ··· 223 , sortDirection = data.sortDirection 224 } 225 -- 226 - , Cmd.batch 227 - [ Equalizer.adjustAllKnobs newEqualizerSettings 228 - , Ports.setRepeat data.repeat 229 - ] 230 ) 231 232 Err err ->
··· 18 import UI.Equalizer.State as Equalizer 19 import UI.Page as Page 20 import UI.Playlists.Directory 21 import UI.Sources.State as Sources 22 import UI.Tracks.State as Tracks 23 import UI.Types as UI exposing (..) ··· 222 , sortDirection = data.sortDirection 223 } 224 -- 225 + , Equalizer.adjustAllKnobs newEqualizerSettings 226 ) 227 228 Err err ->
-7
src/Applications/UI/View.elm
··· 193 model.nowPlaying 194 model.repeat 195 model.shuffle 196 - { stalled = model.audioHasStalled 197 - , loading = model.audioIsLoading 198 - , playing = model.audioIsPlaying 199 - } 200 - ( model.audioPosition 201 - , model.audioDuration 202 - ) 203 ] 204 205
··· 193 model.nowPlaying 194 model.repeat 195 model.shuffle 196 ] 197 198
+24
src/Javascript/Brain/application.ts
···
··· 1 + import "./index.d" 2 + 3 + // @ts-ignore 4 + import { Elm } from "brain.elm.js" 5 + 6 + 7 + // 🚀 8 + 9 + 10 + const flags: Record<string, string> = location 11 + .hash 12 + .substring(1) 13 + .split("&") 14 + .reduce((acc, flag) => { 15 + const [k, v] = flag.split("=") 16 + return { ...acc, [k]: v } 17 + }, {}) 18 + 19 + 20 + export const load = () => Elm.Brain.init({ 21 + flags: { 22 + initialUrl: decodeURIComponent(flags.appHref) || "" 23 + } 24 + })
+120 -14
src/Javascript/Brain/artwork.ts
··· 2 // Album Covers 3 // (◕‿◕✿) 4 5 import { transformUrl } from "../urls" 6 - import * as processing from "../processing" 7 8 9 const REJECT = () => Promise.reject("No artwork found") 10 11 12 - export function find(prep, app) { 13 - return findUsingTags(prep, app) 14 .then(a => a ? a : findUsingMusicBrainz(prep)) 15 .then(a => a ? a : findUsingLastFm(prep)) 16 .then(a => a ? a : REJECT()) 17 .then(a => a.type.startsWith("image/") ? a : REJECT()) 18 - } 19 - 20 - 21 - function decodeCacheKey(cacheKey) { 22 - return decodeURIComponent(escape(atob(cacheKey))) 23 } 24 25 ··· 27 // 1. TAGS 28 29 30 - async function findUsingTags(prep, app) { 31 return Promise.all( 32 [ 33 transformUrl(prep.trackHeadUrl, app), ··· 53 // 2. MUSIC BRAINZ 54 55 56 - function findUsingMusicBrainz(prep) { 57 const parts = decodeCacheKey(prep.cacheKey).split(" --- ") 58 const artist = parts[ 0 ] 59 const album = parts[ 1 ] || parts[ 0 ] ··· 64 return fetch(`https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`) 65 .then(r => r.json()) 66 .then(r => musicBrainzCover(r.releases)) 67 - .catch(_ => REJECT()) 68 } 69 70 ··· 90 // 3. LAST FM 91 92 93 - function findUsingLastFm(prep) { 94 - const query = decodeCacheKey(prep.cacheKey).replace(" --- ", " ") 95 96 return fetch(`https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`) 97 .then(r => r.json()) 98 .then(r => lastFmCover(r.results.albummatches.album)) 99 - .catch(_ => REJECT()) 100 } 101 102
··· 2 // Album Covers 3 // (◕‿◕✿) 4 5 + import * as Uint8arrays from "uint8arrays" 6 + 7 + import * as processing from "./processing" 8 + import { type App } from "./elm/types" 9 import { transformUrl } from "../urls" 10 + import { toCache } from "./common" 11 + import { type CoverPrep } from "../common" 12 + 13 + 14 + // 🌳 15 + 16 + 17 + type CoverPrepWithUrls = CoverPrep & { 18 + trackGetUrl: string 19 + trackHeadUrl: string 20 + } 21 + 22 + 23 + 24 + // 🏔️ 25 + 26 + 27 + let artworkQueue: CoverPrep[] = [] 28 + let app: App 29 + 30 + 31 + 32 + // 🚀 33 + 34 + 35 + export function init(a: App) { 36 + app = a 37 + 38 + app.ports.provideArtworkTrackUrls.subscribe(provideArtworkTrackUrls) 39 + } 40 + 41 + 42 + 43 + // PORTS 44 + 45 + 46 + function provideArtworkTrackUrls(prep: CoverPrepWithUrls) { 47 + find(prep).then(blob => { 48 + return toCache(`coverCache.${prep.cacheKey}`, blob).then(_ => blob) 49 + }) 50 + .then((blob: Blob) => { 51 + const url = URL.createObjectURL(blob) 52 + 53 + self.postMessage({ 54 + tag: "GOT_CACHED_COVER", 55 + data: { imageType: blob.type, key: prep.cacheKey, url: url }, 56 + error: null 57 + }) 58 + }) 59 + .catch(err => { 60 + if (err === "No artwork found") { 61 + // Indicate that we've tried to find artwork, 62 + // so that we don't try to find it each time we launch the app. 63 + return toCache(`coverCache.${prep.cacheKey}`, "TRIED") 64 + 65 + } else { 66 + // Something went wrong 67 + console.error(err) 68 + return toCache(`coverCache.${prep.cacheKey}`, "TRIED") 69 + 70 + } 71 + }) 72 + .catch(() => { 73 + console.warn("Failed to download artwork for ", prep) 74 + }) 75 + .finally(shiftQueue) 76 + } 77 + 78 + 79 + 80 + // 🛠️ 81 + 82 + 83 + export function download(list: CoverPrep[]) { 84 + const exe = !artworkQueue[0] 85 + artworkQueue = artworkQueue.concat(list) 86 + if (exe) shiftQueue() 87 + } 88 + 89 + 90 + function shiftQueue() { 91 + const next = artworkQueue.shift() 92 + 93 + if (next) { 94 + app.ports.makeArtworkTrackUrls.send(next) 95 + } else { 96 + self.postMessage({ 97 + action: "FINISHED_DOWNLOADING_ARTWORK", 98 + data: null 99 + }) 100 + } 101 + } 102 + 103 + 104 + 105 + // ㊙️ 106 107 108 const REJECT = () => Promise.reject("No artwork found") 109 110 111 + function decodeCacheKey(cacheKey: string) { 112 + return Uint8arrays.toString( 113 + Uint8arrays.fromString(cacheKey, "base64"), 114 + "utf8" 115 + ) 116 + } 117 + 118 + 119 + function find(prep: CoverPrepWithUrls) { 120 + return findUsingTags(prep) 121 .then(a => a ? a : findUsingMusicBrainz(prep)) 122 .then(a => a ? a : findUsingLastFm(prep)) 123 .then(a => a ? a : REJECT()) 124 .then(a => a.type.startsWith("image/") ? a : REJECT()) 125 } 126 127 ··· 129 // 1. TAGS 130 131 132 + async function findUsingTags(prep: CoverPrepWithUrls) { 133 return Promise.all( 134 [ 135 transformUrl(prep.trackHeadUrl, app), ··· 155 // 2. MUSIC BRAINZ 156 157 158 + function findUsingMusicBrainz(prep: CoverPrepWithUrls) { 159 + if (!navigator.onLine) return null 160 + 161 const parts = decodeCacheKey(prep.cacheKey).split(" --- ") 162 const artist = parts[ 0 ] 163 const album = parts[ 1 ] || parts[ 0 ] ··· 168 return fetch(`https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`) 169 .then(r => r.json()) 170 .then(r => musicBrainzCover(r.releases)) 171 } 172 173 ··· 193 // 3. LAST FM 194 195 196 + function findUsingLastFm(prep: CoverPrepWithUrls) { 197 + if (!navigator.onLine) return null 198 + 199 + const query = encodeURIComponent( 200 + decodeCacheKey(prep.cacheKey).replace(" --- ", " ") 201 + ) 202 203 return fetch(`https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`) 204 .then(r => r.json()) 205 .then(r => lastFmCover(r.results.albummatches.album)) 206 } 207 208
+13 -11
src/Javascript/Brain/common.ts
··· 13 // 🔱 14 15 16 - export function isLocalHost(url) { 17 return ( 18 url.startsWith("localhost") || 19 url.startsWith("localhost") || ··· 23 } 24 25 26 - export function parseJsonIfNeeded(a) { 27 if (typeof a === "string") return JSON.parse(a) 28 return a 29 } ··· 57 // Cache 58 // ----- 59 60 - export function removeCache(key: string) { 61 return db().removeItem(key) 62 } 63 64 65 - export function fromCache(key: string) { 66 return db().getItem(key) 67 } 68 69 70 - export function toCache(key: string, data: unknown) { 71 return db().setItem(key, data) 72 } 73 ··· 76 // Crypto 77 // ------ 78 79 - export function decryptIfNeeded(data) { 80 if (typeof data !== "string") { 81 return Promise.resolve(data) 82 83 - } else if (data.startsWith("{") || data.startsWith("[")) { 84 return Promise.resolve(data) 85 86 } else if (data.length < 15 && Number.isInteger(parseInt(data, 10))) { ··· 100 101 export async function encryptIfPossible(unencryptedData: string): Promise<string> { 102 return unencryptedData 103 - ? getSecretKey() 104 - .then(secretKey => crypto.encrypt(secretKey, unencryptedData)) 105 - .catch(_ => unencryptedData) 106 : unencryptedData 107 } 108 ··· 110 export { encryptIfPossible as encryptWithSecretKey } 111 112 113 - export function getSecretKey() { 114 return db().getItem(SECRET_KEY_LOCATION) 115 }
··· 13 // 🔱 14 15 16 + export function isLocalHost(url: string) { 17 return ( 18 url.startsWith("localhost") || 19 url.startsWith("localhost") || ··· 23 } 24 25 26 + export function parseJsonIfNeeded(a: unknown) { 27 if (typeof a === "string") return JSON.parse(a) 28 return a 29 } ··· 57 // Cache 58 // ----- 59 60 + export function removeCache(key: string): Promise<void> { 61 return db().removeItem(key) 62 } 63 64 65 + export function fromCache(key: string): Promise<unknown> { 66 return db().getItem(key) 67 } 68 69 70 + export function toCache(key: string, data: unknown): Promise<unknown> { 71 return db().setItem(key, data) 72 } 73 ··· 76 // Crypto 77 // ------ 78 79 + export function decryptIfNeeded(data: unknown): Promise<unknown | null> { 80 if (typeof data !== "string") { 81 return Promise.resolve(data) 82 83 + } else if (typeof data === "string" && (data.startsWith("{") || data.startsWith("["))) { 84 return Promise.resolve(data) 85 86 } else if (data.length < 15 && Number.isInteger(parseInt(data, 10))) { ··· 100 101 export async function encryptIfPossible(unencryptedData: string): Promise<string> { 102 return unencryptedData 103 + ? getSecretKey().then(secretKey => 104 + secretKey 105 + ? crypto.encrypt(secretKey, unencryptedData) 106 + : unencryptedData 107 + ) 108 : unencryptedData 109 } 110 ··· 112 export { encryptIfPossible as encryptWithSecretKey } 113 114 115 + export function getSecretKey(): Promise<CryptoKey | null> { 116 return db().getItem(SECRET_KEY_LOCATION) 117 }
+10
src/Javascript/Brain/elm/types.ts
···
··· 1 + export type App = any // TODO: ElmApp<ElmPorts> 2 + 3 + 4 + export type ElmPorts = { 5 + // ← Elm 6 + // ... 7 + 8 + // → Elm 9 + fromAlien: PortToElm<unknown> 10 + }
+14
src/Javascript/Brain/index.d.ts
···
··· 1 + import type { ElmPorts } from "./elm/types" 2 + 3 + 4 + export { } 5 + 6 + 7 + declare const Elm: { Brain: ElmMain<ElmPorts> } 8 + declare const BUILD_TIMESTAMP: string 9 + 10 + 11 + declare module "elm-taskport" { 12 + const install: () => void 13 + const register: (a: string, b: (arg: any) => any) => void 14 + }
+22 -356
src/Javascript/Brain/index.ts
··· 4 // 5 // This worker is responsible for everything non-UI. 6 7 - import type { } from "../index.d" 8 - 9 - // @ts-ignore 10 - import * as TaskPort from "elm-taskport" 11 - 12 - import * as artwork from "./artwork" 13 - import * as processing from "../processing" 14 - import * as user from "./user" 15 - 16 - import { db } from "../common" 17 - import { fromCache, removeCache, reportError } from "./common" 18 - import { sendData, toCache } from "./common" 19 - import { transformUrl } from "../urls" 20 - 21 - // @ts-ignore 22 - import { Elm } from "brain.elm.js" 23 - 24 - 25 - // 🍱 26 - 27 - 28 - let app 29 - const wire: any = {} 30 - 31 - 32 - TaskPort.install() 33 - 34 - 35 - TaskPort.register("fromCache", fromCache) 36 - TaskPort.register("removeCache", removeCache) 37 - TaskPort.register("toCache", ({ key, value }) => toCache(key, value)) 38 - 39 - 40 - user.setupTaskPorts() 41 - 42 - 43 - 44 - // UI 45 - // == 46 - 47 - wire.ui = () => { 48 - app.ports.toUI.subscribe(event => { 49 - self.postMessage(event) 50 - }) 51 - } 52 - 53 - 54 - self.onmessage = event => { 55 - if (event.data.action) return handleAction(event.data.action, event.data.data) 56 - if (event.data.tag) return app.ports.fromAlien.send(event.data) 57 - } 58 - 59 - 60 - function handleAction(action, data) { 61 - switch (action) { 62 - case "DOWNLOAD_ARTWORK": return downloadArtwork(data) 63 - } 64 - } 65 - 66 - 67 - 68 - // Cache 69 - // ----- 70 - 71 - wire.caching = () => { 72 - app.ports.removeCache.subscribe(event => { 73 - removeCache(event.tag) 74 - .catch(reportError(app, event)) 75 - }) 76 - 77 - app.ports.requestCache.subscribe(event => { 78 - const key = event.data && event.data.file 79 - ? event.tag + "_" + event.data.file 80 - : event.tag 81 - 82 - fromCache(key) 83 - .then(sendData(app, event)) 84 - .catch(reportError(app, event)) 85 - }) 86 - 87 - app.ports.toCache.subscribe(event => { 88 - const key = event.data && event.data.file 89 - ? event.tag + "_" + event.data.file 90 - : event.tag 91 - 92 - toCache(key, event.data.data || event.data) 93 - .catch(reportError(app, event)) 94 - }) 95 - } 96 - 97 - 98 - 99 - // Cache (Artwork) 100 - // --------------- 101 - 102 - let artworkQueue = [] 103 - 104 - 105 - wire.artworkCaching = () => { 106 - app.ports.provideArtworkTrackUrls.subscribe(provideArtworkTrackUrls) 107 - } 108 - 109 - 110 - function downloadArtwork(list) { 111 - const exe = !artworkQueue[0] 112 - artworkQueue = artworkQueue.concat(list) 113 - if (exe) shiftArtworkQueue() 114 - } 115 - 116 - 117 - function shiftArtworkQueue() { 118 - const next = artworkQueue.shift() 119 - 120 - if (next) { 121 - app.ports.makeArtworkTrackUrls.send(next) 122 - } else { 123 - self.postMessage({ 124 - action: "FINISHED_DOWNLOADING_ARTWORK", 125 - data: null 126 - }) 127 - } 128 - } 129 - 130 - 131 - function provideArtworkTrackUrls(prep) { 132 - artwork 133 - .find(prep, app) 134 - .then(blob => { 135 - const url = URL.createObjectURL(blob) 136 - 137 - self.postMessage({ 138 - tag: "GOT_CACHED_COVER", 139 - data: { key: prep.cacheKey, url: url }, 140 - error: null 141 - }) 142 - 143 - return toCache(`coverCache.${prep.cacheKey}`, blob) 144 - }) 145 - .catch(err => { 146 - if (err === "No artwork found") { 147 - // Indicate that we've tried to find artwork, 148 - // so that we don't try to find it each time we launch the app. 149 - return toCache(`coverCache.${prep.cacheKey}`, "TRIED") 150 - 151 - } else { 152 - // Something went wrong 153 - reportError(app, { tag: "REPORT_ERROR" })(err) 154 - 155 - } 156 - }) 157 - .catch(() => { 158 - console.warn("Failed to download artwork for ", prep) 159 - }) 160 - .finally(shiftArtworkQueue) 161 - } 162 - 163 - 164 - 165 - // Cache (Tracks) 166 - // -------------- 167 - 168 - wire.tracksCaching = () => { 169 - app.ports.removeTracksFromCache.subscribe(removeTracksFromCache) 170 - app.ports.storeTracksInCache.subscribe(storeTracksInCache) 171 - } 172 - 173 - 174 - function removeTracksFromCache(trackIds) { 175 - trackIds.reduce( 176 - (acc, id) => acc.then(_ => db("tracks").removeItem(id)), 177 - Promise.resolve() 178 - 179 - ).catch( 180 - _ => reportError 181 - (app, { tag: "REMOVE_TRACKS_FROM_CACHE" }) 182 - ("Failed to remove tracks from cache") 183 - 184 - ) 185 - } 186 - 187 - 188 - function storeTracksInCache(list) { 189 - list.reduce( 190 - (acc, item) => { 191 - return acc 192 - .then(_ => transformUrl(item.url, app)) 193 - .then(fetch) 194 - .then(r => r.blob()) 195 - .then(b => db("tracks").setItem(item.trackId, b)) 196 - }, 197 - Promise.resolve() 198 - 199 - ).then( 200 - _ => self.postMessage({ 201 - tag: "STORE_TRACKS_IN_CACHE", 202 - data: list.map(l => l.trackId), 203 - error: null 204 - }) 205 - 206 - ).catch( 207 - err => { 208 - console.error(err) 209 - self.postMessage({ 210 - tag: "STORE_TRACKS_IN_CACHE", 211 - data: list.map(l => l.trackId), 212 - error: err.message || err 213 - }) 214 - } 215 - 216 - ) 217 - } 218 - 219 - 220 - 221 - // Downloading 222 - // ----------- 223 - 224 - wire.downloading = () => { 225 - app.ports.downloadTracks.subscribe(group => { 226 - self.postMessage({ 227 - action: "DOWNLOAD_TRACKS", 228 - data: group 229 - }) 230 - }) 231 - } 232 - 233 - 234 - 235 - // Search 236 - // ------ 237 - 238 - const search = new Worker( 239 - "../../search.js", 240 - { type: "module" } 241 - ) 242 - 243 - 244 - wire.search = () => { 245 - app.ports.requestSearch.subscribe(requestSearch) 246 - app.ports.updateSearchIndex.subscribe(updateSearchIndex) 247 - } 248 - 249 - 250 - function requestSearch(searchTerm) { 251 - search.postMessage({ 252 - action: "PERFORM_SEARCH", 253 - data: searchTerm 254 - }) 255 - } 256 - 257 - 258 - function updateSearchIndex(tracksJson) { 259 - search.postMessage({ 260 - action: "UPDATE_SEARCH_INDEX", 261 - data: tracksJson 262 - }) 263 - } 264 - 265 - 266 - search.onmessage = event => { 267 - switch (event.data.action) { 268 - case "PERFORM_SEARCH": 269 - app.ports.receiveSearchResults.send(event.data.data) 270 - break 271 - } 272 - } 273 - 274 - 275 - 276 - // Tags 277 - // ---- 278 - 279 - wire.tags = () => { 280 - app.ports.requestTags.subscribe(context => { 281 - processing.processContext(context, app).then(newContext => { 282 - app.ports.receiveTags.send(newContext) 283 - }) 284 - }) 285 - 286 - app.ports.syncTags.subscribe(context => { 287 - processing.processContext(context, app).then(newContext => { 288 - app.ports.replaceTags.send(newContext) 289 - }) 290 - }) 291 - } 292 - 293 294 295 // 🚀 296 297 - 298 - const flags: Record<string, string> = location 299 - .hash 300 - .substr(1) 301 - .split("&") 302 - .reduce((acc, flag) => { 303 - const [k, v] = flag.split("=") 304 - return { ...acc, [k]: v } 305 - }, {}) 306 - 307 - 308 - forwardCompatibility().then(initialise) 309 - 310 - 311 - function initialise() { 312 - app = Elm.Brain.init({ 313 - flags: { 314 - initialUrl: decodeURIComponent(flags.appHref) || "" 315 - } 316 - }) 317 - 318 - user.setupPorts(app) 319 - 320 - wire.ui() 321 - wire.caching() 322 - wire.artworkCaching() 323 - wire.tracksCaching() 324 - wire.downloading() 325 - wire.search() 326 - wire.tags() 327 - 328 - self.postMessage({ action: "READY" }) 329 - } 330 - 331 - 332 - async function forwardCompatibility() { 333 - // TODO: Future, check version to migrate 334 - if (await fromCache("MIGRATED")) return 335 336 - await moveOldDbValue({ oldName: "AUTH_SECRET_KEY", newName: "SECRET_KEY" }) 337 - await moveOldDbValue({ oldName: "AUTH_ENCLOSED_DATA", newName: "ENCLOSED_DATA" }) 338 339 - const method = await fromCache("AUTH_METHOD") 340 341 - if (method === "LOCAL") { 342 - await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_favourites.json", newName: "SYNC_LOCAL_favourites.json", parseJSON: true }) 343 - await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_playlists.json", newName: "SYNC_LOCAL_playlists.json", parseJSON: true }) 344 - await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_progress.json", newName: "SYNC_LOCAL_progress.json", parseJSON: true }) 345 - await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_settings.json", newName: "SYNC_LOCAL_settings.json", parseJSON: true }) 346 - await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_sources.json", newName: "SYNC_LOCAL_sources.json", parseJSON: true }) 347 - await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_tracks.json", newName: "SYNC_LOCAL_tracks.json", parseJSON: true }) 348 349 - await removeCache("AUTH_METHOD") 350 351 - } else if (method) { 352 - await toCache("SYNC_METHOD", method) 353 - await removeCache("AUTH_METHOD") 354 355 - } 356 - 357 - await toCache("MIGRATED", "3.3.0") 358 - } 359 - 360 361 - async function moveOldDbValue( 362 - { oldName, newName, parseJSON }: { 363 - oldName: string 364 - newName: string 365 - parseJSON?: boolean 366 - } 367 - ) { 368 - const value = await fromCache(oldName) 369 - if (value && typeof value === "string") { 370 - await toCache(newName, parseJSON ? JSON.parse(value) : value) 371 - await removeCache(oldName) 372 - } 373 - }
··· 4 // 5 // This worker is responsible for everything non-UI. 6 7 + import * as Application from "./application" 8 + import * as Artwork from "./artwork" 9 + import * as Processing from "./processing" 10 + import * as Search from "./search" 11 + import * as User from "./user" 12 + import * as TaskPorts from "./task-ports" 13 + import * as Tracks from "./tracks" 14 + import * as UI from "./ui" 15 16 17 // 🚀 18 19 + TaskPorts.register() 20 + User.TaskPorts.register() 21 22 + const app = Application.load() 23 + const brain = self as unknown as Worker 24 25 + // 🖼️ 26 27 + UI.link(brain, app) 28 29 + // ⚡ 30 + Artwork.init(app) 31 + Processing.init(app) 32 + Search.init(app) 33 + Tracks.init(app) 34 35 + User.Ports.register(app) 36 37 + // 🛫 38 39 + brain.postMessage({ action: "READY" })
+54
src/Javascript/Brain/search.ts
···
··· 1 + import type { App } from "./elm/types" 2 + 3 + 4 + // 🏔️ 5 + 6 + 7 + let app: App 8 + 9 + 10 + 11 + // 🚀 12 + 13 + 14 + export function init(a: App) { 15 + app = a 16 + 17 + app.ports.requestSearch.subscribe(requestSearch) 18 + app.ports.updateSearchIndex.subscribe(updateSearchIndex) 19 + } 20 + 21 + 22 + const search = new Worker( 23 + "../../search.js", 24 + { type: "module" } 25 + ) 26 + 27 + 28 + search.onmessage = event => { 29 + switch (event.data.action) { 30 + case "PERFORM_SEARCH": 31 + app.ports.receiveSearchResults.send(event.data.data) 32 + break 33 + } 34 + } 35 + 36 + 37 + 38 + // PORTS 39 + 40 + 41 + function requestSearch(searchTerm: string) { 42 + search.postMessage({ 43 + action: "PERFORM_SEARCH", 44 + data: searchTerm 45 + }) 46 + } 47 + 48 + 49 + function updateSearchIndex(tracksJson: string) { 50 + search.postMessage({ 51 + action: "UPDATE_SEARCH_INDEX", 52 + data: tracksJson 53 + }) 54 + }
+13
src/Javascript/Brain/task-ports.ts
···
··· 1 + // @ts-ignore 2 + import * as TaskPort from "elm-taskport" 3 + 4 + import { fromCache, removeCache, toCache } from "./common" 5 + 6 + 7 + export function register() { 8 + TaskPort.install() 9 + 10 + TaskPort.register("fromCache", fromCache) 11 + TaskPort.register("removeCache", removeCache) 12 + TaskPort.register("toCache", ({ key, value }) => toCache(key, value)) 13 + }
+81
src/Javascript/Brain/tracks.ts
···
··· 1 + import type { App } from "./elm/types" 2 + import { db } from "../common" 3 + import { reportError } from "./common" 4 + import { transformUrl } from "../urls" 5 + 6 + 7 + // 🏔️ 8 + 9 + 10 + let app: App 11 + 12 + 13 + 14 + // 🚀 15 + 16 + 17 + export function init(a: App) { 18 + app = a 19 + 20 + app.ports.downloadTracks.subscribe(downloadTracks) 21 + app.ports.removeTracksFromCache.subscribe(removeTracksFromCache) 22 + app.ports.storeTracksInCache.subscribe(storeTracksInCache) 23 + } 24 + 25 + 26 + 27 + // PORTS 28 + 29 + 30 + function downloadTracks(group) { 31 + self.postMessage({ 32 + action: "DOWNLOAD_TRACKS", 33 + data: group 34 + }) 35 + } 36 + 37 + 38 + function removeTracksFromCache(trackIds) { 39 + trackIds.reduce( 40 + (acc, id) => acc.then(_ => db("tracks").removeItem(id)), 41 + Promise.resolve() 42 + 43 + ).catch( 44 + _ => reportError 45 + (app, { tag: "REMOVE_TRACKS_FROM_CACHE" }) 46 + ("Failed to remove tracks from cache") 47 + 48 + ) 49 + } 50 + 51 + 52 + function storeTracksInCache(list) { 53 + list.reduce( 54 + (acc, item) => { 55 + return acc 56 + .then(_ => transformUrl(item.url, app)) 57 + .then(fetch) 58 + .then(r => r.blob()) 59 + .then(b => db("tracks").setItem(item.trackId, b)) 60 + }, 61 + Promise.resolve() 62 + 63 + ).then( 64 + _ => self.postMessage({ 65 + tag: "STORE_TRACKS_IN_CACHE", 66 + data: list.map(l => l.trackId), 67 + error: null 68 + }) 69 + 70 + ).catch( 71 + err => { 72 + console.error(err) 73 + self.postMessage({ 74 + tag: "STORE_TRACKS_IN_CACHE", 75 + data: list.map(l => l.trackId), 76 + error: err.message || err 77 + }) 78 + } 79 + 80 + ) 81 + }
+21
src/Javascript/Brain/ui.ts
···
··· 1 + import type { App } from "./elm/types" 2 + import * as Artwork from "./artwork" 3 + 4 + 5 + export function link(worker: Worker, app: App) { 6 + app.ports.toUI.subscribe(event => { 7 + worker.postMessage(event) 8 + }) 9 + 10 + worker.onmessage = event => { 11 + if (event.data.action) return handleAction(event.data.action, event.data.data) 12 + if (event.data.tag) return app.ports.fromAlien.send(event.data) 13 + } 14 + 15 + 16 + function handleAction(action: string, data: unknown) { 17 + switch (action) { 18 + case "DOWNLOAD_ARTWORK": return Artwork.download(data) 19 + } 20 + } 21 + }
+9 -3
src/Javascript/Brain/user.ts
··· 7 8 // @ts-ignore 9 import * as TaskPort from "elm-taskport" 10 - import { APP_INFO, ODD_CONFIG } from "../common" 11 12 import * as crypto from "../crypto" 13 14 import { decryptIfNeeded, encryptIfPossible, SECRET_KEY_LOCATION } from "./common" 15 import { parseJsonIfNeeded, removeCache, toCache } from "./common" 16 ··· 299 // EXPORT 300 // ====== 301 302 - export function setupPorts(app) { 303 Object.keys(ports).forEach(name => { 304 const fn = ports[ name ](app) 305 app.ports[ name ].subscribe(fn) 306 }) 307 } 308 309 - export function setupTaskPorts() { 310 Object.keys(taskPorts).forEach(name => { 311 const fn = taskPorts[ name ] 312 TaskPort.register(name, fn) 313 }) 314 }
··· 7 8 // @ts-ignore 9 import * as TaskPort from "elm-taskport" 10 + 11 + import type { App } from "./elm/types" 12 13 import * as crypto from "../crypto" 14 15 + import { APP_INFO, ODD_CONFIG } from "../common" 16 import { decryptIfNeeded, encryptIfPossible, SECRET_KEY_LOCATION } from "./common" 17 import { parseJsonIfNeeded, removeCache, toCache } from "./common" 18 ··· 301 // EXPORT 302 // ====== 303 304 + function registerPorts(app: App) { 305 Object.keys(ports).forEach(name => { 306 const fn = ports[ name ](app) 307 app.ports[ name ].subscribe(fn) 308 }) 309 } 310 311 + function registerTaskPorts() { 312 Object.keys(taskPorts).forEach(name => { 313 const fn = taskPorts[ name ] 314 TaskPort.register(name, fn) 315 }) 316 } 317 + 318 + 319 + export const TaskPorts = { register: registerTaskPorts } 320 + export const Ports = { register: registerPorts }
+106
src/Javascript/UI/application.ts
···
··· 1 + import "./index.d" 2 + import type { App } from "./elm/types" 3 + import { version } from "../../../package.json" 4 + 5 + 6 + // 🏔️ 7 + 8 + 9 + let app: App 10 + let channel: BroadcastChannel 11 + 12 + 13 + 14 + // 🚀 15 + 16 + 17 + export const load = ({ isNativeWrapper, reg }: { isNativeWrapper: boolean, reg: ServiceWorkerRegistration }) => Elm.UI.init({ 18 + node: document.getElementById("elm") || undefined, 19 + flags: { 20 + buildTimestamp: BUILD_TIMESTAMP, 21 + darkMode: preferredColorScheme().matches, 22 + initialTime: Date.now(), 23 + isInstallingServiceWorker: !!reg.installing, 24 + isOnline: navigator.onLine, 25 + isTauri: isNativeWrapper, 26 + version, 27 + viewport: { 28 + height: window.innerHeight, 29 + width: window.innerWidth 30 + } 31 + } 32 + }) 33 + 34 + 35 + export function init(a: App, c: BroadcastChannel) { 36 + app = a 37 + channel = c 38 + 39 + app.ports.downloadJsonUsingTauri.subscribe(downloadJsonUsingTauri) 40 + app.ports.openUrlOnNewPage.subscribe(openUrlOnNewPage) 41 + app.ports.reloadApp.subscribe(reloadApp) 42 + } 43 + 44 + 45 + 46 + // 🌗 47 + 48 + 49 + function preferredColorScheme() { 50 + const m = 51 + window.matchMedia && 52 + window.matchMedia("(prefers-color-scheme: dark)") 53 + 54 + m?.addEventListener("change", e => { 55 + app.ports.preferredColorSchemaChanged.send({ dark: e.matches }) 56 + }) 57 + 58 + return m 59 + } 60 + 61 + 62 + 63 + // PORTS 64 + 65 + 66 + async function downloadJsonUsingTauri( 67 + { filename, json }: { filename: string, json: string } 68 + ) { 69 + const { save } = await import("@tauri-apps/plugin-dialog") 70 + const { writeTextFile } = await import("@tauri-apps/plugin-fs") 71 + const { BaseDirectory } = await import("@tauri-apps/api/path") 72 + 73 + const filePath = await save({ defaultPath: filename }) 74 + await writeTextFile(filePath || filename, json, { baseDir: BaseDirectory.Download }) 75 + } 76 + 77 + 78 + function openUrlOnNewPage(url: string) { 79 + if (globalThis.__TAURI__) { 80 + globalThis.__TAURI__.shell.open( 81 + url.includes("://") ? url : `${location.origin}/${url.replace(/^\.\//, "")}` 82 + ) 83 + 84 + } else { 85 + window.open(url, "_blank") 86 + 87 + } 88 + } 89 + 90 + 91 + function reloadApp() { 92 + const timeout = setTimeout(async () => { 93 + const reg = await navigator.serviceWorker.getRegistration() 94 + if (reg?.waiting) reg.waiting.postMessage("skipWaiting") 95 + window.location.reload() 96 + }, 250) 97 + 98 + channel.addEventListener("message", event => { 99 + if (event.data === "PONG") { 100 + clearTimeout(timeout) 101 + alert("⚠️ You can only update the app when you have no more than one instance open.") 102 + } 103 + }) 104 + 105 + channel.postMessage("PING") 106 + }
+111
src/Javascript/UI/artwork.ts
···
··· 1 + import { debounce } from "throttle-debounce" 2 + 3 + import type { App } from "./elm/types" 4 + import { type CoverPrep, db } from "../common" 5 + 6 + 7 + 8 + // 🏔️ 9 + 10 + 11 + let app: App 12 + let brain: Worker 13 + 14 + 15 + 16 + // 🚀 17 + 18 + 19 + export function init(a: App, b: Worker) { 20 + app = a 21 + brain = b 22 + 23 + app.ports.loadAlbumCovers.subscribe( 24 + debounce(500, loadAlbumCoversFromDom) 25 + ) 26 + 27 + db().keys().then(cachedCovers) 28 + } 29 + 30 + 31 + 32 + // 🛠️ 33 + 34 + 35 + export function albumCover(coverKey: string): Promise<Blob | null> { 36 + return db().getItem(`coverCache.${coverKey}`) 37 + } 38 + 39 + 40 + async function loadAlbumCoversFromDom({ coverView, list }: { coverView: boolean, list: boolean }): Promise<void> { 41 + let nodes: HTMLElement[] = [] 42 + 43 + if (list) nodes = nodes.concat(Array.from( 44 + document.querySelectorAll("#diffuse__track-covers [data-key]") 45 + )) 46 + 47 + if (coverView) nodes = nodes.concat(Array.from( 48 + document.querySelectorAll("#diffuse__track-covers + div [data-key]") 49 + )) 50 + 51 + if (!nodes.length) return; 52 + 53 + const coverPrepList = nodes.reduce((acc: CoverPrep[], node: HTMLElement) => { 54 + const a = { 55 + cacheKey: node.getAttribute("data-key"), 56 + trackFilename: node.getAttribute("data-filename"), 57 + trackPath: node.getAttribute("data-path"), 58 + trackSourceId: node.getAttribute("data-source-id"), 59 + variousArtists: node.getAttribute("data-various-artists") 60 + } 61 + 62 + if (a.cacheKey && a.trackFilename && a.trackPath && a.trackSourceId && a.variousArtists) { 63 + return [...acc, a as CoverPrep] 64 + } else { 65 + return acc 66 + } 67 + }, [] as CoverPrep[]) 68 + 69 + return loadAlbumCovers(coverPrepList) 70 + } 71 + 72 + 73 + export async function loadAlbumCovers(coverPrepList: CoverPrep[]): Promise<void> { 74 + const withoutEarlierAttempts = await coverPrepList.reduce(async ( 75 + acc: Promise<CoverPrep[]>, 76 + prep: CoverPrep 77 + ): Promise<CoverPrep[]> => { 78 + const arr = await acc 79 + const a = await albumCover(prep.cacheKey) 80 + if (!a) return [...arr, prep] 81 + return arr 82 + }, Promise.resolve([])) 83 + 84 + brain.postMessage({ 85 + action: "DOWNLOAD_ARTWORK", 86 + data: withoutEarlierAttempts 87 + }) 88 + } 89 + 90 + 91 + // Send a dictionary of the cached covers to the app. 92 + async function cachedCovers(keys: string[]) { 93 + const cacheKeys = keys.filter( 94 + k => k.startsWith("coverCache.") 95 + ) 96 + 97 + const cache = await cacheKeys.reduce(async (acc, key) => { 98 + const c = await acc 99 + const blob = await db().getItem(key) 100 + const cacheKey = key.slice(11) 101 + 102 + if (blob && typeof blob !== "string" && blob instanceof Blob) { 103 + c[cacheKey] = URL.createObjectURL(blob) 104 + } 105 + 106 + return c 107 + }, Promise.resolve({})) 108 + 109 + app.ports.insertCoverCache.send(cache) 110 + setTimeout(() => loadAlbumCoversFromDom({ list: true, coverView: true }), 500) 111 + }
+513
src/Javascript/UI/audio.ts
···
··· 1 + // 2 + // Audio engine 3 + // ♪(´ε` ) 4 + 5 + import type { App } from "./elm/types" 6 + 7 + import Timer from "timer.js" 8 + import { debounce } from "throttle-debounce" 9 + import { CoverPrep, db, mimeType } from "../common" 10 + import { albumCover, loadAlbumCovers } from "./artwork" 11 + 12 + 13 + // 🏔️ 14 + 15 + 16 + const silentMp3File = 17 + "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV" 18 + 19 + 20 + let app: App 21 + let container: Element | null = null 22 + let scrobbleTimer: Timer | null = null 23 + 24 + 25 + 26 + // 🚀 27 + 28 + 29 + export function init(a: App) { 30 + app = a 31 + 32 + app.ports.adjustEqualizerSetting.subscribe(adjustEqualizerSetting) 33 + app.ports.pause.subscribe(pause) 34 + app.ports.pauseScrobbleTimer.subscribe(pauseScrobbleTimer) 35 + app.ports.play.subscribe(play) 36 + app.ports.reloadAudioNodeIfNeeded.subscribe(reloadAudioNodeIfNeeded) 37 + app.ports.renderAudioElements.subscribe(renderAudioElements) 38 + app.ports.resetScrobbleTimer.subscribe(resetScrobbleTimer) 39 + app.ports.seek.subscribe(seek) 40 + app.ports.setMediaSessionArtwork.subscribe(setMediaSessionArtwork) 41 + app.ports.setMediaSessionMetadata.subscribe(setMediaSessionMetadata) 42 + app.ports.setMediaSessionPlaybackState.subscribe(setMediaSessionPlaybackState) 43 + app.ports.setMediaSessionPositionState.subscribe(setMediaSessionPositionState) 44 + app.ports.startScrobbleTimer.subscribe(startScrobbleTimer) 45 + } 46 + 47 + 48 + 49 + // 🌳 50 + 51 + 52 + /** 53 + * Javascript representation of `Queue.EngineItem` in Elm. 54 + */ 55 + type EngineItem = { 56 + isCached: boolean 57 + isPreload: boolean 58 + progress: number | null 59 + sourceId: string 60 + trackId: string 61 + trackTags: TrackTags 62 + trackPath: string 63 + url: string 64 + } 65 + 66 + 67 + /**/ 68 + type TrackTags = { 69 + disc: number 70 + nr: number 71 + 72 + // Main 73 + album: string | null 74 + artist: string | null 75 + title: string 76 + 77 + // Extra 78 + genre: string | null 79 + picture: string | null 80 + year: number | null 81 + } 82 + 83 + 84 + 85 + // Ports 86 + // ----- 87 + 88 + 89 + function adjustEqualizerSetting({ knob, value }: { knob: string, value: number }): void { 90 + switch (knob) { 91 + case "VOLUME": 92 + Array.from( 93 + document.body.querySelectorAll('#audio-elements audio[data-is-preload="false"]'), 94 + ).forEach((audio) => ((audio as HTMLAudioElement).volume = value)) 95 + break 96 + } 97 + } 98 + 99 + 100 + function pause({ trackId }: { trackId: string }) { 101 + withAudioNode(trackId, (audio) => audio.pause()) 102 + } 103 + 104 + 105 + function pauseScrobbleTimer() { 106 + if (this.scrobbleTimer) this.scrobbleTimer.pause() 107 + } 108 + 109 + 110 + function play({ trackId, volume }: { trackId: string, volume: number }) { 111 + withAudioNode(trackId, (audio) => { 112 + audio.volume = volume 113 + audio.muted = false 114 + 115 + if (audio.readyState === 0) audio.load() 116 + 117 + const promise = audio.play() || Promise.resolve() 118 + 119 + promise.catch((e) => { 120 + const err = "Couldn't play audio automatically. Please resume playback manually." 121 + console.error(err, e) 122 + if (app) app.ports.fromAlien.send({ tag: "", data: null, error: err }) 123 + }) 124 + }) 125 + } 126 + 127 + 128 + async function reloadAudioNodeIfNeeded(args: { play: boolean, progress: number | null, trackId: string }) { 129 + withAudioNode(args.trackId, (audio) => { 130 + if (audio.readyState === 0 || audio.error?.code === 2) { 131 + audio.load() 132 + 133 + if (args.progress) { 134 + audio.setAttribute("data-initial-progress", JSON.stringify(args.progress)) 135 + } 136 + 137 + if (args.play) { 138 + play({ trackId: args.trackId, volume: audio.volume }) 139 + } 140 + } 141 + }) 142 + } 143 + 144 + 145 + async function renderAudioElements(args: { 146 + items: Array<EngineItem> 147 + play: string | null 148 + volume: number 149 + }) { 150 + await render(args.items) 151 + if (args.play) play({ trackId: args.play, volume: args.volume }) 152 + } 153 + 154 + 155 + function resetScrobbleTimer({ duration, trackId }: { duration: number, trackId: string }) { 156 + const timestamp = Math.round(Date.now() / 1000) 157 + const scrobbleTimeoutDuration = Math.min(240 + 0.5, duration / 1.95) 158 + 159 + if (this.scrobbleTimer) this.scrobbleTimer.stop() 160 + 161 + scrobbleTimer = new Timer({ 162 + onend: () => 163 + app.ports.scrobble.send({ 164 + duration: Math.round(duration), 165 + timestamp, 166 + trackId, 167 + }), 168 + }) 169 + 170 + scrobbleTimer.start(scrobbleTimeoutDuration) 171 + } 172 + 173 + 174 + function seek({ percentage, trackId }: { percentage: number, trackId: string }) { 175 + withAudioNode(trackId, (audio) => { 176 + if (!isNaN(audio.duration)) { 177 + audio.currentTime = audio.duration * percentage 178 + } 179 + }) 180 + } 181 + 182 + 183 + async function setMediaSessionArtwork({ blobUrl, imageType }: { blobUrl: string, imageType: string }) { 184 + const artwork: MediaImage[] = [{ 185 + src: blobUrl, 186 + type: imageType 187 + }] 188 + 189 + navigator.mediaSession.metadata = new MediaMetadata({ 190 + title: navigator.mediaSession.metadata?.title, 191 + artist: navigator.mediaSession.metadata?.artist, 192 + album: navigator.mediaSession.metadata?.album, 193 + artwork: artwork, 194 + }) 195 + } 196 + 197 + 198 + async function setMediaSessionMetadata({ 199 + album, 200 + artist, 201 + title, 202 + 203 + coverPrep, 204 + }: { 205 + album: string | null 206 + artist: string | null 207 + title: string 208 + 209 + coverPrep: CoverPrep | null 210 + }) { 211 + let artwork: MediaImage[] = [] 212 + 213 + if (coverPrep) { 214 + const blob = await albumCover(coverPrep.cacheKey) 215 + 216 + artwork = blob 217 + ? [{ 218 + src: URL.createObjectURL(blob), 219 + type: blob.type 220 + }] 221 + : [] 222 + 223 + if (!blob) { 224 + // Download artwork and set it later 225 + loadAlbumCovers([coverPrep]) 226 + } 227 + } 228 + 229 + navigator.mediaSession.metadata = new MediaMetadata({ 230 + title, 231 + artist: artist || undefined, 232 + album: album || undefined, 233 + artwork: artwork, 234 + }) 235 + } 236 + 237 + 238 + function setMediaSessionPlaybackState(state: MediaSessionPlaybackState) { 239 + if (navigator.mediaSession) navigator.mediaSession.playbackState = state 240 + } 241 + 242 + 243 + function setMediaSessionPositionState({ 244 + currentTime, 245 + duration, 246 + }: { 247 + currentTime: number 248 + duration: number 249 + }) { 250 + try { 251 + navigator?.mediaSession?.setPositionState({ 252 + duration: duration, 253 + position: currentTime, 254 + }) 255 + } catch (_err) { 256 + // 257 + } 258 + } 259 + 260 + 261 + function startScrobbleTimer() { 262 + if (this.scrobbleTimer) this.scrobbleTimer.start() 263 + } 264 + 265 + 266 + 267 + // Media Keys 268 + // ---------- 269 + 270 + 271 + if ("mediaSession" in navigator) { 272 + navigator.mediaSession.setActionHandler("play", () => { 273 + app.ports.requestPlay.send(null) 274 + }) 275 + 276 + navigator.mediaSession.setActionHandler("pause", () => { 277 + app.ports.requestPause.send(null) 278 + }) 279 + 280 + navigator.mediaSession.setActionHandler("previoustrack", () => { 281 + app.ports.requestPrevious.send(null) 282 + }) 283 + 284 + navigator.mediaSession.setActionHandler("nexttrack", () => { 285 + app.ports.requestNext.send(null) 286 + }) 287 + 288 + navigator.mediaSession.setActionHandler("seekbackward", (event) => { 289 + const seekOffset = event.seekOffset || 10 290 + withActiveAudioNode( 291 + (audio) => (audio.currentTime = Math.max(audio.currentTime - seekOffset, 0)), 292 + ) 293 + }) 294 + 295 + navigator.mediaSession.setActionHandler("seekforward", (event) => { 296 + const seekOffset = event.seekOffset || 10 297 + withActiveAudioNode( 298 + (audio) => (audio.currentTime = Math.min(audio.currentTime + seekOffset, audio.duration)), 299 + ) 300 + }) 301 + 302 + navigator.mediaSession.setActionHandler("seekto", (event) => { 303 + withActiveAudioNode((audio) => (audio.currentTime = event.seekTime || audio.currentTime)) 304 + }) 305 + } 306 + 307 + 308 + 309 + // 🖼️ 310 + 311 + 312 + async function render(items: Array<EngineItem>) { 313 + if (!container) { 314 + container = document.createElement("div") 315 + container.id = "audio-elements" 316 + container.className = "absolute h-0 invisible left-0 pointer-events-none top-0 w-0" 317 + 318 + document.body.appendChild(container) 319 + } 320 + 321 + const trackIds = items.map((e) => e.trackId) 322 + const existingNodes = {} 323 + 324 + // Manage existing nodes 325 + Array.from(container.querySelectorAll("audio")).map((node: HTMLAudioElement) => { 326 + if (trackIds.includes(node.id)) { 327 + existingNodes[node.id] = node 328 + } else { 329 + node.src = silentMp3File 330 + container?.removeChild(node) 331 + } 332 + }) 333 + 334 + // Adjust existing and add new 335 + await items.reduce(async (acc: Promise<void>, item: EngineItem) => { 336 + await acc 337 + 338 + if (existingNodes[item.trackId]) { 339 + existingNodes[item.trackId].setAttribute( 340 + "data-is-preload", 341 + item.isPreload ? "true" : "false", 342 + ) 343 + } else { 344 + await createElement(item) 345 + } 346 + }, Promise.resolve()) 347 + } 348 + 349 + 350 + export async function createElement(item: EngineItem) { 351 + const url = item.isCached 352 + ? await db("tracks") 353 + .getItem(item.trackId) 354 + .then((blob) => (blob ? URL.createObjectURL(blob as Blob) : item.url)) 355 + : item.url 356 + 357 + // Mime + SRC 358 + const fileName = item.trackPath.split("/").reverse()[0] 359 + const fileExtMatch = fileName.match(/\.(\w+)$/) 360 + const fileExt = fileExtMatch && fileExtMatch[1] 361 + const mime = fileExt ? mimeType(fileExt) : null 362 + 363 + const source = document.createElement("source") 364 + if (mime) source.setAttribute("type", mime) 365 + source.setAttribute("src", url) 366 + 367 + // Audio node 368 + const audio = new Audio() 369 + audio.setAttribute("id", item.trackId) 370 + audio.setAttribute("crossorigin", "anonymous") 371 + audio.setAttribute("data-initial-progress", JSON.stringify(item.progress)) 372 + audio.setAttribute("data-is-preload", item.isPreload ? "true" : "false") 373 + audio.setAttribute("muted", "true") 374 + audio.setAttribute("preload", "auto") 375 + audio.appendChild(source) 376 + 377 + audio.addEventListener("canplay", canplayEvent) 378 + audio.addEventListener("durationchange", durationchangeEvent) 379 + audio.addEventListener("ended", endedEvent) 380 + audio.addEventListener("error", errorEvent) 381 + audio.addEventListener("pause", pauseEvent) 382 + audio.addEventListener("play", playEvent) 383 + audio.addEventListener("suspend", suspendEvent) 384 + audio.addEventListener("timeupdate", timeupdateEvent) 385 + audio.addEventListener("waiting", debounce(1500, waitingEvent)) 386 + 387 + container?.appendChild(audio) 388 + } 389 + 390 + 391 + 392 + // 🖼 ░░ EVENTS 393 + 394 + 395 + function canplayEvent(event: Event) { 396 + const target = event.target as HTMLAudioElement 397 + 398 + if (target.hasAttribute("data-initial-progress") && target.duration && !isNaN(target.duration)) { 399 + const progress = JSON.parse(target.getAttribute("data-initial-progress") as string) 400 + target.currentTime = target.duration * progress 401 + target.removeAttribute("data-initial-progress") 402 + } 403 + 404 + finishedLoading(event) 405 + } 406 + 407 + 408 + function durationchangeEvent(event: Event) { 409 + const target = event.target as HTMLAudioElement 410 + 411 + if (!isNaN(target.duration)) { 412 + app.ports.audioDurationChange.send({ 413 + trackId: target.id, 414 + duration: target.duration, 415 + }) 416 + } 417 + } 418 + 419 + function endedEvent(event: Event) { 420 + app.ports.audioEnded.send({ 421 + trackId: (event.target as HTMLAudioElement).id, 422 + }) 423 + } 424 + 425 + function errorEvent(event: Event) { 426 + const audio = event.target as HTMLAudioElement 427 + 428 + app.ports.audioError.send({ 429 + trackId: audio.id, 430 + code: audio.error?.code || 0 431 + }) 432 + } 433 + 434 + 435 + function pauseEvent(event: Event) { 436 + app.ports.audioPlaybackStateChanged.send({ 437 + trackId: (event.target as HTMLAudioElement).id, 438 + isPlaying: false, 439 + }) 440 + } 441 + 442 + 443 + function playEvent(event: Event) { 444 + const audio = event.target as HTMLAudioElement 445 + 446 + app.ports.audioPlaybackStateChanged.send({ 447 + trackId: audio.id, 448 + isPlaying: true, 449 + }) 450 + 451 + // In case audio was preloaded: 452 + if (audio.readyState === 4) finishedLoading(event) 453 + } 454 + 455 + 456 + function suspendEvent(event: Event) { 457 + finishedLoading(event) 458 + } 459 + 460 + 461 + function timeupdateEvent(event: Event) { 462 + const target = event.target as HTMLAudioElement 463 + 464 + app.ports.audioTimeUpdated.send({ 465 + trackId: target.id, 466 + currentTime: target.currentTime, 467 + duration: isNaN(target.duration) ? null : target.duration, 468 + }) 469 + } 470 + 471 + 472 + function waitingEvent(event: Event) { 473 + initiateLoading(event) 474 + } 475 + 476 + 477 + 478 + // 🛠️ 479 + 480 + 481 + function finishedLoading(event: Event) { 482 + app.ports.audioHasLoaded.send({ 483 + trackId: (event.target as HTMLAudioElement).id, 484 + }) 485 + } 486 + 487 + 488 + function initiateLoading(event: Event) { 489 + const audio = event.target as HTMLAudioElement 490 + 491 + if (audio.readyState < 4) 492 + app.ports.audioIsLoading.send({ 493 + trackId: audio.id, 494 + }) 495 + } 496 + 497 + 498 + function withActiveAudioNode(fn: (node: HTMLAudioElement) => void): void { 499 + const nonPreloadNodes: HTMLAudioElement[] = Array.from( 500 + document.body.querySelectorAll(`#audio-elements audio[data-is-preload="false"]`), 501 + ) 502 + const playingNodes = nonPreloadNodes.filter((n) => n.paused === false) 503 + const node = playingNodes.length ? playingNodes[0] : nonPreloadNodes[0] 504 + if (node) fn(node) 505 + } 506 + 507 + 508 + function withAudioNode(trackId: string, fn: (node: HTMLAudioElement) => void): void { 509 + const node = document.body.querySelector( 510 + `#audio-elements audio[id="${trackId}"][data-is-preload="false"]`, 511 + ) 512 + if (node) fn(node as HTMLAudioElement) 513 + }
+46
src/Javascript/UI/backdrop.ts
···
··· 1 + // 🚀 2 + 3 + 4 + export function init(app) { 5 + app.ports.pickAverageBackgroundColor.subscribe((src: string) => { 6 + const avgColor = pickAverageBackgroundColor(src) 7 + if (avgColor) app.ports.setAverageBackgroundColor.send(avgColor) 8 + }) 9 + } 10 + 11 + 12 + 13 + // 🛠️ 14 + 15 + 16 + function averageColorOfImage(img: HTMLImageElement): { r: number, g: number, b: number } | null { 17 + const canvas = document.createElement("canvas") 18 + const ctx = canvas.getContext("2d") 19 + canvas.width = img.naturalWidth 20 + canvas.height = img.naturalHeight 21 + 22 + if (!ctx) return null 23 + 24 + ctx.drawImage(img, 0, 0) 25 + 26 + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) 27 + const color = { r: 0, g: 0, b: 0 } 28 + 29 + for (let i = 0, l = imageData.data.length; i < l; i += 4) { 30 + color.r += imageData.data[i] 31 + color.g += imageData.data[i + 1] 32 + color.b += imageData.data[i + 2] 33 + } 34 + 35 + color.r = Math.floor(color.r / (imageData.data.length / 4)) 36 + color.g = Math.floor(color.g / (imageData.data.length / 4)) 37 + color.b = Math.floor(color.b / (imageData.data.length / 4)) 38 + 39 + return color 40 + } 41 + 42 + 43 + function pickAverageBackgroundColor(src: string): { r: number, g: number, b: number } | null { 44 + const img = document.querySelector(`img[src$="${src}"]`) 45 + return img ? averageColorOfImage(img as HTMLImageElement) : null 46 + }
+39
src/Javascript/UI/brain.ts
···
··· 1 + import type { App } from "./elm/types" 2 + import * as Tracks from "./tracks" 3 + 4 + 5 + export async function load(): Promise<Worker> { 6 + const brain = new Worker( 7 + "./js/brain/index.js#appHref=" + encodeURIComponent(window.location.href), 8 + { type: "module" } 9 + ) 10 + 11 + await new Promise((resolve, reject) => { 12 + brain.onmessage = event => { 13 + if (event.data.action === "READY") resolve(null) 14 + } 15 + 16 + brain.addEventListener("error", () => { 17 + reject("<strong>Failed to load web worker.</strong><br />If you're using Firefox, you might need to upgrade your browser (version 113 and up) and set `dom.workers.modules.enabled` to `true` in `about:config`") 18 + }) 19 + }) 20 + 21 + // Fin 22 + return brain 23 + } 24 + 25 + 26 + export function link({ app, brain }: { app: App, brain: Worker }) { 27 + function handleAction(action, data, _ports) { 28 + switch (action) { 29 + case "DOWNLOAD_TRACKS": return Tracks.download(data) 30 + } 31 + } 32 + 33 + brain.onmessage = event => { 34 + if (event.data.action) return handleAction(event.data.action, event.data.data, event.ports) 35 + if (event.data.tag) app.ports.fromAlien.send(event.data) 36 + } 37 + 38 + app.ports.toBrain.subscribe(a => brain.postMessage(a)) 39 + }
+11
src/Javascript/UI/broadcast.ts
···
··· 1 + export function channel() { 2 + const bc = new BroadcastChannel(`diffuse-${location.hostname}`) 3 + 4 + bc.addEventListener("message", event => { 5 + switch (event.data) { 6 + case "PING": return bc.postMessage("PONG") 7 + } 8 + }) 9 + 10 + return bc 11 + }
+11
src/Javascript/UI/elm/types.ts
···
··· 1 + export type App = any // TODO: ElmApp<ElmPorts> 2 + 3 + 4 + export type ElmPorts = { 5 + // ← Elm 6 + openUrlOnNewPage: PortFromElm<string> 7 + 8 + // → Elm 9 + fromAlien: PortToElm<unknown> 10 + indicateTouchDevice: PortToElm<void> 11 + }
+20
src/Javascript/UI/errors.ts
···
··· 1 + export function failure(text: string): void { 2 + const note = document.createElement("div") 3 + 4 + note.className = "flex flex-col font-body items-center h-screen italic justify-center leading-relaxed px-4 text-center text-base text-white" 5 + note.innerHTML = ` 6 + <a class="block logo mb-5" href="../"> 7 + <img src="../images/diffuse-light.svg" /> 8 + </a> 9 + 10 + <p class="max-w-sm opacity-60"> 11 + ${text} 12 + </p> 13 + ` 14 + 15 + document.body.appendChild(note) 16 + 17 + // Remove loader 18 + const elm = document.querySelector("#elm") 19 + elm?.parentNode?.removeChild(elm) 20 + }
+10
src/Javascript/UI/index.d.ts
···
··· 1 + import type { ElmPorts } from "./elm/types" 2 + 3 + export { } 4 + 5 + declare global { 6 + const BUILD_TIMESTAMP: string 7 + 8 + const Elm: { UI: ElmMain<ElmPorts> } 9 + const tocca: any 10 + }
+61
src/Javascript/UI/index.ts
···
··· 1 + // 2 + // | (• ◡•)| (❍ᴥ❍ʋ) 3 + // 4 + // The bit where we launch the Elm apps & workers, 5 + // and connect the other bits to it. 6 + 7 + import "./pointer-events" 8 + 9 + import * as Application from "./application" 10 + import * as Artwork from "./artwork" 11 + import * as Audio from "./audio" 12 + import * as Backdrop from "./backdrop" 13 + import * as Brain from "./brain" 14 + import * as Broadcast from "./broadcast" 15 + import * as Errors from "./errors" 16 + import * as Misc from "./misc" 17 + import * as ServiceWorker from "./service-worker" 18 + import * as Tracks from "./tracks" 19 + import * as UserLayer from "./user-layer" 20 + 21 + 22 + 23 + // 🌸 24 + 25 + 26 + const isNativeWrapper = !!globalThis.__TAURI__ 27 + 28 + 29 + 30 + // 🚀 31 + 32 + 33 + ServiceWorker 34 + .load({ isNativeWrapper }) 35 + .then(async (reg: ServiceWorkerRegistration) => { 36 + const brain = await Brain.load() 37 + const app = Application.load({ isNativeWrapper, reg }) 38 + const channel = Broadcast.channel() 39 + 40 + // 🧑‍🏭 41 + ServiceWorker.link({ 42 + app, isNativeWrapper, reg 43 + }) 44 + 45 + // 🧠 46 + Brain.link({ 47 + app, brain 48 + }) 49 + 50 + // ⚡ 51 + Application.init(app, channel) 52 + Artwork.init(app, brain) 53 + Audio.init(app) 54 + Backdrop.init(app) 55 + Misc.init(app) 56 + Tracks.init(app) 57 + UserLayer.init(app) 58 + }) 59 + .catch( 60 + Errors.failure 61 + )
+96
src/Javascript/UI/misc.ts
···
··· 1 + import type { App } from "./elm/types" 2 + 3 + 4 + // 🏔️ 5 + 6 + 7 + let app: App 8 + 9 + 10 + 11 + // 🚀 12 + 13 + 14 + export function init(a: App) { 15 + app = a 16 + 17 + app.ports.copyToClipboard.subscribe(copyToClipboard) 18 + } 19 + 20 + 21 + 22 + // Clipboard 23 + // --------- 24 + 25 + 26 + async function copyToClipboard(text: string) { 27 + navigator.clipboard.writeText(text) 28 + } 29 + 30 + 31 + 32 + // Focus 33 + // ----- 34 + 35 + window.addEventListener("blur", event => { 36 + if (app && event.target === window) app.ports.lostWindowFocus.send(null) 37 + }) 38 + 39 + 40 + 41 + // Forms 42 + // ----- 43 + // Adds a `changed` attribute to form fields, if the form was "changed". 44 + // This is to help with styling, we don't want to show an error immediately. 45 + 46 + const FIELD_SELECTOR = "input, textarea" 47 + 48 + 49 + document.addEventListener("keyup", e => { 50 + const field = e.target && (<HTMLElement>e.target).closest(FIELD_SELECTOR) 51 + if (field) field.setAttribute("changed", "") 52 + }) 53 + 54 + 55 + document.addEventListener("click", e => { 56 + if (!e.target || (<HTMLElement>e.target).tagName !== "BUTTON") return; 57 + const form = (<HTMLElement>e.target).closest("form") 58 + if (form) markAllFormFieldsAsChanged(form) 59 + }) 60 + 61 + 62 + document.addEventListener("submit", e => { 63 + const form = e.target && (<HTMLElement>e.target).closest("form") 64 + if (form) markAllFormFieldsAsChanged(form) 65 + }) 66 + 67 + 68 + function markAllFormFieldsAsChanged(form) { 69 + [].slice.call(form.querySelectorAll(FIELD_SELECTOR)).forEach(field => { 70 + field.setAttribute("changed", "") 71 + }) 72 + } 73 + 74 + 75 + 76 + // Internet Connection 77 + // ------------------- 78 + 79 + window.addEventListener("online", onlineStatusChanged) 80 + window.addEventListener("offline", onlineStatusChanged) 81 + 82 + 83 + function onlineStatusChanged() { 84 + if (app) app.ports.setIsOnline.send(navigator.onLine) 85 + } 86 + 87 + 88 + 89 + // Touch Device 90 + // ------------ 91 + 92 + window.addEventListener("touchstart", function onFirstTouch() { 93 + if (!app) return 94 + app.ports.indicateTouchDevice.send() 95 + window.removeEventListener("touchstart", onFirstTouch, false) 96 + }, false)
+116
src/Javascript/UI/pointer-events.ts
···
··· 1 + import "./index.d" 2 + import "tocca" 3 + 4 + // Pointer Events 5 + // -------------- 6 + // Thanks to https://github.com/mpizenberg/elm-pep/ 7 + 8 + let enteredElement 9 + 10 + 11 + tocca({ 12 + dbltapThreshold: 400, 13 + tapThreshold: 250 14 + }) 15 + 16 + 17 + function mousePointerEvent(eventType, mouseEvent) { 18 + const pointerEvent: any = new MouseEvent(eventType, mouseEvent) 19 + pointerEvent.pointerId = 1 20 + pointerEvent.isPrimary = true 21 + pointerEvent.pointerType = "mouse" 22 + pointerEvent.width = 1 23 + pointerEvent.height = 1 24 + pointerEvent.tiltX = 0 25 + pointerEvent.tiltY = 0 26 + 27 + "buttons" in mouseEvent && mouseEvent.buttons !== 0 28 + ? (pointerEvent.pressure = 0.5) 29 + : (pointerEvent.pressure = 0) 30 + 31 + return pointerEvent 32 + } 33 + 34 + 35 + function touchPointerEvent(eventType, touchEvent, touch) { 36 + const pointerEvent: any = new CustomEvent(eventType, { 37 + bubbles: true, 38 + cancelable: true 39 + }) 40 + 41 + pointerEvent.ctrlKey = touchEvent.ctrlKey 42 + pointerEvent.shiftKey = touchEvent.shiftKey 43 + pointerEvent.altKey = touchEvent.altKey 44 + pointerEvent.metaKey = touchEvent.metaKey 45 + 46 + pointerEvent.clientX = touch.clientX 47 + pointerEvent.clientY = touch.clientY 48 + pointerEvent.screenX = touch.screenX 49 + pointerEvent.screenY = touch.screenY 50 + pointerEvent.pageX = touch.pageX 51 + pointerEvent.pageY = touch.pageY 52 + 53 + const rect = touch.target.getBoundingClientRect() 54 + pointerEvent.offsetX = touch.clientX - rect.left 55 + pointerEvent.offsetY = touch.clientY - rect.top 56 + pointerEvent.pointerId = 1 + touch.identifier 57 + 58 + pointerEvent.button = 0 59 + pointerEvent.buttons = 1 60 + pointerEvent.movementX = 0 61 + pointerEvent.movementY = 0 62 + pointerEvent.region = null 63 + pointerEvent.relatedTarget = null 64 + pointerEvent.x = pointerEvent.clientX 65 + pointerEvent.y = pointerEvent.clientY 66 + 67 + pointerEvent.pointerType = "touch" 68 + pointerEvent.width = 1 69 + pointerEvent.height = 1 70 + pointerEvent.tiltX = 0 71 + pointerEvent.tiltY = 0 72 + pointerEvent.pressure = 1 73 + pointerEvent.isPrimary = true 74 + 75 + return pointerEvent 76 + } 77 + 78 + 79 + // Simulate `pointerenter` and `pointerleave` event for non-touch devices 80 + if (!self.PointerEvent) { 81 + document.addEventListener("mouseover", event => { 82 + const section = document.body.querySelector("section") 83 + const isDragging = section && section.classList.contains("dragging-something") 84 + const node = isDragging && document.elementFromPoint(event.clientX, event.clientY) 85 + 86 + if (node && node != enteredElement) { 87 + enteredElement && enteredElement.dispatchEvent(mousePointerEvent("pointerleave", event)) 88 + node.dispatchEvent(mousePointerEvent("pointerenter", event)) 89 + enteredElement = node 90 + } 91 + }) 92 + } 93 + 94 + 95 + // Simulate `pointerenter` and `pointerleave` event for touch devices 96 + document.body.addEventListener("touchmove", event => { 97 + const section = document.body.querySelector("section") 98 + const isDragging = section && section.classList.contains("dragging-something") 99 + const touch = event.touches[0] 100 + 101 + let node 102 + 103 + if (isDragging && touch) { 104 + node = document.elementFromPoint(touch.clientX, touch.clientY) 105 + } 106 + 107 + if (node && node != enteredElement) { 108 + enteredElement && enteredElement.dispatchEvent(touchPointerEvent("pointerleave", event, touch)) 109 + node.dispatchEvent(touchPointerEvent("pointerenter", event, touch)) 110 + enteredElement = node 111 + } 112 + 113 + if (isDragging) { 114 + event.stopPropagation() 115 + } 116 + })
+112
src/Javascript/UI/service-worker.ts
···
··· 1 + import type { App } from "./elm/types" 2 + 3 + 4 + /** 5 + * Load: 6 + * 7 + * 1. Redirect to HTTPS if using the `diffuse.sh` domain (subdomains included). 8 + * 2. Fail if not a secure context. 9 + * 3. Set up service worker, ensure it's ready and then continue initialisation. 10 + */ 11 + export async function load({ isNativeWrapper } : { isNativeWrapper: boolean }): Promise<ServiceWorkerRegistration> { 12 + return new Promise((resolve, reject) => { 13 + if (location.hostname.endsWith("diffuse.sh") && location.protocol === "http:") { 14 + location.href = location.href.replace("http://", "https://") 15 + reject("Just a moment, redirecting to HTTPS.") 16 + 17 + } else if (!self.isSecureContext) { 18 + reject(` 19 + This app only works on a <a class="underline" target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#When_is_a_context_considered_secure">secure context</a>, HTTPS & localhost, and modern browsers. 20 + `) 21 + 22 + } else if ("serviceWorker" in navigator) { 23 + // Service worker 24 + window.addEventListener("load", () => { 25 + navigator.serviceWorker 26 + .getRegistrations() 27 + .then(async registrations => { 28 + const serverIsOnline = navigator.onLine && await fetch(`${location.origin}?ping=1`) 29 + .then(r => r.text()) 30 + .then(a => a === "false" ? false : true) 31 + 32 + if (isNativeWrapper) await Promise.all( 33 + registrations.map(r => r.unregister()) 34 + ) 35 + 36 + if (serverIsOnline) return navigator.serviceWorker.register( 37 + "service-worker.js", 38 + { type: "module" } 39 + ) 40 + 41 + if (registrations[0]) return registrations 42 + 43 + throw new Error("Web server is offline") 44 + }) 45 + .then(() => navigator.serviceWorker.ready) 46 + .then(resolve) 47 + .catch(err => { 48 + const isFirefox = navigator.userAgent.toLowerCase().includes("firefox") 49 + 50 + console.error(err) 51 + return reject( 52 + location.protocol === "https:" || location.hostname === "localhost" 53 + ? "Failed to start the service worker." + (isFirefox ? " Make sure the setting <strong>Delete cookies and site data when Firefox is closed</strong> is off, or Diffuse's domain is added as an exception." : "") 54 + : "Failed to start the service worker, try using HTTPS." 55 + ) 56 + }) 57 + }) 58 + 59 + } 60 + }) 61 + } 62 + 63 + 64 + /** 65 + * Link. 66 + */ 67 + export function link( 68 + { app, isNativeWrapper, reg } : { app: App, isNativeWrapper: boolean, reg: ServiceWorkerRegistration } 69 + ) { 70 + if (reg.installing) console.log("🧑‍✈️ Service worker is installing") 71 + const initialInstall = reg.installing 72 + 73 + initialInstall?.addEventListener("statechange", function() { 74 + if (this.state === "activated") { 75 + console.log("🧑‍✈️ Service worker is activated") 76 + app.ports.installedNewServiceWorker.send(null) 77 + } 78 + }) 79 + 80 + if (reg.waiting) { 81 + console.log("🧑‍✈️ A new version of Diffuse is available") 82 + app.ports.installingNewServiceWorker.send(null) 83 + app.ports.installedNewServiceWorker.send(null) 84 + } 85 + 86 + if (initialInstall?.state === "activated") { 87 + console.log("🧑‍✈️ Service worker is activated") 88 + app.ports.installedNewServiceWorker.send(null) 89 + } 90 + 91 + reg.addEventListener("updatefound", () => { 92 + const newWorker = reg.installing 93 + if (!newWorker) return 94 + 95 + // No worker was installed yet, so we'll only want to track the state changes 96 + if (newWorker !== initialInstall) { 97 + console.log("🧑‍✈️ A new version of Diffuse is available") 98 + app.ports.installingNewServiceWorker.send(null) 99 + } 100 + 101 + newWorker.addEventListener("statechange", (e: any) => { 102 + console.log("🧑‍✈️ Service worker is", e.target.state) 103 + if (e.target.state === "installed") app.ports.installedNewServiceWorker.send(null) 104 + }) 105 + }) 106 + 107 + // Check for service worker updates and every hour after that 108 + if (!isNativeWrapper && navigator.onLine) { 109 + reg.update() 110 + setInterval(() => reg.update(), 1 * 1000 * 60 * 60) 111 + } 112 + }
+50
src/Javascript/UI/tracks.ts
···
··· 1 + import type { App } from "./elm/types" 2 + import { fileExtension } from "../common" 3 + import { transformUrl } from "../urls" 4 + 5 + 6 + // 🏔️ 7 + 8 + 9 + let app: App 10 + 11 + 12 + 13 + // 🚀 14 + 15 + 16 + export function init(a: App) { 17 + app = a 18 + } 19 + 20 + 21 + 22 + // 🛠️ 23 + 24 + 25 + export async function download(group) { 26 + const { saveAs } = await import("file-saver").then(a => a.default) 27 + const JSZip = await import("jszip").then(a => a.default) 28 + 29 + const zip = new JSZip() 30 + const folder = zip.folder("Diffuse - " + group.name) 31 + if (!folder) throw new Error("Failed to create ZIP file") 32 + 33 + return group.tracks 34 + .reduce((acc, track) => { 35 + return acc 36 + .then(() => transformUrl(track.url, app)) 37 + .then(fetch) 38 + .then((r: Response) => { 39 + const mimeType = r.headers.get("content-type") 40 + const fileExt = (mimeType ? fileExtension(mimeType) : null) || "unknown" 41 + 42 + return r.blob().then((b: Blob) => folder.file(track.filename + "." + fileExt, b)) 43 + }) 44 + }, Promise.resolve()) 45 + .then(() => zip.generateAsync({ type: "blob" })) 46 + .then((zipFile: Blob) => { 47 + saveAs(zipFile, "Diffuse - " + group.name + ".zip") 48 + app.ports.downloadTracksFinished.send(null) 49 + }) 50 + }
+76
src/Javascript/UI/user-layer.ts
···
··· 1 + import type { Program as OddProgram } from "@oddjs/odd" 2 + import type { App } from "./elm/types.js" 3 + 4 + import { ODD_CONFIG } from "../common" 5 + 6 + 7 + // 🏔️ 8 + 9 + 10 + let app: App 11 + let odd 12 + 13 + 14 + 15 + // 🚀 16 + 17 + 18 + export function init(a: App) { 19 + app = a 20 + 21 + app.ports.authenticateWithFission.subscribe(async () => { 22 + const program = await oddProgram() 23 + await program.capabilities.request({ 24 + returnUrl: location.origin + "?action=authenticate/fission" 25 + }) 26 + }) 27 + 28 + app.ports.collectFissionCapabilities.subscribe(() => { 29 + // The ODD SDK should collect the capabilities for us, 30 + // if everything is valid, we'll receive a session. 31 + oddProgram().then( 32 + () => { 33 + history.replaceState({}, "", location.origin) 34 + app.ports.collectedFissionCapabilities.send(null) 35 + } 36 + ).catch( 37 + err => console.error(err) 38 + ) 39 + }) 40 + } 41 + 42 + 43 + 44 + // Fission ~ ODD 45 + // ------------- 46 + 47 + 48 + async function oddProgram(): Promise<OddProgram> { 49 + try { 50 + await loadOdd() 51 + } catch (err) { 52 + console.trace(err) 53 + throw new Error("Failed to load the ODD SDK") 54 + } 55 + 56 + const capComponent = await import("../Odd/components/capabilities.js") 57 + 58 + const crypto = await odd.defaultCryptoComponent(ODD_CONFIG) 59 + const storage = await odd.defaultStorageComponent(ODD_CONFIG) 60 + const depot = await odd.defaultDepotComponent({ storage }, ODD_CONFIG) 61 + 62 + return odd.program({ 63 + ...ODD_CONFIG, 64 + capabilities: capComponent.implementation({ 65 + crypto, 66 + depot 67 + }), 68 + fileSystem: { loadImmediately: false } 69 + }) 70 + } 71 + 72 + 73 + async function loadOdd() { 74 + if (odd) return 75 + odd = await import("@oddjs/odd") 76 + }
+12 -12
src/Javascript/Workers/search.ts
··· 17 ) 18 19 20 - let index 21 22 23 24 // Incoming messages 25 // ----------------- 26 27 - self.onmessage = event => { 28 switch (event.data.action) { 29 case "PERFORM_SEARCH": 30 performSearch(event.data.data) ··· 53 // Actions 54 // ------- 55 56 - function performSearch(rawSearchTerm) { 57 - let results = 58 [] 59 60 const searchTerm = rawSearchTerm ··· 62 .replace(/\+\s+/g, "+") 63 .split(/ +/) 64 .reduce( 65 - ([ acc, previousOperator, previousPrefix ], chunk) => { 66 const operator = (a => a && a[0])( chunk.match(/^(\+|-)/) ) 67 68 let chunkWithoutOperator = chunk.replace(/^(\+|-)/, "").replace(/\*$/, "").trim() ··· 123 } 124 125 126 - function updateSearchIndex(input) { 127 const tracks = (typeof input == "string") 128 ? JSON.parse(input) 129 : input 130 131 - index = customLunr(function() { 132 FIELDS.forEach( 133 - field => this.field(field) 134 ) 135 136 ;(tracks || []) 137 .map(mapTrack) 138 - .forEach(t => this.add(t)) 139 }) 140 } 141 142 143 144 - function customLunr(config) { 145 const builder = new lunr.Builder 146 147 builder.pipeline.add(removeParenthesesFromToken, lunr.stemmer) 148 builder.searchPipeline.add(removeParenthesesFromToken, lunr.stemmer) 149 150 - config.call(builder, builder) 151 return builder.build() 152 } 153 154 155 - function removeParenthesesFromToken(token) { 156 return token.update(s => s.replace(/\(|\)/, "")) 157 }
··· 17 ) 18 19 20 + let index: lunr.Index 21 22 23 24 // Incoming messages 25 // ----------------- 26 27 + self.onmessage = (event: MessageEvent) => { 28 switch (event.data.action) { 29 case "PERFORM_SEARCH": 30 performSearch(event.data.data) ··· 53 // Actions 54 // ------- 55 56 + function performSearch(rawSearchTerm: string) { 57 + let results: string[] = 58 [] 59 60 const searchTerm = rawSearchTerm ··· 62 .replace(/\+\s+/g, "+") 63 .split(/ +/) 64 .reduce( 65 + ([ acc, previousOperator, previousPrefix ]: [ string[], string, string ], chunk: string): [ string[], string, string ] => { 66 const operator = (a => a && a[0])( chunk.match(/^(\+|-)/) ) 67 68 let chunkWithoutOperator = chunk.replace(/^(\+|-)/, "").replace(/\*$/, "").trim() ··· 123 } 124 125 126 + function updateSearchIndex(input: string | object[]) { 127 const tracks = (typeof input == "string") 128 ? JSON.parse(input) 129 : input 130 131 + index = customLunr((builder: lunr.Builder) => { 132 FIELDS.forEach( 133 + field => builder.field(field) 134 ) 135 136 ;(tracks || []) 137 .map(mapTrack) 138 + .forEach(t => builder.add(t)) 139 }) 140 } 141 142 143 144 + function customLunr(fn: (b: lunr.Builder) => void) { 145 const builder = new lunr.Builder 146 147 builder.pipeline.add(removeParenthesesFromToken, lunr.stemmer) 148 builder.searchPipeline.add(removeParenthesesFromToken, lunr.stemmer) 149 150 + fn(builder) 151 return builder.build() 152 } 153 154 155 + function removeParenthesesFromToken(token: lunr.Token): lunr.Token { 156 return token.update(s => s.replace(/\(|\)/, "")) 157 }
+6 -8
src/Javascript/Workers/service.ts
··· 9 // 10 /// <reference lib="webworker" /> 11 12 - import { } from "../index.d" 13 - 14 15 const KEY = 16 /* eslint-disable no-undef */ ··· 19 20 const EXCLUDE = 21 [ "_headers" 22 - , "_redirects" 23 - , "CORS" 24 ] 25 26 ··· 39 // 📣 40 41 42 - self.addEventListener("activate", _event => { 43 // Remove all caches except the one with the currently used `KEY` 44 caches.keys().then(keys => { 45 keys.forEach(k => { ··· 82 })() 83 ) 84 85 - // When doing a request with basic authentication in the url, put it in the headers instead 86 } else if (event.request.url.includes("service_worker_authentication=")) { 87 const url = new URL(event.request.url) 88 const token = url.searchParams.get("service_worker_authentication") ··· 96 "Basic " + token 97 ) 98 99 - // When doing a request with access token in the url, put it in the headers instead 100 } else if (event.request.url.includes("bearer_token=")) { 101 const url = new URL(event.request.url) 102 const token = url.searchParams.get("bearer_token") ··· 112 "Bearer " + token 113 ) 114 115 - // Use cache if internal request and not using native app 116 } else if (isInternal) { 117 event.respondWith( 118 isNativeWrapper
··· 9 // 10 /// <reference lib="webworker" /> 11 12 13 const KEY = 14 /* eslint-disable no-undef */ ··· 17 18 const EXCLUDE = 19 [ "_headers" 20 + , "_redirects" 21 + , "CORS" 22 ] 23 24 ··· 37 // 📣 38 39 40 + self.addEventListener("activate", () => { 41 // Remove all caches except the one with the currently used `KEY` 42 caches.keys().then(keys => { 43 keys.forEach(k => { ··· 80 })() 81 ) 82 83 + // When doing a request with basic authentication in the url, put it in the headers instead 84 } else if (event.request.url.includes("service_worker_authentication=")) { 85 const url = new URL(event.request.url) 86 const token = url.searchParams.get("service_worker_authentication") ··· 94 "Basic " + token 95 ) 96 97 + // When doing a request with access token in the url, put it in the headers instead 98 } else if (event.request.url.includes("bearer_token=")) { 99 const url = new URL(event.request.url) 100 const token = url.searchParams.get("bearer_token") ··· 110 "Bearer " + token 111 ) 112 113 + // Use cache if internal request and not using native app 114 } else if (isInternal) { 115 event.respondWith( 116 isNativeWrapper
-569
src/Javascript/audio-engine.ts
··· 1 - // 2 - // Audio engine 3 - // ♪(´ε` ) 4 - // 5 - // Creates audio elements and interacts with the Web Audio API. 6 - 7 - 8 - import { throttle } from "throttle-debounce" 9 - import Timer from "timer.js" 10 - 11 - import { db } from "./common" 12 - import { transformUrl } from "./urls" 13 - import { mimeType } from "./common" 14 - 15 - 16 - // ⛩ 17 - 18 - 19 - const IS_SAFARI = !!navigator.platform.match(/iPhone|iPod|iPad/) || 20 - navigator.vendor === "Apple Computer, Inc." 21 - 22 - 23 - 24 - // Container for <audio> elements 25 - // ------------------------------ 26 - 27 - const audioElementsContainer: HTMLElement = (() => { 28 - let c 29 - let styles = 30 - [ "height: 0" 31 - , "width: 0" 32 - , "visibility: hidden" 33 - , "pointer-events: none" 34 - ] 35 - 36 - c = document.createElement("div") 37 - c.setAttribute("class", "absolute left-0 top-0") 38 - c.setAttribute("style", styles.join("; ")) 39 - 40 - return c 41 - })() 42 - 43 - 44 - function addAudioContainer() { 45 - document.body.appendChild(audioElementsContainer) 46 - } 47 - 48 - 49 - 50 - // Setup 51 - // ----- 52 - 53 - const silentMp3File = "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV" 54 - 55 - 56 - export function setup(orchestrion) { 57 - addAudioContainer() 58 - } 59 - 60 - 61 - 62 - // EQ 63 - // -- 64 - 65 - let volume = 0.5 66 - 67 - export function adjustEqualizerSetting(orchestrion, knobType, value) { 68 - switch (knobType) { 69 - case "VOLUME": 70 - volume = value 71 - if (orchestrion.audio) orchestrion.audio.volume = value 72 - break; 73 - } 74 - } 75 - 76 - 77 - 78 - // Playback 79 - // -------- 80 - 81 - export function insertTrack(orchestrion, queueItem, maybeArtwork = null) { 82 - if (queueItem.url == undefined) console.error("insertTrack, missing `url`"); 83 - if (queueItem.trackId == undefined) console.error("insertTrack, missing `trackId`"); 84 - 85 - // reset 86 - orchestrion.app.ports.setAudioHasStalled.send(false) 87 - orchestrion.app.ports.setAudioPosition.send(0) 88 - clearTimeout(orchestrion.unstallTimeout) 89 - timesStalled = 1 90 - 91 - // metadata 92 - setMediaSessionMetadata(queueItem, maybeArtwork) 93 - 94 - // initial promise 95 - const initialPromise = queueItem.isCached 96 - ? db("tracks").getItem(queueItem.trackId).then(blobUrl) 97 - : transformUrl(queueItem.url, orchestrion.app) 98 - 99 - // find or create audio node 100 - let audioNode 101 - 102 - return initialPromise.then(url => { 103 - queueItem.url = url 104 - audioNode = audioElementsContainer.querySelector("audio") 105 - 106 - if (audioNode = findExistingAudioElement(queueItem)) { 107 - audioNode.setAttribute("data-preload", "f") 108 - audioNode.setAttribute("data-timestamp", Date.now()) 109 - audioNode.volume = 1 110 - 111 - if (audioNode.readyState >= 4) { 112 - playAudio(audioNode, queueItem, orchestrion.app) 113 - } else { 114 - orchestrion.app.ports.setAudioIsLoading.send(true) 115 - audioNode.load() 116 - } 117 - 118 - } else { 119 - audioNode = createAudioElement(orchestrion, queueItem, Date.now(), false) 120 - 121 - } 122 - 123 - audioNode.volume = volume 124 - orchestrion.audio = audioNode 125 - }) 126 - } 127 - 128 - 129 - function findExistingAudioElement(queueItem) { 130 - return audioElementsContainer.querySelector(`[rel="${queueItem.trackId}"]`) 131 - } 132 - 133 - 134 - function createAudioElement(orchestrion, queueItem, timestampInMilliseconds, isPreload) { 135 - const bind = fn => event => { 136 - const is = isActiveAudioElement(orchestrion, event.target) 137 - if (is) fn.call(orchestrion, event) 138 - } 139 - 140 - const crossorigin = isCrossOrginUrl(queueItem.url) ? "use-credentials" : "anonymous" 141 - 142 - const fileName = queueItem.trackPath.split("/").reverse()[ 0 ] 143 - const fileExtMatch = fileName.match(/\.(\w+)$/) 144 - const fileExt = fileExtMatch && fileExtMatch[ 1 ] 145 - const mime = mimeType(fileExt) 146 - 147 - const source = document.createElement("source") 148 - if (mime) source.setAttribute("type", mime) 149 - source.setAttribute("src", queueItem.url) 150 - 151 - const audio = document.createElement("audio") 152 - audio.setAttribute("crossorigin", crossorigin) 153 - audio.setAttribute("data-preload", isPreload ? "t" : "f") 154 - audio.setAttribute("data-timestamp", timestampInMilliseconds) 155 - audio.setAttribute("preload", "auto") 156 - audio.setAttribute("rel", queueItem.trackId) 157 - audio.appendChild(source) 158 - 159 - audio.crossOrigin = "anonymous" 160 - audio.volume = isPreload ? 0 : 1 161 - 162 - audio.addEventListener("canplay", bind(audioCanPlayEvent)) 163 - audio.addEventListener("ended", bind(audioEndEvent)) 164 - audio.addEventListener("error", bind(audioErrorEvent)) 165 - audio.addEventListener("loadstart", bind(audioLoading)) 166 - audio.addEventListener("loadeddata", bind(audioLoaded)) 167 - audio.addEventListener("pause", bind(audioPauseEvent)) 168 - audio.addEventListener("play", bind(audioPlayEvent)) 169 - audio.addEventListener("seeking", bind(audioLoading)) 170 - audio.addEventListener("seeked", bind(audioLoaded)) 171 - audio.addEventListener("timeupdate", bind(audioTimeUpdateEvent)) 172 - 173 - // Audio stalled event doesn't work well in Safari 174 - if (!IS_SAFARI) { 175 - audio.addEventListener("stalled", bind(audioStalledEvent)) 176 - } 177 - 178 - audioElementsContainer.appendChild(audio) 179 - audio.load() 180 - 181 - return audio 182 - } 183 - 184 - 185 - export function preloadAudioElement(orchestrion, queueItem) { 186 - // already loaded? 187 - if (findExistingAudioElement(queueItem)) return 188 - 189 - // remove other preloads 190 - audioElementsContainer.querySelectorAll(`[data-preload="t"]`).forEach( 191 - n => n.parentNode?.removeChild(n) 192 - ) 193 - 194 - // audio element remains valid for 45 minutes 195 - transformUrl(queueItem.url, orchestrion.app).then(url => { 196 - const queueItemWithTransformedUrl = 197 - Object.assign({}, queueItem, { url: url }) 198 - 199 - createAudioElement( 200 - orchestrion, 201 - queueItemWithTransformedUrl, 202 - Date.now() + 1000 * 60 * 45, 203 - true 204 - ) 205 - }) 206 - } 207 - 208 - 209 - export function playAudio(element, queueItem, app) { 210 - if (queueItem.progress && element.duration) { 211 - element.currentTime = queueItem.progress * element.duration 212 - } 213 - 214 - const promise = element.play() || Promise.resolve() 215 - 216 - promise.catch(e => { 217 - const err = "Couldn't play audio automatically. Please resume playback manually." 218 - console.error(err, e) 219 - if (app) app.ports.fromAlien.send({ tag: "", data: null, error: err }) 220 - }) 221 - } 222 - 223 - 224 - export function seek(orchestrion, percentage) { 225 - const audio = orchestrion.audio 226 - if (audio && !isNaN(audio.duration)) { 227 - if (audio.paused) playAudio(audio, orchestrion.activeQueueItem, orchestrion.app) 228 - audio.currentTime = audio.duration * percentage 229 - } 230 - } 231 - 232 - 233 - export function isCrossOrginUrl(url) { 234 - return url.includes("service_worker_authentication") 235 - } 236 - 237 - 238 - 239 - // Audio events 240 - // ------------ 241 - 242 - let showedNoNetworkError = false 243 - let timesStalled = 1 244 - 245 - 246 - function audioErrorEvent(event) { 247 - this.app.ports.setAudioIsPlaying.send(false) 248 - 249 - switch (event.target.error.code) { 250 - case event.target.error.MEDIA_ERR_ABORTED: 251 - console.error("You aborted the audio playback.") 252 - break 253 - case event.target.error.MEDIA_ERR_NETWORK: 254 - console.error("A network error caused the audio download to fail.") 255 - showNetworkErrorNotification.call(this) 256 - audioStalledEvent.call(this, event) 257 - break 258 - case event.target.error.MEDIA_ERR_DECODE: 259 - console.error("The audio playback was aborted due to a corruption problem or because the video used features your browser did not support.") 260 - break 261 - case event.target.error.MEDIA_ERR_SRC_NOT_SUPPORTED: 262 - console.error("The audio not be loaded, either because the server or network failed or because the format is not supported.") 263 - if (event.target.currentTime && event.target.currentTime > 0) { 264 - showNetworkErrorNotification.call(this) 265 - audioStalledEvent.call(this, event) 266 - } else if (navigator.onLine) { 267 - showUnsupportedSrcErrorNotification.call(this) 268 - clearTimeout(this.loadingTimeoutId) 269 - this.app.ports.setAudioIsLoading.send(false) 270 - } else { 271 - showNetworkErrorNotification.call(this) 272 - audioStalledEvent.call(this, event) 273 - } 274 - break 275 - default: 276 - console.error("An unknown error occurred.") 277 - } 278 - } 279 - 280 - 281 - function showNetworkErrorNotification() { 282 - if (showedNoNetworkError) return 283 - showedNoNetworkError = true 284 - this.app.ports.showErrorNotification.send( 285 - navigator.onLine 286 - ? "I can't play this track because of a network error. I'll try to reconnect." 287 - : "I can't play this track because we're offline. I'll try to reconnect." 288 - ) 289 - } 290 - 291 - 292 - function showUnsupportedSrcErrorNotification() { 293 - this.app.ports.showErrorNotification.send( 294 - "__I can't play this track because your browser didn't recognize it.__ Try checking your developer console for a warning to find out why." 295 - ) 296 - } 297 - 298 - 299 - function audioStalledEvent(event, notifyAppImmediately) { 300 - this.app.ports.setAudioIsLoading.send(true) 301 - clearTimeout(this.unstallTimeout) 302 - 303 - // Notify app 304 - if (timesStalled >= 3 || notifyAppImmediately) { 305 - this.app.ports.setAudioHasStalled.send(true) 306 - } 307 - 308 - // Timeout 309 - this.unstallTimeout = setTimeout(_ => { 310 - if (isActiveAudioElement(this, event.target)) { 311 - unstallAudio.call(this, event.target) 312 - } 313 - }, timesStalled * 2500) 314 - 315 - // Increase counter 316 - timesStalled++ 317 - } 318 - 319 - 320 - function audioTimeUpdateEvent(event) { 321 - const node = event.target 322 - 323 - if ( 324 - isNaN(node.duration) || 325 - isNaN(node.currentTime) || 326 - node.duration === 0 327 - ) return; 328 - 329 - setDurationIfNecessary.call(this, node) 330 - this.app.ports.setAudioPosition.send(node.currentTime) 331 - 332 - if (navigator.mediaSession && navigator.mediaSession.setPositionState) { 333 - try { 334 - navigator.mediaSession.setPositionState({ 335 - duration: node.duration, 336 - position: node.currentTime 337 - }) 338 - } catch (_err) { } 339 - } 340 - 341 - const progress = node.currentTime / node.duration 342 - 343 - if (node.duration >= 30 * 60) { 344 - sendProgress(this, progress) 345 - } 346 - } 347 - 348 - 349 - function audioEndEvent(event) { 350 - if (this.repeat) { 351 - event.target.startedPlayingAt = Math.floor(Date.now() / 1000) 352 - if (this.scrobbleTimer) this.scrobbleTimer.stop() 353 - playAudio(event.target, this.activeQueueItem, this.app) 354 - } else { 355 - this.app.ports.noteProgress.send({ trackId: this.activeQueueItem.trackId, progress: 1 }) 356 - this.app.ports.activeQueueItemEnded.send(null) 357 - } 358 - } 359 - 360 - 361 - function audioLoading(event) { 362 - clearTimeout(this.loadingTimeoutId) 363 - 364 - this.loadingTimeoutId = setTimeout(() => { 365 - const audio = event.target 366 - 367 - if (!audio || !isActiveAudioElement(this, audio)) { 368 - return 369 - } else if (audio.readyState === 4 && audio.currentTime === 0) { 370 - this.app.ports.setAudioIsLoading.send(false) 371 - } else if (audio.readyState < 3 && IS_SAFARI) { 372 - this.app.ports.setAudioIsLoading.send(true) 373 - this.unstallTimeout = setTimeout( 374 - () => { 375 - if (isActiveAudioElement(this, audio)) { 376 - unstallSafariAudio.call(this, audio) 377 - } 378 - }, 379 - timesStalled * 2500 380 - ) 381 - } else { 382 - this.app.ports.setAudioIsLoading.send(true) 383 - } 384 - }, 1750) 385 - } 386 - 387 - 388 - function audioLoaded(event) { 389 - clearTimeout(this.loadingTimeoutId) 390 - clearTimeout(this.unstallTimeout) 391 - this.app.ports.setAudioHasStalled.send(false) 392 - this.app.ports.setAudioIsLoading.send(false) 393 - if (event.target.paused && (event.type === "seeked" || !event.target.hasPlayed)) { 394 - playAudio(event.target, this.activeQueueItem, this.app) 395 - } 396 - } 397 - 398 - 399 - function audioPlayEvent(event) { 400 - event.target.hasPlayed = true 401 - this.app.ports.setAudioIsPlaying.send(true) 402 - if (navigator.mediaSession) navigator.mediaSession.playbackState = "playing" 403 - if (this.scrobbleTimer) this.scrobbleTimer.start() 404 - } 405 - 406 - 407 - function audioPauseEvent(event) { 408 - this.app.ports.setAudioIsPlaying.send(false) 409 - if (navigator.mediaSession) navigator.mediaSession.playbackState = "paused" 410 - if (this.scrobbleTimer) this.scrobbleTimer.pause() 411 - } 412 - 413 - 414 - function audioCanPlayEvent(event) { 415 - showedNoNetworkError = false 416 - setDurationIfNecessary.call(this, event.target) 417 - } 418 - 419 - 420 - 421 - // 🖍 Utensils 422 - // ----------- 423 - 424 - function audioElementTrackId(node) { 425 - return node ? node.getAttribute("rel") : undefined 426 - } 427 - 428 - 429 - function blobUrl(blob) { 430 - return URL.createObjectURL(blob) 431 - } 432 - 433 - 434 - function isActiveAudioElement(orchestrion, node) { 435 - const isActive = ( 436 - !orchestrion.activeQueueItem || 437 - !node || 438 - node.getAttribute("data-preload") === "t" || 439 - node.getAttribute("data-deactivated") === "t" 440 - ) 441 - ? false 442 - : orchestrion.activeQueueItem.trackId === audioElementTrackId(node); 443 - 444 - return isActive 445 - } 446 - 447 - 448 - const sendProgress = throttle(30000, (orchestrion, progress) => { 449 - orchestrion.app.ports.noteProgress.send({ 450 - trackId: orchestrion.activeQueueItem.trackId, 451 - progress: progress 452 - }) 453 - }, { 454 - noLeading: false, 455 - noTrailing: false 456 - }) 457 - 458 - 459 - let lastSetDuration = 0 460 - 461 - 462 - function setDurationIfNecessary(audio) { 463 - if (audio.duration === lastSetDuration) return; 464 - 465 - this.app.ports.setAudioDuration.send(audio.duration || 0) 466 - lastSetDuration = audio.duration 467 - 468 - // Scrobble 469 - if (!lastSetDuration || lastSetDuration < 30) return; 470 - 471 - const timestamp = Math.floor(Date.now() / 1000) 472 - const scrobbleTimeoutDuration = Math.min(240 + 0.5, lastSetDuration / 1.95) 473 - const trackId = audio.getAttribute("rel") 474 - 475 - audio.startedPlayingAt = timestamp 476 - 477 - this.scrobbleTimer = new Timer({ 478 - onend: _ => this.app.ports.scrobble.send({ 479 - duration: Math.round(lastSetDuration), 480 - timestamp: audio.startedPlayingAt || timestamp, 481 - trackId: trackId 482 - }) 483 - }) 484 - 485 - this.scrobbleTimer.start(scrobbleTimeoutDuration) 486 - } 487 - 488 - 489 - export function setMediaSessionMetadata(queueItem, maybeArtwork) { 490 - if ("mediaSession" in navigator === false || !queueItem.trackTags) return 491 - 492 - let artwork: MediaImage[] = [] 493 - 494 - if (maybeArtwork && typeof maybeArtwork !== "string") { 495 - artwork = [ { 496 - src: URL.createObjectURL(maybeArtwork), 497 - type: maybeArtwork.type 498 - } ] 499 - } 500 - 501 - navigator.mediaSession.metadata = new MediaMetadata({ 502 - title: queueItem.trackTags.title, 503 - artist: queueItem.trackTags.artist, 504 - album: queueItem.trackTags.album, 505 - artwork: artwork 506 - }) 507 - } 508 - 509 - 510 - function unstallAudio(node: HTMLAudioElement) { 511 - const time = node.currentTime 512 - 513 - node.load() 514 - node.currentTime = time 515 - 516 - if (timesStalled > 5 && !showedNoNetworkError && navigator.onLine) { 517 - this.app.ports.showStickyErrorNotification.send( 518 - "You loaded too many tracks too quickly, " + 519 - "which the browser can't handle. " + 520 - "You'll most likely have to reload the browser." 521 - ) 522 - } 523 - } 524 - 525 - 526 - function unstallSafariAudio(node: HTMLAudioElement) { 527 - timesStalled++ 528 - 529 - // Deactivate 530 - node.setAttribute("data-deactivated", "t") 531 - 532 - // Force browser to stop loading 533 - try { node.src = silentMp3File } catch (_err) { } 534 - 535 - // Remove element 536 - audioElementsContainer.removeChild(node) 537 - 538 - // Create new element 539 - createAudioElement(this, this.activeQueueItem, Date.now() + 1000 * 60 * 45, false) 540 - } 541 - 542 - 543 - 544 - // 💥 545 - // -- 546 - // Remove all the audio elements with a timestamp older than the given one. 547 - 548 - export function removeOlderAudioElements(timestamp) { 549 - const nodes: NodeListOf<HTMLAudioElement> = audioElementsContainer.querySelectorAll( 550 - "audio[data-timestamp]" 551 - ) 552 - 553 - nodes.forEach(node => { 554 - const tAttr = node.getAttribute("data-timestamp") 555 - if (!tAttr) return 556 - 557 - const t = parseInt(tAttr, 10) 558 - if (t >= timestamp) return 559 - 560 - // Deactivate 561 - node.setAttribute("data-deactivated", "t") 562 - 563 - // Force browser to stop loading 564 - try { node.src = silentMp3File } catch (_err) { } 565 - 566 - // Remove element 567 - audioElementsContainer.removeChild(node) 568 - }) 569 - }
···
+13
src/Javascript/common.ts
··· 23 24 25 26 // FUNCTIONS 27 28
··· 23 24 25 26 + // 🌳 27 + 28 + 29 + export type CoverPrep = { 30 + cacheKey: string 31 + trackFilename: string 32 + trackPath: string 33 + trackSourceId: string 34 + variousArtists: string 35 + } 36 + 37 + 38 + 39 // FUNCTIONS 40 41
+18 -22
src/Javascript/crypto.ts
··· 11 const extractable = false 12 13 14 - export function keyFromPassphrase(passphrase) { 15 - return crypto.subtle.importKey( 16 "raw", 17 Uint8arrays.fromString(passphrase, "utf8"), 18 { ··· 20 }, 21 false, 22 [ "deriveKey" ] 23 24 - ).then(baseKey => crypto.subtle.deriveKey( 25 { 26 name: "PBKDF2", 27 salt: Uint8arrays.fromString("diffuse", "utf8"), ··· 35 }, 36 extractable, 37 [ "encrypt", "decrypt" ] 38 - 39 - )) 40 } 41 42 43 - export function encrypt(key, string) { 44 - let iv = crypto.getRandomValues(new Uint8Array(12)) 45 46 - return crypto.subtle.encrypt( 47 { 48 name: "AES-GCM", 49 iv: iv, ··· 51 }, 52 key, 53 Uint8arrays.fromString(string, "base64pad") 54 - 55 - ).then(buf => { 56 - const iv_b64 = Uint8arrays.toString(iv, "base64pad") 57 - const buf_b64 = Uint8arrays.toString(new Uint8Array(buf), "base64pad") 58 - return iv_b64 + buf_b64 59 60 - }) 61 } 62 63 64 - export function decrypt(key, string) { 65 const iv_b64 = string.substring(0, 16) 66 const buf_b64 = string.substring(16) 67 68 const iv = Uint8arrays.fromString(iv_b64, "base64pad") 69 const buf = Uint8arrays.fromString(buf_b64, "base64pad") 70 71 - return crypto.subtle.decrypt( 72 { 73 name: "AES-GCM", 74 iv: iv, ··· 76 }, 77 key, 78 buf 79 - 80 - ).then( 81 - buffer => Uint8arrays.toString( 82 - new Uint8Array(buffer), 83 - "utf8" 84 - ) 85 86 ) 87 }
··· 11 const extractable = false 12 13 14 + export async function keyFromPassphrase(passphrase: string): Promise<CryptoKey> { 15 + const baseKey = await crypto.subtle.importKey( 16 "raw", 17 Uint8arrays.fromString(passphrase, "utf8"), 18 { ··· 20 }, 21 false, 22 [ "deriveKey" ] 23 + ) 24 25 + return await crypto.subtle.deriveKey( 26 { 27 name: "PBKDF2", 28 salt: Uint8arrays.fromString("diffuse", "utf8"), ··· 36 }, 37 extractable, 38 [ "encrypt", "decrypt" ] 39 + ) 40 } 41 42 43 + export async function encrypt(key: CryptoKey, string: string): Promise<string> { 44 + const iv = crypto.getRandomValues(new Uint8Array(12)) 45 46 + const buf = await crypto.subtle.encrypt( 47 { 48 name: "AES-GCM", 49 iv: iv, ··· 51 }, 52 key, 53 Uint8arrays.fromString(string, "base64pad") 54 + ) 55 56 + const iv_b64 = Uint8arrays.toString(iv, "base64pad") 57 + const buf_b64 = Uint8arrays.toString(new Uint8Array(buf), "base64pad") 58 + return iv_b64 + buf_b64 59 } 60 61 62 + export async function decrypt(key: CryptoKey, string: string): Promise<string> { 63 const iv_b64 = string.substring(0, 16) 64 const buf_b64 = string.substring(16) 65 66 const iv = Uint8arrays.fromString(iv_b64, "base64pad") 67 const buf = Uint8arrays.fromString(buf_b64, "base64pad") 68 69 + const decrypted = await crypto.subtle.decrypt( 70 { 71 name: "AES-GCM", 72 iv: iv, ··· 74 }, 75 key, 76 buf 77 + ) 78 79 + return Uint8arrays.toString( 80 + new Uint8Array(decrypted), 81 + "utf8" 82 ) 83 }
-8
src/Javascript/index.d.ts
··· 1 - export { } 2 - 3 - declare global { 4 - const BUILD_TIMESTAMP: string 5 - 6 - const Elm: any 7 - const tocca: any 8 - }
···
-970
src/Javascript/index.ts
··· 1 - // 2 - // | (• ◡•)| (❍ᴥ❍ʋ) 3 - // 4 - // The bit where we launch the Elm app, 5 - // and connect the other bits to it. 6 - 7 - import "tocca" 8 - 9 - import type { Program as OddProgram } from "@oddjs/odd" 10 - import type { } from "./index.d" 11 - 12 - import { debounce } from "throttle-debounce" 13 - 14 - import * as audioEngine from "./audio-engine" 15 - import { db, fileExtension, ODD_CONFIG } from "./common" 16 - import { transformUrl } from "./urls" 17 - import { version } from "../../package.json" 18 - 19 - 20 - 21 - // 🌸 22 - 23 - 24 - const isNativeWrapper = !!globalThis.__TAURI__ 25 - 26 - 27 - 28 - // 🔐 29 - 30 - 31 - // Redirect to HTTPS if using the `diffuse.sh` domain (subdomains included) 32 - if (location.hostname.endsWith("diffuse.sh") && location.protocol === "http:") { 33 - location.href = location.href.replace("http://", "https://") 34 - failure("Just a moment, redirecting to HTTPS.") 35 - 36 - // Not a secure context 37 - } else if (!self.isSecureContext) { 38 - failure(` 39 - This app only works on a <a class="underline" target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#When_is_a_context_considered_secure">secure context</a>, HTTPS & localhost, and modern browsers. 40 - `) 41 - 42 - // Service worker 43 - } else if ("serviceWorker" in navigator) { 44 - window.addEventListener("load", () => { 45 - navigator.serviceWorker 46 - .getRegistrations() 47 - .then(async registrations => { 48 - const resp = await fetch(`${location.origin}?ping=1`).then(r => r.text()).then(a => a === "false" ? false : true) 49 - const serverIsOnline = navigator.onLine && resp 50 - 51 - if (isNativeWrapper) await Promise.all( 52 - registrations.map(r => r.unregister()) 53 - ) 54 - 55 - return serverIsOnline 56 - }) 57 - .then(async serverIsOnline => { 58 - if (serverIsOnline) { 59 - return navigator.serviceWorker.register( 60 - "service-worker.js", 61 - { type: "module" } 62 - ) 63 - } 64 - }) 65 - .then(_ => { 66 - return navigator.serviceWorker.ready 67 - }) 68 - .catch(err => { 69 - const isFirefox = navigator.userAgent.toLowerCase().includes("firefox") 70 - 71 - console.error(err) 72 - return failure( 73 - location.protocol === "https:" || location.hostname === "localhost" 74 - ? "Failed to start the service worker." + (isFirefox ? " Make sure the setting <strong>Delete cookies and site data when Firefox is closed</strong> is off, or Diffuse's domain is added as an exception." : "") 75 - : "Failed to start the service worker, try using HTTPS." 76 - ) 77 - }) 78 - .then(initialise) 79 - .catch(err => { 80 - console.error(err) 81 - return failure("<strong>Failed to start the application.</strong><br />See browser console for details.") 82 - }) 83 - }) 84 - 85 - } 86 - 87 - 88 - 89 - // 🍱 90 - 91 - 92 - let app 93 - let brain 94 - let wire: any = {} 95 - 96 - 97 - async function initialise(reg) { 98 - brain = new Worker( 99 - "./js/brain/index.js#appHref=" + encodeURIComponent(window.location.href), 100 - { type: "module" } 101 - ) 102 - 103 - brain.addEventListener("error", err => { 104 - failure("<strong>Failed to load web worker.</strong><br />If you're using Firefox, you might need to upgrade your browser (version 113 and up) and set `dom.workers.modules.enabled` to `true` in `about:config`") 105 - }) 106 - 107 - await new Promise(resolve => { 108 - brain.onmessage = event => { 109 - if (event.data.action === "READY") resolve(null) 110 - } 111 - }) 112 - 113 - app = Elm.UI.init({ 114 - node: document.getElementById("elm"), 115 - flags: { 116 - buildTimestamp: BUILD_TIMESTAMP, 117 - darkMode: preferredColorScheme().matches, 118 - initialTime: Date.now(), 119 - isInstallingServiceWorker: !!reg.installing, 120 - isOnline: navigator.onLine, 121 - isTauri: isNativeWrapper, 122 - version, 123 - viewport: { 124 - height: window.innerHeight, 125 - width: window.innerWidth 126 - } 127 - } 128 - }) 129 - 130 - // ⚡️ 131 - wire.brain() 132 - wire.audio() 133 - wire.backdrop() 134 - wire.broadcastChannel() 135 - wire.clipboard() 136 - wire.covers() 137 - wire.serviceWorker(reg) 138 - wire.odd() 139 - 140 - // Other ports 141 - app.ports.downloadJsonUsingTauri.subscribe(async ( 142 - { filename, json }: { filename: string, json: string } 143 - ) => { 144 - const { save } = await import("@tauri-apps/plugin-dialog") 145 - const { writeTextFile } = await import("@tauri-apps/plugin-fs") 146 - const { BaseDirectory } = await import("@tauri-apps/api/path") 147 - 148 - const filePath = await save({ defaultPath: filename }) 149 - await writeTextFile(filePath || filename, json, { baseDir: BaseDirectory.Download }) 150 - }) 151 - 152 - app.ports.openUrlOnNewPage.subscribe((url: string) => { 153 - if (globalThis.__TAURI__) { 154 - globalThis.__TAURI__.shell.open( 155 - url.includes("://") ? url : `${location.origin}/${url.replace(/^\.\//, "")}` 156 - ) 157 - 158 - } else { 159 - window.open(url, "_blank") 160 - 161 - } 162 - }) 163 - 164 - app.ports.reloadApp.subscribe(_ => { 165 - let timeout = setTimeout(() => { 166 - if (reg.waiting) reg.waiting.postMessage("skipWaiting") 167 - window.location.reload() 168 - }, 250) 169 - 170 - bc.addEventListener("message", event => { 171 - if (event.data === "PONG") { 172 - clearTimeout(timeout) 173 - alert("⚠️ You can only update the app when you have no more than one instance open.") 174 - } 175 - }) 176 - 177 - bc.postMessage("PING") 178 - }) 179 - } 180 - 181 - 182 - function failure(text: string) { 183 - const note = document.createElement("div") 184 - 185 - note.className = "flex flex-col font-body items-center h-screen italic justify-center leading-relaxed px-4 text-center text-base text-white" 186 - note.innerHTML = ` 187 - <a class="block logo mb-5" href="../"> 188 - <img src="../images/diffuse-light.svg" /> 189 - </a> 190 - 191 - <p class="max-w-sm opacity-60"> 192 - ${text} 193 - </p> 194 - ` 195 - 196 - document.body.appendChild(note) 197 - 198 - // Remove loader 199 - const elm = document.querySelector("#elm") 200 - elm?.parentNode?.removeChild(elm) 201 - } 202 - 203 - 204 - 205 - // Brain 206 - // ===== 207 - 208 - wire.brain = () => { 209 - brain.onmessage = event => { 210 - if (event.data.action) return handleAction(event.data.action, event.data.data, event.ports) 211 - if (event.data.tag) app.ports.fromAlien.send(event.data) 212 - 213 - switch (event.data.tag) { 214 - case "GOT_CACHED_COVER": return gotCachedCover(event.data.data) 215 - } 216 - } 217 - 218 - app.ports.toBrain.subscribe(a => brain.postMessage(a)) 219 - } 220 - 221 - 222 - function handleAction(action, data, _ports) { 223 - switch (action) { 224 - case "DOWNLOAD_TRACKS": return downloadTracks(data) 225 - case "FINISHED_DOWNLOADING_ARTWORK": return finishedDownloadingArtwork() 226 - } 227 - } 228 - 229 - 230 - 231 - // Audio 232 - // ----- 233 - 234 - let orchestrion 235 - 236 - 237 - wire.audio = () => { 238 - orchestrion = { 239 - activeQueueItem: null, 240 - audio: null, 241 - app: app, 242 - repeat: false 243 - } 244 - 245 - audioEngine.setup(orchestrion) 246 - 247 - app.ports.activeQueueItemChanged.subscribe(activeQueueItemChanged) 248 - app.ports.adjustEqualizerSetting.subscribe(adjustEqualizerSetting) 249 - app.ports.pause.subscribe(pause) 250 - app.ports.play.subscribe(play) 251 - app.ports.preloadAudio.subscribe(preloadAudio()) 252 - app.ports.seek.subscribe(seek) 253 - app.ports.setRepeat.subscribe(setRepeat) 254 - } 255 - 256 - 257 - function activeQueueItemChanged(item) { 258 - if ( 259 - orchestrion.activeQueueItem && 260 - orchestrion.audio && 261 - item && 262 - item.trackId === orchestrion.activeQueueItem.trackId 263 - ) { 264 - orchestrion.audio.currentTime = 0 265 - return 266 - } 267 - 268 - const timestampInMilliseconds = Date.now() 269 - 270 - orchestrion.activeQueueItem = item 271 - orchestrion.audio = null 272 - orchestrion.coverPrep = null 273 - 274 - // Reset scrobble timer 275 - if (orchestrion.scrobbleTimer) { 276 - orchestrion.scrobbleTimer.stop() 277 - orchestrion.scrobbleTimer = null 278 - } 279 - 280 - // Remove older audio elements if possible 281 - audioEngine.removeOlderAudioElements(timestampInMilliseconds) 282 - 283 - // 🎵 284 - if (item) { 285 - const coverPrep = { 286 - cacheKey: btoa(unescape(encodeURIComponent((item.trackTags.artist || "?") + " --- " + (item.trackTags.album || "?")))), 287 - trackFilename: item.trackPath.split("/").reverse()[0], 288 - trackPath: item.trackPath, 289 - trackSourceId: item.sourceId, 290 - variousArtists: "f" 291 - } 292 - 293 - albumCover(coverPrep.cacheKey).then(maybeCover => { 294 - maybeCover = maybeCover === "TRIED" ? null : maybeCover 295 - orchestrion.coverPrep = coverPrep 296 - 297 - audioEngine.insertTrack( 298 - orchestrion, 299 - item, 300 - maybeCover as any 301 - 302 - ).then(() => { 303 - if (!maybeCover) { 304 - if (!orchestrion.audio) return 305 - orchestrion.audio.waitingForArtwork = coverPrep.cacheKey 306 - loadAlbumCovers([coverPrep]) 307 - } else { 308 - orchestrion.audio.waitingForArtwork = null 309 - } 310 - 311 - }) 312 - }) 313 - 314 - // ✋ 315 - } else { 316 - app.ports.setAudioIsPlaying.send(false) 317 - app.ports.setAudioPosition.send(0) 318 - if (navigator.mediaSession) navigator.mediaSession.playbackState = "none" 319 - 320 - } 321 - } 322 - 323 - 324 - function adjustEqualizerSetting(e) { 325 - audioEngine.adjustEqualizerSetting(orchestrion, e.knob, e.value) 326 - } 327 - 328 - 329 - function pause(_) { 330 - if (orchestrion.audio) orchestrion.audio.pause() 331 - } 332 - 333 - 334 - function play(_) { 335 - if (orchestrion.audio) { 336 - audioEngine.playAudio(orchestrion.audio, orchestrion.activeQueueItem, app) 337 - } 338 - } 339 - 340 - 341 - function preloadAudio() { 342 - if (navigator.onLine === false) return; 343 - 344 - return debounce(15000, item => { 345 - // Wait 15 seconds to preload something. 346 - // This is particularly useful when quickly shifting through tracks, 347 - // or when moving things around in the queue. 348 - item.isCached 349 - ? false 350 - : audioEngine.preloadAudioElement(orchestrion, item) 351 - }) 352 - } 353 - 354 - 355 - function seek(percentage) { 356 - audioEngine.seek(orchestrion, percentage) 357 - } 358 - 359 - 360 - function setRepeat(repeat) { 361 - orchestrion.repeat = repeat 362 - } 363 - 364 - 365 - 366 - // Backdrop 367 - // -------- 368 - 369 - wire.backdrop = () => { 370 - app.ports.pickAverageBackgroundColor.subscribe(pickAverageBackgroundColor) 371 - } 372 - 373 - 374 - function averageColorOfImage(img) { 375 - const canvas = document.createElement("canvas") 376 - const ctx = canvas.getContext("2d") 377 - canvas.width = img.naturalWidth 378 - canvas.height = img.naturalHeight 379 - 380 - if (!ctx) return null 381 - 382 - ctx.drawImage(img, 0, 0) 383 - 384 - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) 385 - const color = { r: 0, g: 0, b: 0 } 386 - 387 - for (let i = 0, l = imageData.data.length; i < l; i += 4) { 388 - color.r += imageData.data[i] 389 - color.g += imageData.data[i + 1] 390 - color.b += imageData.data[i + 2] 391 - } 392 - 393 - color.r = Math.floor(color.r / (imageData.data.length / 4)) 394 - color.g = Math.floor(color.g / (imageData.data.length / 4)) 395 - color.b = Math.floor(color.b / (imageData.data.length / 4)) 396 - 397 - return color 398 - } 399 - 400 - 401 - function pickAverageBackgroundColor(src) { 402 - const img = document.querySelector(`img[src$="${src}"]`) 403 - 404 - if (img) { 405 - const avgColor = averageColorOfImage(img) 406 - app.ports.setAverageBackgroundColor.send(avgColor) 407 - } 408 - } 409 - 410 - 411 - 412 - // Broadcast channel 413 - // ----------------- 414 - 415 - let bc 416 - 417 - wire.broadcastChannel = () => { 418 - bc = new BroadcastChannel(`Diffuse-${location.hostname}`) 419 - bc.addEventListener("message", event => { 420 - switch (event.data) { 421 - case "PING": return bc.postMessage("PONG") 422 - } 423 - }) 424 - } 425 - 426 - 427 - 428 - // Clipboard 429 - // --------- 430 - 431 - wire.clipboard = () => { 432 - app.ports.copyToClipboard.subscribe(async text => { 433 - // TODO: Find a better solution for this 434 - const adjustedText = (() => { 435 - if (text.startsWith("dropbox://")) { 436 - return transformUrl(text, app) 437 - } else if (text.startsWith("google://")) { 438 - return transformUrl(text, app) 439 - } else { 440 - return text 441 - 442 - } 443 - })() 444 - 445 - navigator.clipboard.writeText(await adjustedText) 446 - }) 447 - } 448 - 449 - 450 - 451 - // Covers 452 - // ------ 453 - 454 - wire.covers = () => { 455 - app.ports.loadAlbumCovers.subscribe( 456 - debounce(500, loadAlbumCoversFromDom) 457 - ) 458 - 459 - db().keys().then(cachedCovers) 460 - } 461 - 462 - 463 - function albumCover(coverKey) { 464 - return db().getItem(`coverCache.${coverKey}`) 465 - } 466 - 467 - 468 - function gotCachedCover({ key, url }) { 469 - const item = orchestrion.activeQueueItem 470 - 471 - if (item && orchestrion.coverPrep && key === orchestrion.coverPrep.key && url) { 472 - let artwork = [{ src: url, type: undefined }] 473 - 474 - if (typeof url !== "string") { 475 - artwork = [{ 476 - src: URL.createObjectURL(url), 477 - type: url.type 478 - }] 479 - } 480 - 481 - navigator.mediaSession.metadata = new MediaMetadata({ 482 - title: item.trackTags.title, 483 - artist: item.trackTags.artist, 484 - album: item.trackTags.album, 485 - artwork: artwork 486 - }) 487 - } 488 - } 489 - 490 - 491 - function loadAlbumCoversFromDom({ coverView, list }) { 492 - if (!navigator.onLine) return 493 - 494 - let nodes: HTMLElement[] = [] 495 - 496 - if (list) nodes = nodes.concat(Array.from( 497 - document.querySelectorAll("#diffuse__track-covers [data-key]") 498 - )) 499 - 500 - if (coverView) nodes = nodes.concat(Array.from( 501 - document.querySelectorAll("#diffuse__track-covers + div [data-key]") 502 - )) 503 - 504 - if (!nodes.length) return; 505 - 506 - const coverPrepList = nodes.map(node => ({ 507 - cacheKey: node.getAttribute("data-key"), 508 - trackFilename: node.getAttribute("data-filename"), 509 - trackPath: node.getAttribute("data-path"), 510 - trackSourceId: node.getAttribute("data-source-id"), 511 - variousArtists: node.getAttribute("data-various-artists") 512 - })) 513 - 514 - return loadAlbumCovers(coverPrepList) 515 - } 516 - 517 - 518 - function loadAlbumCovers(coverPrepList) { 519 - return coverPrepList.reduce((acc, prep) => { 520 - return acc.then(arr => { 521 - return albumCover(prep.cacheKey).then(a => { 522 - if (!a) return arr.concat([prep]) 523 - return arr 524 - }) 525 - }) 526 - 527 - }, Promise.resolve([])).then(withoutEarlierAttempts => { 528 - brain.postMessage({ 529 - action: "DOWNLOAD_ARTWORK", 530 - data: withoutEarlierAttempts 531 - }) 532 - 533 - }) 534 - } 535 - 536 - 537 - // Send a dictionary of the cached covers to the app. 538 - function cachedCovers(keys) { 539 - const cacheKeys = keys.filter( 540 - k => k.startsWith("coverCache.") 541 - ) 542 - 543 - const cachePromise = cacheKeys.reduce((acc, key) => { 544 - return acc.then(cache => { 545 - return db().getItem(key).then(blob => { 546 - const cacheKey = key.slice(11) 547 - 548 - if (blob && typeof blob !== "string" && blob instanceof Blob) { 549 - cache[cacheKey] = URL.createObjectURL(blob) 550 - } 551 - 552 - return cache 553 - }) 554 - }) 555 - }, Promise.resolve({})) 556 - 557 - cachePromise.then(cache => { 558 - app.ports.insertCoverCache.send(cache) 559 - setTimeout(() => loadAlbumCoversFromDom({ list: true, coverView: true }), 500) 560 - }) 561 - } 562 - 563 - 564 - function finishedDownloadingArtwork() { 565 - if (!orchestrion.audio || !orchestrion.audio.waitingForArtwork || !orchestrion.activeQueueItem) return 566 - 567 - albumCover(orchestrion.audio.waitingForArtwork).then(maybeArtwork => { 568 - audioEngine.setMediaSessionMetadata(orchestrion.activeQueueItem, maybeArtwork) 569 - }) 570 - 571 - orchestrion.audio.waitingForArtwork = null 572 - } 573 - 574 - 575 - 576 - // Dark mode 577 - // --------- 578 - 579 - function preferredColorScheme() { 580 - const m = 581 - window.matchMedia && 582 - window.matchMedia("(prefers-color-scheme: dark)") 583 - 584 - m && m.addEventListener && m.addEventListener("change", e => { 585 - app.ports.preferredColorSchemaChanged.send({ dark: e.matches }) 586 - }) 587 - 588 - return m 589 - } 590 - 591 - 592 - 593 - // Downloading 594 - // ----------- 595 - 596 - async function downloadTracks(group) { 597 - const { saveAs } = await import("file-saver").then(a => a.default) 598 - const JSZip = await import("jszip").then(a => a.default) 599 - 600 - const zip = new JSZip() 601 - const folder = zip.folder("Diffuse - " + group.name) 602 - if (!folder) throw new Error("Failed to create ZIP file") 603 - 604 - return group.tracks.reduce( 605 - (acc, track) => { 606 - return acc 607 - .then(_ => transformUrl(track.url, app)) 608 - .then(fetch) 609 - .then(r => { 610 - const mimeType = r.headers.get("content-type") 611 - const fileExt = fileExtension(mimeType) || "unknown" 612 - 613 - return r.blob().then( 614 - b => folder.file(track.filename + "." + fileExt, b) 615 - ) 616 - }) 617 - }, 618 - Promise.resolve() 619 - 620 - ).then(_ => zip.generateAsync({ type: "blob" }) 621 - ).then(zipFile => { 622 - saveAs(zipFile, "Diffuse - " + group.name + ".zip") 623 - app.ports.downloadTracksFinished.send(null) 624 - 625 - }) 626 - } 627 - 628 - 629 - 630 - // Focus 631 - // ----- 632 - 633 - window.addEventListener("blur", event => { 634 - if (app && event.target === window) app.ports.lostWindowFocus.send(null) 635 - }) 636 - 637 - 638 - 639 - // Forms 640 - // ----- 641 - // Adds a `changed` attribute to form fields, if the form was "changed". 642 - // This is to help with styling, we don't want to show an error immediately. 643 - 644 - const FIELD_SELECTOR = "input, textarea" 645 - 646 - 647 - document.addEventListener("keyup", e => { 648 - const field = e.target && (<HTMLElement>e.target).closest(FIELD_SELECTOR) 649 - if (field) field.setAttribute("changed", "") 650 - }) 651 - 652 - 653 - document.addEventListener("click", e => { 654 - if (!e.target || (<HTMLElement>e.target).tagName !== "BUTTON") return; 655 - const form = (<HTMLElement>e.target).closest("form") 656 - if (form) markAllFormFieldsAsChanged(form) 657 - }) 658 - 659 - 660 - document.addEventListener("submit", e => { 661 - const form = e.target && (<HTMLElement>e.target).closest("form") 662 - if (form) markAllFormFieldsAsChanged(form) 663 - }) 664 - 665 - 666 - function markAllFormFieldsAsChanged(form) { 667 - [].slice.call(form.querySelectorAll(FIELD_SELECTOR)).forEach(field => { 668 - field.setAttribute("changed", "") 669 - }) 670 - } 671 - 672 - 673 - 674 - // Internet Connection 675 - // ------------------- 676 - 677 - window.addEventListener("online", onlineStatusChanged) 678 - window.addEventListener("offline", onlineStatusChanged) 679 - 680 - 681 - function onlineStatusChanged() { 682 - app.ports.setIsOnline.send(navigator.onLine) 683 - } 684 - 685 - 686 - 687 - // Media Keys 688 - // ---------- 689 - 690 - if ("mediaSession" in navigator) { 691 - 692 - navigator.mediaSession.setActionHandler("play", () => { 693 - app.ports.requestPlay.send(null) 694 - }) 695 - 696 - 697 - navigator.mediaSession.setActionHandler("pause", () => { 698 - app.ports.requestPause.send(null) 699 - }) 700 - 701 - 702 - navigator.mediaSession.setActionHandler("previoustrack", () => { 703 - app.ports.requestPrevious.send(null) 704 - }) 705 - 706 - 707 - navigator.mediaSession.setActionHandler("nexttrack", () => { 708 - app.ports.requestNext.send(null) 709 - }) 710 - 711 - 712 - navigator.mediaSession.setActionHandler("seekbackward", event => { 713 - const audio = orchestrion.audio 714 - const seekOffset = event.seekOffset || 10 715 - if (audio) audio.currentTime = Math.max(audio.currentTime - seekOffset, 0) 716 - }) 717 - 718 - 719 - navigator.mediaSession.setActionHandler("seekforward", event => { 720 - const audio = orchestrion.audio 721 - const seekOffset = event.seekOffset || 10 722 - if (audio) audio.currentTime = Math.min(audio.currentTime + seekOffset, audio.duration) 723 - }) 724 - 725 - 726 - navigator.mediaSession.setActionHandler("seekto", event => { 727 - const audio = orchestrion.audio 728 - if (audio) audio.currentTime = event.seekTime 729 - }) 730 - 731 - } 732 - 733 - 734 - 735 - // Pointer Events 736 - // -------------- 737 - // Thanks to https://github.com/mpizenberg/elm-pep/ 738 - 739 - let enteredElement 740 - 741 - 742 - tocca({ 743 - dbltapThreshold: 400, 744 - tapThreshold: 250 745 - }) 746 - 747 - 748 - function mousePointerEvent(eventType, mouseEvent) { 749 - let pointerEvent: any = new MouseEvent(eventType, mouseEvent) 750 - pointerEvent.pointerId = 1 751 - pointerEvent.isPrimary = true 752 - pointerEvent.pointerType = "mouse" 753 - pointerEvent.width = 1 754 - pointerEvent.height = 1 755 - pointerEvent.tiltX = 0 756 - pointerEvent.tiltY = 0 757 - 758 - "buttons" in mouseEvent && mouseEvent.buttons !== 0 759 - ? (pointerEvent.pressure = 0.5) 760 - : (pointerEvent.pressure = 0) 761 - 762 - return pointerEvent 763 - } 764 - 765 - 766 - function touchPointerEvent(eventType, touchEvent, touch) { 767 - let pointerEvent: any = new CustomEvent(eventType, { 768 - bubbles: true, 769 - cancelable: true 770 - }) 771 - 772 - pointerEvent.ctrlKey = touchEvent.ctrlKey 773 - pointerEvent.shiftKey = touchEvent.shiftKey 774 - pointerEvent.altKey = touchEvent.altKey 775 - pointerEvent.metaKey = touchEvent.metaKey 776 - 777 - pointerEvent.clientX = touch.clientX 778 - pointerEvent.clientY = touch.clientY 779 - pointerEvent.screenX = touch.screenX 780 - pointerEvent.screenY = touch.screenY 781 - pointerEvent.pageX = touch.pageX 782 - pointerEvent.pageY = touch.pageY 783 - 784 - const rect = touch.target.getBoundingClientRect() 785 - pointerEvent.offsetX = touch.clientX - rect.left 786 - pointerEvent.offsetY = touch.clientY - rect.top 787 - pointerEvent.pointerId = 1 + touch.identifier 788 - 789 - pointerEvent.button = 0 790 - pointerEvent.buttons = 1 791 - pointerEvent.movementX = 0 792 - pointerEvent.movementY = 0 793 - pointerEvent.region = null 794 - pointerEvent.relatedTarget = null 795 - pointerEvent.x = pointerEvent.clientX 796 - pointerEvent.y = pointerEvent.clientY 797 - 798 - pointerEvent.pointerType = "touch" 799 - pointerEvent.width = 1 800 - pointerEvent.height = 1 801 - pointerEvent.tiltX = 0 802 - pointerEvent.tiltY = 0 803 - pointerEvent.pressure = 1 804 - pointerEvent.isPrimary = true 805 - 806 - return pointerEvent 807 - } 808 - 809 - 810 - // Simulate `pointerenter` and `pointerleave` event for non-touch devices 811 - if (!self.PointerEvent) { 812 - document.addEventListener("mouseover", event => { 813 - const section = document.body.querySelector("section") 814 - const isDragging = section && section.classList.contains("dragging-something") 815 - const node = isDragging && document.elementFromPoint(event.clientX, event.clientY) 816 - 817 - if (node && node != enteredElement) { 818 - enteredElement && enteredElement.dispatchEvent(mousePointerEvent("pointerleave", event)) 819 - node.dispatchEvent(mousePointerEvent("pointerenter", event)) 820 - enteredElement = node 821 - } 822 - }) 823 - } 824 - 825 - 826 - // Simulate `pointerenter` and `pointerleave` event for touch devices 827 - document.body.addEventListener("touchmove", event => { 828 - const section = document.body.querySelector("section") 829 - const isDragging = section && section.classList.contains("dragging-something") 830 - 831 - let touch = event.touches[0] 832 - let node 833 - 834 - if (isDragging && touch) { 835 - node = document.elementFromPoint(touch.clientX, touch.clientY) 836 - } 837 - 838 - if (node && node != enteredElement) { 839 - enteredElement && enteredElement.dispatchEvent(touchPointerEvent("pointerleave", event, touch)) 840 - node.dispatchEvent(touchPointerEvent("pointerenter", event, touch)) 841 - enteredElement = node 842 - } 843 - 844 - if (isDragging) { 845 - event.stopPropagation() 846 - } 847 - }) 848 - 849 - 850 - 851 - // Service worker 852 - // -------------- 853 - 854 - wire.serviceWorker = async (reg: ServiceWorkerRegistration) => { 855 - if (reg.installing) console.log("🧑‍✈️ Service worker is installing") 856 - const initialInstall = reg.installing 857 - 858 - initialInstall?.addEventListener("statechange", function() { 859 - if (this.state === "activated") { 860 - console.log("🧑‍✈️ Service worker is activated") 861 - app.ports.installedNewServiceWorker.send(null) 862 - } 863 - }) 864 - 865 - if (reg.waiting) { 866 - console.log("🧑‍✈️ A new version of Diffuse is available") 867 - app.ports.installingNewServiceWorker.send(null) 868 - app.ports.installedNewServiceWorker.send(null) 869 - } 870 - 871 - if (initialInstall?.state === "activated") { 872 - console.log("🧑‍✈️ Service worker is activated") 873 - app.ports.installedNewServiceWorker.send(null) 874 - } 875 - 876 - reg.addEventListener("updatefound", () => { 877 - const newWorker = reg.installing 878 - if (!newWorker) return 879 - 880 - // No worker was installed yet, so we'll only want to track the state changes 881 - if (newWorker !== initialInstall) { 882 - console.log("🧑‍✈️ A new version of Diffuse is available") 883 - app.ports.installingNewServiceWorker.send(null) 884 - } 885 - 886 - newWorker.addEventListener("statechange", (e: any) => { 887 - console.log("🧑‍✈️ Service worker is", e.target.state) 888 - if (e.target.state === "installed") app.ports.installedNewServiceWorker.send(null) 889 - }) 890 - }) 891 - 892 - // Check for service worker updates and every hour after that 893 - if (!isNativeWrapper && navigator.onLine) { 894 - reg.update() 895 - setInterval(() => reg.update(), 1 * 1000 * 60 * 60) 896 - } 897 - } 898 - 899 - 900 - 901 - // Syncing 902 - // ------- 903 - 904 - let odd 905 - 906 - 907 - wire.odd = () => { 908 - app.ports.authenticateWithFission.subscribe(async () => { 909 - const program = await oddProgram() 910 - await program.capabilities.request({ 911 - returnUrl: location.origin + "?action=authenticate/fission" 912 - }) 913 - }) 914 - 915 - app.ports.collectFissionCapabilities.subscribe(() => { 916 - // The ODD SDK should collect the capabilities for us, 917 - // if everything is valid, we'll receive a session. 918 - oddProgram().then( 919 - program => { 920 - history.replaceState({}, "", location.origin) 921 - app.ports.collectedFissionCapabilities.send(null) 922 - } 923 - ).catch( 924 - err => console.error(err) 925 - ) 926 - }) 927 - } 928 - 929 - 930 - 931 - async function oddProgram(): Promise<OddProgram> { 932 - try { 933 - await loadOdd() 934 - } catch (err) { 935 - console.trace(err) 936 - throw new Error("Failed to load the ODD SDK") 937 - } 938 - 939 - const capComponent = await import("./Odd/components/capabilities.js") 940 - 941 - const crypto = await odd.defaultCryptoComponent(ODD_CONFIG) 942 - const storage = await odd.defaultStorageComponent(ODD_CONFIG) 943 - const depot = await odd.defaultDepotComponent({ storage }, ODD_CONFIG) 944 - 945 - return odd.program({ 946 - ...ODD_CONFIG, 947 - capabilities: capComponent.implementation({ 948 - crypto, 949 - depot 950 - }), 951 - fileSystem: { loadImmediately: false } 952 - }) 953 - } 954 - 955 - 956 - async function loadOdd() { 957 - if (odd) return 958 - odd = await import("@oddjs/odd") 959 - } 960 - 961 - 962 - 963 - // Touch Device 964 - // ------------ 965 - 966 - window.addEventListener("touchstart", function onFirstTouch() { 967 - if (!app) return 968 - app.ports.indicateTouchDevice.send(null) 969 - window.removeEventListener("touchstart", onFirstTouch, false) 970 - }, false)
···
+80 -30
src/Javascript/processing.ts src/Javascript/Brain/processing.ts
··· 4 // 5 // Audio processing, getting metadata, etc. 6 7 - import type { IAudioMetadata } from "music-metadata"; 8 - import type { MediaInfoType } from "mediainfo.js"; 9 10 - import * as Uint8arrays from "uint8arrays"; 11 - import { transformUrl } from "./urls"; 12 13 // Contexts 14 // -------- 15 16 export async function processContext(context, app) { 17 const initialPromise = Promise.resolve([]); ··· 40 }); 41 } 42 43 // Tags - General 44 // -------------- 45 46 type Tags = { 47 disc: number; ··· 63 const musicMetadata = await import("music-metadata-browser").then((a) => a.default); 64 const httpTokenizer = await import("@tokenizer/http").then((a) => a.default); 65 66 - let tokenizer 67 - let mmResult 68 69 try { 70 tokenizer = await httpTokenizer.makeTokenizer(headUrl); ··· 78 tokenizer.rangeRequestClient.resolvedUrl = undefined; 79 } 80 81 - mmResult = await musicMetadata.parseFromTokenizer( 82 - tokenizer, 83 - { skipCovers: !covers } 84 - ).catch(err => { 85 - console.warn(err) 86 - return null 87 - }); 88 } catch (err) { 89 - console.warn(err) 90 } 91 92 const mmTags = mmResult && pickTagsFromMusicMetadata(filename, mmResult); ··· 94 95 const miResult = await (await mediaInfoClient(covers)) 96 .analyzeData(getSize(headUrl), readChunk(getUrl)) 97 - .catch(err => { 98 - console.warn(err) 99 - return null 100 }); 101 102 const miTags = miResult && pickTagsFromMediaInfo(filename, miResult); ··· 164 return new Uint8Array(await response.arrayBuffer()); 165 }; 166 167 - function pickTagsFromMediaInfo(filename: string, result: MediaInfoType): Tags | null { 168 - const tags = result?.media?.track?.filter((t) => t["@type"] === "General")[0]; 169 - if (!tags) return null; 170 171 let artist = typeof tags.Performer == "string" ? tags.Performer : null; 172 - const album = typeof tags.Album == "string" ? tags.Album : null; 173 174 - const title = typeof tags.Track == "string" 175 - ? tags.Track 176 - : typeof tags.Title == "string" 177 - ? tags.Title 178 - : null; 179 180 if (!artist && !title) return null; 181 182 // TODO: Encoding issues with mediainfo.js 183 - if (artist?.includes("�") || album?.includes("�") || title?.includes("�")) return null 184 185 if (artist && artist.includes(" / ")) { 186 artist = artist ··· 201 year: year !== null && isNaN(year) ? null : year, 202 picture: tags.Cover_Data 203 ? { 204 - data: Uint8arrays.fromString(tags.Cover_Data, "base64"), 205 format: tags.Cover_Mime || "image/jpeg", 206 } 207 : null, 208 }; 209 } 210 211 // Tags - Music Metadata 212 // --------------------- 213 214 function pickTagsFromMusicMetadata(filename: string, result: IAudioMetadata): Tags | null { 215 const tags = result && result.common; ··· 235 }; 236 } 237 238 // 🛠️ 239 - // -- 240 241 async function mediaInfoClient(covers: boolean) { 242 - const MediaInfoFactory = await import("mediainfo.js").then(a => a.default) 243 244 return await MediaInfoFactory({ 245 coverData: covers,
··· 4 // 5 // Audio processing, getting metadata, etc. 6 7 + import type { IAudioMetadata } from "music-metadata" 8 + import type { GeneralTrack, MediaInfoResult } from "mediainfo.js" 9 + 10 + import * as Uint8arrays from "uint8arrays" 11 + import { type App } from "./elm/types" 12 + import { transformUrl } from "../urls" 13 + 14 + 15 + // 🏔️ 16 + 17 + 18 + const ENCODING_ISSUE_REPLACE_CHAR = '▩'; 19 + 20 + let app: App 21 + 22 + 23 + 24 + // 🚀 25 + 26 + 27 + export function init(a: App) { 28 + app = a 29 + 30 + app.ports.requestTags.subscribe(requestTags) 31 + app.ports.syncTags.subscribe(syncTags) 32 + } 33 + 34 + 35 + 36 + // Ports 37 + // ----- 38 + 39 + 40 + function requestTags(context) { 41 + processContext(context, app).then(newContext => { 42 + app.ports.receiveTags.send(newContext) 43 + }) 44 + } 45 + 46 + 47 + function syncTags(context) { 48 + processContext(context, app).then(newContext => { 49 + app.ports.replaceTags.send(newContext) 50 + }) 51 + } 52 + 53 54 55 // Contexts 56 // -------- 57 + 58 59 export async function processContext(context, app) { 60 const initialPromise = Promise.resolve([]); ··· 83 }); 84 } 85 86 + 87 + 88 // Tags - General 89 // -------------- 90 + 91 92 type Tags = { 93 disc: number; ··· 109 const musicMetadata = await import("music-metadata-browser").then((a) => a.default); 110 const httpTokenizer = await import("@tokenizer/http").then((a) => a.default); 111 112 + let tokenizer; 113 + let mmResult; 114 115 try { 116 tokenizer = await httpTokenizer.makeTokenizer(headUrl); ··· 124 tokenizer.rangeRequestClient.resolvedUrl = undefined; 125 } 126 127 + mmResult = await musicMetadata 128 + .parseFromTokenizer(tokenizer, { skipCovers: !covers }) 129 + .catch((err) => { 130 + console.warn(err); 131 + return null; 132 + }); 133 } catch (err) { 134 + console.warn(err); 135 } 136 137 const mmTags = mmResult && pickTagsFromMusicMetadata(filename, mmResult); ··· 139 140 const miResult = await (await mediaInfoClient(covers)) 141 .analyzeData(getSize(headUrl), readChunk(getUrl)) 142 + .catch((err) => { 143 + console.warn(err); 144 + return null; 145 }); 146 147 const miTags = miResult && pickTagsFromMediaInfo(filename, miResult); ··· 209 return new Uint8Array(await response.arrayBuffer()); 210 }; 211 212 + function pickTagsFromMediaInfo(filename: string, result: MediaInfoResult): Tags | null { 213 + const tagsRaw = result?.media?.track?.filter((t) => t["@type"] === "General")[0]; 214 + const tags = tagsRaw === undefined ? undefined : tagsRaw as GeneralTrack; 215 + if (tags === undefined) return null; 216 217 let artist = typeof tags.Performer == "string" ? tags.Performer : null; 218 + let album = typeof tags.Album == "string" ? tags.Album : null; 219 220 + let title = 221 + typeof tags.Track == "string" ? tags.Track : typeof tags.Title == "string" ? tags.Title : null; 222 223 if (!artist && !title) return null; 224 225 // TODO: Encoding issues with mediainfo.js 226 + // https://github.com/buzz/mediainfo.js/issues/150 227 + if (artist?.includes("�")) artist = artist.replace("�", ENCODING_ISSUE_REPLACE_CHAR) 228 + if (album?.includes("�")) album = album.replace("�", ENCODING_ISSUE_REPLACE_CHAR) 229 + if (title?.includes("�")) title = title.replace("�", ENCODING_ISSUE_REPLACE_CHAR) 230 231 if (artist && artist.includes(" / ")) { 232 artist = artist ··· 247 year: year !== null && isNaN(year) ? null : year, 248 picture: tags.Cover_Data 249 ? { 250 + data: Uint8arrays.fromString(tags.Cover_Data.split(" / ")[0], "base64pad"), 251 format: tags.Cover_Mime || "image/jpeg", 252 } 253 : null, 254 }; 255 } 256 257 + 258 // Tags - Music Metadata 259 // --------------------- 260 + 261 262 function pickTagsFromMusicMetadata(filename: string, result: IAudioMetadata): Tags | null { 263 const tags = result && result.common; ··· 283 }; 284 } 285 286 + 287 + 288 // 🛠️ 289 + 290 291 async function mediaInfoClient(covers: boolean) { 292 + const MediaInfoFactory = await import("mediainfo.js").then((a) => a.default); 293 294 return await MediaInfoFactory({ 295 coverData: covers,
+8
src/Library/MediaSession.elm
···
··· 1 + module MediaSession exposing (states) 2 + 3 + 4 + states = 5 + { none = "none" 6 + , paused = "paused" 7 + , playing = "playing" 8 + }
+4 -2
src/Library/Queue.elm
··· 21 22 type alias EngineItem = 23 { isCached : Bool 24 , progress : Maybe Float 25 , sourceId : String 26 , trackId : String ··· 34 -- 🔱 35 36 37 - makeEngineItem : Time.Posix -> List Source -> List String -> Dict String Float -> Track -> EngineItem 38 - makeEngineItem timestamp sources cachedTrackIds progressTable track = 39 { isCached = List.member track.id cachedTrackIds 40 , progress = Dict.get track.id progressTable 41 , sourceId = track.sourceId 42 , trackId = track.id
··· 21 22 type alias EngineItem = 23 { isCached : Bool 24 + , isPreload : Bool 25 , progress : Maybe Float 26 , sourceId : String 27 , trackId : String ··· 35 -- 🔱 36 37 38 + makeEngineItem : Bool -> Time.Posix -> List Source -> List String -> Dict String Float -> Track -> EngineItem 39 + makeEngineItem preload timestamp sources cachedTrackIds progressTable track = 40 { isCached = List.member track.id cachedTrackIds 41 + , isPreload = preload 42 , progress = Dict.get track.id progressTable 43 , sourceId = track.sourceId 44 , trackId = track.id
+5
src/Library/Tracks.elm
··· 425 |> (\( t, _ ) -> { playlist | tracks = t }) 426 427 428 shouldRenderGroup : Identifiers -> Bool 429 shouldRenderGroup identifiers = 430 identifiers.group
··· 425 |> (\( t, _ ) -> { playlist | tracks = t }) 426 427 428 + shouldNoteProgress : { duration : Float } -> Bool 429 + shouldNoteProgress { duration } = 430 + duration >= 30 * 60 431 + 432 + 433 shouldRenderGroup : Identifiers -> Bool 434 shouldRenderGroup identifiers = 435 identifiers.group
+1 -1
src/Static/Manifests/manifest.json
··· 2 "name": "Diffuse", 3 "short_name": "Diffuse", 4 "description": "A music player that connects to your cloud/distributed storage", 5 - "version": "3.4.0", 6 "author": "Steven Vandevelde <icid.asset@gmail.com>", 7 "icons": [ 8 {
··· 2 "name": "Diffuse", 3 "short_name": "Diffuse", 4 "description": "A music player that connects to your cloud/distributed storage", 5 + "version": "3.5.0", 6 "author": "Steven Vandevelde <icid.asset@gmail.com>", 7 "icons": [ 8 {