forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
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);