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.

chore: improve playlist facet

+127 -146
+15 -4
src/facets/data/playlists/index.html
··· 43 43 white-space: nowrap; 44 44 } 45 45 46 + .playlists-category { 47 + font-size: var(--fs-xs); 48 + font-weight: 600; 49 + letter-spacing: var(--tracking-wider); 50 + opacity: 0.4; 51 + padding-block: var(--space-2xs); 52 + text-transform: uppercase; 53 + } 54 + 55 + .playlists-category + .playlists-category, 56 + .playlists-item + .playlists-category { 57 + margin-top: var(--space-xs); 58 + } 59 + 46 60 .tracks-section { 47 61 display: flex; 48 62 flex-direction: column; ··· 81 95 text-overflow: ellipsis; 82 96 white-space: nowrap; 83 97 } 84 - 85 98 </style> 86 99 87 100 <dialog id="playlists-dialog"></dialog> ··· 100 113 </a> 101 114 </div> 102 115 <h1>Playlists</h1> 103 - <p> 104 - An overview of all playlists and their contents. 105 - </p> 116 + <p>An overview of all playlists and their contents.</p> 106 117 </div> 107 118 108 119 <div class="facet__right">
+112 -142
src/facets/data/playlists/index.inline.js
··· 40 40 41 41 const playlists = [...Playlist.gather(playlistItems).values()] 42 42 .sort((a, b) => a.name.localeCompare(b.name)); 43 - const stats = computeStats(tracks, playlists); 43 + 44 + const orderedPlaylists = playlists.filter((p) => !p.unordered); 45 + const unorderedPlaylists = playlists.filter((p) => p.unordered); 44 46 45 47 list.hidden = playlists.length === 0; 46 48 empty.hidden = playlists.length > 0; 47 49 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> 50 + /** @param {typeof playlists} group @param {number} offset */ 51 + const renderGroup = (group, offset) => group.map(({ name, items }, index) => { 52 + const menuId = `playlists-menu-${offset + index}`; 53 + return html` 54 + <li class="playlists-item"> 55 + <div class="playlists-item__info"> 56 + <span class="playlists-item__name">${name}</span> 57 + </div> 58 + <button 59 + class="button--plain button--icon" 60 + aria-label="More" 61 + popovertarget="${menuId}" 62 + > 63 + <i class="ph-fill ph-dots-three-outline-vertical"></i> 64 + </button> 65 + <div id="${menuId}" class="dropdown" popover> 60 66 <button 61 - class="button--plain button--icon" 62 - aria-label="More" 63 - popovertarget="${menuId}" 67 + @click="${(/** @type {MouseEvent} */ e) => { 68 + /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.currentTarget).closest("[popover]"))?.hidePopover(); 69 + showDetails(name, tracks, items); 70 + }}" 64 71 > 65 - <i class="ph-fill ph-dots-three-outline-vertical"></i> 72 + <i class="ph-fill ph-binoculars"></i> 73 + View tracks 66 74 </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 - })} 75 + <button 76 + @click="${(/** @type {MouseEvent} */ e) => { 77 + /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.currentTarget).closest("[popover]"))?.hidePopover(); 78 + removeDuplicates(name, items); 79 + }}" 80 + > 81 + <i class="ph-fill ph-copy"></i> 82 + Remove duplicates 83 + </button> 84 + <button 85 + @click="${(/** @type {MouseEvent} */ e) => { 86 + /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.currentTarget).closest("[popover]"))?.hidePopover(); 87 + removePlaylist(name); 88 + }}" 89 + > 90 + <i class="ph-fill ph-skull"></i> 91 + Delete 92 + </button> 93 + </div> 94 + </li> 95 + `; 96 + }); 97 + 98 + litRender( 99 + html` 100 + ${orderedPlaylists.length > 0 ? html` 101 + <li class="playlists-category">Ordered</li> 102 + ${renderGroup(orderedPlaylists, 0)} 103 + ` : null} 104 + ${unorderedPlaylists.length > 0 ? html` 105 + <li class="playlists-category">Not ordered</li> 106 + ${renderGroup(unorderedPlaylists, orderedPlaylists.length)} 107 + ` : null} 99 108 `, 100 109 list, 101 110 ); ··· 151 160 * @param {PlaylistItem[]} items 152 161 */ 153 162 function showDetails(name, tracks, items) { 163 + // Build per-shape track indexes so each item resolves in O(1) instead of O(tracks). 164 + /** @type {Map<string, { parts: string[][], transformations: (string[] | undefined)[], trackMap: Map<string, Track> }>} */ 165 + const shapes = new Map(); 166 + 167 + for (const item of items) { 168 + const shapeKey = item.criteria 169 + .map((c) => `${c.field}\0${(c.transformations ?? []).join(",")}`) 170 + .join("\0\0"); 171 + if (!shapes.has(shapeKey)) { 172 + shapes.set(shapeKey, { 173 + parts: item.criteria.map((c) => c.field.split(".")), 174 + transformations: item.criteria.map((c) => c.transformations), 175 + trackMap: new Map(), 176 + }); 177 + } 178 + } 179 + 180 + for (const track of tracks) { 181 + for (const shape of shapes.values()) { 182 + const key = shape.parts 183 + .map((parts, i) => { 184 + let v = /** @type {any} */ (track); 185 + for (const p of parts) v = v?.[p]; 186 + return Playlist.transform(v, shape.transformations[i]); 187 + }) 188 + .join("\0"); 189 + if (!shape.trackMap.has(key)) shape.trackMap.set(key, track); 190 + } 191 + } 192 + 193 + /** @type {Track[]} */ 194 + const found = []; 195 + /** @type {PlaylistItem[]} */ 196 + const notFoundItems = []; 154 197 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 198 160 - const notFound = items 161 - .filter((item) => !tracks.some((t) => Playlist.match(t, item))) 199 + for (const item of items) { 200 + const shapeKey = item.criteria 201 + .map((c) => `${c.field}\0${(c.transformations ?? []).join(",")}`) 202 + .join("\0\0"); 203 + const itemKey = item.criteria 204 + .map((c) => Playlist.transform(c.value, c.transformations)) 205 + .join("\0"); 206 + const track = shapes.get(shapeKey)?.trackMap.get(itemKey); 207 + 208 + if (track && !seenIds.has(track.id)) { 209 + seenIds.add(track.id); 210 + found.push(track); 211 + } else if (!track) { 212 + notFoundItems.push(item); 213 + } 214 + } 215 + 216 + found.sort((a, b) => (a.tags?.title ?? "").localeCompare(b.tags?.title ?? "")); 217 + 218 + const notFound = notFoundItems 162 219 .map((item) => ({ 163 220 title: String(item.criteria.find((c) => c.field === "tags.title")?.value ?? ""), 164 221 artist: String(item.criteria.find((c) => c.field === "tags.artist")?.value ?? "") || null, ··· 211 268 212 269 dialog.showModal(); 213 270 } 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 - */ 227 - function 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 271 302 272 //////////////////////////////////////////// 303 273 // 🚀