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

Configure Feed

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

feat: atproto connect facet + various improvements to s3

+204 -40
+10 -5
src/_data/facets.json
··· 30 30 "desc": "Store tracks locally for offline usage automatically after they've been playing for a while." 31 31 }, 32 32 { 33 + "url": "facets/connect/atproto/index.html", 34 + "title": "Connect / AT Protocol", 35 + "category": "Data", 36 + "featured": true, 37 + "desc": "Use your AT Protocol identity for user-data storage." 38 + }, 39 + { 33 40 "url": "facets/connect/s3/index.html", 34 - "title": "Connect to S3", 41 + "title": "Connect / S3", 35 42 "category": "Data", 36 43 "featured": true, 37 44 "desc": "Connect to an S3-compatible storage for audio input or user-data storage." ··· 48 55 "kind": "prelude", 49 56 "category": "Data", 50 57 "featured": true, 51 - "desc": "The default setup for audio input sources. Supports these services: HTTPS, Icecast, the local filesystem, OpenSubsonic, and S3-compatible storage." 58 + "desc": "The default setup for audio input sources. Adds support for: HTTPS, Icecast, the local filesystem, OpenSubsonic, and S3-compatible storage." 52 59 }, 53 60 { 54 61 "url": "facets/data/output-bundle/index.html", ··· 56 63 "kind": "prelude", 57 64 "category": "Data", 58 65 "featured": true, 59 - "desc": "The default setup for user-data storage output. Supports these services: AT Protocol and S3-compatible storage. For both of these a custom local-first syncing algorithm is used." 66 + "desc": "The default setup for user-data storage output. Adds support for: AT Protocol and S3-compatible storage. For both of these a custom local-first syncing algorithm is used." 60 67 }, 61 68 { 62 69 "url": "facets/data/process-tracks/index.html", ··· 109 116 "url": "themes/winamp/configurators/input/facet/index.html", 110 117 "title": "Winamp / Input", 111 118 "category": "Data", 112 - "featured": true, 113 119 "desc": "Add your audio sources." 114 120 }, 115 121 { 116 122 "url": "themes/winamp/configurators/output/facet/index.html", 117 123 "title": "Winamp / Output", 118 124 "category": "Data", 119 - "featured": true, 120 125 "desc": "Manage your data storage." 121 126 } 122 127 ]
+2 -2
src/common/facets/constants.js
··· 1 + import * as TID from "@atcute/tid"; 1 2 import facets from "../../_data/facets.json" with { 2 3 type: "json", 3 4 }; ··· 29 30 ) { 30 31 return [{ 31 32 ...properties, 32 - id: "defaults/" + 33 - facet.url.replace(/^\facets\/\w+\//, "").replace(/\/index.html/, ""), 33 + id: TID.now(), 34 34 }]; 35 35 } 36 36
+1 -1
src/components/transformer/output/raw/atproto-sync/element.js
··· 66 66 // Track deletions: any id present in local but absent in 67 67 // newData has been deleted by the user. 68 68 const oldCol = await Output.data(l[name]); 69 - if (Array.isArray(oldCol.data)) { 69 + if (oldCol && Array.isArray(oldCol.data)) { 70 70 const newIds = new Set(newData.map((/** @type {any} */ r) => r.id)); 71 71 72 72 for (const record of oldCol.data) {
+15
src/facets/connect/atproto/index.html
··· 1 + <style> 2 + @import "./vendor/@awesome.me/webawesome/styles/webawesome.css" layer(wa); 3 + @import "./facets/connect/common.css" layer(connect); 4 + 5 + @layer base, diffuse, wa; 6 + </style> 7 + 8 + <main class="wa-theme-default"></main> 9 + 10 + <script type="module" src="facets/connect/atproto/index.inline.js"></script> 11 + 12 + <script type="module"> 13 + await customElements.whenDefined("wa-card"); 14 + document.querySelector("main")?.classList.add("has-loaded"); 15 + </script>
+118
src/facets/connect/atproto/index.inline.js
··· 1 + import "@awesome.me/webawesome/dist/components/input/input.js"; 2 + 3 + import "~/common/webawesome/detect-dark.js"; 4 + import "~/common/webawesome/phosphor/bold.js"; 5 + 6 + import { html } from "lit-html"; 7 + 8 + import { NAME as ATPROTO_NAME } from "~/components/output/raw/atproto/element.js"; 9 + import { effect } from "~/common/signal.js"; 10 + import foundation from "~/common/foundation.js"; 11 + 12 + import { setup } from "~/facets/connect/common.js"; 13 + 14 + document.title = "Connect AT Protocol | Diffuse"; 15 + 16 + /** 17 + * @import { default as WaInput } from "@awesome.me/webawesome/dist/components/input/input.js" 18 + * @import { ATProtoOutputElement } from "~/components/output/raw/atproto/types.d.ts" 19 + */ 20 + 21 + //////////////////////////////////////////// 22 + // SETUP 23 + //////////////////////////////////////////// 24 + 25 + const outputOrchestrator = await foundation.orchestrator.output(); 26 + 27 + await customElements.whenDefined(outputOrchestrator.localName); 28 + 29 + const atprotoOption = (await outputOrchestrator.options()).find( 30 + (o) => o.label === "AT Protocol", 31 + ); 32 + 33 + const atprotoEl = /** @type {ATProtoOutputElement | undefined} */ ( 34 + outputOrchestrator.root().querySelector(ATPROTO_NAME) 35 + ); 36 + 37 + if (!atprotoOption) { 38 + throw new Error("AT Protocol output was not enabled!"); 39 + } 40 + 41 + const ATPROTO_OUTPUT_ID = atprotoOption.id; 42 + 43 + //////////////////////////////////////////// 44 + // UI 45 + //////////////////////////////////////////// 46 + 47 + const { setItems } = setup({ 48 + title: "AT Protocol", 49 + hasInput: false, 50 + 51 + description: html` 52 + <p> 53 + Connect to your AT Protocol identity to use it as user-data storage. 54 + </p> 55 + <p class="wa-caption-xs"> 56 + Your data is stored as lexicon records in your personal data server (PDS). 57 + </p> 58 + `, 59 + 60 + formFields: html` 61 + <wa-input 62 + id="atproto-handle" 63 + label="Handle" 64 + placeholder="you.bsky.social" 65 + required 66 + ></wa-input> 67 + <p class="wa-caption-xs">* Required fields</p> 68 + `, 69 + 70 + onSubmit: (_mode) => connect(), 71 + 72 + onOutputActivate: async () => { 73 + await outputOrchestrator.select(ATPROTO_OUTPUT_ID); 74 + }, 75 + }); 76 + 77 + const handleInput = 78 + /** @type {WaInput} */ (document.querySelector("#atproto-handle")); 79 + 80 + //////////////////////////////////////////// 81 + // REACTIVE LIST 82 + //////////////////////////////////////////// 83 + 84 + effect(() => { 85 + const did = atprotoEl?.did(); 86 + const isSelectedOutput = outputOrchestrator.selected()?.id === ATPROTO_OUTPUT_ID; 87 + 88 + setItems( 89 + did 90 + ? [ 91 + { 92 + name: did, 93 + detail: "AT Protocol", 94 + isInput: false, 95 + isOutput: true, 96 + isSelectedOutput, 97 + onRemove: () => disconnect(), 98 + }, 99 + ] 100 + : [], 101 + ); 102 + }); 103 + 104 + //////////////////////////////////////////// 105 + // ACTIONS 106 + //////////////////////////////////////////// 107 + 108 + async function connect() { 109 + const handle = handleInput.value?.trim(); 110 + if (!handle) return; 111 + 112 + await outputOrchestrator.select(ATPROTO_OUTPUT_ID); 113 + await atprotoEl?.login(handle); 114 + } 115 + 116 + async function disconnect() { 117 + await atprotoEl?.logout(); 118 + }
+1
src/facets/connect/common.css
··· 59 59 display: flex; 60 60 align-items: center; 61 61 gap: var(--wa-space-s); 62 + margin-left: 0; 62 63 } 63 64 64 65 .connect-item__info {
+28 -10
src/facets/connect/common.js
··· 13 13 */ 14 14 15 15 /** 16 - * @typedef {{ name: string; detail: string; isInput: boolean; isOutput: boolean; onRemove: () => void }} ConnectItem 16 + * @typedef {{ name: string; detail: string; isInput: boolean; isOutput: boolean; isSelectedOutput: boolean; onRemove: () => void }} ConnectItem 17 17 */ 18 18 19 19 /** ··· 26 26 * @param {TemplateResult | string} config.description - Content above the buttons 27 27 * @param {TemplateResult} config.formFields - Form body content (inputs, footnotes, etc.) 28 28 * @param {(mode: 'input' | 'output') => Promise<void>} config.onSubmit 29 + * @param {boolean} [config.hasInput] - Whether to show the "Add audio input" button (default: true) 30 + * @param {() => Promise<void>} [config.onOutputActivate] - Called instead of opening the dialog when output is already configured but inactive 29 31 * 30 32 * @returns {{ setItems: (items: ConnectItem[]) => void }} 31 33 */ 32 - export function setup({ title, description, formFields, onSubmit }) { 34 + export function setup({ title, description, formFields, onSubmit, hasInput = true, onOutputActivate }) { 33 35 const main = document.querySelector("main"); 34 36 if (!main) throw new Error("No <main> element"); 35 37 ··· 42 44 <div class="card-body"> 43 45 ${description} 44 46 <div class="button-row"> 45 - <wa-button id="connect-add-input-btn" variant="brand" appearance="filled"> 47 + ${hasInput 48 + ? html`<wa-button id="connect-add-input-btn" variant="brand" appearance="filled"> 46 49 <wa-icon slot="start" library="phosphor/bold" name="music-notes"></wa-icon> 47 50 Add audio input 48 - </wa-button> 51 + </wa-button>` 52 + : nothing} 49 53 <wa-button id="connect-add-output-btn" variant="neutral" appearance="outlined"> 50 54 <wa-icon slot="start" library="phosphor/bold" name="person"></wa-icon> 51 55 Use as userdata storage ··· 80 84 const form = /** @type {HTMLFormElement} */ (main.querySelector("#connect-form")); 81 85 const divider = /** @type {HTMLElement} */ (main.querySelector("#connect-divider")); 82 86 const list = /** @type {HTMLElement} */ (main.querySelector("#connect-list")); 87 + const outputBtn = /** @type {HTMLElement} */ (main.querySelector("#connect-add-output-btn")); 83 88 84 89 /** @type {'input' | 'output'} */ 85 90 let mode = "input"; 91 + 92 + /** @type {ConnectItem[]} */ 93 + let currentItems = []; 86 94 87 95 /** @param {'input' | 'output'} m */ 88 96 const openDialog = (m) => { ··· 92 100 dialog.open = true; 93 101 }; 94 102 95 - main 96 - .querySelector("#connect-add-input-btn") 97 - ?.addEventListener("click", () => openDialog("input")); 103 + if (hasInput) { 104 + main 105 + .querySelector("#connect-add-input-btn") 106 + ?.addEventListener("click", () => openDialog("input")); 107 + } 98 108 99 109 main 100 110 .querySelector("#connect-add-output-btn") 101 - ?.addEventListener("click", () => openDialog("output")); 111 + ?.addEventListener("click", () => { 112 + if (onOutputActivate && currentItems.some((i) => i.isOutput)) { 113 + onOutputActivate(); 114 + } else { 115 + openDialog("output"); 116 + } 117 + }); 102 118 103 119 main.querySelector("#connect-cancel-btn")?.addEventListener("click", () => { 104 120 dialog.open = false; ··· 118 134 * @param {ConnectItem[]} items 119 135 */ 120 136 setItems(items) { 137 + currentItems = items; 121 138 divider.hidden = items.length === 0; 122 139 list.hidden = items.length === 0; 140 + outputBtn.hidden = items.some((i) => i.isOutput && i.isSelectedOutput); 123 141 litRender( 124 142 html`${items.map( 125 - ({ name, detail, isInput, isOutput, onRemove }) => html` 143 + ({ name, detail, isInput, isOutput, isSelectedOutput, onRemove }) => html` 126 144 <li class="connect-item"> 127 145 <div class="connect-item__info"> 128 146 <span class="connect-item__name">${name}</span> ··· 133 151 ? html`<wa-badge appearance="outlined" variant="brand">Input</wa-badge>` 134 152 : nothing} 135 153 ${isOutput 136 - ? html`<wa-badge appearance="outlined" variant="neutral">Output</wa-badge>` 154 + ? html`<wa-badge appearance="outlined" variant="${isSelectedOutput ? "success" : "warning"}">Output</wa-badge>` 137 155 : nothing} 138 156 </div> 139 157 <wa-button
+29 -22
src/facets/connect/s3/index.inline.js
··· 3 3 import "~/common/webawesome/detect-dark.js"; 4 4 import "~/common/webawesome/phosphor/bold.js"; 5 5 6 + import * as TID from "@atcute/tid"; 6 7 import { html } from "lit-html"; 7 8 8 - import * as IDB from "idb-keyval"; 9 - import * as TID from "@atcute/tid"; 10 - 9 + import * as Output from "~/common/output.js"; 10 + import { NAME as S3_NAME } from "~/components/output/bytes/s3/element.js"; 11 + import { SCHEME as S3_SCHEME } from "~/components/input/s3/constants.js"; 11 12 import { bucketId, buildURI, parseURI } from "~/components/input/s3/common.js"; 12 - import { SCHEME as S3_SCHEME } from "~/components/input/s3/constants.js"; 13 + import { effect } from "~/common/signal.js"; 13 14 import foundation from "~/common/foundation.js"; 14 - import { effect, signal } from "~/common/signal.js"; 15 15 16 16 import { setup } from "~/facets/connect/common.js"; 17 17 ··· 20 20 /** 21 21 * @import { default as WaInput } from "@awesome.me/webawesome/dist/components/input/input.js" 22 22 * @import { Bucket } from "~/components/input/s3/types.d.ts" 23 + * @import { S3OutputElement } from "~/components/output/bytes/s3/types.d.ts" 23 24 */ 24 - 25 - const OUTPUT_IDB_KEY = "diffuse/output/bytes/s3/bucket"; 26 25 27 26 //////////////////////////////////////////// 28 27 // SETUP ··· 41 40 customElements.whenDefined(sourcesOrchestrator.localName), 42 41 ]); 43 42 44 - const $outputBucket = signal( 45 - /** @type {Bucket | undefined} */ (await IDB.get(OUTPUT_IDB_KEY)), 43 + const s3Option = (await outputOrchestrator.options()).find( 44 + (o) => o.label === "S3", 45 + ); 46 + 47 + const s3El = /** @type {S3OutputElement | undefined} */ ( 48 + outputOrchestrator.root().querySelector(S3_NAME) 46 49 ); 47 50 51 + if (!s3Option) { 52 + throw new Error("S3 output was not enabled!"); 53 + } 54 + 55 + const OUTPUT_S3_ID = s3Option?.id; 56 + 48 57 //////////////////////////////////////////// 49 58 // UI 50 59 //////////////////////////////////////////// ··· 84 93 `, 85 94 86 95 onSubmit: (mode) => addBucket(mode), 96 + 97 + onOutputActivate: async () => { 98 + await outputOrchestrator.select(OUTPUT_S3_ID); 99 + }, 87 100 }); 88 101 89 102 const accessKeyInput = ··· 103 116 104 117 effect(() => { 105 118 const inputSources = sourcesOrchestrator.sources()[S3_SCHEME] ?? []; 106 - const outputBucket = $outputBucket.value; 119 + const outputBucket = s3El?.bucket(); 120 + const isSelectedOutput = outputOrchestrator.selected()?.id === OUTPUT_S3_ID; 107 121 108 122 /** @type {Map<string, { name: string; host: string; uri?: string; isInput: boolean; isOutput: boolean }>} */ 109 123 const allBuckets = new Map(); ··· 145 159 detail: host, 146 160 isInput, 147 161 isOutput, 162 + isSelectedOutput: isOutput && isSelectedOutput, 148 163 onRemove: () => removeBucket(uri, isOutput), 149 164 })), 150 165 ); ··· 160 175 */ 161 176 async function removeBucket(uri, isOutput) { 162 177 if (uri) { 163 - const tracksCol = outputOrchestrator.tracks.collection(); 164 - const tracks = tracksCol.state === "loaded" ? tracksCol.data : []; 165 - 178 + const tracks = await Output.data(outputOrchestrator.tracks); 166 179 const detachedTracks = await inputConfigurator.detach({ 167 180 fileUriOrScheme: uri, 168 181 tracks, ··· 172 185 } 173 186 174 187 if (isOutput) { 175 - $outputBucket.value = undefined; 176 - await IDB.del(OUTPUT_IDB_KEY); 188 + await s3El?.unsetBucket(); 177 189 } 178 190 } 179 191 ··· 213 225 }, 214 226 ]); 215 227 } else { 216 - $outputBucket.value = bucket; 217 - await IDB.set(OUTPUT_IDB_KEY, bucket); 218 - 219 - const option = (await outputOrchestrator.options()).find( 220 - (o) => o.label === "S3", 221 - ); 222 - if (option) await outputOrchestrator.select(option.id); 228 + await s3El?.setBucket(bucket); 229 + await outputOrchestrator.select(OUTPUT_S3_ID); 223 230 } 224 231 }