diff --git a/src/_data/facets.json b/src/_data/facets.json index fc6bfe0d..da2a8fb5 100644 --- a/src/_data/facets.json +++ b/src/_data/facets.json @@ -160,6 +160,13 @@ "category": "Misc", "desc": "Connect to Rocksky to setup the Rocksky scrobbler." }, + { + "url": "facets/data/sources/index.html", + "title": "Sources", + "category": "Data", + "featured": true, + "desc": "An overview of all audio inputs. Disable a source to hide its tracks without removing them." + }, { "url": "facets/misc/split-view/index.html", "title": "Split View", diff --git a/src/components/input/opensubsonic/element.js b/src/components/input/opensubsonic/element.js index 1859516b..8a8a1805 100644 --- a/src/components/input/opensubsonic/element.js +++ b/src/components/input/opensubsonic/element.js @@ -42,7 +42,7 @@ class OpensubsonicInput extends DiffuseElement { sources(tracks) { return Object.values(serversFromTracks(tracks)).map((server) => { return { - label: `${server.host} (${server.username ?? server.apiKey})`, + label: server.host, uri: buildURI(server), }; }); diff --git a/src/components/orchestrator/sources/element.js b/src/components/orchestrator/sources/element.js index 01b09dcc..32b73fef 100644 --- a/src/components/orchestrator/sources/element.js +++ b/src/components/orchestrator/sources/element.js @@ -34,6 +34,9 @@ class Sources extends BroadcastableDiffuseElement { * @param {string} uri */ async toggle(uri) { + const q = uri.indexOf("?"); + const key = q === -1 ? uri : uri.slice(0, q); + const output = this.#output.value; if (!output) { console.warn("Output element is not available yet."); @@ -54,10 +57,10 @@ class Sources extends BroadcastableDiffuseElement { } } - if (disabled.includes(uri)) { - disabled = disabled.filter((u) => u !== uri); + if (disabled.includes(key)) { + disabled = disabled.filter((u) => u !== key); } else { - disabled = [...disabled, uri]; + disabled = [...disabled, key]; } const value = JSON.stringify(disabled); diff --git a/src/facets/connect/common.js b/src/facets/connect/common.js index c986d3c8..11e1be46 100644 --- a/src/facets/connect/common.js +++ b/src/facets/connect/common.js @@ -219,11 +219,11 @@ export function setup( : nothing} `, diff --git a/src/facets/connect/opensubsonic/index.inline.js b/src/facets/connect/opensubsonic/index.inline.js index f5aba343..aa389648 100644 --- a/src/facets/connect/opensubsonic/index.inline.js +++ b/src/facets/connect/opensubsonic/index.inline.js @@ -46,10 +46,7 @@ const { setItems, setError } = setup({ description: html`

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

-

- Supports authentication via username + password, or an API key. + Connect to an OpenSubsonic server to use it as audio input. Supports authentication via username + password, or an API key.

`, diff --git a/src/facets/data/sources/index.html b/src/facets/data/sources/index.html new file mode 100644 index 00000000..4db802c4 --- /dev/null +++ b/src/facets/data/sources/index.html @@ -0,0 +1,89 @@ + + +
+
+
+ + + Diffuse + + + +
+

Sources

+

+ An overview of all audio inputs. Disable a source to hide its tracks without removing them. +

+
+ +
+ +

No sources configured yet.

+
+
+ + diff --git a/src/facets/data/sources/index.inline.js b/src/facets/data/sources/index.inline.js new file mode 100644 index 00000000..919c015c --- /dev/null +++ b/src/facets/data/sources/index.inline.js @@ -0,0 +1,204 @@ +import { html, render as litRender } from "lit-html"; + +import * as Output from "~/common/output.js"; +import foundation from "~/common/foundation.js"; +import { computed, effect } from "~/common/signal.js"; + +import { CLASS as Sources } from "~/components/orchestrator/sources/element.js"; + +import { SCHEME as SCHEME_EPHEMERAL_CACHE } from "~/components/input/ephemeral-cache/constants.js"; +import { SCHEME as SCHEME_HTTPS } from "~/components/input/https/constants.js"; +import { SCHEME as SCHEME_ICECAST } from "~/components/input/icecast/constants.js"; +import { SCHEME as SCHEME_LOCAL } from "~/components/input/local/constants.js"; +import { SCHEME as SCHEME_OPENSUBSONIC } from "~/components/input/opensubsonic/constants.js"; +import { SCHEME as SCHEME_S3 } from "~/components/input/s3/constants.js"; + +/** @type {Record} */ +const SCHEME_NAMES = { + [SCHEME_EPHEMERAL_CACHE]: "Browser storage", + [SCHEME_HTTPS]: "HTTPS", + [SCHEME_ICECAST]: "Icecast", + [SCHEME_LOCAL]: "Local directories & files", + [SCHEME_OPENSUBSONIC]: "OpenSubsonic", + [SCHEME_S3]: "S3", +}; + +foundation.setup({ title: "Sources | Diffuse" }); + +//////////////////////////////////////////// +// SETUP +//////////////////////////////////////////// + +const [inputConfigurator, sourcesOrchestrator, outputOrchestrator] = + await Promise.all([ + foundation.configurator.input(), + foundation.orchestrator.sources(), + foundation.orchestrator.output(), + ]); + +await Promise.all([ + customElements.whenDefined(inputConfigurator.localName), + customElements.whenDefined(sourcesOrchestrator.localName), + customElements.whenDefined(outputOrchestrator.localName), +]); + +const disabledSources = computed(() => { + const col = outputOrchestrator.settings.collection(); + if (col.state !== "loaded") return /** @type {string[]} */ ([]); + + const setting = col.data.find((s) => s.key === Sources.DISABLED_KEY); + if (!setting) return /** @type {string[]} */ ([]); + + try { + const parsed = JSON.parse(setting.value); + return Array.isArray(parsed) ? /** @type {string[]} */ (parsed) : []; + } catch { + return /** @type {string[]} */ ([]); + } +}); + +/** + * Returns the part of a source URI suitable for `startsWith` matching against + * track URIs. Some inputs (e.g. OpenSubsonic) include query params in the + * source URI that won't appear at the start of a track URI, so we strip them. + * + * @param {string} uri + */ +function trackPrefix(uri) { + const q = uri.indexOf("?"); + return q === -1 ? uri : uri.slice(0, q); +} + +//////////////////////////////////////////// +// UI +//////////////////////////////////////////// + +const list = + /** @type {HTMLElement} */ (document.querySelector("#sources-list")); +const empty = + /** @type {HTMLElement} */ (document.querySelector("#sources-empty")); + +effect(() => { + const sourcesRecord = sourcesOrchestrator.sources(); + const disabled = disabledSources(); + + const tracksCol = outputOrchestrator.tracks.collection(); + const tracks = tracksCol.state === "loaded" ? tracksCol.data : []; + + const entries = Object.entries(sourcesRecord).filter( + ([, sources]) => sources.length > 0, + ); + + list.hidden = entries.length === 0; + empty.hidden = entries.length > 0; + + litRender( + html` + ${entries.map(([scheme, sources]) => { + if (scheme === SCHEME_EPHEMERAL_CACHE) { + const uri = `${SCHEME_EPHEMERAL_CACHE}://`; + const isDisabled = disabled.includes(uri); + const trackCount = tracks.filter((t) => + t.uri.startsWith(trackPrefix(uri)) + ).length; + return html` +
  • ${SCHEME_NAMES[scheme] ?? scheme}
  • +
  • +
    + Files stored in the browser + ${trackCount} track${trackCount === + 1 + ? "" + : "s"} +
    + + +
  • + `; + } + + return html` +
  • ${SCHEME_NAMES[scheme] ?? scheme}
  • + ${sources.map(({ label, uri }) => { + const isDisabled = disabled.includes(trackPrefix(uri)); + const trackCount = tracks.filter((t) => + t.uri.startsWith(trackPrefix(uri)) + ).length; + return html` +
  • +
    + ${label} + ${trackCount} track${trackCount === + 1 + ? "" + : "s"} +
    + + +
  • + `; + })} + `; + })} + `, + list, + ); +}); + +//////////////////////////////////////////// +// ACTIONS +//////////////////////////////////////////// + +async function removeEphemeralSources() { + return removeSource(SCHEME_EPHEMERAL_CACHE); +} + +/** @param {string} uri */ +async function removeSource(uri) { + const tracks = await Output.data(outputOrchestrator.tracks); + + const detachedTracks = await inputConfigurator.detach({ + fileUriOrScheme: uri, + tracks, + }); + + if (detachedTracks) await outputOrchestrator.tracks.save(detachedTracks); +} + +//////////////////////////////////////////// +// 🚀 +//////////////////////////////////////////// + +foundation.ready(); diff --git a/src/styles/diffuse/facet.css b/src/styles/diffuse/facet.css index d3d09ec6..5750b266 100644 --- a/src/styles/diffuse/facet.css +++ b/src/styles/diffuse/facet.css @@ -25,10 +25,19 @@ main { @media (min-width: 48rem) { margin-bottom: 0; } + + p:last-child { + margin-bottom: 0; + } } .facet__right { + flex: 1; max-width: 24rem; + + p:first-child { + margin-top: 0; + } } .diffuse-logo-container { @@ -125,14 +134,12 @@ button { font-size: var(--fs-sm); gap: var(--space-2xs); padding: var(--space-2xs) var(--space-sm); - - &:hover:not(:disabled) { - opacity: 0.8; - } + transition-duration: 250ms; + transition-property: opacity; &:disabled { cursor: not-allowed; - opacity: 0.5; + opacity: 0.4; } &.button--brand { @@ -141,6 +148,15 @@ button { color: var(--bg-color); } + &.button--icon { + opacity: 0.4; + + &:hover, + &:focus { + opacity: 1; + } + } + &.button--outlined { background: transparent; } @@ -250,7 +266,3 @@ p, .callout { margin: var(--space-sm) 0; } - -.facet__right p:first-child { - margin-top: 0; -}