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.

feat: blur track menu

+232 -104
+76
src/facets/themes/blur/browser/element.css
··· 178 178 margin-right: 0; 179 179 } 180 180 181 + .table-header .col-menu { 182 + border-left: none; 183 + cursor: default; 184 + margin-right: 0; 185 + padding: 0; 186 + } 187 + 181 188 /*********************************** 182 189 * Column widths 183 190 ***********************************/ ··· 457 464 background: color-mix(in oklch, currentColor 6%, transparent); 458 465 border-radius: var(--radius-md); 459 466 overflow: hidden; 467 + position: relative; 460 468 width: 100%; 461 469 } 462 470 ··· 666 674 display: none; 667 675 } 668 676 } 677 + 678 + /*********************************** 679 + * Track menu button 680 + ***********************************/ 681 + 682 + .col-menu { 683 + align-items: center; 684 + display: flex; 685 + flex-shrink: 0; 686 + justify-content: center; 687 + overflow: visible !important; 688 + width: var(--space-md); 689 + } 690 + 691 + .track-row .col-menu, 692 + .album-track-row .col-menu { 693 + padding-left: var(--space-2xs); 694 + } 695 + 696 + .track-menu-btn { 697 + background: none; 698 + border: none; 699 + color: color-mix(in oklch, currentColor 20%, transparent); 700 + cursor: pointer; 701 + font-size: 90%; 702 + line-height: 0; 703 + margin: calc(-1 * var(--space-2xs)); 704 + opacity: 0; 705 + padding: var(--space-2xs); 706 + transition: opacity 80ms; 707 + } 708 + 709 + .track-row:hover .track-menu-btn, 710 + .album-track-row:hover .track-menu-btn { 711 + opacity: 1; 712 + } 713 + 714 + .track-menu-btn:hover { 715 + color: color-mix(in oklch, currentColor 70%, transparent); 716 + } 717 + 718 + /*********************************** 719 + * Cover card menu button 720 + ***********************************/ 721 + 722 + .cover-menu-btn { 723 + background: oklch(0 0 0 / 0.5); 724 + border: none; 725 + border-radius: var(--radius-sm); 726 + bottom: var(--space-2xs); 727 + color: oklch(1 0 0 / 0.85); 728 + cursor: pointer; 729 + font-size: 90%; 730 + line-height: 0; 731 + opacity: 0; 732 + padding: var(--space-2xs); 733 + position: absolute; 734 + right: var(--space-2xs); 735 + transition: opacity 80ms; 736 + } 737 + 738 + .cover-card:hover .cover-menu-btn { 739 + opacity: 1; 740 + } 741 + 742 + .cover-menu-btn:hover { 743 + color: oklch(1 0 0); 744 + }
+156 -104
src/facets/themes/blur/browser/element.js
··· 141 141 return map; 142 142 }); 143 143 144 + $tracksByAlbum = computed(() => { 145 + /** @type {Map<string, Track[]>} */ 146 + const map = new Map(); 147 + for (const t of this.$provider.value?.tracks() ?? []) { 148 + const key = String(t.tags?.album ?? "").toLowerCase(); 149 + if (!map.has(key)) map.set(key, []); 150 + map.get(key)?.push(t); 151 + } 152 + return map; 153 + }); 154 + 155 + $tracksByArtist = computed(() => { 156 + /** @type {Map<string, Track[]>} */ 157 + const map = new Map(); 158 + for (const t of this.$provider.value?.tracks() ?? []) { 159 + const key = String(t.tags?.artist ?? "").toLowerCase(); 160 + if (!map.has(key)) map.set(key, []); 161 + map.get(key)?.push(t); 162 + } 163 + return map; 164 + }); 165 + 166 + $favouritesSet = computed(() => { 167 + const favItems = this.$favourites.value?.playlistItems() ?? []; 168 + return new Set( 169 + favItems.map((item) => { 170 + const a = item.criteria.find((c) => c.field === "tags.artist"); 171 + const t = item.criteria.find((c) => c.field === "tags.title"); 172 + return `${String(a?.value ?? "").toLowerCase()}|${ 173 + String(t?.value ?? "").toLowerCase() 174 + }`; 175 + }), 176 + ); 177 + }); 178 + 179 + $sortedArtistGroups = computed(() => { 180 + const groups = this.$coverGroups.value?.artistGroups() ?? []; 181 + return this.$scope.value?.sortDirection() === "desc" 182 + ? groups.map((g) => ({ ...g, groups: [...g.groups].reverse() })) 183 + : groups; 184 + }); 185 + 186 + $sortedCoverGroups = computed(() => { 187 + const groups = this.$coverGroups.value?.coverGroups() ?? []; 188 + return this.$scope.value?.sortDirection() === "desc" 189 + ? groups.map((g) => ({ ...g, groups: [...g.groups].reverse() })) 190 + : groups; 191 + }); 192 + 193 + $detailTracks = computed(() => { 194 + const item = this.#openCoverItem.value; 195 + if (!item) return /** @type {Track[]} */ ([]); 196 + if (item.type === "album") { 197 + return this.$tracksByAlbum().get(item.albumKey) ?? []; 198 + } 199 + return this.$tracksByArtist().get(item.artistKey) ?? []; 200 + }); 201 + 144 202 $groupedPlaylists = computed(() => { 145 203 const col = this.$output.value?.playlistItems.collection(); 146 204 if (!col || col.state !== "loaded" || !col.data.length) return []; ··· 359 417 this.$favourites.value?.toggle(track); 360 418 }; 361 419 420 + /** 421 + * @param {Function} html 422 + * @param {string} menuId 423 + * @param {Track[]} tracks 424 + */ 425 + #renderTrackMenu(html, menuId, tracks) { 426 + const uris = tracks.map((t) => t.uri); 427 + const allCached = uris.length > 0 && uris.every((u) => this.#cachedUris.value.has(u)); 428 + return html` 429 + <div id="${menuId}" class="dropdown" popover> 430 + <button @click="${() => { 431 + if (!tracks.length) return; 432 + this.$queue.value?.add({ inFront: true, trackIds: tracks.map((t) => t.id) }); 433 + /** @type {HTMLElement | null} */ (this.root().querySelector(`#${menuId}`))?.hidePopover(); 434 + }}"> 435 + <i class="ph-bold ph-clock-counter-clockwise"></i> 436 + Play next 437 + </button> 438 + <button @click="${() => { 439 + if (!tracks.length) return; 440 + this.$queue.value?.add({ trackIds: tracks.map((t) => t.id) }); 441 + /** @type {HTMLElement | null} */ (this.root().querySelector(`#${menuId}`))?.hidePopover(); 442 + }}"> 443 + <i class="ph-bold ph-clock-counter-clockwise"></i> 444 + Add to queue 445 + </button> 446 + <button @click="${async () => { 447 + if (!uris.length) return; 448 + const isCached = uris.every((u) => this.#cachedUris.value.has(u)); 449 + if (isCached) { 450 + await this.$input.value?.removeFromCache(uris); 451 + } else { 452 + await this.$input.value?.cache(uris); 453 + } 454 + const updated = await this.$input.value?.listCached(); 455 + if (updated) this.#cachedUris.value = new Set(updated); 456 + /** @type {HTMLElement | null} */ (this.root().querySelector(`#${menuId}`))?.hidePopover(); 457 + }}"> 458 + <i class="ph-fill ph-lightning"></i> 459 + ${allCached ? `Remove from cache` : `Store in cache`} 460 + </button> 461 + </div> 462 + `; 463 + } 464 + 362 465 toggleViewMode = () => { 363 466 if (this.#viewMode.value === "cover") { 364 467 this.#disconnectCoverObserver(); ··· 535 638 const coverViewMode = this.#coverViewMode.value; 536 639 const sortDirection = this.$scope.value?.sortDirection() ?? "asc"; 537 640 538 - const totalCount = coverViewMode === "artists" 539 - ? (this.$coverGroups.value?.artistGroups() ?? []).reduce( 540 - (n, g) => n + g.groups.length, 541 - 0, 542 - ) 543 - : (this.$coverGroups.value?.coverGroups() ?? []).reduce( 544 - (n, g) => n + g.groups.length, 545 - 0, 546 - ); 641 + const groups = coverViewMode === "artists" 642 + ? this.$sortedArtistGroups() 643 + : this.$sortedCoverGroups(); 644 + const totalCount = groups.reduce((n, g) => n + g.groups.length, 0); 547 645 548 646 const countLabel = coverViewMode === "artists" 549 647 ? `${totalCount} ${totalCount === 1 ? "artist" : "artists"}` ··· 601 699 `; 602 700 } 603 701 604 - if (coverViewMode === "artists") { 605 - const rawArtistGroups = this.$coverGroups.value?.artistGroups() ?? []; 606 - const artistGroups = sortDirection === "desc" 607 - ? rawArtistGroups.map((g) => ({ 608 - ...g, 609 - groups: [...g.groups].reverse(), 610 - })) 611 - : rawArtistGroups; 702 + const albumTracksMap = this.$tracksByAlbum(); 703 + const artistTracksMap = this.$tracksByArtist(); 612 704 705 + if (coverViewMode === "artists") { 613 706 requestAnimationFrame(() => this.#setupCoverObserver()); 614 707 615 708 return html` 616 709 ${tabs} 617 710 <div class="scroll-panel cover-scroll-panel"> 618 - ${artistGroups.map(({ label, groups }, groupIndex) => 711 + ${this.$sortedArtistGroups().map(({ label, groups }, groupIndex) => 619 712 html` 620 713 ${label 621 714 ? html` ··· 630 723 <div class="cover-grid"> 631 724 ${groups.map(({ artistKey, artistName, trackCount, track }) => { 632 725 const artUrl = this.#coverArtCache.get(artistKey); 726 + const cardTracks = artistTracksMap.get(artistKey) ?? []; 633 727 return html` 634 728 <div 635 729 class="cover-card" ··· 659 753 : html` 660 754 <div class="cover-art-placeholder"><i class="ph-fill ph-vinyl-record"></i></div> 661 755 `} 756 + <button 757 + class="cover-menu-btn" 758 + popovertarget="artist-card-menu-${track.id}" 759 + @click="${(/** @type {Event} */ e) => e.stopPropagation()}" 760 + > 761 + <i class="ph-fill ph-dots-three-outline"></i> 762 + </button> 763 + ${this.#renderTrackMenu(html, `artist-card-menu-${track.id}`, cardTracks)} 662 764 </div> 663 765 <div class="cover-info"> 664 766 <span class="cover-album">${artistName}</span> ··· 678 780 } 679 781 680 782 // Albums mode 681 - const rawCoverGroups = this.$coverGroups.value?.coverGroups() ?? []; 682 - const coverGroups = sortDirection === "desc" 683 - ? rawCoverGroups.map((g) => ({ ...g, groups: [...g.groups].reverse() })) 684 - : rawCoverGroups; 685 - 686 783 requestAnimationFrame(() => this.#setupCoverObserver()); 687 784 688 785 return html` 689 786 ${tabs} 690 787 <div class="scroll-panel cover-scroll-panel"> 691 - ${coverGroups.map(({ label, groups }, groupIndex) => 788 + ${this.$sortedCoverGroups().map(({ label, groups }, groupIndex) => 692 789 html` 693 790 ${label 694 791 ? html` ··· 703 800 <div class="cover-grid"> 704 801 ${groups.map(({ albumKey, albumName, artist, track }) => { 705 802 const artUrl = this.#coverArtCache.get(albumKey); 803 + const cardTracks = albumTracksMap.get(albumKey) ?? []; 706 804 return html` 707 805 <div 708 806 class="cover-card" ··· 732 830 : html` 733 831 <div class="cover-art-placeholder"><i class="ph-fill ph-vinyl-record"></i></div> 734 832 `} 833 + <button 834 + class="cover-menu-btn" 835 + popovertarget="album-card-menu-${track.id}" 836 + @click="${(/** @type {Event} */ e) => e.stopPropagation()}" 837 + > 838 + <i class="ph-fill ph-dots-three-outline"></i> 839 + </button> 840 + ${this.#renderTrackMenu(html, `album-card-menu-${track.id}`, cardTracks)} 735 841 </div> 736 842 <div class="cover-info"> 737 843 <span class="cover-album">${albumName}</span> ··· 789 895 const virtualItems = this.#virtualizer?.getVirtualItems() ?? []; 790 896 const totalSize = this.#virtualizer?.getTotalSize() ?? 0; 791 897 792 - // Build O(1) favourite lookup — one subscription regardless of visible row count 793 - const favItems = this.$favourites.value?.playlistItems() ?? []; 794 - const favSet = new Set( 795 - favItems.map((item) => { 796 - const a = item.criteria.find((c) => c.field === "tags.artist"); 797 - const t = item.criteria.find((c) => c.field === "tags.title"); 798 - return `${String(a?.value ?? "").toLowerCase()}|${ 799 - String(t?.value ?? "").toLowerCase() 800 - }`; 801 - }), 802 - ); 898 + const favSet = this.$favouritesSet(); 803 899 804 900 /** 805 901 * @param {Track} track ··· 840 936 <div class="col-album"> 841 937 <span>${track.tags?.album}</span> 842 938 </div> 939 + <div class="col-menu"> 940 + <button 941 + class="track-menu-btn" 942 + popovertarget="list-track-menu-${track.id}" 943 + @click="${(/** @type {Event} */ e) => e.stopPropagation()}" 944 + > 945 + <i class="ph-bold ph-dots-three-outline"></i> 946 + </button> 947 + ${this.#renderTrackMenu(html, `list-track-menu-${track.id}`, [track])} 948 + </div> 843 949 </div> 844 950 `; 845 951 }; ··· 886 992 ` 887 993 : ``} 888 994 </div> 995 + <div class="col-menu"></div> 889 996 </div> 890 997 891 998 <div class="scroll-panel"> ··· 932 1039 `; 933 1040 } 934 1041 935 - const allTracks = this.$provider.value?.tracks() ?? []; 1042 + const detailTracks = this.$detailTracks(); 1043 + const favSet = this.$favouritesSet(); 936 1044 937 1045 let key = "", 938 1046 name = "", 939 - subtitle = "", 940 - detailTracks = /** @type {Track[]} */ ([]); 1047 + subtitle = ""; 941 1048 942 1049 if (item.type === "album") { 943 1050 key = item.albumKey; 944 1051 name = item.albumName; 945 1052 subtitle = item.artist; 946 - detailTracks = allTracks.filter((t) => { 947 - return String(t.tags?.album ?? "").toLowerCase() === key; 948 - }); 949 1053 } else { 950 1054 key = item.artistKey; 951 1055 name = item.artistName; 952 1056 subtitle = `${item.trackCount} ${ 953 1057 item.trackCount === 1 ? "track" : "tracks" 954 1058 }`; 955 - detailTracks = allTracks.filter((t) => 956 - String(t.tags?.artist ?? "").toLowerCase() === key 957 - ); 958 1059 } 959 1060 960 1061 const artUrl = this.#coverArtCache.get(key); 961 1062 962 - const favItems = this.$favourites.value?.playlistItems() ?? []; 963 - const favSet = new Set( 964 - favItems.map((fav) => { 965 - const a = fav.criteria.find((c) => c.field === "tags.artist"); 966 - const t = fav.criteria.find((c) => c.field === "tags.title"); 967 - return `${String(a?.value ?? "").toLowerCase()}|${ 968 - String(t?.value ?? "").toLowerCase() 969 - }`; 970 - }), 971 - ); 972 - 973 1063 return html` 974 1064 <div class="album-detail"> 975 1065 <div class="album-detail-actions"> ··· 989 1079 > 990 1080 <i class="ph-fill ph-dots-three-outline"></i> 991 1081 </button> 992 - <div id="album-actions-menu" class="dropdown" popover> 993 - <button @click="${() => { 994 - if (!detailTracks.length) return; 995 - this.$queue.value?.add({ 996 - inFront: true, 997 - trackIds: detailTracks.map((t) => t.id), 998 - }); 999 - /** @type {HTMLElement | null} */ (this.root().querySelector( 1000 - `#album-actions-menu`, 1001 - ))?.hidePopover(); 1002 - }}"> 1003 - <i class="ph-bold ph-clock-counter-clockwise"></i> 1004 - Play next 1005 - </button> 1006 - <button @click="${() => { 1007 - if (!detailTracks.length) return; 1008 - this.$queue.value?.add({ 1009 - trackIds: detailTracks.map((t) => t.id), 1010 - }); 1011 - /** @type {HTMLElement | null} */ (this.root().querySelector( 1012 - `#album-actions-menu`, 1013 - ))?.hidePopover(); 1014 - }}"> 1015 - <i class="ph-bold ph-clock-counter-clockwise"></i> 1016 - Add to queue 1017 - </button> 1018 - ${(() => { 1019 - const uris = detailTracks.map((t) => t.uri); 1020 - const allCached = uris.length > 0 && uris.every((u) => this.#cachedUris.value.has(u)); 1021 - return html` 1022 - <button @click="${async () => { 1023 - if (!uris.length) return; 1024 - if (allCached) { 1025 - await this.$input.value?.removeFromCache(uris); 1026 - } else { 1027 - await this.$input.value?.cache(uris); 1028 - } 1029 - const updated = await this.$input.value?.listCached(); 1030 - if (updated) this.#cachedUris.value = new Set(updated); 1031 - /** @type {HTMLElement | null} */ (this.root().querySelector( 1032 - `#album-actions-menu`, 1033 - ))?.hidePopover(); 1034 - }}"> 1035 - <i class="ph-fill ph-lightning"></i> 1036 - ${allCached ? `Remove from cache` : `Store in cache`} 1037 - </button> 1038 - `; 1039 - })()} 1040 - </div> 1082 + ${this.#renderTrackMenu(html, `album-actions-menu`, detailTracks)} 1041 1083 </div> 1042 1084 <div class="album-detail-main"> 1043 1085 <div class="album-detail-sidebar"> ··· 1092 1134 </div> 1093 1135 <div class="col-artist"> 1094 1136 <span>${t.tags?.artist}</span> 1137 + </div> 1138 + <div class="col-menu"> 1139 + <button 1140 + class="track-menu-btn" 1141 + popovertarget="detail-track-menu-${t.id}" 1142 + @click="${(/** @type {Event} */ e) => e.stopPropagation()}" 1143 + > 1144 + <i class="ph-bold ph-dots-three-outline"></i> 1145 + </button> 1146 + ${this.#renderTrackMenu(html, `detail-track-menu-${t.id}`, [t])} 1095 1147 </div> 1096 1148 </div> 1097 1149 `;