+1
.gitignore
+1
.gitignore
+7
astro.config.js
+7
astro.config.js
···
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
+1
deno.json
+6
-1
deno.lock
+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
]
+139
-8
package-lock.json
+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
+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
+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
+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
+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
+6
src/pages/core/types.d.ts
···
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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+9
-2
src/pages/orchestrator/output-management/types.d.ts
+37
-27
src/pages/orchestrator/single-queue/_applet.astro
+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
+6
-1
src/pages/orchestrator/single-queue/_manifest.json
+2
-2
src/pages/output/indexed-db/_applet.astro
+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
+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
+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
+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": "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
+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
-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
+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
+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
+1
src/styles/themes/pilot/index.css