diff --git a/src/facets/connect/common.css b/src/facets/connect/common.css --- a/src/facets/connect/common.css +++ b/src/facets/connect/common.css @@ -45,6 +45,63 @@ gap: var(--space-3xs); } +.connect-item--disabled .connect-item__info { + opacity: 0.2; +} + +.dropdown { + background: oklch(from var(--bg-color) calc(l + 0.2) c h); + border: 0; + border-radius: var(--radius-md); + box-shadow: var(--box-shadow-xl); + color: var(--text-color); + font-size: var(--fs-sm); + margin: 0; + margin-top: var(--space-3xs); + padding: 0; + position: fixed; + position-area: bottom span-left; + text-align: left; + + @media (prefers-color-scheme: dark) { + background: oklch(from var(--bg-color) calc(l - 0.05) c h); + } + + &::backdrop { + background: transparent; + } + + & > button { + align-items: center; + background: none; + border: 0; + border-radius: 0; + color: inherit; + cursor: pointer; + display: flex; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + gap: var(--space-xs); + min-width: var(--space-3xl); + padding: var(--space-xs) var(--space-sm); + text-align: left; + width: 100%; + + & > * { + pointer-events: none; + } + } + + & > button:not(:last-child) { + border-bottom: 1px solid var(--border-color); + } + + i { + opacity: 0.4; + } +} + .dropzone { align-items: center; border: 2px dashed var(--border-color); diff --git a/src/facets/connect/common.js b/src/facets/connect/common.js --- a/src/facets/connect/common.js +++ b/src/facets/connect/common.js @@ -5,7 +5,7 @@ */ /** - * @typedef {{ name: string; detail: string; isInput: boolean; isOutput: boolean; isSelectedOutput: boolean; onRemove: () => void }} ConnectItem + * @typedef {{ name: string; detail: string; isInput: boolean; isOutput: boolean; isSelectedOutput: boolean; isDisabled?: boolean; onRemove: () => void; onToggleDisabled?: () => void }} ConnectItem */ /** @@ -203,9 +203,9 @@ litRender( html` ${items.map( - ({ name, detail, isInput, isOutput, isSelectedOutput, onRemove }) => + ({ name, detail, isInput, isOutput, isSelectedOutput, isDisabled, onRemove, onToggleDisabled }, index) => html` -
  • +
  • ${name} ${detail} @@ -220,11 +220,35 @@
    +
  • `, )} diff --git a/src/components/orchestrator/sources/element.js b/src/components/orchestrator/sources/element.js --- a/src/components/orchestrator/sources/element.js +++ b/src/components/orchestrator/sources/element.js @@ -22,14 +22,29 @@ // SIGNALS #sources = signal(/** @type {{ [scheme: string]: Source[] }} */ ({})); + #disabled = signal(/** @type {string[]} */ ([])); // STATE sources = this.#sources.get; + disabled = this.#disabled.get; #output = signal(/** @type {OutputElement | null} */ (null)); // METHODS + + /** + * Returns whether the given source URI is disabled. + * Strips query params before comparing, matching how {@link toggle} stores keys. + * + * @param {string} uri + * @returns {boolean} + */ + isDisabled(uri) { + const q = uri.indexOf("?"); + const key = q === -1 ? uri : uri.slice(0, q); + return this.#disabled.get().includes(key); + } /** * @param {string} uri @@ -104,6 +119,20 @@ // Signals this.#output.value = output; + + // Effects + this.effect(() => { + const col = output.settings.collection(); + if (col.state !== "loaded") { this.#disabled.value = []; return; } + const setting = col.data.find((s) => s.key === DISABLED_KEY); + if (!setting) { this.#disabled.value = []; return; } + try { + const parsed = JSON.parse(setting.value); + this.#disabled.value = Array.isArray(parsed) ? parsed : []; + } catch { + this.#disabled.value = []; + } + }); // Single input mode + dependencies const singleInputMode = !!input.SCHEME; diff --git a/src/facets/connect/https/index.inline.js b/src/facets/connect/https/index.inline.js --- a/src/facets/connect/https/index.inline.js +++ b/src/facets/connect/https/index.inline.js @@ -65,7 +65,9 @@ isInput: true, isOutput: false, isSelectedOutput: false, + isDisabled: sourcesOrchestrator.isDisabled(source.uri), onRemove: () => removeUrl(source.uri), + onToggleDisabled: () => sourcesOrchestrator.toggle(source.uri), }; }), ); diff --git a/src/facets/connect/icecast/index.inline.js b/src/facets/connect/icecast/index.inline.js --- a/src/facets/connect/icecast/index.inline.js +++ b/src/facets/connect/icecast/index.inline.js @@ -66,7 +66,9 @@ isInput: true, isOutput: false, isSelectedOutput: false, + isDisabled: sourcesOrchestrator.isDisabled(source.uri), onRemove: () => removeStream(source.uri), + onToggleDisabled: () => sourcesOrchestrator.toggle(source.uri), }; }), ); diff --git a/src/facets/connect/local/index.inline.js b/src/facets/connect/local/index.inline.js --- a/src/facets/connect/local/index.inline.js +++ b/src/facets/connect/local/index.inline.js @@ -172,7 +172,9 @@ isInput: true, isOutput: false, isSelectedOutput: false, + isDisabled: sourcesOrchestrator.isDisabled(uri), onRemove: () => removeEntry(uri), + onToggleDisabled: () => sourcesOrchestrator.toggle(uri), })), ); }); diff --git a/src/facets/connect/opensubsonic/index.inline.js b/src/facets/connect/opensubsonic/index.inline.js --- a/src/facets/connect/opensubsonic/index.inline.js +++ b/src/facets/connect/opensubsonic/index.inline.js @@ -104,7 +104,9 @@ isInput: true, isOutput: false, isSelectedOutput: false, + isDisabled: sourcesOrchestrator.isDisabled(uri), onRemove: () => removeServer(uri), + onToggleDisabled: () => sourcesOrchestrator.toggle(uri), })), ); }); diff --git a/src/facets/connect/s3/index.inline.js b/src/facets/connect/s3/index.inline.js --- a/src/facets/connect/s3/index.inline.js +++ b/src/facets/connect/s3/index.inline.js @@ -144,7 +144,9 @@ isInput, isOutput, isSelectedOutput: isOutput && isSelectedOutput, + isDisabled: uri ? sourcesOrchestrator.isDisabled(uri) : false, onRemove: () => removeBucket(uri, isOutput), + onToggleDisabled: uri ? () => sourcesOrchestrator.toggle(uri) : undefined, })), ); }); diff --git a/src/facets/data/sources/index.inline.js b/src/facets/data/sources/index.inline.js --- a/src/facets/data/sources/index.inline.js +++ b/src/facets/data/sources/index.inline.js @@ -2,9 +2,7 @@ import * as Output from "~/common/output.js"; import foundation from "~/common/foundation.js"; -import { computed, effect } from "~/common/signal.js"; - -import { DISABLED_KEY as DISABLED_SOURCES_KEY } from "~/components/orchestrator/sources/constants.js"; +import { effect } from "~/common/signal.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"; @@ -42,32 +40,6 @@ 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 === DISABLED_SOURCES_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 @@ -78,9 +50,11 @@ const empty = /** @type {HTMLElement} */ (document.querySelector("#sources-empty")); +/** @param {string} uri */ +const trackPrefix = (uri) => { const q = uri.indexOf("?"); return q === -1 ? uri : uri.slice(0, q); }; + effect(() => { const sourcesRecord = sourcesOrchestrator.sources(); - const disabled = disabledSources(); const tracksCol = outputOrchestrator.tracks.collection(); const tracks = tracksCol.state === "loaded" ? tracksCol.data : []; @@ -97,9 +71,9 @@ ${entries.map(([scheme, sources]) => { if (scheme === SCHEME_EPHEMERAL_CACHE) { const uri = `${SCHEME_EPHEMERAL_CACHE}://`; - const isDisabled = disabled.includes(uri); + const isDisabled = sourcesOrchestrator.isDisabled(uri); const trackCount = tracks.filter((t) => - t.uri.startsWith(trackPrefix(uri)) + t.uri.startsWith(uri) ).length; return html`
  • ${SCHEME_NAMES[scheme] ?? scheme}
  • @@ -136,7 +110,7 @@ return html`
  • ${SCHEME_NAMES[scheme] ?? scheme}
  • ${sources.map(({ label, uri }) => { - const isDisabled = disabled.includes(trackPrefix(uri)); + const isDisabled = sourcesOrchestrator.isDisabled(uri); const trackCount = tracks.filter((t) => t.uri.startsWith(trackPrefix(uri)) ).length;