diff --git a/src/_data/facets.json b/src/_data/facets.json index 5a466b7d..e867fe60 100644 --- a/src/_data/facets.json +++ b/src/_data/facets.json @@ -36,6 +36,13 @@ "featured": true, "desc": "Use your AT Protocol identity for user-data storage." }, + { + "url": "facets/connect/opensubsonic/index.html", + "title": "Connect / OpenSubsonic", + "category": "Data", + "featured": true, + "desc": "Connect to an OpenSubsonic server for audio input." + }, { "url": "facets/connect/s3/index.html", "title": "Connect / S3", diff --git a/src/facets/connect/common.js b/src/facets/connect/common.js index 2c60bb16..12ee180a 100644 --- a/src/facets/connect/common.js +++ b/src/facets/connect/common.js @@ -32,6 +32,7 @@ import { html, nothing, render as litRender } from "lit-html"; * @param {TemplateResult} config.formFields - Form body content (inputs, footnotes, etc.) * @param {(mode: 'input' | 'output') => Promise} config.onSubmit * @param {boolean} [config.hasInput] - Whether to show the "Add audio input" button (default: true) + * @param {boolean} [config.hasOutput] - Whether to show the "Use as userdata storage" button (default: true) * @param {() => Promise} [config.onOutputActivate] - Called instead of opening the dialog when output is already configured but inactive * * @returns {{ setItems: (items: ConnectItem[]) => void, setError: (message: string | null) => void }} @@ -43,6 +44,7 @@ export function setup( formFields, onSubmit, hasInput = true, + hasOutput = true, onOutputActivate, }, ) { @@ -66,14 +68,18 @@ export function setup( ` : nothing} - - - Use as userdata storage - + ${hasOutput + ? html` + + + Use as userdata storage + + ` + : nothing} @@ -201,7 +207,7 @@ export function setup( currentItems = items; divider.hidden = items.length === 0; list.hidden = items.length === 0; - outputBtn.hidden = items.some((i) => i.isOutput && i.isSelectedOutput); + if (outputBtn) outputBtn.hidden = items.some((i) => i.isOutput && i.isSelectedOutput); litRender( html` ${items.map( diff --git a/src/facets/connect/opensubsonic/index.html b/src/facets/connect/opensubsonic/index.html new file mode 100644 index 00000000..bf332533 --- /dev/null +++ b/src/facets/connect/opensubsonic/index.html @@ -0,0 +1,15 @@ + + +
+ + + + diff --git a/src/facets/connect/opensubsonic/index.inline.js b/src/facets/connect/opensubsonic/index.inline.js new file mode 100644 index 00000000..aa74b513 --- /dev/null +++ b/src/facets/connect/opensubsonic/index.inline.js @@ -0,0 +1,172 @@ +import "@awesome.me/webawesome/dist/components/input/input.js"; +import "@awesome.me/webawesome/dist/components/select/select.js"; +import "@awesome.me/webawesome/dist/components/option/option.js"; + +import * as TID from "@atcute/tid"; +import { html } from "lit-html"; + +import * as Output from "~/common/output.js"; +import { SCHEME } from "~/components/input/opensubsonic/constants.js"; +import { + buildURI, + parseURI, + serverId, +} from "~/components/input/opensubsonic/common.js"; +import { effect } from "~/common/signal.js"; +import foundation from "~/common/foundation.js"; + +import { setup } from "~/facets/connect/common.js"; + +/** + * @import { default as WaInput } from "@awesome.me/webawesome/dist/components/input/input.js" + * @import { default as WaSelect } from "@awesome.me/webawesome/dist/components/select/select.js" + * @import { Server } from "~/components/input/opensubsonic/types.d.ts" + */ + +document.title = "Connect OpenSubsonic | Diffuse"; + +//////////////////////////////////////////// +// SETUP +//////////////////////////////////////////// + +const [inputConfigurator, outputOrchestrator, sourcesOrchestrator] = + await Promise.all([ + foundation.configurator.input(), + foundation.orchestrator.output(), + foundation.orchestrator.sources(), + ]); + +await Promise.all([ + customElements.whenDefined(inputConfigurator.localName), + customElements.whenDefined(outputOrchestrator.localName), + customElements.whenDefined(sourcesOrchestrator.localName), +]); + +//////////////////////////////////////////// +// UI +//////////////////////////////////////////// + +const { setItems, setError } = setup({ + title: "OpenSubsonic", + hasOutput: false, + + description: html` +

+ Connect to an OpenSubsonic server to use it as audio input. +

+ + `, + + formFields: html` + + + Yes + No + + + + + + + `, + + onSubmit: () => addServer(), +}); + +const hostInput = /** @type {WaInput} */ (document.querySelector("#oss-host")); +const tlsSelect = /** @type {WaSelect} */ (document.querySelector("#oss-tls")); +const usernameInput = + /** @type {WaInput} */ (document.querySelector("#oss-username")); +const passwordInput = + /** @type {WaInput} */ (document.querySelector("#oss-password")); +const apikeyInput = + /** @type {WaInput} */ (document.querySelector("#oss-apikey")); + +//////////////////////////////////////////// +// REACTIVE LIST +//////////////////////////////////////////// + +effect(() => { + const inputSources = sourcesOrchestrator.sources()[SCHEME] ?? []; + + /** @type {Map} */ + const allServers = new Map(); + + for (const source of inputSources) { + const parsed = parseURI(source.uri); + if (!parsed) continue; + + const id = serverId(parsed.server); + if (!allServers.has(id)) { + allServers.set(id, { server: parsed.server, uri: source.uri }); + } + } + + setItems( + [...allServers.values()].map(({ server, uri }) => ({ + name: server.host, + detail: server.tls ? "https" : "http", + isInput: true, + isOutput: false, + isSelectedOutput: false, + onRemove: () => removeServer(uri), + })), + ); +}); + +//////////////////////////////////////////// +// ACTIONS +//////////////////////////////////////////// + +/** @param {string} uri */ +async function removeServer(uri) { + setError(null); + try { + const tracks = await Output.data(outputOrchestrator.tracks); + const detachedTracks = await inputConfigurator.detach({ + fileUriOrScheme: uri, + tracks, + }); + + if (detachedTracks) await outputOrchestrator.tracks.save(detachedTracks); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to remove server"); + } +} + +async function addServer() { + const host = hostInput.value?.trim(); + const tls = tlsSelect.value !== "false"; + const username = usernameInput.value?.trim() || undefined; + const password = passwordInput.value?.trim() || undefined; + const apiKey = apikeyInput.value?.trim() || undefined; + + if (!host) return; + + /** @type {Server} */ + const server = { host, tls, username, password, apiKey }; + const uri = buildURI(server); + + const now = new Date().toISOString(); + const tracksCol = outputOrchestrator.tracks.collection(); + const existingTracks = tracksCol.state === "loaded" ? tracksCol.data : []; + + await outputOrchestrator.tracks.save([ + ...existingTracks, + { + $type: "sh.diffuse.output.track", + id: TID.now(), + createdAt: now, + updatedAt: now, + kind: "placeholder", + uri, + }, + ]); +}