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 306 lines 10 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 foundation from "~/common/foundation.js"; 6import { effect } from "~/common/signal.js"; 7 8/** 9 * @import { PlaylistItem, Track } from "~/definitions/types.d.ts" 10 */ 11 12foundation.setup({ title: "Playlists | Diffuse" }); 13 14//////////////////////////////////////////// 15// SETUP 16//////////////////////////////////////////// 17 18const outputOrchestrator = await foundation.orchestrator.output(); 19 20await customElements.whenDefined(outputOrchestrator.localName); 21 22//////////////////////////////////////////// 23// UI 24//////////////////////////////////////////// 25 26const list = 27 /** @type {HTMLElement} */ (document.querySelector("#playlists-list")); 28const empty = 29 /** @type {HTMLElement} */ (document.querySelector("#playlists-empty")); 30const dialog = 31 /** @type {HTMLDialogElement} */ (document.querySelector("#playlists-dialog")); 32 33effect(() => { 34 const playlistItemsCol = outputOrchestrator.playlistItems.collection(); 35 const playlistItems = 36 playlistItemsCol.state === "loaded" ? playlistItemsCol.data : []; 37 38 const tracksCol = outputOrchestrator.tracks.collection(); 39 const tracks = tracksCol.state === "loaded" ? tracksCol.data : []; 40 41 const playlists = [...Playlist.gather(playlistItems).values()] 42 .sort((a, b) => a.name.localeCompare(b.name)); 43 const stats = computeStats(tracks, playlists); 44 45 list.hidden = playlists.length === 0; 46 empty.hidden = playlists.length > 0; 47 48 litRender( 49 html` 50 ${playlists.map(({ name, items }, index) => { 51 const { matchedCount, missingCount } = stats.get(name) ?? 52 { matchedCount: 0, missingCount: 0 }; 53 const menuId = `playlists-menu-${index}`; 54 return html` 55 <li class="playlists-item"> 56 <div class="playlists-item__info"> 57 <span class="playlists-item__name">${name}</span> 58 <span class="playlists-item__detail">${matchedCount} found · ${missingCount} not found</span> 59 </div> 60 <button 61 class="button--plain button--icon" 62 aria-label="More" 63 popovertarget="${menuId}" 64 > 65 <i class="ph-fill ph-dots-three-outline-vertical"></i> 66 </button> 67 <div id="${menuId}" class="dropdown" popover> 68 <button 69 @click="${(/** @type {MouseEvent} */ e) => { 70 /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.currentTarget).closest("[popover]"))?.hidePopover(); 71 showDetails(name, tracks, items); 72 }}" 73 > 74 <i class="ph-fill ph-binoculars"></i> 75 View tracks 76 </button> 77 <button 78 @click="${(/** @type {MouseEvent} */ e) => { 79 /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.currentTarget).closest("[popover]"))?.hidePopover(); 80 removeDuplicates(name, items); 81 }}" 82 > 83 <i class="ph-fill ph-copy"></i> 84 Remove duplicates 85 </button> 86 <button 87 @click="${(/** @type {MouseEvent} */ e) => { 88 /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.currentTarget).closest("[popover]"))?.hidePopover(); 89 removePlaylist(name); 90 }}" 91 > 92 <i class="ph-fill ph-skull"></i> 93 Delete 94 </button> 95 </div> 96 </li> 97 `; 98 })} 99 `, 100 list, 101 ); 102}); 103 104//////////////////////////////////////////// 105// ACTIONS 106//////////////////////////////////////////// 107 108/** @param {string} name */ 109async function removePlaylist(name) { 110 const playlistItems = await Output.data(outputOrchestrator.playlistItems); 111 await outputOrchestrator.playlistItems.save( 112 playlistItems.filter((item) => item.playlist !== name), 113 ); 114} 115 116/** 117 * @param {string} name 118 * @param {Track[]} tracks 119 * @param {PlaylistItem[]} items 120 */ 121/** 122 * @param {string} name 123 * @param {PlaylistItem[]} items 124 */ 125async function removeDuplicates(name, items) { 126 const seen = new Set(); 127 const duplicateIds = new Set(); 128 129 for (const item of items) { 130 const key = item.criteria 131 .map((c) => `${c.field}\0${String(c.value)}`) 132 .join("\0\0"); 133 if (seen.has(key)) { 134 duplicateIds.add(item.id); 135 } else { 136 seen.add(key); 137 } 138 } 139 140 if (duplicateIds.size === 0) return; 141 142 const allItems = await Output.data(outputOrchestrator.playlistItems); 143 await outputOrchestrator.playlistItems.save( 144 allItems.filter((item) => !duplicateIds.has(item.id)), 145 ); 146} 147 148/** 149 * @param {string} name 150 * @param {Track[]} tracks 151 * @param {PlaylistItem[]} items 152 */ 153function showDetails(name, tracks, items) { 154 const seenIds = new Set(); 155 const found = /** @type {Track[]} */ (items 156 .map((item) => tracks.find((t) => Playlist.match(t, item))) 157 .filter((t) => t != null && !seenIds.has(t.id) && seenIds.add(t.id)) 158 .sort((a, b) => (a?.tags?.title ?? "").localeCompare(b?.tags?.title ?? ""))); 159 160 const notFound = items 161 .filter((item) => !tracks.some((t) => Playlist.match(t, item))) 162 .map((item) => ({ 163 title: String(item.criteria.find((c) => c.field === "tags.title")?.value ?? ""), 164 artist: String(item.criteria.find((c) => c.field === "tags.artist")?.value ?? "") || null, 165 album: String(item.criteria.find((c) => c.field === "tags.album")?.value ?? "") || null, 166 })) 167 .sort((a, b) => a.title.localeCompare(b.title)); 168 169 litRender( 170 html` 171 <div class="dialog-header"> 172 <strong>${name}</strong> 173 <button 174 class="button--plain button--icon" 175 @click="${() => dialog.close()}" 176 > 177 <i class="ph-fill ph-x"></i> 178 </button> 179 </div> 180 <div class="dialog-body"> 181 <div class="tracks-section"> 182 <span class="tracks-section__heading">${found.length} found</span> 183 <ul class="tracks-list"> 184 ${found.map((t) => html` 185 <li> 186 <span class="tracks-list__name">${t.tags?.title ?? t.uri}</span> 187 ${t.tags?.artist 188 ? html`<span class="tracks-list__artist">${t.tags.artist}</span>` 189 : null} 190 </li> 191 `)} 192 </ul> 193 </div> 194 ${notFound.length > 0 ? html` 195 <div class="tracks-section"> 196 <span class="tracks-section__heading">${notFound.length} not found</span> 197 <ul class="tracks-list"> 198 ${notFound.map(({ title, artist, album }) => html` 199 <li> 200 <span class="tracks-list__name">${title}</span> 201 ${artist ? html`<span class="tracks-list__artist">${artist}${album ? html` · ${album}` : null}</span>` : null} 202 </li> 203 `)} 204 </ul> 205 </div> 206 ` : null} 207 </div> 208 `, 209 dialog, 210 ); 211 212 dialog.showModal(); 213} 214 215//////////////////////////////////////////// 216// STATS 217//////////////////////////////////////////// 218 219/** 220 * Compute matched/missing counts for all playlists in a single pass over tracks. 221 * O(tracks × playlists + items_total) instead of O(tracks × items × playlists). 222 * 223 * @param {Track[]} tracks 224 * @param {Array<{ name: string, items: PlaylistItem[] }>} playlists 225 * @returns {Map<string, { matchedCount: number, missingCount: number }>} 226 */ 227function computeStats(tracks, playlists) { 228 // Build a shape index per playlist. 229 const indexes = playlists.map(({ name, items }) => { 230 /** @type {Map<string, { fields: { parts: string[], transformations: string[] | undefined }[], trackKeys: Set<string>, itemKeys: Set<string> }>} */ 231 const shapeMap = new Map(); 232 233 for (const item of items) { 234 const shapeKey = item.criteria 235 .map((c) => `${c.field}\0${(c.transformations ?? []).join(",")}`) 236 .join("\0\0"); 237 238 if (!shapeMap.has(shapeKey)) { 239 shapeMap.set(shapeKey, { 240 fields: item.criteria.map((c) => ({ 241 parts: c.field.split("."), 242 transformations: /** @type {string[] | undefined} */ (c.transformations), 243 })), 244 trackKeys: new Set(), 245 itemKeys: new Set(), 246 }); 247 } 248 249 const shape = shapeMap.get(shapeKey); 250 const itemKey = item.criteria 251 .map((c) => Playlist.transform(c.value, c.transformations)) 252 .join("\0"); 253 shape?.itemKeys.add(itemKey); 254 } 255 256 return { name, shapeMap, shapes: [...shapeMap.values()], items }; 257 }); 258 259 // Single pass over tracks — update all playlist indexes at once. 260 const matchedCounts = new Map(playlists.map((p) => [p.name, 0])); 261 for (const track of tracks) { 262 for (const { name, shapes } of indexes) { 263 let trackMatched = false; 264 for (const shape of shapes) { 265 const trackKey = shape.fields 266 .map(({ parts, transformations }) => 267 Playlist.transform( 268 parts.reduce((v, f) => /** @type {any} */ (v)?.[f], /** @type {any} */ (track)), 269 transformations, 270 ) 271 ) 272 .join("\0"); 273 if (shape.itemKeys.has(trackKey)) { 274 shape.trackKeys.add(trackKey); 275 trackMatched = true; 276 } 277 } 278 if (trackMatched) matchedCounts.set(name, (matchedCounts.get(name) ?? 0) + 1); 279 } 280 } 281 282 // Derive missing counts from the now-populated trackKeys sets. 283 const result = new Map(); 284 for (const { name, shapeMap, items } of indexes) { 285 let missingCount = 0; 286 for (const item of items) { 287 const shapeKey = item.criteria 288 .map((c) => `${c.field}\0${(c.transformations ?? []).join(",")}`) 289 .join("\0\0"); 290 const itemKey = item.criteria 291 .map((c) => Playlist.transform(c.value, c.transformations)) 292 .join("\0"); 293 if (!shapeMap.get(shapeKey)?.trackKeys.has(itemKey)) missingCount++; 294 } 295 result.set(name, { matchedCount: matchedCounts.get(name) ?? 0, missingCount }); 296 } 297 298 return result; 299} 300 301 302//////////////////////////////////////////// 303// 🚀 304//////////////////////////////////////////// 305 306foundation.ready();