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.

feat: add a playlist manager

+481 -54
+7
src/_data/facets.json
··· 163 163 "desc": "Connect to Rocksky to setup the Rocksky scrobbler." 164 164 }, 165 165 { 166 + "url": "facets/data/playlists/index.html", 167 + "title": "Playlists", 168 + "category": "Data", 169 + "featured": true, 170 + "desc": "An overview of all playlists and their contents." 171 + }, 172 + { 166 173 "url": "facets/data/sources/index.html", 167 174 "title": "Sources", 168 175 "category": "Data",
+1 -1
src/common/playlist.js
··· 187 187 * @param {any} val 188 188 * @param {string[] | undefined} transformations 189 189 */ 190 - function transform(val, transformations) { 190 + export function transform(val, transformations) { 191 191 if (!val || !transformations) return val; 192 192 return transformations.reduce((v, t) => { 193 193 try {
-53
src/facets/connect/common.css
··· 49 49 opacity: 0.2; 50 50 } 51 51 52 - .dropdown { 53 - background: oklch(from var(--bg-color) calc(l + 0.2) c h); 54 - border: 0; 55 - border-radius: var(--radius-md); 56 - box-shadow: var(--box-shadow-xl); 57 - color: var(--text-color); 58 - font-size: var(--fs-sm); 59 - margin: 0; 60 - margin-top: var(--space-3xs); 61 - padding: 0; 62 - position: fixed; 63 - position-area: bottom span-left; 64 - text-align: left; 65 - 66 - @media (prefers-color-scheme: dark) { 67 - background: oklch(from var(--bg-color) calc(l - 0.05) c h); 68 - } 69 - 70 - &::backdrop { 71 - background: transparent; 72 - } 73 - 74 - & > button { 75 - align-items: center; 76 - background: none; 77 - border: 0; 78 - border-radius: 0; 79 - color: inherit; 80 - cursor: pointer; 81 - display: flex; 82 - font-family: inherit; 83 - font-size: inherit; 84 - font-weight: inherit; 85 - gap: var(--space-xs); 86 - min-width: var(--space-3xl); 87 - padding: var(--space-xs) var(--space-sm); 88 - text-align: left; 89 - width: 100%; 90 - 91 - & > * { 92 - pointer-events: none; 93 - } 94 - } 95 - 96 - & > button:not(:last-child) { 97 - border-bottom: 1px solid var(--border-color); 98 - } 99 - 100 - i { 101 - opacity: 0.4; 102 - } 103 - } 104 - 105 52 .dropzone { 106 53 align-items: center; 107 54 border: 2px dashed var(--border-color);
+114
src/facets/data/playlists/index.html
··· 1 + <style> 2 + @import "./styles/base.css"; 3 + @import "./styles/diffuse/facet.css"; 4 + @import "./vendor/@phosphor-icons/web/fill/style.css"; 5 + 6 + @layer base, diffuse; 7 + 8 + .playlists-list { 9 + display: flex; 10 + flex-direction: column; 11 + gap: var(--space-xs); 12 + list-style: none; 13 + margin: 0; 14 + padding: 0; 15 + } 16 + 17 + .playlists-item { 18 + align-items: center; 19 + display: flex; 20 + gap: var(--space-xs); 21 + } 22 + 23 + .playlists-item__info { 24 + display: flex; 25 + flex-direction: column; 26 + flex: 1; 27 + gap: var(--space-3xs); 28 + min-width: 0; 29 + } 30 + 31 + .playlists-item__name { 32 + font-weight: 600; 33 + overflow: hidden; 34 + text-overflow: ellipsis; 35 + white-space: nowrap; 36 + } 37 + 38 + .playlists-item__detail { 39 + color: oklch(from var(--text-color) l c h / 0.6); 40 + font-size: var(--fs-xs); 41 + overflow: hidden; 42 + text-overflow: ellipsis; 43 + white-space: nowrap; 44 + } 45 + 46 + .tracks-section { 47 + display: flex; 48 + flex-direction: column; 49 + gap: var(--space-2xs); 50 + } 51 + 52 + .tracks-section__heading { 53 + font-size: var(--fs-xs); 54 + font-weight: 600; 55 + letter-spacing: var(--tracking-wider); 56 + opacity: 0.4; 57 + text-transform: uppercase; 58 + } 59 + 60 + .tracks-list { 61 + display: flex; 62 + flex-direction: column; 63 + gap: var(--space-3xs); 64 + list-style: none; 65 + margin: 0; 66 + padding: 0; 67 + } 68 + 69 + .tracks-list__name { 70 + font-size: var(--fs-sm); 71 + font-weight: 600; 72 + overflow: hidden; 73 + text-overflow: ellipsis; 74 + white-space: nowrap; 75 + } 76 + 77 + .tracks-list__artist { 78 + color: oklch(from var(--text-color) l c h / 0.6); 79 + font-size: var(--fs-xs); 80 + overflow: hidden; 81 + text-overflow: ellipsis; 82 + white-space: nowrap; 83 + } 84 + 85 + </style> 86 + 87 + <dialog id="playlists-dialog"></dialog> 88 + 89 + <main> 90 + <div class="facet__left"> 91 + <div> 92 + <a href="./dashboard/" class="diffuse-logo-container"> 93 + <svg viewBox="0 0 902 134" width="160"> 94 + <title>Diffuse</title> 95 + <use 96 + xlink:href="images/diffuse-current.svg#diffuse" 97 + href="images/diffuse-current.svg#diffuse" 98 + ></use> 99 + </svg> 100 + </a> 101 + </div> 102 + <h1>Playlists</h1> 103 + <p> 104 + An overview of all playlists and their contents. 105 + </p> 106 + </div> 107 + 108 + <div class="facet__right"> 109 + <ul id="playlists-list" class="playlists-list" hidden></ul> 110 + <p id="playlists-empty" class="caption">No playlists yet.</p> 111 + </div> 112 + </main> 113 + 114 + <script type="module" src="facets/data/playlists/index.inline.js"></script>
+306
src/facets/data/playlists/index.inline.js
··· 1 + import { html, render as litRender } from "lit-html"; 2 + 3 + import * as Output from "~/common/output.js"; 4 + import * as Playlist from "~/common/playlist.js"; 5 + import foundation from "~/common/foundation.js"; 6 + import { effect } from "~/common/signal.js"; 7 + 8 + /** 9 + * @import { PlaylistItem, Track } from "~/definitions/types.d.ts" 10 + */ 11 + 12 + foundation.setup({ title: "Playlists | Diffuse" }); 13 + 14 + //////////////////////////////////////////// 15 + // SETUP 16 + //////////////////////////////////////////// 17 + 18 + const outputOrchestrator = await foundation.orchestrator.output(); 19 + 20 + await customElements.whenDefined(outputOrchestrator.localName); 21 + 22 + //////////////////////////////////////////// 23 + // UI 24 + //////////////////////////////////////////// 25 + 26 + const list = 27 + /** @type {HTMLElement} */ (document.querySelector("#playlists-list")); 28 + const empty = 29 + /** @type {HTMLElement} */ (document.querySelector("#playlists-empty")); 30 + const dialog = 31 + /** @type {HTMLDialogElement} */ (document.querySelector("#playlists-dialog")); 32 + 33 + effect(() => { 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 */ 109 + async 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 + */ 125 + async 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 + */ 153 + function showDetails(name, tracks, items) { 154 + const seenIds = new Set(); 155 + const found = 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 + */ 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 + 302 + //////////////////////////////////////////// 303 + // 🚀 304 + //////////////////////////////////////////// 305 + 306 + foundation.ready();
+53
src/styles/diffuse/facet.css
··· 266 266 .callout { 267 267 margin: var(--space-sm) 0; 268 268 } 269 + 270 + .dropdown { 271 + background: oklch(from var(--bg-color) calc(l + 0.2) c h); 272 + border: 0; 273 + border-radius: var(--radius-md); 274 + box-shadow: var(--box-shadow-xl); 275 + color: var(--text-color); 276 + font-size: var(--fs-sm); 277 + margin: 0; 278 + margin-top: var(--space-3xs); 279 + padding: 0; 280 + position: fixed; 281 + position-area: bottom span-left; 282 + text-align: left; 283 + 284 + @media (prefers-color-scheme: dark) { 285 + background: oklch(from var(--bg-color) calc(l - 0.05) c h); 286 + } 287 + 288 + &::backdrop { 289 + background: transparent; 290 + } 291 + 292 + & > button { 293 + align-items: center; 294 + background: none; 295 + border: 0; 296 + border-radius: 0; 297 + color: inherit; 298 + cursor: pointer; 299 + display: flex; 300 + font-family: inherit; 301 + font-size: inherit; 302 + font-weight: inherit; 303 + gap: var(--space-xs); 304 + min-width: var(--space-3xl); 305 + padding: var(--space-xs) var(--space-sm); 306 + text-align: left; 307 + width: 100%; 308 + 309 + & > * { 310 + pointer-events: none; 311 + } 312 + } 313 + 314 + & > button:not(:last-child) { 315 + border-bottom: 1px solid var(--border-color); 316 + } 317 + 318 + i { 319 + opacity: 0.4; 320 + } 321 + }