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: allow toggling a source from the connect pages

+133 -39
+29
src/components/orchestrator/sources/element.js
··· 22 22 // SIGNALS 23 23 24 24 #sources = signal(/** @type {{ [scheme: string]: Source[] }} */ ({})); 25 + #disabled = signal(/** @type {string[]} */ ([])); 25 26 26 27 // STATE 27 28 28 29 sources = this.#sources.get; 30 + disabled = this.#disabled.get; 29 31 30 32 #output = signal(/** @type {OutputElement | null} */ (null)); 31 33 32 34 // METHODS 35 + 36 + /** 37 + * Returns whether the given source URI is disabled. 38 + * Strips query params before comparing, matching how {@link toggle} stores keys. 39 + * 40 + * @param {string} uri 41 + * @returns {boolean} 42 + */ 43 + isDisabled(uri) { 44 + const q = uri.indexOf("?"); 45 + const key = q === -1 ? uri : uri.slice(0, q); 46 + return this.#disabled.get().includes(key); 47 + } 33 48 34 49 /** 35 50 * @param {string} uri ··· 104 119 105 120 // Signals 106 121 this.#output.value = output; 122 + 123 + // Effects 124 + this.effect(() => { 125 + const col = output.settings.collection(); 126 + if (col.state !== "loaded") { this.#disabled.value = []; return; } 127 + const setting = col.data.find((s) => s.key === DISABLED_KEY); 128 + if (!setting) { this.#disabled.value = []; return; } 129 + try { 130 + const parsed = JSON.parse(setting.value); 131 + this.#disabled.value = Array.isArray(parsed) ? parsed : []; 132 + } catch { 133 + this.#disabled.value = []; 134 + } 135 + }); 107 136 108 137 // Single input mode + dependencies 109 138 const singleInputMode = !!input.SCHEME;
+57
src/facets/connect/common.css
··· 45 45 gap: var(--space-3xs); 46 46 } 47 47 48 + .connect-item--disabled .connect-item__info { 49 + opacity: 0.2; 50 + } 51 + 52 + .dropdown { 53 + background: oklch(from var(--bg-color) calc(l + 0.2) c h); 54 + border: 0; 55 + border-radius: var(--radius-md); 56 + box-shadow: var(--box-shadow-xl); 57 + color: var(--text-color); 58 + font-size: var(--fs-sm); 59 + margin: 0; 60 + margin-top: var(--space-3xs); 61 + padding: 0; 62 + position: fixed; 63 + position-area: bottom span-left; 64 + text-align: left; 65 + 66 + @media (prefers-color-scheme: dark) { 67 + background: oklch(from var(--bg-color) calc(l - 0.05) c h); 68 + } 69 + 70 + &::backdrop { 71 + background: transparent; 72 + } 73 + 74 + & > button { 75 + align-items: center; 76 + background: none; 77 + border: 0; 78 + border-radius: 0; 79 + color: inherit; 80 + cursor: pointer; 81 + display: flex; 82 + font-family: inherit; 83 + font-size: inherit; 84 + font-weight: inherit; 85 + gap: var(--space-xs); 86 + min-width: var(--space-3xl); 87 + padding: var(--space-xs) var(--space-sm); 88 + text-align: left; 89 + width: 100%; 90 + 91 + & > * { 92 + pointer-events: none; 93 + } 94 + } 95 + 96 + & > button:not(:last-child) { 97 + border-bottom: 1px solid var(--border-color); 98 + } 99 + 100 + i { 101 + opacity: 0.4; 102 + } 103 + } 104 + 48 105 .dropzone { 49 106 align-items: center; 50 107 border: 2px dashed var(--border-color);
+30 -6
src/facets/connect/common.js
··· 5 5 */ 6 6 7 7 /** 8 - * @typedef {{ name: string; detail: string; isInput: boolean; isOutput: boolean; isSelectedOutput: boolean; onRemove: () => void }} ConnectItem 8 + * @typedef {{ name: string; detail: string; isInput: boolean; isOutput: boolean; isSelectedOutput: boolean; isDisabled?: boolean; onRemove: () => void; onToggleDisabled?: () => void }} ConnectItem 9 9 */ 10 10 11 11 /** ··· 203 203 litRender( 204 204 html` 205 205 ${items.map( 206 - ({ name, detail, isInput, isOutput, isSelectedOutput, onRemove }) => 206 + ({ name, detail, isInput, isOutput, isSelectedOutput, isDisabled, onRemove, onToggleDisabled }, index) => 207 207 html` 208 - <li class="connect-item"> 208 + <li class="connect-item${isDisabled ? " connect-item--disabled" : ""}"> 209 209 <div class="connect-item__info"> 210 210 <span class="connect-item__name">${name}</span> 211 211 <span class="connect-item__detail">${detail}</span> ··· 220 220 </div> 221 221 <button 222 222 class="button--plain button--icon" 223 - aria-label="Remove" 224 - @click="${onRemove}" 223 + aria-label="More" 224 + popovertarget="connect-item-menu-${index}" 225 225 > 226 - <i class="ph-fill ph-skull"></i> 226 + <i class="ph-fill ph-dots-three-outline-vertical"></i> 227 227 </button> 228 + <div id="connect-item-menu-${index}" class="dropdown" popover> 229 + ${onToggleDisabled 230 + ? html` 231 + <button 232 + @click="${(/** @type {MouseEvent} */ e) => { 233 + /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.currentTarget).closest("[popover]"))?.hidePopover(); 234 + onToggleDisabled(); 235 + }}" 236 + > 237 + <i class="ph-fill ${isDisabled ? "ph-eye" : "ph-eye-slash"}"></i> 238 + ${isDisabled ? "Enable" : "Disable"} 239 + </button> 240 + ` 241 + : nothing} 242 + <button 243 + @click="${(/** @type {MouseEvent} */ e) => { 244 + /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.currentTarget).closest("[popover]"))?.hidePopover(); 245 + onRemove(); 246 + }}" 247 + > 248 + <i class="ph-fill ph-skull"></i> 249 + Delete 250 + </button> 251 + </div> 228 252 </li> 229 253 `, 230 254 )}
+2
src/facets/connect/https/index.inline.js
··· 65 65 isInput: true, 66 66 isOutput: false, 67 67 isSelectedOutput: false, 68 + isDisabled: sourcesOrchestrator.isDisabled(source.uri), 68 69 onRemove: () => removeUrl(source.uri), 70 + onToggleDisabled: () => sourcesOrchestrator.toggle(source.uri), 69 71 }; 70 72 }), 71 73 );
+2
src/facets/connect/icecast/index.inline.js
··· 66 66 isInput: true, 67 67 isOutput: false, 68 68 isSelectedOutput: false, 69 + isDisabled: sourcesOrchestrator.isDisabled(source.uri), 69 70 onRemove: () => removeStream(source.uri), 71 + onToggleDisabled: () => sourcesOrchestrator.toggle(source.uri), 70 72 }; 71 73 }), 72 74 );
+2
src/facets/connect/local/index.inline.js
··· 172 172 isInput: true, 173 173 isOutput: false, 174 174 isSelectedOutput: false, 175 + isDisabled: sourcesOrchestrator.isDisabled(uri), 175 176 onRemove: () => removeEntry(uri), 177 + onToggleDisabled: () => sourcesOrchestrator.toggle(uri), 176 178 })), 177 179 ); 178 180 });
+2
src/facets/connect/opensubsonic/index.inline.js
··· 104 104 isInput: true, 105 105 isOutput: false, 106 106 isSelectedOutput: false, 107 + isDisabled: sourcesOrchestrator.isDisabled(uri), 107 108 onRemove: () => removeServer(uri), 109 + onToggleDisabled: () => sourcesOrchestrator.toggle(uri), 108 110 })), 109 111 ); 110 112 });
+2
src/facets/connect/s3/index.inline.js
··· 144 144 isInput, 145 145 isOutput, 146 146 isSelectedOutput: isOutput && isSelectedOutput, 147 + isDisabled: uri ? sourcesOrchestrator.isDisabled(uri) : false, 147 148 onRemove: () => removeBucket(uri, isOutput), 149 + onToggleDisabled: uri ? () => sourcesOrchestrator.toggle(uri) : undefined, 148 150 })), 149 151 ); 150 152 });
+7 -33
src/facets/data/sources/index.inline.js
··· 2 2 3 3 import * as Output from "~/common/output.js"; 4 4 import foundation from "~/common/foundation.js"; 5 - import { computed, effect } from "~/common/signal.js"; 6 - 7 - import { DISABLED_KEY as DISABLED_SOURCES_KEY } from "~/components/orchestrator/sources/constants.js"; 5 + import { effect } from "~/common/signal.js"; 8 6 9 7 import { SCHEME as SCHEME_EPHEMERAL_CACHE } from "~/components/input/ephemeral-cache/constants.js"; 10 8 import { SCHEME as SCHEME_HTTPS } from "~/components/input/https/constants.js"; ··· 42 40 customElements.whenDefined(outputOrchestrator.localName), 43 41 ]); 44 42 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 === DISABLED_SOURCES_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 43 72 44 //////////////////////////////////////////// 73 45 // UI ··· 78 50 const empty = 79 51 /** @type {HTMLElement} */ (document.querySelector("#sources-empty")); 80 52 53 + /** @param {string} uri */ 54 + const trackPrefix = (uri) => { const q = uri.indexOf("?"); return q === -1 ? uri : uri.slice(0, q); }; 55 + 81 56 effect(() => { 82 57 const sourcesRecord = sourcesOrchestrator.sources(); 83 - const disabled = disabledSources(); 84 58 85 59 const tracksCol = outputOrchestrator.tracks.collection(); 86 60 const tracks = tracksCol.state === "loaded" ? tracksCol.data : []; ··· 97 71 ${entries.map(([scheme, sources]) => { 98 72 if (scheme === SCHEME_EPHEMERAL_CACHE) { 99 73 const uri = `${SCHEME_EPHEMERAL_CACHE}://`; 100 - const isDisabled = disabled.includes(uri); 74 + const isDisabled = sourcesOrchestrator.isDisabled(uri); 101 75 const trackCount = tracks.filter((t) => 102 - t.uri.startsWith(trackPrefix(uri)) 76 + t.uri.startsWith(uri) 103 77 ).length; 104 78 return html` 105 79 <li class="sources-scheme">${SCHEME_NAMES[scheme] ?? scheme}</li> ··· 136 110 return html` 137 111 <li class="sources-scheme">${SCHEME_NAMES[scheme] ?? scheme}</li> 138 112 ${sources.map(({ label, uri }) => { 139 - const isDisabled = disabled.includes(trackPrefix(uri)); 113 + const isDisabled = sourcesOrchestrator.isDisabled(uri); 140 114 const trackCount = tracks.filter((t) => 141 115 t.uri.startsWith(trackPrefix(uri)) 142 116 ).length;