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.

refactor: sources + webamp input configurator

+316 -270
+1 -1
.env
··· 1 - #DISABLE_AUTOMATIC_TRACKS_PROCESSING=t 1 + DISABLE_AUTOMATIC_TRACKS_PROCESSING=t
+13 -9
src/common/constituents/default.js
··· 7 7 import RepeatShuffleOrchestrator from "@components/orchestrator/repeat-shuffle/element.js"; 8 8 import SearchProcessor from "@components/processor/search/element.js"; 9 9 import SearchTracksOrchestrator from "@components/orchestrator/search-tracks/element.js"; 10 + import SourcesOrchestrator from "@components/orchestrator/sources/element.js"; 10 11 11 12 export const GROUP = "constituents"; 12 13 ··· 57 58 oqt.setAttribute("output-selector", "#output"); 58 59 oqt.setAttribute("queue-engine-selector", queue.localName); 59 60 60 - const rso = new RepeatShuffleOrchestrator(); 61 - rso.setAttribute("group", GROUP); 62 - rso.setAttribute("queue-engine-selector", queue.localName); 61 + const ors = new RepeatShuffleOrchestrator(); 62 + ors.setAttribute("group", GROUP); 63 + ors.setAttribute("queue-engine-selector", queue.localName); 63 64 64 65 const ost = new SearchTracksOrchestrator(); 65 66 ost.setAttribute("group", GROUP); ··· 67 68 ost.setAttribute("output-selector", "#output"); 68 69 ost.setAttribute("search-processor-selector", search.localName); 69 70 70 - document.body.append(opt, oqt, rso, ost); 71 + const osr = new SourcesOrchestrator(); 72 + osr.setAttribute("group", GROUP); 73 + osr.setAttribute("input-selector", "#input"); 74 + osr.setAttribute("output-selector", "#output"); 75 + 76 + document.body.append(opt, oqt, ors, ost, osr); 71 77 72 78 // Return elements 73 79 return { 74 80 GROUP, 75 81 76 - configurator: { 77 - input, 78 - output, 79 - }, 80 82 engine: { 81 83 queue, 82 84 }, ··· 85 87 output, 86 88 processTracks: opt, 87 89 queueTracks: oqt, 88 - repeatShuffle: rso, 90 + repeatShuffle: ors, 91 + searchTracks: ost, 92 + sources: osr, 89 93 }, 90 94 processor: { 91 95 metadata,
+1 -1
src/components/configurator/input/element.js
··· 27 27 const proxy = this.workerProxy(); 28 28 29 29 this.consult = proxy.consult; 30 - this.contextualize = proxy.contextualize; 30 + this.detach = proxy.detach; 31 31 this.groupConsult = proxy.groupConsult; 32 32 this.list = proxy.list; 33 33 this.resolve = proxy.resolve;
+21 -8
src/components/configurator/input/worker.js
··· 32 32 } 33 33 34 34 /** 35 - * @type {ActionsWithTunnel<InputActions>['contextualize']} 35 + * @type {ActionsWithTunnel<InputActions>['detach']} 36 36 */ 37 - export async function contextualize({ data, ports }) { 38 - const tracks = data; 39 - const groups = groupTracks(tracks, ports); 37 + export async function detach({ data, ports }) { 38 + const cachedTracks = data.tracks; 39 + const groups = groupTracks(cachedTracks, ports); 40 + 40 41 const promises = Object.entries(groups).map( 41 42 async ([scheme, tracksGroup]) => { 42 43 const input = grabInput(scheme, ports); 43 - if (!input || tracksGroup.length === 0) return; 44 - return await input.contextualize(tracksGroup); 44 + if (!input || tracksGroup.length === 0) return tracksGroup; 45 + if ( 46 + data.fileUriOrScheme.includes("://") 47 + ? data.fileUriOrScheme.startsWith(`${scheme}://`) === false 48 + : data.fileUriOrScheme !== scheme 49 + ) return tracksGroup; 50 + 51 + return await input.detach({ 52 + fileUriOrScheme: data.fileUriOrScheme, 53 + tracks: tracksGroup, 54 + }); 45 55 }, 46 56 ); 47 57 48 - await Promise.all(promises); 58 + const nested = await Promise.all(promises); 59 + const tracks = nested.flat(1); 60 + 61 + return tracks; 49 62 } 50 63 51 64 /** ··· 122 135 ostiary((context) => { 123 136 rpc(context, { 124 137 consult, 125 - contextualize, 138 + detach, 126 139 groupConsult, 127 140 list, 128 141 resolve,
-16
src/components/input/opensubsonic/common.js
··· 1 1 import { SubsonicAPI } from "subsonic-api"; 2 - import * as IDB from "idb-keyval"; 3 2 import * as URI from "uri-js"; 4 3 import QS from "query-string"; 5 4 ··· 99 98 } 100 99 101 100 /** 102 - * @returns {Promise<Record<string, Server>>} 103 - */ 104 - export async function loadServers() { 105 - const i = await IDB.get(IDB_SERVERS); 106 - return i ? i : {}; 107 - } 108 - 109 - /** 110 101 * Parse an opensubsonic URI. 111 102 * 112 103 * ``` ··· 151 142 const songId = typeof qs.songId === "string" ? qs.songId : undefined; 152 143 153 144 return { path, server, songId }; 154 - } 155 - 156 - /** 157 - * @param {Record<string, Server>} items 158 - */ 159 - export async function saveServers(items) { 160 - await IDB.set(IDB_SERVERS, items); 161 145 } 162 146 163 147 /**
+10 -39
src/components/input/opensubsonic/element.js
··· 1 1 import { DiffuseElement } from "@common/element.js"; 2 - import { computed, signal } from "@common/signal.js"; 3 - import { listen } from "@common/worker.js"; 4 2 import { SCHEME } from "./constants.js"; 3 + import { buildURI, serversFromTracks } from "./common.js"; 5 4 6 5 /** 7 6 * @import {InputActions, InputSchemeProvider} from "@components/input/types.d.ts" 8 7 * @import {ProxiedActions} from "@common/worker.d.ts" 9 - * 10 - * @import {Server, State} from "./types.d.ts" 8 + * @import {Track} from "@definitions/types.d.ts" 11 9 */ 12 10 13 11 //////////////////////////////////////////// ··· 27 25 constructor() { 28 26 super(); 29 27 30 - /** @type {ProxiedActions<InputActions & State>} */ 28 + /** @type {ProxiedActions<InputActions>} */ 31 29 this.proxy = this.workerProxy(); 32 30 33 31 this.consult = this.proxy.consult; 34 - this.contextualize = this.proxy.contextualize; 32 + this.detach = this.proxy.detach; 35 33 this.groupConsult = this.proxy.groupConsult; 36 34 this.list = this.proxy.list; 37 35 this.resolve = this.proxy.resolve; 38 36 } 39 37 40 - // SIGNALS 41 - 42 - #servers = signal(/** @type {Record<string, Server>} */ ({})); 43 - 44 - // STATE 45 - 46 - servers = this.#servers.get; 47 - 48 - // LIFECYCLE 49 - 50 - /** 51 - * @override 52 - */ 53 - connectedCallback() { 54 - super.connectedCallback(); 55 - 56 - // Sync data with worker 57 - const link = this.workerLink(); 58 - 59 - // Listen for remote data changes 60 - listen("servers", this.#servers.set, link); 61 - 62 - // Fetch current data state 63 - this.proxy.servers().then(this.#servers.set); 64 - } 65 - 66 38 // 🛠️ 67 39 68 - serverList = computed(() => { 69 - const servers = this.#servers.value; 70 - 71 - return Object.values(servers).map((server) => { 40 + /** @param {Track[]} tracks */ 41 + sources(tracks) { 42 + return Object.values(serversFromTracks(tracks)).map((server) => { 72 43 return { 73 44 label: `${server.host} (${server.username ?? server.apiKey})`, 74 - server, 45 + uri: buildURI(server), 75 46 }; 76 47 }); 77 - }); 48 + } 78 49 } 79 50 80 51 export default OpensubsonicInput; ··· 86 57 export const CLASS = OpensubsonicInput; 87 58 export const NAME = "di-opensubsonic"; 88 59 89 - customElements.define(NAME, OpensubsonicInput); 60 + customElements.define(NAME, CLASS);
-6
src/components/input/opensubsonic/types.d.ts
··· 1 - import type { SignalReader } from "@common/signal.d.ts"; 2 - 3 1 // https://opensubsonic.netlify.app/docs/api-reference/ 4 2 export type Server = { 5 3 apiKey?: string; ··· 8 6 tls: boolean; 9 7 username?: string; 10 8 }; 11 - 12 - export type State = { 13 - servers: SignalReader<Record<string, Server>>; 14 - };
+7 -29
src/components/input/opensubsonic/worker.js
··· 1 1 import * as URI from "uri-js"; 2 - 3 - import { effect, signal } from "@common/signal.js"; 4 - import { announce, ostiary, rpc } from "@common/worker.js"; 2 + import { ostiary, rpc } from "@common/worker.js"; 5 3 6 4 import { SCHEME } from "./constants.js"; 7 5 import { ··· 10 8 consultServer, 11 9 createClient, 12 10 groupTracksByServer, 13 - loadServers, 14 11 parseURI, 15 - saveServers, 16 12 serverId, 17 - serversFromTracks, 18 13 } from "./common.js"; 19 14 import { groupKeyHash } from "../common.js"; 20 15 ··· 26 21 */ 27 22 28 23 //////////////////////////////////////////// 29 - // STATE 30 - //////////////////////////////////////////// 31 - 32 - const $servers = signal(/** @type {Record<string, Server>} */ ({})); 33 - 34 - effect(() => { 35 - saveServers($servers.value); 36 - }); 37 - 38 - //////////////////////////////////////////// 39 24 // ACTIONS 40 25 //////////////////////////////////////////// 41 26 ··· 55 40 } 56 41 57 42 /** 58 - * @type {Actions['contextualize']} 43 + * @type {Actions['detach']} 59 44 */ 60 - export async function contextualize(tracks) { 61 - const servers = serversFromTracks(tracks); 62 - $servers.value = servers; 45 + export async function detach({ fileUriOrScheme, tracks }) { 46 + console.log("opensubsonic", fileUriOrScheme); 47 + return tracks; 63 48 } 64 49 65 50 /** ··· 232 217 233 218 // If a server didn't have any tracks, 234 219 // keep a placeholder track so the server gets 235 - // picked up whenever it is re-contextualized. 220 + // picked up as a source. 236 221 if (!tracks.length) { 237 222 tracks = [{ 238 223 $type: "sh.diffuse.output.tracks", ··· 283 268 284 269 rpc(context, { 285 270 consult, 286 - contextualize, 271 + detach, 287 272 groupConsult, 288 273 list, 289 274 resolve, 290 - 291 - // State 292 - servers: $servers.get, 293 275 }); 294 - 295 - // Communicate state 296 - 297 - effect(() => announce("servers", $servers.value, context)); 298 276 });
+10 -39
src/components/input/s3/element.js
··· 1 1 import { DiffuseElement } from "@common/element.js"; 2 2 import { SCHEME } from "./constants.js"; 3 - import { computed, signal } from "@common/signal.js"; 4 - import { listen } from "@common/worker.js"; 3 + import { bucketsFromTracks, buildURI } from "./common.js"; 5 4 6 5 /** 7 6 * @import {InputActions, InputSchemeProvider} from "@components/input/types.d.ts" 8 7 * @import {ProxiedActions} from "@common/worker.d.ts" 9 - * 10 - * @import {Bucket, State} from "./types.d.ts" 8 + * @import {Track} from "@definitions/types.d.ts" 11 9 */ 12 10 13 11 //////////////////////////////////////////// ··· 27 25 constructor() { 28 26 super(); 29 27 30 - /** @type {ProxiedActions<InputActions & State & { demo: () => Promise<void> }>} */ 28 + /** @type {ProxiedActions<InputActions & { demo: () => Promise<void> }>} */ 31 29 this.proxy = this.workerProxy(); 32 30 33 31 this.consult = this.proxy.consult; 34 - this.contextualize = this.proxy.contextualize; 32 + this.detach = this.proxy.detach; 35 33 this.groupConsult = this.proxy.groupConsult; 36 34 this.list = this.proxy.list; 37 35 this.resolve = this.proxy.resolve; ··· 39 37 this.demo = this.proxy.demo; 40 38 } 41 39 42 - // SIGNALS 43 - 44 - #buckets = signal(/** @type {Record<string, Bucket>} */ ({})); 45 - 46 - // STATE 47 - 48 - buckets = this.#buckets.get; 49 - 50 - // LIFECYCLE 51 - 52 - /** 53 - * @override 54 - */ 55 - connectedCallback() { 56 - super.connectedCallback(); 57 - 58 - // Sync data with worker 59 - const link = this.workerLink(); 60 - 61 - // Listen for remote data changes 62 - listen("buckets", this.#buckets.set, link); 63 - 64 - // Fetch current data state 65 - this.proxy.buckets().then(this.#buckets.set); 66 - } 67 - 68 40 // 🛠️ 69 41 70 - bucketList = computed(() => { 71 - const buckets = this.#buckets.value; 72 - 73 - return Object.values(buckets).map((bucket) => { 42 + /** @param {Track[]} tracks */ 43 + sources(tracks) { 44 + return Object.values(bucketsFromTracks(tracks)).map((server) => { 74 45 return { 75 - label: `${bucket.bucketName} (${bucket.accessKey}, ${bucket.host})`, 76 - bucket, 46 + label: `${server.bucketName} (${server.host})`, 47 + uri: buildURI(server), 77 48 }; 78 49 }); 79 - }); 50 + } 80 51 } 81 52 82 53 export default S3Input;
-4
src/components/input/s3/types.d.ts
··· 8 8 region: string; 9 9 secretKey: string; 10 10 }; 11 - 12 - export type State = { 13 - buckets: SignalReader<Record<string, Bucket>>; 14 - };
+7 -29
src/components/input/s3/worker.js
··· 1 1 import { groupKeyHash, isAudioFile } from "@components/input/common.js"; 2 2 import { 3 3 bucketId, 4 - bucketsFromTracks, 5 4 buildURI, 6 5 consultBucket, 7 6 createClient, 8 7 groupTracksByBucket, 9 - loadBuckets, 10 8 parseURI, 11 9 } from "./common.js"; 12 10 import { SCHEME } from "./constants.js"; 13 - import { announce, ostiary, rpc } from "@common/worker.js"; 14 - import { effect, signal } from "@common/signal.js"; 15 - 16 - import { saveBuckets } from "./common.js"; 11 + import { ostiary, rpc } from "@common/worker.js"; 17 12 18 13 /** 19 14 * @import { InputActions as Actions, ConsultGrouping } from "@components/input/types.d.ts"; ··· 22 17 */ 23 18 24 19 //////////////////////////////////////////// 25 - // STATE 26 - //////////////////////////////////////////// 27 - 28 - const $buckets = signal(/** @type {Record<string, Bucket>} */ ({})); 29 - 30 - effect(() => { 31 - saveBuckets($buckets.value); 32 - }); 33 - 34 - //////////////////////////////////////////// 35 20 // ACTIONS 36 21 //////////////////////////////////////////// 37 22 ··· 51 36 } 52 37 53 38 /** 54 - * @type {Actions['contextualize']} 39 + * @type {Actions['detach']} 55 40 */ 56 - export async function contextualize(tracks) { 57 - const buckets = bucketsFromTracks(tracks); 58 - $buckets.value = buckets; 41 + export async function detach({ fileUriOrScheme, tracks }) { 42 + console.log("s3", fileUriOrScheme); 43 + return tracks; 59 44 } 60 45 61 46 /** ··· 144 129 145 130 // If a bucket didn't have any tracks, 146 131 // keep a placeholder track so the bucket gets 147 - // picked up whenever it is re-contextualized. 132 + // picked up as a source. 148 133 if (!tracks.length) { 149 134 tracks = [{ 150 135 $type: "sh.diffuse.output.tracks", ··· 224 209 225 210 rpc(context, { 226 211 consult, 227 - contextualize, 212 + detach, 228 213 groupConsult, 229 214 list, 230 215 resolve, 231 216 232 217 // Additional actions 233 218 demo, 234 - 235 - // State 236 - buckets: $buckets.get, 237 219 }); 238 - 239 - // Communicate state 240 - 241 - effect(() => announce("buckets", $buckets.value, context)); 242 220 });
+7 -6
src/components/input/types.d.ts
··· 21 21 22 22 export type InputActions = { 23 23 consult(fileUriOrScheme: string): Promise<Consult>; 24 - contextualize(tracks: Track[]): Promise<void>; 24 + detach(args: { fileUriOrScheme: string; tracks: Track[] }): Promise<Track[]>; 25 25 groupConsult(tracks: Track[]): Promise<GroupConsult>; 26 - list(cachedTracks: Track[]): Promise<Track[]>; 27 - resolve( 28 - { method, uri }: { method?: string; uri: string }, 29 - ): Promise<ResolvedUri>; 26 + list(tracks: Track[]): Promise<Track[]>; 27 + resolve(args: { method?: string; uri: string }): Promise<ResolvedUri>; 30 28 }; 31 29 32 30 export type InputElement = 33 31 & DiffuseElement 34 32 & InputSchemeProvider 35 - & ProxiedActions<InputActions>; 33 + & ProxiedActions<InputActions> 34 + & { sources: (tracks: Track[]) => Source[] }; 36 35 37 36 export type InputSchemeProvider = { SCHEME: string }; 38 37 ··· 40 39 stream: ReadableStream; 41 40 expiresAt: number; 42 41 } | { url: string; expiresAt: number }; 42 + 43 + export type Source = { label: string; uri: string };
+2 -2
src/components/orchestrator/input/element.js
··· 33 33 consult = /** @type {InputActions["consult"]} */ (...args) => 34 34 this.input.consult(...args); 35 35 36 - contextualize = /** @type {InputActions["contextualize"]} */ (...args) => 37 - this.input.contextualize(...args); 36 + detach = /** @type {InputActions["detach"]} */ (...args) => 37 + this.input.detach(...args); 38 38 39 39 groupConsult = /** @type {InputActions["groupConsult"]} */ (...args) => 40 40 this.input.groupConsult(...args);
+1 -9
src/components/orchestrator/process-tracks/element.js
··· 82 82 83 83 const skip = /** @type {any} */ (import.meta).env 84 84 ?.DISABLE_AUTOMATIC_TRACKS_PROCESSING ?? false; 85 - if (skip) { 86 - // Should still trigger contextualize which `process` normally does for us. 87 - untracked(() => { 88 - input.contextualize( 89 - output.tracks.collection(), 90 - ); 91 - }); 92 - return; 93 - } 85 + if (skip) return; 94 86 95 87 untracked(() => this.process()); 96 88 });
-3
src/components/orchestrator/process-tracks/worker.js
··· 29 29 ports.input.start(); 30 30 ports.metadataProcessor.start(); 31 31 32 - // Contextualize 33 - await input.contextualize(cachedTracks); 34 - 35 32 // List 36 33 const tracks = await input.list(cachedTracks); 37 34
+97
src/components/orchestrator/sources/element.js
··· 1 + import { BroadcastableDiffuseElement, query } from "@common/element.js"; 2 + import { groupTracksPerScheme } from "@common/utils.js"; 3 + import { signal } from "@common/signal.js"; 4 + 5 + /** 6 + * @import {Track} from "@definitions/types.d.ts" 7 + * @import {InputElement, Source} from "@components/input/types.d.ts" 8 + * @import {OutputElement} from "@components/output/types.d.ts" 9 + */ 10 + 11 + //////////////////////////////////////////// 12 + // ELEMENT 13 + //////////////////////////////////////////// 14 + 15 + class Sources extends BroadcastableDiffuseElement { 16 + static NAME = "diffuse/orchestrator/sources"; 17 + 18 + // SIGNALS 19 + 20 + #sources = signal(/** @type {{ [scheme: string]: Source[] }} */ ({})); 21 + 22 + // STATE 23 + 24 + sources = this.#sources.get; 25 + 26 + // LIFECYCLE 27 + 28 + /** 29 + * @override 30 + */ 31 + async connectedCallback() { 32 + // Broadcast if needed 33 + if (this.hasAttribute("group")) { 34 + this.broadcast(this.nameWithGroup, {}); 35 + } 36 + 37 + // Super 38 + super.connectedCallback(); 39 + 40 + /** @type {InputElement} */ 41 + const input = query(this, "input-selector"); 42 + 43 + /** @type {OutputElement<Track[]>} */ 44 + const output = query(this, "output-selector"); 45 + 46 + // Wait until defined 47 + await customElements.whenDefined(input.localName); 48 + await customElements.whenDefined(output.localName); 49 + 50 + const singleInputMode = !!input.SCHEME; 51 + const deps = 52 + /** @type {{ [k: string]: InputElement }} */ (singleInputMode 53 + ? {} 54 + : input.dependencies()); 55 + 56 + // Effects 57 + this.effect(() => { 58 + const tracks = output.tracks.collection(); 59 + const groups = groupTracksPerScheme(tracks); 60 + 61 + /** @type {{ [scheme: string]: Source[] }} */ 62 + const record = {}; 63 + 64 + Object.entries(groups).map(([scheme, tracks]) => { 65 + /** @type {Source[]} */ 66 + let sources; 67 + 68 + if (singleInputMode) { 69 + if (input.SCHEME === scheme) { 70 + sources = input.sources(tracks); 71 + } else { 72 + sources = []; 73 + } 74 + } else { 75 + const dep = deps[scheme]; 76 + if (!dep) sources = []; 77 + else sources = dep.sources(tracks); 78 + } 79 + 80 + record[scheme] = sources; 81 + }); 82 + 83 + this.#sources.value = record; 84 + }); 85 + } 86 + } 87 + 88 + export default Sources; 89 + 90 + //////////////////////////////////////////// 91 + // REGISTER 92 + //////////////////////////////////////////// 93 + 94 + export const CLASS = Sources; 95 + export const NAME = "do-sources"; 96 + 97 + customElements.define(NAME, CLASS);
+4 -1
src/index.vto
··· 78 78 desc: "**A default output configuration.** Contains all the outputs provided here along with the relevant transformers." 79 79 - url: "components/orchestrator/process-tracks/element.js" 80 80 title: "Process inputs into tracks" 81 - desc: "Whenever the cached tracks are initially loaded through the passed output element it will contextualize and then list tracks by using the passed input element. Afterwards it loops over all tracks and checks if metadata needs to be fetched. If anything has changed, it'll pass the results to the output element." 81 + desc: "Whenever the cached tracks are initially loaded through the passed output element it will list tracks by using the passed input element. Afterwards it loops over all tracks and checks if metadata needs to be fetched. If anything has changed, it'll pass the results to the output element." 82 82 - url: "components/orchestrator/queue-audio/element.js" 83 83 title: "Queue ⭤ Audio" 84 84 desc: "Connects the given queue engine to the given audio engine." ··· 88 88 - url: "components/orchestrator/repeat-shuffle/element.js" 89 89 title: "Repeat & Shuffle" 90 90 desc: "An opinionated way to setup repeat & shuffle." 91 + - url: "components/orchestrator/sources/element.js" 92 + title: "Sources" 93 + desc: "Monitor tracks from the given output to form a list of sources based on the input's sources return value." 91 94 - url: "components/orchestrator/search-tracks/element.js" 92 95 title: "Search ⭤ Tracks" 93 96 desc: "Supplies tracks to the given search processor whenever the tracks collection changes."
+2 -19
src/themes/webamp/browser/element.js
··· 1 1 import { DiffuseElement, query, whenElementsDefined } from "@common/element.js"; 2 2 import { signal } from "@common/signal.js"; 3 + import { highlightTableEntry } from "../common/ui.js"; 3 4 4 5 /** 5 6 * @import {RenderArg} from "@common/element.d.ts" ··· 81 82 } 82 83 83 84 // EVENTS 84 - 85 - /** 86 - * @param {MouseEvent} event 87 - */ 88 - highlightTableEntry(event) { 89 - if (event.target instanceof HTMLElement === false) return; 90 - 91 - const tr = event.target.tagName === "TR" 92 - ? event.target 93 - : event.target.closest("tr"); 94 - if (!tr) return; 95 - 96 - tr.parentElement?.querySelector("tr.highlighted")?.classList.remove( 97 - "highlighted", 98 - ); 99 - 100 - tr.classList.add("highlighted"); 101 - } 102 85 103 86 /** 104 87 * @param {Track} track ··· 221 204 ` 222 205 : tracks.map((track) => { 223 206 return html` 224 - <tr @click="${this.highlightTableEntry}" @dblclick="${() => 207 + <tr @click="${highlightTableEntry}" @dblclick="${() => 225 208 this.playTrack(track)}"> 226 209 <td>${track.tags?.title}</td> 227 210 <td>${track.tags?.artist}</td>
+18
src/themes/webamp/common/ui.js
··· 1 + /** 2 + * @param {MouseEvent} event 3 + */ 4 + export function highlightTableEntry(event) { 5 + if (event.target instanceof HTMLElement === false) return; 6 + 7 + const tr = event.target.tagName === "TR" 8 + ? event.target 9 + : event.target.closest("tr"); 10 + if (!tr) return; 11 + 12 + tr.parentElement?.querySelector("tr.highlighted")?.classList.remove( 13 + "highlighted", 14 + ); 15 + 16 + tr.classList.add("highlighted"); 17 + } 18 +
+114 -49
src/themes/webamp/configurators/input/element.js
··· 9 9 import { buildURI as buildOpenSubsonicURI } from "@components/input/opensubsonic/common.js"; 10 10 import { buildURI as buildS3cURI } from "@components/input/s3/common.js"; 11 11 12 + import { SCHEME as OPENSUBSONIC_SCHEME } from "@components/input/opensubsonic/constants.js"; 13 + import { SCHEME as S3_SCHEME } from "@components/input/s3/constants.js"; 14 + 15 + import { highlightTableEntry } from "../../common/ui.js"; 16 + 12 17 /** 13 18 * @import {RenderArg} from "@common/element.d.ts" 14 19 * @import {Track} from "@definitions/types.d.ts" ··· 33 38 34 39 $output = signal( 35 40 /** @type {OutputElement<Track[]> | undefined} */ (undefined), 41 + ); 42 + 43 + $sourcesOrchestrator = signal( 44 + /** @type {import("@components/orchestrator/sources/element.js").CLASS | undefined} */ (undefined), 36 45 ); 37 46 38 47 // LIFECYCLE ··· 49 58 /** @type {OutputElement<Track[]>} */ 50 59 const output = query(this, "output-selector"); 51 60 61 + /** @type {import("@components/orchestrator/sources/element.js").CLASS} */ 62 + const sourcesOrchestrator = query(this, "sources-orchestrator-selector"); 63 + 52 64 this.$input.value = input; 53 65 this.$output.value = output; 54 - 55 - // Wait for the elements to be defined before proceeding 56 - whenElementsDefined({ input, output }).then(() => { 57 - // 58 - }); 66 + this.$sourcesOrchestrator.value = sourcesOrchestrator; 59 67 } 60 68 61 69 // EVENTS ··· 138 146 if (button) button.disabled = false; 139 147 }; 140 148 149 + /** 150 + * @param {Event} event 151 + */ 152 + #deleteSelected = async (event) => { 153 + const button = /** @type {HTMLElement} */ (event.target); 154 + const fieldset = event.target ? button.closest("fieldset") : null; 155 + if (!fieldset) return; 156 + 157 + const selected = fieldset.querySelector( 158 + "table tr.highlighted", 159 + ); 160 + if (!selected) return; 161 + 162 + const uri = selected.getAttribute("data-uri"); 163 + if (!uri) throw new Error("Missing `uri` attribute"); 164 + 165 + const detachedTracks = await this.$input.value?.detach({ 166 + fileUriOrScheme: uri, 167 + tracks: this.$output.value?.tracks.collection() ?? [], 168 + }); 169 + 170 + if (detachedTracks) this.$output.value?.tracks.save(detachedTracks); 171 + }; 172 + 173 + /** @param {MouseEvent} event */ 174 + #highlightTableEntry(event) { 175 + highlightTableEntry(event); 176 + 177 + const fieldset = event.target 178 + ? /** @type {HTMLElement} */ (event.target).closest("fieldset") 179 + : null; 180 + if (!fieldset) return; 181 + 182 + fieldset.querySelector('button[role="delete"]')?.removeAttribute( 183 + "disabled", 184 + ); 185 + } 186 + 141 187 // 🛠️ 142 188 143 189 /** ··· 152 198 uri, 153 199 }; 154 200 155 - const output = this.$output.value; 156 - if (!output) throw new Error("Output isn't ready yet!"); 157 - 158 - await output.tracks.save( 159 - [...output.tracks.collection(), track], 201 + await this.$output.value?.tracks.save( 202 + [...(this.$output.value?.tracks.collection() ?? []), track], 160 203 ); 161 204 } 162 205 163 - // 🔮 164 - 165 - openSubsonicServers() { 166 - const input = document.querySelector("di-opensubsonic"); 167 - return input 168 - ? /** @type {import("@components/input/opensubsonic/element.js").CLASS} */ (input) 169 - .serverList() 170 - : []; 171 - } 172 - 173 - s3Buckets() { 174 - const input = document.querySelector("di-s3"); 175 - return input 176 - ? /** @type {import("@components/input/s3/element.js").CLASS} */ (input) 177 - .bucketList() 178 - : []; 179 - } 180 - 181 206 /** 182 207 * @param {string} id 183 208 * @returns {HTMLInputElement | null} ··· 192 217 * @param {RenderArg} _ 193 218 */ 194 219 render({ html }) { 195 - const opensubsonicList = this.openSubsonicServers(); 196 - const s3List = this.s3Buckets(); 220 + const sources = this.$sourcesOrchestrator.value?.sources(); 197 221 198 222 return html` 199 223 <link rel="stylesheet" href="styles/vendor/98.css" /> ··· 249 273 #tabbed:has(#opensubsonic-tab:checked) #opensubsonic-contents { display: block } 250 274 #tabbed:has(#s3-tab:checked) #s3-contents { display: block } 251 275 276 + /* LIST */ 277 + 278 + table { 279 + table-layout: fixed; 280 + } 281 + 282 + table td { 283 + overflow: hidden; 284 + text-overflow: ellipsis; 285 + } 286 + 287 + table tbody tr { 288 + cursor: pointer; 289 + } 290 + 252 291 /* FORMS */ 253 292 254 293 input, select, textarea { ··· 276 315 <!-- Opensubsonic --> 277 316 <div class="window-body" id="opensubsonic-contents"> 278 317 <fieldset> 279 - <legend>Added servers</legend> 280 - ${this.renderList(html, opensubsonicList)} 318 + ${this.renderList( 319 + html, 320 + sources?.[OPENSUBSONIC_SCHEME] ?? [], 321 + "Added servers", 322 + )} 323 + 324 + <p> 325 + <button disabled role="delete" @click="${this.#deleteSelected}"> 326 + Delete selected 327 + </button> 328 + </p> 281 329 </fieldset> 282 330 283 331 <form @submit="${this.#addOpenSubsonicServer}"> ··· 334 382 <!-- S3 --> 335 383 <div class="window-body" id="s3-contents"> 336 384 <fieldset> 337 - <legend>Added buckets</legend> 338 - ${this.renderList(html, s3List)} 385 + ${this.renderList( 386 + html, 387 + sources?.[S3_SCHEME] ?? [], 388 + "Added buckets", 389 + )} 390 + 391 + <p> 392 + <button disabled role="delete" @click="${this.#deleteSelected}"> 393 + Delete selected 394 + </button> 395 + </p> 339 396 </fieldset> 340 397 341 398 <form @submit="${this.#addS3Bucket}"> ··· 397 454 398 455 /** 399 456 * @param {RenderArg["html"]} html 400 - * @param {Array<{ label: string}>} list 457 + * @param {Array<{label: string, uri: string}>} list 458 + * @param {string} title 401 459 */ 402 - renderList(html, list) { 403 - return list.length 404 - ? html` 405 - <ul class="tree-view"> 406 - ${list.map((item) => { 407 - return html` 408 - <li> 409 - ${item.label} 410 - </li> 411 - `; 412 - })} 413 - </ul> 414 - ` 415 - : nothing; 460 + renderList(html, list, title) { 461 + return html` 462 + <div class="sunken-panel"> 463 + <table style="width: 100%;" @click="${this.#highlightTableEntry}"> 464 + <thead> 465 + <tr> 466 + <th>${title}</th> 467 + </tr> 468 + </thead> 469 + <tbody> 470 + ${list.map((item) => 471 + html` 472 + <tr data-uri="${item.uri}"> 473 + <td>${item.label}</td> 474 + </tr> 475 + ` 476 + )} 477 + </tbody> 478 + </table> 479 + </div> 480 + `; 416 481 } 417 482 } 418 483
+1
src/themes/webamp/configurators/input/index.vto
··· 28 28 <dtw-input-config 29 29 input-selector="do-input" 30 30 output-selector="do-output" 31 + sources-orchestrator-selector="do-sources" 31 32 ></dtw-input-config> 32 33 </div> 33 34 </div>