A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: s3 connect facet

+421 -16
+3 -1
src/_includes/layouts/diffuse.vto
··· 46 46 47 47 "@awesome.me/webawesome/dist/": "./vendor/@awesome.me/webawesome/", 48 48 "@awesome.me/webawesome/dist-cdn/": "./vendor/@awesome.me/webawesome/", 49 - "@phosphor-icons/web/": "./vendor/@phosphor-icons/web/" 49 + "@phosphor-icons/web/": "./vendor/@phosphor-icons/web/", 50 + "idb-keyval": "./vendor/idb-keyval/index.js", 51 + "lit-html": "./vendor/lit-html/index.js" 50 52 } 51 53 } 52 54 </script>
+1 -1
src/components/output/bytes/s3/constants.js
··· 1 - export const OBJECT_PREFIX = "diffuse/output/bytes/s3/"; 1 + export const OBJECT_PREFIX = "";
+150 -10
src/facets/connect/s3/index.html
··· 1 - <link rel="stylesheet" href="vendor/@awesome.me/webawesome/styles/webawesome.css" /> 1 + <style> 2 + @import "./vendor/@awesome.me/webawesome/styles/webawesome.css" layer(wa); 2 3 3 - <style> 4 - #container { 4 + @layer base, diffuse, wa; 5 + 6 + main { 5 7 display: flex; 6 8 align-items: center; 7 9 justify-content: center; 8 10 min-height: 100dvh; 11 + opacity: 0; 12 + pointer-events: none; 13 + transition: opacity 750ms; 14 + 15 + &.has-loaded { 16 + opacity: 1; 17 + pointer-events: auto; 18 + } 19 + } 20 + 21 + wa-card { 22 + width: min(400px, calc(100vw - 2rem)); 23 + } 24 + 25 + .card-header { 26 + display: flex; 27 + align-items: center; 28 + justify-content: space-between; 29 + } 30 + 31 + .card-body { 32 + display: flex; 33 + flex-direction: column; 34 + gap: var(--wa-space-m); 35 + } 36 + 37 + .button-row { 38 + display: flex; 39 + gap: var(--wa-space-s); 40 + flex-wrap: wrap; 41 + } 42 + 43 + .dialog-body { 44 + display: flex; 45 + flex-direction: column; 46 + gap: var(--wa-space-m); 47 + } 48 + 49 + .dialog-footer { 50 + display: flex; 51 + gap: var(--wa-space-s); 52 + } 53 + 54 + .buckets-list { 55 + list-style: none; 56 + padding: 0; 57 + margin: 0; 58 + display: flex; 59 + flex-direction: column; 60 + gap: var(--wa-space-s); 61 + } 62 + 63 + .bucket-item { 64 + display: flex; 65 + align-items: center; 66 + gap: var(--wa-space-s); 67 + } 68 + 69 + .bucket-info { 70 + display: flex; 71 + flex-direction: column; 72 + gap: var(--wa-space-2xs); 73 + flex: 1; 74 + min-width: 0; 75 + } 76 + 77 + .bucket-name { 78 + font-weight: var(--wa-font-weight-semibold); 79 + white-space: nowrap; 80 + overflow: hidden; 81 + text-overflow: ellipsis; 82 + } 83 + 84 + .bucket-host { 85 + font-size: var(--wa-font-size-xs); 86 + color: var(--wa-color-text-quiet); 87 + white-space: nowrap; 88 + overflow: hidden; 89 + text-overflow: ellipsis; 90 + } 91 + 92 + .bucket-tags { 93 + display: flex; 94 + gap: var(--wa-space-2xs); 95 + flex-shrink: 0; 96 + } 97 + 98 + [hidden] { 99 + display: none !important; 100 + } 101 + 102 + p { 103 + margin: 0; 9 104 } 10 105 </style> 11 106 12 - <main class="wa-theme-default wa-cloak wa-stack wa-gap-2xl"> 13 - <div class="wa-stack wa-gap-xs wa-align-items-center"> 14 - <h2 class="wa-heading-2xl">Connect S3</h2> 15 - <p class="wa-caption-m"> 16 - Connect to an S3-compatible storage for audio input or user-data storage. 17 - </p> 18 - </div> 107 + <main class="wa-theme-default"> 108 + <wa-card> 109 + <div slot="header" class="card-header"> 110 + <strong>S3</strong> 111 + </div> 112 + 113 + <div class="card-body"> 114 + <p>Connect to an S3-compatible storage to use it as audio input or user-data storage.</p> 115 + 116 + <p class="wa-caption-xs"> 117 + A custom syncing strategy is used for the user-data storage, tracking what was added and 118 + removed so conflicts can be resolved. 119 + </p> 120 + 121 + <div class="button-row"> 122 + <wa-button id="add-input-btn" variant="brand" appearance="filled"> 123 + <wa-icon slot="start" library="phosphor/bold" name="music-notes"></wa-icon> 124 + Add audio input 125 + </wa-button> 126 + <wa-button id="add-output-btn" variant="neutral" appearance="outlined"> 127 + <wa-icon slot="start" library="phosphor/bold" name="person"></wa-icon> 128 + Use as userdata storage 129 + </wa-button> 130 + </div> 131 + 132 + <wa-divider id="buckets-divider" hidden></wa-divider> 133 + 134 + <ul id="buckets-list" class="buckets-list" hidden> 135 + <!-- Rendered by JS --> 136 + </ul> 137 + </div> 138 + </wa-card> 139 + 140 + <wa-dialog id="bucket-dialog" label="Add S3 Bucket"> 141 + <div class="dialog-body"> 142 + <wa-input id="host-input" label="Host" placeholder="s3.amazonaws.com"></wa-input> 143 + <wa-input id="bucket-name-input" label="Bucket name" placeholder="my-bucket"></wa-input> 144 + <wa-input id="region-input" label="Region" placeholder="us-east-1"></wa-input> 145 + <wa-input id="access-key-input" label="Access key"></wa-input> 146 + <wa-input id="secret-key-input" label="Secret key" type="password"></wa-input> 147 + <wa-input id="path-input" label="Path" placeholder="/"></wa-input> 148 + </div> 149 + <div slot="footer" class="dialog-footer"> 150 + <wa-button id="confirm-btn" variant="brand" appearance="filled">Add</wa-button> 151 + <wa-button id="cancel-btn" variant="neutral" appearance="outlined">Cancel</wa-button> 152 + </div> 153 + </wa-dialog> 19 154 </main> 20 155 21 156 <script type="module" src="facets/connect/s3/index.inline.js"></script> 157 + 158 + <script type="module"> 159 + await customElements.whenDefined("wa-button"); 160 + document.querySelector("main")?.classList.add("has-loaded"); 161 + </script>
+265 -4
src/facets/connect/s3/index.inline.js
··· 1 + import "@awesome.me/webawesome/dist/components/badge/badge.js"; 2 + import "@awesome.me/webawesome/dist/components/button/button.js"; 1 3 import "@awesome.me/webawesome/dist/components/card/card.js"; 2 - import "@awesome.me/webawesome/dist/components/button/button.js"; 3 - import "@awesome.me/webawesome/dist/components/drawer/drawer.js"; 4 + import "@awesome.me/webawesome/dist/components/dialog/dialog.js"; 5 + import "@awesome.me/webawesome/dist/components/divider/divider.js"; 6 + import "@awesome.me/webawesome/dist/components/icon/icon.js"; 4 7 import "@awesome.me/webawesome/dist/components/input/input.js"; 5 - import "@awesome.me/webawesome/dist/components/icon/icon.js"; 6 8 7 9 import "~/common/webawesome/detect-dark.js"; 10 + import "~/common/webawesome/phosphor/bold.js"; 8 11 9 - // Set doc title 12 + import { html, nothing, render } from "lit-html"; 13 + 14 + import * as IDB from "idb-keyval"; 15 + import * as TID from "@atcute/tid"; 16 + 17 + import { bucketId, buildURI, parseURI } from "~/components/input/s3/common.js"; 18 + import { SCHEME as S3_SCHEME } from "~/components/input/s3/constants.js"; 19 + import foundation from "~/common/foundation.js"; 20 + import { effect, signal } from "~/common/signal.js"; 21 + 10 22 document.title = "Connect S3 | Diffuse"; 23 + 24 + /** 25 + * @import { default as WaDialog } from "@awesome.me/webawesome/dist/components/dialog/dialog.js" 26 + * @import { default as WaInput } from "@awesome.me/webawesome/dist/components/input/input.js" 27 + * @import { Bucket } from "~/components/input/s3/types.d.ts" 28 + */ 29 + 30 + const OUTPUT_IDB_KEY = "diffuse/output/bytes/s3/bucket"; 31 + 32 + //////////////////////////////////////////// 33 + // SETUP 34 + //////////////////////////////////////////// 35 + 36 + const [inputConfigurator, outputOrchestrator, sourcesOrchestrator] = 37 + await Promise.all([ 38 + foundation.configurator.input(), 39 + foundation.orchestrator.output(), 40 + foundation.orchestrator.sources(), 41 + ]); 42 + 43 + await Promise.all([ 44 + customElements.whenDefined(inputConfigurator.localName), 45 + customElements.whenDefined(outputOrchestrator.localName), 46 + customElements.whenDefined(sourcesOrchestrator.localName), 47 + ]); 48 + 49 + //////////////////////////////////////////// 50 + // STATE 51 + //////////////////////////////////////////// 52 + 53 + /** @type {'input' | 'output'} */ 54 + let dialogMode = "input"; 55 + 56 + const $outputBucket = signal( 57 + /** @type {Bucket | undefined} */ (await IDB.get(OUTPUT_IDB_KEY)), 58 + ); 59 + 60 + //////////////////////////////////////////// 61 + // ELEMENTS 62 + //////////////////////////////////////////// 63 + 64 + const bucketDialog = /** @type {WaDialog} */ ( 65 + document.querySelector("#bucket-dialog") 66 + ); 67 + 68 + const hostInput = /** @type {WaInput} */ (document.querySelector("#host-input")); 69 + const bucketNameInput = /** @type {WaInput} */ ( 70 + document.querySelector("#bucket-name-input") 71 + ); 72 + const regionInput = /** @type {WaInput} */ ( 73 + document.querySelector("#region-input") 74 + ); 75 + const accessKeyInput = /** @type {WaInput} */ ( 76 + document.querySelector("#access-key-input") 77 + ); 78 + const secretKeyInput = /** @type {WaInput} */ ( 79 + document.querySelector("#secret-key-input") 80 + ); 81 + const pathInput = /** @type {WaInput} */ (document.querySelector("#path-input")); 82 + 83 + const bucketsDivider = /** @type {HTMLElement} */ ( 84 + document.querySelector("#buckets-divider") 85 + ); 86 + 87 + const bucketsList = /** @type {HTMLElement} */ ( 88 + document.querySelector("#buckets-list") 89 + ); 90 + 91 + //////////////////////////////////////////// 92 + // RENDER 93 + //////////////////////////////////////////// 94 + 95 + effect(() => { 96 + const inputSources = sourcesOrchestrator.sources()[S3_SCHEME] ?? []; 97 + const outputBucket = $outputBucket.value; 98 + 99 + /** @type {Map<string, { name: string; host: string; uri?: string; isInput: boolean; isOutput: boolean }>} */ 100 + const allBuckets = new Map(); 101 + 102 + for (const source of inputSources) { 103 + const parsed = parseURI(source.uri); 104 + if (!parsed) continue; 105 + 106 + const id = bucketId(parsed.bucket); 107 + allBuckets.set(id, { 108 + name: parsed.bucket.bucketName, 109 + host: parsed.bucket.host, 110 + uri: source.uri, 111 + isInput: true, 112 + isOutput: false, 113 + }); 114 + } 115 + 116 + if (outputBucket) { 117 + const id = bucketId(outputBucket); 118 + const existing = allBuckets.get(id); 119 + 120 + if (existing) { 121 + existing.isOutput = true; 122 + } else { 123 + allBuckets.set(id, { 124 + name: outputBucket.bucketName, 125 + host: outputBucket.host, 126 + uri: undefined, 127 + isInput: false, 128 + isOutput: true, 129 + }); 130 + } 131 + } 132 + 133 + const hasAny = allBuckets.size > 0; 134 + bucketsDivider.hidden = !hasAny; 135 + bucketsList.hidden = !hasAny; 136 + 137 + render( 138 + html` 139 + ${[...allBuckets.entries()].map( 140 + ([id, { name, host, uri, isInput, isOutput }]) => html` 141 + <li class="bucket-item"> 142 + <div class="bucket-info"> 143 + <span class="bucket-name">${name}</span> 144 + <span class="bucket-host">${host}</span> 145 + </div> 146 + <div class="bucket-tags"> 147 + ${isInput 148 + ? html`<wa-badge appearance="outlined" variant="brand">Input</wa-badge>` 149 + : nothing} 150 + ${isOutput 151 + ? html`<wa-badge appearance="outlined" variant="neutral">Output</wa-badge>` 152 + : nothing} 153 + </div> 154 + <wa-button 155 + appearance="plain" 156 + size="small" 157 + aria-label="Remove" 158 + @click="${() => removeBucket(uri, isOutput)}" 159 + > 160 + <wa-icon library="phosphor/bold" name="x"></wa-icon> 161 + </wa-button> 162 + </li> 163 + `, 164 + )} 165 + `, 166 + bucketsList, 167 + ); 168 + }); 169 + 170 + //////////////////////////////////////////// 171 + // ACTIONS 172 + //////////////////////////////////////////// 173 + 174 + /** @param {'input' | 'output'} mode */ 175 + function openDialog(mode) { 176 + dialogMode = mode; 177 + bucketDialog.label = 178 + mode === "input" ? "Add audio input" : "Use as userdata storage"; 179 + hostInput.value = ""; 180 + bucketNameInput.value = ""; 181 + regionInput.value = ""; 182 + accessKeyInput.value = ""; 183 + secretKeyInput.value = ""; 184 + pathInput.value = ""; 185 + bucketDialog.open = true; 186 + } 187 + 188 + /** 189 + * @param {string | undefined} uri 190 + * @param {boolean} isOutput 191 + */ 192 + async function removeBucket(uri, isOutput) { 193 + if (uri) { 194 + const tracksCol = outputOrchestrator.tracks.collection(); 195 + const tracks = tracksCol.state === "loaded" ? tracksCol.data : []; 196 + 197 + const detachedTracks = await inputConfigurator.detach({ 198 + fileUriOrScheme: uri, 199 + tracks, 200 + }); 201 + 202 + if (detachedTracks) await outputOrchestrator.tracks.save(detachedTracks); 203 + } 204 + 205 + if (isOutput) { 206 + $outputBucket.value = undefined; 207 + await IDB.del(OUTPUT_IDB_KEY); 208 + } 209 + } 210 + 211 + async function addBucket() { 212 + const host = hostInput.value?.trim(); 213 + const bucketName = bucketNameInput.value?.trim(); 214 + const region = regionInput.value?.trim() || "us-east-1"; 215 + const accessKey = accessKeyInput.value?.trim(); 216 + const secretKey = secretKeyInput.value?.trim(); 217 + const path = pathInput.value?.trim() || "/"; 218 + 219 + if (!host || !bucketName || !accessKey || !secretKey) return; 220 + 221 + /** @type {Bucket} */ 222 + const bucket = { accessKey, bucketName, host, path, region, secretKey }; 223 + 224 + if (dialogMode === "input") { 225 + const now = new Date().toISOString(); 226 + const uri = buildURI(bucket); 227 + 228 + const tracksCol = outputOrchestrator.tracks.collection(); 229 + const existingTracks = tracksCol.state === "loaded" ? tracksCol.data : []; 230 + 231 + await outputOrchestrator.tracks.save([ 232 + ...existingTracks, 233 + { 234 + $type: "sh.diffuse.output.track", 235 + id: TID.now(), 236 + createdAt: now, 237 + updatedAt: now, 238 + kind: "placeholder", 239 + uri, 240 + }, 241 + ]); 242 + } else { 243 + $outputBucket.value = bucket; 244 + await IDB.set(OUTPUT_IDB_KEY, bucket); 245 + 246 + const option = (await outputOrchestrator.options()).find( 247 + (o) => o.label === "S3", 248 + ); 249 + if (option) await outputOrchestrator.select(option.id); 250 + } 251 + 252 + bucketDialog.open = false; 253 + } 254 + 255 + //////////////////////////////////////////// 256 + // EVENT LISTENERS 257 + //////////////////////////////////////////// 258 + 259 + document 260 + .querySelector("#add-input-btn") 261 + ?.addEventListener("click", () => openDialog("input")); 262 + 263 + document 264 + .querySelector("#add-output-btn") 265 + ?.addEventListener("click", () => openDialog("output")); 266 + 267 + document.querySelector("#cancel-btn")?.addEventListener("click", () => { 268 + bucketDialog.open = false; 269 + }); 270 + 271 + document.querySelector("#confirm-btn")?.addEventListener("click", addBucket);
+1
src/vendor/idb-keyval/index.js
··· 1 + export * from "idb-keyval";
+1
src/vendor/lit-html/index.js
··· 1 + export * from "lit-html";