A music player that connects to your cloud/distributed storage.
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

at v4 397 lines 12 kB view raw
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();