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

Configure Feed

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

feat: add sources overview

+331 -19
+7
src/_data/facets.json
··· 161 161 "desc": "Connect to Rocksky to setup the Rocksky scrobbler." 162 162 }, 163 163 { 164 + "url": "facets/data/sources/index.html", 165 + "title": "Sources", 166 + "category": "Data", 167 + "featured": true, 168 + "desc": "An overview of all audio inputs. Disable a source to hide its tracks without removing them." 169 + }, 170 + { 164 171 "url": "facets/misc/split-view/index.html", 165 172 "title": "Split View", 166 173 "category": "Misc",
+1 -1
src/components/input/opensubsonic/element.js
··· 42 42 sources(tracks) { 43 43 return Object.values(serversFromTracks(tracks)).map((server) => { 44 44 return { 45 - label: `${server.host} (${server.username ?? server.apiKey})`, 45 + label: server.host, 46 46 uri: buildURI(server), 47 47 }; 48 48 });
+6 -3
src/components/orchestrator/sources/element.js
··· 34 34 * @param {string} uri 35 35 */ 36 36 async toggle(uri) { 37 + const q = uri.indexOf("?"); 38 + const key = q === -1 ? uri : uri.slice(0, q); 39 + 37 40 const output = this.#output.value; 38 41 if (!output) { 39 42 console.warn("Output element is not available yet."); ··· 54 57 } 55 58 } 56 59 57 - if (disabled.includes(uri)) { 58 - disabled = disabled.filter((u) => u !== uri); 60 + if (disabled.includes(key)) { 61 + disabled = disabled.filter((u) => u !== key); 59 62 } else { 60 - disabled = [...disabled, uri]; 63 + disabled = [...disabled, key]; 61 64 } 62 65 63 66 const value = JSON.stringify(disabled);
+2 -2
src/facets/connect/common.js
··· 219 219 : nothing} 220 220 </div> 221 221 <button 222 - class="button--plain button--small" 222 + class="button--plain button--icon" 223 223 aria-label="Remove" 224 224 @click="${onRemove}" 225 225 > 226 - <i class="ph-bold ph-x"></i> 226 + <i class="ph-fill ph-skull"></i> 227 227 </button> 228 228 </li> 229 229 `,
+1 -4
src/facets/connect/opensubsonic/index.inline.js
··· 46 46 47 47 description: html` 48 48 <p> 49 - Connect to an OpenSubsonic server to use it as audio input. 50 - </p> 51 - <p class="caption"> 52 - Supports authentication via username + password, or an API key. 49 + Connect to an OpenSubsonic server to use it as audio input. Supports authentication via username + password, or an API key. 53 50 </p> 54 51 `, 55 52
+89
src/facets/data/sources/index.html
··· 1 + <style> 2 + @import "./styles/base.css"; 3 + @import "./styles/diffuse/facet.css"; 4 + @import "./vendor/@phosphor-icons/web/fill/style.css"; 5 + 6 + @layer base, diffuse; 7 + 8 + .sources-list { 9 + display: flex; 10 + flex-direction: column; 11 + gap: var(--space-xs); 12 + list-style: none; 13 + margin: 0; 14 + padding: 0; 15 + } 16 + 17 + .sources-scheme { 18 + font-size: var(--fs-2xs); 19 + font-weight: 600; 20 + letter-spacing: var(--tracking-wider); 21 + margin-top: var(--space-lg); 22 + opacity: 0.4; 23 + text-transform: uppercase; 24 + 25 + &:first-child { 26 + margin-top: 0; 27 + } 28 + } 29 + 30 + .sources-item { 31 + align-items: center; 32 + display: flex; 33 + gap: var(--space-xs); 34 + } 35 + 36 + .sources-item--disabled .sources-item__info { 37 + opacity: 0.2; 38 + } 39 + 40 + .sources-item__info { 41 + display: flex; 42 + flex-direction: column; 43 + flex: 1; 44 + gap: var(--space-3xs); 45 + min-width: 0; 46 + } 47 + 48 + .sources-item__name { 49 + font-weight: 600; 50 + overflow: hidden; 51 + text-overflow: ellipsis; 52 + white-space: nowrap; 53 + } 54 + 55 + .sources-item__detail { 56 + color: oklch(from var(--text-color) l c h / 0.6); 57 + font-size: var(--fs-xs); 58 + overflow: hidden; 59 + text-overflow: ellipsis; 60 + white-space: nowrap; 61 + } 62 + </style> 63 + 64 + <main> 65 + <div class="facet__left"> 66 + <div> 67 + <a href="./dashboard/" class="diffuse-logo-container"> 68 + <svg viewBox="0 0 902 134" width="160"> 69 + <title>Diffuse</title> 70 + <use 71 + xlink:href="images/diffuse-current.svg#diffuse" 72 + href="images/diffuse-current.svg#diffuse" 73 + ></use> 74 + </svg> 75 + </a> 76 + </div> 77 + <h1>Sources</h1> 78 + <p> 79 + An overview of all audio inputs. Disable a source to hide its tracks without removing them. 80 + </p> 81 + </div> 82 + 83 + <div class="facet__right"> 84 + <ul id="sources-list" class="sources-list" hidden></ul> 85 + <p id="sources-empty" class="caption">No sources configured yet.</p> 86 + </div> 87 + </main> 88 + 89 + <script type="module" src="facets/data/sources/index.inline.js"></script>
+204
src/facets/data/sources/index.inline.js
··· 1 + import { html, render as litRender } from "lit-html"; 2 + 3 + import * as Output from "~/common/output.js"; 4 + import foundation from "~/common/foundation.js"; 5 + import { computed, effect } from "~/common/signal.js"; 6 + 7 + import { CLASS as Sources } from "~/components/orchestrator/sources/element.js"; 8 + 9 + import { SCHEME as SCHEME_EPHEMERAL_CACHE } from "~/components/input/ephemeral-cache/constants.js"; 10 + import { SCHEME as SCHEME_HTTPS } from "~/components/input/https/constants.js"; 11 + import { SCHEME as SCHEME_ICECAST } from "~/components/input/icecast/constants.js"; 12 + import { SCHEME as SCHEME_LOCAL } from "~/components/input/local/constants.js"; 13 + import { SCHEME as SCHEME_OPENSUBSONIC } from "~/components/input/opensubsonic/constants.js"; 14 + import { SCHEME as SCHEME_S3 } from "~/components/input/s3/constants.js"; 15 + 16 + /** @type {Record<string, string>} */ 17 + const SCHEME_NAMES = { 18 + [SCHEME_EPHEMERAL_CACHE]: "Browser storage", 19 + [SCHEME_HTTPS]: "HTTPS", 20 + [SCHEME_ICECAST]: "Icecast", 21 + [SCHEME_LOCAL]: "Local directories & files", 22 + [SCHEME_OPENSUBSONIC]: "OpenSubsonic", 23 + [SCHEME_S3]: "S3", 24 + }; 25 + 26 + foundation.setup({ title: "Sources | Diffuse" }); 27 + 28 + //////////////////////////////////////////// 29 + // SETUP 30 + //////////////////////////////////////////// 31 + 32 + const [inputConfigurator, sourcesOrchestrator, outputOrchestrator] = 33 + await Promise.all([ 34 + foundation.configurator.input(), 35 + foundation.orchestrator.sources(), 36 + foundation.orchestrator.output(), 37 + ]); 38 + 39 + await Promise.all([ 40 + customElements.whenDefined(inputConfigurator.localName), 41 + customElements.whenDefined(sourcesOrchestrator.localName), 42 + customElements.whenDefined(outputOrchestrator.localName), 43 + ]); 44 + 45 + const disabledSources = computed(() => { 46 + const col = outputOrchestrator.settings.collection(); 47 + if (col.state !== "loaded") return /** @type {string[]} */ ([]); 48 + 49 + const setting = col.data.find((s) => s.key === Sources.DISABLED_KEY); 50 + if (!setting) return /** @type {string[]} */ ([]); 51 + 52 + try { 53 + const parsed = JSON.parse(setting.value); 54 + return Array.isArray(parsed) ? /** @type {string[]} */ (parsed) : []; 55 + } catch { 56 + return /** @type {string[]} */ ([]); 57 + } 58 + }); 59 + 60 + /** 61 + * Returns the part of a source URI suitable for `startsWith` matching against 62 + * track URIs. Some inputs (e.g. OpenSubsonic) include query params in the 63 + * source URI that won't appear at the start of a track URI, so we strip them. 64 + * 65 + * @param {string} uri 66 + */ 67 + function trackPrefix(uri) { 68 + const q = uri.indexOf("?"); 69 + return q === -1 ? uri : uri.slice(0, q); 70 + } 71 + 72 + //////////////////////////////////////////// 73 + // UI 74 + //////////////////////////////////////////// 75 + 76 + const list = 77 + /** @type {HTMLElement} */ (document.querySelector("#sources-list")); 78 + const empty = 79 + /** @type {HTMLElement} */ (document.querySelector("#sources-empty")); 80 + 81 + effect(() => { 82 + const sourcesRecord = sourcesOrchestrator.sources(); 83 + const disabled = disabledSources(); 84 + 85 + const tracksCol = outputOrchestrator.tracks.collection(); 86 + const tracks = tracksCol.state === "loaded" ? tracksCol.data : []; 87 + 88 + const entries = Object.entries(sourcesRecord).filter( 89 + ([, sources]) => sources.length > 0, 90 + ); 91 + 92 + list.hidden = entries.length === 0; 93 + empty.hidden = entries.length > 0; 94 + 95 + litRender( 96 + html` 97 + ${entries.map(([scheme, sources]) => { 98 + if (scheme === SCHEME_EPHEMERAL_CACHE) { 99 + const uri = `${SCHEME_EPHEMERAL_CACHE}://`; 100 + const isDisabled = disabled.includes(uri); 101 + const trackCount = tracks.filter((t) => 102 + t.uri.startsWith(trackPrefix(uri)) 103 + ).length; 104 + return html` 105 + <li class="sources-scheme">${SCHEME_NAMES[scheme] ?? scheme}</li> 106 + <li class="sources-item ${isDisabled 107 + ? "sources-item--disabled" 108 + : ""}"> 109 + <div class="sources-item__info"> 110 + <span class="sources-item__name">Files stored in the browser</span> 111 + <span class="sources-item__detail">${trackCount} track${trackCount === 112 + 1 113 + ? "" 114 + : "s"}</span> 115 + </div> 116 + <button 117 + class="button--plain" 118 + title="${isDisabled ? "Enable source" : "Disable source"}" 119 + @click="${() => sourcesOrchestrator.toggle(uri)}" 120 + > 121 + <i class="ph-fill ${isDisabled 122 + ? "ph-eye-slash" 123 + : "ph-eye"}"></i> 124 + </button> 125 + <button 126 + class="button--plain button--icon" 127 + title="Remove source" 128 + @click="${() => removeEphemeralSources()}" 129 + > 130 + <i class="ph-fill ph-skull"></i> 131 + </button> 132 + </li> 133 + `; 134 + } 135 + 136 + return html` 137 + <li class="sources-scheme">${SCHEME_NAMES[scheme] ?? scheme}</li> 138 + ${sources.map(({ label, uri }) => { 139 + const isDisabled = disabled.includes(trackPrefix(uri)); 140 + const trackCount = tracks.filter((t) => 141 + t.uri.startsWith(trackPrefix(uri)) 142 + ).length; 143 + return html` 144 + <li class="sources-item ${isDisabled 145 + ? "sources-item--disabled" 146 + : ""}"> 147 + <div class="sources-item__info"> 148 + <span class="sources-item__name">${label}</span> 149 + <span class="sources-item__detail">${trackCount} track${trackCount === 150 + 1 151 + ? "" 152 + : "s"}</span> 153 + </div> 154 + <button 155 + class="button--plain button--icon" 156 + title="${isDisabled ? "Enable source" : "Disable source"}" 157 + @click="${() => sourcesOrchestrator.toggle(uri)}" 158 + > 159 + <i class="ph-fill ${isDisabled 160 + ? "ph-eye-slash" 161 + : "ph-eye"}"></i> 162 + </button> 163 + <button 164 + class="button--plain button--icon" 165 + title="Remove source" 166 + @click="${() => removeSource(uri)}" 167 + > 168 + <i class="ph-fill ph-skull"></i> 169 + </button> 170 + </li> 171 + `; 172 + })} 173 + `; 174 + })} 175 + `, 176 + list, 177 + ); 178 + }); 179 + 180 + //////////////////////////////////////////// 181 + // ACTIONS 182 + //////////////////////////////////////////// 183 + 184 + async function removeEphemeralSources() { 185 + return removeSource(SCHEME_EPHEMERAL_CACHE); 186 + } 187 + 188 + /** @param {string} uri */ 189 + async function removeSource(uri) { 190 + const tracks = await Output.data(outputOrchestrator.tracks); 191 + 192 + const detachedTracks = await inputConfigurator.detach({ 193 + fileUriOrScheme: uri, 194 + tracks, 195 + }); 196 + 197 + if (detachedTracks) await outputOrchestrator.tracks.save(detachedTracks); 198 + } 199 + 200 + //////////////////////////////////////////// 201 + // 🚀 202 + //////////////////////////////////////////// 203 + 204 + foundation.ready();
+21 -9
src/styles/diffuse/facet.css
··· 25 25 @media (min-width: 48rem) { 26 26 margin-bottom: 0; 27 27 } 28 + 29 + p:last-child { 30 + margin-bottom: 0; 31 + } 28 32 } 29 33 30 34 .facet__right { 35 + flex: 1; 31 36 max-width: 24rem; 37 + 38 + p:first-child { 39 + margin-top: 0; 40 + } 32 41 } 33 42 34 43 .diffuse-logo-container { ··· 125 134 font-size: var(--fs-sm); 126 135 gap: var(--space-2xs); 127 136 padding: var(--space-2xs) var(--space-sm); 128 - 129 - &:hover:not(:disabled) { 130 - opacity: 0.8; 131 - } 137 + transition-duration: 250ms; 138 + transition-property: opacity; 132 139 133 140 &:disabled { 134 141 cursor: not-allowed; 135 - opacity: 0.5; 142 + opacity: 0.4; 136 143 } 137 144 138 145 &.button--brand { 139 146 background: var(--accent); 140 147 border-color: transparent; 141 148 color: var(--bg-color); 149 + } 150 + 151 + &.button--icon { 152 + opacity: 0.4; 153 + 154 + &:hover, 155 + &:focus { 156 + opacity: 1; 157 + } 142 158 } 143 159 144 160 &.button--outlined { ··· 250 266 .callout { 251 267 margin: var(--space-sm) 0; 252 268 } 253 - 254 - .facet__right p:first-child { 255 - margin-top: 0; 256 - }