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 1 .astro 2 2 .dprc 3 + .netlify 3 4 dist 4 5 node_modules 5 6 public/applets/
+7
astro.config.js
··· 1 1 import { defineConfig } from "astro/config"; 2 2 import scope from "astro-scope"; 3 + import wasm from "vite-plugin-wasm"; 3 4 4 5 import purgecss from "astro-purgecss"; 5 6 ··· 7 8 integrations: [scope(), purgecss()], 8 9 build: { 9 10 inlineStylesheets: "never", 11 + }, 12 + vite: { 13 + plugins: [wasm()], 14 + server: { 15 + hmr: false, 16 + }, 10 17 }, 11 18 });
+1
deno.json
··· 10 10 "dependencies": ["astro:build"] 11 11 }, 12 12 "copy-types": "deno run --allow-read --allow-write tasks/copy-types.ts", 13 + "deploy:netlify": "npx netlify deploy --prod", 13 14 "dev": "astro dev" 14 15 } 15 16 }
+6 -1
deno.lock
··· 21 21 ], 22 22 "packageJson": { 23 23 "dependencies": [ 24 + "npm:98.css@~0.1.21", 25 + "npm:@automerge/automerge@^3.0.0-beta.0", 24 26 "npm:@jsr/bradenmacdonald__s3-lite-client@0.9", 25 27 "npm:@jsr/std__media-types@^1.1.0", 26 28 "npm:@picocss/pico@^2.1.1", 29 + "npm:@tokenizer/http@~0.9.2", 30 + "npm:@tokenizer/range@0.13", 27 31 "npm:@types/throttle-debounce@^5.0.2", 28 32 "npm:astro-purgecss@^5.2.2", 29 33 "npm:astro-scope@^3.0.1", ··· 32 36 "npm:idb-keyval@^6.2.1", 33 37 "npm:music-metadata@^11.2.3", 34 38 "npm:native-file-system-adapter@^3.0.1", 35 - "npm:node-s3-url-encode@^0.0.4", 36 39 "npm:purgecss@^7.0.2", 37 40 "npm:query-string@^9.1.2", 38 41 "npm:sass@^1.87.0", 39 42 "npm:spellcaster@6", 40 43 "npm:throttle-debounce@^5.0.2", 44 + "npm:uint8arrays@^5.1.0", 41 45 "npm:uri-js@^4.4.1", 46 + "npm:vite-plugin-wasm@^3.4.1", 42 47 "npm:webamp@^1.5.0", 43 48 "npm:xxh32@^2.0.5" 44 49 ]
+3
netlify.toml
··· 1 + [build] 2 + command = "deno run build" 3 + publish = "dist"
+139 -8
package-lock.json
··· 5 5 "packages": { 6 6 "": { 7 7 "dependencies": { 8 + "@automerge/automerge": "^3.0.0-beta.0", 8 9 "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client@^0.9.0", 9 10 "@picocss/pico": "^2.1.1", 10 11 "@std/media-types": "npm:@jsr/std__media-types@^1.1.0", 12 + "@tokenizer/http": "^0.9.2", 13 + "@tokenizer/range": "^0.13.0", 11 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", 12 16 "iconoir": "^7.11.0", 13 17 "idb-keyval": "^6.2.1", 14 18 "music-metadata": "^11.2.3", 15 19 "native-file-system-adapter": "^3.0.1", 16 - "node-s3-url-encode": "^0.0.4", 17 20 "query-string": "^9.1.2", 18 21 "spellcaster": "^6.0.0", 19 22 "throttle-debounce": "^5.0.2", 23 + "uint8arrays": "^5.1.0", 20 24 "uri-js": "^4.4.1", 21 25 "webamp": "^1.5.0", 22 26 "xxh32": "^2.0.5" ··· 27 31 "astro-purgecss": "^5.2.2", 28 32 "astro-scope": "^3.0.1", 29 33 "purgecss": "^7.0.2", 30 - "sass": "^1.87.0" 34 + "sass": "^1.87.0", 35 + "vite-plugin-wasm": "^3.4.1" 31 36 } 32 37 }, 33 38 "node_modules/@assemblyscript/loader": { ··· 105 110 }, 106 111 "engines": { 107 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" 108 122 } 109 123 }, 110 124 "node_modules/@babel/helper-string-parser": { ··· 1703 1717 "tslib": "^2.8.0" 1704 1718 } 1705 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 + }, 1706 1782 "node_modules/@tokenizer/inflate": { 1707 1783 "version": "0.2.7", 1708 1784 "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", ··· 1715 1791 }, 1716 1792 "engines": { 1717 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" 1718 1811 }, 1719 1812 "funding": { 1720 1813 "type": "github", ··· 1830 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", 1831 1924 "integrity": "sha512-AL1T69Yr2yA0MV+JaCWj+SufF83aSBfwLe3iPVh5WB7qH1nH4vu2cC7JJK1FYNBs8wEYmyh2SNHGQjKQyoFy4w==", 1832 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==", 1833 1932 "license": "MIT" 1834 1933 }, 1835 1934 "node_modules/acorn": { ··· 4778 4877 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 4779 4878 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 4780 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 + }, 4781 4886 "node_modules/music-metadata": { 4782 4887 "version": "11.2.3", 4783 4888 "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.2.3.tgz", ··· 5016 5121 "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.0.tgz", 5017 5122 "integrity": "sha512-0uGYQ1WQL1M5kKvGRXWQ3uZCHtLTO8hln3oBjIusM75WoesZ909uQJs/Hb946i2SS+Gsrhkaa6iAO17jRIv6DQ==", 5018 5123 "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 5124 }, 5026 5125 "node_modules/normalize-path": { 5027 5126 "version": "3.0.0", ··· 6388 6487 "url": "https://github.com/sponsors/sindresorhus" 6389 6488 } 6390 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 + }, 6391 6499 "node_modules/ultrahtml": { 6392 6500 "version": "1.6.0", 6393 6501 "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", ··· 6702 6810 "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", 6703 6811 "license": "ISC" 6704 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 + }, 6705 6826 "node_modules/vfile": { 6706 6827 "version": "6.0.3", 6707 6828 "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", ··· 6816 6937 "yaml": { 6817 6938 "optional": true 6818 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" 6819 6950 } 6820 6951 }, 6821 6952 "node_modules/vitefu": {
+7 -1
package.json
··· 1 1 { 2 2 "dependencies": { 3 + "@automerge/automerge": "^3.0.0-beta.0", 3 4 "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client@^0.9.0", 4 5 "@picocss/pico": "^2.1.1", 5 6 "@std/media-types": "npm:@jsr/std__media-types@^1.1.0", 7 + "@tokenizer/http": "^0.9.2", 8 + "@tokenizer/range": "^0.13.0", 6 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", 7 11 "iconoir": "^7.11.0", 8 12 "idb-keyval": "^6.2.1", 9 13 "music-metadata": "^11.2.3", ··· 11 15 "query-string": "^9.1.2", 12 16 "spellcaster": "^6.0.0", 13 17 "throttle-debounce": "^5.0.2", 18 + "uint8arrays": "^5.1.0", 14 19 "uri-js": "^4.4.1", 15 20 "webamp": "^1.5.0", 16 21 "xxh32": "^2.0.5" ··· 21 26 "astro-purgecss": "^5.2.2", 22 27 "astro-scope": "^3.0.1", 23 28 "purgecss": "^7.0.2", 24 - "sass": "^1.87.0" 29 + "sass": "^1.87.0", 30 + "vite-plugin-wasm": "^3.4.1" 25 31 } 26 32 }
+17 -18
src/pages/configurator/input/_applet.astro
··· 11 11 <i class="iconoir-open-in-window"></i> 12 12 <strong>My device</strong> 13 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> 14 19 </p> 15 20 </div> 16 21 <p> 17 - <small 18 - ><em><strong>More options coming soon!</strong><br />S3-compatible APIs, Dropbox, etc.</em 19 - ></small 20 - > 22 + <small><em><strong>More options coming soon!</strong></em></small> 21 23 </p> 22 24 </main> 23 25 24 - <div id="iframes"></div> 25 - 26 - <style> 27 - #iframes { 26 + <style is:global> 27 + iframe { 28 28 display: none; 29 29 } 30 30 </style> 31 31 32 32 <script> 33 - import { applets } from "@web-applets/sdk"; 34 - 35 33 import type { Track } from "@applets/core/types.d.ts"; 36 - import { applet } from "@scripts/theme"; 34 + import { applet, register } from "@scripts/applets/common"; 37 35 38 36 //////////////////////////////////////////// 39 37 // SETUP 40 38 //////////////////////////////////////////// 41 - const container = document.querySelector("#iframes") || undefined; 42 - 43 - // Register applet 44 - const context = applets.register<{ ready: boolean }>(); 39 + const context = register<{ ready: boolean }>(); 45 40 46 41 // Initial state 47 42 context.data = { ··· 50 45 51 46 // Applet connections 52 47 const input = { 53 - nativeFs: await applet("../../input/native-fs", { container }), 54 - s3: await applet("../../input/s3", { container }), 48 + nativeFs: await applet("../../input/native-fs"), 49 + s3: await applet("../../input/s3"), 55 50 }; 56 51 57 52 //////////////////////////////////////////// 58 53 // ACTIONS 59 54 //////////////////////////////////////////// 55 + const contextualize = async (tracks: Track[]) => { 56 + await input.s3.sendAction("contextualize", tracks); 57 + }; 60 58 61 59 const list = async (cachedTracks: Track[] = []) => { 62 60 const groups = cachedTracks.reduce( 63 61 (acc: Record<string, Track[]>, track: Track) => { 64 62 const scheme = track.uri.split(":", 1)[0]; 65 - return { ...acc, [scheme]: [...(acc.scheme || []), track] }; 63 + return { ...acc, [scheme]: [...(acc[scheme] || []), track] }; 66 64 }, 67 65 { 68 66 [input.nativeFs.manifest.input_properties.scheme]: [], ··· 110 108 } 111 109 }; 112 110 111 + context.setActionHandler("contextualize", contextualize); 113 112 context.setActionHandler("list", list); 114 113 context.setActionHandler("resolve", resolve); 115 114
+9 -1
src/pages/configurator/input/_manifest.json
··· 3 3 "title": "Diffuse Configurator | Input", 4 4 "entrypoint": "index.html", 5 5 "actions": { 6 + "contextualize": { 7 + "title": "Contextualize", 8 + "params_schema": { 9 + "type": "array", 10 + "description": "Array of tracks", 11 + "items": { "type": "object" } 12 + } 13 + }, 6 14 "list": { 7 15 "title": "List", 8 16 "description": "List tracks from all inputs.", ··· 16 24 }, 17 25 "resolve": { 18 26 "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`.", 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`.", 20 28 "params_schema": { 21 29 "type": "object", 22 30 "properties": {
+2 -3
src/pages/configurator/output/_applet.astro
··· 39 39 import scope from "astro:scope"; 40 40 import { type Signal, computed, effect, signal } from "spellcaster/spellcaster.js"; 41 41 import { type ElementConfigurator, repeat, text } from "spellcaster/hyperscript.js"; 42 - import { applets } from "@web-applets/sdk"; 43 42 44 - import { applet, hs } from "@src/scripts/theme"; 43 + import { applet, hs, register } from "@scripts/applets/common"; 45 44 import type { OutputGetter, OutputSetter } from "@applets/core/types.d.ts"; 46 45 47 46 const METHODS = ["browser", "custom", "device"] as const; ··· 63 62 //////////////////////////////////////////// 64 63 // SETUP 65 64 //////////////////////////////////////////// 66 - const context = applets.register<{ ready: boolean }>(); 65 + const context = register<{ ready: boolean }>(); 67 66 68 67 // Applets container 69 68 const container = document.createElement("div");
+6
src/pages/core/types.d.ts
··· 9 9 10 10 /* TRACKS */ 11 11 12 + export type ResolvedUri = undefined | { url: string; expiresAt: number }; // TODO: Streams? 13 + 12 14 export interface Track<Tags = TrackTags, Stats = TrackStats> { 13 15 id: string; 14 16 ··· 29 31 export interface TrackTags { 30 32 album?: string; 31 33 artist?: string; 34 + disc: { no: number; of?: number }; 35 + genre?: string; 32 36 title: string; 37 + track: { no: number; of?: number }; 38 + year?: number; 33 39 }
+12 -13
src/pages/engine/audio/_applet.astro
··· 1 1 <script> 2 - import { applets } from "@web-applets/sdk"; 3 - import { State, Track, TrackState } from "./types"; 2 + import type { State, Track, TrackState } from "./types"; 3 + import { register } from "@scripts/applets/common"; 4 4 5 5 //////////////////////////////////////////// 6 6 // CONSTANTS ··· 11 11 //////////////////////////////////////////// 12 12 // SETUP 13 13 //////////////////////////////////////////// 14 - const context = applets.register<State>(); 14 + const context = register<State>(); 15 + 16 + // Audio elements container 15 17 const container = document.createElement("div"); 16 - 17 18 container.id = "container"; 18 19 document.body.appendChild(container); 19 20 ··· 40 41 //////////////////////////////////////////// 41 42 // ACTIONS 42 43 //////////////////////////////////////////// 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 44 context.setActionHandler("pause", pause); 52 45 context.setActionHandler("play", play); 53 46 context.setActionHandler("reload", reload); 47 + context.setActionHandler("render", render); 54 48 context.setActionHandler("seek", seek); 55 49 context.setActionHandler("volume", volume); 56 50 ··· 102 96 }); 103 97 } 104 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 + 105 104 function seek({ percentage, trackId }: { percentage: number; trackId: string }) { 106 105 withAudioNode(trackId, (audio) => { 107 106 if (!isNaN(audio.duration)) { ··· 122 121 //////////////////////////////////////////// 123 122 // RENDER 124 123 //////////////////////////////////////////// 125 - async function render(tracks: Array<Track>) { 124 + async function renderTracks(tracks: Array<Track>) { 126 125 const ids = tracks.map((e) => e.id); 127 126 const existingNodes: Record<string, HTMLAudioElement> = {}; 128 127
+2 -2
src/pages/engine/queue/_applet.astro
··· 1 1 <script> 2 - import { applets } from "@web-applets/sdk"; 3 2 import { QueueItem, State } from "./types"; 3 + import { register } from "@scripts/applets/common"; 4 4 5 5 //////////////////////////////////////////// 6 6 // SETUP 7 7 //////////////////////////////////////////// 8 - const context = applets.register<State>(); 8 + const context = register<State>(); 9 9 10 10 // Initial state 11 11 context.data = {
+5 -5
src/pages/index.astro
··· 36 36 37 37 const input = [ 38 38 { url: "input/native-fs/", title: "Native File System" }, 39 - { url: "input/s3-compatible/", title: "(TODO) S3-Compatible API" }, 39 + { url: "input/s3/", title: "S3-Compatible API" }, 40 40 ]; 41 41 42 42 const orchestrators = [ ··· 49 49 { url: "output/indexed-db/", title: "IndexedDB" }, 50 50 { url: "output/native-fs/", title: "Native File System" }, 51 51 { url: "output/todo/", title: "(TODO) Keyhive/Beelay" }, 52 - { url: "output/todo/", title: "(TODO) Some local-first sync engine" }, 52 + { url: "output/todo/", title: "(TODO) Dialog DB" }, 53 53 ]; 54 54 55 55 const processors = [ ··· 141 141 </Applet> 142 142 143 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. 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 147 </Applet> 148 148 149 149 <Applet title="Output" list={output}>
+3 -3
src/pages/input/native-fs/_applet.astro
··· 16 16 </main> 17 17 18 18 <script> 19 - import { applets } from "@web-applets/sdk"; 20 19 import { computed, effect, Signal, signal } from "spellcaster"; 21 20 import { repeat, tags, text } from "spellcaster/hyperscript.js"; 22 21 import { type FileSystemDirectoryHandle, showDirectoryPicker } from "native-file-system-adapter"; ··· 26 25 27 26 import type { Track } from "@applets/core/types.d.ts"; 28 27 import { isAudioFile } from "@scripts/inputs/common"; 28 + import { register } from "@scripts/applets/common"; 29 29 30 30 import manifest from "./_manifest.json"; 31 31 ··· 41 41 const SCHEME = manifest.input_properties.scheme; 42 42 43 43 // Register applet 44 - const context = applets.register(); 44 + const context = register(); 45 45 46 46 //////////////////////////////////////////// 47 47 // UI ··· 198 198 const file = await fileHandle.getFile(); 199 199 const url = URL.createObjectURL(file); 200 200 201 - return url; 201 + return { expiresAt: Infinity, url }; 202 202 }; 203 203 204 204 const mount = async () => {
+1 -1
src/pages/input/native-fs/_manifest.json
··· 27 27 }, 28 28 "resolve": { 29 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.", 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 31 "params_schema": { 32 32 "type": "object", 33 33 "properties": {
+33 -30
src/pages/input/s3/_applet.astro
··· 38 38 39 39 <script> 40 40 import { S3Client } from "@bradenmacdonald/s3-lite-client"; 41 - import { type AppletEvent, applets } from "@web-applets/sdk"; 42 41 import { computed, effect, Signal, signal } from "spellcaster"; 43 42 import { type Props, repeat, tags, text } from "spellcaster/hyperscript.js"; 44 43 import * as IDB from "idb-keyval"; 45 44 import * as URI from "uri-js"; 46 45 import QS from "query-string"; 47 46 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 - 47 + import type { Track } from "@applets/core/types.d.ts"; 48 + import { isAudioFile } from "@scripts/inputs/common"; 49 + import { register } from "@scripts/applets/common"; 54 50 import manifest from "./_manifest.json"; 55 - import { isAudioFile } from "@scripts/inputs/common"; 56 51 57 52 type Bucket = { 58 53 accessKey: string; ··· 90 85 const SCHEME = manifest.input_properties.scheme; 91 86 92 87 // 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 - }); 88 + const context = register(); 106 89 107 90 //////////////////////////////////////////// 108 91 // UI ··· 221 204 // ACTIONS 222 205 //////////////////////////////////////////// 223 206 const consult = async (fileUriOrScheme: string) => { 224 - if (!navigator.onLine) return false; 207 + if (!navigator.onLine) 208 + return { supported: false, reason: "Internet connection is not available" }; 225 209 226 210 // TODO: Check if bucket is avail*able + CORS works? 227 - return true; 211 + return { supported: true }; 212 + }; 213 + 214 + const contextualize = async (tracks: Track[]) => { 215 + const b = bucketsFromTracks(tracks); 216 + setBuckets({ ...buckets(), ...b }); 228 217 }; 229 218 230 - const list = async (_cachedTracks: Track[] = []) => { 231 - // TODO: Do we need to do something with the old tracks here? 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 + }, {}); 232 225 233 226 const promises = Object.values(buckets()).map(async (bucket) => { 234 227 const client = createClient(bucket); ··· 242 235 return list 243 236 .filter((l) => isAudioFile(l.key)) 244 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 + 245 244 const track: Track = { 246 - id: crypto.randomUUID(), 245 + id, 246 + stats, 247 + tags, 247 248 uri: buildURI(bucket, l.key), 248 249 }; 249 250 ··· 264 265 bucket.path.replace(/\/$/, "") + URI.unescapeComponent(parsedURI.path || "") 265 266 ).replace(/^\//, ""); 266 267 268 + const expiresInSeconds = 60 * 60 * 24 * 7; // 7 days 269 + const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds; 267 270 const url = await client.getPresignedUrl(method.toUpperCase() as any, path); 268 - return url; 271 + 272 + return { expiresAt: expiresAtSeconds, url }; 269 273 }; 270 274 271 275 const mount = async () => {}; ··· 273 277 const unmount = async () => {}; 274 278 275 279 context.setActionHandler("consult", consult); 280 + context.setActionHandler("contextualize", contextualize); 276 281 context.setActionHandler("list", list); 277 282 context.setActionHandler("resolve", resolve); 278 283 context.setActionHandler("mount", mount); ··· 331 336 332 337 async function loadBuckets() { 333 338 const i = await IDB.get(IDB_BUCKETS); 334 - const t = bucketsFromTracks(orchestrator.output.data.tracks); 335 - 336 - return { ...i, ...t }; 339 + return i ? i : {}; 337 340 } 338 341 339 342 function parseURI(uriString: string): Bucket | undefined {
+9 -1
src/pages/input/s3/_manifest.json
··· 13 13 "description": "The uri to check the availability of." 14 14 } 15 15 }, 16 + "contextualize": { 17 + "title": "Contextualize", 18 + "params_schema": { 19 + "type": "array", 20 + "description": "Array of tracks", 21 + "items": { "type": "object" } 22 + } 23 + }, 16 24 "list": { 17 25 "title": "List", 18 26 "description": "List tracks.", ··· 26 34 }, 27 35 "resolve": { 28 36 "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.", 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.", 30 38 "params_schema": { 31 39 "type": "object", 32 40 "properties": {
+45 -30
src/pages/orchestrator/input-cache/_applet.astro
··· 1 1 <script> 2 - import { applets } from "@web-applets/sdk"; 2 + import type { ResolvedUri, Track } from "@applets/core/types.d.ts"; 3 3 4 - import type { Output, Track, TrackStats, TrackTags } from "@applets/core/types.d.ts"; 5 - import { applet, waitUntilAppletIsReady } from "@scripts/theme"; 4 + import { 5 + applet, 6 + register, 7 + waitUntilAppletData, 8 + waitUntilAppletIsReady, 9 + } from "@scripts/applets/common"; 6 10 7 11 //////////////////////////////////////////// 8 12 // SETUP 9 13 //////////////////////////////////////////// 10 - const context = applets.register<{ ready: boolean }>(); 14 + import type * as OutputOrchestrator from "@applets/orchestrator/output-management/types.d.ts"; 15 + 16 + const context = register<{ isProcessing: boolean; ready: boolean }>(); 11 17 12 18 // Initial data 13 19 context.data = { 20 + isProcessing: false, 14 21 ready: false, 15 22 }; 16 23 17 24 // Applet connections 18 25 const configurator = { 19 - input: await applet("../../configurator/input", { context: self.top || self.parent }), 26 + input: await applet("../../configurator/input"), 20 27 }; 21 28 22 29 const orchestrator = { 23 - output: await applet<Output>("../../orchestrator/output-management", { 24 - context: self.parent, 25 - }), 30 + output: await applet<OutputOrchestrator.State>("../../orchestrator/output-management"), 26 31 }; 27 32 28 33 const processor = { 29 - metadataFetcher: await applet("../../processor/metadata-fetcher", { context: self.parent }), 34 + metadataFetcher: await applet("../../processor/metadata-fetcher"), 30 35 }; 31 36 32 37 // 🚀 33 - process(); 38 + waitUntilAppletData(orchestrator.output, (d) => !!d?.hasSyncedTracks).then(() => process()); 34 39 35 40 //////////////////////////////////////////// 36 41 // ACTIONS ··· 38 43 context.setActionHandler("process", process); 39 44 40 45 async function process() { 46 + if (context.data.isProcessing) return; 47 + context.data = { ...context.data, isProcessing: true }; 48 + console.log("🪵 Processing initiated"); 49 + 41 50 await waitUntilAppletIsReady(configurator.input); 42 51 43 - const cachedTracks = orchestrator.output.data.tracks; 52 + const cachedTracks = orchestrator.output.data.tracks.collection; 53 + await configurator.input.sendAction("contextualize", cachedTracks); 54 + 44 55 const tracks = await configurator.input.sendAction<Track[]>("list", cachedTracks, { 45 56 timeoutDuration: 60000 * 60 * 24, 46 57 }); ··· 50 61 async (promise: Promise<Track[]>, track: Track) => { 51 62 const acc = await promise; 52 63 53 - if (track.tags) return [...acc, track]; 64 + if (track.tags && track.stats) return [...acc, track]; 54 65 55 - const getURL = await configurator.input.sendAction<string | undefined>( 66 + const resGet = await configurator.input.sendAction<ResolvedUri>( 56 67 "resolve", 57 68 { method: "GET", uri: track.uri }, 58 69 { 59 - timeoutDuration: 60000, 70 + timeoutDuration: 60000 * 5, 60 71 }, 61 72 ); 62 73 63 - if (!getURL) return acc; 74 + const resHead = await configurator.input.sendAction<ResolvedUri>( 75 + "resolve", 76 + { method: "HEAD", uri: track.uri }, 77 + { 78 + timeoutDuration: 60000 * 5, 79 + }, 80 + ); 64 81 65 - // TODO: Do we need to pass the HEAD URL too? 66 - const meta = await processor.metadataFetcher.sendAction("extract", getURL, { 67 - timeoutDuration: 60000, 68 - }); 82 + if (!resGet) return acc; 69 83 70 - const stats: TrackStats = { 71 - duration: meta.format.duration, 72 - }; 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 + ); 73 91 74 - const tags: TrackTags = { 75 - album: meta.common.album, 76 - artist: meta.common.artist, 77 - title: meta.common.title, 78 - }; 92 + console.log(stats, tags); 79 93 80 94 return [...acc, { ...track, stats, tags }]; 81 95 }, ··· 84 98 85 99 // Save 86 100 await orchestrator.output.sendAction("tracks", tracksWithMetadata, { 87 - timeoutDuration: 60000 * 2, 101 + timeoutDuration: 60000 * 5, 88 102 }); 89 103 90 - // Log 104 + // Fin 91 105 console.log("🪵 Processing completed"); 106 + context.data = { ...context.data, isProcessing: false }; 92 107 } 93 108 94 109 //////////////////////////////////////////// 95 110 // 🚦 96 111 //////////////////////////////////////////// 97 - context.data = { ready: true }; 112 + context.data = { ...context.data, ready: true }; 98 113 </script>
+98 -22
src/pages/orchestrator/output-management/_applet.astro
··· 1 1 <!-- TODO: Button to export all user/output data. --><!-- TODO: Button to import data? --> 2 2 <script> 3 - import { applets } from "@web-applets/sdk"; 4 3 import { debounce } from "throttle-debounce"; 4 + import * as Automerge from "@automerge/automerge"; 5 + import * as Uint8 from "uint8arrays"; 5 6 6 7 import type { Track } from "@applets/core/types.d.ts"; 7 8 import type { State } from "./types.d.ts"; 8 - import { applet, waitUntilAppletIsReady } from "@scripts/theme"; 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 + ); 9 19 10 20 //////////////////////////////////////////// 11 21 // SETUP 12 22 //////////////////////////////////////////// 13 - const context = applets.register<State>(); 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; 14 45 15 46 // Initial data 16 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 + 17 54 ready: false, 18 - tracks: [], 19 55 }; 20 56 21 57 // Applet connections 22 58 const configurator = { 23 - output: await applet("../../configurator/output", { context: self.top || self.parent }), 59 + output: await applet("../../configurator/output"), 24 60 }; 25 61 26 - // Load tracks 27 - loadTracks().then((tracks) => { 28 - update({ tracks }); 29 - }); 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 + }); 30 75 31 76 // State helpers 32 77 function update(partial: Partial<State>): void { 33 78 context.data = { ...context.data, ...partial }; 34 79 } 35 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 + 36 94 //////////////////////////////////////////// 37 95 // LOADERS 38 96 //////////////////////////////////////////// 39 - async function loadTracks(): Promise<Track[]> { 97 + async function loadTracks() { 40 98 await waitUntilAppletIsReady(configurator.output); 41 99 42 100 const data = await configurator.output.sendAction( ··· 49 107 }, 50 108 ); 51 109 110 + console.log("🔮 Loading tracks, got:", data); 111 + 52 112 if (!data) { 53 - return []; 113 + return undefined; 54 114 } 55 115 56 - return decode(data as Uint8Array); 116 + return Automerge.load<TracksDoc>(data as Uint8Array); 57 117 } 58 118 59 119 //////////////////////////////////////////// 60 120 // ACTIONS 61 121 //////////////////////////////////////////// 62 122 const tracksHandler = (tracks: Track[]) => { 63 - update({ tracks }); 123 + const doc = updateTracks(tracks); 124 + 125 + console.log("🔮 Tracks collection updated in memory"); 64 126 65 127 // 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); 128 + // TODO: For each track.uri scheme ask the input configurator if it needs to be cached? 129 + saveTracksToOutput(doc); 68 130 }; 69 131 70 - const saveTracksToOutput = debounce(5000, async function (tracks: Track[]) { 71 - const data = encode(tracks); 132 + const saveTracksToOutput = debounce(5000, async function (doc: Automerge.Doc<TracksDoc>) { 133 + const data = Automerge.save(doc); 134 + 135 + console.log("🔮 Saving tracks"); 72 136 73 137 await configurator.output.sendAction("put", { 74 138 name: "tracks.json", 75 139 data, 76 140 }); 141 + 142 + console.log("🔮 Tracks saved to output"); 77 143 }); 78 144 79 145 context.setActionHandler("tracks", tracksHandler); ··· 81 147 //////////////////////////////////////////// 82 148 // 🛠️ 83 149 //////////////////////////////////////////// 84 - function decode(data: Uint8Array) { 85 - return JSON.parse(new TextDecoder().decode(data)); 86 - } 150 + function cleanUndefinedValuesForTracks(tracks: Track[]): Track[] { 151 + return tracks.map((track) => { 152 + const t = { ...track }; 87 153 88 - function encode(data: Object) { 89 - return new TextEncoder().encode(JSON.stringify(data)); 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 + }); 90 166 } 91 167 92 168 ////////////////////////////////////////////
+9 -2
src/pages/orchestrator/output-management/types.d.ts
··· 1 - import { Output } from "@applets/core/types"; 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; 2 8 3 - export type State = Output & { ready: boolean }; 9 + ready: boolean; 10 + };
+37 -27
src/pages/orchestrator/single-queue/_applet.astro
··· 1 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"; 2 + import type { ResolvedUri, Track } from "@applets/core/types.d.ts"; 3 + import { applet, comparable, reactive, register } from "@scripts/applets/common"; 6 4 7 5 //////////////////////////////////////////// 8 6 // SETUP 9 7 //////////////////////////////////////////// 10 8 import type * as AudioEngine from "@applets/engine/audio/types.d.ts"; 11 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"; 12 11 13 12 // Register applet 14 - const context = applets.register<unknown>(); 13 + const context = register<unknown>(); 15 14 16 15 // Applet connections 16 + const configurator = { 17 + input: await applet("../../configurator/input"), 18 + }; 19 + 17 20 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 - }), 21 + audio: await applet<AudioEngine.State>("../../engine/audio"), 22 + queue: await applet<QueueEngine.State>("../../engine/queue"), 24 23 }; 25 24 26 25 const orchestrator = { 27 - output: await applet<Output>("../../orchestrator/output-management", { context: self.parent }), 26 + output: await applet<OutputOrchestrator.State>("../../orchestrator/output-management"), 28 27 }; 29 28 30 29 //////////////////////////////////////////// ··· 32 31 //////////////////////////////////////////// 33 32 context.setActionHandler("fill", fill); 34 33 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 - }), 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([]), 47 55 ); 56 + 57 + await engine.queue.sendAction("add", queueItems); 48 58 } 49 59 50 60 //////////////////////////////////////////// ··· 96 106 }, 97 107 }); 98 108 99 - fill(); 109 + fill(orchestrator.output.data.tracks.collection); 100 110 }, 101 111 ); 102 112 ··· 106 116 //////////////////////////////////////////// 107 117 reactive( 108 118 orchestrator.output, 109 - (data) => (data ? comparable(data.tracks) : undefined), 119 + (data) => (data ? comparable(data.tracks.collection) : undefined), 110 120 (hash) => { 111 - if (hash) fill(); 121 + if (hash) fill(orchestrator.output.data.tracks.collection); 112 122 }, 113 123 ); 114 124 </script>
+6 -1
src/pages/orchestrator/single-queue/_manifest.json
··· 5 5 "actions": { 6 6 "fill": { 7 7 "title": "Fill", 8 - "description": "Fill up the queue." 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 + } 9 14 } 10 15 } 11 16 }
+2 -2
src/pages/output/indexed-db/_applet.astro
··· 1 1 <script> 2 2 import * as IDB from "idb-keyval"; 3 - import { applets } from "@web-applets/sdk"; 4 3 5 4 import type { OutputGetter, OutputSetter } from "@applets/core/types.d.ts"; 5 + import { register } from "@scripts/applets/common"; 6 6 7 7 //////////////////////////////////////////// 8 8 // SETUP 9 9 //////////////////////////////////////////// 10 10 const IDB_PREFIX = "@applets/output/indexed-db"; 11 - const context = applets.register(); 11 + const context = register(); 12 12 13 13 //////////////////////////////////////////// 14 14 // ACTIONS
+2 -2
src/pages/output/native-fs/_applet.astro
··· 1 1 <script> 2 2 import * as IDB from "idb-keyval"; 3 - import { applets } from "@web-applets/sdk"; 4 3 import { type FileSystemDirectoryHandle, showDirectoryPicker } from "native-file-system-adapter"; 5 4 6 5 import type { OutputGetter, OutputSetter } from "@applets/core/types.d.ts"; 6 + import { register } from "@scripts/applets/common"; 7 7 8 8 //////////////////////////////////////////// 9 9 // SETUP ··· 11 11 const IDB_PREFIX = "@applets/output/native-fs"; 12 12 const IDB_DEVICE_KEY = `${IDB_PREFIX}/device`; 13 13 14 - const context = applets.register(); 14 + const context = register(); 15 15 16 16 //////////////////////////////////////////// 17 17 // ACTIONS
+70 -11
src/pages/processor/metadata-fetcher/_applet.astro
··· 1 1 <script> 2 2 import { applets } from "@web-applets/sdk"; 3 - import { parseWebStream } from "music-metadata"; 3 + import { parseFromTokenizer, parseWebStream } from "music-metadata"; 4 4 import { contentType } from "@std/media-types"; 5 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"; 6 10 7 11 //////////////////////////////////////////// 8 12 // SETUP 9 13 //////////////////////////////////////////// 10 14 const context = applets.register(); 11 15 16 + type Extraction = { stats: TrackStats; tags: TrackTags }; 17 + type Urls = { get: string; head: string }; 18 + 12 19 //////////////////////////////////////////// 13 20 // ACTIONS 14 21 //////////////////////////////////////////// 15 22 context.setActionHandler("extract", extract); 16 23 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 }); 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; 26 59 27 - return metadata; 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 + }; 28 87 } 29 88 </script>
+14 -2
src/pages/processor/metadata-fetcher/_manifest.json
··· 7 7 "title": "Extract", 8 8 "description": "Get the metadata for a given URL.", 9 9 "params_schema": { 10 - "type": "string", 11 - "description": "URL" 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 + } 12 24 } 13 25 } 14 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"; 1 + import { applet, reactive } from "@scripts/applets/common"; 3 2 4 3 //////////////////////////////////////////// 5 4 // 🎨 Styles ··· 14 13 15 14 import type * as AudioUI from "@applets/themes/pilot/ui/audio/types.d.ts"; 16 15 17 - const _configurator = { 18 - output: await applet("../../configurator/output"), 19 - }; 16 + // TODO: Themes 20 17 21 18 const engine = { 22 19 audio: await applet<AudioEngine.State>("../../engine/audio"), 23 20 queue: await applet<QueueEngine.State>("../../engine/queue"), 24 21 }; 25 22 26 - const input = { 27 - nativeFs: await applet("../../input/native-fs"), 28 - }; 29 - 30 23 const _orchestrator = { 31 - input: await applet<Output>("../../orchestrator/input-cache"), 32 - output: await applet<Output>("../../orchestrator/output-management"), 24 + input: await applet("../../orchestrator/input-cache", { 25 + applets: { input: "todo" }, 26 + }), 27 + output: await applet("../../orchestrator/output-management"), 33 28 queue: await applet("../../orchestrator/single-queue"), 34 29 }; 35 30
+8 -7
src/scripts/themes/webamp/index.ts
··· 1 1 import Webamp from "webamp"; 2 2 import { URLTrack } from "webamp"; 3 3 4 - import type { Output, Track } from "@applets/core/types.d.ts"; 5 - import { applet, waitUntilAppletIsReady } from "../../theme.ts"; 4 + import type { ResolvedUri, Track } from "@applets/core/types.d.ts"; 5 + import { applet } from "@scripts/applets/common"; 6 6 7 7 //////////////////////////////////////////// 8 8 // 🎨 Styles ··· 12 12 //////////////////////////////////////////// 13 13 // 🗂️ Applets 14 14 //////////////////////////////////////////// 15 + import type * as OutputOrchestrator from "@applets/orchestrator/output-management/types.d.ts"; 15 16 16 17 const configurator = { 17 18 input: await applet("../../configurator/input"), 18 19 }; 19 20 20 21 const orchestrator = { 21 - output: await applet<Output>("../../orchestrator/output-management"), 22 + output: await applet<OutputOrchestrator.State>("../../orchestrator/output-management"), 22 23 23 24 // TODO: Should this be explicitely be ran after the output orchestrator is loaded? 24 25 input: await applet("../../orchestrator/input-cache"), ··· 47 48 // 🛠️ 48 49 //////////////////////////////////////////// 49 50 async function loadTracks(): Promise<URLTrack[]> { 50 - return await orchestrator.output.data.tracks.reduce( 51 + return await orchestrator.output.data.tracks.collection.reduce( 51 52 async (promise: Promise<URLTrack[]>, track: Track) => { 52 53 const acc = await promise; 53 54 54 55 // TODO: Ideally the URL should only be resolved when needed, 55 56 // but webamp doesn't allow for that. 56 57 // Maybe you could work around it with a service worker. 57 - const url = await configurator.input.sendAction<string | undefined>( 58 + const resGet = await configurator.input.sendAction<ResolvedUri>( 58 59 "resolve", 59 60 { method: "GET", uri: track.uri }, 60 61 { ··· 62 63 }, 63 64 ); 64 65 65 - if (!url) return acc; 66 + if (!resGet) return acc; 66 67 67 68 const urlTrack: URLTrack = { 68 - url, 69 + url: resGet.url, 69 70 metaData: { 70 71 title: track.tags?.title || "", 71 72 artist: track.tags?.artist || "",
+1
src/styles/themes/pilot/index.css
··· 54 54 iframe[src*="/engine/"], 55 55 iframe[src*="/input/"], 56 56 iframe[src*="/orchestrator/"], 57 + iframe[src*="/processor/"], 57 58 iframe[src*="/output/"] { 58 59 height: 0; 59 60 left: 110vw;