A music player that connects to your cloud/distributed storage.
1import { html, render as litRender } from "lit-html";
2
3import * as Output from "~/common/output.js";
4import * as Playlist from "~/common/playlist.js";
5import * as TID from "@atcute/tid";
6import foundation from "~/common/foundation.js";
7import { effect } from "~/common/signal.js";
8
9/**
10 * @import { PlaylistItem, Track } from "~/definitions/types.d.ts"
11 */
12
13foundation.setup({ title: "Playlists | Diffuse" });
14
15////////////////////////////////////////////
16// SETUP
17////////////////////////////////////////////
18
19const outputOrchestrator = await foundation.orchestrator.output();
20
21await customElements.whenDefined(outputOrchestrator.localName);
22
23////////////////////////////////////////////
24// UI
25////////////////////////////////////////////
26
27const list =
28 /** @type {HTMLElement} */ (document.querySelector("#playlists-list"));
29const empty =
30 /** @type {HTMLElement} */ (document.querySelector("#playlists-empty"));
31const dialog =
32 /** @type {HTMLDialogElement} */ (document.querySelector(
33 "#playlists-dialog",
34 ));
35const createDialog =
36 /** @type {HTMLDialogElement} */ (document.querySelector(
37 "#create-playlist-dialog",
38 ));
39
40document.querySelector("#create-playlist-btn")?.addEventListener(
41 "click",
42 () => {
43 showCreatePlaylist();
44 },
45);
46
47effect(() => {
48 const playlistItemsCol = outputOrchestrator.playlistItems.collection();
49 const playlistItems = playlistItemsCol.state === "loaded"
50 ? playlistItemsCol.data
51 : [];
52
53 const tracksCol = outputOrchestrator.tracks.collection();
54 const tracks = tracksCol.state === "loaded" ? tracksCol.data : [];
55
56 const playlists = [...Playlist.gather(playlistItems).values()]
57 .sort((a, b) => a.name.localeCompare(b.name));
58
59 const orderedPlaylists = playlists.filter((p) => !p.unordered);
60 const unorderedPlaylists = playlists.filter((p) => p.unordered);
61
62 list.hidden = playlists.length === 0;
63 empty.hidden = playlists.length > 0;
64
65 /** @param {typeof playlists} group @param {number} offset */
66 const renderGroup = (group, offset) =>
67 group.map(({ name, items }, index) => {
68 const menuId = `playlists-menu-${offset + index}`;
69 return html`
70 <li class="playlists-item">
71 <div class="playlists-item__info">
72 <span class="playlists-item__name">${name}</span>
73 </div>
74 <button
75 class="button--plain button--icon"
76 aria-label="More"
77 popovertarget="${menuId}"
78 >
79 <i class="ph-fill ph-dots-three-outline-vertical"></i>
80 </button>
81 <div id="${menuId}" class="dropdown" popover>
82 <button
83 @click="${(/** @type {MouseEvent} */ e) => {
84 /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e
85 .currentTarget).closest("[popover]"))?.hidePopover();
86 showDetails(name, tracks, items);
87 }}"
88 >
89 <i class="ph-fill ph-binoculars"></i>
90 View tracks
91 </button>
92 <button
93 @click="${(/** @type {MouseEvent} */ e) => {
94 /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e
95 .currentTarget).closest("[popover]"))?.hidePopover();
96 removeDuplicates(name, items);
97 }}"
98 >
99 <i class="ph-fill ph-copy"></i>
100 Remove duplicates
101 </button>
102 <button
103 @click="${(/** @type {MouseEvent} */ e) => {
104 /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e
105 .currentTarget).closest("[popover]"))?.hidePopover();
106 removePlaylist(name);
107 }}"
108 >
109 <i class="ph-fill ph-skull"></i>
110 Delete
111 </button>
112 </div>
113 </li>
114 `;
115 });
116
117 litRender(
118 html`
119 ${orderedPlaylists.length > 0
120 ? html`
121 <li class="playlists-category">Ordered</li>
122 ${renderGroup(orderedPlaylists, 0)}
123 `
124 : null} ${unorderedPlaylists.length > 0
125 ? html`
126 <li class="playlists-category">Not ordered</li>
127 ${renderGroup(unorderedPlaylists, orderedPlaylists.length)}
128 `
129 : null}
130 `,
131 list,
132 );
133});
134
135////////////////////////////////////////////
136// ACTIONS
137////////////////////////////////////////////
138
139/** @param {string} name */
140async function removePlaylist(name) {
141 const playlistItems = await Output.data(outputOrchestrator.playlistItems);
142 await outputOrchestrator.playlistItems.save(
143 playlistItems.filter((item) => item.playlist !== name),
144 );
145}
146
147/**
148 * @param {string} name
149 * @param {Track[]} tracks
150 * @param {PlaylistItem[]} items
151 */
152/**
153 * @param {string} name
154 * @param {PlaylistItem[]} items
155 */
156async function removeDuplicates(name, items) {
157 const seen = new Set();
158 const duplicateIds = new Set();
159
160 for (const item of items) {
161 const key = item.criteria
162 .map((c) => `${c.field}\0${String(c.value)}`)
163 .join("\0\0");
164 if (seen.has(key)) {
165 duplicateIds.add(item.id);
166 } else {
167 seen.add(key);
168 }
169 }
170
171 if (duplicateIds.size === 0) return;
172
173 const allItems = await Output.data(outputOrchestrator.playlistItems);
174 await outputOrchestrator.playlistItems.save(
175 allItems.filter((item) => !duplicateIds.has(item.id)),
176 );
177}
178
179/**
180 * @param {string} name
181 * @param {Track[]} tracks
182 * @param {PlaylistItem[]} items
183 */
184function showDetails(name, tracks, items) {
185 // Build per-shape track indexes so each item resolves in O(1) instead of O(tracks).
186 /** @type {Map<string, { parts: string[][], transformations: (string[] | undefined)[], trackMap: Map<string, Track> }>} */
187 const shapes = new Map();
188
189 for (const item of items) {
190 const shapeKey = item.criteria
191 .map((c) => `${c.field}\0${(c.transformations ?? []).join(",")}`)
192 .join("\0\0");
193 if (!shapes.has(shapeKey)) {
194 shapes.set(shapeKey, {
195 parts: item.criteria.map((c) => c.field.split(".")),
196 transformations: item.criteria.map((c) => c.transformations),
197 trackMap: new Map(),
198 });
199 }
200 }
201
202 for (const track of tracks) {
203 for (const shape of shapes.values()) {
204 const key = shape.parts
205 .map((parts, i) => {
206 let v = /** @type {any} */ (track);
207 for (const p of parts) v = v?.[p];
208 return Playlist.transform(v, shape.transformations[i]);
209 })
210 .join("\0");
211 if (!shape.trackMap.has(key)) shape.trackMap.set(key, track);
212 }
213 }
214
215 /** @type {Track[]} */
216 const found = [];
217 /** @type {PlaylistItem[]} */
218 const notFoundItems = [];
219 const seenIds = new Set();
220
221 for (const item of items) {
222 const shapeKey = item.criteria
223 .map((c) => `${c.field}\0${(c.transformations ?? []).join(",")}`)
224 .join("\0\0");
225 const itemKey = item.criteria
226 .map((c) => Playlist.transform(c.value, c.transformations))
227 .join("\0");
228 const track = shapes.get(shapeKey)?.trackMap.get(itemKey);
229
230 if (track && !seenIds.has(track.id)) {
231 seenIds.add(track.id);
232 found.push(track);
233 } else if (!track) {
234 notFoundItems.push(item);
235 }
236 }
237
238 found.sort((a, b) =>
239 (a.tags?.title ?? "").localeCompare(b.tags?.title ?? "")
240 );
241
242 const notFound = notFoundItems
243 .map((item) => ({
244 title: String(
245 item.criteria.find((c) => c.field === "tags.title")?.value ?? "",
246 ),
247 artist:
248 String(
249 item.criteria.find((c) => c.field === "tags.artist")?.value ?? "",
250 ) || null,
251 album:
252 String(
253 item.criteria.find((c) => c.field === "tags.album")?.value ?? "",
254 ) || null,
255 }))
256 .sort((a, b) => a.title.localeCompare(b.title));
257
258 litRender(
259 html`
260 <div class="dialog-header">
261 <strong>${name}</strong>
262 <button
263 class="button--plain button--icon"
264 @click="${() => dialog.close()}"
265 >
266 <i class="ph-fill ph-x"></i>
267 </button>
268 </div>
269 <div class="dialog-body">
270 <div class="tracks-section">
271 <span class="tracks-section__heading">${found.length} found</span>
272 <ul class="tracks-list">
273 ${found.map((t) =>
274 html`
275 <li>
276 <span class="tracks-list__name">${t.tags?.title ??
277 t.uri}</span>
278 ${t.tags?.artist
279 ? html`
280 <span class="tracks-list__artist">${t.tags.artist}</span>
281 `
282 : null}
283 </li>
284 `
285 )}
286 </ul>
287 </div>
288 ${notFound.length > 0
289 ? html`
290 <div class="tracks-section">
291 <span class="tracks-section__heading">${notFound
292 .length} not found</span>
293 <ul class="tracks-list">
294 ${notFound.map(({ title, artist, album }) =>
295 html`
296 <li>
297 <span class="tracks-list__name">${title}</span>
298 ${artist
299 ? html`
300 <span class="tracks-list__artist">${artist}${album
301 ? html`
302 · ${album}
303 `
304 : null}</span>
305 `
306 : null}
307 </li>
308 `
309 )}
310 </ul>
311 </div>
312 `
313 : null}
314 </div>
315 `,
316 dialog,
317 );
318
319 dialog.showModal();
320}
321
322function showCreatePlaylist() {
323 litRender(
324 html`
325 <div class="dialog-header">
326 <strong>Create playlist</strong>
327 <button
328 class="button--plain button--icon"
329 @click="${() => createDialog.close()}"
330 >
331 <i class="ph-fill ph-x"></i>
332 </button>
333 </div>
334 <div class="dialog-body">
335 <form
336 class="create-form"
337 @submit="${async (/** @type {SubmitEvent} */ e) => {
338 e.preventDefault();
339 const form = /** @type {HTMLFormElement} */ (e.currentTarget);
340 const name =
341 /** @type {HTMLInputElement} */ (form.elements.namedItem("name"))
342 ?.value.trim();
343 if (!name) return;
344 await createPlaylist(name);
345 createDialog.close();
346 }}"
347 >
348 <label>
349 <span class="create-form__label">Name</span>
350 <input
351 name="name"
352 type="text"
353 placeholder="My playlist"
354 autocomplete="off"
355 required
356 autofocus
357 />
358 </label>
359 <p class="button-row">
360 <button class="button button--accent" type="submit">
361 <i class="ph-bold ph-plus"></i>
362 Create playlist
363 </button>
364 </p>
365 </form>
366 </div>
367 `,
368 createDialog,
369 );
370
371 createDialog.showModal();
372}
373
374/** @param {string} name */
375async function createPlaylist(name) {
376 const existing = await Output.data(outputOrchestrator.playlistItems);
377
378 const now = new Date().toISOString();
379
380 /** @type {import("~/definitions/types.d.ts").PlaylistItem} */
381 const item = {
382 $type: "sh.diffuse.output.playlistItem",
383 id: TID.now(),
384 playlist: name,
385 criteria: [],
386 createdAt: now,
387 updatedAt: now,
388 };
389
390 await outputOrchestrator.playlistItems.save([...existing, item]);
391}
392
393////////////////////////////////////////////
394// 🚀
395////////////////////////////////////////////
396
397foundation.ready();