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: blur theme cover view

+1393 -186
+19 -2
src/common/foundation.js
··· 50 50 controller: signal( 51 51 /** @type {import("~/components/orchestrator/controller/element.js").CLASS | null} */ (null), 52 52 ), 53 + coverGroups: signal( 54 + /** @type {import("~/components/orchestrator/cover-groups/element.js").CLASS | null} */ (null), 55 + ), 53 56 autoQueue: signal( 54 57 /** @type {import("~/components/orchestrator/auto-queue/element.js").CLASS | null} */ (null), 55 58 ), ··· 107 110 orchestrator: { 108 111 artwork, 109 112 controller, 113 + coverGroups, 110 114 autoQueue, 111 115 favourites, 112 116 mediaSession, ··· 140 144 orchestrator: { 141 145 artwork: signals.orchestrator.artwork.get, 142 146 controller: signals.orchestrator.controller.get, 147 + coverGroups: signals.orchestrator.coverGroups.get, 143 148 autoQueue: signals.orchestrator.autoQueue.get, 144 149 favourites: signals.orchestrator.favourites.get, 145 150 mediaSession: signals.orchestrator.mediaSession.get, ··· 303 308 return findExistingOrAdd(s, signals.engine.scope); 304 309 } 305 310 306 - // Orchestrators (cont.) 311 + // Orchestrators 312 + 307 313 async function artwork() { 308 314 const [{ CLASS: ArtworkOrchestrator }, ac] = await Promise.all([ 309 315 import("~/components/orchestrator/artwork/element.js"), ··· 317 323 return findExistingOrAdd(a, signals.orchestrator.artwork); 318 324 } 319 325 320 - // Orchestrators 321 326 async function autoQueue() { 322 327 const [{ CLASS: AutoQueueOrchestrator }, q, r, t] = await Promise.all([ 323 328 import("~/components/orchestrator/auto-queue/element.js"), ··· 349 354 co.setAttribute("queue-engine-selector", q.selector); 350 355 351 356 return findExistingOrAdd(co, signals.orchestrator.controller); 357 + } 358 + 359 + async function coverGroups() { 360 + const [{ CLASS: CoverGroupsOrchestrator }, t] = await Promise.all([ 361 + import("~/components/orchestrator/cover-groups/element.js"), 362 + scopedTracks(), 363 + ]); 364 + 365 + const cgo = new CoverGroupsOrchestrator(); 366 + cgo.setAttribute("tracks-selector", t.selector); 367 + 368 + return findExistingOrAdd(cgo, signals.orchestrator.coverGroups); 352 369 } 353 370 354 371 async function favourites() {
+1 -1
src/components/engine/queue/types.d.ts
··· 5 5 /** 6 6 * Clear the `future()` items. 7 7 */ 8 - clear: (args: { manualOnly?: boolean }) => void; 8 + clear: (args: { keepManual?: boolean }) => void; 9 9 fill: ( 10 10 args: { 11 11 /** Always keep adding, even if the amount of non-manual items in the queue are passed the given `amount` */
+6 -6
src/components/engine/queue/worker.js
··· 66 66 /** 67 67 * @type {Actions['clear']} 68 68 * 69 - * @example Keeps manual entries when manualOnly is true 69 + * @example Keeps manual entries when keepManual is true 70 70 * ```js 71 71 * import { clear, $future } from "~/components/engine/queue/worker.js"; 72 72 * ··· 74 74 * { id: "manual", manualEntry: true }, 75 75 * { id: "auto", manualEntry: false }, 76 76 * ]; 77 - * clear({ manualOnly: true }); 77 + * clear({ keepManual: true }); 78 78 * 79 79 * if ($future.value.length !== 1) throw new Error("expected 1 item remaining"); 80 80 * if ($future.value[0].id !== "manual") throw new Error("manual entry should remain"); 81 81 * ``` 82 82 * 83 - * @example Clears all items when manualOnly is false 83 + * @example Clears all items when keepManual is false 84 84 * ```js 85 85 * import { clear, $future } from "~/components/engine/queue/worker.js"; 86 86 * ··· 88 88 * { id: "manual", manualEntry: true }, 89 89 * { id: "auto", manualEntry: false }, 90 90 * ]; 91 - * clear({ manualOnly: false }); 91 + * clear({ keepManual: false }); 92 92 * 93 93 * if ($future.value.length !== 0) throw new Error("expected empty queue"); 94 94 * ``` 95 95 */ 96 - export function clear({ manualOnly }) { 97 - $future.value = manualOnly 96 + export function clear({ keepManual }) { 97 + $future.value = keepManual 98 98 ? $future.value.filter((i) => i.manualEntry === true) 99 99 : []; 100 100 }
+1 -1
src/components/orchestrator/auto-queue/element.js
··· 74 74 if (shuffled !== lastShuffle || fingerprint !== lastFingerprint) { 75 75 lastShuffle = shuffled; 76 76 lastFingerprint = fingerprint; 77 - queue.clear({ manualOnly: true }); 77 + queue.clear({ keepManual: true }); 78 78 } 79 79 80 80 queue.fill({ amount: 10, shuffled: repeatShuffle.shuffle() });
+184
src/components/orchestrator/cover-groups/element.js
··· 1 + import { defineElement, DiffuseElement, query } from "~/common/element.js"; 2 + import { computed, signal } from "~/common/signal.js"; 3 + 4 + /** 5 + * @import {SignalReader} from "~/common/signal.d.ts" 6 + * @import {Track} from "~/definitions/types.d.ts" 7 + */ 8 + 9 + //////////////////////////////////////////// 10 + // ELEMENT 11 + //////////////////////////////////////////// 12 + 13 + class CoverGroupsOrchestrator extends DiffuseElement { 14 + static NAME = "diffuse/orchestrator/cover-groups"; 15 + 16 + // SIGNALS 17 + 18 + #provider = signal( 19 + /** @type {DiffuseElement & { tracks: SignalReader<Track[]> } | null} */ (null), 20 + ); 21 + 22 + // STATE 23 + 24 + artistGroups = computed(() => { 25 + const groups = /** @type {any} */ (this.#provider.value)?.groups?.(); 26 + const allTracks = this.#provider.value?.tracks() ?? []; 27 + 28 + // Total track counts per artist across all groups 29 + /** @type {Map<string, number>} */ 30 + const totalCounts = new Map(); 31 + for (const track of allTracks) { 32 + const key = String(track.tags?.artist ?? "").toLowerCase(); 33 + totalCounts.set(key, (totalCounts.get(key) ?? 0) + 1); 34 + } 35 + 36 + /** @type {{ label: string; groups: ArtistGroup[] }[]} */ 37 + const result = []; 38 + 39 + if (groups?.length) { 40 + for ( 41 + const group 42 + of /** @type {{ label: string; tracks: Track[] }[]} */ (groups) 43 + ) { 44 + const artists = deduplicateArtists(group.tracks).map((a) => ({ 45 + ...a, 46 + trackCount: totalCounts.get(a.artistKey) ?? a.trackCount, 47 + })); 48 + if (artists.length) result.push({ label: group.label, groups: artists }); 49 + } 50 + } else { 51 + const artists = deduplicateArtists(allTracks); 52 + if (artists.length) result.push({ label: "", groups: artists }); 53 + } 54 + 55 + return result; 56 + }); 57 + 58 + coverGroups = computed(() => { 59 + const groups = /** @type {any} */ (this.#provider.value)?.groups?.(); 60 + 61 + /** @type {{ label: string; groups: CoverGroup[] }[]} */ 62 + const result = []; 63 + 64 + if (groups?.length) { 65 + for ( 66 + const group 67 + of /** @type {{ label: string; tracks: Track[] }[]} */ (groups) 68 + ) { 69 + const albums = deduplicateAlbums(group.tracks); 70 + if (albums.length) result.push({ label: group.label, groups: albums }); 71 + } 72 + } else { 73 + const tracks = this.#provider.value?.tracks() ?? []; 74 + const albums = deduplicateAlbums(tracks); 75 + if (albums.length) result.push({ label: "", groups: albums }); 76 + } 77 + 78 + return result; 79 + }); 80 + 81 + // LIFECYCLE 82 + 83 + /** 84 + * @override 85 + */ 86 + async connectedCallback() { 87 + super.connectedCallback(); 88 + 89 + /** @type {DiffuseElement & { tracks: SignalReader<Track[]> }} */ 90 + const provider = query(this, "tracks-selector"); 91 + 92 + await customElements.whenDefined(provider.localName); 93 + this.#provider.value = provider; 94 + } 95 + } 96 + 97 + export default CoverGroupsOrchestrator; 98 + 99 + //////////////////////////////////////////// 100 + // HELPERS 101 + //////////////////////////////////////////// 102 + 103 + /** 104 + * @typedef {{ albumKey: string; albumName: string; artist: string; track: Track }} CoverGroup 105 + */ 106 + 107 + /** 108 + * @typedef {{ artistKey: string; artistName: string; trackCount: number; track: Track }} ArtistGroup 109 + */ 110 + 111 + /** 112 + * @param {Track[]} tracks 113 + * @returns {CoverGroup[]} 114 + */ 115 + function deduplicateAlbums(tracks) { 116 + const sorted = [...tracks].sort((a, b) => { 117 + const aAlbum = String(a.tags?.album ?? "").toLowerCase(); 118 + const bAlbum = String(b.tags?.album ?? "").toLowerCase(); 119 + return aAlbum.localeCompare(bAlbum); 120 + }); 121 + 122 + /** @type {Map<string, { track: Track; artists: Set<string> }>} */ 123 + const albumMap = new Map(); 124 + 125 + for (const track of sorted) { 126 + const albumKey = String(track.tags?.album ?? "").toLowerCase(); 127 + const existing = albumMap.get(albumKey); 128 + if (existing) { 129 + existing.artists.add(track.tags?.artist ?? "Unknown artist"); 130 + } else { 131 + albumMap.set(albumKey, { 132 + track, 133 + artists: new Set([track.tags?.artist ?? "Unknown artist"]), 134 + }); 135 + } 136 + } 137 + 138 + return [...albumMap.entries()].map(([albumKey, { track, artists }]) => ({ 139 + albumKey, 140 + albumName: track.tags?.album ?? "Unknown album", 141 + artist: artists.size > 1 ? "Various Artists" : /** @type {string} */ ([...artists][0]), 142 + track, 143 + })); 144 + } 145 + 146 + /** 147 + * @param {Track[]} tracks 148 + * @returns {ArtistGroup[]} 149 + */ 150 + function deduplicateArtists(tracks) { 151 + /** @type {Map<string, { artistName: string; tracks: Track[] }>} */ 152 + const map = new Map(); 153 + 154 + for (const track of tracks) { 155 + const artistKey = String(track.tags?.artist ?? "").toLowerCase(); 156 + const existing = map.get(artistKey); 157 + if (existing) { 158 + existing.tracks.push(track); 159 + } else { 160 + map.set(artistKey, { 161 + artistName: track.tags?.artist ?? "Unknown artist", 162 + tracks: [track], 163 + }); 164 + } 165 + } 166 + 167 + return [...map.entries()] 168 + .sort(([a], [b]) => a.localeCompare(b)) 169 + .map(([artistKey, { artistName, tracks }]) => ({ 170 + artistKey, 171 + artistName, 172 + trackCount: tracks.length, 173 + track: tracks[0], 174 + })); 175 + } 176 + 177 + //////////////////////////////////////////// 178 + // REGISTER 179 + //////////////////////////////////////////// 180 + 181 + export const CLASS = CoverGroupsOrchestrator; 182 + export const NAME = "do-cover-groups"; 183 + 184 + defineElement(NAME, CLASS);
+1 -1
src/styles/diffuse/facet.css
··· 321 321 font-family: inherit; 322 322 font-size: inherit; 323 323 font-weight: inherit; 324 - gap: var(--space-xs); 324 + gap: var(--space-2xs); 325 325 min-width: var(--space-3xl); 326 326 padding: var(--space-xs) var(--space-sm); 327 327 text-align: left;
+293 -8
src/themes/blur/browser/element.css
··· 67 67 .toolbar-actions { 68 68 align-items: center; 69 69 display: flex; 70 + gap: var(--space-3xs); 70 71 } 71 72 72 - .playlist-btn { 73 + .browser-button { 73 74 align-items: center; 74 75 background: transparent; 75 76 border: 1px solid var(--border-color); 76 77 border-radius: var(--radius-md); 77 - color: inherit; 78 + color: color-mix(in oklch, currentColor 50%, transparent); 78 79 cursor: pointer; 79 80 display: flex; 80 81 font-family: inherit; 81 82 font-size: 100%; 82 83 gap: var(--space-2xs); 84 + padding: var(--space-3xs) var(--space-2xs); 85 + text-box: trim-both cap alphabetic; 86 + } 87 + 88 + .browser-button i { 89 + flex-shrink: 0; 90 + } 91 + 92 + .browser-button--active { 93 + background: color-mix(in oklch, currentColor 8%, transparent); 94 + color: currentColor; 95 + } 96 + 97 + .browser-button:hover:not(.browser-button--active) { 98 + color: color-mix(in oklch, currentColor 75%, transparent); 99 + } 100 + 101 + .browser-button--playlist { 83 102 margin-left: var(--space-2xs); 84 103 max-width: 12rem; 85 104 overflow: hidden; 86 - padding: var(--space-3xs) var(--space-2xs) calc(var(--space-3xs) - 1px); 87 - text-box: trim-both cap alphabetic; 88 105 text-overflow: ellipsis; 89 106 white-space: nowrap; 90 - } 91 - 92 - .playlist-btn i { 93 - flex-shrink: 0; 94 107 } 95 108 96 109 .toolbar-icon-btn { ··· 348 361 color: color-mix(in oklch, currentColor 40%, transparent); 349 362 padding: var(--space-lg) var(--space-md); 350 363 } 364 + 365 + /*********************************** 366 + * View mode toggle 367 + ***********************************/ 368 + 369 + .toolbar-icon-btn--active { 370 + color: currentColor; 371 + } 372 + 373 + /*********************************** 374 + * Cover tabs 375 + ***********************************/ 376 + 377 + .cover-tabs { 378 + align-items: center; 379 + display: flex; 380 + justify-content: space-between; 381 + padding: var(--space-sm) var(--space-sm) var(--space-2xs); 382 + } 383 + 384 + .cover-tabs-start { 385 + align-items: center; 386 + display: flex; 387 + gap: var(--space-2xs); 388 + } 389 + 390 + .cover-tabs-end { 391 + align-items: center; 392 + color: color-mix(in oklch, currentColor 40%, transparent); 393 + display: flex; 394 + font-size: 90%; 395 + gap: var(--space-3xs); 396 + } 397 + 398 + .cover-count { 399 + text-box: trim-both cap alphabetic; 400 + } 401 + 402 + /*********************************** 403 + * Cover view 404 + ***********************************/ 405 + 406 + .cover-scroll-panel { 407 + flex: 1; 408 + overflow-y: auto; 409 + min-height: 0; 410 + user-select: none; 411 + } 412 + 413 + .cover-group-header { 414 + align-items: center; 415 + color: color-mix(in oklch, currentColor 45%, transparent); 416 + display: flex; 417 + font-size: 110%; 418 + font-weight: 600; 419 + gap: var(--space-2xs); 420 + padding: var(--space-sm) var(--space-sm) var(--space-2xs); 421 + 422 + & > span { 423 + overflow: hidden; 424 + text-overflow: ellipsis; 425 + white-space: nowrap; 426 + } 427 + } 428 + 429 + .cover-group-header--top { 430 + padding-top: var(--space-xs); 431 + } 432 + 433 + .cover-grid { 434 + display: grid; 435 + gap: var(--space-sm); 436 + grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); 437 + padding: var(--space-2xs) var(--space-sm) var(--space-sm); 438 + } 439 + 440 + .cover-card { 441 + cursor: pointer; 442 + display: flex; 443 + flex-direction: column; 444 + gap: var(--space-2xs); 445 + } 446 + 447 + .cover-card:hover .cover-art img, 448 + .cover-card:hover .cover-art-placeholder { 449 + opacity: 0.85; 450 + } 451 + 452 + .cover-art { 453 + aspect-ratio: 1; 454 + background: color-mix(in oklch, currentColor 6%, transparent); 455 + border-radius: var(--radius-md); 456 + overflow: hidden; 457 + width: 100%; 458 + } 459 + 460 + .cover-art img { 461 + display: block; 462 + height: 100%; 463 + object-fit: cover; 464 + transition: opacity 80ms; 465 + width: 100%; 466 + } 467 + 468 + .cover-art-placeholder { 469 + align-items: center; 470 + color: color-mix(in oklch, currentColor 20%, transparent); 471 + display: flex; 472 + font-size: 2rem; 473 + height: 100%; 474 + justify-content: center; 475 + transition: opacity 80ms; 476 + width: 100%; 477 + } 478 + 479 + .cover-art-loading { 480 + animation: cover-pulse 1.2s ease-in-out infinite; 481 + } 482 + 483 + @keyframes cover-pulse { 484 + 0%, 485 + 100% { 486 + opacity: 1; 487 + } 488 + 50% { 489 + opacity: 0.4; 490 + } 491 + } 492 + 493 + .cover-info { 494 + display: flex; 495 + flex-direction: column; 496 + gap: 1px; 497 + overflow: hidden; 498 + } 499 + 500 + .cover-album { 501 + display: block; 502 + font-weight: 500; 503 + overflow: hidden; 504 + text-overflow: ellipsis; 505 + white-space: nowrap; 506 + } 507 + 508 + .cover-artist { 509 + color: color-mix(in oklch, currentColor 55%, transparent); 510 + display: block; 511 + font-size: 90%; 512 + overflow: hidden; 513 + text-overflow: ellipsis; 514 + white-space: nowrap; 515 + } 516 + 517 + /*********************************** 518 + * Album detail view 519 + ***********************************/ 520 + 521 + .album-detail { 522 + display: flex; 523 + flex-direction: column; 524 + flex: 1; 525 + min-height: 0; 526 + overflow: hidden; 527 + } 528 + 529 + .album-detail-actions { 530 + align-items: center; 531 + display: flex; 532 + gap: var(--space-2xs); 533 + padding: var(--space-sm) var(--space-xs) var(--space-2xs); 534 + } 535 + 536 + .album-detail-main { 537 + display: flex; 538 + flex: 1; 539 + min-height: 0; 540 + overflow: hidden; 541 + } 542 + 543 + .album-detail-sidebar { 544 + flex: 0 0 auto; 545 + overflow-y: auto; 546 + padding: var(--space-xs) var(--space-sm); 547 + width: 13rem; 548 + } 549 + 550 + .album-detail-art { 551 + aspect-ratio: 1; 552 + background: color-mix(in oklch, currentColor 6%, transparent); 553 + border-radius: var(--radius-md); 554 + overflow: hidden; 555 + width: 100%; 556 + } 557 + 558 + .album-detail-art img { 559 + display: block; 560 + height: 100%; 561 + object-fit: cover; 562 + width: 100%; 563 + } 564 + 565 + .album-detail-info { 566 + display: flex; 567 + flex-direction: column; 568 + gap: 2px; 569 + margin-top: var(--space-xs); 570 + overflow: hidden; 571 + padding: 0 var(--space-3xs); 572 + } 573 + 574 + .album-detail-name { 575 + display: block; 576 + font-weight: 600; 577 + overflow: hidden; 578 + text-overflow: ellipsis; 579 + white-space: nowrap; 580 + } 581 + 582 + .album-detail-artist { 583 + color: color-mix(in oklch, currentColor 55%, transparent); 584 + display: block; 585 + font-size: 90%; 586 + overflow: hidden; 587 + text-overflow: ellipsis; 588 + white-space: nowrap; 589 + } 590 + 591 + .album-detail-tracks { 592 + flex: 1; 593 + min-width: 0; 594 + overflow-y: auto; 595 + user-select: none; 596 + } 597 + 598 + .album-track-row { 599 + align-items: center; 600 + cursor: pointer; 601 + display: flex; 602 + height: 40px; 603 + padding: 0 var(--space-xs); 604 + transition: background-color 80ms; 605 + } 606 + 607 + .album-track-row--alt { 608 + background-color: color-mix(in oklch, currentColor 1.5%, transparent); 609 + } 610 + 611 + .album-track-row:hover { 612 + background-color: color-mix(in oklch, currentColor 6%, var(--bg-color)); 613 + } 614 + 615 + .album-track-row > div { 616 + overflow: hidden; 617 + } 618 + 619 + .album-track-row .col-fav { 620 + align-items: center; 621 + display: flex; 622 + flex-shrink: 0; 623 + } 624 + 625 + .album-track-row .col-title, 626 + .album-track-row .col-artist { 627 + padding-left: var(--space-2xs); 628 + } 629 + 630 + .album-track-row span { 631 + display: block; 632 + overflow: hidden; 633 + text-overflow: ellipsis; 634 + white-space: nowrap; 635 + }
+874 -160
src/themes/blur/browser/element.js
··· 2 2 defineElement, 3 3 DiffuseElement, 4 4 query, 5 + queryOptional, 5 6 whenElementsDefined, 6 7 } from "~/common/element.js"; 7 8 import { computed, signal, untracked } from "~/common/signal.js"; ··· 18 19 * @import {SignalReader} from "~/common/signal.d.ts"; 19 20 * @import {Track} from "~/definitions/types.d.ts" 20 21 * @import {OutputElement} from "~/components/output/types.d.ts" 22 + * @import {ArtworkElement} from "~/components/artwork/types.d.ts" 21 23 */ 22 24 25 + const MAX_ART_CONCURRENT = 8; 26 + 23 27 const TRACK_ROW_HEIGHT = 40; 24 28 const GROUP_HEADER_HEIGHT = 52; 25 29 const OVERSCAN = 10; ··· 34 38 const DEFAULT_SORT = ["createdAt"]; 35 39 36 40 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 + { 42 + value: "firstLetter", 43 + label: "Group by first letter", 44 + icon: "ph-text-aa", 45 + sortBy: null, 46 + sortDirection: /** @type {"asc" | "desc" | undefined} */ (undefined), 47 + }, 48 + { 49 + value: "directory", 50 + label: "Group by path", 51 + icon: "ph-folder", 52 + sortBy: ["uri"], 53 + sortDirection: /** @type {"asc" | "desc" | undefined} */ (undefined), 54 + }, 55 + { 56 + value: "createdAt", 57 + label: "Group by processing date", 58 + icon: "ph-clock", 59 + sortBy: ["createdAt"], 60 + sortDirection: /** @type {"asc" | "desc" | undefined} */ ("desc"), 61 + }, 62 + { 63 + value: "tags.year", 64 + label: "Group by track year", 65 + icon: "ph-calendar", 66 + sortBy: ["tags.year"], 67 + sortDirection: /** @type {"asc" | "desc" | undefined} */ ("desc"), 68 + }, 41 69 ]; 42 70 43 71 /** ··· 46 74 * @typedef {GroupItem | TrackItem} VirtualItem 47 75 */ 48 76 77 + /** 78 + * @typedef {{ type: "album"; albumKey: string; albumName: string; artist: string; track: Track }} OpenAlbumItem 79 + * @typedef {{ type: "artist"; artistKey: string; artistName: string; trackCount: number; track: Track }} OpenArtistItem 80 + * @typedef {OpenAlbumItem | OpenArtistItem} OpenCoverItem 81 + */ 82 + 49 83 class Browser extends DiffuseElement { 50 84 constructor() { 51 85 super(); ··· 54 88 55 89 // SIGNALS 56 90 91 + $artwork = signal( 92 + /** @type {ArtworkElement | undefined} */ (undefined), 93 + ); 94 + 95 + $coverGroups = signal( 96 + /** @type {import("~/components/orchestrator/cover-groups/element.js").CLASS | undefined} */ (undefined), 97 + ); 98 + 57 99 $output = signal( 58 100 /** @type {OutputElement | undefined} */ (undefined), 59 101 ); ··· 74 116 /** @type {import("~/components/orchestrator/favourites/element.js").CLASS | undefined} */ (undefined), 75 117 ); 76 118 119 + // SIGNALS - Pt. 2 120 + 121 + #viewMode = signal( 122 + /** @type {"list" | "cover"} */ ( 123 + localStorage.getItem("diffuse:browser:view-mode") === "cover" 124 + ? "cover" 125 + : "list" 126 + ), 127 + ); 128 + 129 + #coverViewMode = signal(/** @type {"albums" | "artists"} */ ("albums")); 130 + 131 + #openCoverItem = signal(/** @type {OpenCoverItem | null} */ (null)); 132 + 133 + // SIGNALS - Pt. 3 134 + 135 + $albumTrackMap = computed(() => { 136 + /** @type {Map<string, Track>} */ 137 + const map = new Map(); 138 + for (const { groups } of this.$coverGroups.value?.coverGroups() ?? []) { 139 + for (const { albumKey, track } of groups) { 140 + map.set(albumKey, track); 141 + } 142 + } 143 + for (const { groups } of this.$coverGroups.value?.artistGroups() ?? []) { 144 + for (const { artistKey, track } of groups) { 145 + map.set(artistKey, track); 146 + } 147 + } 148 + return map; 149 + }); 150 + 77 151 $groupedPlaylists = computed(() => { 78 152 const col = this.$output.value?.playlistItems.collection(); 79 153 if (!col || col.state !== "loaded" || !col.data.length) return []; ··· 97 171 98 172 // STATE 99 173 174 + /** @type {Map<string, string | null>} */ 175 + #coverArtCache = new Map(); 176 + 177 + /** @type {Set<string>} */ 178 + #pendingArtFetch = new Set(); 179 + 180 + /** @type {{ albumKey: string; track: Track }[]} */ 181 + #artFetchQueue = []; 182 + 183 + #artFetchActive = 0; 184 + #artRenderScheduled = false; 185 + 186 + /** @type {IntersectionObserver | undefined} */ 187 + #coverObserver = undefined; 188 + 189 + #observedCards = new WeakSet(); 190 + 100 191 /** @type {VirtualItem[]} */ 101 192 #flatItems = []; 102 193 ··· 120 211 connectedCallback() { 121 212 super.connectedCallback(); 122 213 214 + /** @type {import("~/components/configurator/artwork/element.js").CLASS | null} */ 215 + const artwork = queryOptional(this, "artwork-selector"); 216 + 217 + /** @type {import("~/components/orchestrator/cover-groups/element.js").CLASS | null} */ 218 + const coverGroups = queryOptional( 219 + this, 220 + "cover-groups-orchestrator-selector", 221 + ); 222 + 123 223 /** @type {OutputElement} */ 124 224 const output = query(this, "output-selector"); 125 225 ··· 135 235 /** @type {import("~/components/orchestrator/favourites/element.js").CLASS} */ 136 236 const favourites = query(this, "favourites-orchestrator-selector"); 137 237 138 - whenElementsDefined({ output, provider, queue, scope, favourites }).then(() => { 139 - this.$output.value = output; 140 - this.$provider.value = provider; 141 - this.$queue.value = queue; 142 - this.$scope.value = scope; 143 - this.$favourites.value = favourites; 144 - }); 238 + whenElementsDefined({ output, provider, queue, scope, favourites }).then( 239 + () => { 240 + this.$output.value = output; 241 + this.$provider.value = provider; 242 + this.$queue.value = queue; 243 + this.$scope.value = scope; 244 + this.$favourites.value = favourites; 245 + }, 246 + ); 247 + 248 + if (artwork) { 249 + whenElementsDefined({ artwork }).then(() => { 250 + this.$artwork.value = artwork; 251 + }); 252 + } 253 + 254 + if (coverGroups) { 255 + whenElementsDefined({ coverGroups }).then(() => { 256 + this.$coverGroups.value = coverGroups; 257 + }); 258 + } 145 259 146 260 // Reset scroll when track list changes 147 261 this.effect(() => { ··· 154 268 }); 155 269 156 270 // Set up the virtualizer after the first render, when .scroll-panel exists in the DOM. 157 - // This mirrors the winamp browser's #setupScrollTracking pattern. 158 - requestAnimationFrame(() => { 159 - const panel = this.root().querySelector(".scroll-panel"); 160 - if (!panel) return; 161 - 162 - this.#virtualizer = new Virtualizer({ 163 - count: 0, 164 - getScrollElement: () => panel, 165 - estimateSize: (i) => 166 - this.#flatItems[i]?.type === "group" 167 - ? GROUP_HEADER_HEIGHT 168 - : TRACK_ROW_HEIGHT, 169 - overscan: OVERSCAN, 170 - observeElementRect, 171 - observeElementOffset, 172 - scrollToFn: elementScroll, 173 - onChange: () => { 174 - requestAnimationFrame(() => this.forceRender()); 175 - }, 176 - }); 177 - 178 - this.#virtualizerCleanup = this.#virtualizer._didMount(); 179 - this.#virtualizer._willUpdate(); 180 - 181 - // Render now that the virtualizer is wired up 182 - this.forceRender(); 183 - }); 271 + requestAnimationFrame(() => this.#setupVirtualizer()); 184 272 } 185 273 186 274 /** ··· 191 279 this.#virtualizerCleanup?.(); 192 280 this.#virtualizerCleanup = undefined; 193 281 this.#virtualizer = undefined; 282 + this.#disconnectCoverObserver(); 194 283 } 195 284 196 285 // EVENTS ··· 260 349 this.$favourites.value?.toggle(track); 261 350 }; 262 351 352 + toggleViewMode = () => { 353 + if (this.#viewMode.value === "cover") { 354 + this.#disconnectCoverObserver(); 355 + this.#openCoverItem.value = null; 356 + requestAnimationFrame(() => this.#setupVirtualizer()); 357 + } 358 + const next = this.#viewMode.value === "list" ? "cover" : "list"; 359 + localStorage.setItem("diffuse:browser:view-mode", next); 360 + this.#viewMode.value = next; 361 + }; 362 + 363 + /** 364 + * @param {"albums" | "artists"} mode 365 + */ 366 + setCoverViewMode = (mode) => { 367 + this.#openCoverItem.value = null; 368 + this.#disconnectCoverObserver(); 369 + this.#coverViewMode.value = mode; 370 + }; 371 + 372 + // HELPERS 373 + 374 + /** 375 + * Enqueue an artwork fetch if not already pending or cached. 376 + * @param {string} albumKey 377 + * @param {Track} track 378 + */ 379 + #fetchAlbumArt(albumKey, track) { 380 + if (this.#coverArtCache.has(albumKey)) return; 381 + if (this.#pendingArtFetch.has(albumKey)) return; 382 + this.#pendingArtFetch.add(albumKey); 383 + this.#artFetchQueue.push({ albumKey, track }); 384 + this.#drainArtQueue(); 385 + } 386 + 387 + #drainArtQueue() { 388 + while ( 389 + this.#artFetchActive < MAX_ART_CONCURRENT && 390 + this.#artFetchQueue.length > 0 391 + ) { 392 + const job = this.#artFetchQueue.shift(); 393 + if (!job) break; 394 + this.#artFetchActive++; 395 + this.#doFetchAlbumArt(job.albumKey, job.track); 396 + } 397 + } 398 + 399 + /** 400 + * @param {string} albumKey 401 + * @param {Track} track 402 + */ 403 + async #doFetchAlbumArt(albumKey, track) { 404 + const artwork = this.$artwork.value; 405 + try { 406 + const bytes = artwork ? await artwork.get(track) : null; 407 + if (bytes) { 408 + const mime = detectMime(bytes); 409 + const url = URL.createObjectURL( 410 + new Blob([/** @type {ArrayBuffer} */ (bytes.buffer)], { type: mime }), 411 + ); 412 + this.#coverArtCache.set(albumKey, url); 413 + } else { 414 + this.#coverArtCache.set(albumKey, null); 415 + } 416 + } catch { 417 + this.#coverArtCache.set(albumKey, null); 418 + } finally { 419 + this.#artFetchActive--; 420 + this.#drainArtQueue(); 421 + } 422 + this.#scheduleArtRender(); 423 + } 424 + 425 + #scheduleArtRender() { 426 + if (this.#artRenderScheduled) return; 427 + this.#artRenderScheduled = true; 428 + requestAnimationFrame(() => { 429 + this.#artRenderScheduled = false; 430 + this.forceRender(); 431 + }); 432 + } 433 + 434 + #setupCoverObserver() { 435 + const root = this.root().querySelector(".cover-scroll-panel"); 436 + if (!root) return; 437 + 438 + if (!this.#coverObserver) { 439 + this.#coverObserver = new IntersectionObserver( 440 + (entries) => { 441 + for (const entry of entries) { 442 + if (!entry.isIntersecting) continue; 443 + const albumKey = 444 + /** @type {HTMLElement} */ (entry.target).dataset.albumKey; 445 + if (!albumKey) continue; 446 + const track = this.$albumTrackMap().get(albumKey); 447 + if (track) this.#fetchAlbumArt(albumKey, track); 448 + this.#coverObserver?.unobserve(entry.target); 449 + } 450 + }, 451 + { root, rootMargin: "200px" }, 452 + ); 453 + } 454 + 455 + for ( 456 + const card of this.root().querySelectorAll(".cover-card[data-album-key]") 457 + ) { 458 + if (this.#observedCards.has(card)) continue; 459 + this.#observedCards.add(card); 460 + this.#coverObserver.observe(card); 461 + } 462 + } 463 + 464 + #setupVirtualizer() { 465 + const panel = this.root().querySelector(".scroll-panel"); 466 + if (!panel) return; 467 + 468 + this.#virtualizerCleanup?.(); 469 + 470 + this.#virtualizer = new Virtualizer({ 471 + count: 0, 472 + getScrollElement: () => panel, 473 + estimateSize: (i) => 474 + this.#flatItems[i]?.type === "group" 475 + ? GROUP_HEADER_HEIGHT 476 + : TRACK_ROW_HEIGHT, 477 + overscan: OVERSCAN, 478 + observeElementRect, 479 + observeElementOffset, 480 + scrollToFn: elementScroll, 481 + onChange: () => { 482 + requestAnimationFrame(() => this.forceRender()); 483 + }, 484 + }); 485 + 486 + this.#virtualizerCleanup = this.#virtualizer._didMount(); 487 + this.#virtualizer._willUpdate(); 488 + this.forceRender(); 489 + } 490 + 491 + #disconnectCoverObserver() { 492 + this.#coverObserver?.disconnect(); 493 + this.#coverObserver = undefined; 494 + this.#observedCards = new WeakSet(); 495 + } 496 + 263 497 // RENDER 264 498 265 499 /** 266 - * @param {RenderArg} _ 500 + * @param {Function} html 501 + * @param {boolean} isLoading 267 502 */ 268 - render({ html }) { 269 - const isLoading = 270 - this.$output.value?.tracks?.collection().state !== "loaded"; 503 + #renderCoverView(html, isLoading) { 504 + if (this.#openCoverItem.value) return this.#renderCoverDetail(html); 505 + 506 + const coverViewMode = this.#coverViewMode.value; 507 + const sortDirection = this.$scope.value?.sortDirection() ?? "asc"; 508 + 509 + const totalCount = coverViewMode === "artists" 510 + ? (this.$coverGroups.value?.artistGroups() ?? []).reduce((n, g) => n + g.groups.length, 0) 511 + : (this.$coverGroups.value?.coverGroups() ?? []).reduce((n, g) => n + g.groups.length, 0); 512 + 513 + const countLabel = coverViewMode === "artists" 514 + ? `${totalCount} ${totalCount === 1 ? "artist" : "artists"}` 515 + : `${totalCount} ${totalCount === 1 ? "album" : "albums"}`; 271 516 517 + const tabs = html` 518 + <div class="cover-tabs"> 519 + <div class="cover-tabs-start"> 520 + <button 521 + class="browser-button ${coverViewMode === `artists` 522 + ? `browser-button--active` 523 + : ``}" 524 + @click="${() => this.setCoverViewMode(`artists`)}" 525 + > 526 + <i class="ph-bold ph-user-list"></i> 527 + Artists 528 + </button> 529 + <button 530 + class="browser-button ${coverViewMode === `albums` 531 + ? `browser-button--active` 532 + : ``}" 533 + @click="${() => this.setCoverViewMode(`albums`)}" 534 + > 535 + <i class="ph-bold ph-vinyl-record"></i> 536 + Albums 537 + </button> 538 + </div> 539 + <div class="cover-tabs-end"> 540 + <span class="cover-count">${countLabel}</span> 541 + <button 542 + class="toolbar-icon-btn" 543 + @click="${() => { 544 + const scope = this.$scope.value; 545 + if (!scope) return; 546 + scope.setSortDirection(scope.sortDirection() === `asc` ? `desc` : `asc`); 547 + }}" 548 + title="${sortDirection === `desc` ? `Sort ascending` : `Sort descending`}" 549 + > 550 + <i class="ph-bold ph-arrow-${sortDirection === `desc` ? `down` : `up`}"></i> 551 + </button> 552 + </div> 553 + </div> 554 + `; 555 + 556 + if (isLoading) { 557 + return html` 558 + ${tabs} 559 + <div class="scroll-panel"><div class="loading">Loading ...</div></div> 560 + `; 561 + } 562 + 563 + if (coverViewMode === "artists") { 564 + const artistGroups = this.$coverGroups.value?.artistGroups() ?? []; 565 + 566 + requestAnimationFrame(() => this.#setupCoverObserver()); 567 + 568 + return html` 569 + ${tabs} 570 + <div class="scroll-panel cover-scroll-panel"> 571 + ${artistGroups.map(({ label, groups }, groupIndex) => 572 + html` 573 + ${label 574 + ? html` 575 + <div class="cover-group-header ${groupIndex === 0 576 + ? `cover-group-header--top` 577 + : ``}"> 578 + <i class="ph-fill ph-vinyl-record"></i> 579 + <span>${label}</span> 580 + </div> 581 + ` 582 + : ``} 583 + <div class="cover-grid"> 584 + ${groups.map(({ artistKey, artistName, trackCount, track }) => { 585 + const artUrl = this.#coverArtCache.get(artistKey); 586 + return html` 587 + <div 588 + class="cover-card" 589 + data-album-key="${artistKey}" 590 + @click="${() => { 591 + this.#openCoverItem.value = { 592 + type: "artist", 593 + artistKey, 594 + artistName, 595 + trackCount, 596 + track, 597 + }; 598 + if (!this.#coverArtCache.has(artistKey)) { 599 + this.#fetchAlbumArt(artistKey, track); 600 + } 601 + }}" 602 + title="${artistName}" 603 + > 604 + <div class="cover-art"> 605 + ${artUrl 606 + ? html` 607 + <img src="${artUrl}" alt="${artistName}" loading="lazy" /> 608 + ` 609 + : artUrl === null 610 + ? html` 611 + <div class="cover-art-placeholder"><i class="ph-bold ph-music-note"></i></div> 612 + ` 613 + : html` 614 + <div class="cover-art-placeholder cover-art-loading"></div> 615 + `} 616 + </div> 617 + <div class="cover-info"> 618 + <span class="cover-album">${artistName}</span> 619 + <span class="cover-artist">${trackCount} ${trackCount === 620 + 1 621 + ? `track` 622 + : `tracks`}</span> 623 + </div> 624 + </div> 625 + `; 626 + })} 627 + </div> 628 + ` 629 + )} 630 + </div> 631 + `; 632 + } 633 + 634 + // Albums mode 635 + const coverGroups = this.$coverGroups.value?.coverGroups() ?? []; 636 + 637 + requestAnimationFrame(() => this.#setupCoverObserver()); 638 + 639 + return html` 640 + ${tabs} 641 + <div class="scroll-panel cover-scroll-panel"> 642 + ${coverGroups.map(({ label, groups }, groupIndex) => 643 + html` 644 + ${label 645 + ? html` 646 + <div class="cover-group-header ${groupIndex === 0 647 + ? `cover-group-header--top` 648 + : ``}"> 649 + <i class="ph-fill ph-vinyl-record"></i> 650 + <span>${label}</span> 651 + </div> 652 + ` 653 + : ``} 654 + <div class="cover-grid"> 655 + ${groups.map(({ albumKey, albumName, artist, track }) => { 656 + const artUrl = this.#coverArtCache.get(albumKey); 657 + return html` 658 + <div 659 + class="cover-card" 660 + data-album-key="${albumKey}" 661 + @click="${() => { 662 + this.#openCoverItem.value = { 663 + type: "album", 664 + albumKey, 665 + albumName, 666 + artist, 667 + track, 668 + }; 669 + if (!this.#coverArtCache.has(albumKey)) { 670 + this.#fetchAlbumArt(albumKey, track); 671 + } 672 + }}" 673 + title="${albumName} — ${artist}" 674 + > 675 + <div class="cover-art"> 676 + ${artUrl 677 + ? html` 678 + <img src="${artUrl}" alt="${albumName}" loading="lazy" /> 679 + ` 680 + : artUrl === null 681 + ? html` 682 + <div class="cover-art-placeholder"><i class="ph-bold ph-music-note"></i></div> 683 + ` 684 + : html` 685 + <div class="cover-art-placeholder cover-art-loading"></div> 686 + `} 687 + </div> 688 + <div class="cover-info"> 689 + <span class="cover-album">${albumName}</span> 690 + <span class="cover-artist">${artist}</span> 691 + </div> 692 + </div> 693 + `; 694 + })} 695 + </div> 696 + ` 697 + )} 698 + </div> 699 + `; 700 + } 701 + 702 + /** 703 + * @param {Function} html 704 + * @param {boolean} isLoading 705 + * @param {string[]} sortBy 706 + */ 707 + #renderListView(html, isLoading, sortBy) { 272 708 const tracks = this.$provider.value?.tracks() ?? []; 273 - const playlist = this.$scope.value?.playlist(); 274 - const groupBy = this.$scope.value?.groupBy(); 275 - const searchTerm = this.$scope.value?.searchTerm() ?? ""; 276 - const sortBy = this.$scope.value?.sortBy() ?? DEFAULT_SORT; 709 + const groups = /** @type {any} */ (this.$provider.value)?.groups?.(); 277 710 const sortDirection = this.$scope.value?.sortDirection(); 278 711 279 712 const sortedColumn = Object.entries(COLUMN_SORT).find( ··· 284 717 sortedColumn === col 285 718 ? (sortDirection === "desc" ? "descending" : "ascending") 286 719 : "none"; 287 - 288 - const groups = /** @type {any} */ (this.$provider.value)?.groups?.(); 289 720 290 721 // Rebuild flat items only when data reference changes 291 722 if (groups !== this.#lastGroups || tracks !== this.#lastTracks) { ··· 316 747 favItems.map((item) => { 317 748 const a = item.criteria.find((c) => c.field === "tags.artist"); 318 749 const t = item.criteria.find((c) => c.field === "tags.title"); 319 - return `${String(a?.value ?? "").toLowerCase()}|${String(t?.value ?? "").toLowerCase()}`; 750 + return `${String(a?.value ?? "").toLowerCase()}|${ 751 + String(t?.value ?? "").toLowerCase() 752 + }`; 320 753 }), 321 754 ); 322 755 ··· 326 759 * @param {number} index 327 760 */ 328 761 const renderTrackRow = (track, top, index) => { 329 - const key = `${String(track.tags?.artist ?? "").toLowerCase()}|${String(track.tags?.title ?? "").toLowerCase()}`; 762 + const key = `${String(track.tags?.artist ?? "").toLowerCase()}|${ 763 + String(track.tags?.title ?? "").toLowerCase() 764 + }`; 330 765 const isFav = favSet.has(key); 331 766 return html` 332 767 <div 333 - class="track-row ${index === 0 ? `track-row--top` : ``} ${index % 2 === 1 ? `track-row--alt` : ``}" 768 + class="track-row ${index === 0 769 + ? `track-row--top` 770 + : ``} ${index % 2 === 1 ? `track-row--alt` : ``}" 334 771 style="transform: translateY(${top}px);" 335 772 @dblclick="${() => this.playTrack(track)}" 336 773 > 337 774 <div class="col-fav"> 338 775 <button 339 776 class="fav-btn ${isFav ? `fav-btn--active` : ``}" 340 - @click="${(/** @type {Event} */ e) => { e.stopPropagation(); this.toggleFavourite(track); }}" 777 + @click="${(/** @type {Event} */ e) => { 778 + e.stopPropagation(); 779 + this.toggleFavourite(track); 780 + }}" 341 781 title="${isFav ? `Remove from favourites` : `Add to favourites`}" 342 782 > 343 783 <i class="ph-${isFav ? `fill ph-heart` : `bold ph-heart`}"></i> ··· 357 797 }; 358 798 359 799 return html` 360 - <link rel="stylesheet" href="styles/base.css" /> 361 - <link rel="stylesheet" href="styles/diffuse/facet.css" /> 362 - <link rel="stylesheet" href="vendor/@phosphor-icons/web/bold/style.css" /> 363 - <link rel="stylesheet" href="vendor/@phosphor-icons/web/fill/style.css" /> 364 - <link rel="stylesheet" href="themes/blur/variables.css" /> 365 - <link rel="stylesheet" href="themes/blur/browser/element.css" /> 366 - 367 - <div class="toolbar"> 368 - <label class="search-field"> 369 - <i class="ph-bold ph-magnifying-glass"></i> 370 - <input 371 - id="search-input" 372 - type="search" 373 - placeholder="Search" 374 - @change="${this.setSearchTerm}" 375 - .value="${searchTerm}" 376 - /> 377 - </label> 378 - 379 - <div class="toolbar-actions"> 380 - ${searchTerm ? html` 381 - <button class="toolbar-icon-btn" @click="${this.clearSearch}" title="Clear search"> 382 - <i class="ph-bold ph-x"></i> 383 - </button> 384 - ` : ``} 385 - <button class="toolbar-icon-btn" popovertarget="groupby-menu" title="Group by"> 386 - <i class="ph-fill ph-stack"></i> 387 - </button> 388 - <div id="groupby-menu" class="dropdown" popover> 389 - ${GROUP_BY_OPTIONS.map(({ value, label, icon, sortBy: optSortBy, sortDirection: optSortDirection }) => { 390 - // "firstLetter" is dynamic — its stored value includes the sort field 391 - const primarySortField = sortBy[0] ?? "tags.title"; 392 - const resolvedValue = value === "firstLetter" 393 - ? `firstLetter:${primarySortField}` 394 - : value; 395 - const isActive = value === "firstLetter" 396 - ? groupBy?.startsWith("firstLetter:") 397 - : groupBy === value; 398 - return html` 399 - <button 400 - class="${isActive ? `dropdown-item--active` : ``}" 401 - @click="${() => { 402 - const scope = this.$scope.value; 403 - if (isActive) { 404 - this.setGroupBy(undefined); 405 - } else { 406 - this.setGroupBy(resolvedValue); 407 - if (optSortBy && scope) { 408 - scope.setSortBy(optSortBy); 409 - scope.setSortDirection(optSortDirection); 410 - } 411 - } 412 - /** @type {HTMLElement | null} */ (this.root().querySelector(`#groupby-menu`))?.hidePopover(); 413 - }}" 414 - > 415 - ${isActive ? html`<i class="ph-bold ph-x"></i>` : ``} 416 - ${label} 417 - </button> 418 - `; 419 - })} 420 - </div> 421 - <button class="playlist-btn" popovertarget="playlist-menu"> 422 - <i class="ph-fill ph-playlist"></i> 423 - <span>${playlist ?? `All tracks`}</span> 424 - </button> 425 - <div id="playlist-menu" class="dropdown" popover> 426 - <button @click="${() => { this.setSelectedPlaylist(undefined); /** @type {HTMLElement | null} */ (this.root().querySelector(`#playlist-menu`))?.hidePopover(); }}"> 427 - All tracks 428 - </button> 429 - ${this.$groupedPlaylists().map((group) => 430 - group.playlists.map((p) => 431 - html` 432 - <button @click="${() => { this.setSelectedPlaylist(p.name); /** @type {HTMLElement | null} */ (this.root().querySelector(`#playlist-menu`))?.hidePopover(); }}"> 433 - ${p.name} 434 - </button> 435 - ` 436 - ) 437 - )} 438 - </div> 439 - </div> 440 - </div> 441 - 442 800 <div class="table-header"> 443 801 <div class="col-fav"></div> 444 802 <div ··· 446 804 @click="${() => this.sortByColumn(`title`)}" 447 805 aria-sort="${ariaSort(`title`)}" 448 806 > 449 - Title 450 - ${sortedColumn === `title` 451 - ? html`<i class="ph-bold ${sortDirection === `desc` ? `ph-caret-down` : `ph-caret-up`}"></i>` 807 + Title ${sortedColumn === `title` 808 + ? html` 809 + <i class="ph-bold ${sortDirection === `desc` 810 + ? `ph-caret-down` 811 + : `ph-caret-up`}"></i> 812 + ` 452 813 : ``} 453 814 </div> 454 815 <div ··· 456 817 @click="${() => this.sortByColumn(`artist`)}" 457 818 aria-sort="${ariaSort(`artist`)}" 458 819 > 459 - Artist 460 - ${sortedColumn === `artist` 461 - ? html`<i class="ph-bold ${sortDirection === `desc` ? `ph-caret-down` : `ph-caret-up`}"></i>` 820 + Artist ${sortedColumn === `artist` 821 + ? html` 822 + <i class="ph-bold ${sortDirection === `desc` 823 + ? `ph-caret-down` 824 + : `ph-caret-up`}"></i> 825 + ` 462 826 : ``} 463 827 </div> 464 828 <div ··· 466 830 @click="${() => this.sortByColumn(`album`)}" 467 831 aria-sort="${ariaSort(`album`)}" 468 832 > 469 - Album 470 - ${sortedColumn === `album` 471 - ? html`<i class="ph-bold ${sortDirection === `desc` ? `ph-caret-down` : `ph-caret-up`}"></i>` 833 + Album ${sortedColumn === `album` 834 + ? html` 835 + <i class="ph-bold ${sortDirection === `desc` 836 + ? `ph-caret-down` 837 + : `ph-caret-up`}"></i> 838 + ` 472 839 : ``} 473 840 </div> 474 841 </div> ··· 476 843 <div class="scroll-panel"> 477 844 <div class="virtual-scroll" style="height: ${totalSize}px;"> 478 845 ${isLoading 479 - ? html`<div class="loading">Loading ...</div>` 846 + ? html` 847 + <div class="loading">Loading ...</div> 848 + ` 480 849 : virtualItems.map((vItem) => { 481 - const item = groups 482 - ? this.#flatItems[vItem.index] 483 - : { type: /** @type {"track"} */ ("track"), track: tracks[vItem.index] }; 850 + const item = groups ? this.#flatItems[vItem.index] : { 851 + type: /** @type {"track"} */ ("track"), 852 + track: tracks[vItem.index], 853 + }; 854 + 855 + return item?.type === "group" 856 + ? html` 857 + <div 858 + class="group-header ${vItem.index === 0 859 + ? `group-header--top` 860 + : ``}" 861 + style="transform: translateY(${vItem.start}px);" 862 + > 863 + <i class="ph-fill ph-vinyl-record"></i> 864 + <span>${item.label}</span> 865 + </div> 866 + ` 867 + : item?.type === "track" 868 + ? renderTrackRow(item.track, vItem.start, vItem.index) 869 + : ``; 870 + })} 871 + </div> 872 + </div> 873 + `; 874 + } 875 + 876 + /** 877 + * @param {Function} html 878 + */ 879 + #renderCoverDetail(html) { 880 + const item = this.#openCoverItem.value; 881 + if (!item) { 882 + return html` 883 + 884 + `; 885 + } 886 + 887 + const allTracks = this.$provider.value?.tracks() ?? []; 888 + 889 + let key, name, subtitle, detailTracks; 890 + 891 + if (item.type === "album") { 892 + key = item.albumKey; 893 + name = item.albumName; 894 + subtitle = item.artist; 895 + detailTracks = allTracks.filter((t) => { 896 + return String(t.tags?.album ?? "").toLowerCase() === key; 897 + }); 898 + } else { 899 + key = item.artistKey; 900 + name = item.artistName; 901 + subtitle = `${item.trackCount} ${ 902 + item.trackCount === 1 ? "track" : "tracks" 903 + }`; 904 + detailTracks = allTracks.filter((t) => 905 + String(t.tags?.artist ?? "").toLowerCase() === key 906 + ); 907 + } 908 + 909 + const artUrl = this.#coverArtCache.get(key); 910 + 911 + const favItems = this.$favourites.value?.playlistItems() ?? []; 912 + const favSet = new Set( 913 + favItems.map((fav) => { 914 + const a = fav.criteria.find((c) => c.field === "tags.artist"); 915 + const t = fav.criteria.find((c) => c.field === "tags.title"); 916 + return `${String(a?.value ?? "").toLowerCase()}|${ 917 + String(t?.value ?? "").toLowerCase() 918 + }`; 919 + }), 920 + ); 921 + 922 + const menuLabel = item.type === "album" ? "Play album" : "Play all"; 923 + 924 + return html` 925 + <div class="album-detail"> 926 + <div class="album-detail-actions"> 927 + <button 928 + class="toolbar-icon-btn" 929 + @click="${() => { 930 + this.#openCoverItem.value = null; 931 + }}" 932 + title="Back" 933 + > 934 + <i class="ph-bold ph-arrow-left"></i> 935 + </button> 936 + <button 937 + class="toolbar-icon-btn" 938 + popovertarget="album-actions-menu" 939 + title="More options" 940 + > 941 + <i class="ph-fill ph-dots-three-outline"></i> 942 + </button> 943 + <div id="album-actions-menu" class="dropdown" popover> 944 + <button @click="${() => { 945 + if (!detailTracks.length) return; 946 + this.$queue.value?.add({ 947 + inFront: true, 948 + trackIds: detailTracks.map((t) => t.id), 949 + }); 950 + this.$queue.value?.shift(); 951 + /** @type {HTMLElement | null} */ (this.root().querySelector( 952 + `#album-actions-menu`, 953 + ))?.hidePopover(); 954 + }}"> 955 + ${menuLabel} 956 + </button> 957 + <button @click="${() => { 958 + if (!detailTracks.length) return; 959 + this.$queue.value?.add({ 960 + trackIds: detailTracks.map((t) => t.id), 961 + }); 962 + /** @type {HTMLElement | null} */ (this.root().querySelector( 963 + `#album-actions-menu`, 964 + ))?.hidePopover(); 965 + }}"> 966 + Add to queue 967 + </button> 968 + </div> 969 + </div> 970 + <div class="album-detail-main"> 971 + <div class="album-detail-sidebar"> 972 + <div class="album-detail-art"> 973 + ${artUrl 974 + ? html` 975 + <img src="${artUrl}" alt="${name}" /> 976 + ` 977 + : artUrl === null 978 + ? html` 979 + <div class="cover-art-placeholder"><i class="ph-bold ph-music-note"></i></div> 980 + ` 981 + : html` 982 + <div class="cover-art-placeholder cover-art-loading"></div> 983 + `} 984 + </div> 985 + <div class="album-detail-info"> 986 + <span class="album-detail-name">${name}</span> 987 + <span class="album-detail-artist">${subtitle}</span> 988 + </div> 989 + </div> 990 + <div class="album-detail-tracks"> 991 + ${detailTracks.map((t, i) => { 992 + const favKey = `${String(t.tags?.artist ?? "").toLowerCase()}|${ 993 + String(t.tags?.title ?? "").toLowerCase() 994 + }`; 995 + const isFav = favSet.has(favKey); 996 + return html` 997 + <div 998 + class="album-track-row ${i % 2 === 1 999 + ? `album-track-row--alt` 1000 + : ``}" 1001 + @dblclick="${() => this.playTrack(t)}" 1002 + > 1003 + <div class="col-fav"> 1004 + <button 1005 + class="fav-btn ${isFav ? `fav-btn--active` : ``}" 1006 + @click="${(/** @type {Event} */ e) => { 1007 + e.stopPropagation(); 1008 + this.toggleFavourite(t); 1009 + }}" 1010 + title="${isFav 1011 + ? `Remove from favourites` 1012 + : `Add to favourites`}" 1013 + > 1014 + <i class="ph-${isFav 1015 + ? `fill ph-heart` 1016 + : `bold ph-heart`}"></i> 1017 + </button> 1018 + </div> 1019 + <div class="col-title"> 1020 + <span class="track-title">${t.tags?.title}</span> 1021 + </div> 1022 + <div class="col-artist"> 1023 + <span>${t.tags?.artist}</span> 1024 + </div> 1025 + </div> 1026 + `; 1027 + })} 1028 + </div> 1029 + </div> 1030 + </div> 1031 + `; 1032 + } 484 1033 485 - return item?.type === "group" 1034 + /** 1035 + * @param {RenderArg} _ 1036 + */ 1037 + /** 1038 + * @param {Function} html 1039 + * @param {string[]} sortBy 1040 + * @param {string | undefined} groupBy 1041 + */ 1042 + #renderGroupByMenu(html, sortBy, groupBy) { 1043 + return html` 1044 + <button class="toolbar-icon-btn" popovertarget="groupby-menu" title="Group by"> 1045 + <i class="ph-fill ph-stack"></i> 1046 + </button> 1047 + <div id="groupby-menu" class="dropdown" popover> 1048 + ${GROUP_BY_OPTIONS.map( 1049 + ( 1050 + { 1051 + value, 1052 + label, 1053 + sortBy: optSortBy, 1054 + sortDirection: optSortDirection, 1055 + }, 1056 + ) => { 1057 + const primarySortField = sortBy[0] ?? "tags.title"; 1058 + const resolvedValue = value === "firstLetter" 1059 + ? `firstLetter:${primarySortField}` 1060 + : value; 1061 + const isActive = value === "firstLetter" 1062 + ? groupBy?.startsWith("firstLetter:") 1063 + : groupBy === value; 1064 + return html` 1065 + <button 1066 + class="${isActive ? `dropdown-item--active` : ``}" 1067 + @click="${() => { 1068 + const scope = this.$scope.value; 1069 + if (isActive) { 1070 + this.setGroupBy(undefined); 1071 + } else { 1072 + this.setGroupBy(resolvedValue); 1073 + if (optSortBy && scope) { 1074 + scope.setSortBy(optSortBy); 1075 + scope.setSortDirection(optSortDirection); 1076 + } 1077 + } 1078 + /** @type {HTMLElement | null} */ (this.root().querySelector( 1079 + `#groupby-menu`, 1080 + ))?.hidePopover(); 1081 + }}" 1082 + > 1083 + ${isActive 486 1084 ? html` 487 - <div 488 - class="group-header ${vItem.index === 0 ? `group-header--top` : ``}" 489 - style="transform: translateY(${vItem.start}px);" 490 - > 491 - <i class="ph-fill ph-vinyl-record"></i> 492 - <span>${item.label}</span> 493 - </div> 1085 + <i class="ph-bold ph-x"></i> 494 1086 ` 495 - : item?.type === "track" 496 - ? renderTrackRow(item.track, vItem.start, vItem.index) 497 - : ``; 498 - }) 499 - } 1087 + : ``} ${label} 1088 + </button> 1089 + `; 1090 + }, 1091 + )} 1092 + </div> 1093 + `; 1094 + } 1095 + 1096 + /** 1097 + * @param {Function} html 1098 + * @param {string | undefined} playlist 1099 + */ 1100 + #renderPlaylistMenu(html, playlist) { 1101 + return html` 1102 + <button 1103 + class="browser-button browser-button--playlist ${playlist 1104 + ? `browser-button--active` 1105 + : ``}" 1106 + popovertarget="playlist-menu" 1107 + > 1108 + <i class="ph-fill ph-playlist"></i> 1109 + <span>${playlist ?? `All tracks`}</span> 1110 + </button> 1111 + <div id="playlist-menu" class="dropdown" popover> 1112 + <button @click="${() => { 1113 + this.setSelectedPlaylist(undefined); 1114 + /** @type {HTMLElement | null} */ (this.root().querySelector( 1115 + `#playlist-menu`, 1116 + ))?.hidePopover(); 1117 + }}"> 1118 + All tracks 1119 + </button> 1120 + ${this.$groupedPlaylists().map((group) => 1121 + group.playlists.map((p) => 1122 + html` 1123 + <button @click="${() => { 1124 + this.setSelectedPlaylist(p.name); 1125 + /** @type {HTMLElement | null} */ (this.root().querySelector( 1126 + `#playlist-menu`, 1127 + ))?.hidePopover(); 1128 + }}"> 1129 + ${p.name} 1130 + </button> 1131 + ` 1132 + ) 1133 + )} 1134 + </div> 1135 + `; 1136 + } 1137 + 1138 + /** 1139 + * @param {RenderArg} _ 1140 + */ 1141 + render({ html }) { 1142 + const isLoading = 1143 + this.$output.value?.tracks?.collection().state !== "loaded"; 1144 + 1145 + const playlist = this.$scope.value?.playlist(); 1146 + const groupBy = this.$scope.value?.groupBy(); 1147 + const searchTerm = this.$scope.value?.searchTerm() ?? ""; 1148 + const sortBy = this.$scope.value?.sortBy() ?? DEFAULT_SORT; 1149 + const viewMode = this.#viewMode.value; 1150 + 1151 + return html` 1152 + <link rel="stylesheet" href="styles/base.css" /> 1153 + <link rel="stylesheet" href="styles/diffuse/facet.css" /> 1154 + <link rel="stylesheet" href="vendor/@phosphor-icons/web/bold/style.css" /> 1155 + <link rel="stylesheet" href="vendor/@phosphor-icons/web/fill/style.css" /> 1156 + <link rel="stylesheet" href="themes/blur/variables.css" /> 1157 + <link rel="stylesheet" href="themes/blur/browser/element.css" /> 1158 + 1159 + <div class="toolbar"> 1160 + <label class="search-field"> 1161 + <i class="ph-bold ph-magnifying-glass"></i> 1162 + <input 1163 + id="search-input" 1164 + type="search" 1165 + placeholder="Search" 1166 + @change="${this.setSearchTerm}" 1167 + .value="${searchTerm}" 1168 + /> 1169 + </label> 1170 + 1171 + <div class="toolbar-actions"> 1172 + <!-- CLEAR SEARCH --> 1173 + ${searchTerm 1174 + ? html` 1175 + <button class="toolbar-icon-btn" @click="${this 1176 + .clearSearch}" title="Clear search"> 1177 + <i class="ph-bold ph-x"></i> 1178 + </button> 1179 + ` 1180 + : ``} 1181 + 1182 + <!-- VIEW KIND --> 1183 + <button 1184 + class="toolbar-icon-btn" 1185 + @click="${this.toggleViewMode}" 1186 + title="${viewMode === `cover` 1187 + ? `Switch to list view` 1188 + : `Switch to cover view`}" 1189 + > 1190 + <i class="${viewMode === `cover` 1191 + ? `ph-bold ph-list-heart` 1192 + : `ph-fill ph-images-square`}"></i> 1193 + </button> 1194 + 1195 + <!-- MENUS --> 1196 + ${this.#renderGroupByMenu(html, sortBy, groupBy)} ${this 1197 + .#renderPlaylistMenu(html, playlist)} 500 1198 </div> 501 1199 </div> 1200 + 1201 + ${viewMode === `cover` 1202 + ? this.#renderCoverView(html, isLoading) 1203 + : this.#renderListView(html, isLoading, sortBy)} 502 1204 `; 503 1205 } 504 1206 } ··· 508 1210 //////////////////////////////////////////// 509 1211 // HELPERS 510 1212 //////////////////////////////////////////// 1213 + 1214 + /** 1215 + * @param {Uint8Array} bytes 1216 + * @returns {string} 1217 + */ 1218 + function detectMime(bytes) { 1219 + if (bytes[0] === 0xFF && bytes[1] === 0xD8) return "image/jpeg"; 1220 + if (bytes[0] === 0x89 && bytes[1] === 0x50) return "image/png"; 1221 + if (bytes[0] === 0x47 && bytes[1] === 0x49) return "image/gif"; 1222 + if (bytes[0] === 0x52 && bytes[1] === 0x49) return "image/webp"; 1223 + return "image/jpeg"; 1224 + } 511 1225 512 1226 /** 513 1227 * @param {{ label: string; tracks: Track[] }[]} groups
+7 -3
src/themes/blur/browser/facet/index.inline.js
··· 4 4 // Set doc title 5 5 foundation.setup({ title: "Browser | Blur | Diffuse" }); 6 6 7 - const [out, que, scp, trc, fav] = await Promise.all([ 7 + const [out, que, scp, art, cov, fav, trc] = await Promise.all([ 8 8 foundation.orchestrator.output(), 9 9 foundation.engine.queue(), 10 10 foundation.engine.scope(), 11 - foundation.orchestrator.scopedTracks(), 11 + foundation.orchestrator.artwork(), 12 + foundation.orchestrator.coverGroups(), 12 13 foundation.orchestrator.favourites(), 14 + foundation.orchestrator.scopedTracks(), 13 15 ]); 14 16 15 17 // Default to grouping by date added ··· 17 19 if (!scp.groupBy()) scp.setGroupBy("createdAt"); 18 20 19 21 const el = new BrowserElement(); 22 + el.setAttribute("artwork-selector", art.selector); 23 + el.setAttribute("cover-groups-orchestrator-selector", cov.selector); 24 + el.setAttribute("favourites-orchestrator-selector", fav.selector); 20 25 el.setAttribute("output-selector", out.selector); 21 26 el.setAttribute("queue-engine-selector", que.selector); 22 27 el.setAttribute("scope-engine-selector", scp.selector); 23 28 el.setAttribute("tracks-selector", trc.selector); 24 - el.setAttribute("favourites-orchestrator-selector", fav.selector); 25 29 26 30 (document.querySelector("#container") ?? document.body).append(el); 27 31
+2
src/themes/blur/facet/index.html
··· 34 34 queue-engine-selector="de-queue" 35 35 scope-engine-selector="de-scope" 36 36 favourites-orchestrator-selector="do-favourites" 37 + artwork-selector="do-artwork" 38 + cover-groups-orchestrator-selector="do-cover-groups" 37 39 ></db-browser> 38 40 39 41 <db-artwork-controller
+1
src/themes/blur/facet/index.inline.js
··· 26 26 await foundation.orchestrator.queueAudio(); 27 27 await foundation.orchestrator.controller(); 28 28 await foundation.orchestrator.artwork(); 29 + await foundation.orchestrator.coverGroups(); 29 30 await foundation.orchestrator.favourites(); 30 31 31 32 await import("~/themes/blur/artwork-controller/element.js");
+4 -4
tests/components/engine/queue/test.ts
··· 82 82 expect(items[1].id).toBe(tracks[1].id); 83 83 }); 84 84 85 - it("clears only auto-filled items when manualOnly is true", async () => { 85 + it("clears only auto-filled items when keepManual is true", async () => { 86 86 const items = await testWeb(async () => { 87 87 const QueueEngine = await import("~/components/engine/queue/element.js"); 88 88 const engine = new QueueEngine.CLASS(); ··· 94 94 await engine.supply({ trackIds: tracks.map((t) => t.id) }); 95 95 await engine.add({ trackIds: [tracks[0].id] }); 96 96 await engine.fill({ amount: 2, shuffled: false }); 97 - await engine.clear({ manualOnly: true }); 97 + await engine.clear({ keepManual: true }); 98 98 return engine.future(); 99 99 }); 100 100 ··· 102 102 expect(items[0].manualEntry).toBe(true); 103 103 }); 104 104 105 - it("clears all items when manualOnly is false", async () => { 105 + it("clears all items when keepManual is false", async () => { 106 106 const count = await testWeb(async () => { 107 107 const QueueEngine = await import("~/components/engine/queue/element.js"); 108 108 const engine = new QueueEngine.CLASS(); ··· 112 112 const { tracks } = await import("~/testing/sample/tracks.js"); 113 113 114 114 await engine.add({ trackIds: tracks.map((t) => t.id) }); 115 - await engine.clear({ manualOnly: false }); 115 + await engine.clear({ keepManual: false }); 116 116 return (await engine.future()).length; 117 117 }); 118 118