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.

at v4 433 lines 13 kB view raw
1import { 2 BroadcastableDiffuseElement, 3 defineElement, 4 query, 5 queryOptional, 6} from "~/common/element.js"; 7import { batch, computed, signal } from "~/common/signal.js"; 8import { filterByPlaylist } from "~/common/playlist.js"; 9import { safeDecodeURIComponent } from "~/common/utils.js"; 10import { listen } from "~/common/worker.js"; 11 12/** 13 * @import {ProxiedActions} from "~/common/worker.d.ts" 14 * @import {Track} from "~/definitions/types.d.ts" 15 * @import {InputElement} from "~/components/input/types.d.ts" 16 * @import {OutputElement} from "~/components/output/types.d.ts" 17 * @import {Actions, State} from "./types.d.ts" 18 */ 19 20//////////////////////////////////////////// 21// ELEMENT 22//////////////////////////////////////////// 23 24class ScopedTracksOrchestrator extends BroadcastableDiffuseElement { 25 static NAME = "diffuse/orchestrator/scoped-tracks"; 26 static WORKER_URL = "components/orchestrator/scoped-tracks/worker.js"; 27 28 /** @type {ProxiedActions<Actions & State>} */ 29 #proxy; 30 31 constructor() { 32 super(); 33 this.#proxy = this.workerProxy(); 34 } 35 36 // SIGNALS 37 38 #input = signal(/** @type {InputElement | null} */ (null)); 39 #output = signal(/** @type {OutputElement | null} */ (null)); 40 41 #scope = signal( 42 /** @type {import("~/components/engine/scope/element.js").CLASS | null} */ (null), 43 ); 44 45 #supplyFingerprint = signal(/** @type {string | undefined} */ (undefined)); 46 47 #selectedPlaylistItems = computed(() => { 48 const playlist = this.#scope.value?.playlist(); 49 if (!playlist) return undefined; 50 51 const col = this.#output.value?.playlistItems.collection(); 52 if (!col || col.state !== "loaded") return undefined; 53 return col.data.filter((p) => p.playlist === playlist); 54 }); 55 56 #disabledSources = computed(() => { 57 const col = this.#output.value?.settings.collection(); 58 if (!col || col.state !== "loaded") return []; 59 60 const setting = col.data.find((s) => 61 s.key === "sh.diffuse.input.disabled.uris" 62 ); 63 64 if (!setting) return []; 65 66 try { 67 const parsed = JSON.parse(setting.value); 68 return Array.isArray(parsed) ? /** @type {string[]} */ (parsed) : []; 69 } catch { 70 return []; 71 } 72 }); 73 74 #tracksAvailable = signal(/** @type {Track[]} */ ([])); 75 #tracksSearch = signal(/** @type {Track[]} */ ([])); 76 #tracksFinal = signal(/** @type {Track[]} */ ([])); 77 78 #tracksGrouped = computed(() => { 79 const tracks = this.#tracksFinal.value; 80 const groupBy = this.#scope.value?.groupBy(); 81 if (!groupBy) return undefined; 82 return buildGroups(tracks, groupBy); 83 }); 84 85 // STATE 86 87 supplyFingerprint = this.#supplyFingerprint.get; 88 tracks = this.#tracksFinal.get; 89 groups = this.#tracksGrouped; 90 91 // LIFECYCLE 92 93 /** 94 * @override 95 */ 96 async connectedCallback() { 97 // Broadcast if needed 98 if (this.hasAttribute("group")) { 99 const actions = this.broadcast(this.identifier, { 100 getTracksAvailable: { 101 strategy: "leaderOnly", 102 fn: this.#tracksAvailable.get, 103 }, 104 getTracksSearch: { 105 strategy: "leaderOnly", 106 fn: this.#tracksSearch.get, 107 }, 108 getTracksFinal: { 109 strategy: "leaderOnly", 110 fn: this.#tracksFinal.get, 111 }, 112 setTracksAvailable: { 113 strategy: "replicate", 114 fn: this.#tracksAvailable.set, 115 }, 116 setTracksSearch: { 117 strategy: "replicate", 118 fn: this.#tracksSearch.set, 119 }, 120 setTracksFinal: { 121 strategy: "replicate", 122 fn: this.#tracksFinal.set, 123 }, 124 }); 125 126 if (!actions) return; 127 128 this.#tracksAvailable.set = actions.setTracksAvailable; 129 this.#tracksSearch.set = actions.setTracksSearch; 130 this.#tracksFinal.set = actions.setTracksFinal; 131 132 // Sync signal state with leader 133 Promise.all([ 134 actions.getTracksAvailable(), 135 actions.getTracksSearch(), 136 actions.getTracksFinal(), 137 ]).then(([available, search, final]) => 138 batch(() => { 139 this.#tracksAvailable.value = available; 140 this.#tracksSearch.value = search; 141 this.#tracksFinal.value = final; 142 }) 143 ); 144 } 145 146 // Super 147 super.connectedCallback(); 148 149 /** @type {InputElement} */ 150 const input = query(this, "input-selector"); 151 152 /** @type {OutputElement} */ 153 const output = query(this, "output-selector"); 154 155 /** @type {import("~/components/engine/scope/element.js").CLASS | null} */ 156 const scope = queryOptional(this, "scope-engine-selector"); 157 158 // Assign to self 159 this.#input.value = input; 160 this.#output.value = output; 161 if (scope) this.#scope.value = scope; 162 163 // Sync supply fingerprint with worker 164 const link = this.workerLink(); 165 listen("supplyFingerprint", this.#supplyFingerprint.set, link); 166 this.#proxy.supplyFingerprint().then(this.#supplyFingerprint.set); 167 168 // When defined 169 await customElements.whenDefined(input.localName); 170 await customElements.whenDefined(output.localName); 171 if (scope) await customElements.whenDefined(scope.localName); 172 173 // Watch tracks collection 174 this.effect(async () => { 175 const tracksCol = output.tracks.collection(); 176 177 if ((await this.isLeader()) === false) return; 178 if (tracksCol.state !== "loaded") return; 179 180 /** @type {string[]} */ 181 const uris = []; 182 const tracks = tracksCol.data.filter((t) => { 183 uris.push(t.uri); 184 return t.kind !== "placeholder"; 185 }); 186 187 // Consult inputs 188 const groups = tracksCol.data.length 189 ? await input.groupConsult(uris) 190 : {}; 191 192 /** @type {Set<string>} */ 193 const availableUris = new Set(); 194 195 Object.values(groups).forEach((value) => { 196 if (value.available === false) return; 197 for (const uri of value.uris) { 198 availableUris.add(uri); 199 } 200 }); 201 202 const availableTracks = tracks.filter((t) => { 203 return availableUris.has(t.uri) && !!t.tags; 204 }); 205 206 // Set pool 207 this.#proxy.supply({ tracks: availableTracks }); 208 this.#tracksAvailable.set(availableTracks); 209 }); 210 211 // Watch search supply 212 this.effect(async () => { 213 const _trigger = this.#supplyFingerprint.value; 214 const availableTracks = this.#tracksAvailable.value; 215 const searchTerm = this.#scope.value?.searchTerm(); 216 217 if ((await this.isLeader()) === false) return; 218 219 if (searchTerm?.length) { 220 const searchResults = await this.#proxy.search({ 221 term: searchTerm, 222 }); 223 this.#tracksSearch.set(searchResults); 224 } else { 225 this.#tracksSearch.set(availableTracks); 226 } 227 }); 228 229 // Watch `#tracksSearch` + Playlist + Sort 230 this.effect(async () => { 231 const tracks = this.#tracksSearch.value; 232 const playlistItems = this.#selectedPlaylistItems(); 233 const disabledSources = this.#disabledSources(); 234 const sortBy = this.#scope.value?.sortBy(); 235 const sortDirection = this.#scope.value?.sortDirection(); 236 const groupBy = this.#scope.value?.groupBy(); 237 238 if ((await this.isLeader()) === false) return; 239 240 let final = playlistItems?.length 241 ? filterByPlaylist(tracks, playlistItems) 242 : tracks; 243 244 if (disabledSources.length) { 245 final = final.filter((t) => 246 !disabledSources.some((source) => t.uri.startsWith(source)) 247 ); 248 } 249 250 // When groupBy is active, sort by group key first using the group's 251 // canonical direction (from GROUP_BY_SORT_OVERRIDES, or user's direction 252 // for firstLetter). Within each group, sort by the user's sortBy and 253 // sortDirection as normal. 254 // 255 // Schwartzian transform: precompute all keys once (O(N)) so the 256 // comparator never re-parses URLs or re-splits dot-paths (O(N log N)). 257 const groupOverride = groupBy 258 ? GROUP_BY_SORT_OVERRIDES[groupBy] 259 : undefined; 260 const groupDir = 261 (groupOverride?.sortDirection ?? sortDirection) === "desc" ? -1 : 1; 262 const userFields = sortBy ?? []; 263 const userDir = sortDirection === "desc" ? -1 : 1; 264 const splitPaths = userFields.map((f) => f.split(".")); 265 266 if (groupBy || userFields.length) { 267 const decorated = final.map((track) => ({ 268 track, 269 groupKey: groupBy ? groupKeyLabel(track, groupBy).key : "", 270 fieldVals: splitPaths.map((parts) => { 271 let v = /** @type {any} */ (track); 272 for (const p of parts) v = v?.[p]; 273 return v; 274 }), 275 })); 276 277 decorated.sort((a, b) => { 278 if (groupBy && a.groupKey !== b.groupKey) { 279 if (!a.groupKey) return 1; 280 if (!b.groupKey) return -1; 281 return a.groupKey.localeCompare(b.groupKey) * groupDir; 282 } 283 for (let i = 0; i < a.fieldVals.length; i++) { 284 const av = a.fieldVals[i]; 285 const bv = b.fieldVals[i]; 286 // Null/undefined always sorts last regardless of direction 287 if (av == null && bv == null) continue; 288 if (av == null) return 1; 289 if (bv == null) return -1; 290 const cmp = compareValues(av, bv); 291 if (cmp !== 0) return cmp * userDir; 292 } 293 return 0; 294 }); 295 296 final = decorated.map((d) => d.track); 297 } 298 299 this.#tracksFinal.set(final); 300 }); 301 } 302} 303 304export default ScopedTracksOrchestrator; 305 306//////////////////////////////////////////// 307// HELPERS 308//////////////////////////////////////////// 309 310const MONTHS = [ 311 "January", 312 "February", 313 "March", 314 "April", 315 "May", 316 "June", 317 "July", 318 "August", 319 "September", 320 "October", 321 "November", 322 "December", 323]; 324 325/** @type {Record<string, { sortDirection: "asc" | "desc" }>} */ 326const GROUP_BY_SORT_OVERRIDES = { 327 createdAt: { sortDirection: "desc" }, 328 directory: { sortDirection: "asc" }, 329 firstLetter: { sortDirection: "asc" }, 330 "tags.year": { sortDirection: "desc" }, 331}; 332 333/** 334 * @param {Track[]} tracks 335 * @param {string} groupBy dot-path field, e.g. "createdAt" or "tags.artist" 336 * @returns {{ label: string; tracks: Track[] }[]} 337 */ 338function buildGroups(tracks, groupBy) { 339 /** @type {{ label: string; tracks: Track[] }[]} */ 340 const groups = []; 341 let lastKey = /** @type {string | undefined} */ (undefined); 342 let current = 343 /** @type {{ label: string; tracks: Track[] } | undefined} */ (undefined); 344 345 for (const track of tracks) { 346 const { key, label } = groupKeyLabel(track, groupBy); 347 348 if (key !== lastKey) { 349 current = { label, tracks: [] }; 350 groups.push(current); 351 lastKey = key; 352 } 353 354 current?.tracks.push(track); 355 } 356 357 return groups; 358} 359 360/** 361 * @param {Track} track 362 * @param {string} fieldPath 363 * @returns {{ key: string; label: string }} 364 */ 365function groupKeyLabel(track, fieldPath) { 366 if (fieldPath === "createdAt") { 367 const iso = track.createdAt; 368 if (!iso) return { key: "", label: "Unknown" }; 369 const year = iso.slice(0, 4); 370 const month = iso.slice(5, 7); 371 return { 372 key: `${year}-${month}`, 373 label: `${MONTHS[parseInt(month, 10) - 1]} ${year}`, 374 }; 375 } 376 377 if (fieldPath === "directory") { 378 const uri = track.uri ?? ""; 379 let path = uri; 380 try { 381 path = new URL(uri).pathname; 382 } catch { 383 // not a valid URL, use as-is 384 } 385 const slash = path.lastIndexOf("/"); 386 const dir = slash > 0 ? path.slice(0, slash) : path; 387 const key = uri.slice(0, uri.lastIndexOf("/")); 388 return { key, label: safeDecodeURIComponent(dir) || "Unknown" }; 389 } 390 391 if (fieldPath.startsWith("firstLetter:")) { 392 const dotPath = fieldPath.slice("firstLetter:".length); 393 let val = /** @type {any} */ (track); 394 for (const key of dotPath.split(".")) val = val?.[key]; 395 const str = val != null ? String(val) : ""; 396 const letter = str.charAt(0).toUpperCase(); 397 const key = /[A-Z]/.test(letter) ? letter : "#"; 398 return { key, label: key }; 399 } 400 401 // Generic dot-path extraction 402 let val = /** @type {any} */ (track); 403 for (const key of fieldPath.split(".")) val = val?.[key]; 404 const str = val != null ? String(val) : ""; 405 return { key: str, label: str || "Unknown" }; 406} 407 408/** 409 * @param {any} aVal 410 * @param {any} bVal 411 * @returns {number} 412 */ 413function compareValues(aVal, bVal) { 414 if (aVal == null && bVal == null) return 0; 415 if (aVal == null) return 1; 416 if (bVal == null) return -1; 417 return typeof aVal === "string" && typeof bVal === "string" 418 ? aVal.localeCompare(bVal) 419 : aVal < bVal 420 ? -1 421 : aVal > bVal 422 ? 1 423 : 0; 424} 425 426//////////////////////////////////////////// 427// REGISTER 428//////////////////////////////////////////// 429 430export const CLASS = ScopedTracksOrchestrator; 431export const NAME = "do-scoped-tracks"; 432 433defineElement(NAME, CLASS);