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: improved blur browser + grouping

+156 -52
+30 -2
src/components/orchestrator/scoped-tracks/element.js
··· 6 6 } from "~/common/element.js"; 7 7 import { batch, computed, signal } from "~/common/signal.js"; 8 8 import { filterByPlaylist } from "~/common/playlist.js"; 9 + import { safeDecodeURIComponent } from "~/common/utils.js"; 9 10 import { listen } from "~/common/worker.js"; 10 11 11 12 /** ··· 77 78 #tracksGrouped = computed(() => { 78 79 const tracks = this.#tracksFinal.value; 79 80 const groupBy = this.#scope.value?.groupBy(); 81 + console.log(groupBy) 80 82 if (!groupBy) return undefined; 81 - return buildGroups(tracks, groupBy); 83 + const a = buildGroups(tracks, groupBy); 84 + console.log(a); 85 + return a; 82 86 }); 83 87 84 88 // STATE 85 89 86 90 supplyFingerprint = this.#supplyFingerprint.get; 87 91 tracks = this.#tracksFinal.get; 88 - groups = this.#tracksGrouped.get; 92 + groups = this.#tracksGrouped; 89 93 90 94 // LIFECYCLE 91 95 ··· 333 337 key: `${year}-${month}`, 334 338 label: `${MONTHS[parseInt(month, 10) - 1]} ${year}`, 335 339 }; 340 + } 341 + 342 + if (fieldPath === "directory") { 343 + const uri = track.uri ?? ""; 344 + let path = uri; 345 + try { 346 + path = new URL(uri).pathname; 347 + } catch { 348 + // not a valid URL, use as-is 349 + } 350 + const slash = path.lastIndexOf("/"); 351 + const dir = slash > 0 ? path.slice(0, slash) : path; 352 + const key = uri.slice(0, uri.lastIndexOf("/")); 353 + return { key, label: safeDecodeURIComponent(dir) || "Unknown" }; 354 + } 355 + 356 + if (fieldPath.startsWith("firstLetter:")) { 357 + const dotPath = fieldPath.slice("firstLetter:".length); 358 + let val = /** @type {any} */ (track); 359 + for (const key of dotPath.split(".")) val = val?.[key]; 360 + const str = val != null ? String(val) : ""; 361 + const letter = str.charAt(0).toUpperCase(); 362 + const key = /[A-Z]/.test(letter) ? letter : "#"; 363 + return { key, label: key }; 336 364 } 337 365 338 366 // Generic dot-path extraction
+2
src/styles/diffuse/facet.css
··· 276 276 font-size: var(--fs-sm); 277 277 margin: 0; 278 278 margin-top: var(--space-3xs); 279 + max-height: 80vh; 280 + overflow-y: auto; 279 281 padding: 0; 280 282 position: fixed; 281 283 position-area: bottom span-left;
+50 -21
src/themes/blur/browser/element.css
··· 1 1 :host { 2 2 display: flex; 3 3 flex-direction: column; 4 - font-size: var(--fs-sm); 4 + font-size: calc(var(--fs-sm) * 0.85); 5 5 height: 100%; 6 6 overflow: hidden; 7 7 } ··· 15 15 border-bottom: 1px solid color-mix(in oklch, currentColor 12%, transparent); 16 16 display: flex; 17 17 gap: var(--space-sm); 18 - padding: var(--space-xs); 18 + padding: var(--space-2xs) var(--space-xs); 19 19 } 20 20 21 21 .search-field { ··· 23 23 color: color-mix(in oklch, currentColor 45%, transparent); 24 24 display: flex; 25 25 flex: 1; 26 + flex-direction: row; 26 27 gap: var(--space-2xs); 27 28 } 28 29 29 30 .search-field i { 30 31 flex-shrink: 0; 31 - font-size: var(--fs-sm); 32 32 } 33 33 34 34 .search-field input { ··· 36 36 border: none; 37 37 color: inherit; 38 38 flex: 1; 39 - font-size: var(--fs-sm); 40 39 min-width: 0; 41 40 outline: none; 42 41 } ··· 55 54 gap: var(--space-2xs); 56 55 } 57 56 58 - .toolbar-actions select { 57 + .playlist-btn { 58 + align-items: center; 59 59 background: transparent; 60 - border: 1px solid color-mix(in oklch, currentColor 20%, transparent); 60 + border: 1px solid color-mix(in oklch, currentColor 10%, transparent); 61 61 border-radius: var(--radius-md); 62 62 color: inherit; 63 - font-size: var(--fs-xs); 63 + cursor: pointer; 64 + display: flex; 65 + font-family: inherit; 66 + font-size: 100%; 67 + gap: var(--space-2xs); 64 68 max-width: 12rem; 65 - padding: var(--space-3xs) var(--space-xs); 69 + overflow: hidden; 70 + padding: var(--space-3xs) var(--space-2xs); 71 + text-overflow: ellipsis; 72 + white-space: nowrap; 73 + } 74 + 75 + .playlist-btn i { 76 + flex-shrink: 0; 77 + } 78 + 79 + .toolbar-icon-btn { 80 + align-items: center; 81 + background: transparent; 82 + border: none; 83 + border-radius: var(--radius-md); 84 + color: color-mix(in oklch, currentColor 50%, transparent); 85 + cursor: pointer; 86 + display: flex; 87 + padding: var(--space-3xs); 88 + } 89 + 90 + .toolbar-icon-btn:hover { 91 + color: currentColor; 66 92 } 67 93 68 94 /*********************************** ··· 73 99 border-bottom: 1px solid color-mix(in oklch, currentColor 12%, transparent); 74 100 color: color-mix(in oklch, currentColor 50%, transparent); 75 101 display: flex; 76 - font-size: var(--fs-xs); 102 + font-size: 75%; 77 103 font-weight: 500; 78 104 letter-spacing: var(--tracking-wide); 79 105 padding: 0 var(--space-xs); ··· 95 121 96 122 .table-header .col--sorted { 97 123 color: color-mix(in oklch, currentColor 80%, transparent); 124 + } 125 + 126 + .table-header .col-fav { 127 + cursor: default; 98 128 } 99 129 100 130 /*********************************** ··· 142 172 .group-header { 143 173 align-items: center; 144 174 color: color-mix(in oklch, currentColor 45%, transparent); 145 - height: 36px; 175 + display: flex; 176 + font-size: 110%; 177 + font-weight: 600; 178 + gap: var(--space-2xs); 179 + height: 52px; 146 180 left: 0; 181 + padding: 0 var(--space-xs); 147 182 position: absolute; 148 183 right: 0; 149 - display: flex; 150 - font-size: var(--fs-xs); 151 - font-weight: 500; 152 - gap: var(--space-2xs); 153 - letter-spacing: var(--tracking-wide); 154 - padding: 0 var(--space-xs); 155 - text-transform: uppercase; 156 - } 157 184 158 - .group-header i { 159 - font-size: var(--fs-sm); 185 + & > span { 186 + overflow: hidden; 187 + text-overflow: ellipsis; 188 + white-space: nowrap; 189 + } 160 190 } 161 191 162 192 /*********************************** ··· 240 270 241 271 .loading { 242 272 color: color-mix(in oklch, currentColor 40%, transparent); 243 - font-size: var(--fs-sm); 244 273 padding: var(--space-lg) var(--space-md); 245 274 }
+73 -29
src/themes/blur/browser/element.js
··· 21 21 */ 22 22 23 23 const TRACK_ROW_HEIGHT = 44; 24 - const GROUP_HEADER_HEIGHT = 36; 24 + const GROUP_HEADER_HEIGHT = 52; 25 25 const OVERSCAN = 10; 26 26 27 27 /** @type {Record<string, string[]>} */ ··· 32 32 }; 33 33 34 34 const DEFAULT_SORT = ["createdAt"]; 35 + 36 + const GROUP_BY_OPTIONS = [ 37 + { value: "firstLetter", label: "Group by first letter", icon: "ph-text-aa", sortBy: null, sortDirection: /** @type {"asc" | "desc" | undefined} */ (undefined) }, 38 + { value: "directory", label: "Group by path", icon: "ph-folder", sortBy: ["uri"], sortDirection: /** @type {"asc" | "desc" | undefined} */ (undefined) }, 39 + { value: "createdAt", label: "Group by processing date", icon: "ph-clock", sortBy: ["createdAt"], sortDirection: /** @type {"asc" | "desc" | undefined} */ ("desc") }, 40 + { value: "tags.year", label: "Group by track year", icon: "ph-calendar", sortBy: ["tags.year"], sortDirection: /** @type {"asc" | "desc" | undefined} */ ("desc") }, 41 + ]; 35 42 36 43 /** 37 44 * @typedef {{ type: "group"; label: string }} GroupItem ··· 146 153 }); 147 154 }); 148 155 149 - // Sync playlist select 150 - this.effect(() => { 151 - const playlist = this.$scope.value?.playlist(); 152 - const select = this.root().querySelector("#playlist-select"); 153 - if (select) { 154 - /** @type {HTMLSelectElement} */ (select).value = playlist ?? ""; 155 - } 156 - }); 157 - 158 156 // Set up the virtualizer after the first render, when .scroll-panel exists in the DOM. 159 157 // This mirrors the winamp browser's #setupScrollTracking pattern. 160 158 requestAnimationFrame(() => { ··· 213 211 }; 214 212 215 213 /** 216 - * @param {Event} event 214 + * @param {string | undefined} value 215 + */ 216 + setGroupBy = (value) => { 217 + this.$scope.value?.setGroupBy(value); 218 + }; 219 + 220 + /** 221 + * @param {string | undefined} value 217 222 */ 218 - setSelectedPlaylist = (event) => { 219 - const value = /** @type {HTMLSelectElement} */ (event.currentTarget).value; 220 - this.$scope.value?.setPlaylist(value === "" ? undefined : value); 223 + setSelectedPlaylist = (value) => { 224 + this.$scope.value?.setPlaylist(value); 221 225 }; 222 226 223 227 /** ··· 260 264 261 265 const tracks = this.$provider.value?.tracks() ?? []; 262 266 const playlist = this.$scope.value?.playlist(); 267 + const groupBy = this.$scope.value?.groupBy(); 263 268 const searchTerm = this.$scope.value?.searchTerm() ?? ""; 264 269 const sortBy = this.$scope.value?.sortBy() ?? DEFAULT_SORT; 265 270 const sortDirection = this.$scope.value?.sortDirection(); ··· 345 350 346 351 return html` 347 352 <link rel="stylesheet" href="styles/base.css" /> 353 + <link rel="stylesheet" href="styles/diffuse/facet.css" /> 348 354 <link rel="stylesheet" href="vendor/@phosphor-icons/web/bold/style.css" /> 349 355 <link rel="stylesheet" href="vendor/@phosphor-icons/web/fill/style.css" /> 350 356 <link rel="stylesheet" href="themes/blur/browser/element.css" /> ··· 362 368 </label> 363 369 364 370 <div class="toolbar-actions"> 365 - <select id="playlist-select" @change="${this.setSelectedPlaylist}"> 366 - <option value="" ?selected="${!playlist || playlist === ``}">All tracks</option> 371 + <button class="toolbar-icon-btn" popovertarget="groupby-menu" title="Group by"> 372 + <i class="ph-fill ph-intersect-three"></i> 373 + </button> 374 + <div id="groupby-menu" class="dropdown" popover> 375 + ${GROUP_BY_OPTIONS.map(({ value, label, icon, sortBy: optSortBy, sortDirection: optSortDirection }) => { 376 + // "firstLetter" is dynamic — its stored value includes the sort field 377 + const primarySortField = sortBy[0] ?? "tags.title"; 378 + const resolvedValue = value === "firstLetter" 379 + ? `firstLetter:${primarySortField}` 380 + : value; 381 + const isActive = value === "firstLetter" 382 + ? groupBy?.startsWith("firstLetter:") 383 + : groupBy === value; 384 + return html` 385 + <button 386 + class="${isActive ? `dropdown-item--active` : ``}" 387 + @click="${() => { 388 + const scope = this.$scope.value; 389 + if (isActive) { 390 + this.setGroupBy(undefined); 391 + } else { 392 + this.setGroupBy(resolvedValue); 393 + if (optSortBy && scope) { 394 + scope.setSortBy(optSortBy); 395 + scope.setSortDirection(optSortDirection); 396 + } 397 + } 398 + /** @type {HTMLElement | null} */ (this.root().querySelector(`#groupby-menu`))?.hidePopover(); 399 + }}" 400 + > 401 + <i class="ph-${isActive ? `bold ph-x` : `fill ${icon}`}"></i> 402 + ${label} 403 + </button> 404 + `; 405 + })} 406 + </div> 407 + <button class="playlist-btn" popovertarget="playlist-menu"> 408 + <i class="ph-fill ph-playlist"></i> 409 + ${playlist ?? `All tracks`} 410 + </button> 411 + <div id="playlist-menu" class="dropdown" popover> 412 + <button @click="${() => { this.setSelectedPlaylist(undefined); /** @type {HTMLElement | null} */ (this.root().querySelector(`#playlist-menu`))?.hidePopover(); }}"> 413 + All tracks 414 + </button> 367 415 ${this.$groupedPlaylists().map((group) => 368 - html` 369 - <optgroup label="${group.label}"> 370 - ${group.playlists.map((p) => 371 - html` 372 - <option value="${p.name}" ?selected="${p.name === playlist}"> 373 - ${p.name} 374 - </option> 375 - ` 376 - )} 377 - </optgroup> 378 - ` 416 + group.playlists.map((p) => 417 + html` 418 + <button @click="${() => { this.setSelectedPlaylist(p.name); /** @type {HTMLElement | null} */ (this.root().querySelector(`#playlist-menu`))?.hidePopover(); }}"> 419 + ${p.name} 420 + </button> 421 + ` 422 + ) 379 423 )} 380 - </select> 424 + </div> 381 425 </div> 382 426 </div> 383 427 ··· 430 474 class="group-header" 431 475 style="transform: translateY(${vItem.start}px);" 432 476 > 433 - <i class="ph-bold ph-calendar-blank"></i> 477 + <i class="ph-fill ph-vinyl-record"></i> 434 478 <span>${item.label}</span> 435 479 </div> 436 480 `
+1
src/themes/blur/browser/facet/index.html
··· 2 2 @import "./vendor/@phosphor-icons/web/bold/style.css"; 3 3 @import "./vendor/@phosphor-icons/web/fill/style.css"; 4 4 @import "./styles/base.css"; 5 + @import "./styles/diffuse/facet.css"; 5 6 6 7 db-browser { 7 8 height: 100vh;