diff --git a/src/_data/facets.json b/src/_data/facets.json
index e867fe60..42aa6ffa 100644
--- a/src/_data/facets.json
+++ b/src/_data/facets.json
@@ -36,6 +36,27 @@
"featured": true,
"desc": "Use your AT Protocol identity for user-data storage."
},
+ {
+ "url": "facets/connect/https/index.html",
+ "title": "Connect / HTTPS",
+ "category": "Data",
+ "featured": true,
+ "desc": "Add HTTPS audio files as input."
+ },
+ {
+ "url": "facets/connect/icecast/index.html",
+ "title": "Connect / Icecast",
+ "category": "Data",
+ "featured": true,
+ "desc": "Add an Icecast stream as audio input."
+ },
+ {
+ "url": "facets/connect/local/index.html",
+ "title": "Connect / Local",
+ "category": "Data",
+ "featured": true,
+ "desc": "Add local directories or files as audio input."
+ },
{
"url": "facets/connect/opensubsonic/index.html",
"title": "Connect / OpenSubsonic",
diff --git a/src/common/webawesome/phosphor/_lib.js b/src/common/webawesome/phosphor/_lib.js
index bf33e442..db61945c 100644
--- a/src/common/webawesome/phosphor/_lib.js
+++ b/src/common/webawesome/phosphor/_lib.js
@@ -7,7 +7,9 @@ export function buildIconMap(selection) {
for (const icon of selection.icons) {
const { name } = icon.properties;
// Strip weight suffix to get base name (e.g. "gear-bold" → "gear")
- const baseName = name.replace(/-(?:bold|fill|light|thin|regular|duotone)$/, "");
+ // Take only the primary name before any comma-separated aliases
+ const primaryName = name.split(",")[0].trim();
+ const baseName = primaryName.replace(/-(?:bold|fill|light|thin|regular|duotone)$/, "");
map.set(baseName, { paths: icon.icon.paths, attrs: icon.icon.attrs });
}
return map;
diff --git a/src/facets/connect/https/index.html b/src/facets/connect/https/index.html
new file mode 100644
index 00000000..39022ca4
--- /dev/null
+++ b/src/facets/connect/https/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/src/facets/connect/https/index.inline.js b/src/facets/connect/https/index.inline.js
new file mode 100644
index 00000000..0df47b6d
--- /dev/null
+++ b/src/facets/connect/https/index.inline.js
@@ -0,0 +1,125 @@
+import "@awesome.me/webawesome/dist/components/input/input.js";
+
+import * as TID from "@atcute/tid";
+import { html } from "lit-html";
+
+import * as Output from "~/common/output.js";
+import { SCHEME } from "~/components/input/https/constants.js";
+import { parseURI } from "~/components/input/https/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"
+ */
+
+document.title = "Connect HTTPS | 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: "HTTPS",
+ hasOutput: false,
+
+ description: html`
+
Add HTTPS urls as input.
+ `,
+
+ formFields: html`
+
+ `,
+
+ onSubmit: () => addUrl(),
+});
+
+const urlInput = /** @type {WaInput} */ (document.querySelector("#https-url"));
+
+////////////////////////////////////////////
+// REACTIVE LIST
+////////////////////////////////////////////
+
+effect(() => {
+ const inputSources = sourcesOrchestrator.sources()[SCHEME] ?? [];
+
+ setItems(
+ inputSources.map((source) => {
+ const parsed = parseURI(source.uri);
+ return {
+ name: source.uri,
+ detail: parsed?.host ?? "",
+ isInput: true,
+ isOutput: false,
+ isSelectedOutput: false,
+ onRemove: () => removeUrl(source.uri),
+ };
+ }),
+ );
+});
+
+////////////////////////////////////////////
+// ACTIONS
+////////////////////////////////////////////
+
+/** @param {string} uri */
+async function removeUrl(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 URL");
+ }
+}
+
+async function addUrl() {
+ const url = urlInput.value?.trim();
+ if (!url) return;
+
+ 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: url,
+ },
+ ]);
+}
diff --git a/src/facets/connect/icecast/index.html b/src/facets/connect/icecast/index.html
new file mode 100644
index 00000000..940821e6
--- /dev/null
+++ b/src/facets/connect/icecast/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/src/facets/connect/icecast/index.inline.js b/src/facets/connect/icecast/index.inline.js
new file mode 100644
index 00000000..a1fc5084
--- /dev/null
+++ b/src/facets/connect/icecast/index.inline.js
@@ -0,0 +1,127 @@
+import "@awesome.me/webawesome/dist/components/input/input.js";
+
+import * as TID from "@atcute/tid";
+import { html } from "lit-html";
+
+import * as Output from "~/common/output.js";
+import { SCHEME } from "~/components/input/icecast/constants.js";
+import { buildURI, parseURI } from "~/components/input/icecast/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"
+ */
+
+document.title = "Connect Icecast | 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: "Icecast",
+ hasOutput: false,
+
+ description: html`
+ Add an Icecast stream as audio input.
+ `,
+
+ formFields: html`
+
+ `,
+
+ onSubmit: () => addStream(),
+});
+
+const urlInput =
+ /** @type {WaInput} */ (document.querySelector("#icecast-url"));
+
+////////////////////////////////////////////
+// REACTIVE LIST
+////////////////////////////////////////////
+
+effect(() => {
+ const inputSources = sourcesOrchestrator.sources()[SCHEME] ?? [];
+
+ setItems(
+ inputSources.map((source) => {
+ const parsed = parseURI(source.uri);
+ return {
+ name: parsed?.streamUrl ?? source.uri,
+ detail: parsed?.host ?? "",
+ isInput: true,
+ isOutput: false,
+ isSelectedOutput: false,
+ onRemove: () => removeStream(source.uri),
+ };
+ }),
+ );
+});
+
+////////////////////////////////////////////
+// ACTIONS
+////////////////////////////////////////////
+
+/** @param {string} uri */
+async function removeStream(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 stream");
+ }
+}
+
+async function addStream() {
+ const rawUrl = urlInput.value?.trim();
+ if (!rawUrl) return;
+
+ const uri = buildURI(rawUrl);
+ 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,
+ },
+ ]);
+}
diff --git a/src/facets/connect/local/index.html b/src/facets/connect/local/index.html
new file mode 100644
index 00000000..90120042
--- /dev/null
+++ b/src/facets/connect/local/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/src/facets/connect/local/index.inline.js b/src/facets/connect/local/index.inline.js
new file mode 100644
index 00000000..401b5329
--- /dev/null
+++ b/src/facets/connect/local/index.inline.js
@@ -0,0 +1,182 @@
+import * as TID from "@atcute/tid";
+import { html } from "lit-html";
+
+import * as Output from "~/common/output.js";
+import { SCHEME } from "~/components/input/local/constants.js";
+import { isSupported } from "~/components/input/local/common.js";
+import { effect } from "~/common/signal.js";
+import foundation from "~/common/foundation.js";
+
+import { setup } from "~/facets/connect/common.js";
+
+/**
+ * @import {Track} from "~/definitions/types.d.ts"
+ */
+
+document.title = "Connect Local | 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),
+]);
+
+const localInput =
+ /** @type {import("~/components/input/local/element.js").CLASS} */ (inputConfigurator
+ .inputs?.()[SCHEME]);
+
+////////////////////////////////////////////
+// UI
+////////////////////////////////////////////
+
+const supported = isSupported();
+
+const { setItems, setError } = setup({
+ title: "Local files",
+ hasInput: false,
+ hasOutput: false,
+
+ description: html`
+ Add local directories or files as audio input.
+ ${supported
+ ? html`
+
+
+
+ Add directory
+
+
+
+ Add files
+
+
+ `
+ : html`
+
+ Your browser does not support the File System Access API. Use a Chromium-based
+ browser to add local files.
+
+ `}
+ `,
+
+ formFields: html`
+
+ `,
+ onSubmit: async () => {},
+});
+
+document
+ .querySelector("#local-add-dir-btn")
+ ?.addEventListener("click", () => addDirectory());
+
+document
+ .querySelector("#local-add-files-btn")
+ ?.addEventListener("click", () => addFiles());
+
+////////////////////////////////////////////
+// REACTIVE LIST
+////////////////////////////////////////////
+
+effect(() => {
+ const tracksCol = outputOrchestrator.tracks.collection();
+ const tracks = tracksCol.state === "loaded" ? tracksCol.data : [];
+ const entries = localInput?.sources(tracks) ?? [];
+
+ setItems(
+ entries.map(({ label, uri }) => ({
+ name: label,
+ detail: "local",
+ isInput: true,
+ isOutput: false,
+ isSelectedOutput: false,
+ onRemove: () => removeEntry(uri),
+ })),
+ );
+});
+
+////////////////////////////////////////////
+// ACTIONS
+////////////////////////////////////////////
+
+/** @param {string} uri */
+async function removeEntry(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 entry");
+ }
+}
+
+async function addDirectory() {
+ setError(null);
+ try {
+ const uri = await localInput.addDirectory();
+ 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,
+ },
+ ]);
+ } catch (err) {
+ if (err instanceof Error && err.name !== "AbortError") {
+ setError(err.message);
+ }
+ }
+}
+
+async function addFiles() {
+ setError(null);
+ try {
+ const uris = await localInput.addFiles();
+ const now = new Date().toISOString();
+ const tracksCol = outputOrchestrator.tracks.collection();
+ const existingTracks = tracksCol.state === "loaded" ? tracksCol.data : [];
+ await outputOrchestrator.tracks.save([
+ ...existingTracks,
+ ...uris.map((uri) => {
+ /** @type {Track} */
+ const track = {
+ $type: "sh.diffuse.output.track",
+ id: TID.now(),
+ createdAt: now,
+ updatedAt: now,
+ kind: "placeholder",
+ uri,
+ };
+
+ return track;
+ }),
+ ]);
+ } catch (err) {
+ if (err instanceof Error && err.name !== "AbortError") {
+ setError(err.message);
+ }
+ }
+}