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 1 # Changelog 2 2 3 + ## 3.5.0 4 + 5 + - **Improve audio playback and error handling**. 6 + - Minor improvements/fixes to the artwork downloading process. 7 + 8 + 3 9 ## 3.4.0 4 10 5 11 - **Improved audio metadata parsing**.
+13 -7
Justfile
··· 115 115 --inject:./system/Js/node-shims.js 116 116 117 117 # Main 118 - {{ESBUILD}} ./src/Javascript/index.ts \ 118 + {{ESBUILD}} ./src/Javascript/UI/index.ts \ 119 119 --outdir={{BUILD_DIR}}/js/ui/ \ 120 120 --define:BUILD_TIMESTAMP=$build_timestamp \ 121 121 --splitting ··· 144 144 --inject:./system/Js/node-shims.js 145 145 146 146 # Main 147 - {{ESBUILD}} ./src/Javascript/index.ts \ 147 + {{ESBUILD}} ./src/Javascript/UI/index.ts \ 148 148 --outdir={{BUILD_DIR}}/js/ui/ \ 149 149 --define:BUILD_TIMESTAMP=$build_timestamp \ 150 150 --splitting \ ··· 180 180 ) 181 181 182 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 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 188 194 189 195 190 196 @quality: check-versions
+2 -1
elm.json
··· 49 49 "truqu/elm-base64": "2.0.4", 50 50 "truqu/elm-md5": "1.1.0", 51 51 "wernerdegroot/listzipper": "4.0.0", 52 - "ymtszw/elm-xml-decode": "3.2.1" 52 + "ymtszw/elm-xml-decode": "3.2.2" 53 53 }, 54 54 "indirect": { 55 55 "elm/bytes": "1.0.8", 56 56 "elm/parser": "1.1.0", 57 57 "fredcy/elm-parseint": "2.0.1", 58 + "miniBill/elm-xml-parser": "1.0.1", 58 59 "pzp1997/assoc-list": "1.0.0", 59 60 "zwilias/elm-utf-tools": "2.0.1" 60 61 }
+1 -1
gren.json
··· 9 9 "gren-lang/node": "3.0.1", 10 10 "icidasset/html-gren": "4.1.0", 11 11 "icidasset/markdown-gren": "3.1.0", 12 - "icidasset/shikensu-gren": "5.0.1" 12 + "icidasset/shikensu-gren": "5.1.0" 13 13 }, 14 14 "indirect": { 15 15 "gren-lang/parser": "3.0.1",
+304 -266
package-lock.json
··· 1 1 { 2 2 "name": "diffuse", 3 - "version": "3.4.0", 3 + "version": "3.5.0", 4 4 "lockfileVersion": 2, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "diffuse", 9 - "version": "3.4.0", 9 + "version": "3.5.0", 10 10 "license": "SEE LICENSE IN LICENSE", 11 11 "dependencies": { 12 12 "@oddjs/odd": "^0.37.2", ··· 15 15 "encoding-japanese": "^2.0.0", 16 16 "fast-text-encoding": "^1.0.6", 17 17 "file-saver": "^2.0.2", 18 - "jschardet": "^3.0.0", 19 18 "jszip": "^3.7.1", 20 19 "load-script2": "^2.0.5", 21 20 "localforage": "^1.10.0", 22 21 "lunr": "^2.3.8", 23 - "mediainfo.js": "^0.2.1", 22 + "mediainfo.js": "^0.3.1", 24 23 "music-metadata-browser": "^2.5.10", 25 24 "readable-stream": "^4.5.2", 26 25 "remotestoragejs": "^2.0.0-beta.6", ··· 36 35 "@tauri-apps/plugin-dialog": "^2.0.0-beta.0", 37 36 "@tauri-apps/plugin-fs": "^2.0.0-beta.0", 38 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", 39 42 "@typescript-eslint/eslint-plugin": "^6.21.0", 40 43 "@typescript-eslint/parser": "^6.21.0", 41 44 "assert": "^2.1.0", 42 - "autoprefixer": "^10.4.17", 45 + "autoprefixer": "^10.4.19", 43 46 "buffer": "^6.0.3", 44 47 "elm": "0.19.1-6", 45 48 "elm-format": "^0.8.7", 46 49 "elm-review": "^2.10.3", 47 - "esbuild": "^0.20.0", 50 + "esbuild": "^0.20.2", 48 51 "esbuild-plugin-wasm": "^1.1.0", 49 52 "eslint": "^8.56.0", 50 53 "events": "^3.3.0", ··· 276 279 ] 277 280 }, 278 281 "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 + "version": "0.20.2", 283 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", 284 + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", 282 285 "cpu": [ 283 286 "ppc64" 284 287 ], ··· 292 295 } 293 296 }, 294 297 "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 + "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==", 298 301 "cpu": [ 299 302 "arm" 300 303 ], ··· 308 311 } 309 312 }, 310 313 "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 + "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==", 314 317 "cpu": [ 315 318 "arm64" 316 319 ], ··· 324 327 } 325 328 }, 326 329 "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 + "version": "0.20.2", 331 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", 332 + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", 330 333 "cpu": [ 331 334 "x64" 332 335 ], ··· 340 343 } 341 344 }, 342 345 "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 + "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==", 346 349 "cpu": [ 347 350 "arm64" 348 351 ], ··· 356 359 } 357 360 }, 358 361 "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 + "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==", 362 365 "cpu": [ 363 366 "x64" 364 367 ], ··· 372 375 } 373 376 }, 374 377 "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 + "version": "0.20.2", 379 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", 380 + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", 378 381 "cpu": [ 379 382 "arm64" 380 383 ], ··· 388 391 } 389 392 }, 390 393 "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 + "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==", 394 397 "cpu": [ 395 398 "x64" 396 399 ], ··· 404 407 } 405 408 }, 406 409 "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 + "version": "0.20.2", 411 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", 412 + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", 410 413 "cpu": [ 411 414 "arm" 412 415 ], ··· 420 423 } 421 424 }, 422 425 "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 + "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==", 426 429 "cpu": [ 427 430 "arm64" 428 431 ], ··· 436 439 } 437 440 }, 438 441 "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 + "version": "0.20.2", 443 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", 444 + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", 442 445 "cpu": [ 443 446 "ia32" 444 447 ], ··· 452 455 } 453 456 }, 454 457 "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 + "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==", 458 461 "cpu": [ 459 462 "loong64" 460 463 ], ··· 468 471 } 469 472 }, 470 473 "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 + "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==", 474 477 "cpu": [ 475 478 "mips64el" 476 479 ], ··· 484 487 } 485 488 }, 486 489 "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 + "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==", 490 493 "cpu": [ 491 494 "ppc64" 492 495 ], ··· 500 503 } 501 504 }, 502 505 "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 + "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==", 506 509 "cpu": [ 507 510 "riscv64" 508 511 ], ··· 516 519 } 517 520 }, 518 521 "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 + "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==", 522 525 "cpu": [ 523 526 "s390x" 524 527 ], ··· 532 535 } 533 536 }, 534 537 "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 + "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==", 538 541 "cpu": [ 539 542 "x64" 540 543 ], ··· 548 551 } 549 552 }, 550 553 "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 + "version": "0.20.2", 555 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", 556 + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", 554 557 "cpu": [ 555 558 "x64" 556 559 ], ··· 564 567 } 565 568 }, 566 569 "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 + "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==", 570 573 "cpu": [ 571 574 "x64" 572 575 ], ··· 580 583 } 581 584 }, 582 585 "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 + "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==", 586 589 "cpu": [ 587 590 "x64" 588 591 ], ··· 596 599 } 597 600 }, 598 601 "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 + "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==", 602 605 "cpu": [ 603 606 "arm64" 604 607 ], ··· 612 615 } 613 616 }, 614 617 "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 + "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==", 618 621 "cpu": [ 619 622 "ia32" 620 623 ], ··· 628 631 } 629 632 }, 630 633 "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 + "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==", 634 637 "cpu": [ 635 638 "x64" 636 639 ], ··· 1654 1657 "@types/responselike": "^1.0.0" 1655 1658 } 1656 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 + }, 1657 1672 "node_modules/@types/http-cache-semantics": { 1658 1673 "version": "4.0.1", 1659 1674 "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", ··· 1675 1690 "@types/node": "*" 1676 1691 } 1677 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 + }, 1678 1699 "node_modules/@types/node": { 1679 1700 "version": "18.16.3", 1680 1701 "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.3.tgz", ··· 1693 1714 "version": "7.5.6", 1694 1715 "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", 1695 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==", 1696 1723 "dev": true 1697 1724 }, 1698 1725 "node_modules/@types/tv4": { ··· 2150 2177 } 2151 2178 }, 2152 2179 "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==", 2180 + "version": "10.4.19", 2181 + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", 2182 + "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", 2156 2183 "dev": true, 2157 2184 "funding": [ 2158 2185 { ··· 2169 2196 } 2170 2197 ], 2171 2198 "dependencies": { 2172 - "browserslist": "^4.22.2", 2173 - "caniuse-lite": "^1.0.30001578", 2199 + "browserslist": "^4.23.0", 2200 + "caniuse-lite": "^1.0.30001599", 2174 2201 "fraction.js": "^4.3.7", 2175 2202 "normalize-range": "^0.1.2", 2176 2203 "picocolors": "^1.0.0", ··· 2493 2520 } 2494 2521 }, 2495 2522 "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==", 2523 + "version": "4.23.1", 2524 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", 2525 + "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", 2499 2526 "dev": true, 2500 2527 "funding": [ 2501 2528 { ··· 2512 2539 } 2513 2540 ], 2514 2541 "dependencies": { 2515 - "caniuse-lite": "^1.0.30001580", 2516 - "electron-to-chromium": "^1.4.648", 2542 + "caniuse-lite": "^1.0.30001629", 2543 + "electron-to-chromium": "^1.4.796", 2517 2544 "node-releases": "^2.0.14", 2518 - "update-browserslist-db": "^1.0.13" 2545 + "update-browserslist-db": "^1.0.16" 2519 2546 }, 2520 2547 "bin": { 2521 2548 "browserslist": "cli.js" ··· 2650 2677 } 2651 2678 }, 2652 2679 "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==", 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==", 2656 2683 "dev": true, 2657 2684 "funding": [ 2658 2685 { ··· 3265 3292 "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" 3266 3293 }, 3267 3294 "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==", 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==", 3271 3298 "dev": true 3272 3299 }, 3273 3300 "node_modules/elm": { ··· 3464 3491 } 3465 3492 }, 3466 3493 "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==", 3494 + "version": "0.20.2", 3495 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", 3496 + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", 3470 3497 "dev": true, 3471 3498 "hasInstallScript": true, 3472 3499 "bin": { ··· 3476 3503 "node": ">=12" 3477 3504 }, 3478 3505 "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" 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" 3502 3529 } 3503 3530 }, 3504 3531 "node_modules/esbuild-plugin-wasm": { ··· 5187 5214 "js-yaml": "bin/js-yaml.js" 5188 5215 } 5189 5216 }, 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 5217 "node_modules/json-buffer": { 5199 5218 "version": "3.0.1", 5200 5219 "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", ··· 5549 5568 } 5550 5569 }, 5551 5570 "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==", 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==", 5555 5574 "dependencies": { 5556 5575 "yargs": "^17.7.2" 5557 5576 }, ··· 5559 5578 "mediainfo.js": "dist/esm/cli.js" 5560 5579 }, 5561 5580 "engines": { 5562 - "node": ">=14.16" 5581 + "node": ">=18.0.0" 5563 5582 } 5564 5583 }, 5565 5584 "node_modules/merge-options": { ··· 6252 6271 "dev": true 6253 6272 }, 6254 6273 "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==", 6274 + "version": "1.0.1", 6275 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", 6276 + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", 6258 6277 "dev": true 6259 6278 }, 6260 6279 "node_modules/picomatch": { ··· 7590 7609 } 7591 7610 }, 7592 7611 "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==", 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==", 7596 7615 "dev": true, 7597 7616 "funding": [ 7598 7617 { ··· 7609 7628 } 7610 7629 ], 7611 7630 "dependencies": { 7612 - "escalade": "^3.1.1", 7613 - "picocolors": "^1.0.0" 7631 + "escalade": "^3.1.2", 7632 + "picocolors": "^1.0.1" 7614 7633 }, 7615 7634 "bin": { 7616 7635 "update-browserslist-db": "cli.js" ··· 8053 8072 "optional": true 8054 8073 }, 8055 8074 "@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==", 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==", 8059 8078 "dev": true, 8060 8079 "optional": true 8061 8080 }, 8062 8081 "@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==", 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==", 8066 8085 "dev": true, 8067 8086 "optional": true 8068 8087 }, 8069 8088 "@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==", 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==", 8073 8092 "dev": true, 8074 8093 "optional": true 8075 8094 }, 8076 8095 "@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==", 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==", 8080 8099 "dev": true, 8081 8100 "optional": true 8082 8101 }, 8083 8102 "@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==", 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==", 8087 8106 "dev": true, 8088 8107 "optional": true 8089 8108 }, 8090 8109 "@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==", 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==", 8094 8113 "dev": true, 8095 8114 "optional": true 8096 8115 }, 8097 8116 "@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==", 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==", 8101 8120 "dev": true, 8102 8121 "optional": true 8103 8122 }, 8104 8123 "@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==", 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==", 8108 8127 "dev": true, 8109 8128 "optional": true 8110 8129 }, 8111 8130 "@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==", 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==", 8115 8134 "dev": true, 8116 8135 "optional": true 8117 8136 }, 8118 8137 "@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==", 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==", 8122 8141 "dev": true, 8123 8142 "optional": true 8124 8143 }, 8125 8144 "@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==", 8145 + "version": "0.20.2", 8146 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", 8147 + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", 8129 8148 "dev": true, 8130 8149 "optional": true 8131 8150 }, 8132 8151 "@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==", 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==", 8136 8155 "dev": true, 8137 8156 "optional": true 8138 8157 }, 8139 8158 "@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==", 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==", 8143 8162 "dev": true, 8144 8163 "optional": true 8145 8164 }, 8146 8165 "@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==", 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==", 8150 8169 "dev": true, 8151 8170 "optional": true 8152 8171 }, 8153 8172 "@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==", 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==", 8157 8176 "dev": true, 8158 8177 "optional": true 8159 8178 }, 8160 8179 "@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==", 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==", 8164 8183 "dev": true, 8165 8184 "optional": true 8166 8185 }, 8167 8186 "@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==", 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==", 8171 8190 "dev": true, 8172 8191 "optional": true 8173 8192 }, 8174 8193 "@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==", 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==", 8178 8197 "dev": true, 8179 8198 "optional": true 8180 8199 }, 8181 8200 "@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==", 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==", 8185 8204 "dev": true, 8186 8205 "optional": true 8187 8206 }, 8188 8207 "@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==", 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==", 8192 8211 "dev": true, 8193 8212 "optional": true 8194 8213 }, 8195 8214 "@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==", 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==", 8199 8218 "dev": true, 8200 8219 "optional": true 8201 8220 }, 8202 8221 "@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==", 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==", 8206 8225 "dev": true, 8207 8226 "optional": true 8208 8227 }, 8209 8228 "@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==", 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==", 8213 8232 "dev": true, 8214 8233 "optional": true 8215 8234 }, ··· 8921 8940 "@types/responselike": "^1.0.0" 8922 8941 } 8923 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 + }, 8924 8955 "@types/http-cache-semantics": { 8925 8956 "version": "4.0.1", 8926 8957 "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", ··· 8942 8973 "@types/node": "*" 8943 8974 } 8944 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 + }, 8945 8982 "@types/node": { 8946 8983 "version": "18.16.3", 8947 8984 "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.3.tgz", ··· 8960 8997 "version": "7.5.6", 8961 8998 "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", 8962 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==", 8963 9006 "dev": true 8964 9007 }, 8965 9008 "@types/tv4": { ··· 9261 9304 "dev": true 9262 9305 }, 9263 9306 "autoprefixer": { 9264 - "version": "10.4.17", 9265 - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", 9266 - "integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==", 9307 + "version": "10.4.19", 9308 + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", 9309 + "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", 9267 9310 "dev": true, 9268 9311 "requires": { 9269 - "browserslist": "^4.22.2", 9270 - "caniuse-lite": "^1.0.30001578", 9312 + "browserslist": "^4.23.0", 9313 + "caniuse-lite": "^1.0.30001599", 9271 9314 "fraction.js": "^4.3.7", 9272 9315 "normalize-range": "^0.1.2", 9273 9316 "picocolors": "^1.0.0", ··· 9478 9521 } 9479 9522 }, 9480 9523 "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==", 9524 + "version": "4.23.1", 9525 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", 9526 + "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", 9484 9527 "dev": true, 9485 9528 "requires": { 9486 - "caniuse-lite": "^1.0.30001580", 9487 - "electron-to-chromium": "^1.4.648", 9529 + "caniuse-lite": "^1.0.30001629", 9530 + "electron-to-chromium": "^1.4.796", 9488 9531 "node-releases": "^2.0.14", 9489 - "update-browserslist-db": "^1.0.13" 9532 + "update-browserslist-db": "^1.0.16" 9490 9533 } 9491 9534 }, 9492 9535 "buffer": { ··· 9568 9611 "dev": true 9569 9612 }, 9570 9613 "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==", 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==", 9574 9617 "dev": true 9575 9618 }, 9576 9619 "catering": { ··· 9997 10040 "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" 9998 10041 }, 9999 10042 "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==", 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==", 10003 10046 "dev": true 10004 10047 }, 10005 10048 "elm": { ··· 10147 10190 "dev": true 10148 10191 }, 10149 10192 "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==", 10193 + "version": "0.20.2", 10194 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", 10195 + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", 10153 10196 "dev": true, 10154 10197 "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" 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" 10178 10221 } 10179 10222 }, 10180 10223 "esbuild-plugin-wasm": { ··· 11337 11380 "argparse": "^2.0.1" 11338 11381 } 11339 11382 }, 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 11383 "json-buffer": { 11346 11384 "version": "3.0.1", 11347 11385 "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", ··· 11630 11668 "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" 11631 11669 }, 11632 11670 "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==", 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==", 11636 11674 "requires": { 11637 11675 "yargs": "^17.7.2" 11638 11676 } ··· 12103 12141 "dev": true 12104 12142 }, 12105 12143 "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==", 12144 + "version": "1.0.1", 12145 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", 12146 + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", 12109 12147 "dev": true 12110 12148 }, 12111 12149 "picomatch": { ··· 13030 13068 "dev": true 13031 13069 }, 13032 13070 "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==", 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==", 13036 13074 "dev": true, 13037 13075 "requires": { 13038 - "escalade": "^3.1.1", 13039 - "picocolors": "^1.0.0" 13076 + "escalade": "^3.1.2", 13077 + "picocolors": "^1.0.1" 13040 13078 } 13041 13079 }, 13042 13080 "update-check": {
+8 -5
package.json
··· 1 1 { 2 2 "name": "diffuse", 3 3 "description": "A music player that connects to your cloud/distributed storage", 4 - "version": "3.4.0", 4 + "version": "3.5.0", 5 5 "author": "Steven Vandevelde <icid.asset@gmail.com>", 6 6 "homepage": "https://diffuse.sh", 7 7 "repository": "github:icidasset/diffuse", ··· 12 12 "@tauri-apps/plugin-dialog": "^2.0.0-beta.0", 13 13 "@tauri-apps/plugin-fs": "^2.0.0-beta.0", 14 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", 15 19 "@typescript-eslint/eslint-plugin": "^6.21.0", 16 20 "@typescript-eslint/parser": "^6.21.0", 17 21 "assert": "^2.1.0", 18 - "autoprefixer": "^10.4.17", 22 + "autoprefixer": "^10.4.19", 19 23 "buffer": "^6.0.3", 20 24 "elm": "0.19.1-6", 21 25 "elm-format": "^0.8.7", 22 26 "elm-review": "^2.10.3", 23 - "esbuild": "^0.20.0", 27 + "esbuild": "^0.20.2", 24 28 "esbuild-plugin-wasm": "^1.1.0", 25 29 "eslint": "^8.56.0", 26 30 "events": "^3.3.0", ··· 42 46 "encoding-japanese": "^2.0.0", 43 47 "fast-text-encoding": "^1.0.6", 44 48 "file-saver": "^2.0.2", 45 - "jschardet": "^3.0.0", 46 49 "jszip": "^3.7.1", 47 50 "load-script2": "^2.0.5", 48 51 "localforage": "^1.10.0", 49 52 "lunr": "^2.3.8", 50 - "mediainfo.js": "^0.2.1", 53 + "mediainfo.js": "^0.3.1", 51 54 "music-metadata-browser": "^2.5.10", 52 55 "readable-stream": "^4.5.2", 53 56 "remotestoragejs": "^2.0.0-beta.6",
+1 -1
src-tauri/Cargo.toml
··· 1 1 [package] 2 2 name = "diffuse" 3 - version = "3.4.0" 3 + version = "3.5.0" 4 4 description = "A music player that connects to your cloud/distributed storage" 5 5 authors = ["Steven Vandevelde"] 6 6 edition = "2021"
+1 -1
src-tauri/tauri.conf.json
··· 1 1 { 2 2 "productName": "Diffuse", 3 - "version": "3.4.0", 3 + "version": "3.5.0", 4 4 "identifier": "com.icidasset.diffuse", 5 5 "build": { 6 6 "beforeDevCommand": "",
+12 -3
src/Applications/Brain/Other/State.elm
··· 3 3 import Alien 4 4 import Brain.Common.State as Common 5 5 import Brain.Ports as Ports 6 + import Brain.Task.Ports 6 7 import Brain.Types exposing (..) 7 8 import Dict 8 9 import Json.Decode as Json ··· 56 57 Return.singleton { model | currentTime = time } 57 58 58 59 60 + {-| Save alien data to cache. 61 + -} 59 62 toCache : Json.Value -> Manager 60 63 toCache data = 61 64 case Json.decodeValue Alien.hostDecoder data of 62 65 Ok alienEvent -> 63 - alienEvent 64 - |> Ports.toCache 65 - |> Return.communicate 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" 66 75 67 76 Err err -> 68 77 err
-9
src/Applications/Brain/Ports.elm
··· 12 12 port downloadTracks : Json.Value -> Cmd msg 13 13 14 14 15 - port removeCache : Alien.Event -> Cmd msg 16 - 17 - 18 15 port removeTracksFromCache : Json.Value -> Cmd msg 19 - 20 - 21 - port requestCache : Alien.Event -> Cmd msg 22 16 23 17 24 18 port requestSearch : String -> Cmd msg ··· 31 25 32 26 33 27 port syncTags : ContextForTagsSync -> Cmd msg 34 - 35 - 36 - port toCache : Alien.Event -> Cmd msg 37 28 38 29 39 30 port toUI : Alien.Event -> Cmd msg
-2
src/Applications/Brain/Tracks/State.elm
··· 119 119 makeTrackUrl model.currentTime trackPath maybeSource 120 120 in 121 121 dict 122 - |> Dict.remove "trackPath" 123 - |> Dict.remove "trackSourceId" 124 122 |> Dict.insert "trackGetUrl" (mkTrackUrl Get) 125 123 |> Dict.insert "trackHeadUrl" (mkTrackUrl Head) 126 124 |> Json.Encode.dict identity Json.Encode.string
+25 -18
src/Applications/Brain/User/State.elm
··· 99 99 |> User.decodeHypaethralData 100 100 |> Result.map 101 101 (\hypaethralData -> 102 - ( hypaethralJson 103 - , hypaethralData 104 - ) 102 + Commence 103 + maybeMethod 104 + initialUrl 105 + ( hypaethralJson 106 + , hypaethralData 107 + ) 105 108 ) 106 - |> Result.withDefault 107 - ( User.encodeHypaethralData User.emptyHypaethralData 108 - , User.emptyHypaethralData 109 - ) 110 - |> Commence maybeMethod initialUrl 111 - |> UserMsg 109 + |> Result.mapError Decode.errorToString 110 + |> Common.reportErrorToUI UserMsg 112 111 ) 113 112 114 113 ··· 345 344 unsetSyncMethod model = 346 345 -- 💀 347 346 -- Unset & remove stored method. 348 - [ Ports.removeCache (Alien.trigger Alien.SyncMethod) 349 - , Ports.removeCache (Alien.trigger Alien.SecretKey) 347 + [ Common.attemptPortTask (always Brain.Bypass) (Brain.Task.Ports.removeCache Alien.SyncMethod) 348 + , Common.attemptPortTask (always Brain.Bypass) (Brain.Task.Ports.removeCache Alien.SecretKey) 350 349 351 350 -- 352 351 , case model.userSyncMethod of ··· 380 379 381 380 retrieveEnclosedData : Manager 382 381 retrieveEnclosedData = 383 - Alien.EnclosedData 384 - |> Alien.trigger 385 - |> Ports.requestCache 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 + ) 386 393 |> Return.communicate 387 394 388 395 389 396 saveEnclosedData : Json.Value -> Manager 390 397 saveEnclosedData json = 391 398 json 392 - |> Alien.broadcast Alien.EnclosedData 393 - |> Ports.toCache 399 + |> Brain.Task.Ports.toCache Alien.EnclosedData 400 + |> Common.attemptPortTask (always Brain.Bypass) 394 401 |> Return.communicate 395 402 396 403 ··· 668 675 saveMethod method model = 669 676 method 670 677 |> encodeMethod 671 - |> Alien.broadcast Alien.SyncMethod 672 - |> Ports.toCache 678 + |> Brain.Task.Ports.toCache Alien.SyncMethod 679 + |> Common.attemptPortTask (always Brain.Bypass) 673 680 |> return { model | userSyncMethod = Just method } 674 681 675 682
+47 -28
src/Applications/UI.elm
··· 117 117 ----------------------------------------- 118 118 -- Audio 119 119 ----------------------------------------- 120 - , audioDuration = 0 121 - , audioHasStalled = False 122 - , audioIsLoading = False 123 - , audioIsPlaying = False 124 - , audioPosition = 0 120 + , audioElements = [] 121 + , nowPlaying = Nothing 125 122 , progress = Dict.empty 126 123 , rememberProgress = True 127 124 ··· 136 133 ----------------------------------------- 137 134 -- Debouncing 138 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 139 147 , resizeDebouncer = 140 148 0.25 141 149 |> Debouncer.fromSeconds ··· 174 182 -- Queue 175 183 ----------------------------------------- 176 184 , dontPlay = [] 177 - , nowPlaying = Nothing 178 185 , playedPreviously = [] 179 186 , playingNext = [] 180 187 , selectedQueueItem = Nothing ··· 273 280 ----------------------------------------- 274 281 -- Audio 275 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 + 276 307 NoteProgress a -> 277 308 Audio.noteProgress a 309 + 310 + NoteProgressDebounce a -> 311 + Audio.noteProgressDebounce update a 278 312 279 313 Pause -> 280 314 Audio.pause ··· 284 318 285 319 Seek a -> 286 320 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 321 303 322 Stop -> 304 323 Audio.stop ··· 562 581 ----------------------------------------- 563 582 -- Audio 564 583 ----------------------------------------- 565 - , Ports.noteProgress NoteProgress 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 566 591 , Ports.requestPause (always Pause) 567 592 , Ports.requestPlay (always Play) 568 593 , Ports.requestPlayPause (always TogglePlay) 569 594 , 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 595 576 596 ----------------------------------------- 577 597 -- Backdrop ··· 591 611 ----------------------------------------- 592 612 -- Queue 593 613 ----------------------------------------- 594 - , Ports.activeQueueItemEnded (QueueMsg << always Queue.Shift) 595 614 , Ports.requestNext (\_ -> QueueMsg Queue.Shift) 596 615 , Ports.requestPrevious (\_ -> QueueMsg Queue.Rewind) 597 616
+6 -6
src/Applications/UI/Adjunct.elm
··· 63 63 [ Keyboard.Character "]", Keyboard.Control ] -> 64 64 Queue.shift m 65 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 - 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 72 -- Meta key 73 73 -- 74 74 [ Keyboard.Character "K", Keyboard.Meta ] ->
+311 -61
src/Applications/UI/Audio/State.elm
··· 1 1 module UI.Audio.State exposing (..) 2 2 3 + import Base64 4 + import Common exposing (boolToString) 5 + import Debouncer.Basic as Debouncer 3 6 import Dict 4 7 import LastFm 5 - import Maybe.Extra as Maybe 8 + import List.Extra as List 9 + import MediaSession 6 10 import Return exposing (return) 7 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) 8 16 import UI.Ports as Ports 9 17 import UI.Queue.State as Queue 10 - import UI.Types as UI exposing (Manager) 18 + import UI.Types as UI exposing (Manager, Msg(..)) 11 19 import UI.User.State.Export as User 12 20 13 21 14 22 15 - -- 📣 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 16 60 61 + _ -> 62 + False 17 63 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 64 + metadata = 65 + { album = track.tags.album 66 + , artist = track.tags.artist 67 + , title = track.tags.title 24 68 25 - else if progress > 0.975 then 26 - Dict.remove trackId model.progress 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) 27 118 28 119 else 29 - Dict.insert trackId progress model.progress 30 - in 31 - if model.rememberProgress then 32 - User.saveProgress { model | progress = updatedProgressTable } 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 + ) 33 147 34 - else 35 - Return.singleton model 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 36 214 37 215 38 216 pause : Manager 39 217 pause model = 40 - return model (Ports.pause ()) 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 41 229 42 230 43 231 playPause : Manager 44 232 playPause model = 45 - if Maybe.isNothing model.nowPlaying then 46 - Queue.shift model 233 + case model.nowPlaying of 234 + Just { isPlaying } -> 235 + if isPlaying then 236 + pause model 47 237 48 - else if model.audioIsPlaying then 49 - communicate (Ports.pause ()) model 238 + else 239 + play model 50 240 51 - else 52 - communicate (Ports.play ()) model 241 + Nothing -> 242 + play model 53 243 54 244 55 245 play : Manager 56 246 play model = 57 - if Maybe.isNothing model.nowPlaying then 58 - Queue.shift 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 59 256 60 - else 61 - return model (Ports.play ()) 257 + Nothing -> 258 + Queue.shift model 62 259 63 260 64 - seek : Float -> Manager 65 - seek percentage = 66 - Return.communicate (Ports.seek percentage) 261 + seek : { trackId : String, progress : Float } -> Manager 262 + seek { trackId, progress } = 263 + { percentage = progress, trackId = trackId } 264 + |> Ports.seek 265 + |> Return.communicate 67 266 68 267 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 - } 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 + ) 80 282 81 - Nothing -> 82 - Cmd.none 83 - in 84 - return { model | audioDuration = duration } cmd 85 283 86 284 87 - setHasStalled : Bool -> Manager 88 - setHasStalled hasStalled model = 89 - Return.singleton { model | audioHasStalled = hasStalled } 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 90 297 298 + else 299 + Dict.insert trackId progress model.progress 300 + in 301 + if model.rememberProgress then 302 + User.saveProgress { model | progress = updatedProgressTable } 91 303 92 - setIsLoading : Bool -> Manager 93 - setIsLoading isLoading model = 94 - Return.singleton { model | audioIsLoading = isLoading } 304 + else 305 + Return.singleton model 95 306 96 307 97 - setIsPlaying : Bool -> Manager 98 - setIsPlaying isPlaying model = 99 - Return.singleton { model | audioIsPlaying = isPlaying } 308 + noteProgressDebounce : DebounceManager 309 + noteProgressDebounce = 310 + Common.debounce 311 + .progressDebouncer 312 + (\d m -> { m | progressDebouncer = d }) 313 + UI.NoteProgressDebounce 100 314 101 315 102 - setPosition : Float -> Manager 103 - setPosition position model = 104 - Return.singleton { model | audioPosition = position } 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 105 329 106 330 107 - stop : Manager 108 - stop = 109 - communicate (Ports.pause ()) 331 + preloadDebounce : DebounceManager 332 + preloadDebounce = 333 + Common.debounce 334 + .preloadDebouncer 335 + (\d m -> { m | preloadDebouncer = d }) 336 + UI.AudioPreloadDebounce 110 337 111 338 112 339 toggleRememberProgress : Manager 113 340 toggleRememberProgress model = 114 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 70 nowPlayingCommands : UI.Model -> List (Item UI.Msg) 71 71 nowPlayingCommands model = 72 72 case model.nowPlaying of 73 - Just queueItem -> 73 + Just { item } -> 74 74 let 75 75 ( queueItemIdentifiers, _ ) = 76 - queueItem.identifiedTrack 76 + item.identifiedTrack 77 77 78 78 identifiedTrack = 79 79 model.tracks.harvested 80 80 |> List.getAt queueItemIdentifiers.indexInList 81 - |> Maybe.withDefault queueItem.identifiedTrack 81 + |> Maybe.withDefault item.identifiedTrack 82 82 83 83 ( identifiers, track ) = 84 84 identifiedTrack ··· 116 116 117 117 118 118 playbackCommands model = 119 - [ if model.audioIsPlaying then 119 + [ if Maybe.map .isPlaying model.nowPlaying == Just True then 120 120 { icon = Just (Icons.pause 16) 121 121 , title = "Pause" 122 122 , value = Command UI.TogglePlay
+80 -36
src/Applications/UI/Console.elm
··· 8 8 import Json.Decode as Decode 9 9 import Material.Icons.Round as Icons 10 10 import Material.Icons.Types exposing (Coloring(..)) 11 - import Queue 11 + import Maybe.Extra as Maybe 12 + import UI.Audio.Types exposing (AudioLoadingState(..), NowPlaying, nowPlayingIdentifiedTrack) 12 13 import UI.Queue.Types as Queue 13 14 import UI.Tracks.Types as Tracks 14 15 import UI.Types exposing (Msg(..)) ··· 19 20 20 21 21 22 view : 22 - Maybe Queue.Item 23 + Maybe NowPlaying 23 24 -> Bool 24 25 -> Bool 25 - -> { stalled : Bool, loading : Bool, playing : Bool } 26 - -> ( Float, Float ) 27 26 -> Html Msg 28 - view activeQueueItem repeat shuffle { stalled, loading, playing } ( position, duration ) = 27 + view nowPlaying repeat shuffle = 29 28 chunk 30 29 [ "antialiased" 31 30 , "mt-1" ··· 45 44 , "py-4" 46 45 , "text-white" 47 46 ] 48 - [ if stalled then 49 - text "Audio connection got interrupted, trying to reconnect ..." 47 + [ case Maybe.map .loadingState nowPlaying of 48 + Nothing -> 49 + text "Diffuse" 50 50 51 - else if loading then 52 - text "Loading track ..." 51 + Just Loading -> 52 + text "Loading track ..." 53 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) 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 + ] 66 70 67 - Nothing -> 68 - text tags.title 69 - ] 71 + Nothing -> 72 + text "Diffuse" 70 73 71 - Nothing -> 72 - text "Diffuse" 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." 73 88 ] 74 89 75 90 ----------------------------------------- 76 91 -- Progress Bar 77 92 ----------------------------------------- 78 93 , let 94 + maybeDuration = 95 + Maybe.andThen .duration nowPlaying 96 + 97 + maybePosition = 98 + Maybe.map .playbackPosition nowPlaying 99 + 79 100 progress = 80 - if duration <= 0 then 81 - 0 101 + case ( maybeDuration, maybePosition ) of 102 + ( Just duration, Just position ) -> 103 + if duration <= 0 then 104 + 0 82 105 83 - else 84 - (position / duration) 85 - |> (*) 100 86 - |> min 100 87 - |> max 0 106 + else 107 + (position / duration) 108 + |> (*) 100 109 + |> min 100 110 + |> max 0 111 + 112 + _ -> 113 + 0 88 114 in 89 115 brick 90 - [ on "click" (clickLocationDecoder Seek) ] 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 + ) 91 131 [ "cursor-pointer" 92 132 , "py-1" 93 133 ] ··· 137 177 (QueueMsg Queue.Rewind) 138 178 139 179 -- 140 - , button 180 + , let 181 + isPlaying = 182 + Maybe.unwrap False .isPlaying nowPlaying 183 + in 184 + button 141 185 "" 142 - (largeLight playing) 186 + (largeLight isPlaying) 143 187 play 144 188 TogglePlay 145 189
+1
src/Applications/UI/Notifications.elm
··· 95 95 96 96 -- 97 97 , "absolute" 98 + , "break-all" 98 99 , "bottom-0" 99 100 , "flex" 100 101 , "flex-col"
+31 -4
src/Applications/UI/Other/State.elm
··· 2 2 3 3 import Alien 4 4 import Common exposing (ServiceWorkerStatus(..)) 5 + import Dict 5 6 import Notifications 6 7 import Return exposing (return) 7 8 import Time ··· 41 42 42 43 setIsOnline : Bool -> Manager 43 44 setIsOnline bool model = 44 - if bool then 45 - syncHypaethralData { model | isOnline = bool } 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 46 59 47 - else 48 - Return.singleton { model | isOnline = bool } 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 + ) 49 76 50 77 51 78 setCurrentTime : Time.Posix -> Manager
+54 -23
src/Applications/UI/Ports.elm
··· 3 3 import Alien 4 4 import Json.Encode as Json 5 5 import Queue 6 + import UI.Audio.Types as Audio 6 7 7 8 8 9 ··· 33 34 port openUrlOnNewPage : String -> Cmd msg 34 35 35 36 36 - port pause : () -> Cmd msg 37 + port pause : { trackId : String } -> Cmd msg 38 + 39 + 40 + port pauseScrobbleTimer : () -> Cmd msg 37 41 38 42 39 43 port pickAverageBackgroundColor : String -> Cmd msg 40 44 41 45 42 - port play : () -> Cmd msg 46 + port play : { trackId : String, volume : Float } -> Cmd msg 47 + 48 + 49 + port reloadAudioNodeIfNeeded : { play : Bool, progress : Maybe Float, trackId : String } -> Cmd msg 43 50 44 51 45 52 port preloadAudio : Queue.EngineItem -> Cmd msg ··· 48 55 port reloadApp : () -> Cmd msg 49 56 50 57 51 - port seek : Float -> Cmd msg 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 52 80 53 81 54 - port setRepeat : Bool -> Cmd msg 82 + port startScrobbleTimer : () -> Cmd msg 55 83 56 84 57 85 port toBrain : Alien.Event -> Cmd msg ··· 61 89 -- 📰 62 90 63 91 64 - port activeQueueItemEnded : (() -> msg) -> Sub msg 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 65 111 66 112 67 113 port collectedFissionCapabilities : (() -> msg) -> Sub msg ··· 88 134 port installingNewServiceWorker : (() -> msg) -> Sub msg 89 135 90 136 91 - port noteProgress : ({ trackId : String, progress : Float } -> msg) -> Sub msg 92 - 93 - 94 137 port refreshedAccessToken : (Json.Value -> msg) -> Sub msg 95 138 96 139 97 140 port preferredColorSchemaChanged : ({ dark : Bool } -> msg) -> Sub msg 141 + 142 + 143 + port receiveTask : (Json.Value -> msg) -> Sub msg 98 144 99 145 100 146 port requestNext : (() -> msg) -> Sub msg ··· 116 162 117 163 118 164 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 165 135 166 136 167 port setAverageBackgroundColor : ({ r : Int, g : Int, b : Int } -> msg) -> Sub msg
+91 -14
src/Applications/UI/Queue/State.elm
··· 1 1 module UI.Queue.State exposing (..) 2 2 3 3 import Coordinates 4 + import Debouncer.Basic as Debouncer 4 5 import Dict 5 6 import Html.Events.Extra.Mouse as Mouse 6 7 import List.Extra as List 7 8 import Notifications 8 9 import Queue exposing (..) 9 - import Return exposing (andThen, return) 10 + import Return exposing (andThen) 11 + import Return.Ext as Return 10 12 import Tracks exposing (..) 13 + import UI.Audio.Types exposing (AudioLoadingState(..)) 11 14 import UI.Common.State as Common 12 15 import UI.Ports as Ports 13 16 import UI.Queue.ContextMenu as Queue ··· 26 29 case msg of 27 30 Clear -> 28 31 clear 32 + 33 + PreloadNext -> 34 + preloadNext 29 35 30 36 Reset -> 31 37 reset ··· 85 91 86 92 changeActiveItem : Maybe Item -> Manager 87 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 88 108 maybeItem 89 - |> Maybe.map 90 - (.identifiedTrack >> Tuple.second) 109 + |> Maybe.map (.identifiedTrack >> Tuple.second) 91 110 |> Maybe.map 92 111 (Queue.makeEngineItem 112 + False 93 113 model.currentTime 94 114 model.sources 95 115 model.cachedTracks ··· 100 120 Dict.empty 101 121 ) 102 122 ) 103 - |> Ports.activeQueueItemChanged 104 - |> return { model | nowPlaying = maybeItem } 123 + |> Maybe.map insertTrack 124 + |> Maybe.withDefault Return.singleton 125 + |> (\fn -> fn { model | nowPlaying = maybeNowPlaying }) 105 126 |> andThen fill 106 127 107 128 ··· 145 166 else 146 167 let 147 168 fillState = 148 - { activeItem = m.nowPlaying 169 + { activeItem = Maybe.map .item m.nowPlaying 149 170 , future = m.playingNext 150 171 , ignored = m.dontPlay 151 172 , past = m.playedPreviously ··· 158 179 else 159 180 { m | playingNext = Fill.ordered timestamp nonMissingTracks fillState } 160 181 ) 161 - |> preloadNext 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 + ) 162 239 163 240 164 241 preloadNext : Manager ··· 169 246 |> .identifiedTrack 170 247 |> Tuple.second 171 248 |> Queue.makeEngineItem 249 + True 172 250 model.currentTime 173 251 model.sources 174 252 model.cachedTracks ··· 178 256 else 179 257 Dict.empty 180 258 ) 181 - |> Ports.preloadAudio 182 - |> return model 259 + |> (\engineItem -> 260 + insertTrack engineItem model 261 + ) 183 262 184 263 Nothing -> 185 264 Return.singleton model ··· 192 271 { model 193 272 | playingNext = 194 273 model.nowPlaying 195 - |> Maybe.map (\item -> item :: model.playingNext) 274 + |> Maybe.map (\{ item } -> item :: model.playingNext) 196 275 |> Maybe.withDefault model.playingNext 197 276 , playedPreviously = 198 277 model.playedPreviously ··· 226 305 |> List.drop 1 227 306 , playedPreviously = 228 307 model.nowPlaying 229 - |> Maybe.map List.singleton 308 + |> Maybe.map (.item >> List.singleton) 230 309 |> Maybe.map (List.append model.playedPreviously) 231 310 |> Maybe.withDefault model.playedPreviously 232 311 } ··· 273 352 274 353 toggleRepeat : Manager 275 354 toggleRepeat model = 276 - { model | repeat = not model.repeat } 277 - |> saveEnclosedUserData 278 - |> Return.effect_ (.repeat >> Ports.setRepeat) 355 + saveEnclosedUserData { model | repeat = not model.repeat } 279 356 280 357 281 358 toggleShuffle : Manager
+1
src/Applications/UI/Queue/Types.elm
··· 11 11 12 12 type Msg 13 13 = Clear 14 + | PreloadNext 14 15 | Reset 15 16 | Rewind 16 17 | Select Item
+1 -1
src/Applications/UI/Services/State.elm
··· 56 56 57 57 scrobble : { duration : Int, timestamp : Int, trackId : String } -> Manager 58 58 scrobble { duration, timestamp, trackId } model = 59 - case Maybe.map .identifiedTrack model.nowPlaying of 59 + case Maybe.map (.item >> .identifiedTrack) model.nowPlaying of 60 60 Just ( _, track ) -> 61 61 if trackId == track.id then 62 62 ( model
+3 -4
src/Applications/UI/Tracks/Scene/Covers.elm
··· 15 15 import Material.Icons.Round as Icons 16 16 import Material.Icons.Types exposing (Coloring(..)) 17 17 import Maybe.Extra as Maybe 18 - import Queue 19 18 import Task 20 19 import Tracks exposing (..) 21 20 import UI.Tracks.Scene as Scene ··· 36 35 , favouritesOnly : Bool 37 36 , infiniteList : InfiniteList.Model 38 37 , isVisible : Bool 39 - , nowPlaying : Maybe Queue.Item 38 + , nowPlaying : Maybe IdentifiedTrack 40 39 , selectedCover : Maybe Cover 41 40 , selectedTrackIndexes : List Int 42 41 , sortBy : SortBy ··· 50 49 { cachedCovers : Maybe (Dict String String) 51 50 , columns : Int 52 51 , containerWidth : Int 53 - , nowPlaying : Maybe Queue.Item 52 + , nowPlaying : Maybe IdentifiedTrack 54 53 , sortBy : SortBy 55 54 } 56 55 ··· 664 663 coverView { clickable, horizontal } { cachedCovers, nowPlaying } cover = 665 664 let 666 665 nowPlayingId = 667 - Maybe.unwrap "" (.identifiedTrack >> Tuple.second >> .id) nowPlaying 666 + Maybe.unwrap "" (Tuple.second >> .id) nowPlaying 668 667 669 668 missingTracks = 670 669 List.any
+6 -7
src/Applications/UI/Tracks/Scene/List.elm
··· 16 16 import Material.Icons.Round as Icons 17 17 import Material.Icons.Types exposing (Coloring(..)) 18 18 import Maybe.Extra as Maybe 19 - import Queue 20 19 import Task 21 20 import Tracks exposing (..) 22 21 import UI.DnD as DnD ··· 48 47 } 49 48 50 49 51 - view : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe Queue.Item -> Maybe String -> SortBy -> SortDirection -> List Int -> Maybe (DnD.Model Int) -> Html Msg 50 + view : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe IdentifiedTrack -> Maybe String -> SortBy -> SortDirection -> List Int -> Maybe (DnD.Model Int) -> Html Msg 52 51 view deps harvest infiniteList favouritesOnly nowPlaying searchTerm sortBy sortDirection selectedTrackIndexes maybeDnD = 53 52 brick 54 53 (tabindex (ifThenElse deps.isVisible 0 -1) :: viewAttributes) ··· 261 260 -- INFINITE LIST 262 261 263 262 264 - infiniteListView : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe String -> ( Maybe Queue.Item, List Int ) -> Maybe (DnD.Model Int) -> Html Msg 263 + infiniteListView : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe String -> ( Maybe IdentifiedTrack, List Int ) -> Maybe (DnD.Model Int) -> Html Msg 265 264 infiniteListView deps harvest infiniteList favouritesOnly searchTerm ( nowPlaying, selectedTrackIndexes ) maybeDnD = 266 265 let 267 266 derivedColors = ··· 364 363 defaultItemView : 365 364 { derivedColors : DerivedColors 366 365 , favouritesOnly : Bool 367 - , nowPlaying : Maybe Queue.Item 366 + , nowPlaying : Maybe IdentifiedTrack 368 367 , roundedCorners : Bool 369 368 , selectedTrackIndexes : List Int 370 369 , showAlbum : Bool ··· 394 393 395 394 rowIdentifiers = 396 395 { isMissing = identifiers.isMissing 397 - , isNowPlaying = Maybe.unwrap False (.identifiedTrack >> isNowPlaying identifiedTrack) nowPlaying 396 + , isNowPlaying = Maybe.unwrap False (isNowPlaying identifiedTrack) nowPlaying 398 397 , isSelected = isSelected 399 398 } 400 399 ··· 480 479 ] 481 480 482 481 483 - playlistItemView : Bool -> Maybe Queue.Item -> Maybe String -> List Int -> DnD.Model Int -> Bool -> Bool -> DerivedColors -> Int -> Int -> IdentifiedTrack -> Html Msg 482 + playlistItemView : Bool -> Maybe IdentifiedTrack -> Maybe String -> List Int -> DnD.Model Int -> Bool -> Bool -> DerivedColors -> Int -> Int -> IdentifiedTrack -> Html Msg 484 483 playlistItemView favouritesOnly nowPlaying _ selectedTrackIndexes dnd showAlbum darkMode derivedColors _ idx identifiedTrack = 485 484 let 486 485 ( identifiers, track ) = ··· 502 501 503 502 rowIdentifiers = 504 503 { isMissing = identifiers.isMissing 505 - , isNowPlaying = Maybe.unwrap False (.identifiedTrack >> isNowPlaying identifiedTrack) nowPlaying 504 + , isNowPlaying = Maybe.unwrap False (isNowPlaying identifiedTrack) nowPlaying 506 505 , isSelected = isSelected 507 506 } 508 507
+44 -10
src/Applications/UI/Tracks/State.elm
··· 1 1 module UI.Tracks.State exposing (..) 2 2 3 3 import Alien 4 + import Base64 4 5 import Common exposing (..) 5 6 import ContextMenu 6 7 import Coordinates exposing (Coordinates) ··· 362 363 let 363 364 cachedCovers = 364 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 365 376 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) 377 + decodedValue 378 + |> Result.map (\( _, key, url ) -> Dict.insert key url cachedCovers) 374 379 |> Result.map (\dict -> { model | cachedCovers = Just dict }) 375 380 |> Result.withDefault model 376 - |> Return.singleton 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 + ) 377 411 378 412 379 413 groupBy : Tracks.Grouping -> Manager ··· 727 761 scrollToNowPlaying model = 728 762 model.nowPlaying 729 763 |> Maybe.map 730 - (.identifiedTrack >> Tuple.second >> .id) 764 + (.item >> .identifiedTrack >> Tuple.second >> .id) 731 765 |> Maybe.andThen 732 766 (\id -> 733 767 List.find
+3 -2
src/Applications/UI/Tracks/View.elm
··· 15 15 import Maybe.Extra as Maybe 16 16 import Playlists exposing (Playlist) 17 17 import Tracks exposing (..) 18 + import UI.Audio.Types exposing (nowPlayingIdentifiedTrack) 18 19 import UI.Kit 19 20 import UI.Navigation exposing (..) 20 21 import UI.Page as Page ··· 95 96 , favouritesOnly = model.favouritesOnly 96 97 , infiniteList = model.infiniteList 97 98 , isVisible = isOnIndexPage 98 - , nowPlaying = model.nowPlaying 99 + , nowPlaying = Maybe.map nowPlayingIdentifiedTrack model.nowPlaying 99 100 , selectedCover = model.selectedCover 100 101 , selectedTrackIndexes = model.selectedTrackIndexes 101 102 , sortBy = model.sortBy ··· 126 127 model.tracks.harvested 127 128 model.infiniteList 128 129 model.favouritesOnly 129 - model.nowPlaying 130 + (Maybe.map nowPlayingIdentifiedTrack model.nowPlaying) 130 131 model.searchTerm 131 132 model.sortBy 132 133 model.sortDirection
+15 -12
src/Applications/UI/Types.elm
··· 26 26 import Sources exposing (Source) 27 27 import Time 28 28 import Tracks exposing (..) 29 + import UI.Audio.Types exposing (DurationChangeEvent, ErrorAudioEvent, GenericAudioEvent, NowPlaying, PlaybackStateEvent, TimeUpdatedEvent) 29 30 import UI.DnD as DnD 30 31 import UI.Page exposing (Page) 31 32 import UI.Queue.Types as Queue ··· 83 84 ----------------------------------------- 84 85 -- Audio 85 86 ----------------------------------------- 86 - , audioDuration : Float 87 - , audioHasStalled : Bool 88 - , audioIsLoading : Bool 89 - , audioIsPlaying : Bool 90 - , audioPosition : Float 87 + , audioElements : List Queue.EngineItem 88 + , nowPlaying : Maybe NowPlaying 91 89 , progress : Dict String Float 92 90 , rememberProgress : Bool 93 91 ··· 102 100 ----------------------------------------- 103 101 -- Debouncing 104 102 ----------------------------------------- 103 + , preloadDebouncer : Debouncer Msg Msg 104 + , progressDebouncer : Debouncer Msg Msg 105 105 , resizeDebouncer : Debouncer Msg Msg 106 106 , searchDebouncer : Debouncer Msg Msg 107 107 ··· 132 132 -- Queue 133 133 ----------------------------------------- 134 134 , dontPlay : List Queue.Item 135 - , nowPlaying : Maybe Queue.Item 136 135 , playedPreviously : List Queue.Item 137 136 , playingNext : List Queue.Item 138 137 , selectedQueueItem : Maybe Queue.Item ··· 199 198 ----------------------------------------- 200 199 -- Audio 201 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 202 209 | NoteProgress { trackId : String, progress : Float } 210 + | NoteProgressDebounce (Debouncer.Msg Msg) 203 211 | Pause 204 212 | Play 205 - | Seek Float 206 - | SetAudioDuration Float 207 - | SetAudioHasStalled Bool 208 - | SetAudioIsLoading Bool 209 - | SetAudioIsPlaying Bool 210 - | SetAudioPosition Float 213 + | Seek { trackId : String, progress : Float } 211 214 | Stop 212 215 | TogglePlay 213 216 | ToggleRememberProgress
+1 -5
src/Applications/UI/User/State/Import.elm
··· 18 18 import UI.Equalizer.State as Equalizer 19 19 import UI.Page as Page 20 20 import UI.Playlists.Directory 21 - import UI.Ports as Ports 22 21 import UI.Sources.State as Sources 23 22 import UI.Tracks.State as Tracks 24 23 import UI.Types as UI exposing (..) ··· 223 222 , sortDirection = data.sortDirection 224 223 } 225 224 -- 226 - , Cmd.batch 227 - [ Equalizer.adjustAllKnobs newEqualizerSettings 228 - , Ports.setRepeat data.repeat 229 - ] 225 + , Equalizer.adjustAllKnobs newEqualizerSettings 230 226 ) 231 227 232 228 Err err ->
-7
src/Applications/UI/View.elm
··· 193 193 model.nowPlaying 194 194 model.repeat 195 195 model.shuffle 196 - { stalled = model.audioHasStalled 197 - , loading = model.audioIsLoading 198 - , playing = model.audioIsPlaying 199 - } 200 - ( model.audioPosition 201 - , model.audioDuration 202 - ) 203 196 ] 204 197 205 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 2 // Album Covers 3 3 // (◕‿◕✿) 4 4 5 + import * as Uint8arrays from "uint8arrays" 6 + 7 + import * as processing from "./processing" 8 + import { type App } from "./elm/types" 5 9 import { transformUrl } from "../urls" 6 - import * as processing from "../processing" 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 + // ㊙️ 7 106 8 107 9 108 const REJECT = () => Promise.reject("No artwork found") 10 109 11 110 12 - export function find(prep, app) { 13 - return findUsingTags(prep, app) 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) 14 121 .then(a => a ? a : findUsingMusicBrainz(prep)) 15 122 .then(a => a ? a : findUsingLastFm(prep)) 16 123 .then(a => a ? a : REJECT()) 17 124 .then(a => a.type.startsWith("image/") ? a : REJECT()) 18 - } 19 - 20 - 21 - function decodeCacheKey(cacheKey) { 22 - return decodeURIComponent(escape(atob(cacheKey))) 23 125 } 24 126 25 127 ··· 27 129 // 1. TAGS 28 130 29 131 30 - async function findUsingTags(prep, app) { 132 + async function findUsingTags(prep: CoverPrepWithUrls) { 31 133 return Promise.all( 32 134 [ 33 135 transformUrl(prep.trackHeadUrl, app), ··· 53 155 // 2. MUSIC BRAINZ 54 156 55 157 56 - function findUsingMusicBrainz(prep) { 158 + function findUsingMusicBrainz(prep: CoverPrepWithUrls) { 159 + if (!navigator.onLine) return null 160 + 57 161 const parts = decodeCacheKey(prep.cacheKey).split(" --- ") 58 162 const artist = parts[ 0 ] 59 163 const album = parts[ 1 ] || parts[ 0 ] ··· 64 168 return fetch(`https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`) 65 169 .then(r => r.json()) 66 170 .then(r => musicBrainzCover(r.releases)) 67 - .catch(_ => REJECT()) 68 171 } 69 172 70 173 ··· 90 193 // 3. LAST FM 91 194 92 195 93 - function findUsingLastFm(prep) { 94 - const query = decodeCacheKey(prep.cacheKey).replace(" --- ", " ") 196 + function findUsingLastFm(prep: CoverPrepWithUrls) { 197 + if (!navigator.onLine) return null 198 + 199 + const query = encodeURIComponent( 200 + decodeCacheKey(prep.cacheKey).replace(" --- ", " ") 201 + ) 95 202 96 203 return fetch(`https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`) 97 204 .then(r => r.json()) 98 205 .then(r => lastFmCover(r.results.albummatches.album)) 99 - .catch(_ => REJECT()) 100 206 } 101 207 102 208
+13 -11
src/Javascript/Brain/common.ts
··· 13 13 // 🔱 14 14 15 15 16 - export function isLocalHost(url) { 16 + export function isLocalHost(url: string) { 17 17 return ( 18 18 url.startsWith("localhost") || 19 19 url.startsWith("localhost") || ··· 23 23 } 24 24 25 25 26 - export function parseJsonIfNeeded(a) { 26 + export function parseJsonIfNeeded(a: unknown) { 27 27 if (typeof a === "string") return JSON.parse(a) 28 28 return a 29 29 } ··· 57 57 // Cache 58 58 // ----- 59 59 60 - export function removeCache(key: string) { 60 + export function removeCache(key: string): Promise<void> { 61 61 return db().removeItem(key) 62 62 } 63 63 64 64 65 - export function fromCache(key: string) { 65 + export function fromCache(key: string): Promise<unknown> { 66 66 return db().getItem(key) 67 67 } 68 68 69 69 70 - export function toCache(key: string, data: unknown) { 70 + export function toCache(key: string, data: unknown): Promise<unknown> { 71 71 return db().setItem(key, data) 72 72 } 73 73 ··· 76 76 // Crypto 77 77 // ------ 78 78 79 - export function decryptIfNeeded(data) { 79 + export function decryptIfNeeded(data: unknown): Promise<unknown | null> { 80 80 if (typeof data !== "string") { 81 81 return Promise.resolve(data) 82 82 83 - } else if (data.startsWith("{") || data.startsWith("[")) { 83 + } else if (typeof data === "string" && (data.startsWith("{") || data.startsWith("["))) { 84 84 return Promise.resolve(data) 85 85 86 86 } else if (data.length < 15 && Number.isInteger(parseInt(data, 10))) { ··· 100 100 101 101 export async function encryptIfPossible(unencryptedData: string): Promise<string> { 102 102 return unencryptedData 103 - ? getSecretKey() 104 - .then(secretKey => crypto.encrypt(secretKey, unencryptedData)) 105 - .catch(_ => unencryptedData) 103 + ? getSecretKey().then(secretKey => 104 + secretKey 105 + ? crypto.encrypt(secretKey, unencryptedData) 106 + : unencryptedData 107 + ) 106 108 : unencryptedData 107 109 } 108 110 ··· 110 112 export { encryptIfPossible as encryptWithSecretKey } 111 113 112 114 113 - export function getSecretKey() { 115 + export function getSecretKey(): Promise<CryptoKey | null> { 114 116 return db().getItem(SECRET_KEY_LOCATION) 115 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 4 // 5 5 // This worker is responsible for everything non-UI. 6 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 - 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" 293 15 294 16 295 17 // 🚀 296 18 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 19 + TaskPorts.register() 20 + User.TaskPorts.register() 335 21 336 - await moveOldDbValue({ oldName: "AUTH_SECRET_KEY", newName: "SECRET_KEY" }) 337 - await moveOldDbValue({ oldName: "AUTH_ENCLOSED_DATA", newName: "ENCLOSED_DATA" }) 22 + const app = Application.load() 23 + const brain = self as unknown as Worker 338 24 339 - const method = await fromCache("AUTH_METHOD") 25 + // 🖼️ 340 26 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 }) 27 + UI.link(brain, app) 348 28 349 - await removeCache("AUTH_METHOD") 29 + // ⚡ 30 + Artwork.init(app) 31 + Processing.init(app) 32 + Search.init(app) 33 + Tracks.init(app) 350 34 351 - } else if (method) { 352 - await toCache("SYNC_METHOD", method) 353 - await removeCache("AUTH_METHOD") 35 + User.Ports.register(app) 354 36 355 - } 356 - 357 - await toCache("MIGRATED", "3.3.0") 358 - } 359 - 37 + // 🛫 360 38 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 - } 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 7 8 8 // @ts-ignore 9 9 import * as TaskPort from "elm-taskport" 10 - import { APP_INFO, ODD_CONFIG } from "../common" 10 + 11 + import type { App } from "./elm/types" 11 12 12 13 import * as crypto from "../crypto" 13 14 15 + import { APP_INFO, ODD_CONFIG } from "../common" 14 16 import { decryptIfNeeded, encryptIfPossible, SECRET_KEY_LOCATION } from "./common" 15 17 import { parseJsonIfNeeded, removeCache, toCache } from "./common" 16 18 ··· 299 301 // EXPORT 300 302 // ====== 301 303 302 - export function setupPorts(app) { 304 + function registerPorts(app: App) { 303 305 Object.keys(ports).forEach(name => { 304 306 const fn = ports[ name ](app) 305 307 app.ports[ name ].subscribe(fn) 306 308 }) 307 309 } 308 310 309 - export function setupTaskPorts() { 311 + function registerTaskPorts() { 310 312 Object.keys(taskPorts).forEach(name => { 311 313 const fn = taskPorts[ name ] 312 314 TaskPort.register(name, fn) 313 315 }) 314 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 17 ) 18 18 19 19 20 - let index 20 + let index: lunr.Index 21 21 22 22 23 23 24 24 // Incoming messages 25 25 // ----------------- 26 26 27 - self.onmessage = event => { 27 + self.onmessage = (event: MessageEvent) => { 28 28 switch (event.data.action) { 29 29 case "PERFORM_SEARCH": 30 30 performSearch(event.data.data) ··· 53 53 // Actions 54 54 // ------- 55 55 56 - function performSearch(rawSearchTerm) { 57 - let results = 56 + function performSearch(rawSearchTerm: string) { 57 + let results: string[] = 58 58 [] 59 59 60 60 const searchTerm = rawSearchTerm ··· 62 62 .replace(/\+\s+/g, "+") 63 63 .split(/ +/) 64 64 .reduce( 65 - ([ acc, previousOperator, previousPrefix ], chunk) => { 65 + ([ acc, previousOperator, previousPrefix ]: [ string[], string, string ], chunk: string): [ string[], string, string ] => { 66 66 const operator = (a => a && a[0])( chunk.match(/^(\+|-)/) ) 67 67 68 68 let chunkWithoutOperator = chunk.replace(/^(\+|-)/, "").replace(/\*$/, "").trim() ··· 123 123 } 124 124 125 125 126 - function updateSearchIndex(input) { 126 + function updateSearchIndex(input: string | object[]) { 127 127 const tracks = (typeof input == "string") 128 128 ? JSON.parse(input) 129 129 : input 130 130 131 - index = customLunr(function() { 131 + index = customLunr((builder: lunr.Builder) => { 132 132 FIELDS.forEach( 133 - field => this.field(field) 133 + field => builder.field(field) 134 134 ) 135 135 136 136 ;(tracks || []) 137 137 .map(mapTrack) 138 - .forEach(t => this.add(t)) 138 + .forEach(t => builder.add(t)) 139 139 }) 140 140 } 141 141 142 142 143 143 144 - function customLunr(config) { 144 + function customLunr(fn: (b: lunr.Builder) => void) { 145 145 const builder = new lunr.Builder 146 146 147 147 builder.pipeline.add(removeParenthesesFromToken, lunr.stemmer) 148 148 builder.searchPipeline.add(removeParenthesesFromToken, lunr.stemmer) 149 149 150 - config.call(builder, builder) 150 + fn(builder) 151 151 return builder.build() 152 152 } 153 153 154 154 155 - function removeParenthesesFromToken(token) { 155 + function removeParenthesesFromToken(token: lunr.Token): lunr.Token { 156 156 return token.update(s => s.replace(/\(|\)/, "")) 157 157 }
+6 -8
src/Javascript/Workers/service.ts
··· 9 9 // 10 10 /// <reference lib="webworker" /> 11 11 12 - import { } from "../index.d" 13 - 14 12 15 13 const KEY = 16 14 /* eslint-disable no-undef */ ··· 19 17 20 18 const EXCLUDE = 21 19 [ "_headers" 22 - , "_redirects" 23 - , "CORS" 20 + , "_redirects" 21 + , "CORS" 24 22 ] 25 23 26 24 ··· 39 37 // 📣 40 38 41 39 42 - self.addEventListener("activate", _event => { 40 + self.addEventListener("activate", () => { 43 41 // Remove all caches except the one with the currently used `KEY` 44 42 caches.keys().then(keys => { 45 43 keys.forEach(k => { ··· 82 80 })() 83 81 ) 84 82 85 - // When doing a request with basic authentication in the url, put it in the headers instead 83 + // When doing a request with basic authentication in the url, put it in the headers instead 86 84 } else if (event.request.url.includes("service_worker_authentication=")) { 87 85 const url = new URL(event.request.url) 88 86 const token = url.searchParams.get("service_worker_authentication") ··· 96 94 "Basic " + token 97 95 ) 98 96 99 - // When doing a request with access token in the url, put it in the headers instead 97 + // When doing a request with access token in the url, put it in the headers instead 100 98 } else if (event.request.url.includes("bearer_token=")) { 101 99 const url = new URL(event.request.url) 102 100 const token = url.searchParams.get("bearer_token") ··· 112 110 "Bearer " + token 113 111 ) 114 112 115 - // Use cache if internal request and not using native app 113 + // Use cache if internal request and not using native app 116 114 } else if (isInternal) { 117 115 event.respondWith( 118 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 23 24 24 25 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 + 26 39 // FUNCTIONS 27 40 28 41
+18 -22
src/Javascript/crypto.ts
··· 11 11 const extractable = false 12 12 13 13 14 - export function keyFromPassphrase(passphrase) { 15 - return crypto.subtle.importKey( 14 + export async function keyFromPassphrase(passphrase: string): Promise<CryptoKey> { 15 + const baseKey = await crypto.subtle.importKey( 16 16 "raw", 17 17 Uint8arrays.fromString(passphrase, "utf8"), 18 18 { ··· 20 20 }, 21 21 false, 22 22 [ "deriveKey" ] 23 + ) 23 24 24 - ).then(baseKey => crypto.subtle.deriveKey( 25 + return await crypto.subtle.deriveKey( 25 26 { 26 27 name: "PBKDF2", 27 28 salt: Uint8arrays.fromString("diffuse", "utf8"), ··· 35 36 }, 36 37 extractable, 37 38 [ "encrypt", "decrypt" ] 38 - 39 - )) 39 + ) 40 40 } 41 41 42 42 43 - export function encrypt(key, string) { 44 - let iv = crypto.getRandomValues(new Uint8Array(12)) 43 + export async function encrypt(key: CryptoKey, string: string): Promise<string> { 44 + const iv = crypto.getRandomValues(new Uint8Array(12)) 45 45 46 - return crypto.subtle.encrypt( 46 + const buf = await crypto.subtle.encrypt( 47 47 { 48 48 name: "AES-GCM", 49 49 iv: iv, ··· 51 51 }, 52 52 key, 53 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 54 + ) 59 55 60 - }) 56 + const iv_b64 = Uint8arrays.toString(iv, "base64pad") 57 + const buf_b64 = Uint8arrays.toString(new Uint8Array(buf), "base64pad") 58 + return iv_b64 + buf_b64 61 59 } 62 60 63 61 64 - export function decrypt(key, string) { 62 + export async function decrypt(key: CryptoKey, string: string): Promise<string> { 65 63 const iv_b64 = string.substring(0, 16) 66 64 const buf_b64 = string.substring(16) 67 65 68 66 const iv = Uint8arrays.fromString(iv_b64, "base64pad") 69 67 const buf = Uint8arrays.fromString(buf_b64, "base64pad") 70 68 71 - return crypto.subtle.decrypt( 69 + const decrypted = await crypto.subtle.decrypt( 72 70 { 73 71 name: "AES-GCM", 74 72 iv: iv, ··· 76 74 }, 77 75 key, 78 76 buf 79 - 80 - ).then( 81 - buffer => Uint8arrays.toString( 82 - new Uint8Array(buffer), 83 - "utf8" 84 - ) 77 + ) 85 78 79 + return Uint8arrays.toString( 80 + new Uint8Array(decrypted), 81 + "utf8" 86 82 ) 87 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 4 // 5 5 // Audio processing, getting metadata, etc. 6 6 7 - import type { IAudioMetadata } from "music-metadata"; 8 - import type { MediaInfoType } from "mediainfo.js"; 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 + 9 53 10 - import * as Uint8arrays from "uint8arrays"; 11 - import { transformUrl } from "./urls"; 12 54 13 55 // Contexts 14 56 // -------- 57 + 15 58 16 59 export async function processContext(context, app) { 17 60 const initialPromise = Promise.resolve([]); ··· 40 83 }); 41 84 } 42 85 86 + 87 + 43 88 // Tags - General 44 89 // -------------- 90 + 45 91 46 92 type Tags = { 47 93 disc: number; ··· 63 109 const musicMetadata = await import("music-metadata-browser").then((a) => a.default); 64 110 const httpTokenizer = await import("@tokenizer/http").then((a) => a.default); 65 111 66 - let tokenizer 67 - let mmResult 112 + let tokenizer; 113 + let mmResult; 68 114 69 115 try { 70 116 tokenizer = await httpTokenizer.makeTokenizer(headUrl); ··· 78 124 tokenizer.rangeRequestClient.resolvedUrl = undefined; 79 125 } 80 126 81 - mmResult = await musicMetadata.parseFromTokenizer( 82 - tokenizer, 83 - { skipCovers: !covers } 84 - ).catch(err => { 85 - console.warn(err) 86 - return null 87 - }); 127 + mmResult = await musicMetadata 128 + .parseFromTokenizer(tokenizer, { skipCovers: !covers }) 129 + .catch((err) => { 130 + console.warn(err); 131 + return null; 132 + }); 88 133 } catch (err) { 89 - console.warn(err) 134 + console.warn(err); 90 135 } 91 136 92 137 const mmTags = mmResult && pickTagsFromMusicMetadata(filename, mmResult); ··· 94 139 95 140 const miResult = await (await mediaInfoClient(covers)) 96 141 .analyzeData(getSize(headUrl), readChunk(getUrl)) 97 - .catch(err => { 98 - console.warn(err) 99 - return null 142 + .catch((err) => { 143 + console.warn(err); 144 + return null; 100 145 }); 101 146 102 147 const miTags = miResult && pickTagsFromMediaInfo(filename, miResult); ··· 164 209 return new Uint8Array(await response.arrayBuffer()); 165 210 }; 166 211 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; 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; 170 216 171 217 let artist = typeof tags.Performer == "string" ? tags.Performer : null; 172 - const album = typeof tags.Album == "string" ? tags.Album : null; 218 + let album = typeof tags.Album == "string" ? tags.Album : null; 173 219 174 - const title = typeof tags.Track == "string" 175 - ? tags.Track 176 - : typeof tags.Title == "string" 177 - ? tags.Title 178 - : null; 220 + let title = 221 + typeof tags.Track == "string" ? tags.Track : typeof tags.Title == "string" ? tags.Title : null; 179 222 180 223 if (!artist && !title) return null; 181 224 182 225 // TODO: Encoding issues with mediainfo.js 183 - if (artist?.includes("�") || album?.includes("�") || title?.includes("�")) return null 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) 184 230 185 231 if (artist && artist.includes(" / ")) { 186 232 artist = artist ··· 201 247 year: year !== null && isNaN(year) ? null : year, 202 248 picture: tags.Cover_Data 203 249 ? { 204 - data: Uint8arrays.fromString(tags.Cover_Data, "base64"), 250 + data: Uint8arrays.fromString(tags.Cover_Data.split(" / ")[0], "base64pad"), 205 251 format: tags.Cover_Mime || "image/jpeg", 206 252 } 207 253 : null, 208 254 }; 209 255 } 210 256 257 + 211 258 // Tags - Music Metadata 212 259 // --------------------- 260 + 213 261 214 262 function pickTagsFromMusicMetadata(filename: string, result: IAudioMetadata): Tags | null { 215 263 const tags = result && result.common; ··· 235 283 }; 236 284 } 237 285 286 + 287 + 238 288 // 🛠️ 239 - // -- 289 + 240 290 241 291 async function mediaInfoClient(covers: boolean) { 242 - const MediaInfoFactory = await import("mediainfo.js").then(a => a.default) 292 + const MediaInfoFactory = await import("mediainfo.js").then((a) => a.default); 243 293 244 294 return await MediaInfoFactory({ 245 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 21 22 22 type alias EngineItem = 23 23 { isCached : Bool 24 + , isPreload : Bool 24 25 , progress : Maybe Float 25 26 , sourceId : String 26 27 , trackId : String ··· 34 35 -- 🔱 35 36 36 37 37 - makeEngineItem : Time.Posix -> List Source -> List String -> Dict String Float -> Track -> EngineItem 38 - makeEngineItem timestamp sources cachedTrackIds progressTable track = 38 + makeEngineItem : Bool -> Time.Posix -> List Source -> List String -> Dict String Float -> Track -> EngineItem 39 + makeEngineItem preload timestamp sources cachedTrackIds progressTable track = 39 40 { isCached = List.member track.id cachedTrackIds 41 + , isPreload = preload 40 42 , progress = Dict.get track.id progressTable 41 43 , sourceId = track.sourceId 42 44 , trackId = track.id
+5
src/Library/Tracks.elm
··· 425 425 |> (\( t, _ ) -> { playlist | tracks = t }) 426 426 427 427 428 + shouldNoteProgress : { duration : Float } -> Bool 429 + shouldNoteProgress { duration } = 430 + duration >= 30 * 60 431 + 432 + 428 433 shouldRenderGroup : Identifiers -> Bool 429 434 shouldRenderGroup identifiers = 430 435 identifiers.group
+1 -1
src/Static/Manifests/manifest.json
··· 2 2 "name": "Diffuse", 3 3 "short_name": "Diffuse", 4 4 "description": "A music player that connects to your cloud/distributed storage", 5 - "version": "3.4.0", 5 + "version": "3.5.0", 6 6 "author": "Steven Vandevelde <icid.asset@gmail.com>", 7 7 "icons": [ 8 8 {