diff --git a/CHANGELOG.md b/CHANGELOG.md index 907530db..fc9d2f8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 3.5.0 + +- **Improve audio playback and error handling**. +- Minor improvements/fixes to the artwork downloading process. + + ## 3.4.0 - **Improved audio metadata parsing**. diff --git a/Justfile b/Justfile index 8cd971ff..c736b3be 100644 --- a/Justfile +++ b/Justfile @@ -115,7 +115,7 @@ js: --inject:./system/Js/node-shims.js # Main - {{ESBUILD}} ./src/Javascript/index.ts \ + {{ESBUILD}} ./src/Javascript/UI/index.ts \ --outdir={{BUILD_DIR}}/js/ui/ \ --define:BUILD_TIMESTAMP=$build_timestamp \ --splitting @@ -144,7 +144,7 @@ js-prod: --inject:./system/Js/node-shims.js # Main - {{ESBUILD}} ./src/Javascript/index.ts \ + {{ESBUILD}} ./src/Javascript/UI/index.ts \ --outdir={{BUILD_DIR}}/js/ui/ \ --define:BUILD_TIMESTAMP=$build_timestamp \ --splitting \ @@ -180,11 +180,17 @@ js-prod: ) -@elm-housekeeping: - echo "> Running elm-format" - {{NPM_DIR}}/.bin/elm-format {{SRC_DIR}} --yes - echo "> Running elm-review" - {{ELM_REVIEW}} --fix-all +@elm-format: + echo "> Running elm-format" + {{NPM_DIR}}/.bin/elm-format {{SRC_DIR}} --yes + + +@elm-housekeeping: elm-format elm-review + + +@elm-review: + echo "> Running elm-review" + {{ELM_REVIEW}} --fix-all @quality: check-versions diff --git a/elm.json b/elm.json index ce71ee0d..8e330c2a 100644 --- a/elm.json +++ b/elm.json @@ -49,12 +49,13 @@ "truqu/elm-base64": "2.0.4", "truqu/elm-md5": "1.1.0", "wernerdegroot/listzipper": "4.0.0", - "ymtszw/elm-xml-decode": "3.2.1" + "ymtszw/elm-xml-decode": "3.2.2" }, "indirect": { "elm/bytes": "1.0.8", "elm/parser": "1.1.0", "fredcy/elm-parseint": "2.0.1", + "miniBill/elm-xml-parser": "1.0.1", "pzp1997/assoc-list": "1.0.0", "zwilias/elm-utf-tools": "2.0.1" } diff --git a/gren.json b/gren.json index 5f0e6506..4b56e6f9 100644 --- a/gren.json +++ b/gren.json @@ -9,7 +9,7 @@ "gren-lang/node": "3.0.1", "icidasset/html-gren": "4.1.0", "icidasset/markdown-gren": "3.1.0", - "icidasset/shikensu-gren": "5.0.1" + "icidasset/shikensu-gren": "5.1.0" }, "indirect": { "gren-lang/parser": "3.0.1", diff --git a/package-lock.json b/package-lock.json index d1f4619e..439726be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "diffuse", - "version": "3.4.0", + "version": "3.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "diffuse", - "version": "3.4.0", + "version": "3.5.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@oddjs/odd": "^0.37.2", @@ -15,12 +15,11 @@ "encoding-japanese": "^2.0.0", "fast-text-encoding": "^1.0.6", "file-saver": "^2.0.2", - "jschardet": "^3.0.0", "jszip": "^3.7.1", "load-script2": "^2.0.5", "localforage": "^1.10.0", "lunr": "^2.3.8", - "mediainfo.js": "^0.2.1", + "mediainfo.js": "^0.3.1", "music-metadata-browser": "^2.5.10", "readable-stream": "^4.5.2", "remotestoragejs": "^2.0.0-beta.6", @@ -36,15 +35,19 @@ "@tauri-apps/plugin-dialog": "^2.0.0-beta.0", "@tauri-apps/plugin-fs": "^2.0.0-beta.0", "@tauri-apps/plugin-shell": "^2.0.0-beta.0", + "@types/elm": "^0.19.3", + "@types/file-saver": "^2.0.7", + "@types/lunr": "^2.3.7", + "@types/throttle-debounce": "^5.0.2", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "assert": "^2.1.0", - "autoprefixer": "^10.4.17", + "autoprefixer": "^10.4.19", "buffer": "^6.0.3", "elm": "0.19.1-6", "elm-format": "^0.8.7", "elm-review": "^2.10.3", - "esbuild": "^0.20.0", + "esbuild": "^0.20.2", "esbuild-plugin-wasm": "^1.1.0", "eslint": "^8.56.0", "events": "^3.3.0", @@ -276,9 +279,9 @@ ] }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.0.tgz", - "integrity": "sha512-fGFDEctNh0CcSwsiRPxiaqX0P5rq+AqE0SRhYGZ4PX46Lg1FNR6oCxJghf8YgY0WQEgQuh3lErUFE4KxLeRmmw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", "cpu": [ "ppc64" ], @@ -292,9 +295,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.0.tgz", - "integrity": "sha512-3bMAfInvByLHfJwYPJRlpTeaQA75n8C/QKpEaiS4HrFWFiJlNI0vzq/zCjBrhAYcPyVPG7Eo9dMrcQXuqmNk5g==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", "cpu": [ "arm" ], @@ -308,9 +311,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.0.tgz", - "integrity": "sha512-aVpnM4lURNkp0D3qPoAzSG92VXStYmoVPOgXveAUoQBWRSuQzt51yvSju29J6AHPmwY1BjH49uR29oyfH1ra8Q==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", "cpu": [ "arm64" ], @@ -324,9 +327,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.0.tgz", - "integrity": "sha512-uK7wAnlRvjkCPzh8jJ+QejFyrP8ObKuR5cBIsQZ+qbMunwR8sbd8krmMbxTLSrDhiPZaJYKQAU5Y3iMDcZPhyQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", "cpu": [ "x64" ], @@ -340,9 +343,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.0.tgz", - "integrity": "sha512-AjEcivGAlPs3UAcJedMa9qYg9eSfU6FnGHJjT8s346HSKkrcWlYezGE8VaO2xKfvvlZkgAhyvl06OJOxiMgOYQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", "cpu": [ "arm64" ], @@ -356,9 +359,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.0.tgz", - "integrity": "sha512-bsgTPoyYDnPv8ER0HqnJggXK6RyFy4PH4rtsId0V7Efa90u2+EifxytE9pZnsDgExgkARy24WUQGv9irVbTvIw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", "cpu": [ "x64" ], @@ -372,9 +375,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.0.tgz", - "integrity": "sha512-kQ7jYdlKS335mpGbMW5tEe3IrQFIok9r84EM3PXB8qBFJPSc6dpWfrtsC/y1pyrz82xfUIn5ZrnSHQQsd6jebQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", "cpu": [ "arm64" ], @@ -388,9 +391,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.0.tgz", - "integrity": "sha512-uG8B0WSepMRsBNVXAQcHf9+Ko/Tr+XqmK7Ptel9HVmnykupXdS4J7ovSQUIi0tQGIndhbqWLaIL/qO/cWhXKyQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", "cpu": [ "x64" ], @@ -404,9 +407,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.0.tgz", - "integrity": "sha512-2ezuhdiZw8vuHf1HKSf4TIk80naTbP9At7sOqZmdVwvvMyuoDiZB49YZKLsLOfKIr77+I40dWpHVeY5JHpIEIg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", "cpu": [ "arm" ], @@ -420,9 +423,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.0.tgz", - "integrity": "sha512-uTtyYAP5veqi2z9b6Gr0NUoNv9F/rOzI8tOD5jKcCvRUn7T60Bb+42NDBCWNhMjkQzI0qqwXkQGo1SY41G52nw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", "cpu": [ "arm64" ], @@ -436,9 +439,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.0.tgz", - "integrity": "sha512-c88wwtfs8tTffPaoJ+SQn3y+lKtgTzyjkD8NgsyCtCmtoIC8RDL7PrJU05an/e9VuAke6eJqGkoMhJK1RY6z4w==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", "cpu": [ "ia32" ], @@ -452,9 +455,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.0.tgz", - "integrity": "sha512-lR2rr/128/6svngnVta6JN4gxSXle/yZEZL3o4XZ6esOqhyR4wsKyfu6qXAL04S4S5CgGfG+GYZnjFd4YiG3Aw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", "cpu": [ "loong64" ], @@ -468,9 +471,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.0.tgz", - "integrity": "sha512-9Sycc+1uUsDnJCelDf6ZNqgZQoK1mJvFtqf2MUz4ujTxGhvCWw+4chYfDLPepMEvVL9PDwn6HrXad5yOrNzIsQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", "cpu": [ "mips64el" ], @@ -484,9 +487,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.0.tgz", - "integrity": "sha512-CoWSaaAXOZd+CjbUTdXIJE/t7Oz+4g90A3VBCHLbfuc5yUQU/nFDLOzQsN0cdxgXd97lYW/psIIBdjzQIwTBGw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", "cpu": [ "ppc64" ], @@ -500,9 +503,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.0.tgz", - "integrity": "sha512-mlb1hg/eYRJUpv8h/x+4ShgoNLL8wgZ64SUr26KwglTYnwAWjkhR2GpoKftDbPOCnodA9t4Y/b68H4J9XmmPzA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", "cpu": [ "riscv64" ], @@ -516,9 +519,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.0.tgz", - "integrity": "sha512-fgf9ubb53xSnOBqyvWEY6ukBNRl1mVX1srPNu06B6mNsNK20JfH6xV6jECzrQ69/VMiTLvHMicQR/PgTOgqJUQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", "cpu": [ "s390x" ], @@ -532,9 +535,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.0.tgz", - "integrity": "sha512-H9Eu6MGse++204XZcYsse1yFHmRXEWgadk2N58O/xd50P9EvFMLJTQLg+lB4E1cF2xhLZU5luSWtGTb0l9UeSg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", "cpu": [ "x64" ], @@ -548,9 +551,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.0.tgz", - "integrity": "sha512-lCT675rTN1v8Fo+RGrE5KjSnfY0x9Og4RN7t7lVrN3vMSjy34/+3na0q7RIfWDAj0e0rCh0OL+P88lu3Rt21MQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", "cpu": [ "x64" ], @@ -564,9 +567,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.0.tgz", - "integrity": "sha512-HKoUGXz/TOVXKQ+67NhxyHv+aDSZf44QpWLa3I1lLvAwGq8x1k0T+e2HHSRvxWhfJrFxaaqre1+YyzQ99KixoA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", "cpu": [ "x64" ], @@ -580,9 +583,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.0.tgz", - "integrity": "sha512-GDwAqgHQm1mVoPppGsoq4WJwT3vhnz/2N62CzhvApFD1eJyTroob30FPpOZabN+FgCjhG+AgcZyOPIkR8dfD7g==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", "cpu": [ "x64" ], @@ -596,9 +599,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.0.tgz", - "integrity": "sha512-0vYsP8aC4TvMlOQYozoksiaxjlvUcQrac+muDqj1Fxy6jh9l9CZJzj7zmh8JGfiV49cYLTorFLxg7593pGldwQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", "cpu": [ "arm64" ], @@ -612,9 +615,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.0.tgz", - "integrity": "sha512-p98u4rIgfh4gdpV00IqknBD5pC84LCub+4a3MO+zjqvU5MVXOc3hqR2UgT2jI2nh3h8s9EQxmOsVI3tyzv1iFg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", "cpu": [ "ia32" ], @@ -628,9 +631,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.0.tgz", - "integrity": "sha512-NgJnesu1RtWihtTtXGFMU5YSE6JyyHPMxCwBZK7a6/8d31GuSo9l0Ss7w1Jw5QnKUawG6UEehs883kcXf5fYwg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", "cpu": [ "x64" ], @@ -1654,6 +1657,18 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/elm": { + "version": "0.19.3", + "resolved": "https://registry.npmjs.org/@types/elm/-/elm-0.19.3.tgz", + "integrity": "sha512-1DnHZiIHvDyjL6MHrePqbD3ooLLix13k6ow8gEydFOAXImkcvbzQX0Ri+WJOM7RvgPfmyUe6uQ2Acupb1oL+GA==", + "dev": true + }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", @@ -1675,6 +1690,12 @@ "@types/node": "*" } }, + "node_modules/@types/lunr": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@types/lunr/-/lunr-2.3.7.tgz", + "integrity": "sha512-Tb/kUm38e8gmjahQzdCKhbdsvQ9/ppzHFfsJ0dMs3ckqQsRj+P5IkSAwFTBrBxdyr3E/LoMUUrZngjDYAjiE3A==", + "dev": true + }, "node_modules/@types/node": { "version": "18.16.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.3.tgz", @@ -1695,6 +1716,12 @@ "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", "dev": true }, + "node_modules/@types/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==", + "dev": true + }, "node_modules/@types/tv4": { "version": "1.2.31", "resolved": "https://registry.npmjs.org/@types/tv4/-/tv4-1.2.31.tgz", @@ -2150,9 +2177,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.17", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", - "integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==", + "version": "10.4.19", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", + "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", "dev": true, "funding": [ { @@ -2169,8 +2196,8 @@ } ], "dependencies": { - "browserslist": "^4.22.2", - "caniuse-lite": "^1.0.30001578", + "browserslist": "^4.23.0", + "caniuse-lite": "^1.0.30001599", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.0.0", @@ -2493,9 +2520,9 @@ } }, "node_modules/browserslist": { - "version": "4.22.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz", - "integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", + "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", "dev": true, "funding": [ { @@ -2512,10 +2539,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001580", - "electron-to-chromium": "^1.4.648", + "caniuse-lite": "^1.0.30001629", + "electron-to-chromium": "^1.4.796", "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "update-browserslist-db": "^1.0.16" }, "bin": { "browserslist": "cli.js" @@ -2650,9 +2677,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001584", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001584.tgz", - "integrity": "sha512-LOz7CCQ9M1G7OjJOF9/mzmqmj3jE/7VOmrfw6Mgs0E8cjOsbRXQJHsPBfmBOXDskXKrHLyyW3n7kpDW/4BsfpQ==", + "version": "1.0.30001636", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz", + "integrity": "sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg==", "dev": true, "funding": [ { @@ -3265,9 +3292,9 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, "node_modules/electron-to-chromium": { - "version": "1.4.657", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.657.tgz", - "integrity": "sha512-On2ymeleg6QbRuDk7wNgDdXtNqlJLM2w4Agx1D/RiTmItiL+a9oq5p7HUa2ZtkAtGBe/kil2dq/7rPfkbe0r5w==", + "version": "1.4.806", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.806.tgz", + "integrity": "sha512-nkoEX2QIB8kwCOtvtgwhXWy2IHVcOLQZu9Qo36uaGB835mdX/h8uLRlosL6QIhLVUnAiicXRW00PwaPZC74Nrg==", "dev": true }, "node_modules/elm": { @@ -3464,9 +3491,9 @@ } }, "node_modules/esbuild": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.0.tgz", - "integrity": "sha512-6iwE3Y2RVYCME1jLpBqq7LQWK3MW6vjV2bZy6gt/WrqkY+WE74Spyc0ThAOYpMtITvnjX09CrC6ym7A/m9mebA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", "dev": true, "hasInstallScript": true, "bin": { @@ -3476,29 +3503,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.0", - "@esbuild/android-arm": "0.20.0", - "@esbuild/android-arm64": "0.20.0", - "@esbuild/android-x64": "0.20.0", - "@esbuild/darwin-arm64": "0.20.0", - "@esbuild/darwin-x64": "0.20.0", - "@esbuild/freebsd-arm64": "0.20.0", - "@esbuild/freebsd-x64": "0.20.0", - "@esbuild/linux-arm": "0.20.0", - "@esbuild/linux-arm64": "0.20.0", - "@esbuild/linux-ia32": "0.20.0", - "@esbuild/linux-loong64": "0.20.0", - "@esbuild/linux-mips64el": "0.20.0", - "@esbuild/linux-ppc64": "0.20.0", - "@esbuild/linux-riscv64": "0.20.0", - "@esbuild/linux-s390x": "0.20.0", - "@esbuild/linux-x64": "0.20.0", - "@esbuild/netbsd-x64": "0.20.0", - "@esbuild/openbsd-x64": "0.20.0", - "@esbuild/sunos-x64": "0.20.0", - "@esbuild/win32-arm64": "0.20.0", - "@esbuild/win32-ia32": "0.20.0", - "@esbuild/win32-x64": "0.20.0" + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" } }, "node_modules/esbuild-plugin-wasm": { @@ -5187,14 +5214,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jschardet": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/jschardet/-/jschardet-3.0.0.tgz", - "integrity": "sha512-lJH6tJ77V8Nzd5QWRkFYCLc13a3vADkh3r/Fi8HupZGWk2OVVDfnZP8V/VgQgZ+lzW0kG2UGb5hFgt3V3ndotQ==", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -5549,9 +5568,9 @@ } }, "node_modules/mediainfo.js": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.2.1.tgz", - "integrity": "sha512-xbTstvy34gDmxNLVytixbY8Uw4DGKKsQIMvX7q1K8FwIk/gwAVLd30EVvPh/g+QHVscATRuqrNtbTb7XUjDeyw==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.3.1.tgz", + "integrity": "sha512-qUehPOCsqmEn0SmTaEOTgyaIiN9LZrDFYyDibsx2rpe8QaxWA+Dzr/fPMTMaHDt5L6J4Jm7pmcEhREN0N0ewrA==", "dependencies": { "yargs": "^17.7.2" }, @@ -5559,7 +5578,7 @@ "mediainfo.js": "dist/esm/cli.js" }, "engines": { - "node": ">=14.16" + "node": ">=18.0.0" } }, "node_modules/merge-options": { @@ -6252,9 +6271,9 @@ "dev": true }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", "dev": true }, "node_modules/picomatch": { @@ -7590,9 +7609,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", + "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", "dev": true, "funding": [ { @@ -7609,8 +7628,8 @@ } ], "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.1.2", + "picocolors": "^1.0.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -8053,163 +8072,163 @@ "optional": true }, "@esbuild/aix-ppc64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.0.tgz", - "integrity": "sha512-fGFDEctNh0CcSwsiRPxiaqX0P5rq+AqE0SRhYGZ4PX46Lg1FNR6oCxJghf8YgY0WQEgQuh3lErUFE4KxLeRmmw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", "dev": true, "optional": true }, "@esbuild/android-arm": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.0.tgz", - "integrity": "sha512-3bMAfInvByLHfJwYPJRlpTeaQA75n8C/QKpEaiS4HrFWFiJlNI0vzq/zCjBrhAYcPyVPG7Eo9dMrcQXuqmNk5g==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", "dev": true, "optional": true }, "@esbuild/android-arm64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.0.tgz", - "integrity": "sha512-aVpnM4lURNkp0D3qPoAzSG92VXStYmoVPOgXveAUoQBWRSuQzt51yvSju29J6AHPmwY1BjH49uR29oyfH1ra8Q==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", "dev": true, "optional": true }, "@esbuild/android-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.0.tgz", - "integrity": "sha512-uK7wAnlRvjkCPzh8jJ+QejFyrP8ObKuR5cBIsQZ+qbMunwR8sbd8krmMbxTLSrDhiPZaJYKQAU5Y3iMDcZPhyQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", "dev": true, "optional": true }, "@esbuild/darwin-arm64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.0.tgz", - "integrity": "sha512-AjEcivGAlPs3UAcJedMa9qYg9eSfU6FnGHJjT8s346HSKkrcWlYezGE8VaO2xKfvvlZkgAhyvl06OJOxiMgOYQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", "dev": true, "optional": true }, "@esbuild/darwin-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.0.tgz", - "integrity": "sha512-bsgTPoyYDnPv8ER0HqnJggXK6RyFy4PH4rtsId0V7Efa90u2+EifxytE9pZnsDgExgkARy24WUQGv9irVbTvIw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.0.tgz", - "integrity": "sha512-kQ7jYdlKS335mpGbMW5tEe3IrQFIok9r84EM3PXB8qBFJPSc6dpWfrtsC/y1pyrz82xfUIn5ZrnSHQQsd6jebQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", "dev": true, "optional": true }, "@esbuild/freebsd-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.0.tgz", - "integrity": "sha512-uG8B0WSepMRsBNVXAQcHf9+Ko/Tr+XqmK7Ptel9HVmnykupXdS4J7ovSQUIi0tQGIndhbqWLaIL/qO/cWhXKyQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", "dev": true, "optional": true }, "@esbuild/linux-arm": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.0.tgz", - "integrity": "sha512-2ezuhdiZw8vuHf1HKSf4TIk80naTbP9At7sOqZmdVwvvMyuoDiZB49YZKLsLOfKIr77+I40dWpHVeY5JHpIEIg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", "dev": true, "optional": true }, "@esbuild/linux-arm64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.0.tgz", - "integrity": "sha512-uTtyYAP5veqi2z9b6Gr0NUoNv9F/rOzI8tOD5jKcCvRUn7T60Bb+42NDBCWNhMjkQzI0qqwXkQGo1SY41G52nw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", "dev": true, "optional": true }, "@esbuild/linux-ia32": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.0.tgz", - "integrity": "sha512-c88wwtfs8tTffPaoJ+SQn3y+lKtgTzyjkD8NgsyCtCmtoIC8RDL7PrJU05an/e9VuAke6eJqGkoMhJK1RY6z4w==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", "dev": true, "optional": true }, "@esbuild/linux-loong64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.0.tgz", - "integrity": "sha512-lR2rr/128/6svngnVta6JN4gxSXle/yZEZL3o4XZ6esOqhyR4wsKyfu6qXAL04S4S5CgGfG+GYZnjFd4YiG3Aw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", "dev": true, "optional": true }, "@esbuild/linux-mips64el": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.0.tgz", - "integrity": "sha512-9Sycc+1uUsDnJCelDf6ZNqgZQoK1mJvFtqf2MUz4ujTxGhvCWw+4chYfDLPepMEvVL9PDwn6HrXad5yOrNzIsQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", "dev": true, "optional": true }, "@esbuild/linux-ppc64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.0.tgz", - "integrity": "sha512-CoWSaaAXOZd+CjbUTdXIJE/t7Oz+4g90A3VBCHLbfuc5yUQU/nFDLOzQsN0cdxgXd97lYW/psIIBdjzQIwTBGw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", "dev": true, "optional": true }, "@esbuild/linux-riscv64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.0.tgz", - "integrity": "sha512-mlb1hg/eYRJUpv8h/x+4ShgoNLL8wgZ64SUr26KwglTYnwAWjkhR2GpoKftDbPOCnodA9t4Y/b68H4J9XmmPzA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", "dev": true, "optional": true }, "@esbuild/linux-s390x": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.0.tgz", - "integrity": "sha512-fgf9ubb53xSnOBqyvWEY6ukBNRl1mVX1srPNu06B6mNsNK20JfH6xV6jECzrQ69/VMiTLvHMicQR/PgTOgqJUQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", "dev": true, "optional": true }, "@esbuild/linux-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.0.tgz", - "integrity": "sha512-H9Eu6MGse++204XZcYsse1yFHmRXEWgadk2N58O/xd50P9EvFMLJTQLg+lB4E1cF2xhLZU5luSWtGTb0l9UeSg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", "dev": true, "optional": true }, "@esbuild/netbsd-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.0.tgz", - "integrity": "sha512-lCT675rTN1v8Fo+RGrE5KjSnfY0x9Og4RN7t7lVrN3vMSjy34/+3na0q7RIfWDAj0e0rCh0OL+P88lu3Rt21MQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", "dev": true, "optional": true }, "@esbuild/openbsd-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.0.tgz", - "integrity": "sha512-HKoUGXz/TOVXKQ+67NhxyHv+aDSZf44QpWLa3I1lLvAwGq8x1k0T+e2HHSRvxWhfJrFxaaqre1+YyzQ99KixoA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", "dev": true, "optional": true }, "@esbuild/sunos-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.0.tgz", - "integrity": "sha512-GDwAqgHQm1mVoPppGsoq4WJwT3vhnz/2N62CzhvApFD1eJyTroob30FPpOZabN+FgCjhG+AgcZyOPIkR8dfD7g==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", "dev": true, "optional": true }, "@esbuild/win32-arm64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.0.tgz", - "integrity": "sha512-0vYsP8aC4TvMlOQYozoksiaxjlvUcQrac+muDqj1Fxy6jh9l9CZJzj7zmh8JGfiV49cYLTorFLxg7593pGldwQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", "dev": true, "optional": true }, "@esbuild/win32-ia32": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.0.tgz", - "integrity": "sha512-p98u4rIgfh4gdpV00IqknBD5pC84LCub+4a3MO+zjqvU5MVXOc3hqR2UgT2jI2nh3h8s9EQxmOsVI3tyzv1iFg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", "dev": true, "optional": true }, "@esbuild/win32-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.0.tgz", - "integrity": "sha512-NgJnesu1RtWihtTtXGFMU5YSE6JyyHPMxCwBZK7a6/8d31GuSo9l0Ss7w1Jw5QnKUawG6UEehs883kcXf5fYwg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", "dev": true, "optional": true }, @@ -8921,6 +8940,18 @@ "@types/responselike": "^1.0.0" } }, + "@types/elm": { + "version": "0.19.3", + "resolved": "https://registry.npmjs.org/@types/elm/-/elm-0.19.3.tgz", + "integrity": "sha512-1DnHZiIHvDyjL6MHrePqbD3ooLLix13k6ow8gEydFOAXImkcvbzQX0Ri+WJOM7RvgPfmyUe6uQ2Acupb1oL+GA==", + "dev": true + }, + "@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true + }, "@types/http-cache-semantics": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", @@ -8942,6 +8973,12 @@ "@types/node": "*" } }, + "@types/lunr": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@types/lunr/-/lunr-2.3.7.tgz", + "integrity": "sha512-Tb/kUm38e8gmjahQzdCKhbdsvQ9/ppzHFfsJ0dMs3ckqQsRj+P5IkSAwFTBrBxdyr3E/LoMUUrZngjDYAjiE3A==", + "dev": true + }, "@types/node": { "version": "18.16.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.3.tgz", @@ -8962,6 +8999,12 @@ "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", "dev": true }, + "@types/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==", + "dev": true + }, "@types/tv4": { "version": "1.2.31", "resolved": "https://registry.npmjs.org/@types/tv4/-/tv4-1.2.31.tgz", @@ -9261,13 +9304,13 @@ "dev": true }, "autoprefixer": { - "version": "10.4.17", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", - "integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==", + "version": "10.4.19", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", + "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", "dev": true, "requires": { - "browserslist": "^4.22.2", - "caniuse-lite": "^1.0.30001578", + "browserslist": "^4.23.0", + "caniuse-lite": "^1.0.30001599", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.0.0", @@ -9478,15 +9521,15 @@ } }, "browserslist": { - "version": "4.22.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz", - "integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", + "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", "dev": true, "requires": { - "caniuse-lite": "^1.0.30001580", - "electron-to-chromium": "^1.4.648", + "caniuse-lite": "^1.0.30001629", + "electron-to-chromium": "^1.4.796", "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "update-browserslist-db": "^1.0.16" } }, "buffer": { @@ -9568,9 +9611,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001584", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001584.tgz", - "integrity": "sha512-LOz7CCQ9M1G7OjJOF9/mzmqmj3jE/7VOmrfw6Mgs0E8cjOsbRXQJHsPBfmBOXDskXKrHLyyW3n7kpDW/4BsfpQ==", + "version": "1.0.30001636", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz", + "integrity": "sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg==", "dev": true }, "catering": { @@ -9997,9 +10040,9 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, "electron-to-chromium": { - "version": "1.4.657", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.657.tgz", - "integrity": "sha512-On2ymeleg6QbRuDk7wNgDdXtNqlJLM2w4Agx1D/RiTmItiL+a9oq5p7HUa2ZtkAtGBe/kil2dq/7rPfkbe0r5w==", + "version": "1.4.806", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.806.tgz", + "integrity": "sha512-nkoEX2QIB8kwCOtvtgwhXWy2IHVcOLQZu9Qo36uaGB835mdX/h8uLRlosL6QIhLVUnAiicXRW00PwaPZC74Nrg==", "dev": true }, "elm": { @@ -10147,34 +10190,34 @@ "dev": true }, "esbuild": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.0.tgz", - "integrity": "sha512-6iwE3Y2RVYCME1jLpBqq7LQWK3MW6vjV2bZy6gt/WrqkY+WE74Spyc0ThAOYpMtITvnjX09CrC6ym7A/m9mebA==", - "dev": true, - "requires": { - "@esbuild/aix-ppc64": "0.20.0", - "@esbuild/android-arm": "0.20.0", - "@esbuild/android-arm64": "0.20.0", - "@esbuild/android-x64": "0.20.0", - "@esbuild/darwin-arm64": "0.20.0", - "@esbuild/darwin-x64": "0.20.0", - "@esbuild/freebsd-arm64": "0.20.0", - "@esbuild/freebsd-x64": "0.20.0", - "@esbuild/linux-arm": "0.20.0", - "@esbuild/linux-arm64": "0.20.0", - "@esbuild/linux-ia32": "0.20.0", - "@esbuild/linux-loong64": "0.20.0", - "@esbuild/linux-mips64el": "0.20.0", - "@esbuild/linux-ppc64": "0.20.0", - "@esbuild/linux-riscv64": "0.20.0", - "@esbuild/linux-s390x": "0.20.0", - "@esbuild/linux-x64": "0.20.0", - "@esbuild/netbsd-x64": "0.20.0", - "@esbuild/openbsd-x64": "0.20.0", - "@esbuild/sunos-x64": "0.20.0", - "@esbuild/win32-arm64": "0.20.0", - "@esbuild/win32-ia32": "0.20.0", - "@esbuild/win32-x64": "0.20.0" + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" } }, "esbuild-plugin-wasm": { @@ -11337,11 +11380,6 @@ "argparse": "^2.0.1" } }, - "jschardet": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/jschardet/-/jschardet-3.0.0.tgz", - "integrity": "sha512-lJH6tJ77V8Nzd5QWRkFYCLc13a3vADkh3r/Fi8HupZGWk2OVVDfnZP8V/VgQgZ+lzW0kG2UGb5hFgt3V3ndotQ==" - }, "json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -11630,9 +11668,9 @@ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" }, "mediainfo.js": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.2.1.tgz", - "integrity": "sha512-xbTstvy34gDmxNLVytixbY8Uw4DGKKsQIMvX7q1K8FwIk/gwAVLd30EVvPh/g+QHVscATRuqrNtbTb7XUjDeyw==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.3.1.tgz", + "integrity": "sha512-qUehPOCsqmEn0SmTaEOTgyaIiN9LZrDFYyDibsx2rpe8QaxWA+Dzr/fPMTMaHDt5L6J4Jm7pmcEhREN0N0ewrA==", "requires": { "yargs": "^17.7.2" } @@ -12103,9 +12141,9 @@ "dev": true }, "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", "dev": true }, "picomatch": { @@ -13030,13 +13068,13 @@ "dev": true }, "update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", + "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", "dev": true, "requires": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.1.2", + "picocolors": "^1.0.1" } }, "update-check": { diff --git a/package.json b/package.json index 3ecf0def..afd7ddf9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "diffuse", "description": "A music player that connects to your cloud/distributed storage", - "version": "3.4.0", + "version": "3.5.0", "author": "Steven Vandevelde ", "homepage": "https://diffuse.sh", "repository": "github:icidasset/diffuse", @@ -12,15 +12,19 @@ "@tauri-apps/plugin-dialog": "^2.0.0-beta.0", "@tauri-apps/plugin-fs": "^2.0.0-beta.0", "@tauri-apps/plugin-shell": "^2.0.0-beta.0", + "@types/elm": "^0.19.3", + "@types/file-saver": "^2.0.7", + "@types/lunr": "^2.3.7", + "@types/throttle-debounce": "^5.0.2", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "assert": "^2.1.0", - "autoprefixer": "^10.4.17", + "autoprefixer": "^10.4.19", "buffer": "^6.0.3", "elm": "0.19.1-6", "elm-format": "^0.8.7", "elm-review": "^2.10.3", - "esbuild": "^0.20.0", + "esbuild": "^0.20.2", "esbuild-plugin-wasm": "^1.1.0", "eslint": "^8.56.0", "events": "^3.3.0", @@ -42,12 +46,11 @@ "encoding-japanese": "^2.0.0", "fast-text-encoding": "^1.0.6", "file-saver": "^2.0.2", - "jschardet": "^3.0.0", "jszip": "^3.7.1", "load-script2": "^2.0.5", "localforage": "^1.10.0", "lunr": "^2.3.8", - "mediainfo.js": "^0.2.1", + "mediainfo.js": "^0.3.1", "music-metadata-browser": "^2.5.10", "readable-stream": "^4.5.2", "remotestoragejs": "^2.0.0-beta.6", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4a903af3..a72846c9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "diffuse" -version = "3.4.0" +version = "3.5.0" description = "A music player that connects to your cloud/distributed storage" authors = ["Steven Vandevelde"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a90cdbed..cc3c666b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "Diffuse", - "version": "3.4.0", + "version": "3.5.0", "identifier": "com.icidasset.diffuse", "build": { "beforeDevCommand": "", diff --git a/src/Applications/Brain/Other/State.elm b/src/Applications/Brain/Other/State.elm index 2950f73e..16a3ffab 100644 --- a/src/Applications/Brain/Other/State.elm +++ b/src/Applications/Brain/Other/State.elm @@ -3,6 +3,7 @@ module Brain.Other.State exposing (..) import Alien import Brain.Common.State as Common import Brain.Ports as Ports +import Brain.Task.Ports import Brain.Types exposing (..) import Dict import Json.Decode as Json @@ -56,13 +57,21 @@ setCurrentTime time model = Return.singleton { model | currentTime = time } +{-| Save alien data to cache. +-} toCache : Json.Value -> Manager toCache data = case Json.decodeValue Alien.hostDecoder data of Ok alienEvent -> - alienEvent - |> Ports.toCache - |> Return.communicate + case Alien.tagFromString alienEvent.tag of + Just tag -> + alienEvent.data + |> Brain.Task.Ports.toCache tag + |> Common.attemptPortTask (always Bypass) + |> Return.communicate + + Nothing -> + Common.reportUI Alien.ToCache "Failed to decode alien tag" Err err -> err diff --git a/src/Applications/Brain/Ports.elm b/src/Applications/Brain/Ports.elm index a9e4d596..ceca2bd1 100644 --- a/src/Applications/Brain/Ports.elm +++ b/src/Applications/Brain/Ports.elm @@ -12,15 +12,9 @@ import Sources.Processing exposing (ContextForTags, ContextForTagsSync) port downloadTracks : Json.Value -> Cmd msg -port removeCache : Alien.Event -> Cmd msg - - port removeTracksFromCache : Json.Value -> Cmd msg -port requestCache : Alien.Event -> Cmd msg - - port requestSearch : String -> Cmd msg @@ -33,9 +27,6 @@ port storeTracksInCache : Json.Value -> Cmd msg port syncTags : ContextForTagsSync -> Cmd msg -port toCache : Alien.Event -> Cmd msg - - port toUI : Alien.Event -> Cmd msg diff --git a/src/Applications/Brain/Tracks/State.elm b/src/Applications/Brain/Tracks/State.elm index 0b618aba..ff0cad38 100644 --- a/src/Applications/Brain/Tracks/State.elm +++ b/src/Applications/Brain/Tracks/State.elm @@ -119,8 +119,6 @@ makeArtworkTrackUrls json model = makeTrackUrl model.currentTime trackPath maybeSource in dict - |> Dict.remove "trackPath" - |> Dict.remove "trackSourceId" |> Dict.insert "trackGetUrl" (mkTrackUrl Get) |> Dict.insert "trackHeadUrl" (mkTrackUrl Head) |> Json.Encode.dict identity Json.Encode.string diff --git a/src/Applications/Brain/User/State.elm b/src/Applications/Brain/User/State.elm index 2967c37a..bf612515 100644 --- a/src/Applications/Brain/User/State.elm +++ b/src/Applications/Brain/User/State.elm @@ -99,16 +99,15 @@ loadLocalHypaethralData { initialUrl, methodTask } = |> User.decodeHypaethralData |> Result.map (\hypaethralData -> - ( hypaethralJson - , hypaethralData - ) + Commence + maybeMethod + initialUrl + ( hypaethralJson + , hypaethralData + ) ) - |> Result.withDefault - ( User.encodeHypaethralData User.emptyHypaethralData - , User.emptyHypaethralData - ) - |> Commence maybeMethod initialUrl - |> UserMsg + |> Result.mapError Decode.errorToString + |> Common.reportErrorToUI UserMsg ) @@ -345,8 +344,8 @@ unsetSyncMethod : Manager unsetSyncMethod model = -- ๐Ÿ’€ -- Unset & remove stored method. - [ Ports.removeCache (Alien.trigger Alien.SyncMethod) - , Ports.removeCache (Alien.trigger Alien.SecretKey) + [ Common.attemptPortTask (always Brain.Bypass) (Brain.Task.Ports.removeCache Alien.SyncMethod) + , Common.attemptPortTask (always Brain.Bypass) (Brain.Task.Ports.removeCache Alien.SecretKey) -- , case model.userSyncMethod of @@ -380,17 +379,25 @@ enclosedDataRetrieved json = retrieveEnclosedData : Manager retrieveEnclosedData = - Alien.EnclosedData - |> Alien.trigger - |> Ports.requestCache + Decode.value + |> Brain.Task.Ports.fromCache Alien.EnclosedData + |> Common.attemptPortTask + (\maybe -> + case maybe of + Just json -> + Brain.UserMsg (EnclosedDataRetrieved json) + + Nothing -> + Brain.Bypass + ) |> Return.communicate saveEnclosedData : Json.Value -> Manager saveEnclosedData json = json - |> Alien.broadcast Alien.EnclosedData - |> Ports.toCache + |> Brain.Task.Ports.toCache Alien.EnclosedData + |> Common.attemptPortTask (always Brain.Bypass) |> Return.communicate @@ -668,8 +675,8 @@ saveMethod : Method -> Manager saveMethod method model = method |> encodeMethod - |> Alien.broadcast Alien.SyncMethod - |> Ports.toCache + |> Brain.Task.Ports.toCache Alien.SyncMethod + |> Common.attemptPortTask (always Brain.Bypass) |> return { model | userSyncMethod = Just method } diff --git a/src/Applications/UI.elm b/src/Applications/UI.elm index a839f5a2..04f48730 100644 --- a/src/Applications/UI.elm +++ b/src/Applications/UI.elm @@ -117,11 +117,8 @@ init flags url key = ----------------------------------------- -- Audio ----------------------------------------- - , audioDuration = 0 - , audioHasStalled = False - , audioIsLoading = False - , audioIsPlaying = False - , audioPosition = 0 + , audioElements = [] + , nowPlaying = Nothing , progress = Dict.empty , rememberProgress = True @@ -136,6 +133,17 @@ init flags url key = ----------------------------------------- -- Debouncing ----------------------------------------- + , preloadDebouncer = + 30 + |> Debouncer.fromSeconds + |> Debouncer.debounce + |> Debouncer.toDebouncer + , progressDebouncer = + 30 + |> Debouncer.fromSeconds + |> Debouncer.throttle + |> Debouncer.emitWhenUnsettled Nothing + |> Debouncer.toDebouncer , resizeDebouncer = 0.25 |> Debouncer.fromSeconds @@ -174,7 +182,6 @@ init flags url key = -- Queue ----------------------------------------- , dontPlay = [] - , nowPlaying = Nothing , playedPreviously = [] , playingNext = [] , selectedQueueItem = Nothing @@ -273,9 +280,36 @@ update msg = ----------------------------------------- -- Audio ----------------------------------------- + AudioDurationChange a -> + Audio.durationChange a + + AudioEnded a -> + Audio.ended a + + AudioError a -> + Audio.error a + + AudioHasLoaded a -> + Audio.hasLoaded a + + AudioIsLoading a -> + Audio.isLoading a + + AudioPlaybackStateChanged a -> + Audio.playbackStateChanged a + + AudioPreloadDebounce a -> + Audio.preloadDebounce update a + + AudioTimeUpdated a -> + Audio.timeUpdated a + NoteProgress a -> Audio.noteProgress a + NoteProgressDebounce a -> + Audio.noteProgressDebounce update a + Pause -> Audio.pause @@ -285,21 +319,6 @@ update msg = Seek a -> Audio.seek a - SetAudioDuration a -> - Audio.setDuration a - - SetAudioHasStalled a -> - Audio.setHasStalled a - - SetAudioIsLoading a -> - Audio.setIsLoading a - - SetAudioIsPlaying a -> - Audio.setIsPlaying a - - SetAudioPosition a -> - Audio.setPosition a - Stop -> Audio.stop @@ -562,16 +581,17 @@ subscriptions _ = ----------------------------------------- -- Audio ----------------------------------------- - , Ports.noteProgress NoteProgress + , Ports.audioDurationChange AudioDurationChange + , Ports.audioEnded AudioEnded + , Ports.audioError AudioError + , Ports.audioPlaybackStateChanged AudioPlaybackStateChanged + , Ports.audioIsLoading AudioIsLoading + , Ports.audioHasLoaded AudioHasLoaded + , Ports.audioTimeUpdated AudioTimeUpdated , Ports.requestPause (always Pause) , Ports.requestPlay (always Play) , Ports.requestPlayPause (always TogglePlay) , Ports.requestStop (always Stop) - , Ports.setAudioDuration SetAudioDuration - , Ports.setAudioHasStalled SetAudioHasStalled - , Ports.setAudioIsLoading SetAudioIsLoading - , Ports.setAudioIsPlaying SetAudioIsPlaying - , Ports.setAudioPosition SetAudioPosition ----------------------------------------- -- Backdrop @@ -591,7 +611,6 @@ subscriptions _ = ----------------------------------------- -- Queue ----------------------------------------- - , Ports.activeQueueItemEnded (QueueMsg << always Queue.Shift) , Ports.requestNext (\_ -> QueueMsg Queue.Shift) , Ports.requestPrevious (\_ -> QueueMsg Queue.Rewind) diff --git a/src/Applications/UI/Adjunct.elm b/src/Applications/UI/Adjunct.elm index 3bb71377..69faf3a2 100644 --- a/src/Applications/UI/Adjunct.elm +++ b/src/Applications/UI/Adjunct.elm @@ -63,12 +63,12 @@ keyboardInput msg model = [ Keyboard.Character "]", Keyboard.Control ] -> Queue.shift m - [ Keyboard.Character "{", Keyboard.Shift, Keyboard.Control ] -> - Audio.seek ((m.audioPosition - 10) / m.audioDuration) m - - [ Keyboard.Character "}", Keyboard.Shift, Keyboard.Control ] -> - Audio.seek ((m.audioPosition + 10) / m.audioDuration) m - + -- TODO: + -- [ Keyboard.Character "{", Keyboard.Shift, Keyboard.Control ] -> + -- Audio.seek ((m.audioPosition - 10) / m.audioDuration) m + -- + -- [ Keyboard.Character "}", Keyboard.Shift, Keyboard.Control ] -> + -- Audio.seek ((m.audioPosition + 10) / m.audioDuration) m -- Meta key -- [ Keyboard.Character "K", Keyboard.Meta ] -> diff --git a/src/Applications/UI/Audio/State.elm b/src/Applications/UI/Audio/State.elm index c9e59fc0..917f21e1 100644 --- a/src/Applications/UI/Audio/State.elm +++ b/src/Applications/UI/Audio/State.elm @@ -1,114 +1,364 @@ module UI.Audio.State exposing (..) +import Base64 +import Common exposing (boolToString) +import Debouncer.Basic as Debouncer import Dict import LastFm -import Maybe.Extra as Maybe +import List.Extra as List +import MediaSession import Return exposing (return) import Return.Ext as Return exposing (communicate) +import Tracks +import UI.Audio.Types exposing (..) +import UI.Common.State as Common +import UI.Common.Types exposing (DebounceManager) import UI.Ports as Ports import UI.Queue.State as Queue -import UI.Types as UI exposing (Manager) +import UI.Types as UI exposing (Manager, Msg(..)) import UI.User.State.Export as User --- ๐Ÿ“ฃ +-- ๐Ÿ“ฃ โ–‘โ–‘ EVENTS + + +durationChange : DurationChangeEvent -> Manager +durationChange { trackId, duration } = + onlyIfMatchesNowPlaying + { trackId = trackId } + (\nowPlaying model -> + let + ( identifiers, track ) = + nowPlaying.item.identifiedTrack + + maybeCover = + List.find + (\c -> List.member trackId c.trackIds) + model.covers.arranged + + coverPrep = + Maybe.map + (\cover -> + { cacheKey = Base64.encode (Tracks.coverKey cover.variousArtists track) + , trackFilename = identifiers.filename + , trackPath = track.path + , trackSourceId = track.sourceId + , variousArtists = boolToString cover.variousArtists + } + ) + maybeCover + + coverLoaded = + case ( maybeCover, model.cachedCovers ) of + ( Just cover, Just cachedCovers ) -> + let + key = + Base64.encode (Tracks.coverKey cover.variousArtists track) + in + Dict.member key cachedCovers + + _ -> + False + + metadata = + { album = track.tags.album + , artist = track.tags.artist + , title = track.tags.title + + -- + , coverPrep = coverPrep + } + in + model + |> replaceNowPlaying { nowPlaying | coverLoaded = coverLoaded, duration = Just duration } + |> Return.command (Ports.setMediaSessionMetadata metadata) + |> Return.command (Ports.resetScrobbleTimer { duration = duration, trackId = trackId }) + |> Return.andThen (notifyScrobblersOfTrackPlaying { duration = duration }) + ) + + +error : ErrorAudioEvent -> Manager +error { trackId, code } = + onlyIfMatchesNowPlaying + { trackId = trackId } + (\nowPlaying -> + replaceNowPlaying + (case code of + 2 -> + { nowPlaying | loadingState = NetworkError } + + 3 -> + { nowPlaying | loadingState = DecodeError } + + 4 -> + { nowPlaying | loadingState = NotSupportedError } + + _ -> + nowPlaying + ) + ) + + +ended : GenericAudioEvent -> Manager +ended { trackId } = + onlyIfMatchesNowPlaying + { trackId = trackId } + (\nowPlaying model -> + if model.repeat then + Return.command + (case nowPlaying.duration of + Just duration -> + Ports.resetScrobbleTimer { duration = duration, trackId = trackId } + + Nothing -> + Cmd.none + ) + (play model) + else + Return.andThen + (if Maybe.map (\d -> Tracks.shouldNoteProgress { duration = d }) nowPlaying.duration == Just True then + noteProgress { trackId = trackId, progress = 1.0 } + + else + Return.singleton + ) + (Queue.shift model) + ) + + +hasLoaded : GenericAudioEvent -> Manager +hasLoaded { trackId } = + onlyIfMatchesNowPlaying + { trackId = trackId } + (\nowPlaying -> + replaceNowPlaying { nowPlaying | loadingState = Loaded } + ) + + +isLoading : GenericAudioEvent -> Manager +isLoading { trackId } = + onlyIfMatchesNowPlaying + { trackId = trackId } + (\nowPlaying -> + replaceNowPlaying { nowPlaying | loadingState = Loading } + ) + + +playbackStateChanged : PlaybackStateEvent -> Manager +playbackStateChanged { trackId, isPlaying } = + onlyIfMatchesNowPlaying + { trackId = trackId } + (\nowPlaying model -> + { model | nowPlaying = Just { nowPlaying | isPlaying = isPlaying } } + |> Return.singleton + |> Return.command + (if isPlaying then + Ports.startScrobbleTimer () + + else + Ports.pauseScrobbleTimer () + ) + |> Return.command + (Ports.setMediaSessionPlaybackState + (if isPlaying then + MediaSession.states.playing + + else + MediaSession.states.paused + ) + ) + ) + + +timeUpdated : TimeUpdatedEvent -> Manager +timeUpdated { trackId, currentTime, duration } = + onlyIfMatchesNowPlaying + { trackId = trackId } + (\nowPlaying model -> + let + dur = + Maybe.withDefault 0 duration + in + { model | nowPlaying = Just { nowPlaying | duration = duration, playbackPosition = currentTime } } + |> (if Tracks.shouldNoteProgress { duration = dur } then + { trackId = trackId + , progress = currentTime / dur + } + |> NoteProgress + |> Debouncer.provideInput + |> NoteProgressDebounce + |> Return.task + |> Return.communicate -noteProgress : { trackId : String, progress : Float } -> Manager -noteProgress { trackId, progress } model = - let - updatedProgressTable = - if not model.rememberProgress then - model.progress + else + Return.singleton + ) + |> Return.command + (case duration of + Just d -> + Ports.setMediaSessionPositionState + { currentTime = currentTime + , duration = d + } - else if progress > 0.975 then - Dict.remove trackId model.progress + Nothing -> + Cmd.none + ) + ) - else - Dict.insert trackId progress model.progress - in - if model.rememberProgress then - User.saveProgress { model | progress = updatedProgressTable } - else - Return.singleton model + +-- ๐Ÿ“ฃ โ–‘โ–‘ COMMANDS pause : Manager pause model = - return model (Ports.pause ()) + case model.nowPlaying of + Just { item } -> + communicate + (Ports.pause + { trackId = (Tuple.second item.identifiedTrack).id + } + ) + model + + Nothing -> + Return.singleton model playPause : Manager playPause model = - if Maybe.isNothing model.nowPlaying then - Queue.shift model + case model.nowPlaying of + Just { isPlaying } -> + if isPlaying then + pause model - else if model.audioIsPlaying then - communicate (Ports.pause ()) model + else + play model - else - communicate (Ports.play ()) model + Nothing -> + play model play : Manager play model = - if Maybe.isNothing model.nowPlaying then - Queue.shift model + case model.nowPlaying of + Just { item } -> + communicate + (Ports.play + { trackId = (Tuple.second item.identifiedTrack).id + , volume = model.eqSettings.volume + } + ) + model - else - return model (Ports.play ()) + Nothing -> + Queue.shift model -seek : Float -> Manager -seek percentage = - Return.communicate (Ports.seek percentage) +seek : { trackId : String, progress : Float } -> Manager +seek { trackId, progress } = + { percentage = progress, trackId = trackId } + |> Ports.seek + |> Return.communicate -setDuration : Float -> Manager -setDuration duration model = - let - cmd = - case Maybe.map .identifiedTrack model.nowPlaying of - Just ( _, track ) -> - LastFm.nowPlaying model.lastFm - { duration = round duration - , msg = UI.Bypass - , track = track - } +stop : Manager +stop model = + model.audioElements + |> List.filter (.isPreload >> (==) True) + |> (\a -> { model | audioElements = a }) + |> Queue.changeActiveItem Nothing + |> Return.effect_ + (\m -> + Ports.renderAudioElements + { items = m.audioElements + , play = Nothing + , volume = m.eqSettings.volume + } + ) - Nothing -> - Cmd.none - in - return { model | audioDuration = duration } cmd -setHasStalled : Bool -> Manager -setHasStalled hasStalled model = - Return.singleton { model | audioHasStalled = hasStalled } +-- ๐Ÿ“ฃ + + +noteProgress : { trackId : String, progress : Float } -> Manager +noteProgress { trackId, progress } model = + let + updatedProgressTable = + if not model.rememberProgress then + model.progress + else if progress > 0.975 then + Dict.remove trackId model.progress -setIsLoading : Bool -> Manager -setIsLoading isLoading model = - Return.singleton { model | audioIsLoading = isLoading } + else + Dict.insert trackId progress model.progress + in + if model.rememberProgress then + User.saveProgress { model | progress = updatedProgressTable } + else + Return.singleton model -setIsPlaying : Bool -> Manager -setIsPlaying isPlaying model = - Return.singleton { model | audioIsPlaying = isPlaying } +noteProgressDebounce : DebounceManager +noteProgressDebounce = + Common.debounce + .progressDebouncer + (\d m -> { m | progressDebouncer = d }) + UI.NoteProgressDebounce -setPosition : Float -> Manager -setPosition position model = - Return.singleton { model | audioPosition = position } +notifyScrobblersOfTrackPlaying : { duration : Float } -> Manager +notifyScrobblersOfTrackPlaying { duration } model = + case model.nowPlaying of + Just { item } -> + { duration = round duration + , msg = UI.Bypass + , track = Tuple.second item.identifiedTrack + } + |> LastFm.nowPlaying model.lastFm + |> return model -stop : Manager -stop = - communicate (Ports.pause ()) + Nothing -> + Return.singleton model + + +preloadDebounce : DebounceManager +preloadDebounce = + Common.debounce + .preloadDebouncer + (\d m -> { m | preloadDebouncer = d }) + UI.AudioPreloadDebounce toggleRememberProgress : Manager toggleRememberProgress model = User.saveSettings { model | rememberProgress = not model.rememberProgress } + + + +-- ๐Ÿ› ๏ธ + + +onlyIfMatchesNowPlaying : { trackId : String } -> (NowPlaying -> Manager) -> Manager +onlyIfMatchesNowPlaying { trackId } fn model = + case model.nowPlaying of + Just ({ item } as nowPlaying) -> + if trackId == (Tuple.second item.identifiedTrack).id then + fn nowPlaying model + + else + Return.singleton model + + Nothing -> + Return.singleton model + + +replaceNowPlaying : NowPlaying -> Manager +replaceNowPlaying np model = + Return.singleton { model | nowPlaying = Just np } diff --git a/src/Applications/UI/Audio/Types.elm b/src/Applications/UI/Audio/Types.elm new file mode 100644 index 00000000..6b58a017 --- /dev/null +++ b/src/Applications/UI/Audio/Types.elm @@ -0,0 +1,69 @@ +module UI.Audio.Types exposing (..) + +import Queue +import Tracks exposing (IdentifiedTrack) + + + +-- ๐ŸŒณ + + +type AudioLoadingState + = Loading + | Loaded + -- Errors + | DecodeError + | NetworkError + | NotSupportedError + + +type alias CoverPrep = + { cacheKey : String + , trackFilename : String + , trackPath : String + , trackSourceId : String + , variousArtists : String + } + + +type alias NowPlaying = + { coverLoaded : Bool + , duration : Maybe Float + , isPlaying : Bool + , item : Queue.Item + , loadingState : AudioLoadingState + , playbackPosition : Float + } + + + +-- ๐ŸŒณ โ–‘โ–‘ EVENTS + + +type alias DurationChangeEvent = + { trackId : String, duration : Float } + + +type alias ErrorAudioEvent = + { trackId : String, code : Int } + + +type alias GenericAudioEvent = + { trackId : String } + + +type alias PlaybackStateEvent = + { trackId : String, isPlaying : Bool } + + +type alias TimeUpdatedEvent = + { trackId : String, currentTime : Float, duration : Maybe Float } + + + +-- ๐Ÿ› ๏ธ + + +nowPlayingIdentifiedTrack : NowPlaying -> IdentifiedTrack +nowPlayingIdentifiedTrack { item } = + item.identifiedTrack diff --git a/src/Applications/UI/Commands/Alfred.elm b/src/Applications/UI/Commands/Alfred.elm index e6c31923..747b045c 100644 --- a/src/Applications/UI/Commands/Alfred.elm +++ b/src/Applications/UI/Commands/Alfred.elm @@ -70,15 +70,15 @@ miscCommands model = nowPlayingCommands : UI.Model -> List (Item UI.Msg) nowPlayingCommands model = case model.nowPlaying of - Just queueItem -> + Just { item } -> let ( queueItemIdentifiers, _ ) = - queueItem.identifiedTrack + item.identifiedTrack identifiedTrack = model.tracks.harvested |> List.getAt queueItemIdentifiers.indexInList - |> Maybe.withDefault queueItem.identifiedTrack + |> Maybe.withDefault item.identifiedTrack ( identifiers, track ) = identifiedTrack @@ -116,7 +116,7 @@ nowPlayingCommands model = playbackCommands model = - [ if model.audioIsPlaying then + [ if Maybe.map .isPlaying model.nowPlaying == Just True then { icon = Just (Icons.pause 16) , title = "Pause" , value = Command UI.TogglePlay diff --git a/src/Applications/UI/Console.elm b/src/Applications/UI/Console.elm index 0944cd82..a66216ca 100644 --- a/src/Applications/UI/Console.elm +++ b/src/Applications/UI/Console.elm @@ -8,7 +8,8 @@ import Html.Events exposing (on, onClick) import Json.Decode as Decode import Material.Icons.Round as Icons import Material.Icons.Types exposing (Coloring(..)) -import Queue +import Maybe.Extra as Maybe +import UI.Audio.Types exposing (AudioLoadingState(..), NowPlaying, nowPlayingIdentifiedTrack) import UI.Queue.Types as Queue import UI.Tracks.Types as Tracks import UI.Types exposing (Msg(..)) @@ -19,13 +20,11 @@ import UI.Types exposing (Msg(..)) view : - Maybe Queue.Item + Maybe NowPlaying -> Bool -> Bool - -> { stalled : Bool, loading : Bool, playing : Bool } - -> ( Float, Float ) -> Html Msg -view activeQueueItem repeat shuffle { stalled, loading, playing } ( position, duration ) = +view nowPlaying repeat shuffle = chunk [ "antialiased" , "mt-1" @@ -45,49 +44,90 @@ view activeQueueItem repeat shuffle { stalled, loading, playing } ( position, du , "py-4" , "text-white" ] - [ if stalled then - text "Audio connection got interrupted, trying to reconnect ..." - - else if loading then - text "Loading track ..." - - else - case Maybe.map .identifiedTrack activeQueueItem of - Just ( _, { tags } ) -> - slab - Html.span - [ onClick (TracksMsg Tracks.ScrollToNowPlaying) - , title "Scroll to track" - ] - [ "cursor-pointer" ] - [ case tags.artist of - Just artist -> - text (artist ++ " - " ++ tags.title) - - Nothing -> - text tags.title - ] - - Nothing -> - text "Diffuse" + [ case Maybe.map .loadingState nowPlaying of + Nothing -> + text "Diffuse" + + Just Loading -> + text "Loading track ..." + + Just Loaded -> + case Maybe.map nowPlayingIdentifiedTrack nowPlaying of + Just ( _, { tags } ) -> + slab + Html.span + [ onClick (TracksMsg Tracks.ScrollToNowPlaying) + , title "Scroll to track" + ] + [ "cursor-pointer" ] + [ case tags.artist of + Just artist -> + text (artist ++ " - " ++ tags.title) + + Nothing -> + text tags.title + ] + + Nothing -> + text "Diffuse" + + ----------------------------------------- + -- Errors + ----------------------------------------- + Just DecodeError -> + text "(!) An error occurred while decoding the audio" + + Just NetworkError -> + text "Waiting until your internet connection comes back online ..." + + Just NotSupportedError -> + text "(!) Your browser does not support playing this type of audio" + + -- Just NotSupportedOrMissing -> + -- text "The audio is missing or is in a format not supported by your browser." ] ----------------------------------------- -- Progress Bar ----------------------------------------- , let + maybeDuration = + Maybe.andThen .duration nowPlaying + + maybePosition = + Maybe.map .playbackPosition nowPlaying + progress = - if duration <= 0 then - 0 - - else - (position / duration) - |> (*) 100 - |> min 100 - |> max 0 + case ( maybeDuration, maybePosition ) of + ( Just duration, Just position ) -> + if duration <= 0 then + 0 + + else + (position / duration) + |> (*) 100 + |> min 100 + |> max 0 + + _ -> + 0 in brick - [ on "click" (clickLocationDecoder Seek) ] + (case nowPlaying of + Just { item } -> + item.identifiedTrack + |> Tuple.second + |> .id + |> (\id -> + \float -> Seek { progress = float, trackId = id } + ) + |> clickLocationDecoder + |> on "click" + |> List.singleton + + Nothing -> + [] + ) [ "cursor-pointer" , "py-1" ] @@ -137,9 +177,13 @@ view activeQueueItem repeat shuffle { stalled, loading, playing } ( position, du (QueueMsg Queue.Rewind) -- - , button + , let + isPlaying = + Maybe.unwrap False .isPlaying nowPlaying + in + button "" - (largeLight playing) + (largeLight isPlaying) play TogglePlay diff --git a/src/Applications/UI/Notifications.elm b/src/Applications/UI/Notifications.elm index 92f28384..8b4e6343 100644 --- a/src/Applications/UI/Notifications.elm +++ b/src/Applications/UI/Notifications.elm @@ -95,6 +95,7 @@ view extractedBackdropColor collection = -- , "absolute" + , "break-all" , "bottom-0" , "flex" , "flex-col" diff --git a/src/Applications/UI/Other/State.elm b/src/Applications/UI/Other/State.elm index 9a46e699..72e8c909 100644 --- a/src/Applications/UI/Other/State.elm +++ b/src/Applications/UI/Other/State.elm @@ -2,6 +2,7 @@ module UI.Other.State exposing (..) import Alien import Common exposing (ServiceWorkerStatus(..)) +import Dict import Notifications import Return exposing (return) import Time @@ -41,11 +42,37 @@ reloadApp model = setIsOnline : Bool -> Manager setIsOnline bool model = - if bool then - syncHypaethralData { model | isOnline = bool } - - else - Return.singleton { model | isOnline = bool } + { model | isOnline = bool } + |> Return.singleton + |> Return.command + (case model.nowPlaying of + Just { isPlaying, item } -> + let + trackId = + (Tuple.second item.identifiedTrack).id + in + Ports.reloadAudioNodeIfNeeded + { play = isPlaying + , progress = + if model.rememberProgress then + Dict.get trackId model.progress + + else + Nothing + , trackId = trackId + } + + Nothing -> + Cmd.none + ) + |> Return.andThen + (case ( model.isOnline, bool ) of + ( False, True ) -> + syncHypaethralData + + _ -> + Return.singleton + ) setCurrentTime : Time.Posix -> Manager diff --git a/src/Applications/UI/Ports.elm b/src/Applications/UI/Ports.elm index e9ead51f..66f8e1cb 100644 --- a/src/Applications/UI/Ports.elm +++ b/src/Applications/UI/Ports.elm @@ -3,6 +3,7 @@ port module UI.Ports exposing (..) import Alien import Json.Encode as Json import Queue +import UI.Audio.Types as Audio @@ -33,13 +34,19 @@ port loadAlbumCovers : { list : Bool, coverView : Bool } -> Cmd msg port openUrlOnNewPage : String -> Cmd msg -port pause : () -> Cmd msg +port pause : { trackId : String } -> Cmd msg + + +port pauseScrobbleTimer : () -> Cmd msg port pickAverageBackgroundColor : String -> Cmd msg -port play : () -> Cmd msg +port play : { trackId : String, volume : Float } -> Cmd msg + + +port reloadAudioNodeIfNeeded : { play : Bool, progress : Maybe Float, trackId : String } -> Cmd msg port preloadAudio : Queue.EngineItem -> Cmd msg @@ -48,10 +55,31 @@ port preloadAudio : Queue.EngineItem -> Cmd msg port reloadApp : () -> Cmd msg -port seek : Float -> Cmd msg +port renderAudioElements : { items : List Queue.EngineItem, play : Maybe String, volume : Float } -> Cmd msg + + +port resetScrobbleTimer : { duration : Float, trackId : String } -> Cmd msg + + +port seek : { percentage : Float, trackId : String } -> Cmd msg + + +port sendTask : Json.Value -> Cmd msg + +port setMediaSessionArtwork : { blobUrl : String, imageType : String } -> Cmd msg -port setRepeat : Bool -> Cmd msg + +port setMediaSessionMetadata : { album : Maybe String, artist : Maybe String, title : String, coverPrep : Maybe Audio.CoverPrep } -> Cmd msg + + +port setMediaSessionPlaybackState : String -> Cmd msg + + +port setMediaSessionPositionState : { currentTime : Float, duration : Float } -> Cmd msg + + +port startScrobbleTimer : () -> Cmd msg port toBrain : Alien.Event -> Cmd msg @@ -61,7 +89,25 @@ port toBrain : Alien.Event -> Cmd msg -- ๐Ÿ“ฐ -port activeQueueItemEnded : (() -> msg) -> Sub msg +port audioDurationChange : (Audio.DurationChangeEvent -> msg) -> Sub msg + + +port audioEnded : (Audio.GenericAudioEvent -> msg) -> Sub msg + + +port audioError : (Audio.ErrorAudioEvent -> msg) -> Sub msg + + +port audioPlaybackStateChanged : (Audio.PlaybackStateEvent -> msg) -> Sub msg + + +port audioIsLoading : (Audio.GenericAudioEvent -> msg) -> Sub msg + + +port audioHasLoaded : (Audio.GenericAudioEvent -> msg) -> Sub msg + + +port audioTimeUpdated : (Audio.TimeUpdatedEvent -> msg) -> Sub msg port collectedFissionCapabilities : (() -> msg) -> Sub msg @@ -88,15 +134,15 @@ port installedNewServiceWorker : (() -> msg) -> Sub msg port installingNewServiceWorker : (() -> msg) -> Sub msg -port noteProgress : ({ trackId : String, progress : Float } -> msg) -> Sub msg - - port refreshedAccessToken : (Json.Value -> msg) -> Sub msg port preferredColorSchemaChanged : ({ dark : Bool } -> msg) -> Sub msg +port receiveTask : (Json.Value -> msg) -> Sub msg + + port requestNext : (() -> msg) -> Sub msg @@ -118,21 +164,6 @@ port requestStop : (() -> msg) -> Sub msg port scrobble : ({ duration : Int, timestamp : Int, trackId : String } -> msg) -> Sub msg -port setAudioPosition : (Float -> msg) -> Sub msg - - -port setAudioDuration : (Float -> msg) -> Sub msg - - -port setAudioIsLoading : (Bool -> msg) -> Sub msg - - -port setAudioIsPlaying : (Bool -> msg) -> Sub msg - - -port setAudioHasStalled : (Bool -> msg) -> Sub msg - - port setAverageBackgroundColor : ({ r : Int, g : Int, b : Int } -> msg) -> Sub msg diff --git a/src/Applications/UI/Queue/State.elm b/src/Applications/UI/Queue/State.elm index a2e46904..c38c619f 100644 --- a/src/Applications/UI/Queue/State.elm +++ b/src/Applications/UI/Queue/State.elm @@ -1,13 +1,16 @@ module UI.Queue.State exposing (..) import Coordinates +import Debouncer.Basic as Debouncer import Dict import Html.Events.Extra.Mouse as Mouse import List.Extra as List import Notifications import Queue exposing (..) -import Return exposing (andThen, return) +import Return exposing (andThen) +import Return.Ext as Return import Tracks exposing (..) +import UI.Audio.Types exposing (AudioLoadingState(..)) import UI.Common.State as Common import UI.Ports as Ports import UI.Queue.ContextMenu as Queue @@ -27,6 +30,9 @@ update msg = Clear -> clear + PreloadNext -> + preloadNext + Reset -> reset @@ -85,11 +91,25 @@ update msg = changeActiveItem : Maybe Item -> Manager changeActiveItem maybeItem model = + let + maybeNowPlaying = + Maybe.map + (\item -> + { coverLoaded = False + , duration = Nothing + , isPlaying = False + , item = item + , loadingState = Loading + , playbackPosition = 0 + } + ) + maybeItem + in maybeItem - |> Maybe.map - (.identifiedTrack >> Tuple.second) + |> Maybe.map (.identifiedTrack >> Tuple.second) |> Maybe.map (Queue.makeEngineItem + False model.currentTime model.sources model.cachedTracks @@ -100,8 +120,9 @@ changeActiveItem maybeItem model = Dict.empty ) ) - |> Ports.activeQueueItemChanged - |> return { model | nowPlaying = maybeItem } + |> Maybe.map insertTrack + |> Maybe.withDefault Return.singleton + |> (\fn -> fn { model | nowPlaying = maybeNowPlaying }) |> andThen fill @@ -145,7 +166,7 @@ fill model = else let fillState = - { activeItem = m.nowPlaying + { activeItem = Maybe.map .item m.nowPlaying , future = m.playingNext , ignored = m.dontPlay , past = m.playedPreviously @@ -158,7 +179,63 @@ fill model = else { m | playingNext = Fill.ordered timestamp nonMissingTracks fillState } ) - |> preloadNext + |> Return.communicate + (Queue.PreloadNext + |> QueueMsg + |> Debouncer.provideInput + |> AudioPreloadDebounce + |> Return.task + ) + + +insertTrack : EngineItem -> Manager +insertTrack item model = + item + |> (\engineItem -> + if + List.any + (\a -> engineItem.trackId == a.trackId) + model.audioElements + then + List.map + (\a -> + if engineItem.trackId == a.trackId then + { a | isPreload = False } + + else + a + ) + model.audioElements + + else + model.audioElements ++ [ engineItem ] + ) + |> List.filter + (\a -> + if item.isPreload then + True + + else if a.trackId /= item.trackId && not a.isPreload then + False + + else + True + ) + |> (\a -> { model | audioElements = a }) + |> Return.singleton + |> Return.effect_ + (\m -> + Ports.renderAudioElements + { items = m.audioElements + , play = + if item.isPreload then + Nothing + + else + Just item.trackId + , volume = m.eqSettings.volume + } + ) preloadNext : Manager @@ -169,6 +246,7 @@ preloadNext model = |> .identifiedTrack |> Tuple.second |> Queue.makeEngineItem + True model.currentTime model.sources model.cachedTracks @@ -178,8 +256,9 @@ preloadNext model = else Dict.empty ) - |> Ports.preloadAudio - |> return model + |> (\engineItem -> + insertTrack engineItem model + ) Nothing -> Return.singleton model @@ -192,7 +271,7 @@ rewind model = { model | playingNext = model.nowPlaying - |> Maybe.map (\item -> item :: model.playingNext) + |> Maybe.map (\{ item } -> item :: model.playingNext) |> Maybe.withDefault model.playingNext , playedPreviously = model.playedPreviously @@ -226,7 +305,7 @@ shift model = |> List.drop 1 , playedPreviously = model.nowPlaying - |> Maybe.map List.singleton + |> Maybe.map (.item >> List.singleton) |> Maybe.map (List.append model.playedPreviously) |> Maybe.withDefault model.playedPreviously } @@ -273,9 +352,7 @@ showHistoryMenu item mouseEvent model = toggleRepeat : Manager toggleRepeat model = - { model | repeat = not model.repeat } - |> saveEnclosedUserData - |> Return.effect_ (.repeat >> Ports.setRepeat) + saveEnclosedUserData { model | repeat = not model.repeat } toggleShuffle : Manager diff --git a/src/Applications/UI/Queue/Types.elm b/src/Applications/UI/Queue/Types.elm index dcacd4d0..c5a35260 100644 --- a/src/Applications/UI/Queue/Types.elm +++ b/src/Applications/UI/Queue/Types.elm @@ -11,6 +11,7 @@ import Tracks exposing (IdentifiedTrack) type Msg = Clear + | PreloadNext | Reset | Rewind | Select Item diff --git a/src/Applications/UI/Services/State.elm b/src/Applications/UI/Services/State.elm index 26e40be5..b0c7cdc0 100644 --- a/src/Applications/UI/Services/State.elm +++ b/src/Applications/UI/Services/State.elm @@ -56,7 +56,7 @@ gotLastFmSession result model = scrobble : { duration : Int, timestamp : Int, trackId : String } -> Manager scrobble { duration, timestamp, trackId } model = - case Maybe.map .identifiedTrack model.nowPlaying of + case Maybe.map (.item >> .identifiedTrack) model.nowPlaying of Just ( _, track ) -> if trackId == track.id then ( model diff --git a/src/Applications/UI/Tracks/Scene/Covers.elm b/src/Applications/UI/Tracks/Scene/Covers.elm index 9df67c7b..23a71621 100644 --- a/src/Applications/UI/Tracks/Scene/Covers.elm +++ b/src/Applications/UI/Tracks/Scene/Covers.elm @@ -15,7 +15,6 @@ import InfiniteList import Material.Icons.Round as Icons import Material.Icons.Types exposing (Coloring(..)) import Maybe.Extra as Maybe -import Queue import Task import Tracks exposing (..) import UI.Tracks.Scene as Scene @@ -36,7 +35,7 @@ type alias Dependencies = , favouritesOnly : Bool , infiniteList : InfiniteList.Model , isVisible : Bool - , nowPlaying : Maybe Queue.Item + , nowPlaying : Maybe IdentifiedTrack , selectedCover : Maybe Cover , selectedTrackIndexes : List Int , sortBy : SortBy @@ -50,7 +49,7 @@ type alias ItemDependencies = { cachedCovers : Maybe (Dict String String) , columns : Int , containerWidth : Int - , nowPlaying : Maybe Queue.Item + , nowPlaying : Maybe IdentifiedTrack , sortBy : SortBy } @@ -664,7 +663,7 @@ coverView : ItemViewOptions -> ItemDependencies -> Cover -> Html Msg coverView { clickable, horizontal } { cachedCovers, nowPlaying } cover = let nowPlayingId = - Maybe.unwrap "" (.identifiedTrack >> Tuple.second >> .id) nowPlaying + Maybe.unwrap "" (Tuple.second >> .id) nowPlaying missingTracks = List.any diff --git a/src/Applications/UI/Tracks/Scene/List.elm b/src/Applications/UI/Tracks/Scene/List.elm index 72892d66..18264e26 100644 --- a/src/Applications/UI/Tracks/Scene/List.elm +++ b/src/Applications/UI/Tracks/Scene/List.elm @@ -16,7 +16,6 @@ import Json.Decode as Decode import Material.Icons.Round as Icons import Material.Icons.Types exposing (Coloring(..)) import Maybe.Extra as Maybe -import Queue import Task import Tracks exposing (..) import UI.DnD as DnD @@ -48,7 +47,7 @@ type alias DerivedColors = } -view : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe Queue.Item -> Maybe String -> SortBy -> SortDirection -> List Int -> Maybe (DnD.Model Int) -> Html Msg +view : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe IdentifiedTrack -> Maybe String -> SortBy -> SortDirection -> List Int -> Maybe (DnD.Model Int) -> Html Msg view deps harvest infiniteList favouritesOnly nowPlaying searchTerm sortBy sortDirection selectedTrackIndexes maybeDnD = brick (tabindex (ifThenElse deps.isVisible 0 -1) :: viewAttributes) @@ -261,7 +260,7 @@ headerColumn text_ width maybeSortIcon msg = -- INFINITE LIST -infiniteListView : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe String -> ( Maybe Queue.Item, List Int ) -> Maybe (DnD.Model Int) -> Html Msg +infiniteListView : Dependencies -> List IdentifiedTrack -> InfiniteList.Model -> Bool -> Maybe String -> ( Maybe IdentifiedTrack, List Int ) -> Maybe (DnD.Model Int) -> Html Msg infiniteListView deps harvest infiniteList favouritesOnly searchTerm ( nowPlaying, selectedTrackIndexes ) maybeDnD = let derivedColors = @@ -364,7 +363,7 @@ dynamicRowHeight _ ( i, t ) = defaultItemView : { derivedColors : DerivedColors , favouritesOnly : Bool - , nowPlaying : Maybe Queue.Item + , nowPlaying : Maybe IdentifiedTrack , roundedCorners : Bool , selectedTrackIndexes : List Int , showAlbum : Bool @@ -394,7 +393,7 @@ defaultItemView args _ idx identifiedTrack = rowIdentifiers = { isMissing = identifiers.isMissing - , isNowPlaying = Maybe.unwrap False (.identifiedTrack >> isNowPlaying identifiedTrack) nowPlaying + , isNowPlaying = Maybe.unwrap False (isNowPlaying identifiedTrack) nowPlaying , isSelected = isSelected } @@ -480,7 +479,7 @@ defaultItemView args _ idx identifiedTrack = ] -playlistItemView : Bool -> Maybe Queue.Item -> Maybe String -> List Int -> DnD.Model Int -> Bool -> Bool -> DerivedColors -> Int -> Int -> IdentifiedTrack -> Html Msg +playlistItemView : Bool -> Maybe IdentifiedTrack -> Maybe String -> List Int -> DnD.Model Int -> Bool -> Bool -> DerivedColors -> Int -> Int -> IdentifiedTrack -> Html Msg playlistItemView favouritesOnly nowPlaying _ selectedTrackIndexes dnd showAlbum darkMode derivedColors _ idx identifiedTrack = let ( identifiers, track ) = @@ -502,7 +501,7 @@ playlistItemView favouritesOnly nowPlaying _ selectedTrackIndexes dnd showAlbum rowIdentifiers = { isMissing = identifiers.isMissing - , isNowPlaying = Maybe.unwrap False (.identifiedTrack >> isNowPlaying identifiedTrack) nowPlaying + , isNowPlaying = Maybe.unwrap False (isNowPlaying identifiedTrack) nowPlaying , isSelected = isSelected } diff --git a/src/Applications/UI/Tracks/State.elm b/src/Applications/UI/Tracks/State.elm index 8e13aad0..21acebd3 100644 --- a/src/Applications/UI/Tracks/State.elm +++ b/src/Applications/UI/Tracks/State.elm @@ -1,6 +1,7 @@ module UI.Tracks.State exposing (..) import Alien +import Base64 import Common exposing (..) import ContextMenu import Coordinates exposing (Coordinates) @@ -362,18 +363,51 @@ gotCachedCover json model = let cachedCovers = Maybe.withDefault Dict.empty model.cachedCovers + + decodedValue = + Json.decodeValue + (Json.map3 + (\i k u -> ( i, k, u )) + (Json.field "imageType" Json.string) + (Json.field "key" Json.string) + (Json.field "url" Json.string) + ) + json in - json - |> Json.decodeValue - (Json.map2 - Tuple.pair - (Json.field "key" Json.string) - (Json.field "url" Json.string) - ) - |> Result.map (\( key, url ) -> Dict.insert key url cachedCovers) + decodedValue + |> Result.map (\( _, key, url ) -> Dict.insert key url cachedCovers) |> Result.map (\dict -> { model | cachedCovers = Just dict }) |> Result.withDefault model - |> Return.singleton + |> (\m -> + case ( m.nowPlaying, decodedValue ) of + ( Just nowPlaying, Ok val ) -> + let + ( imageType, key, url ) = + val + + ( _, track ) = + nowPlaying.item.identifiedTrack + + hasntLoadedYet = + nowPlaying.coverLoaded == False + + ( keyA, keyB ) = + ( Base64.encode (Tracks.coverKey False track) + , Base64.encode (Tracks.coverKey True track) + ) + + keyMatches = + keyA == key || keyB == key + in + if hasntLoadedYet && keyMatches then + ( m, Ports.setMediaSessionArtwork { blobUrl = url, imageType = imageType } ) + + else + Return.singleton m + + _ -> + Return.singleton m + ) groupBy : Tracks.Grouping -> Manager @@ -727,7 +761,7 @@ scrollToNowPlaying : Manager scrollToNowPlaying model = model.nowPlaying |> Maybe.map - (.identifiedTrack >> Tuple.second >> .id) + (.item >> .identifiedTrack >> Tuple.second >> .id) |> Maybe.andThen (\id -> List.find diff --git a/src/Applications/UI/Tracks/View.elm b/src/Applications/UI/Tracks/View.elm index d2d6d536..992dff73 100644 --- a/src/Applications/UI/Tracks/View.elm +++ b/src/Applications/UI/Tracks/View.elm @@ -15,6 +15,7 @@ import Material.Icons.Types exposing (Coloring(..)) import Maybe.Extra as Maybe import Playlists exposing (Playlist) import Tracks exposing (..) +import UI.Audio.Types exposing (nowPlayingIdentifiedTrack) import UI.Kit import UI.Navigation exposing (..) import UI.Page as Page @@ -95,7 +96,7 @@ view model = , favouritesOnly = model.favouritesOnly , infiniteList = model.infiniteList , isVisible = isOnIndexPage - , nowPlaying = model.nowPlaying + , nowPlaying = Maybe.map nowPlayingIdentifiedTrack model.nowPlaying , selectedCover = model.selectedCover , selectedTrackIndexes = model.selectedTrackIndexes , sortBy = model.sortBy @@ -126,7 +127,7 @@ view model = model.tracks.harvested model.infiniteList model.favouritesOnly - model.nowPlaying + (Maybe.map nowPlayingIdentifiedTrack model.nowPlaying) model.searchTerm model.sortBy model.sortDirection diff --git a/src/Applications/UI/Types.elm b/src/Applications/UI/Types.elm index 2bc1362f..004eeffa 100644 --- a/src/Applications/UI/Types.elm +++ b/src/Applications/UI/Types.elm @@ -26,6 +26,7 @@ import Random import Sources exposing (Source) import Time import Tracks exposing (..) +import UI.Audio.Types exposing (DurationChangeEvent, ErrorAudioEvent, GenericAudioEvent, NowPlaying, PlaybackStateEvent, TimeUpdatedEvent) import UI.DnD as DnD import UI.Page exposing (Page) import UI.Queue.Types as Queue @@ -83,11 +84,8 @@ type alias Model = ----------------------------------------- -- Audio ----------------------------------------- - , audioDuration : Float - , audioHasStalled : Bool - , audioIsLoading : Bool - , audioIsPlaying : Bool - , audioPosition : Float + , audioElements : List Queue.EngineItem + , nowPlaying : Maybe NowPlaying , progress : Dict String Float , rememberProgress : Bool @@ -102,6 +100,8 @@ type alias Model = ----------------------------------------- -- Debouncing ----------------------------------------- + , preloadDebouncer : Debouncer Msg Msg + , progressDebouncer : Debouncer Msg Msg , resizeDebouncer : Debouncer Msg Msg , searchDebouncer : Debouncer Msg Msg @@ -132,7 +132,6 @@ type alias Model = -- Queue ----------------------------------------- , dontPlay : List Queue.Item - , nowPlaying : Maybe Queue.Item , playedPreviously : List Queue.Item , playingNext : List Queue.Item , selectedQueueItem : Maybe Queue.Item @@ -199,15 +198,19 @@ type Msg ----------------------------------------- -- Audio ----------------------------------------- + | AudioDurationChange DurationChangeEvent + | AudioError ErrorAudioEvent + | AudioEnded GenericAudioEvent + | AudioHasLoaded GenericAudioEvent + | AudioIsLoading GenericAudioEvent + | AudioPlaybackStateChanged PlaybackStateEvent + | AudioPreloadDebounce (Debouncer.Msg Msg) + | AudioTimeUpdated TimeUpdatedEvent | NoteProgress { trackId : String, progress : Float } + | NoteProgressDebounce (Debouncer.Msg Msg) | Pause | Play - | Seek Float - | SetAudioDuration Float - | SetAudioHasStalled Bool - | SetAudioIsLoading Bool - | SetAudioIsPlaying Bool - | SetAudioPosition Float + | Seek { trackId : String, progress : Float } | Stop | TogglePlay | ToggleRememberProgress diff --git a/src/Applications/UI/User/State/Import.elm b/src/Applications/UI/User/State/Import.elm index 1bb3a35a..ccc5d78a 100644 --- a/src/Applications/UI/User/State/Import.elm +++ b/src/Applications/UI/User/State/Import.elm @@ -18,7 +18,6 @@ import UI.Demo as Demo import UI.Equalizer.State as Equalizer import UI.Page as Page import UI.Playlists.Directory -import UI.Ports as Ports import UI.Sources.State as Sources import UI.Tracks.State as Tracks import UI.Types as UI exposing (..) @@ -223,10 +222,7 @@ importEnclosed value model = , sortDirection = data.sortDirection } -- - , Cmd.batch - [ Equalizer.adjustAllKnobs newEqualizerSettings - , Ports.setRepeat data.repeat - ] + , Equalizer.adjustAllKnobs newEqualizerSettings ) Err err -> diff --git a/src/Applications/UI/View.elm b/src/Applications/UI/View.elm index 90827355..f578939c 100644 --- a/src/Applications/UI/View.elm +++ b/src/Applications/UI/View.elm @@ -193,13 +193,6 @@ defaultScreen model = model.nowPlaying model.repeat model.shuffle - { stalled = model.audioHasStalled - , loading = model.audioIsLoading - , playing = model.audioIsPlaying - } - ( model.audioPosition - , model.audioDuration - ) ] diff --git a/src/Javascript/Brain/application.ts b/src/Javascript/Brain/application.ts new file mode 100644 index 00000000..eeb2156d --- /dev/null +++ b/src/Javascript/Brain/application.ts @@ -0,0 +1,24 @@ +import "./index.d" + +// @ts-ignore +import { Elm } from "brain.elm.js" + + +// ๐Ÿš€ + + +const flags: Record = location + .hash + .substring(1) + .split("&") + .reduce((acc, flag) => { + const [k, v] = flag.split("=") + return { ...acc, [k]: v } + }, {}) + + +export const load = () => Elm.Brain.init({ + flags: { + initialUrl: decodeURIComponent(flags.appHref) || "" + } +}) diff --git a/src/Javascript/Brain/artwork.ts b/src/Javascript/Brain/artwork.ts index 1301fb2c..fd8e9c89 100644 --- a/src/Javascript/Brain/artwork.ts +++ b/src/Javascript/Brain/artwork.ts @@ -2,15 +2,122 @@ // Album Covers // (โ—•โ€ฟโ—•โœฟ) +import * as Uint8arrays from "uint8arrays" + +import * as processing from "./processing" +import { type App } from "./elm/types" import { transformUrl } from "../urls" -import * as processing from "../processing" +import { toCache } from "./common" +import { type CoverPrep } from "../common" + + +// ๐ŸŒณ + + +type CoverPrepWithUrls = CoverPrep & { + trackGetUrl: string + trackHeadUrl: string +} + + + +// ๐Ÿ”๏ธ + + +let artworkQueue: CoverPrep[] = [] +let app: App + + + +// ๐Ÿš€ + + +export function init(a: App) { + app = a + + app.ports.provideArtworkTrackUrls.subscribe(provideArtworkTrackUrls) +} + + + +// PORTS + + +function provideArtworkTrackUrls(prep: CoverPrepWithUrls) { + find(prep).then(blob => { + return toCache(`coverCache.${prep.cacheKey}`, blob).then(_ => blob) + }) + .then((blob: Blob) => { + const url = URL.createObjectURL(blob) + + self.postMessage({ + tag: "GOT_CACHED_COVER", + data: { imageType: blob.type, key: prep.cacheKey, url: url }, + error: null + }) + }) + .catch(err => { + if (err === "No artwork found") { + // Indicate that we've tried to find artwork, + // so that we don't try to find it each time we launch the app. + return toCache(`coverCache.${prep.cacheKey}`, "TRIED") + + } else { + // Something went wrong + console.error(err) + return toCache(`coverCache.${prep.cacheKey}`, "TRIED") + + } + }) + .catch(() => { + console.warn("Failed to download artwork for ", prep) + }) + .finally(shiftQueue) +} + + + +// ๐Ÿ› ๏ธ + + +export function download(list: CoverPrep[]) { + const exe = !artworkQueue[0] + artworkQueue = artworkQueue.concat(list) + if (exe) shiftQueue() +} + + +function shiftQueue() { + const next = artworkQueue.shift() + + if (next) { + app.ports.makeArtworkTrackUrls.send(next) + } else { + self.postMessage({ + action: "FINISHED_DOWNLOADING_ARTWORK", + data: null + }) + } +} + + + +// ใŠ™๏ธ const REJECT = () => Promise.reject("No artwork found") -export function find(prep, app) { - return findUsingTags(prep, app) +function decodeCacheKey(cacheKey: string) { + return Uint8arrays.toString( + Uint8arrays.fromString(cacheKey, "base64"), + "utf8" + ) +} + + +function find(prep: CoverPrepWithUrls) { + return findUsingTags(prep) .then(a => a ? a : findUsingMusicBrainz(prep)) .then(a => a ? a : findUsingLastFm(prep)) .then(a => a ? a : REJECT()) @@ -18,16 +125,11 @@ export function find(prep, app) { } -function decodeCacheKey(cacheKey) { - return decodeURIComponent(escape(atob(cacheKey))) -} - - // 1. TAGS -async function findUsingTags(prep, app) { +async function findUsingTags(prep: CoverPrepWithUrls) { return Promise.all( [ transformUrl(prep.trackHeadUrl, app), @@ -53,7 +155,9 @@ async function findUsingTags(prep, app) { // 2. MUSIC BRAINZ -function findUsingMusicBrainz(prep) { +function findUsingMusicBrainz(prep: CoverPrepWithUrls) { + if (!navigator.onLine) return null + const parts = decodeCacheKey(prep.cacheKey).split(" --- ") const artist = parts[ 0 ] const album = parts[ 1 ] || parts[ 0 ] @@ -64,7 +168,6 @@ function findUsingMusicBrainz(prep) { return fetch(`https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`) .then(r => r.json()) .then(r => musicBrainzCover(r.releases)) - .catch(_ => REJECT()) } @@ -90,13 +193,16 @@ function musicBrainzCover(remainingReleases) { // 3. LAST FM -function findUsingLastFm(prep) { - const query = decodeCacheKey(prep.cacheKey).replace(" --- ", " ") +function findUsingLastFm(prep: CoverPrepWithUrls) { + if (!navigator.onLine) return null + + const query = encodeURIComponent( + decodeCacheKey(prep.cacheKey).replace(" --- ", " ") + ) return fetch(`https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`) .then(r => r.json()) .then(r => lastFmCover(r.results.albummatches.album)) - .catch(_ => REJECT()) } diff --git a/src/Javascript/Brain/common.ts b/src/Javascript/Brain/common.ts index 7675bc5e..f61bedd5 100644 --- a/src/Javascript/Brain/common.ts +++ b/src/Javascript/Brain/common.ts @@ -13,7 +13,7 @@ export const SECRET_KEY_LOCATION = "SECRET_KEY" // ๐Ÿ”ฑ -export function isLocalHost(url) { +export function isLocalHost(url: string) { return ( url.startsWith("localhost") || url.startsWith("localhost") || @@ -23,7 +23,7 @@ export function isLocalHost(url) { } -export function parseJsonIfNeeded(a) { +export function parseJsonIfNeeded(a: unknown) { if (typeof a === "string") return JSON.parse(a) return a } @@ -57,17 +57,17 @@ export function sendData(app, event, opts: any = {}) { // Cache // ----- -export function removeCache(key: string) { +export function removeCache(key: string): Promise { return db().removeItem(key) } -export function fromCache(key: string) { +export function fromCache(key: string): Promise { return db().getItem(key) } -export function toCache(key: string, data: unknown) { +export function toCache(key: string, data: unknown): Promise { return db().setItem(key, data) } @@ -76,11 +76,11 @@ export function toCache(key: string, data: unknown) { // Crypto // ------ -export function decryptIfNeeded(data) { +export function decryptIfNeeded(data: unknown): Promise { if (typeof data !== "string") { return Promise.resolve(data) - } else if (data.startsWith("{") || data.startsWith("[")) { + } else if (typeof data === "string" && (data.startsWith("{") || data.startsWith("["))) { return Promise.resolve(data) } else if (data.length < 15 && Number.isInteger(parseInt(data, 10))) { @@ -100,9 +100,11 @@ export function decryptIfNeeded(data) { export async function encryptIfPossible(unencryptedData: string): Promise { return unencryptedData - ? getSecretKey() - .then(secretKey => crypto.encrypt(secretKey, unencryptedData)) - .catch(_ => unencryptedData) + ? getSecretKey().then(secretKey => + secretKey + ? crypto.encrypt(secretKey, unencryptedData) + : unencryptedData + ) : unencryptedData } @@ -110,6 +112,6 @@ export async function encryptIfPossible(unencryptedData: string): Promise { return db().getItem(SECRET_KEY_LOCATION) } diff --git a/src/Javascript/Brain/elm/types.ts b/src/Javascript/Brain/elm/types.ts new file mode 100644 index 00000000..e5a05984 --- /dev/null +++ b/src/Javascript/Brain/elm/types.ts @@ -0,0 +1,10 @@ +export type App = any // TODO: ElmApp + + +export type ElmPorts = { + // โ† Elm + // ... + + // โ†’ Elm + fromAlien: PortToElm +} diff --git a/src/Javascript/Brain/index.d.ts b/src/Javascript/Brain/index.d.ts new file mode 100644 index 00000000..d1860d30 --- /dev/null +++ b/src/Javascript/Brain/index.d.ts @@ -0,0 +1,14 @@ +import type { ElmPorts } from "./elm/types" + + +export { } + + +declare const Elm: { Brain: ElmMain } +declare const BUILD_TIMESTAMP: string + + +declare module "elm-taskport" { + const install: () => void + const register: (a: string, b: (arg: any) => any) => void +} diff --git a/src/Javascript/Brain/index.ts b/src/Javascript/Brain/index.ts index 42f265c7..4444ef91 100644 --- a/src/Javascript/Brain/index.ts +++ b/src/Javascript/Brain/index.ts @@ -4,370 +4,36 @@ // // This worker is responsible for everything non-UI. -import type { } from "../index.d" - -// @ts-ignore -import * as TaskPort from "elm-taskport" - -import * as artwork from "./artwork" -import * as processing from "../processing" -import * as user from "./user" - -import { db } from "../common" -import { fromCache, removeCache, reportError } from "./common" -import { sendData, toCache } from "./common" -import { transformUrl } from "../urls" - -// @ts-ignore -import { Elm } from "brain.elm.js" - - -// ๐Ÿฑ - - -let app -const wire: any = {} - - -TaskPort.install() - - -TaskPort.register("fromCache", fromCache) -TaskPort.register("removeCache", removeCache) -TaskPort.register("toCache", ({ key, value }) => toCache(key, value)) - - -user.setupTaskPorts() - - - -// UI -// == - -wire.ui = () => { - app.ports.toUI.subscribe(event => { - self.postMessage(event) - }) -} - - -self.onmessage = event => { - if (event.data.action) return handleAction(event.data.action, event.data.data) - if (event.data.tag) return app.ports.fromAlien.send(event.data) -} - - -function handleAction(action, data) { - switch (action) { - case "DOWNLOAD_ARTWORK": return downloadArtwork(data) - } -} - - - -// Cache -// ----- - -wire.caching = () => { - app.ports.removeCache.subscribe(event => { - removeCache(event.tag) - .catch(reportError(app, event)) - }) - - app.ports.requestCache.subscribe(event => { - const key = event.data && event.data.file - ? event.tag + "_" + event.data.file - : event.tag - - fromCache(key) - .then(sendData(app, event)) - .catch(reportError(app, event)) - }) - - app.ports.toCache.subscribe(event => { - const key = event.data && event.data.file - ? event.tag + "_" + event.data.file - : event.tag - - toCache(key, event.data.data || event.data) - .catch(reportError(app, event)) - }) -} - - - -// Cache (Artwork) -// --------------- - -let artworkQueue = [] - - -wire.artworkCaching = () => { - app.ports.provideArtworkTrackUrls.subscribe(provideArtworkTrackUrls) -} - - -function downloadArtwork(list) { - const exe = !artworkQueue[0] - artworkQueue = artworkQueue.concat(list) - if (exe) shiftArtworkQueue() -} - - -function shiftArtworkQueue() { - const next = artworkQueue.shift() - - if (next) { - app.ports.makeArtworkTrackUrls.send(next) - } else { - self.postMessage({ - action: "FINISHED_DOWNLOADING_ARTWORK", - data: null - }) - } -} - - -function provideArtworkTrackUrls(prep) { - artwork - .find(prep, app) - .then(blob => { - const url = URL.createObjectURL(blob) - - self.postMessage({ - tag: "GOT_CACHED_COVER", - data: { key: prep.cacheKey, url: url }, - error: null - }) - - return toCache(`coverCache.${prep.cacheKey}`, blob) - }) - .catch(err => { - if (err === "No artwork found") { - // Indicate that we've tried to find artwork, - // so that we don't try to find it each time we launch the app. - return toCache(`coverCache.${prep.cacheKey}`, "TRIED") - - } else { - // Something went wrong - reportError(app, { tag: "REPORT_ERROR" })(err) - - } - }) - .catch(() => { - console.warn("Failed to download artwork for ", prep) - }) - .finally(shiftArtworkQueue) -} - - - -// Cache (Tracks) -// -------------- - -wire.tracksCaching = () => { - app.ports.removeTracksFromCache.subscribe(removeTracksFromCache) - app.ports.storeTracksInCache.subscribe(storeTracksInCache) -} - - -function removeTracksFromCache(trackIds) { - trackIds.reduce( - (acc, id) => acc.then(_ => db("tracks").removeItem(id)), - Promise.resolve() - - ).catch( - _ => reportError - (app, { tag: "REMOVE_TRACKS_FROM_CACHE" }) - ("Failed to remove tracks from cache") - - ) -} - - -function storeTracksInCache(list) { - list.reduce( - (acc, item) => { - return acc - .then(_ => transformUrl(item.url, app)) - .then(fetch) - .then(r => r.blob()) - .then(b => db("tracks").setItem(item.trackId, b)) - }, - Promise.resolve() - - ).then( - _ => self.postMessage({ - tag: "STORE_TRACKS_IN_CACHE", - data: list.map(l => l.trackId), - error: null - }) - - ).catch( - err => { - console.error(err) - self.postMessage({ - tag: "STORE_TRACKS_IN_CACHE", - data: list.map(l => l.trackId), - error: err.message || err - }) - } - - ) -} - - - -// Downloading -// ----------- - -wire.downloading = () => { - app.ports.downloadTracks.subscribe(group => { - self.postMessage({ - action: "DOWNLOAD_TRACKS", - data: group - }) - }) -} - - - -// Search -// ------ - -const search = new Worker( - "../../search.js", - { type: "module" } -) - - -wire.search = () => { - app.ports.requestSearch.subscribe(requestSearch) - app.ports.updateSearchIndex.subscribe(updateSearchIndex) -} - - -function requestSearch(searchTerm) { - search.postMessage({ - action: "PERFORM_SEARCH", - data: searchTerm - }) -} - - -function updateSearchIndex(tracksJson) { - search.postMessage({ - action: "UPDATE_SEARCH_INDEX", - data: tracksJson - }) -} - - -search.onmessage = event => { - switch (event.data.action) { - case "PERFORM_SEARCH": - app.ports.receiveSearchResults.send(event.data.data) - break - } -} - - - -// Tags -// ---- - -wire.tags = () => { - app.ports.requestTags.subscribe(context => { - processing.processContext(context, app).then(newContext => { - app.ports.receiveTags.send(newContext) - }) - }) - - app.ports.syncTags.subscribe(context => { - processing.processContext(context, app).then(newContext => { - app.ports.replaceTags.send(newContext) - }) - }) -} - +import * as Application from "./application" +import * as Artwork from "./artwork" +import * as Processing from "./processing" +import * as Search from "./search" +import * as User from "./user" +import * as TaskPorts from "./task-ports" +import * as Tracks from "./tracks" +import * as UI from "./ui" // ๐Ÿš€ +TaskPorts.register() +User.TaskPorts.register() -const flags: Record = location - .hash - .substr(1) - .split("&") - .reduce((acc, flag) => { - const [k, v] = flag.split("=") - return { ...acc, [k]: v } - }, {}) - - -forwardCompatibility().then(initialise) - - -function initialise() { - app = Elm.Brain.init({ - flags: { - initialUrl: decodeURIComponent(flags.appHref) || "" - } - }) - - user.setupPorts(app) - - wire.ui() - wire.caching() - wire.artworkCaching() - wire.tracksCaching() - wire.downloading() - wire.search() - wire.tags() - - self.postMessage({ action: "READY" }) -} - - -async function forwardCompatibility() { - // TODO: Future, check version to migrate - if (await fromCache("MIGRATED")) return - - await moveOldDbValue({ oldName: "AUTH_SECRET_KEY", newName: "SECRET_KEY" }) - await moveOldDbValue({ oldName: "AUTH_ENCLOSED_DATA", newName: "ENCLOSED_DATA" }) - - const method = await fromCache("AUTH_METHOD") - - if (method === "LOCAL") { - await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_favourites.json", newName: "SYNC_LOCAL_favourites.json", parseJSON: true }) - await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_playlists.json", newName: "SYNC_LOCAL_playlists.json", parseJSON: true }) - await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_progress.json", newName: "SYNC_LOCAL_progress.json", parseJSON: true }) - await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_settings.json", newName: "SYNC_LOCAL_settings.json", parseJSON: true }) - await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_sources.json", newName: "SYNC_LOCAL_sources.json", parseJSON: true }) - await moveOldDbValue({ oldName: "AUTH_ANONYMOUS_tracks.json", newName: "SYNC_LOCAL_tracks.json", parseJSON: true }) +const app = Application.load() +const brain = self as unknown as Worker - await removeCache("AUTH_METHOD") +// ๐Ÿ–ผ๏ธ - } else if (method) { - await toCache("SYNC_METHOD", method) - await removeCache("AUTH_METHOD") +UI.link(brain, app) - } +// โšก +Artwork.init(app) +Processing.init(app) +Search.init(app) +Tracks.init(app) - await toCache("MIGRATED", "3.3.0") -} +User.Ports.register(app) +// ๐Ÿ›ซ -async function moveOldDbValue( - { oldName, newName, parseJSON }: { - oldName: string - newName: string - parseJSON?: boolean - } -) { - const value = await fromCache(oldName) - if (value && typeof value === "string") { - await toCache(newName, parseJSON ? JSON.parse(value) : value) - await removeCache(oldName) - } -} +brain.postMessage({ action: "READY" }) diff --git a/src/Javascript/processing.ts b/src/Javascript/Brain/processing.ts similarity index 73% rename from src/Javascript/processing.ts rename to src/Javascript/Brain/processing.ts index f7a1641e..29992293 100644 --- a/src/Javascript/processing.ts +++ b/src/Javascript/Brain/processing.ts @@ -4,15 +4,58 @@ // // Audio processing, getting metadata, etc. -import type { IAudioMetadata } from "music-metadata"; -import type { MediaInfoType } from "mediainfo.js"; +import type { IAudioMetadata } from "music-metadata" +import type { GeneralTrack, MediaInfoResult } from "mediainfo.js" + +import * as Uint8arrays from "uint8arrays" +import { type App } from "./elm/types" +import { transformUrl } from "../urls" + + +// ๐Ÿ”๏ธ + + +const ENCODING_ISSUE_REPLACE_CHAR = 'โ–ฉ'; + +let app: App + + + +// ๐Ÿš€ + + +export function init(a: App) { + app = a + + app.ports.requestTags.subscribe(requestTags) + app.ports.syncTags.subscribe(syncTags) +} + + + +// Ports +// ----- + + +function requestTags(context) { + processContext(context, app).then(newContext => { + app.ports.receiveTags.send(newContext) + }) +} + + +function syncTags(context) { + processContext(context, app).then(newContext => { + app.ports.replaceTags.send(newContext) + }) +} + -import * as Uint8arrays from "uint8arrays"; -import { transformUrl } from "./urls"; // Contexts // -------- + export async function processContext(context, app) { const initialPromise = Promise.resolve([]); @@ -40,9 +83,12 @@ export async function processContext(context, app) { }); } + + // Tags - General // -------------- + type Tags = { disc: number; nr: number; @@ -63,8 +109,8 @@ export async function getTags( const musicMetadata = await import("music-metadata-browser").then((a) => a.default); const httpTokenizer = await import("@tokenizer/http").then((a) => a.default); - let tokenizer - let mmResult + let tokenizer; + let mmResult; try { tokenizer = await httpTokenizer.makeTokenizer(headUrl); @@ -78,15 +124,14 @@ export async function getTags( tokenizer.rangeRequestClient.resolvedUrl = undefined; } - mmResult = await musicMetadata.parseFromTokenizer( - tokenizer, - { skipCovers: !covers } - ).catch(err => { - console.warn(err) - return null - }); + mmResult = await musicMetadata + .parseFromTokenizer(tokenizer, { skipCovers: !covers }) + .catch((err) => { + console.warn(err); + return null; + }); } catch (err) { - console.warn(err) + console.warn(err); } const mmTags = mmResult && pickTagsFromMusicMetadata(filename, mmResult); @@ -94,9 +139,9 @@ export async function getTags( const miResult = await (await mediaInfoClient(covers)) .analyzeData(getSize(headUrl), readChunk(getUrl)) - .catch(err => { - console.warn(err) - return null + .catch((err) => { + console.warn(err); + return null; }); const miTags = miResult && pickTagsFromMediaInfo(filename, miResult); @@ -164,23 +209,24 @@ const readChunk = return new Uint8Array(await response.arrayBuffer()); }; -function pickTagsFromMediaInfo(filename: string, result: MediaInfoType): Tags | null { - const tags = result?.media?.track?.filter((t) => t["@type"] === "General")[0]; - if (!tags) return null; +function pickTagsFromMediaInfo(filename: string, result: MediaInfoResult): Tags | null { + const tagsRaw = result?.media?.track?.filter((t) => t["@type"] === "General")[0]; + const tags = tagsRaw === undefined ? undefined : tagsRaw as GeneralTrack; + if (tags === undefined) return null; let artist = typeof tags.Performer == "string" ? tags.Performer : null; - const album = typeof tags.Album == "string" ? tags.Album : null; + let album = typeof tags.Album == "string" ? tags.Album : null; - const title = typeof tags.Track == "string" - ? tags.Track - : typeof tags.Title == "string" - ? tags.Title - : null; + let title = + typeof tags.Track == "string" ? tags.Track : typeof tags.Title == "string" ? tags.Title : null; if (!artist && !title) return null; // TODO: Encoding issues with mediainfo.js - if (artist?.includes("๏ฟฝ") || album?.includes("๏ฟฝ") || title?.includes("๏ฟฝ")) return null + // https://github.com/buzz/mediainfo.js/issues/150 + if (artist?.includes("๏ฟฝ")) artist = artist.replace("๏ฟฝ", ENCODING_ISSUE_REPLACE_CHAR) + if (album?.includes("๏ฟฝ")) album = album.replace("๏ฟฝ", ENCODING_ISSUE_REPLACE_CHAR) + if (title?.includes("๏ฟฝ")) title = title.replace("๏ฟฝ", ENCODING_ISSUE_REPLACE_CHAR) if (artist && artist.includes(" / ")) { artist = artist @@ -201,16 +247,18 @@ function pickTagsFromMediaInfo(filename: string, result: MediaInfoType): Tags | year: year !== null && isNaN(year) ? null : year, picture: tags.Cover_Data ? { - data: Uint8arrays.fromString(tags.Cover_Data, "base64"), + data: Uint8arrays.fromString(tags.Cover_Data.split(" / ")[0], "base64pad"), format: tags.Cover_Mime || "image/jpeg", } : null, }; } + // Tags - Music Metadata // --------------------- + function pickTagsFromMusicMetadata(filename: string, result: IAudioMetadata): Tags | null { const tags = result && result.common; if (!tags) return null; @@ -235,11 +283,13 @@ function pickTagsFromMusicMetadata(filename: string, result: IAudioMetadata): Ta }; } + + // ๐Ÿ› ๏ธ -// -- + async function mediaInfoClient(covers: boolean) { - const MediaInfoFactory = await import("mediainfo.js").then(a => a.default) + const MediaInfoFactory = await import("mediainfo.js").then((a) => a.default); return await MediaInfoFactory({ coverData: covers, diff --git a/src/Javascript/Brain/search.ts b/src/Javascript/Brain/search.ts new file mode 100644 index 00000000..b2b978b5 --- /dev/null +++ b/src/Javascript/Brain/search.ts @@ -0,0 +1,54 @@ +import type { App } from "./elm/types" + + +// ๐Ÿ”๏ธ + + +let app: App + + + +// ๐Ÿš€ + + +export function init(a: App) { + app = a + + app.ports.requestSearch.subscribe(requestSearch) + app.ports.updateSearchIndex.subscribe(updateSearchIndex) +} + + +const search = new Worker( + "../../search.js", + { type: "module" } +) + + +search.onmessage = event => { + switch (event.data.action) { + case "PERFORM_SEARCH": + app.ports.receiveSearchResults.send(event.data.data) + break + } +} + + + +// PORTS + + +function requestSearch(searchTerm: string) { + search.postMessage({ + action: "PERFORM_SEARCH", + data: searchTerm + }) +} + + +function updateSearchIndex(tracksJson: string) { + search.postMessage({ + action: "UPDATE_SEARCH_INDEX", + data: tracksJson + }) +} diff --git a/src/Javascript/Brain/task-ports.ts b/src/Javascript/Brain/task-ports.ts new file mode 100644 index 00000000..ea7d6bf9 --- /dev/null +++ b/src/Javascript/Brain/task-ports.ts @@ -0,0 +1,13 @@ +// @ts-ignore +import * as TaskPort from "elm-taskport" + +import { fromCache, removeCache, toCache } from "./common" + + +export function register() { + TaskPort.install() + + TaskPort.register("fromCache", fromCache) + TaskPort.register("removeCache", removeCache) + TaskPort.register("toCache", ({ key, value }) => toCache(key, value)) +} diff --git a/src/Javascript/Brain/tracks.ts b/src/Javascript/Brain/tracks.ts new file mode 100644 index 00000000..530eaa91 --- /dev/null +++ b/src/Javascript/Brain/tracks.ts @@ -0,0 +1,81 @@ +import type { App } from "./elm/types" +import { db } from "../common" +import { reportError } from "./common" +import { transformUrl } from "../urls" + + +// ๐Ÿ”๏ธ + + +let app: App + + + +// ๐Ÿš€ + + +export function init(a: App) { + app = a + + app.ports.downloadTracks.subscribe(downloadTracks) + app.ports.removeTracksFromCache.subscribe(removeTracksFromCache) + app.ports.storeTracksInCache.subscribe(storeTracksInCache) +} + + + +// PORTS + + +function downloadTracks(group) { + self.postMessage({ + action: "DOWNLOAD_TRACKS", + data: group + }) +} + + +function removeTracksFromCache(trackIds) { + trackIds.reduce( + (acc, id) => acc.then(_ => db("tracks").removeItem(id)), + Promise.resolve() + + ).catch( + _ => reportError + (app, { tag: "REMOVE_TRACKS_FROM_CACHE" }) + ("Failed to remove tracks from cache") + + ) +} + + +function storeTracksInCache(list) { + list.reduce( + (acc, item) => { + return acc + .then(_ => transformUrl(item.url, app)) + .then(fetch) + .then(r => r.blob()) + .then(b => db("tracks").setItem(item.trackId, b)) + }, + Promise.resolve() + + ).then( + _ => self.postMessage({ + tag: "STORE_TRACKS_IN_CACHE", + data: list.map(l => l.trackId), + error: null + }) + + ).catch( + err => { + console.error(err) + self.postMessage({ + tag: "STORE_TRACKS_IN_CACHE", + data: list.map(l => l.trackId), + error: err.message || err + }) + } + + ) +} diff --git a/src/Javascript/Brain/ui.ts b/src/Javascript/Brain/ui.ts new file mode 100644 index 00000000..46c104f4 --- /dev/null +++ b/src/Javascript/Brain/ui.ts @@ -0,0 +1,21 @@ +import type { App } from "./elm/types" +import * as Artwork from "./artwork" + + +export function link(worker: Worker, app: App) { + app.ports.toUI.subscribe(event => { + worker.postMessage(event) + }) + + worker.onmessage = event => { + if (event.data.action) return handleAction(event.data.action, event.data.data) + if (event.data.tag) return app.ports.fromAlien.send(event.data) + } + + + function handleAction(action: string, data: unknown) { + switch (action) { + case "DOWNLOAD_ARTWORK": return Artwork.download(data) + } + } +} diff --git a/src/Javascript/Brain/user.ts b/src/Javascript/Brain/user.ts index e9534a76..da7bbff4 100644 --- a/src/Javascript/Brain/user.ts +++ b/src/Javascript/Brain/user.ts @@ -7,10 +7,12 @@ // @ts-ignore import * as TaskPort from "elm-taskport" -import { APP_INFO, ODD_CONFIG } from "../common" + +import type { App } from "./elm/types" import * as crypto from "../crypto" +import { APP_INFO, ODD_CONFIG } from "../common" import { decryptIfNeeded, encryptIfPossible, SECRET_KEY_LOCATION } from "./common" import { parseJsonIfNeeded, removeCache, toCache } from "./common" @@ -299,16 +301,20 @@ taskPorts.toRemoteStorage = ({ data, fileName, userAddress, token }) => { // EXPORT // ====== -export function setupPorts(app) { +function registerPorts(app: App) { Object.keys(ports).forEach(name => { const fn = ports[ name ](app) app.ports[ name ].subscribe(fn) }) } -export function setupTaskPorts() { +function registerTaskPorts() { Object.keys(taskPorts).forEach(name => { const fn = taskPorts[ name ] TaskPort.register(name, fn) }) } + + +export const TaskPorts = { register: registerTaskPorts } +export const Ports = { register: registerPorts } diff --git a/src/Javascript/UI/application.ts b/src/Javascript/UI/application.ts new file mode 100644 index 00000000..58acdd26 --- /dev/null +++ b/src/Javascript/UI/application.ts @@ -0,0 +1,106 @@ +import "./index.d" +import type { App } from "./elm/types" +import { version } from "../../../package.json" + + +// ๐Ÿ”๏ธ + + +let app: App +let channel: BroadcastChannel + + + +// ๐Ÿš€ + + +export const load = ({ isNativeWrapper, reg }: { isNativeWrapper: boolean, reg: ServiceWorkerRegistration }) => Elm.UI.init({ + node: document.getElementById("elm") || undefined, + flags: { + buildTimestamp: BUILD_TIMESTAMP, + darkMode: preferredColorScheme().matches, + initialTime: Date.now(), + isInstallingServiceWorker: !!reg.installing, + isOnline: navigator.onLine, + isTauri: isNativeWrapper, + version, + viewport: { + height: window.innerHeight, + width: window.innerWidth + } + } +}) + + +export function init(a: App, c: BroadcastChannel) { + app = a + channel = c + + app.ports.downloadJsonUsingTauri.subscribe(downloadJsonUsingTauri) + app.ports.openUrlOnNewPage.subscribe(openUrlOnNewPage) + app.ports.reloadApp.subscribe(reloadApp) +} + + + +// ๐ŸŒ— + + +function preferredColorScheme() { + const m = + window.matchMedia && + window.matchMedia("(prefers-color-scheme: dark)") + + m?.addEventListener("change", e => { + app.ports.preferredColorSchemaChanged.send({ dark: e.matches }) + }) + + return m +} + + + +// PORTS + + +async function downloadJsonUsingTauri( + { filename, json }: { filename: string, json: string } +) { + const { save } = await import("@tauri-apps/plugin-dialog") + const { writeTextFile } = await import("@tauri-apps/plugin-fs") + const { BaseDirectory } = await import("@tauri-apps/api/path") + + const filePath = await save({ defaultPath: filename }) + await writeTextFile(filePath || filename, json, { baseDir: BaseDirectory.Download }) +} + + +function openUrlOnNewPage(url: string) { + if (globalThis.__TAURI__) { + globalThis.__TAURI__.shell.open( + url.includes("://") ? url : `${location.origin}/${url.replace(/^\.\//, "")}` + ) + + } else { + window.open(url, "_blank") + + } +} + + +function reloadApp() { + const timeout = setTimeout(async () => { + const reg = await navigator.serviceWorker.getRegistration() + if (reg?.waiting) reg.waiting.postMessage("skipWaiting") + window.location.reload() + }, 250) + + channel.addEventListener("message", event => { + if (event.data === "PONG") { + clearTimeout(timeout) + alert("โš ๏ธ You can only update the app when you have no more than one instance open.") + } + }) + + channel.postMessage("PING") +} diff --git a/src/Javascript/UI/artwork.ts b/src/Javascript/UI/artwork.ts new file mode 100644 index 00000000..14e5cefa --- /dev/null +++ b/src/Javascript/UI/artwork.ts @@ -0,0 +1,111 @@ +import { debounce } from "throttle-debounce" + +import type { App } from "./elm/types" +import { type CoverPrep, db } from "../common" + + + +// ๐Ÿ”๏ธ + + +let app: App +let brain: Worker + + + +// ๐Ÿš€ + + +export function init(a: App, b: Worker) { + app = a + brain = b + + app.ports.loadAlbumCovers.subscribe( + debounce(500, loadAlbumCoversFromDom) + ) + + db().keys().then(cachedCovers) +} + + + +// ๐Ÿ› ๏ธ + + +export function albumCover(coverKey: string): Promise { + return db().getItem(`coverCache.${coverKey}`) +} + + +async function loadAlbumCoversFromDom({ coverView, list }: { coverView: boolean, list: boolean }): Promise { + let nodes: HTMLElement[] = [] + + if (list) nodes = nodes.concat(Array.from( + document.querySelectorAll("#diffuse__track-covers [data-key]") + )) + + if (coverView) nodes = nodes.concat(Array.from( + document.querySelectorAll("#diffuse__track-covers + div [data-key]") + )) + + if (!nodes.length) return; + + const coverPrepList = nodes.reduce((acc: CoverPrep[], node: HTMLElement) => { + const a = { + cacheKey: node.getAttribute("data-key"), + trackFilename: node.getAttribute("data-filename"), + trackPath: node.getAttribute("data-path"), + trackSourceId: node.getAttribute("data-source-id"), + variousArtists: node.getAttribute("data-various-artists") + } + + if (a.cacheKey && a.trackFilename && a.trackPath && a.trackSourceId && a.variousArtists) { + return [...acc, a as CoverPrep] + } else { + return acc + } + }, [] as CoverPrep[]) + + return loadAlbumCovers(coverPrepList) +} + + +export async function loadAlbumCovers(coverPrepList: CoverPrep[]): Promise { + const withoutEarlierAttempts = await coverPrepList.reduce(async ( + acc: Promise, + prep: CoverPrep + ): Promise => { + const arr = await acc + const a = await albumCover(prep.cacheKey) + if (!a) return [...arr, prep] + return arr + }, Promise.resolve([])) + + brain.postMessage({ + action: "DOWNLOAD_ARTWORK", + data: withoutEarlierAttempts + }) +} + + +// Send a dictionary of the cached covers to the app. +async function cachedCovers(keys: string[]) { + const cacheKeys = keys.filter( + k => k.startsWith("coverCache.") + ) + + const cache = await cacheKeys.reduce(async (acc, key) => { + const c = await acc + const blob = await db().getItem(key) + const cacheKey = key.slice(11) + + if (blob && typeof blob !== "string" && blob instanceof Blob) { + c[cacheKey] = URL.createObjectURL(blob) + } + + return c + }, Promise.resolve({})) + + app.ports.insertCoverCache.send(cache) + setTimeout(() => loadAlbumCoversFromDom({ list: true, coverView: true }), 500) +} diff --git a/src/Javascript/UI/audio.ts b/src/Javascript/UI/audio.ts new file mode 100644 index 00000000..825a258f --- /dev/null +++ b/src/Javascript/UI/audio.ts @@ -0,0 +1,513 @@ +// +// Audio engine +// โ™ช(ยดฮต๏ฝ€ ) + +import type { App } from "./elm/types" + +import Timer from "timer.js" +import { debounce } from "throttle-debounce" +import { CoverPrep, db, mimeType } from "../common" +import { albumCover, loadAlbumCovers } from "./artwork" + + +// ๐Ÿ”๏ธ + + +const silentMp3File = + "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV" + + +let app: App +let container: Element | null = null +let scrobbleTimer: Timer | null = null + + + +// ๐Ÿš€ + + +export function init(a: App) { + app = a + + app.ports.adjustEqualizerSetting.subscribe(adjustEqualizerSetting) + app.ports.pause.subscribe(pause) + app.ports.pauseScrobbleTimer.subscribe(pauseScrobbleTimer) + app.ports.play.subscribe(play) + app.ports.reloadAudioNodeIfNeeded.subscribe(reloadAudioNodeIfNeeded) + app.ports.renderAudioElements.subscribe(renderAudioElements) + app.ports.resetScrobbleTimer.subscribe(resetScrobbleTimer) + app.ports.seek.subscribe(seek) + app.ports.setMediaSessionArtwork.subscribe(setMediaSessionArtwork) + app.ports.setMediaSessionMetadata.subscribe(setMediaSessionMetadata) + app.ports.setMediaSessionPlaybackState.subscribe(setMediaSessionPlaybackState) + app.ports.setMediaSessionPositionState.subscribe(setMediaSessionPositionState) + app.ports.startScrobbleTimer.subscribe(startScrobbleTimer) +} + + + +// ๐ŸŒณ + + +/** + * Javascript representation of `Queue.EngineItem` in Elm. + */ +type EngineItem = { + isCached: boolean + isPreload: boolean + progress: number | null + sourceId: string + trackId: string + trackTags: TrackTags + trackPath: string + url: string +} + + +/**/ +type TrackTags = { + disc: number + nr: number + + // Main + album: string | null + artist: string | null + title: string + + // Extra + genre: string | null + picture: string | null + year: number | null +} + + + +// Ports +// ----- + + +function adjustEqualizerSetting({ knob, value }: { knob: string, value: number }): void { + switch (knob) { + case "VOLUME": + Array.from( + document.body.querySelectorAll('#audio-elements audio[data-is-preload="false"]'), + ).forEach((audio) => ((audio as HTMLAudioElement).volume = value)) + break + } +} + + +function pause({ trackId }: { trackId: string }) { + withAudioNode(trackId, (audio) => audio.pause()) +} + + +function pauseScrobbleTimer() { + if (this.scrobbleTimer) this.scrobbleTimer.pause() +} + + +function play({ trackId, volume }: { trackId: string, volume: number }) { + withAudioNode(trackId, (audio) => { + audio.volume = volume + audio.muted = false + + if (audio.readyState === 0) audio.load() + + const promise = audio.play() || Promise.resolve() + + promise.catch((e) => { + const err = "Couldn't play audio automatically. Please resume playback manually." + console.error(err, e) + if (app) app.ports.fromAlien.send({ tag: "", data: null, error: err }) + }) + }) +} + + +async function reloadAudioNodeIfNeeded(args: { play: boolean, progress: number | null, trackId: string }) { + withAudioNode(args.trackId, (audio) => { + if (audio.readyState === 0 || audio.error?.code === 2) { + audio.load() + + if (args.progress) { + audio.setAttribute("data-initial-progress", JSON.stringify(args.progress)) + } + + if (args.play) { + play({ trackId: args.trackId, volume: audio.volume }) + } + } + }) +} + + +async function renderAudioElements(args: { + items: Array + play: string | null + volume: number +}) { + await render(args.items) + if (args.play) play({ trackId: args.play, volume: args.volume }) +} + + +function resetScrobbleTimer({ duration, trackId }: { duration: number, trackId: string }) { + const timestamp = Math.round(Date.now() / 1000) + const scrobbleTimeoutDuration = Math.min(240 + 0.5, duration / 1.95) + + if (this.scrobbleTimer) this.scrobbleTimer.stop() + + scrobbleTimer = new Timer({ + onend: () => + app.ports.scrobble.send({ + duration: Math.round(duration), + timestamp, + trackId, + }), + }) + + scrobbleTimer.start(scrobbleTimeoutDuration) +} + + +function seek({ percentage, trackId }: { percentage: number, trackId: string }) { + withAudioNode(trackId, (audio) => { + if (!isNaN(audio.duration)) { + audio.currentTime = audio.duration * percentage + } + }) +} + + +async function setMediaSessionArtwork({ blobUrl, imageType }: { blobUrl: string, imageType: string }) { + const artwork: MediaImage[] = [{ + src: blobUrl, + type: imageType + }] + + navigator.mediaSession.metadata = new MediaMetadata({ + title: navigator.mediaSession.metadata?.title, + artist: navigator.mediaSession.metadata?.artist, + album: navigator.mediaSession.metadata?.album, + artwork: artwork, + }) +} + + +async function setMediaSessionMetadata({ + album, + artist, + title, + + coverPrep, +}: { + album: string | null + artist: string | null + title: string + + coverPrep: CoverPrep | null +}) { + let artwork: MediaImage[] = [] + + if (coverPrep) { + const blob = await albumCover(coverPrep.cacheKey) + + artwork = blob + ? [{ + src: URL.createObjectURL(blob), + type: blob.type + }] + : [] + + if (!blob) { + // Download artwork and set it later + loadAlbumCovers([coverPrep]) + } + } + + navigator.mediaSession.metadata = new MediaMetadata({ + title, + artist: artist || undefined, + album: album || undefined, + artwork: artwork, + }) +} + + +function setMediaSessionPlaybackState(state: MediaSessionPlaybackState) { + if (navigator.mediaSession) navigator.mediaSession.playbackState = state +} + + +function setMediaSessionPositionState({ + currentTime, + duration, +}: { + currentTime: number + duration: number +}) { + try { + navigator?.mediaSession?.setPositionState({ + duration: duration, + position: currentTime, + }) + } catch (_err) { + // + } +} + + +function startScrobbleTimer() { + if (this.scrobbleTimer) this.scrobbleTimer.start() +} + + + +// Media Keys +// ---------- + + +if ("mediaSession" in navigator) { + navigator.mediaSession.setActionHandler("play", () => { + app.ports.requestPlay.send(null) + }) + + navigator.mediaSession.setActionHandler("pause", () => { + app.ports.requestPause.send(null) + }) + + navigator.mediaSession.setActionHandler("previoustrack", () => { + app.ports.requestPrevious.send(null) + }) + + navigator.mediaSession.setActionHandler("nexttrack", () => { + app.ports.requestNext.send(null) + }) + + navigator.mediaSession.setActionHandler("seekbackward", (event) => { + const seekOffset = event.seekOffset || 10 + withActiveAudioNode( + (audio) => (audio.currentTime = Math.max(audio.currentTime - seekOffset, 0)), + ) + }) + + navigator.mediaSession.setActionHandler("seekforward", (event) => { + const seekOffset = event.seekOffset || 10 + withActiveAudioNode( + (audio) => (audio.currentTime = Math.min(audio.currentTime + seekOffset, audio.duration)), + ) + }) + + navigator.mediaSession.setActionHandler("seekto", (event) => { + withActiveAudioNode((audio) => (audio.currentTime = event.seekTime || audio.currentTime)) + }) +} + + + +// ๐Ÿ–ผ๏ธ + + +async function render(items: Array) { + if (!container) { + container = document.createElement("div") + container.id = "audio-elements" + container.className = "absolute h-0 invisible left-0 pointer-events-none top-0 w-0" + + document.body.appendChild(container) + } + + const trackIds = items.map((e) => e.trackId) + const existingNodes = {} + + // Manage existing nodes + Array.from(container.querySelectorAll("audio")).map((node: HTMLAudioElement) => { + if (trackIds.includes(node.id)) { + existingNodes[node.id] = node + } else { + node.src = silentMp3File + container?.removeChild(node) + } + }) + + // Adjust existing and add new + await items.reduce(async (acc: Promise, item: EngineItem) => { + await acc + + if (existingNodes[item.trackId]) { + existingNodes[item.trackId].setAttribute( + "data-is-preload", + item.isPreload ? "true" : "false", + ) + } else { + await createElement(item) + } + }, Promise.resolve()) +} + + +export async function createElement(item: EngineItem) { + const url = item.isCached + ? await db("tracks") + .getItem(item.trackId) + .then((blob) => (blob ? URL.createObjectURL(blob as Blob) : item.url)) + : item.url + + // Mime + SRC + const fileName = item.trackPath.split("/").reverse()[0] + const fileExtMatch = fileName.match(/\.(\w+)$/) + const fileExt = fileExtMatch && fileExtMatch[1] + const mime = fileExt ? mimeType(fileExt) : null + + const source = document.createElement("source") + if (mime) source.setAttribute("type", mime) + source.setAttribute("src", url) + + // Audio node + const audio = new Audio() + audio.setAttribute("id", item.trackId) + audio.setAttribute("crossorigin", "anonymous") + audio.setAttribute("data-initial-progress", JSON.stringify(item.progress)) + audio.setAttribute("data-is-preload", item.isPreload ? "true" : "false") + audio.setAttribute("muted", "true") + audio.setAttribute("preload", "auto") + audio.appendChild(source) + + audio.addEventListener("canplay", canplayEvent) + audio.addEventListener("durationchange", durationchangeEvent) + audio.addEventListener("ended", endedEvent) + audio.addEventListener("error", errorEvent) + audio.addEventListener("pause", pauseEvent) + audio.addEventListener("play", playEvent) + audio.addEventListener("suspend", suspendEvent) + audio.addEventListener("timeupdate", timeupdateEvent) + audio.addEventListener("waiting", debounce(1500, waitingEvent)) + + container?.appendChild(audio) +} + + + +// ๐Ÿ–ผ โ–‘โ–‘ EVENTS + + +function canplayEvent(event: Event) { + const target = event.target as HTMLAudioElement + + if (target.hasAttribute("data-initial-progress") && target.duration && !isNaN(target.duration)) { + const progress = JSON.parse(target.getAttribute("data-initial-progress") as string) + target.currentTime = target.duration * progress + target.removeAttribute("data-initial-progress") + } + + finishedLoading(event) +} + + +function durationchangeEvent(event: Event) { + const target = event.target as HTMLAudioElement + + if (!isNaN(target.duration)) { + app.ports.audioDurationChange.send({ + trackId: target.id, + duration: target.duration, + }) + } +} + +function endedEvent(event: Event) { + app.ports.audioEnded.send({ + trackId: (event.target as HTMLAudioElement).id, + }) +} + +function errorEvent(event: Event) { + const audio = event.target as HTMLAudioElement + + app.ports.audioError.send({ + trackId: audio.id, + code: audio.error?.code || 0 + }) +} + + +function pauseEvent(event: Event) { + app.ports.audioPlaybackStateChanged.send({ + trackId: (event.target as HTMLAudioElement).id, + isPlaying: false, + }) +} + + +function playEvent(event: Event) { + const audio = event.target as HTMLAudioElement + + app.ports.audioPlaybackStateChanged.send({ + trackId: audio.id, + isPlaying: true, + }) + + // In case audio was preloaded: + if (audio.readyState === 4) finishedLoading(event) +} + + +function suspendEvent(event: Event) { + finishedLoading(event) +} + + +function timeupdateEvent(event: Event) { + const target = event.target as HTMLAudioElement + + app.ports.audioTimeUpdated.send({ + trackId: target.id, + currentTime: target.currentTime, + duration: isNaN(target.duration) ? null : target.duration, + }) +} + + +function waitingEvent(event: Event) { + initiateLoading(event) +} + + + +// ๐Ÿ› ๏ธ + + +function finishedLoading(event: Event) { + app.ports.audioHasLoaded.send({ + trackId: (event.target as HTMLAudioElement).id, + }) +} + + +function initiateLoading(event: Event) { + const audio = event.target as HTMLAudioElement + + if (audio.readyState < 4) + app.ports.audioIsLoading.send({ + trackId: audio.id, + }) +} + + +function withActiveAudioNode(fn: (node: HTMLAudioElement) => void): void { + const nonPreloadNodes: HTMLAudioElement[] = Array.from( + document.body.querySelectorAll(`#audio-elements audio[data-is-preload="false"]`), + ) + const playingNodes = nonPreloadNodes.filter((n) => n.paused === false) + const node = playingNodes.length ? playingNodes[0] : nonPreloadNodes[0] + if (node) fn(node) +} + + +function withAudioNode(trackId: string, fn: (node: HTMLAudioElement) => void): void { + const node = document.body.querySelector( + `#audio-elements audio[id="${trackId}"][data-is-preload="false"]`, + ) + if (node) fn(node as HTMLAudioElement) +} diff --git a/src/Javascript/UI/backdrop.ts b/src/Javascript/UI/backdrop.ts new file mode 100644 index 00000000..ee5daff7 --- /dev/null +++ b/src/Javascript/UI/backdrop.ts @@ -0,0 +1,46 @@ +// ๐Ÿš€ + + +export function init(app) { + app.ports.pickAverageBackgroundColor.subscribe((src: string) => { + const avgColor = pickAverageBackgroundColor(src) + if (avgColor) app.ports.setAverageBackgroundColor.send(avgColor) + }) +} + + + +// ๐Ÿ› ๏ธ + + +function averageColorOfImage(img: HTMLImageElement): { r: number, g: number, b: number } | null { + const canvas = document.createElement("canvas") + const ctx = canvas.getContext("2d") + canvas.width = img.naturalWidth + canvas.height = img.naturalHeight + + if (!ctx) return null + + ctx.drawImage(img, 0, 0) + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) + const color = { r: 0, g: 0, b: 0 } + + for (let i = 0, l = imageData.data.length; i < l; i += 4) { + color.r += imageData.data[i] + color.g += imageData.data[i + 1] + color.b += imageData.data[i + 2] + } + + color.r = Math.floor(color.r / (imageData.data.length / 4)) + color.g = Math.floor(color.g / (imageData.data.length / 4)) + color.b = Math.floor(color.b / (imageData.data.length / 4)) + + return color +} + + +function pickAverageBackgroundColor(src: string): { r: number, g: number, b: number } | null { + const img = document.querySelector(`img[src$="${src}"]`) + return img ? averageColorOfImage(img as HTMLImageElement) : null +} diff --git a/src/Javascript/UI/brain.ts b/src/Javascript/UI/brain.ts new file mode 100644 index 00000000..bfb6efa2 --- /dev/null +++ b/src/Javascript/UI/brain.ts @@ -0,0 +1,39 @@ +import type { App } from "./elm/types" +import * as Tracks from "./tracks" + + +export async function load(): Promise { + const brain = new Worker( + "./js/brain/index.js#appHref=" + encodeURIComponent(window.location.href), + { type: "module" } + ) + + await new Promise((resolve, reject) => { + brain.onmessage = event => { + if (event.data.action === "READY") resolve(null) + } + + brain.addEventListener("error", () => { + reject("Failed to load web worker.
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`") + }) + }) + + // Fin + return brain +} + + +export function link({ app, brain }: { app: App, brain: Worker }) { + function handleAction(action, data, _ports) { + switch (action) { + case "DOWNLOAD_TRACKS": return Tracks.download(data) + } + } + + brain.onmessage = event => { + if (event.data.action) return handleAction(event.data.action, event.data.data, event.ports) + if (event.data.tag) app.ports.fromAlien.send(event.data) + } + + app.ports.toBrain.subscribe(a => brain.postMessage(a)) +} diff --git a/src/Javascript/UI/broadcast.ts b/src/Javascript/UI/broadcast.ts new file mode 100644 index 00000000..5e4be211 --- /dev/null +++ b/src/Javascript/UI/broadcast.ts @@ -0,0 +1,11 @@ +export function channel() { + const bc = new BroadcastChannel(`diffuse-${location.hostname}`) + + bc.addEventListener("message", event => { + switch (event.data) { + case "PING": return bc.postMessage("PONG") + } + }) + + return bc +} diff --git a/src/Javascript/UI/elm/types.ts b/src/Javascript/UI/elm/types.ts new file mode 100644 index 00000000..d4ab9a50 --- /dev/null +++ b/src/Javascript/UI/elm/types.ts @@ -0,0 +1,11 @@ +export type App = any // TODO: ElmApp + + +export type ElmPorts = { + // โ† Elm + openUrlOnNewPage: PortFromElm + + // โ†’ Elm + fromAlien: PortToElm + indicateTouchDevice: PortToElm +} diff --git a/src/Javascript/UI/errors.ts b/src/Javascript/UI/errors.ts new file mode 100644 index 00000000..b15472cd --- /dev/null +++ b/src/Javascript/UI/errors.ts @@ -0,0 +1,20 @@ +export function failure(text: string): void { + const note = document.createElement("div") + + note.className = "flex flex-col font-body items-center h-screen italic justify-center leading-relaxed px-4 text-center text-base text-white" + note.innerHTML = ` + + +

+ ${text} +

+ ` + + document.body.appendChild(note) + + // Remove loader + const elm = document.querySelector("#elm") + elm?.parentNode?.removeChild(elm) +} diff --git a/src/Javascript/UI/index.d.ts b/src/Javascript/UI/index.d.ts new file mode 100644 index 00000000..6e505a79 --- /dev/null +++ b/src/Javascript/UI/index.d.ts @@ -0,0 +1,10 @@ +import type { ElmPorts } from "./elm/types" + +export { } + +declare global { + const BUILD_TIMESTAMP: string + + const Elm: { UI: ElmMain } + const tocca: any +} diff --git a/src/Javascript/UI/index.ts b/src/Javascript/UI/index.ts new file mode 100644 index 00000000..30746ac7 --- /dev/null +++ b/src/Javascript/UI/index.ts @@ -0,0 +1,61 @@ +// +// | (โ€ข โ—กโ€ข)| (โแดฅโส‹) +// +// The bit where we launch the Elm apps & workers, +// and connect the other bits to it. + +import "./pointer-events" + +import * as Application from "./application" +import * as Artwork from "./artwork" +import * as Audio from "./audio" +import * as Backdrop from "./backdrop" +import * as Brain from "./brain" +import * as Broadcast from "./broadcast" +import * as Errors from "./errors" +import * as Misc from "./misc" +import * as ServiceWorker from "./service-worker" +import * as Tracks from "./tracks" +import * as UserLayer from "./user-layer" + + + +// ๐ŸŒธ + + +const isNativeWrapper = !!globalThis.__TAURI__ + + + +// ๐Ÿš€ + + +ServiceWorker + .load({ isNativeWrapper }) + .then(async (reg: ServiceWorkerRegistration) => { + const brain = await Brain.load() + const app = Application.load({ isNativeWrapper, reg }) + const channel = Broadcast.channel() + + // ๐Ÿง‘โ€๐Ÿญ + ServiceWorker.link({ + app, isNativeWrapper, reg + }) + + // ๐Ÿง  + Brain.link({ + app, brain + }) + + // โšก + Application.init(app, channel) + Artwork.init(app, brain) + Audio.init(app) + Backdrop.init(app) + Misc.init(app) + Tracks.init(app) + UserLayer.init(app) + }) + .catch( + Errors.failure + ) diff --git a/src/Javascript/UI/misc.ts b/src/Javascript/UI/misc.ts new file mode 100644 index 00000000..e96c9e04 --- /dev/null +++ b/src/Javascript/UI/misc.ts @@ -0,0 +1,96 @@ +import type { App } from "./elm/types" + + +// ๐Ÿ”๏ธ + + +let app: App + + + +// ๐Ÿš€ + + +export function init(a: App) { + app = a + + app.ports.copyToClipboard.subscribe(copyToClipboard) +} + + + +// Clipboard +// --------- + + +async function copyToClipboard(text: string) { + navigator.clipboard.writeText(text) +} + + + +// Focus +// ----- + +window.addEventListener("blur", event => { + if (app && event.target === window) app.ports.lostWindowFocus.send(null) +}) + + + +// Forms +// ----- +// Adds a `changed` attribute to form fields, if the form was "changed". +// This is to help with styling, we don't want to show an error immediately. + +const FIELD_SELECTOR = "input, textarea" + + +document.addEventListener("keyup", e => { + const field = e.target && (e.target).closest(FIELD_SELECTOR) + if (field) field.setAttribute("changed", "") +}) + + +document.addEventListener("click", e => { + if (!e.target || (e.target).tagName !== "BUTTON") return; + const form = (e.target).closest("form") + if (form) markAllFormFieldsAsChanged(form) +}) + + +document.addEventListener("submit", e => { + const form = e.target && (e.target).closest("form") + if (form) markAllFormFieldsAsChanged(form) +}) + + +function markAllFormFieldsAsChanged(form) { + [].slice.call(form.querySelectorAll(FIELD_SELECTOR)).forEach(field => { + field.setAttribute("changed", "") + }) +} + + + +// Internet Connection +// ------------------- + +window.addEventListener("online", onlineStatusChanged) +window.addEventListener("offline", onlineStatusChanged) + + +function onlineStatusChanged() { + if (app) app.ports.setIsOnline.send(navigator.onLine) +} + + + +// Touch Device +// ------------ + +window.addEventListener("touchstart", function onFirstTouch() { + if (!app) return + app.ports.indicateTouchDevice.send() + window.removeEventListener("touchstart", onFirstTouch, false) +}, false) diff --git a/src/Javascript/UI/pointer-events.ts b/src/Javascript/UI/pointer-events.ts new file mode 100644 index 00000000..0a152211 --- /dev/null +++ b/src/Javascript/UI/pointer-events.ts @@ -0,0 +1,116 @@ +import "./index.d" +import "tocca" + +// Pointer Events +// -------------- +// Thanks to https://github.com/mpizenberg/elm-pep/ + +let enteredElement + + +tocca({ + dbltapThreshold: 400, + tapThreshold: 250 +}) + + +function mousePointerEvent(eventType, mouseEvent) { + const pointerEvent: any = new MouseEvent(eventType, mouseEvent) + pointerEvent.pointerId = 1 + pointerEvent.isPrimary = true + pointerEvent.pointerType = "mouse" + pointerEvent.width = 1 + pointerEvent.height = 1 + pointerEvent.tiltX = 0 + pointerEvent.tiltY = 0 + + "buttons" in mouseEvent && mouseEvent.buttons !== 0 + ? (pointerEvent.pressure = 0.5) + : (pointerEvent.pressure = 0) + + return pointerEvent +} + + +function touchPointerEvent(eventType, touchEvent, touch) { + const pointerEvent: any = new CustomEvent(eventType, { + bubbles: true, + cancelable: true + }) + + pointerEvent.ctrlKey = touchEvent.ctrlKey + pointerEvent.shiftKey = touchEvent.shiftKey + pointerEvent.altKey = touchEvent.altKey + pointerEvent.metaKey = touchEvent.metaKey + + pointerEvent.clientX = touch.clientX + pointerEvent.clientY = touch.clientY + pointerEvent.screenX = touch.screenX + pointerEvent.screenY = touch.screenY + pointerEvent.pageX = touch.pageX + pointerEvent.pageY = touch.pageY + + const rect = touch.target.getBoundingClientRect() + pointerEvent.offsetX = touch.clientX - rect.left + pointerEvent.offsetY = touch.clientY - rect.top + pointerEvent.pointerId = 1 + touch.identifier + + pointerEvent.button = 0 + pointerEvent.buttons = 1 + pointerEvent.movementX = 0 + pointerEvent.movementY = 0 + pointerEvent.region = null + pointerEvent.relatedTarget = null + pointerEvent.x = pointerEvent.clientX + pointerEvent.y = pointerEvent.clientY + + pointerEvent.pointerType = "touch" + pointerEvent.width = 1 + pointerEvent.height = 1 + pointerEvent.tiltX = 0 + pointerEvent.tiltY = 0 + pointerEvent.pressure = 1 + pointerEvent.isPrimary = true + + return pointerEvent +} + + +// Simulate `pointerenter` and `pointerleave` event for non-touch devices +if (!self.PointerEvent) { + document.addEventListener("mouseover", event => { + const section = document.body.querySelector("section") + const isDragging = section && section.classList.contains("dragging-something") + const node = isDragging && document.elementFromPoint(event.clientX, event.clientY) + + if (node && node != enteredElement) { + enteredElement && enteredElement.dispatchEvent(mousePointerEvent("pointerleave", event)) + node.dispatchEvent(mousePointerEvent("pointerenter", event)) + enteredElement = node + } + }) +} + + +// Simulate `pointerenter` and `pointerleave` event for touch devices +document.body.addEventListener("touchmove", event => { + const section = document.body.querySelector("section") + const isDragging = section && section.classList.contains("dragging-something") + const touch = event.touches[0] + + let node + + if (isDragging && touch) { + node = document.elementFromPoint(touch.clientX, touch.clientY) + } + + if (node && node != enteredElement) { + enteredElement && enteredElement.dispatchEvent(touchPointerEvent("pointerleave", event, touch)) + node.dispatchEvent(touchPointerEvent("pointerenter", event, touch)) + enteredElement = node + } + + if (isDragging) { + event.stopPropagation() + } +}) diff --git a/src/Javascript/UI/service-worker.ts b/src/Javascript/UI/service-worker.ts new file mode 100644 index 00000000..42f14ea3 --- /dev/null +++ b/src/Javascript/UI/service-worker.ts @@ -0,0 +1,112 @@ +import type { App } from "./elm/types" + + +/** + * Load: + * + * 1. Redirect to HTTPS if using the `diffuse.sh` domain (subdomains included). + * 2. Fail if not a secure context. + * 3. Set up service worker, ensure it's ready and then continue initialisation. + */ +export async function load({ isNativeWrapper } : { isNativeWrapper: boolean }): Promise { + return new Promise((resolve, reject) => { + if (location.hostname.endsWith("diffuse.sh") && location.protocol === "http:") { + location.href = location.href.replace("http://", "https://") + reject("Just a moment, redirecting to HTTPS.") + + } else if (!self.isSecureContext) { + reject(` + This app only works on a secure context, HTTPS & localhost, and modern browsers. + `) + + } else if ("serviceWorker" in navigator) { + // Service worker + window.addEventListener("load", () => { + navigator.serviceWorker + .getRegistrations() + .then(async registrations => { + const serverIsOnline = navigator.onLine && await fetch(`${location.origin}?ping=1`) + .then(r => r.text()) + .then(a => a === "false" ? false : true) + + if (isNativeWrapper) await Promise.all( + registrations.map(r => r.unregister()) + ) + + if (serverIsOnline) return navigator.serviceWorker.register( + "service-worker.js", + { type: "module" } + ) + + if (registrations[0]) return registrations + + throw new Error("Web server is offline") + }) + .then(() => navigator.serviceWorker.ready) + .then(resolve) + .catch(err => { + const isFirefox = navigator.userAgent.toLowerCase().includes("firefox") + + console.error(err) + return reject( + location.protocol === "https:" || location.hostname === "localhost" + ? "Failed to start the service worker." + (isFirefox ? " Make sure the setting Delete cookies and site data when Firefox is closed is off, or Diffuse's domain is added as an exception." : "") + : "Failed to start the service worker, try using HTTPS." + ) + }) + }) + + } + }) +} + + +/** + * Link. + */ +export function link( + { app, isNativeWrapper, reg } : { app: App, isNativeWrapper: boolean, reg: ServiceWorkerRegistration } +) { + if (reg.installing) console.log("๐Ÿง‘โ€โœˆ๏ธ Service worker is installing") + const initialInstall = reg.installing + + initialInstall?.addEventListener("statechange", function() { + if (this.state === "activated") { + console.log("๐Ÿง‘โ€โœˆ๏ธ Service worker is activated") + app.ports.installedNewServiceWorker.send(null) + } + }) + + if (reg.waiting) { + console.log("๐Ÿง‘โ€โœˆ๏ธ A new version of Diffuse is available") + app.ports.installingNewServiceWorker.send(null) + app.ports.installedNewServiceWorker.send(null) + } + + if (initialInstall?.state === "activated") { + console.log("๐Ÿง‘โ€โœˆ๏ธ Service worker is activated") + app.ports.installedNewServiceWorker.send(null) + } + + reg.addEventListener("updatefound", () => { + const newWorker = reg.installing + if (!newWorker) return + + // No worker was installed yet, so we'll only want to track the state changes + if (newWorker !== initialInstall) { + console.log("๐Ÿง‘โ€โœˆ๏ธ A new version of Diffuse is available") + app.ports.installingNewServiceWorker.send(null) + } + + newWorker.addEventListener("statechange", (e: any) => { + console.log("๐Ÿง‘โ€โœˆ๏ธ Service worker is", e.target.state) + if (e.target.state === "installed") app.ports.installedNewServiceWorker.send(null) + }) + }) + + // Check for service worker updates and every hour after that + if (!isNativeWrapper && navigator.onLine) { + reg.update() + setInterval(() => reg.update(), 1 * 1000 * 60 * 60) + } +} diff --git a/src/Javascript/UI/tracks.ts b/src/Javascript/UI/tracks.ts new file mode 100644 index 00000000..f0c8bc6e --- /dev/null +++ b/src/Javascript/UI/tracks.ts @@ -0,0 +1,50 @@ +import type { App } from "./elm/types" +import { fileExtension } from "../common" +import { transformUrl } from "../urls" + + +// ๐Ÿ”๏ธ + + +let app: App + + + +// ๐Ÿš€ + + +export function init(a: App) { + app = a +} + + + +// ๐Ÿ› ๏ธ + + +export async function download(group) { + const { saveAs } = await import("file-saver").then(a => a.default) + const JSZip = await import("jszip").then(a => a.default) + + const zip = new JSZip() + const folder = zip.folder("Diffuse - " + group.name) + if (!folder) throw new Error("Failed to create ZIP file") + + return group.tracks + .reduce((acc, track) => { + return acc + .then(() => transformUrl(track.url, app)) + .then(fetch) + .then((r: Response) => { + const mimeType = r.headers.get("content-type") + const fileExt = (mimeType ? fileExtension(mimeType) : null) || "unknown" + + return r.blob().then((b: Blob) => folder.file(track.filename + "." + fileExt, b)) + }) + }, Promise.resolve()) + .then(() => zip.generateAsync({ type: "blob" })) + .then((zipFile: Blob) => { + saveAs(zipFile, "Diffuse - " + group.name + ".zip") + app.ports.downloadTracksFinished.send(null) + }) +} diff --git a/src/Javascript/UI/user-layer.ts b/src/Javascript/UI/user-layer.ts new file mode 100644 index 00000000..1e0b22dd --- /dev/null +++ b/src/Javascript/UI/user-layer.ts @@ -0,0 +1,76 @@ +import type { Program as OddProgram } from "@oddjs/odd" +import type { App } from "./elm/types.js" + +import { ODD_CONFIG } from "../common" + + +// ๐Ÿ”๏ธ + + +let app: App +let odd + + + +// ๐Ÿš€ + + +export function init(a: App) { + app = a + + app.ports.authenticateWithFission.subscribe(async () => { + const program = await oddProgram() + await program.capabilities.request({ + returnUrl: location.origin + "?action=authenticate/fission" + }) + }) + + app.ports.collectFissionCapabilities.subscribe(() => { + // The ODD SDK should collect the capabilities for us, + // if everything is valid, we'll receive a session. + oddProgram().then( + () => { + history.replaceState({}, "", location.origin) + app.ports.collectedFissionCapabilities.send(null) + } + ).catch( + err => console.error(err) + ) + }) +} + + + +// Fission ~ ODD +// ------------- + + +async function oddProgram(): Promise { + try { + await loadOdd() + } catch (err) { + console.trace(err) + throw new Error("Failed to load the ODD SDK") + } + + const capComponent = await import("../Odd/components/capabilities.js") + + const crypto = await odd.defaultCryptoComponent(ODD_CONFIG) + const storage = await odd.defaultStorageComponent(ODD_CONFIG) + const depot = await odd.defaultDepotComponent({ storage }, ODD_CONFIG) + + return odd.program({ + ...ODD_CONFIG, + capabilities: capComponent.implementation({ + crypto, + depot + }), + fileSystem: { loadImmediately: false } + }) +} + + +async function loadOdd() { + if (odd) return + odd = await import("@oddjs/odd") +} diff --git a/src/Javascript/Workers/search.ts b/src/Javascript/Workers/search.ts index 28724db5..cdbc7085 100644 --- a/src/Javascript/Workers/search.ts +++ b/src/Javascript/Workers/search.ts @@ -17,14 +17,14 @@ lunr.Pipeline.registerFunction( ) -let index +let index: lunr.Index // Incoming messages // ----------------- -self.onmessage = event => { +self.onmessage = (event: MessageEvent) => { switch (event.data.action) { case "PERFORM_SEARCH": performSearch(event.data.data) @@ -53,8 +53,8 @@ const mapTrack = track => ({ // Actions // ------- -function performSearch(rawSearchTerm) { - let results = +function performSearch(rawSearchTerm: string) { + let results: string[] = [] const searchTerm = rawSearchTerm @@ -62,7 +62,7 @@ function performSearch(rawSearchTerm) { .replace(/\+\s+/g, "+") .split(/ +/) .reduce( - ([ acc, previousOperator, previousPrefix ], chunk) => { + ([ acc, previousOperator, previousPrefix ]: [ string[], string, string ], chunk: string): [ string[], string, string ] => { const operator = (a => a && a[0])( chunk.match(/^(\+|-)/) ) let chunkWithoutOperator = chunk.replace(/^(\+|-)/, "").replace(/\*$/, "").trim() @@ -123,35 +123,35 @@ function performSearch(rawSearchTerm) { } -function updateSearchIndex(input) { +function updateSearchIndex(input: string | object[]) { const tracks = (typeof input == "string") ? JSON.parse(input) : input - index = customLunr(function() { + index = customLunr((builder: lunr.Builder) => { FIELDS.forEach( - field => this.field(field) + field => builder.field(field) ) ;(tracks || []) .map(mapTrack) - .forEach(t => this.add(t)) + .forEach(t => builder.add(t)) }) } -function customLunr(config) { +function customLunr(fn: (b: lunr.Builder) => void) { const builder = new lunr.Builder builder.pipeline.add(removeParenthesesFromToken, lunr.stemmer) builder.searchPipeline.add(removeParenthesesFromToken, lunr.stemmer) - config.call(builder, builder) + fn(builder) return builder.build() } -function removeParenthesesFromToken(token) { +function removeParenthesesFromToken(token: lunr.Token): lunr.Token { return token.update(s => s.replace(/\(|\)/, "")) } diff --git a/src/Javascript/Workers/service.ts b/src/Javascript/Workers/service.ts index 8c4d9b1c..8d78deae 100644 --- a/src/Javascript/Workers/service.ts +++ b/src/Javascript/Workers/service.ts @@ -9,8 +9,6 @@ // /// -import { } from "../index.d" - const KEY = /* eslint-disable no-undef */ @@ -19,8 +17,8 @@ const KEY = const EXCLUDE = [ "_headers" - , "_redirects" - , "CORS" + , "_redirects" + , "CORS" ] @@ -39,7 +37,7 @@ let googleDriveToken // ๐Ÿ“ฃ -self.addEventListener("activate", _event => { +self.addEventListener("activate", () => { // Remove all caches except the one with the currently used `KEY` caches.keys().then(keys => { keys.forEach(k => { @@ -82,7 +80,7 @@ self.addEventListener("fetch", event => { })() ) - // When doing a request with basic authentication in the url, put it in the headers instead + // When doing a request with basic authentication in the url, put it in the headers instead } else if (event.request.url.includes("service_worker_authentication=")) { const url = new URL(event.request.url) const token = url.searchParams.get("service_worker_authentication") @@ -96,7 +94,7 @@ self.addEventListener("fetch", event => { "Basic " + token ) - // When doing a request with access token in the url, put it in the headers instead + // When doing a request with access token in the url, put it in the headers instead } else if (event.request.url.includes("bearer_token=")) { const url = new URL(event.request.url) const token = url.searchParams.get("bearer_token") @@ -112,7 +110,7 @@ self.addEventListener("fetch", event => { "Bearer " + token ) - // Use cache if internal request and not using native app + // Use cache if internal request and not using native app } else if (isInternal) { event.respondWith( isNativeWrapper diff --git a/src/Javascript/audio-engine.ts b/src/Javascript/audio-engine.ts deleted file mode 100644 index cece72b0..00000000 --- a/src/Javascript/audio-engine.ts +++ /dev/null @@ -1,569 +0,0 @@ -// -// Audio engine -// โ™ช(ยดฮต๏ฝ€ ) -// -// Creates audio elements and interacts with the Web Audio API. - - -import { throttle } from "throttle-debounce" -import Timer from "timer.js" - -import { db } from "./common" -import { transformUrl } from "./urls" -import { mimeType } from "./common" - - -// โ›ฉ - - -const IS_SAFARI = !!navigator.platform.match(/iPhone|iPod|iPad/) || - navigator.vendor === "Apple Computer, Inc." - - - -// Container for