Experiment to rebuild Diffuse using web applets.

Compare changes

Choose any two refs to compare.

Changed files
+840 -355
src
pages
configurator
core
engine
input
orchestrator
input-cache
output-management
single-queue
output
indexed-db
native-fs
processor
metadata-fetcher
scripts
applets
themes
pilot
webamp
styles
themes
pilot
+1
.gitignore
··· 1 .astro 2 .dprc 3 dist 4 node_modules 5 public/applets/
··· 1 .astro 2 .dprc 3 + .netlify 4 dist 5 node_modules 6 public/applets/
+7
astro.config.js
··· 1 import { defineConfig } from "astro/config"; 2 import scope from "astro-scope"; 3 4 import purgecss from "astro-purgecss"; 5 ··· 7 integrations: [scope(), purgecss()], 8 build: { 9 inlineStylesheets: "never", 10 }, 11 });
··· 1 import { defineConfig } from "astro/config"; 2 import scope from "astro-scope"; 3 + import wasm from "vite-plugin-wasm"; 4 5 import purgecss from "astro-purgecss"; 6 ··· 8 integrations: [scope(), purgecss()], 9 build: { 10 inlineStylesheets: "never", 11 + }, 12 + vite: { 13 + plugins: [wasm()], 14 + server: { 15 + hmr: false, 16 + }, 17 }, 18 });
+1
deno.json
··· 10 "dependencies": ["astro:build"] 11 }, 12 "copy-types": "deno run --allow-read --allow-write tasks/copy-types.ts", 13 "dev": "astro dev" 14 } 15 }
··· 10 "dependencies": ["astro:build"] 11 }, 12 "copy-types": "deno run --allow-read --allow-write tasks/copy-types.ts", 13 + "deploy:netlify": "npx netlify deploy --prod", 14 "dev": "astro dev" 15 } 16 }
+6 -1
deno.lock
··· 21 ], 22 "packageJson": { 23 "dependencies": [ 24 "npm:@jsr/bradenmacdonald__s3-lite-client@0.9", 25 "npm:@jsr/std__media-types@^1.1.0", 26 "npm:@picocss/pico@^2.1.1", 27 "npm:@types/throttle-debounce@^5.0.2", 28 "npm:astro-purgecss@^5.2.2", 29 "npm:astro-scope@^3.0.1", ··· 32 "npm:idb-keyval@^6.2.1", 33 "npm:music-metadata@^11.2.3", 34 "npm:native-file-system-adapter@^3.0.1", 35 - "npm:node-s3-url-encode@^0.0.4", 36 "npm:purgecss@^7.0.2", 37 "npm:query-string@^9.1.2", 38 "npm:sass@^1.87.0", 39 "npm:spellcaster@6", 40 "npm:throttle-debounce@^5.0.2", 41 "npm:uri-js@^4.4.1", 42 "npm:webamp@^1.5.0", 43 "npm:xxh32@^2.0.5" 44 ]
··· 21 ], 22 "packageJson": { 23 "dependencies": [ 24 + "npm:98.css@~0.1.21", 25 + "npm:@automerge/automerge@^3.0.0-beta.0", 26 "npm:@jsr/bradenmacdonald__s3-lite-client@0.9", 27 "npm:@jsr/std__media-types@^1.1.0", 28 "npm:@picocss/pico@^2.1.1", 29 + "npm:@tokenizer/http@~0.9.2", 30 + "npm:@tokenizer/range@0.13", 31 "npm:@types/throttle-debounce@^5.0.2", 32 "npm:astro-purgecss@^5.2.2", 33 "npm:astro-scope@^3.0.1", ··· 36 "npm:idb-keyval@^6.2.1", 37 "npm:music-metadata@^11.2.3", 38 "npm:native-file-system-adapter@^3.0.1", 39 "npm:purgecss@^7.0.2", 40 "npm:query-string@^9.1.2", 41 "npm:sass@^1.87.0", 42 "npm:spellcaster@6", 43 "npm:throttle-debounce@^5.0.2", 44 + "npm:uint8arrays@^5.1.0", 45 "npm:uri-js@^4.4.1", 46 + "npm:vite-plugin-wasm@^3.4.1", 47 "npm:webamp@^1.5.0", 48 "npm:xxh32@^2.0.5" 49 ]
+3
netlify.toml
···
··· 1 + [build] 2 + command = "deno run build" 3 + publish = "dist"
+139 -8
package-lock.json
··· 5 "packages": { 6 "": { 7 "dependencies": { 8 "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client@^0.9.0", 9 "@picocss/pico": "^2.1.1", 10 "@std/media-types": "npm:@jsr/std__media-types@^1.1.0", 11 "@web-applets/sdk": "https://gitpkg.vercel.app/unternet-co/web-applets/sdk?tokono.ma/experiment&scripts.postinstall=npm%20i%20%40types%2Fnode%20%26%26%20npx%20tsc", 12 "iconoir": "^7.11.0", 13 "idb-keyval": "^6.2.1", 14 "music-metadata": "^11.2.3", 15 "native-file-system-adapter": "^3.0.1", 16 - "node-s3-url-encode": "^0.0.4", 17 "query-string": "^9.1.2", 18 "spellcaster": "^6.0.0", 19 "throttle-debounce": "^5.0.2", 20 "uri-js": "^4.4.1", 21 "webamp": "^1.5.0", 22 "xxh32": "^2.0.5" ··· 27 "astro-purgecss": "^5.2.2", 28 "astro-scope": "^3.0.1", 29 "purgecss": "^7.0.2", 30 - "sass": "^1.87.0" 31 } 32 }, 33 "node_modules/@assemblyscript/loader": { ··· 105 }, 106 "engines": { 107 "node": "^18.17.1 || ^20.3.0 || >=22.0.0" 108 } 109 }, 110 "node_modules/@babel/helper-string-parser": { ··· 1703 "tslib": "^2.8.0" 1704 } 1705 }, 1706 "node_modules/@tokenizer/inflate": { 1707 "version": "0.2.7", 1708 "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", ··· 1715 }, 1716 "engines": { 1717 "node": ">=18" 1718 }, 1719 "funding": { 1720 "type": "github", ··· 1830 "resolved": "https://gitpkg.vercel.app/unternet-co/web-applets/sdk?tokono.ma/experiment&scripts.postinstall=npm%20i%20%40types%2Fnode%20%26%26%20npx%20tsc", 1831 "integrity": "sha512-AL1T69Yr2yA0MV+JaCWj+SufF83aSBfwLe3iPVh5WB7qH1nH4vu2cC7JJK1FYNBs8wEYmyh2SNHGQjKQyoFy4w==", 1832 "hasInstallScript": true, 1833 "license": "MIT" 1834 }, 1835 "node_modules/acorn": { ··· 4778 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 4779 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 4780 }, 4781 "node_modules/music-metadata": { 4782 "version": "11.2.3", 4783 "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.2.3.tgz", ··· 5016 "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.0.tgz", 5017 "integrity": "sha512-0uGYQ1WQL1M5kKvGRXWQ3uZCHtLTO8hln3oBjIusM75WoesZ909uQJs/Hb946i2SS+Gsrhkaa6iAO17jRIv6DQ==", 5018 "dev": true 5019 - }, 5020 - "node_modules/node-s3-url-encode": { 5021 - "version": "0.0.4", 5022 - "resolved": "https://registry.npmjs.org/node-s3-url-encode/-/node-s3-url-encode-0.0.4.tgz", 5023 - "integrity": "sha512-l0IizfnxE1hb9dadzYBpA27syfL9LFkPzCKH6YWrssv2sPLjVuCent67A8GPe4isdj4bEsbgdPWLTcV4gxEg9w==", 5024 - "license": "MIT" 5025 }, 5026 "node_modules/normalize-path": { 5027 "version": "3.0.0", ··· 6388 "url": "https://github.com/sponsors/sindresorhus" 6389 } 6390 }, 6391 "node_modules/ultrahtml": { 6392 "version": "1.6.0", 6393 "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", ··· 6702 "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", 6703 "license": "ISC" 6704 }, 6705 "node_modules/vfile": { 6706 "version": "6.0.3", 6707 "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", ··· 6816 "yaml": { 6817 "optional": true 6818 } 6819 } 6820 }, 6821 "node_modules/vitefu": {
··· 5 "packages": { 6 "": { 7 "dependencies": { 8 + "@automerge/automerge": "^3.0.0-beta.0", 9 "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client@^0.9.0", 10 "@picocss/pico": "^2.1.1", 11 "@std/media-types": "npm:@jsr/std__media-types@^1.1.0", 12 + "@tokenizer/http": "^0.9.2", 13 + "@tokenizer/range": "^0.13.0", 14 "@web-applets/sdk": "https://gitpkg.vercel.app/unternet-co/web-applets/sdk?tokono.ma/experiment&scripts.postinstall=npm%20i%20%40types%2Fnode%20%26%26%20npx%20tsc", 15 + "98.css": "^0.1.21", 16 "iconoir": "^7.11.0", 17 "idb-keyval": "^6.2.1", 18 "music-metadata": "^11.2.3", 19 "native-file-system-adapter": "^3.0.1", 20 "query-string": "^9.1.2", 21 "spellcaster": "^6.0.0", 22 "throttle-debounce": "^5.0.2", 23 + "uint8arrays": "^5.1.0", 24 "uri-js": "^4.4.1", 25 "webamp": "^1.5.0", 26 "xxh32": "^2.0.5" ··· 31 "astro-purgecss": "^5.2.2", 32 "astro-scope": "^3.0.1", 33 "purgecss": "^7.0.2", 34 + "sass": "^1.87.0", 35 + "vite-plugin-wasm": "^3.4.1" 36 } 37 }, 38 "node_modules/@assemblyscript/loader": { ··· 110 }, 111 "engines": { 112 "node": "^18.17.1 || ^20.3.0 || >=22.0.0" 113 + } 114 + }, 115 + "node_modules/@automerge/automerge": { 116 + "version": "3.0.0-preview.13", 117 + "resolved": "https://registry.npmjs.org/@automerge/automerge/-/automerge-3.0.0-preview.13.tgz", 118 + "integrity": "sha512-1r7ggaTqsQ4PHGv45QjVOxPOvJIKjSrHY+HTiFxCU04Qlx3kvXxDLVyBbZeN1jg2I+Y8tpuG0eVtC4QxL9wGIg==", 119 + "license": "MIT", 120 + "dependencies": { 121 + "uuid": "^9.0.0" 122 } 123 }, 124 "node_modules/@babel/helper-string-parser": { ··· 1717 "tslib": "^2.8.0" 1718 } 1719 }, 1720 + "node_modules/@tokenizer/http": { 1721 + "version": "0.9.2", 1722 + "resolved": "https://registry.npmjs.org/@tokenizer/http/-/http-0.9.2.tgz", 1723 + "integrity": "sha512-rzJwHcqDjO3FdBPr+FK2R6dYE6Qbg6QZP7S47rhCEtG+/YqEFLqZ+gFCLcL8y5D39aYQB9vDssiwbsJlRLePPg==", 1724 + "license": "MIT", 1725 + "dependencies": { 1726 + "@tokenizer/range": "^0.12.0", 1727 + "debug": "^4.3.7", 1728 + "strtok3": "^10.0.0" 1729 + }, 1730 + "funding": { 1731 + "type": "github", 1732 + "url": "https://github.com/sponsors/Borewit" 1733 + } 1734 + }, 1735 + "node_modules/@tokenizer/http/node_modules/@tokenizer/range": { 1736 + "version": "0.12.0", 1737 + "resolved": "https://registry.npmjs.org/@tokenizer/range/-/range-0.12.0.tgz", 1738 + "integrity": "sha512-xvJ1OflWjopkC5EgLge+9HrwsWStgVewQkmusoF2BxgCuGdm1KuhZAMVMNzC7h1WNei9JA6xKQlkbPNJtjZ6aw==", 1739 + "license": "MIT", 1740 + "dependencies": { 1741 + "debug": "^4.3.7", 1742 + "strtok3": "^9.1.1" 1743 + }, 1744 + "engines": { 1745 + "node": ">=16" 1746 + }, 1747 + "funding": { 1748 + "type": "github", 1749 + "url": "https://github.com/sponsors/Borewit" 1750 + } 1751 + }, 1752 + "node_modules/@tokenizer/http/node_modules/@tokenizer/range/node_modules/strtok3": { 1753 + "version": "9.1.1", 1754 + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-9.1.1.tgz", 1755 + "integrity": "sha512-FhwotcEqjr241ZbjFzjlIYg6c5/L/s4yBGWSMvJ9UoExiSqL+FnFA/CaeZx17WGaZMS/4SOZp8wH18jSS4R4lw==", 1756 + "license": "MIT", 1757 + "dependencies": { 1758 + "@tokenizer/token": "^0.3.0", 1759 + "peek-readable": "^5.3.1" 1760 + }, 1761 + "engines": { 1762 + "node": ">=16" 1763 + }, 1764 + "funding": { 1765 + "type": "github", 1766 + "url": "https://github.com/sponsors/Borewit" 1767 + } 1768 + }, 1769 + "node_modules/@tokenizer/http/node_modules/peek-readable": { 1770 + "version": "5.4.2", 1771 + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.4.2.tgz", 1772 + "integrity": "sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==", 1773 + "license": "MIT", 1774 + "engines": { 1775 + "node": ">=14.16" 1776 + }, 1777 + "funding": { 1778 + "type": "github", 1779 + "url": "https://github.com/sponsors/Borewit" 1780 + } 1781 + }, 1782 "node_modules/@tokenizer/inflate": { 1783 "version": "0.2.7", 1784 "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", ··· 1791 }, 1792 "engines": { 1793 "node": ">=18" 1794 + }, 1795 + "funding": { 1796 + "type": "github", 1797 + "url": "https://github.com/sponsors/Borewit" 1798 + } 1799 + }, 1800 + "node_modules/@tokenizer/range": { 1801 + "version": "0.13.0", 1802 + "resolved": "https://registry.npmjs.org/@tokenizer/range/-/range-0.13.0.tgz", 1803 + "integrity": "sha512-ibLGQRU8an1g/y952+OxeZDGIj+W1HW8AQPtk26VIFWzy3tvQImmGBwYbpHJXMMAz1nhCPAAepCRptGKB8YrKg==", 1804 + "license": "MIT", 1805 + "dependencies": { 1806 + "debug": "^4.4.0", 1807 + "strtok3": "^10.2.0" 1808 + }, 1809 + "engines": { 1810 + "node": ">=16" 1811 }, 1812 "funding": { 1813 "type": "github", ··· 1923 "resolved": "https://gitpkg.vercel.app/unternet-co/web-applets/sdk?tokono.ma/experiment&scripts.postinstall=npm%20i%20%40types%2Fnode%20%26%26%20npx%20tsc", 1924 "integrity": "sha512-AL1T69Yr2yA0MV+JaCWj+SufF83aSBfwLe3iPVh5WB7qH1nH4vu2cC7JJK1FYNBs8wEYmyh2SNHGQjKQyoFy4w==", 1925 "hasInstallScript": true, 1926 + "license": "MIT" 1927 + }, 1928 + "node_modules/98.css": { 1929 + "version": "0.1.21", 1930 + "resolved": "https://registry.npmjs.org/98.css/-/98.css-0.1.21.tgz", 1931 + "integrity": "sha512-ddk5qtUWyapM0Bzd5jwGExoE5fdSEGrP+F5VbYjyZLf2c9UVmn6w2NPTvCsoD4BWdGsjdLjlkQGhWwWTJcYQJQ==", 1932 "license": "MIT" 1933 }, 1934 "node_modules/acorn": { ··· 4877 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 4878 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 4879 }, 4880 + "node_modules/multiformats": { 4881 + "version": "13.3.6", 4882 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.3.6.tgz", 4883 + "integrity": "sha512-yakbt9cPYj8d3vi/8o/XWm61MrOILo7fsTL0qxNx6zS0Nso6K5JqqS2WV7vK/KSuDBvrW3KfCwAdAgarAgOmww==", 4884 + "license": "Apache-2.0 OR MIT" 4885 + }, 4886 "node_modules/music-metadata": { 4887 "version": "11.2.3", 4888 "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.2.3.tgz", ··· 5121 "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.0.tgz", 5122 "integrity": "sha512-0uGYQ1WQL1M5kKvGRXWQ3uZCHtLTO8hln3oBjIusM75WoesZ909uQJs/Hb946i2SS+Gsrhkaa6iAO17jRIv6DQ==", 5123 "dev": true 5124 }, 5125 "node_modules/normalize-path": { 5126 "version": "3.0.0", ··· 6487 "url": "https://github.com/sponsors/sindresorhus" 6488 } 6489 }, 6490 + "node_modules/uint8arrays": { 6491 + "version": "5.1.0", 6492 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.0.tgz", 6493 + "integrity": "sha512-vA6nFepEmlSKkMBnLBaUMVvAC4G3CTmO58C12y4sq6WPDOR7mOFYOi7GlrQ4djeSbP6JG9Pv9tJDM97PedRSww==", 6494 + "license": "Apache-2.0 OR MIT", 6495 + "dependencies": { 6496 + "multiformats": "^13.0.0" 6497 + } 6498 + }, 6499 "node_modules/ultrahtml": { 6500 "version": "1.6.0", 6501 "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", ··· 6810 "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", 6811 "license": "ISC" 6812 }, 6813 + "node_modules/uuid": { 6814 + "version": "9.0.1", 6815 + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", 6816 + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", 6817 + "funding": [ 6818 + "https://github.com/sponsors/broofa", 6819 + "https://github.com/sponsors/ctavan" 6820 + ], 6821 + "license": "MIT", 6822 + "bin": { 6823 + "uuid": "dist/bin/uuid" 6824 + } 6825 + }, 6826 "node_modules/vfile": { 6827 "version": "6.0.3", 6828 "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", ··· 6937 "yaml": { 6938 "optional": true 6939 } 6940 + } 6941 + }, 6942 + "node_modules/vite-plugin-wasm": { 6943 + "version": "3.4.1", 6944 + "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.4.1.tgz", 6945 + "integrity": "sha512-ja3nSo2UCkVeitltJGkS3pfQHAanHv/DqGatdI39ja6McgABlpsZ5hVgl6wuR8Qx5etY3T5qgDQhOWzc5RReZA==", 6946 + "dev": true, 6947 + "license": "MIT", 6948 + "peerDependencies": { 6949 + "vite": "^2 || ^3 || ^4 || ^5 || ^6" 6950 } 6951 }, 6952 "node_modules/vitefu": {
+7 -1
package.json
··· 1 { 2 "dependencies": { 3 "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client@^0.9.0", 4 "@picocss/pico": "^2.1.1", 5 "@std/media-types": "npm:@jsr/std__media-types@^1.1.0", 6 "@web-applets/sdk": "https://gitpkg.vercel.app/unternet-co/web-applets/sdk?tokono.ma/experiment&scripts.postinstall=npm%20i%20%40types%2Fnode%20%26%26%20npx%20tsc", 7 "iconoir": "^7.11.0", 8 "idb-keyval": "^6.2.1", 9 "music-metadata": "^11.2.3", ··· 11 "query-string": "^9.1.2", 12 "spellcaster": "^6.0.0", 13 "throttle-debounce": "^5.0.2", 14 "uri-js": "^4.4.1", 15 "webamp": "^1.5.0", 16 "xxh32": "^2.0.5" ··· 21 "astro-purgecss": "^5.2.2", 22 "astro-scope": "^3.0.1", 23 "purgecss": "^7.0.2", 24 - "sass": "^1.87.0" 25 } 26 }
··· 1 { 2 "dependencies": { 3 + "@automerge/automerge": "^3.0.0-beta.0", 4 "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client@^0.9.0", 5 "@picocss/pico": "^2.1.1", 6 "@std/media-types": "npm:@jsr/std__media-types@^1.1.0", 7 + "@tokenizer/http": "^0.9.2", 8 + "@tokenizer/range": "^0.13.0", 9 "@web-applets/sdk": "https://gitpkg.vercel.app/unternet-co/web-applets/sdk?tokono.ma/experiment&scripts.postinstall=npm%20i%20%40types%2Fnode%20%26%26%20npx%20tsc", 10 + "98.css": "^0.1.21", 11 "iconoir": "^7.11.0", 12 "idb-keyval": "^6.2.1", 13 "music-metadata": "^11.2.3", ··· 15 "query-string": "^9.1.2", 16 "spellcaster": "^6.0.0", 17 "throttle-debounce": "^5.0.2", 18 + "uint8arrays": "^5.1.0", 19 "uri-js": "^4.4.1", 20 "webamp": "^1.5.0", 21 "xxh32": "^2.0.5" ··· 26 "astro-purgecss": "^5.2.2", 27 "astro-scope": "^3.0.1", 28 "purgecss": "^7.0.2", 29 + "sass": "^1.87.0", 30 + "vite-plugin-wasm": "^3.4.1" 31 } 32 }
+17 -18
src/pages/configurator/input/_applet.astro
··· 11 <i class="iconoir-open-in-window"></i> 12 <strong>My device</strong> 13 </a> 14 </p> 15 </div> 16 <p> 17 - <small 18 - ><em><strong>More options coming soon!</strong><br />S3-compatible APIs, Dropbox, etc.</em 19 - ></small 20 - > 21 </p> 22 </main> 23 24 - <div id="iframes"></div> 25 - 26 - <style> 27 - #iframes { 28 display: none; 29 } 30 </style> 31 32 <script> 33 - import { applets } from "@web-applets/sdk"; 34 - 35 import type { Track } from "@applets/core/types.d.ts"; 36 - import { applet } from "@scripts/theme"; 37 38 //////////////////////////////////////////// 39 // SETUP 40 //////////////////////////////////////////// 41 - const container = document.querySelector("#iframes") || undefined; 42 - 43 - // Register applet 44 - const context = applets.register<{ ready: boolean }>(); 45 46 // Initial state 47 context.data = { ··· 50 51 // Applet connections 52 const input = { 53 - nativeFs: await applet("../../input/native-fs", { container }), 54 - s3: await applet("../../input/s3", { container }), 55 }; 56 57 //////////////////////////////////////////// 58 // ACTIONS 59 //////////////////////////////////////////// 60 61 const list = async (cachedTracks: Track[] = []) => { 62 const groups = cachedTracks.reduce( 63 (acc: Record<string, Track[]>, track: Track) => { 64 const scheme = track.uri.split(":", 1)[0]; 65 - return { ...acc, [scheme]: [...(acc.scheme || []), track] }; 66 }, 67 { 68 [input.nativeFs.manifest.input_properties.scheme]: [], ··· 110 } 111 }; 112 113 context.setActionHandler("list", list); 114 context.setActionHandler("resolve", resolve); 115
··· 11 <i class="iconoir-open-in-window"></i> 12 <strong>My device</strong> 13 </a> 14 + <br /> 15 + <a href="../../input/s3/" class="with-icon"> 16 + <i class="iconoir-open-in-window"></i> 17 + <strong>S3-compatible service</strong> 18 + </a> 19 </p> 20 </div> 21 <p> 22 + <small><em><strong>More options coming soon!</strong></em></small> 23 </p> 24 </main> 25 26 + <style is:global> 27 + iframe { 28 display: none; 29 } 30 </style> 31 32 <script> 33 import type { Track } from "@applets/core/types.d.ts"; 34 + import { applet, register } from "@scripts/applets/common"; 35 36 //////////////////////////////////////////// 37 // SETUP 38 //////////////////////////////////////////// 39 + const context = register<{ ready: boolean }>(); 40 41 // Initial state 42 context.data = { ··· 45 46 // Applet connections 47 const input = { 48 + nativeFs: await applet("../../input/native-fs"), 49 + s3: await applet("../../input/s3"), 50 }; 51 52 //////////////////////////////////////////// 53 // ACTIONS 54 //////////////////////////////////////////// 55 + const contextualize = async (tracks: Track[]) => { 56 + await input.s3.sendAction("contextualize", tracks); 57 + }; 58 59 const list = async (cachedTracks: Track[] = []) => { 60 const groups = cachedTracks.reduce( 61 (acc: Record<string, Track[]>, track: Track) => { 62 const scheme = track.uri.split(":", 1)[0]; 63 + return { ...acc, [scheme]: [...(acc[scheme] || []), track] }; 64 }, 65 { 66 [input.nativeFs.manifest.input_properties.scheme]: [], ··· 108 } 109 }; 110 111 + context.setActionHandler("contextualize", contextualize); 112 context.setActionHandler("list", list); 113 context.setActionHandler("resolve", resolve); 114
+9 -1
src/pages/configurator/input/_manifest.json
··· 3 "title": "Diffuse Configurator | Input", 4 "entrypoint": "index.html", 5 "actions": { 6 "list": { 7 "title": "List", 8 "description": "List tracks from all inputs.", ··· 16 }, 17 "resolve": { 18 "title": "Resolve", 19 - "description": "Potentially translates a track uri with a matching scheme into a URL pointing at the audio bytes. If it can be resolved that is, otherwise you'll get `undefined`.", 20 "params_schema": { 21 "type": "object", 22 "properties": {
··· 3 "title": "Diffuse Configurator | Input", 4 "entrypoint": "index.html", 5 "actions": { 6 + "contextualize": { 7 + "title": "Contextualize", 8 + "params_schema": { 9 + "type": "array", 10 + "description": "Array of tracks", 11 + "items": { "type": "object" } 12 + } 13 + }, 14 "list": { 15 "title": "List", 16 "description": "List tracks from all inputs.", ··· 24 }, 25 "resolve": { 26 "title": "Resolve", 27 + "description": "Potentially translates a track uri with a matching scheme into a URL pointing at the audio bytes or an audio stream. If it can be resolved that is, otherwise you'll get `undefined`.", 28 "params_schema": { 29 "type": "object", 30 "properties": {
+2 -3
src/pages/configurator/output/_applet.astro
··· 39 import scope from "astro:scope"; 40 import { type Signal, computed, effect, signal } from "spellcaster/spellcaster.js"; 41 import { type ElementConfigurator, repeat, text } from "spellcaster/hyperscript.js"; 42 - import { applets } from "@web-applets/sdk"; 43 44 - import { applet, hs } from "@src/scripts/theme"; 45 import type { OutputGetter, OutputSetter } from "@applets/core/types.d.ts"; 46 47 const METHODS = ["browser", "custom", "device"] as const; ··· 63 //////////////////////////////////////////// 64 // SETUP 65 //////////////////////////////////////////// 66 - const context = applets.register<{ ready: boolean }>(); 67 68 // Applets container 69 const container = document.createElement("div");
··· 39 import scope from "astro:scope"; 40 import { type Signal, computed, effect, signal } from "spellcaster/spellcaster.js"; 41 import { type ElementConfigurator, repeat, text } from "spellcaster/hyperscript.js"; 42 43 + import { applet, hs, register } from "@scripts/applets/common"; 44 import type { OutputGetter, OutputSetter } from "@applets/core/types.d.ts"; 45 46 const METHODS = ["browser", "custom", "device"] as const; ··· 62 //////////////////////////////////////////// 63 // SETUP 64 //////////////////////////////////////////// 65 + const context = register<{ ready: boolean }>(); 66 67 // Applets container 68 const container = document.createElement("div");
+6
src/pages/core/types.d.ts
··· 9 10 /* TRACKS */ 11 12 export interface Track<Tags = TrackTags, Stats = TrackStats> { 13 id: string; 14 ··· 29 export interface TrackTags { 30 album?: string; 31 artist?: string; 32 title: string; 33 }
··· 9 10 /* TRACKS */ 11 12 + export type ResolvedUri = undefined | { url: string; expiresAt: number }; // TODO: Streams? 13 + 14 export interface Track<Tags = TrackTags, Stats = TrackStats> { 15 id: string; 16 ··· 31 export interface TrackTags { 32 album?: string; 33 artist?: string; 34 + disc: { no: number; of?: number }; 35 + genre?: string; 36 title: string; 37 + track: { no: number; of?: number }; 38 + year?: number; 39 }
+12 -13
src/pages/engine/audio/_applet.astro
··· 1 <script> 2 - import { applets } from "@web-applets/sdk"; 3 - import { State, Track, TrackState } from "./types"; 4 5 //////////////////////////////////////////// 6 // CONSTANTS ··· 11 //////////////////////////////////////////// 12 // SETUP 13 //////////////////////////////////////////// 14 - const context = applets.register<State>(); 15 const container = document.createElement("div"); 16 - 17 container.id = "container"; 18 document.body.appendChild(container); 19 ··· 40 //////////////////////////////////////////// 41 // ACTIONS 42 //////////////////////////////////////////// 43 - context.setActionHandler( 44 - "render", 45 - async (args: { play?: { trackId: string; volume?: number }; tracks: Track[] }) => { 46 - await render(args.tracks); 47 - if (args.play) play({ trackId: args.play.trackId, volume: args.play.volume }); 48 - }, 49 - ); 50 - 51 context.setActionHandler("pause", pause); 52 context.setActionHandler("play", play); 53 context.setActionHandler("reload", reload); 54 context.setActionHandler("seek", seek); 55 context.setActionHandler("volume", volume); 56 ··· 102 }); 103 } 104 105 function seek({ percentage, trackId }: { percentage: number; trackId: string }) { 106 withAudioNode(trackId, (audio) => { 107 if (!isNaN(audio.duration)) { ··· 122 //////////////////////////////////////////// 123 // RENDER 124 //////////////////////////////////////////// 125 - async function render(tracks: Array<Track>) { 126 const ids = tracks.map((e) => e.id); 127 const existingNodes: Record<string, HTMLAudioElement> = {}; 128
··· 1 <script> 2 + import type { State, Track, TrackState } from "./types"; 3 + import { register } from "@scripts/applets/common"; 4 5 //////////////////////////////////////////// 6 // CONSTANTS ··· 11 //////////////////////////////////////////// 12 // SETUP 13 //////////////////////////////////////////// 14 + const context = register<State>(); 15 + 16 + // Audio elements container 17 const container = document.createElement("div"); 18 container.id = "container"; 19 document.body.appendChild(container); 20 ··· 41 //////////////////////////////////////////// 42 // ACTIONS 43 //////////////////////////////////////////// 44 context.setActionHandler("pause", pause); 45 context.setActionHandler("play", play); 46 context.setActionHandler("reload", reload); 47 + context.setActionHandler("render", render); 48 context.setActionHandler("seek", seek); 49 context.setActionHandler("volume", volume); 50 ··· 96 }); 97 } 98 99 + async function render(args: { play?: { trackId: string; volume?: number }; tracks: Track[] }) { 100 + await renderTracks(args.tracks); 101 + if (args.play) play({ trackId: args.play.trackId, volume: args.play.volume }); 102 + } 103 + 104 function seek({ percentage, trackId }: { percentage: number; trackId: string }) { 105 withAudioNode(trackId, (audio) => { 106 if (!isNaN(audio.duration)) { ··· 121 //////////////////////////////////////////// 122 // RENDER 123 //////////////////////////////////////////// 124 + async function renderTracks(tracks: Array<Track>) { 125 const ids = tracks.map((e) => e.id); 126 const existingNodes: Record<string, HTMLAudioElement> = {}; 127
+2 -2
src/pages/engine/queue/_applet.astro
··· 1 <script> 2 - import { applets } from "@web-applets/sdk"; 3 import { QueueItem, State } from "./types"; 4 5 //////////////////////////////////////////// 6 // SETUP 7 //////////////////////////////////////////// 8 - const context = applets.register<State>(); 9 10 // Initial state 11 context.data = {
··· 1 <script> 2 import { QueueItem, State } from "./types"; 3 + import { register } from "@scripts/applets/common"; 4 5 //////////////////////////////////////////// 6 // SETUP 7 //////////////////////////////////////////// 8 + const context = register<State>(); 9 10 // Initial state 11 context.data = {
+5 -5
src/pages/index.astro
··· 36 37 const input = [ 38 { url: "input/native-fs/", title: "Native File System" }, 39 - { url: "input/s3-compatible/", title: "(TODO) S3-Compatible API" }, 40 ]; 41 42 const orchestrators = [ ··· 49 { url: "output/indexed-db/", title: "IndexedDB" }, 50 { url: "output/native-fs/", title: "Native File System" }, 51 { url: "output/todo/", title: "(TODO) Keyhive/Beelay" }, 52 - { url: "output/todo/", title: "(TODO) Some local-first sync engine" }, 53 ]; 54 55 const processors = [ ··· 141 </Applet> 142 143 <Applet title="Orchestrators" list={orchestrators}> 144 - These too are applet compositions. However, unlike themes, these are purely logical, and 145 - reuse applet instances from the parent context (when available). Mostly exist in order to 146 - construct sensible defaults to use across themes and abstractions. 147 </Applet> 148 149 <Applet title="Output" list={output}>
··· 36 37 const input = [ 38 { url: "input/native-fs/", title: "Native File System" }, 39 + { url: "input/s3/", title: "S3-Compatible API" }, 40 ]; 41 42 const orchestrators = [ ··· 49 { url: "output/indexed-db/", title: "IndexedDB" }, 50 { url: "output/native-fs/", title: "Native File System" }, 51 { url: "output/todo/", title: "(TODO) Keyhive/Beelay" }, 52 + { url: "output/todo/", title: "(TODO) Dialog DB" }, 53 ]; 54 55 const processors = [ ··· 141 </Applet> 142 143 <Applet title="Orchestrators" list={orchestrators}> 144 + These too are applet compositions. However, unlike themes, these are purely logical. 145 + Mostly exist in order to construct sensible defaults to use across themes and 146 + abstractions. 147 </Applet> 148 149 <Applet title="Output" list={output}>
+3 -3
src/pages/input/native-fs/_applet.astro
··· 16 </main> 17 18 <script> 19 - import { applets } from "@web-applets/sdk"; 20 import { computed, effect, Signal, signal } from "spellcaster"; 21 import { repeat, tags, text } from "spellcaster/hyperscript.js"; 22 import { type FileSystemDirectoryHandle, showDirectoryPicker } from "native-file-system-adapter"; ··· 26 27 import type { Track } from "@applets/core/types.d.ts"; 28 import { isAudioFile } from "@scripts/inputs/common"; 29 30 import manifest from "./_manifest.json"; 31 ··· 41 const SCHEME = manifest.input_properties.scheme; 42 43 // Register applet 44 - const context = applets.register(); 45 46 //////////////////////////////////////////// 47 // UI ··· 198 const file = await fileHandle.getFile(); 199 const url = URL.createObjectURL(file); 200 201 - return url; 202 }; 203 204 const mount = async () => {
··· 16 </main> 17 18 <script> 19 import { computed, effect, Signal, signal } from "spellcaster"; 20 import { repeat, tags, text } from "spellcaster/hyperscript.js"; 21 import { type FileSystemDirectoryHandle, showDirectoryPicker } from "native-file-system-adapter"; ··· 25 26 import type { Track } from "@applets/core/types.d.ts"; 27 import { isAudioFile } from "@scripts/inputs/common"; 28 + import { register } from "@scripts/applets/common"; 29 30 import manifest from "./_manifest.json"; 31 ··· 41 const SCHEME = manifest.input_properties.scheme; 42 43 // Register applet 44 + const context = register(); 45 46 //////////////////////////////////////////// 47 // UI ··· 198 const file = await fileHandle.getFile(); 199 const url = URL.createObjectURL(file); 200 201 + return { expiresAt: Infinity, url }; 202 }; 203 204 const mount = async () => {
+1 -1
src/pages/input/native-fs/_manifest.json
··· 27 }, 28 "resolve": { 29 "title": "Resolve", 30 - "description": "Potentially translates a track uri with a matching scheme into a URL pointing at the audio bytes. If it can be resolved that is, otherwise you'll get `undefined`. Use the `consult` action to get a more detailed answer.", 31 "params_schema": { 32 "type": "object", 33 "properties": {
··· 27 }, 28 "resolve": { 29 "title": "Resolve", 30 + "description": "Potentially translates a track uri with a matching scheme into a URL pointing at the audio bytes or an audio stream. If it can be resolved that is, otherwise you'll get `undefined`. Use the `consult` action to get a more detailed answer.", 31 "params_schema": { 32 "type": "object", 33 "properties": {
+33 -30
src/pages/input/s3/_applet.astro
··· 38 39 <script> 40 import { S3Client } from "@bradenmacdonald/s3-lite-client"; 41 - import { type AppletEvent, applets } from "@web-applets/sdk"; 42 import { computed, effect, Signal, signal } from "spellcaster"; 43 import { type Props, repeat, tags, text } from "spellcaster/hyperscript.js"; 44 import * as IDB from "idb-keyval"; 45 import * as URI from "uri-js"; 46 import QS from "query-string"; 47 48 - // @ts-ignore 49 - import { encodeS3URI } from "node-s3-url-encode"; 50 - 51 - import type { Output, Track } from "@applets/core/types.d.ts"; 52 - import { applet } from "@scripts/theme"; 53 - 54 import manifest from "./_manifest.json"; 55 - import { isAudioFile } from "@scripts/inputs/common"; 56 57 type Bucket = { 58 accessKey: string; ··· 90 const SCHEME = manifest.input_properties.scheme; 91 92 // Register applet 93 - const context = applets.register(); 94 - 95 - // Applet connections 96 - const orchestrator = { 97 - output: await applet<Output>("../../orchestrator/output-management", { 98 - context: self.top || self.parent, 99 - }), 100 - }; 101 - 102 - // Watch for data changes 103 - orchestrator.output.addEventListener("data", async (event: AppletEvent) => { 104 - await loadBuckets(); 105 - }); 106 107 //////////////////////////////////////////// 108 // UI ··· 221 // ACTIONS 222 //////////////////////////////////////////// 223 const consult = async (fileUriOrScheme: string) => { 224 - if (!navigator.onLine) return false; 225 226 // TODO: Check if bucket is avail*able + CORS works? 227 - return true; 228 }; 229 230 - const list = async (_cachedTracks: Track[] = []) => { 231 - // TODO: Do we need to do something with the old tracks here? 232 233 const promises = Object.values(buckets()).map(async (bucket) => { 234 const client = createClient(bucket); ··· 242 return list 243 .filter((l) => isAudioFile(l.key)) 244 .map((l) => { 245 const track: Track = { 246 - id: crypto.randomUUID(), 247 uri: buildURI(bucket, l.key), 248 }; 249 ··· 264 bucket.path.replace(/\/$/, "") + URI.unescapeComponent(parsedURI.path || "") 265 ).replace(/^\//, ""); 266 267 const url = await client.getPresignedUrl(method.toUpperCase() as any, path); 268 - return url; 269 }; 270 271 const mount = async () => {}; ··· 273 const unmount = async () => {}; 274 275 context.setActionHandler("consult", consult); 276 context.setActionHandler("list", list); 277 context.setActionHandler("resolve", resolve); 278 context.setActionHandler("mount", mount); ··· 331 332 async function loadBuckets() { 333 const i = await IDB.get(IDB_BUCKETS); 334 - const t = bucketsFromTracks(orchestrator.output.data.tracks); 335 - 336 - return { ...i, ...t }; 337 } 338 339 function parseURI(uriString: string): Bucket | undefined {
··· 38 39 <script> 40 import { S3Client } from "@bradenmacdonald/s3-lite-client"; 41 import { computed, effect, Signal, signal } from "spellcaster"; 42 import { type Props, repeat, tags, text } from "spellcaster/hyperscript.js"; 43 import * as IDB from "idb-keyval"; 44 import * as URI from "uri-js"; 45 import QS from "query-string"; 46 47 + import type { Track } from "@applets/core/types.d.ts"; 48 + import { isAudioFile } from "@scripts/inputs/common"; 49 + import { register } from "@scripts/applets/common"; 50 import manifest from "./_manifest.json"; 51 52 type Bucket = { 53 accessKey: string; ··· 85 const SCHEME = manifest.input_properties.scheme; 86 87 // Register applet 88 + const context = register(); 89 90 //////////////////////////////////////////// 91 // UI ··· 204 // ACTIONS 205 //////////////////////////////////////////// 206 const consult = async (fileUriOrScheme: string) => { 207 + if (!navigator.onLine) 208 + return { supported: false, reason: "Internet connection is not available" }; 209 210 // TODO: Check if bucket is avail*able + CORS works? 211 + return { supported: true }; 212 + }; 213 + 214 + const contextualize = async (tracks: Track[]) => { 215 + const b = bucketsFromTracks(tracks); 216 + setBuckets({ ...buckets(), ...b }); 217 }; 218 219 + const list = async (cachedTracks: Track[] = []) => { 220 + const cache = cachedTracks.reduce((acc: Record<string, Track>, t: Track) => { 221 + const uri = URI.parse(t.uri); 222 + if (!uri.path) return acc; 223 + return { ...acc, [URI.unescapeComponent(uri.path)]: t }; 224 + }, {}); 225 226 const promises = Object.values(buckets()).map(async (bucket) => { 227 const client = createClient(bucket); ··· 235 return list 236 .filter((l) => isAudioFile(l.key)) 237 .map((l) => { 238 + const cachedTrack = cache[`/${l.key}`]; 239 + 240 + const id = cachedTrack?.id || crypto.randomUUID(); 241 + const stats = cachedTrack?.stats; 242 + const tags = cachedTrack?.tags; 243 + 244 const track: Track = { 245 + id, 246 + stats, 247 + tags, 248 uri: buildURI(bucket, l.key), 249 }; 250 ··· 265 bucket.path.replace(/\/$/, "") + URI.unescapeComponent(parsedURI.path || "") 266 ).replace(/^\//, ""); 267 268 + const expiresInSeconds = 60 * 60 * 24 * 7; // 7 days 269 + const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds; 270 const url = await client.getPresignedUrl(method.toUpperCase() as any, path); 271 + 272 + return { expiresAt: expiresAtSeconds, url }; 273 }; 274 275 const mount = async () => {}; ··· 277 const unmount = async () => {}; 278 279 context.setActionHandler("consult", consult); 280 + context.setActionHandler("contextualize", contextualize); 281 context.setActionHandler("list", list); 282 context.setActionHandler("resolve", resolve); 283 context.setActionHandler("mount", mount); ··· 336 337 async function loadBuckets() { 338 const i = await IDB.get(IDB_BUCKETS); 339 + return i ? i : {}; 340 } 341 342 function parseURI(uriString: string): Bucket | undefined {
+9 -1
src/pages/input/s3/_manifest.json
··· 13 "description": "The uri to check the availability of." 14 } 15 }, 16 "list": { 17 "title": "List", 18 "description": "List tracks.", ··· 26 }, 27 "resolve": { 28 "title": "Resolve", 29 - "description": "Potentially translates a track uri with a matching scheme into a URL pointing at the audio bytes. If it can be resolved that is, otherwise you'll get `undefined`. Use the `consult` action to get a more detailed answer.", 30 "params_schema": { 31 "type": "object", 32 "properties": {
··· 13 "description": "The uri to check the availability of." 14 } 15 }, 16 + "contextualize": { 17 + "title": "Contextualize", 18 + "params_schema": { 19 + "type": "array", 20 + "description": "Array of tracks", 21 + "items": { "type": "object" } 22 + } 23 + }, 24 "list": { 25 "title": "List", 26 "description": "List tracks.", ··· 34 }, 35 "resolve": { 36 "title": "Resolve", 37 + "description": "Potentially translates a track uri with a matching scheme into a URL pointing at the audio bytes or an audio stream. If it can be resolved that is, otherwise you'll get `undefined`. Use the `consult` action to get a more detailed answer.", 38 "params_schema": { 39 "type": "object", 40 "properties": {
+45 -30
src/pages/orchestrator/input-cache/_applet.astro
··· 1 <script> 2 - import { applets } from "@web-applets/sdk"; 3 4 - import type { Output, Track, TrackStats, TrackTags } from "@applets/core/types.d.ts"; 5 - import { applet, waitUntilAppletIsReady } from "@scripts/theme"; 6 7 //////////////////////////////////////////// 8 // SETUP 9 //////////////////////////////////////////// 10 - const context = applets.register<{ ready: boolean }>(); 11 12 // Initial data 13 context.data = { 14 ready: false, 15 }; 16 17 // Applet connections 18 const configurator = { 19 - input: await applet("../../configurator/input", { context: self.top || self.parent }), 20 }; 21 22 const orchestrator = { 23 - output: await applet<Output>("../../orchestrator/output-management", { 24 - context: self.parent, 25 - }), 26 }; 27 28 const processor = { 29 - metadataFetcher: await applet("../../processor/metadata-fetcher", { context: self.parent }), 30 }; 31 32 // 🚀 33 - process(); 34 35 //////////////////////////////////////////// 36 // ACTIONS ··· 38 context.setActionHandler("process", process); 39 40 async function process() { 41 await waitUntilAppletIsReady(configurator.input); 42 43 - const cachedTracks = orchestrator.output.data.tracks; 44 const tracks = await configurator.input.sendAction<Track[]>("list", cachedTracks, { 45 timeoutDuration: 60000 * 60 * 24, 46 }); ··· 50 async (promise: Promise<Track[]>, track: Track) => { 51 const acc = await promise; 52 53 - if (track.tags) return [...acc, track]; 54 55 - const getURL = await configurator.input.sendAction<string | undefined>( 56 "resolve", 57 { method: "GET", uri: track.uri }, 58 { 59 - timeoutDuration: 60000, 60 }, 61 ); 62 63 - if (!getURL) return acc; 64 65 - // TODO: Do we need to pass the HEAD URL too? 66 - const meta = await processor.metadataFetcher.sendAction("extract", getURL, { 67 - timeoutDuration: 60000, 68 - }); 69 70 - const stats: TrackStats = { 71 - duration: meta.format.duration, 72 - }; 73 74 - const tags: TrackTags = { 75 - album: meta.common.album, 76 - artist: meta.common.artist, 77 - title: meta.common.title, 78 - }; 79 80 return [...acc, { ...track, stats, tags }]; 81 }, ··· 84 85 // Save 86 await orchestrator.output.sendAction("tracks", tracksWithMetadata, { 87 - timeoutDuration: 60000 * 2, 88 }); 89 90 - // Log 91 console.log("🪵 Processing completed"); 92 } 93 94 //////////////////////////////////////////// 95 // 🚦 96 //////////////////////////////////////////// 97 - context.data = { ready: true }; 98 </script>
··· 1 <script> 2 + import type { ResolvedUri, Track } from "@applets/core/types.d.ts"; 3 4 + import { 5 + applet, 6 + register, 7 + waitUntilAppletData, 8 + waitUntilAppletIsReady, 9 + } from "@scripts/applets/common"; 10 11 //////////////////////////////////////////// 12 // SETUP 13 //////////////////////////////////////////// 14 + import type * as OutputOrchestrator from "@applets/orchestrator/output-management/types.d.ts"; 15 + 16 + const context = register<{ isProcessing: boolean; ready: boolean }>(); 17 18 // Initial data 19 context.data = { 20 + isProcessing: false, 21 ready: false, 22 }; 23 24 // Applet connections 25 const configurator = { 26 + input: await applet("../../configurator/input"), 27 }; 28 29 const orchestrator = { 30 + output: await applet<OutputOrchestrator.State>("../../orchestrator/output-management"), 31 }; 32 33 const processor = { 34 + metadataFetcher: await applet("../../processor/metadata-fetcher"), 35 }; 36 37 // 🚀 38 + waitUntilAppletData(orchestrator.output, (d) => !!d?.hasSyncedTracks).then(() => process()); 39 40 //////////////////////////////////////////// 41 // ACTIONS ··· 43 context.setActionHandler("process", process); 44 45 async function process() { 46 + if (context.data.isProcessing) return; 47 + context.data = { ...context.data, isProcessing: true }; 48 + console.log("🪵 Processing initiated"); 49 + 50 await waitUntilAppletIsReady(configurator.input); 51 52 + const cachedTracks = orchestrator.output.data.tracks.collection; 53 + await configurator.input.sendAction("contextualize", cachedTracks); 54 + 55 const tracks = await configurator.input.sendAction<Track[]>("list", cachedTracks, { 56 timeoutDuration: 60000 * 60 * 24, 57 }); ··· 61 async (promise: Promise<Track[]>, track: Track) => { 62 const acc = await promise; 63 64 + if (track.tags && track.stats) return [...acc, track]; 65 66 + const resGet = await configurator.input.sendAction<ResolvedUri>( 67 "resolve", 68 { method: "GET", uri: track.uri }, 69 { 70 + timeoutDuration: 60000 * 5, 71 }, 72 ); 73 74 + const resHead = await configurator.input.sendAction<ResolvedUri>( 75 + "resolve", 76 + { method: "HEAD", uri: track.uri }, 77 + { 78 + timeoutDuration: 60000 * 5, 79 + }, 80 + ); 81 82 + if (!resGet) return acc; 83 84 + const { stats, tags } = await processor.metadataFetcher.sendAction( 85 + "extract", 86 + { urls: { get: resGet.url, head: resHead?.url || resGet.url } }, 87 + { 88 + timeoutDuration: 60000 * 15, 89 + }, 90 + ); 91 92 + console.log(stats, tags); 93 94 return [...acc, { ...track, stats, tags }]; 95 }, ··· 98 99 // Save 100 await orchestrator.output.sendAction("tracks", tracksWithMetadata, { 101 + timeoutDuration: 60000 * 5, 102 }); 103 104 + // Fin 105 console.log("🪵 Processing completed"); 106 + context.data = { ...context.data, isProcessing: false }; 107 } 108 109 //////////////////////////////////////////// 110 // 🚦 111 //////////////////////////////////////////// 112 + context.data = { ...context.data, ready: true }; 113 </script>
+98 -22
src/pages/orchestrator/output-management/_applet.astro
··· 1 <!-- TODO: Button to export all user/output data. --><!-- TODO: Button to import data? --> 2 <script> 3 - import { applets } from "@web-applets/sdk"; 4 import { debounce } from "throttle-debounce"; 5 6 import type { Track } from "@applets/core/types.d.ts"; 7 import type { State } from "./types.d.ts"; 8 - import { applet, waitUntilAppletIsReady } from "@scripts/theme"; 9 10 //////////////////////////////////////////// 11 // SETUP 12 //////////////////////////////////////////// 13 - const context = applets.register<State>(); 14 15 // Initial data 16 context.data = { 17 ready: false, 18 - tracks: [], 19 }; 20 21 // Applet connections 22 const configurator = { 23 - output: await applet("../../configurator/output", { context: self.top || self.parent }), 24 }; 25 26 - // Load tracks 27 - loadTracks().then((tracks) => { 28 - update({ tracks }); 29 - }); 30 31 // State helpers 32 function update(partial: Partial<State>): void { 33 context.data = { ...context.data, ...partial }; 34 } 35 36 //////////////////////////////////////////// 37 // LOADERS 38 //////////////////////////////////////////// 39 - async function loadTracks(): Promise<Track[]> { 40 await waitUntilAppletIsReady(configurator.output); 41 42 const data = await configurator.output.sendAction( ··· 49 }, 50 ); 51 52 if (!data) { 53 - return []; 54 } 55 56 - return decode(data as Uint8Array); 57 } 58 59 //////////////////////////////////////////// 60 // ACTIONS 61 //////////////////////////////////////////// 62 const tracksHandler = (tracks: Track[]) => { 63 - update({ tracks }); 64 65 // Save tracks to output, but only the ones that need to be saved. 66 - // TODO: For each track.uri scheme ask the output configurator if it needs to be cached. 67 - saveTracksToOutput(tracks); 68 }; 69 70 - const saveTracksToOutput = debounce(5000, async function (tracks: Track[]) { 71 - const data = encode(tracks); 72 73 await configurator.output.sendAction("put", { 74 name: "tracks.json", 75 data, 76 }); 77 }); 78 79 context.setActionHandler("tracks", tracksHandler); ··· 81 //////////////////////////////////////////// 82 // 🛠️ 83 //////////////////////////////////////////// 84 - function decode(data: Uint8Array) { 85 - return JSON.parse(new TextDecoder().decode(data)); 86 - } 87 88 - function encode(data: Object) { 89 - return new TextEncoder().encode(JSON.stringify(data)); 90 } 91 92 ////////////////////////////////////////////
··· 1 <!-- TODO: Button to export all user/output data. --><!-- TODO: Button to import data? --> 2 <script> 3 import { debounce } from "throttle-debounce"; 4 + import * as Automerge from "@automerge/automerge"; 5 + import * as Uint8 from "uint8arrays"; 6 7 import type { Track } from "@applets/core/types.d.ts"; 8 import type { State } from "./types.d.ts"; 9 + import { applet, register, waitUntilAppletIsReady } from "@scripts/applets/common"; 10 + 11 + type TracksDoc = { collection: Track[] }; 12 + 13 + const TRACKS_INITIAL_DOC = Automerge.load<TracksDoc>( 14 + Uint8.fromString( 15 + "hW9Kg5qsIsEAeAEQkb+c0IkXTSWyGqZ6jXtFxgETwM42fL3CMN78UZ4Qa3a9RfOrJu5qKzlM7IxwAUXelQYBAgMCEwIjBkACVgIHFQwhAiMCNAFCAlYCgAECfwB/AX8Bf+ub7MEGfwB/B38KY29sbGVjdGlvbn8AfwEBfwJ/AH8AAA", 16 + "base64", 17 + ), 18 + ); 19 20 //////////////////////////////////////////// 21 // SETUP 22 //////////////////////////////////////////// 23 + const context = register<State>(); 24 + 25 + // Data codec 26 + const codec = { 27 + decode(data: any) { 28 + return { 29 + hasSyncedTracks: data.hasSyncedTracks, 30 + ready: context.data.ready, 31 + tracks: Automerge.load<TracksDoc>(data.tracks), 32 + }; 33 + }, 34 + 35 + encode(data: State) { 36 + return { 37 + hasSyncedTracks: true, 38 + ready: context.data.ready, 39 + tracks: Automerge.save(data.tracks), 40 + }; 41 + }, 42 + }; 43 + 44 + context.codec = codec; 45 46 // Initial data 47 context.data = { 48 + // Empty tracks collection, DO NOT CHANGE. 49 + // (avoids the initial sync problem with Automerge) 50 + tracks: TRACKS_INITIAL_DOC, 51 + 52 + hasSyncedTracks: false, 53 + 54 ready: false, 55 }; 56 57 // Applet connections 58 const configurator = { 59 + output: await applet("../../configurator/output"), 60 }; 61 62 + // Load tracks if needed 63 + if (context.isMainInstance()) 64 + loadTracks().then((doc) => { 65 + console.log("LOADED DOC", doc); 66 + 67 + if (doc) { 68 + const mergedDoc = Automerge.merge(context.data.tracks, doc); 69 + console.log("MERGED DOC", doc); 70 + update({ tracks: mergedDoc }); 71 + } 72 + 73 + update({ hasSyncedTracks: true }); 74 + }); 75 76 // State helpers 77 function update(partial: Partial<State>): void { 78 context.data = { ...context.data, ...partial }; 79 } 80 81 + function updateTracks(tracks: Track[]): Automerge.Doc<TracksDoc> { 82 + console.log(context.data.tracks); 83 + console.log(context.isMainInstance()); 84 + 85 + const doc = Automerge.change(context.data.tracks, (d) => { 86 + d.collection = cleanUndefinedValuesForTracks(tracks); 87 + }); 88 + 89 + update({ tracks: doc }); 90 + 91 + return doc; 92 + } 93 + 94 //////////////////////////////////////////// 95 // LOADERS 96 //////////////////////////////////////////// 97 + async function loadTracks() { 98 await waitUntilAppletIsReady(configurator.output); 99 100 const data = await configurator.output.sendAction( ··· 107 }, 108 ); 109 110 + console.log("🔮 Loading tracks, got:", data); 111 + 112 if (!data) { 113 + return undefined; 114 } 115 116 + return Automerge.load<TracksDoc>(data as Uint8Array); 117 } 118 119 //////////////////////////////////////////// 120 // ACTIONS 121 //////////////////////////////////////////// 122 const tracksHandler = (tracks: Track[]) => { 123 + const doc = updateTracks(tracks); 124 + 125 + console.log("🔮 Tracks collection updated in memory"); 126 127 // Save tracks to output, but only the ones that need to be saved. 128 + // TODO: For each track.uri scheme ask the input configurator if it needs to be cached? 129 + saveTracksToOutput(doc); 130 }; 131 132 + const saveTracksToOutput = debounce(5000, async function (doc: Automerge.Doc<TracksDoc>) { 133 + const data = Automerge.save(doc); 134 + 135 + console.log("🔮 Saving tracks"); 136 137 await configurator.output.sendAction("put", { 138 name: "tracks.json", 139 data, 140 }); 141 + 142 + console.log("🔮 Tracks saved to output"); 143 }); 144 145 context.setActionHandler("tracks", tracksHandler); ··· 147 //////////////////////////////////////////// 148 // 🛠️ 149 //////////////////////////////////////////// 150 + function cleanUndefinedValuesForTracks(tracks: Track[]): Track[] { 151 + return tracks.map((track) => { 152 + const t = { ...track }; 153 154 + if (t.tags) { 155 + if ("album" in t.tags && t.tags.album === undefined) delete t.tags.album; 156 + if ("artist" in t.tags && t.tags.artist === undefined) delete t.tags.artist; 157 + if ("genre" in t.tags && t.tags.genre === undefined) delete t.tags.genre; 158 + if ("year" in t.tags && t.tags.year === undefined) delete t.tags.year; 159 + 160 + if ("of" in t.tags.disc && t.tags.disc.of === undefined) delete t.tags.disc.of; 161 + if ("of" in t.tags.track && t.tags.track.of === undefined) delete t.tags.track.of; 162 + } 163 + 164 + return t; 165 + }); 166 } 167 168 ////////////////////////////////////////////
+9 -2
src/pages/orchestrator/output-management/types.d.ts
··· 1 - import { Output } from "@applets/core/types"; 2 3 - export type State = Output & { ready: boolean };
··· 1 + import type { Doc } from "@automerge/automerge"; 2 + import type { Output } from "@applets/core/types"; 3 + 4 + export type State = { 5 + tracks: Doc<{ collection: Output["tracks"] }>; 6 + 7 + hasSyncedTracks: boolean; 8 9 + ready: boolean; 10 + };
+37 -27
src/pages/orchestrator/single-queue/_applet.astro
··· 1 <script> 2 - import { applets } from "@web-applets/sdk"; 3 - 4 - import type { Track, Output } from "@applets/core/types.d.ts"; 5 - import { applet, comparable, reactive } from "@scripts/theme"; 6 7 //////////////////////////////////////////// 8 // SETUP 9 //////////////////////////////////////////// 10 import type * as AudioEngine from "@applets/engine/audio/types.d.ts"; 11 import type * as QueueEngine from "@applets/engine/queue/types.d.ts"; 12 13 // Register applet 14 - const context = applets.register<unknown>(); 15 16 // Applet connections 17 const engine = { 18 - audio: await applet<AudioEngine.State>("../../engine/audio", { 19 - context: self.top || self.parent, 20 - }), 21 - queue: await applet<QueueEngine.State>("../../engine/queue", { 22 - context: self.top || self.parent, 23 - }), 24 }; 25 26 const orchestrator = { 27 - output: await applet<Output>("../../orchestrator/output-management", { context: self.parent }), 28 }; 29 30 //////////////////////////////////////////// ··· 32 //////////////////////////////////////////// 33 context.setActionHandler("fill", fill); 34 35 - function fill() { 36 - // TODO: Need to translate the semi-permanent track uri 37 - // into a usable audio URL. 38 - engine.queue.sendAction( 39 - "add", 40 - orchestrator.output.data.tracks.map((track: Track) => { 41 - return { 42 - expiresAt: Infinity, 43 - id: track.id, 44 - url: track.uri, 45 - }; 46 - }), 47 ); 48 } 49 50 //////////////////////////////////////////// ··· 96 }, 97 }); 98 99 - fill(); 100 }, 101 ); 102 ··· 106 //////////////////////////////////////////// 107 reactive( 108 orchestrator.output, 109 - (data) => (data ? comparable(data.tracks) : undefined), 110 (hash) => { 111 - if (hash) fill(); 112 }, 113 ); 114 </script>
··· 1 <script> 2 + import type { ResolvedUri, Track } from "@applets/core/types.d.ts"; 3 + import { applet, comparable, reactive, register } from "@scripts/applets/common"; 4 5 //////////////////////////////////////////// 6 // SETUP 7 //////////////////////////////////////////// 8 import type * as AudioEngine from "@applets/engine/audio/types.d.ts"; 9 import type * as QueueEngine from "@applets/engine/queue/types.d.ts"; 10 + import type * as OutputOrchestrator from "@applets/orchestrator/output-management/types.d.ts"; 11 12 // Register applet 13 + const context = register<unknown>(); 14 15 // Applet connections 16 + const configurator = { 17 + input: await applet("../../configurator/input"), 18 + }; 19 + 20 const engine = { 21 + audio: await applet<AudioEngine.State>("../../engine/audio"), 22 + queue: await applet<QueueEngine.State>("../../engine/queue"), 23 }; 24 25 const orchestrator = { 26 + output: await applet<OutputOrchestrator.State>("../../orchestrator/output-management"), 27 }; 28 29 //////////////////////////////////////////// ··· 31 //////////////////////////////////////////// 32 context.setActionHandler("fill", fill); 33 34 + async function fill(tracks: Track[]) { 35 + const queueItems = await tracks.reduce( 36 + async (promise: Promise<QueueEngine.QueueItem[]>, track: Track) => { 37 + const acc = await promise; 38 + const res = await configurator.input.sendAction<ResolvedUri>("resolve", { 39 + method: "GET", 40 + uri: track.uri, 41 + }); 42 + 43 + if (!res) return acc; 44 + 45 + return [ 46 + ...acc, 47 + { 48 + expiresAt: res.expiresAt, 49 + id: track.id, 50 + url: res.url, 51 + }, 52 + ]; 53 + }, 54 + Promise.resolve([]), 55 ); 56 + 57 + await engine.queue.sendAction("add", queueItems); 58 } 59 60 //////////////////////////////////////////// ··· 106 }, 107 }); 108 109 + fill(orchestrator.output.data.tracks.collection); 110 }, 111 ); 112 ··· 116 //////////////////////////////////////////// 117 reactive( 118 orchestrator.output, 119 + (data) => (data ? comparable(data.tracks.collection) : undefined), 120 (hash) => { 121 + if (hash) fill(orchestrator.output.data.tracks.collection); 122 }, 123 ); 124 </script>
+6 -1
src/pages/orchestrator/single-queue/_manifest.json
··· 5 "actions": { 6 "fill": { 7 "title": "Fill", 8 - "description": "Fill up the queue." 9 } 10 } 11 }
··· 5 "actions": { 6 "fill": { 7 "title": "Fill", 8 + "description": "Fill up the queue.", 9 + "params_schema": { 10 + "type": "array", 11 + "items": { "type": "object" }, 12 + "description": "Array of tracks to be used to fill up the queue." 13 + } 14 } 15 } 16 }
+2 -2
src/pages/output/indexed-db/_applet.astro
··· 1 <script> 2 import * as IDB from "idb-keyval"; 3 - import { applets } from "@web-applets/sdk"; 4 5 import type { OutputGetter, OutputSetter } from "@applets/core/types.d.ts"; 6 7 //////////////////////////////////////////// 8 // SETUP 9 //////////////////////////////////////////// 10 const IDB_PREFIX = "@applets/output/indexed-db"; 11 - const context = applets.register(); 12 13 //////////////////////////////////////////// 14 // ACTIONS
··· 1 <script> 2 import * as IDB from "idb-keyval"; 3 4 import type { OutputGetter, OutputSetter } from "@applets/core/types.d.ts"; 5 + import { register } from "@scripts/applets/common"; 6 7 //////////////////////////////////////////// 8 // SETUP 9 //////////////////////////////////////////// 10 const IDB_PREFIX = "@applets/output/indexed-db"; 11 + const context = register(); 12 13 //////////////////////////////////////////// 14 // ACTIONS
+2 -2
src/pages/output/native-fs/_applet.astro
··· 1 <script> 2 import * as IDB from "idb-keyval"; 3 - import { applets } from "@web-applets/sdk"; 4 import { type FileSystemDirectoryHandle, showDirectoryPicker } from "native-file-system-adapter"; 5 6 import type { OutputGetter, OutputSetter } from "@applets/core/types.d.ts"; 7 8 //////////////////////////////////////////// 9 // SETUP ··· 11 const IDB_PREFIX = "@applets/output/native-fs"; 12 const IDB_DEVICE_KEY = `${IDB_PREFIX}/device`; 13 14 - const context = applets.register(); 15 16 //////////////////////////////////////////// 17 // ACTIONS
··· 1 <script> 2 import * as IDB from "idb-keyval"; 3 import { type FileSystemDirectoryHandle, showDirectoryPicker } from "native-file-system-adapter"; 4 5 import type { OutputGetter, OutputSetter } from "@applets/core/types.d.ts"; 6 + import { register } from "@scripts/applets/common"; 7 8 //////////////////////////////////////////// 9 // SETUP ··· 11 const IDB_PREFIX = "@applets/output/native-fs"; 12 const IDB_DEVICE_KEY = `${IDB_PREFIX}/device`; 13 14 + const context = register(); 15 16 //////////////////////////////////////////// 17 // ACTIONS
+70 -11
src/pages/processor/metadata-fetcher/_applet.astro
··· 1 <script> 2 import { applets } from "@web-applets/sdk"; 3 - import { parseWebStream } from "music-metadata"; 4 import { contentType } from "@std/media-types"; 5 import * as URI from "uri-js"; 6 7 //////////////////////////////////////////// 8 // SETUP 9 //////////////////////////////////////////// 10 const context = applets.register(); 11 12 //////////////////////////////////////////// 13 // ACTIONS 14 //////////////////////////////////////////// 15 context.setActionHandler("extract", extract); 16 17 - async function extract(url: string) { 18 - const uri = URI.parse(url); 19 - const pathParts = uri.path?.split("/"); 20 - const mimeType = pathParts?.[pathParts.length - 1]?.includes(".") 21 - ? contentType(pathParts[pathParts.length - 1].split(".").reverse()[0]) 22 - : undefined; 23 - const resp = await fetch(url); 24 - const stream = resp.body; 25 - const metadata = await parseWebStream(stream, { mimeType }); 26 27 - return metadata; 28 } 29 </script>
··· 1 <script> 2 import { applets } from "@web-applets/sdk"; 3 + import { parseFromTokenizer, parseWebStream } from "music-metadata"; 4 import { contentType } from "@std/media-types"; 5 import * as URI from "uri-js"; 6 + import * as HTTP_TOKENIZER from "@tokenizer/http"; 7 + import * as RANGE_TOKENIZER from "@tokenizer/range"; 8 + 9 + import { TrackStats, TrackTags } from "@applets/core/types"; 10 11 //////////////////////////////////////////// 12 // SETUP 13 //////////////////////////////////////////// 14 const context = applets.register(); 15 16 + type Extraction = { stats: TrackStats; tags: TrackTags }; 17 + type Urls = { get: string; head: string }; 18 + 19 //////////////////////////////////////////// 20 // ACTIONS 21 //////////////////////////////////////////// 22 context.setActionHandler("extract", extract); 23 24 + async function extract(args: { mimeType?: string; stream?: ReadableStream; urls?: Urls }) { 25 + // Construct records 26 + // TODO: Use other metadata lib as fallback: https://github.com/buzz/mediainfo.js 27 + const { stats, tags } = await musicMetadataTags(args, false).catch(() => ({ 28 + stats: undefined, 29 + tags: undefined, 30 + })); 31 + 32 + // Fin 33 + return { stats, tags }; 34 + } 35 + 36 + // 🛠️ 37 + async function musicMetadataTags( 38 + { mimeType, stream, urls }: { mimeType?: string; stream?: ReadableStream; urls?: Urls }, 39 + covers: boolean = false, 40 + ): Promise<Extraction> { 41 + const uri = urls ? URI.parse(urls.get) : undefined; 42 + const pathParts = uri?.path?.split("/"); 43 + const filename = pathParts?.[pathParts.length - 1]; 44 + 45 + let meta; 46 + 47 + if (urls?.get.startsWith("blob:")) { 48 + const mimeFallback = filename?.includes(".") 49 + ? contentType(filename.split(".").reverse()[0]) 50 + : undefined; 51 + 52 + const resp = await fetch(urls.get); 53 + const stream = resp.body; 54 + 55 + meta = await parseWebStream(stream, { mimeType: mimeType || mimeFallback }); 56 + } else if (urls) { 57 + const httpClient = new HTTP_TOKENIZER.HttpClient(urls.head, { resolveUrl: false }); 58 + httpClient.resolvedUrl = urls.get; 59 60 + const tokenizer = await RANGE_TOKENIZER.tokenizer(httpClient); 61 + 62 + meta = await parseFromTokenizer(tokenizer, { skipCovers: covers }); 63 + } else if (stream) { 64 + meta = await parseWebStream(stream, { mimeType }); 65 + } else { 66 + throw new Error("Missing args, need either some urls or a stream."); 67 + } 68 + 69 + const stats: TrackStats = { 70 + duration: meta.format.duration, 71 + }; 72 + 73 + const tags: TrackTags = { 74 + album: meta.common.album, 75 + artist: meta.common.artist, 76 + disc: { no: meta.common.disk.no || 1, of: meta.common.disk.of ?? undefined }, 77 + genre: Array.isArray(meta.common.genre) ? meta.common.genre[0] : meta.common.genre, 78 + title: meta.common.title || filename || urls?.head || "Unknown", 79 + track: { no: meta.common.track.no || 1, of: meta.common.track.of ?? undefined }, 80 + year: meta.common.year, 81 + }; 82 + 83 + return { 84 + stats, 85 + tags, 86 + }; 87 } 88 </script>
+14 -2
src/pages/processor/metadata-fetcher/_manifest.json
··· 7 "title": "Extract", 8 "description": "Get the metadata for a given URL.", 9 "params_schema": { 10 - "type": "string", 11 - "description": "URL" 12 } 13 } 14 }
··· 7 "title": "Extract", 8 "description": "Get the metadata for a given URL.", 9 "params_schema": { 10 + "type": "object", 11 + "properties": { 12 + "stream": { 13 + "type": "object" 14 + }, 15 + "urls": { 16 + "type": "object", 17 + "properties": { 18 + "get": { "type": "string" }, 19 + "head": { "type": "string" } 20 + }, 21 + "required": ["get", "head"] 22 + } 23 + } 24 } 25 } 26 }
+279
src/scripts/applets/common.ts
···
··· 1 + import type { Applet, AppletEvent } from "@web-applets/sdk"; 2 + 3 + import QS from "query-string"; 4 + import { applets } from "@web-applets/sdk"; 5 + import { type ElementConfigurator, h } from "spellcaster/hyperscript.js"; 6 + import { effect, isSignal, Signal, signal } from "spellcaster/spellcaster.js"; 7 + import { xxh32 } from "xxh32"; 8 + 9 + //////////////////////////////////////////// 10 + // 🪟 Applet connector 11 + //////////////////////////////////////////// 12 + export async function applet<D>( 13 + src: string, 14 + opts: { 15 + addSlashSuffix?: boolean; 16 + applets?: Record<string, string>; 17 + container?: HTMLElement | Element; 18 + id?: string; 19 + setHeight?: boolean; 20 + } = {}, 21 + ): Promise<Applet<D>> { 22 + src = `${src}${ 23 + src.endsWith("/") 24 + ? "" 25 + : opts.addSlashSuffix === undefined || opts.addSlashSuffix === true 26 + ? "/" 27 + : "" 28 + }`; 29 + 30 + if (opts.applets) { 31 + src = QS.stringifyUrl({ url: src, query: opts.applets }); 32 + } 33 + 34 + const existingFrame: HTMLIFrameElement | null = window.document.querySelector(`[src="${src}"]`); 35 + 36 + let frame; 37 + 38 + if (existingFrame) { 39 + frame = existingFrame; 40 + } else { 41 + frame = document.createElement("iframe"); 42 + frame.src = src; 43 + if (opts.id) frame.id = opts.id; 44 + 45 + if (opts.container) { 46 + opts.container.appendChild(frame); 47 + } else { 48 + window.document.body.appendChild(frame); 49 + } 50 + } 51 + 52 + if (frame.contentWindow === null) { 53 + throw new Error("iframe does not have a contentWindow"); 54 + } 55 + 56 + const applet = await applets.connect<D>(frame.contentWindow).catch((err) => { 57 + console.error("Error connecting to " + src, err); 58 + throw err; 59 + }); 60 + 61 + if (opts.setHeight) { 62 + applet.onresize = () => { 63 + frame.height = `${applet.height}px`; 64 + frame.classList.add("has-loaded"); 65 + }; 66 + } else { 67 + if (frame.contentDocument?.readyState === "complete") { 68 + frame.classList.add("has-loaded"); 69 + } 70 + 71 + frame.addEventListener("load", () => { 72 + frame.classList.add("has-loaded"); 73 + }); 74 + } 75 + 76 + return applet; 77 + } 78 + 79 + //////////////////////////////////////////// 80 + // 🪟 Applet registration 81 + //////////////////////////////////////////// 82 + export function register<DataType = any>() { 83 + const id = `${location.host}${location.pathname}`; 84 + const scope = applets.register<DataType>(); 85 + 86 + let isMainInstance = true; 87 + let waitingForPong = true; 88 + 89 + // One instance to rule them all 90 + // 91 + // Ping other instances to see if there are any. 92 + // As long as there aren't any, it is considered the main instance. 93 + // 94 + // Actions are performed on the main instance, 95 + // and data is replicated from main to the other instances. 96 + const channel = new BroadcastChannel(id); 97 + 98 + channel.addEventListener("message", async (event) => { 99 + if (event.data === "PING") { 100 + channel.postMessage("PONG"); 101 + } else if (event.data?.type === "data") { 102 + scope.data = context.codec.decode(event.data.data); 103 + } else if (waitingForPong && event.data === "PONG") { 104 + waitingForPong = false; 105 + isMainInstance = false; 106 + } else if (isMainInstance && event.data?.type === "action" && event.data?.actionId) { 107 + const result = await scope.actionHandlers[event.data.actionId]?.(...event.data.arguments); 108 + channel.postMessage({ 109 + type: "actioncomplete", 110 + id: event.data.id, 111 + result, 112 + }); 113 + } 114 + }); 115 + 116 + setTimeout(() => (waitingForPong = false), 1000); 117 + 118 + channel.postMessage("PING"); 119 + 120 + scope.ondata = (event) => { 121 + if (isMainInstance) { 122 + channel.postMessage({ 123 + type: "data", 124 + data: context.codec.encode(event.data), 125 + }); 126 + } 127 + }; 128 + 129 + const context = { 130 + scope, 131 + 132 + get id() { 133 + return id; 134 + }, 135 + 136 + get data() { 137 + return scope.data; 138 + }, 139 + 140 + set data(data: DataType) { 141 + scope.data = data; 142 + }, 143 + 144 + codec: { 145 + decode: (data: any) => data as DataType, 146 + encode: (data: DataType) => data as any, 147 + }, 148 + 149 + isMainInstance() { 150 + return isMainInstance; 151 + }, 152 + 153 + setActionHandler: <H extends Function>(actionId: string, actionHandler: H) => { 154 + const handler = (...args: any) => { 155 + if (isMainInstance) { 156 + return actionHandler(...args); 157 + } 158 + 159 + const actionMessage = { 160 + id: crypto.randomUUID(), 161 + type: "action", 162 + actionId, 163 + arguments: args, 164 + }; 165 + 166 + return new Promise((resolve) => { 167 + const actionCallback = (event: MessageEvent) => { 168 + if (event.data?.type === "actioncomplete" && event.data?.id === actionMessage.id) { 169 + channel.removeEventListener("message", actionCallback); 170 + resolve(event.data.result); 171 + } 172 + }; 173 + 174 + channel.addEventListener("message", actionCallback); 175 + channel.postMessage(actionMessage); 176 + }); 177 + }; 178 + 179 + scope.setActionHandler(actionId, handler); 180 + }, 181 + }; 182 + 183 + return context; 184 + } 185 + 186 + //////////////////////////////////////////// 187 + // 🔮 Reactive state management 188 + //////////////////////////////////////////// 189 + export function reactive<D, T>( 190 + applet: Applet<D>, 191 + dataFn: (data: D) => T, 192 + effectFn: (t: T) => void, 193 + ) { 194 + const [getter, setter] = signal(dataFn(applet.data)); 195 + 196 + effect(() => { 197 + effectFn(getter()); 198 + return undefined; 199 + }); 200 + 201 + applet.addEventListener("data", (event: AppletEvent) => { 202 + setter(dataFn(event.data)); 203 + }); 204 + } 205 + 206 + //////////////////////////////////////////// 207 + // 🛠️ 208 + //////////////////////////////////////////// 209 + export function addScope<O extends object>(astroScope: string, object: O): O { 210 + return { 211 + ...object, 212 + attrs: { 213 + ...((object as any).attrs || {}), 214 + [`data-astro-cid-${astroScope}`]: "", 215 + }, 216 + }; 217 + } 218 + 219 + export function appletScopePort() { 220 + let port: MessagePort | undefined; 221 + 222 + function connection(event: AppletEvent) { 223 + if (event.data?.type === "appletconnect") { 224 + window.removeEventListener("message", connection); 225 + port = (event as any).ports[0]; 226 + } 227 + } 228 + 229 + window.addEventListener("message", connection); 230 + 231 + return () => port; 232 + } 233 + 234 + export function comparable(value: unknown) { 235 + return xxh32(JSON.stringify(value)); 236 + } 237 + 238 + export function hs( 239 + tag: string, 240 + astroScope: string, 241 + props?: Record<string, unknown> | Signal<Record<string, unknown>>, 242 + configure?: ElementConfigurator, 243 + ) { 244 + const propsWithScope = 245 + props && isSignal(props) 246 + ? () => addScope(astroScope, props()) 247 + : addScope(astroScope, props || {}); 248 + 249 + return h(tag, propsWithScope, configure); 250 + } 251 + 252 + export function isPrimitive(test: unknown) { 253 + return test !== Object(test); 254 + } 255 + 256 + export function waitUntilAppletData<A>( 257 + applet: Applet<A>, 258 + dataFn: (a: A | undefined) => boolean, 259 + ): Promise<void> { 260 + return new Promise((resolve) => { 261 + if (dataFn(applet.data) === true) { 262 + resolve(); 263 + return; 264 + } 265 + 266 + const callback = (event: AppletEvent) => { 267 + if (dataFn(event.data) === true) { 268 + applet.removeEventListener("data", callback); 269 + resolve(); 270 + } 271 + }; 272 + 273 + applet.addEventListener("data", callback); 274 + }); 275 + } 276 + 277 + export function waitUntilAppletIsReady(applet: Applet): Promise<void> { 278 + return waitUntilAppletData(applet, (data) => !!data?.ready); 279 + }
-151
src/scripts/theme.ts
··· 1 - import type { Applet, AppletEvent } from "@web-applets/sdk"; 2 - 3 - import { applets } from "@web-applets/sdk"; 4 - import { type ElementConfigurator, h } from "spellcaster/hyperscript.js"; 5 - import { effect, isSignal, Signal, signal } from "spellcaster/spellcaster.js"; 6 - import { xxh32 } from "xxh32"; 7 - 8 - //////////////////////////////////////////// 9 - // 🪟 Applet initialiser 10 - //////////////////////////////////////////// 11 - export async function applet<D>( 12 - src: string, 13 - opts: { 14 - addSlashSuffix?: boolean; 15 - context?: Window; 16 - container?: HTMLElement | Element; 17 - id?: string; 18 - setHeight?: boolean; 19 - } = {}, 20 - ): Promise<Applet<D>> { 21 - src = `${src}${ 22 - src.endsWith("/") 23 - ? "" 24 - : opts.addSlashSuffix === undefined || opts.addSlashSuffix === true 25 - ? "/" 26 - : "" 27 - }`; 28 - 29 - const existingFrame: HTMLIFrameElement | null = (opts.context || window).document.querySelector( 30 - `[src="${src}"]`, 31 - ); 32 - 33 - let frame; 34 - 35 - if (existingFrame) { 36 - frame = existingFrame; 37 - } else { 38 - frame = document.createElement("iframe"); 39 - frame.src = src; 40 - if (opts.id) frame.id = opts.id; 41 - 42 - if (opts.container) { 43 - opts.container.appendChild(frame); 44 - } else { 45 - (opts.context || window).document.body.appendChild(frame); 46 - } 47 - } 48 - 49 - if (frame.contentWindow === null) { 50 - throw new Error("iframe does not have a contentWindow"); 51 - } 52 - 53 - const applet = await applets 54 - .connect<D>(frame.contentWindow, { 55 - context: opts.context, 56 - }) 57 - .catch((err) => { 58 - console.error("Error connecting to " + src, err); 59 - throw err; 60 - }); 61 - 62 - if (opts.setHeight) { 63 - applet.onresize = () => { 64 - frame.height = `${applet.height}px`; 65 - frame.classList.add("has-loaded"); 66 - }; 67 - } else { 68 - if (frame.contentDocument?.readyState === "complete") { 69 - frame.classList.add("has-loaded"); 70 - } 71 - 72 - frame.addEventListener("load", () => { 73 - frame.classList.add("has-loaded"); 74 - }); 75 - } 76 - 77 - return applet; 78 - } 79 - 80 - //////////////////////////////////////////// 81 - // 🔮 Reactive state management 82 - //////////////////////////////////////////// 83 - export function reactive<D, T>( 84 - applet: Applet<D>, 85 - dataFn: (data: D) => T, 86 - effectFn: (t: T) => void, 87 - ) { 88 - const [getter, setter] = signal(dataFn(applet.data)); 89 - 90 - effect(() => { 91 - effectFn(getter()); 92 - return undefined; 93 - }); 94 - 95 - applet.addEventListener("data", (event: AppletEvent) => { 96 - setter(dataFn(event.data)); 97 - }); 98 - } 99 - 100 - //////////////////////////////////////////// 101 - // 🛠️ 102 - //////////////////////////////////////////// 103 - export function addScope<O extends object>(astroScope: string, object: O): O { 104 - return { 105 - ...object, 106 - attrs: { 107 - ...((object as any).attrs || {}), 108 - [`data-astro-cid-${astroScope}`]: "", 109 - }, 110 - }; 111 - } 112 - 113 - export function comparable(value: unknown) { 114 - return xxh32(JSON.stringify(value)); 115 - } 116 - 117 - export function hs( 118 - tag: string, 119 - astroScope: string, 120 - props?: Record<string, unknown> | Signal<Record<string, unknown>>, 121 - configure?: ElementConfigurator, 122 - ) { 123 - const propsWithScope = 124 - props && isSignal(props) 125 - ? () => addScope(astroScope, props()) 126 - : addScope(astroScope, props || {}); 127 - 128 - return h(tag, propsWithScope, configure); 129 - } 130 - 131 - export function isPrimitive(test: unknown) { 132 - return test !== Object(test); 133 - } 134 - 135 - export function waitUntilAppletIsReady(applet: Applet): Promise<void> { 136 - return new Promise((resolve) => { 137 - if (applet.data?.ready === true) { 138 - resolve(); 139 - return; 140 - } 141 - 142 - const callback = (event: AppletEvent) => { 143 - if (event.data?.ready === true) { 144 - applet.removeEventListener("data", callback); 145 - resolve(); 146 - } 147 - }; 148 - 149 - applet.addEventListener("data", callback); 150 - }); 151 - }
···
+6 -11
src/scripts/themes/pilot/index.ts
··· 1 - import type { Output, Track } from "@applets/core/types.d.ts"; 2 - import { applet, reactive } from "../../theme.ts"; 3 4 //////////////////////////////////////////// 5 // 🎨 Styles ··· 14 15 import type * as AudioUI from "@applets/themes/pilot/ui/audio/types.d.ts"; 16 17 - const _configurator = { 18 - output: await applet("../../configurator/output"), 19 - }; 20 21 const engine = { 22 audio: await applet<AudioEngine.State>("../../engine/audio"), 23 queue: await applet<QueueEngine.State>("../../engine/queue"), 24 }; 25 26 - const input = { 27 - nativeFs: await applet("../../input/native-fs"), 28 - }; 29 - 30 const _orchestrator = { 31 - input: await applet<Output>("../../orchestrator/input-cache"), 32 - output: await applet<Output>("../../orchestrator/output-management"), 33 queue: await applet("../../orchestrator/single-queue"), 34 }; 35
··· 1 + import { applet, reactive } from "@scripts/applets/common"; 2 3 //////////////////////////////////////////// 4 // 🎨 Styles ··· 13 14 import type * as AudioUI from "@applets/themes/pilot/ui/audio/types.d.ts"; 15 16 + // TODO: Themes 17 18 const engine = { 19 audio: await applet<AudioEngine.State>("../../engine/audio"), 20 queue: await applet<QueueEngine.State>("../../engine/queue"), 21 }; 22 23 const _orchestrator = { 24 + input: await applet("../../orchestrator/input-cache", { 25 + applets: { input: "todo" }, 26 + }), 27 + output: await applet("../../orchestrator/output-management"), 28 queue: await applet("../../orchestrator/single-queue"), 29 }; 30
+8 -7
src/scripts/themes/webamp/index.ts
··· 1 import Webamp from "webamp"; 2 import { URLTrack } from "webamp"; 3 4 - import type { Output, Track } from "@applets/core/types.d.ts"; 5 - import { applet, waitUntilAppletIsReady } from "../../theme.ts"; 6 7 //////////////////////////////////////////// 8 // 🎨 Styles ··· 12 //////////////////////////////////////////// 13 // 🗂️ Applets 14 //////////////////////////////////////////// 15 16 const configurator = { 17 input: await applet("../../configurator/input"), 18 }; 19 20 const orchestrator = { 21 - output: await applet<Output>("../../orchestrator/output-management"), 22 23 // TODO: Should this be explicitely be ran after the output orchestrator is loaded? 24 input: await applet("../../orchestrator/input-cache"), ··· 47 // 🛠️ 48 //////////////////////////////////////////// 49 async function loadTracks(): Promise<URLTrack[]> { 50 - return await orchestrator.output.data.tracks.reduce( 51 async (promise: Promise<URLTrack[]>, track: Track) => { 52 const acc = await promise; 53 54 // TODO: Ideally the URL should only be resolved when needed, 55 // but webamp doesn't allow for that. 56 // Maybe you could work around it with a service worker. 57 - const url = await configurator.input.sendAction<string | undefined>( 58 "resolve", 59 { method: "GET", uri: track.uri }, 60 { ··· 62 }, 63 ); 64 65 - if (!url) return acc; 66 67 const urlTrack: URLTrack = { 68 - url, 69 metaData: { 70 title: track.tags?.title || "", 71 artist: track.tags?.artist || "",
··· 1 import Webamp from "webamp"; 2 import { URLTrack } from "webamp"; 3 4 + import type { ResolvedUri, Track } from "@applets/core/types.d.ts"; 5 + import { applet } from "@scripts/applets/common"; 6 7 //////////////////////////////////////////// 8 // 🎨 Styles ··· 12 //////////////////////////////////////////// 13 // 🗂️ Applets 14 //////////////////////////////////////////// 15 + import type * as OutputOrchestrator from "@applets/orchestrator/output-management/types.d.ts"; 16 17 const configurator = { 18 input: await applet("../../configurator/input"), 19 }; 20 21 const orchestrator = { 22 + output: await applet<OutputOrchestrator.State>("../../orchestrator/output-management"), 23 24 // TODO: Should this be explicitely be ran after the output orchestrator is loaded? 25 input: await applet("../../orchestrator/input-cache"), ··· 48 // 🛠️ 49 //////////////////////////////////////////// 50 async function loadTracks(): Promise<URLTrack[]> { 51 + return await orchestrator.output.data.tracks.collection.reduce( 52 async (promise: Promise<URLTrack[]>, track: Track) => { 53 const acc = await promise; 54 55 // TODO: Ideally the URL should only be resolved when needed, 56 // but webamp doesn't allow for that. 57 // Maybe you could work around it with a service worker. 58 + const resGet = await configurator.input.sendAction<ResolvedUri>( 59 "resolve", 60 { method: "GET", uri: track.uri }, 61 { ··· 63 }, 64 ); 65 66 + if (!resGet) return acc; 67 68 const urlTrack: URLTrack = { 69 + url: resGet.url, 70 metaData: { 71 title: track.tags?.title || "", 72 artist: track.tags?.artist || "",
+1
src/styles/themes/pilot/index.css
··· 54 iframe[src*="/engine/"], 55 iframe[src*="/input/"], 56 iframe[src*="/orchestrator/"], 57 iframe[src*="/output/"] { 58 height: 0; 59 left: 110vw;
··· 54 iframe[src*="/engine/"], 55 iframe[src*="/input/"], 56 iframe[src*="/orchestrator/"], 57 + iframe[src*="/processor/"], 58 iframe[src*="/output/"] { 59 height: 0; 60 left: 110vw;