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 theme add to playlist

+453 -29
+184
src/facets/themes/blur/browser/element.css
··· 8 8 font-size: calc(var(--fs-sm) * 0.85); 9 9 height: 100%; 10 10 overflow: hidden; 11 + position: relative; 11 12 } 12 13 13 14 /*********************************** ··· 119 120 120 121 .toolbar-icon-btn:hover { 121 122 color: currentColor; 123 + } 124 + 125 + .toolbar-icon-btn--link { 126 + text-decoration: none; 122 127 } 123 128 124 129 /*********************************** ··· 742 747 .cover-menu-btn:hover { 743 748 color: oklch(1 0 0); 744 749 } 750 + 751 + /*********************************** 752 + * Playlist picker overlay 753 + ***********************************/ 754 + 755 + .playlist-picker-overlay { 756 + background: color-mix(in oklch, var(--bg-color) 60%, transparent); 757 + backdrop-filter: blur(12px); 758 + -webkit-backdrop-filter: blur(12px); 759 + bottom: 0; 760 + display: flex; 761 + align-items: center; 762 + justify-content: center; 763 + left: 0; 764 + position: absolute; 765 + right: 0; 766 + top: 0; 767 + z-index: 100; 768 + } 769 + 770 + .playlist-picker-panel { 771 + background: var(--bg-color); 772 + border: 1px solid var(--border-color); 773 + border-radius: var(--radius-lg); 774 + box-shadow: 775 + 0 8px 32px -4px rgba(0, 0, 0, 0.18), 776 + 0 2px 8px -2px rgba(0, 0, 0, 0.12); 777 + display: flex; 778 + flex-direction: column; 779 + max-height: 70%; 780 + overflow: hidden; 781 + width: min(20rem, 90%); 782 + 783 + @media (prefers-color-scheme: dark) { 784 + box-shadow: 785 + 0 8px 32px -4px rgba(0, 0, 0, 0.5), 786 + 0 2px 8px -2px rgba(0, 0, 0, 0.4); 787 + } 788 + } 789 + 790 + .playlist-picker-header { 791 + align-items: center; 792 + border-bottom: 1px solid var(--border-color); 793 + display: flex; 794 + gap: var(--space-3xs); 795 + justify-content: space-between; 796 + padding: var(--space-xs) var(--space-xs) var(--space-xs) var(--space-xs); 797 + } 798 + 799 + .playlist-picker-title { 800 + flex: 1; 801 + font-weight: 600; 802 + text-box: trim-both cap alphabetic; 803 + } 804 + 805 + .playlist-picker-list { 806 + display: flex; 807 + flex-direction: column; 808 + overflow-y: auto; 809 + padding: var(--space-2xs) 0; 810 + } 811 + 812 + .playlist-picker-group-label { 813 + color: color-mix(in oklch, currentColor 40%, transparent); 814 + font-size: 75%; 815 + font-weight: 500; 816 + letter-spacing: var(--tracking-wider); 817 + padding: var(--space-xs) var(--space-sm) var(--space-3xs); 818 + text-box: trim-both cap alphabetic; 819 + text-transform: uppercase; 820 + } 821 + 822 + .playlist-picker-item { 823 + background: transparent; 824 + border: none; 825 + border-radius: 0; 826 + color: inherit; 827 + cursor: pointer; 828 + font-family: inherit; 829 + font-size: 100%; 830 + padding: var(--space-2xs) var(--space-sm); 831 + text-align: left; 832 + transition: background-color 80ms; 833 + } 834 + 835 + .playlist-picker-item:hover { 836 + background: color-mix(in oklch, currentColor 6%, transparent); 837 + } 838 + 839 + .playlist-picker-item--active { 840 + background: color-mix(in oklch, currentColor 8%, transparent); 841 + font-weight: 500; 842 + } 843 + 844 + .playlist-picker-item--create { 845 + align-items: center; 846 + color: color-mix(in oklch, currentColor 60%, transparent); 847 + display: flex; 848 + gap: var(--space-2xs); 849 + } 850 + 851 + .playlist-picker-item--create:hover { 852 + color: currentColor; 853 + } 854 + 855 + .playlist-picker-divider { 856 + border-top: 1px solid var(--border-color); 857 + margin: var(--space-2xs) 0; 858 + } 859 + 860 + .playlist-picker-empty { 861 + color: color-mix(in oklch, currentColor 40%, transparent); 862 + padding: var(--space-sm) var(--space-sm); 863 + } 864 + 865 + /*********************************** 866 + * Playlist picker — create form 867 + ***********************************/ 868 + 869 + .playlist-picker-create-form { 870 + display: flex; 871 + flex-direction: column; 872 + gap: var(--space-sm); 873 + padding: var(--space-sm); 874 + } 875 + 876 + .playlist-picker-create-label { 877 + display: flex; 878 + flex-direction: column; 879 + font-size: 90%; 880 + font-weight: 500; 881 + gap: var(--space-3xs); 882 + color: color-mix(in oklch, currentColor 60%, transparent); 883 + } 884 + 885 + .playlist-picker-create-label--checkbox { 886 + align-items: center; 887 + cursor: pointer; 888 + flex-direction: row; 889 + gap: var(--space-2xs); 890 + } 891 + 892 + .playlist-picker-create-input { 893 + background: color-mix(in oklch, currentColor 5%, transparent); 894 + border: 1px solid var(--border-color); 895 + border-radius: var(--radius-sm); 896 + color: var(--text-color); 897 + font-family: inherit; 898 + font-size: calc(100% / 0.85); 899 + outline: none; 900 + padding: var(--space-2xs) var(--space-xs); 901 + width: 100%; 902 + } 903 + 904 + .playlist-picker-create-input:focus { 905 + border-color: color-mix(in oklch, currentColor 30%, transparent); 906 + } 907 + 908 + .playlist-picker-create-checkbox { 909 + cursor: pointer; 910 + width: auto; 911 + } 912 + 913 + .playlist-picker-create-submit { 914 + background: color-mix(in oklch, currentColor 10%, transparent); 915 + border: 1px solid var(--border-color); 916 + border-radius: var(--radius-sm); 917 + color: inherit; 918 + cursor: pointer; 919 + font-family: inherit; 920 + font-size: 100%; 921 + font-weight: 500; 922 + padding: var(--space-2xs) var(--space-sm); 923 + transition: background-color 80ms; 924 + } 925 + 926 + .playlist-picker-create-submit:hover { 927 + background: color-mix(in oklch, currentColor 15%, transparent); 928 + }
+266 -26
src/facets/themes/blur/browser/element.js
··· 5 5 Virtualizer, 6 6 } from "@tanstack/virtual-core"; 7 7 8 + import * as TID from "@atcute/tid"; 9 + 8 10 import { 9 11 defineElement, 10 12 DiffuseElement, ··· 122 124 #coverViewMode = signal(/** @type {"albums" | "artists"} */ ("albums")); 123 125 124 126 #openCoverItem = signal(/** @type {OpenCoverItem | null} */ (null)); 127 + 128 + #playlistPickerState = signal( 129 + /** @type {{ mode: "filter" } | { mode: "add"; tracks: Track[] } | { mode: "create"; tracks: Track[] } | null} */ (null), 130 + ); 125 131 126 132 // SIGNALS - Pt. 3 127 133 ··· 385 391 * @param {string | undefined} value 386 392 */ 387 393 setSelectedPlaylist = (value) => { 394 + this.#disconnectCoverObserver(); 388 395 this.$scope.value?.setPlaylist(value); 389 396 }; 390 397 ··· 418 425 }; 419 426 420 427 /** 428 + * @param {string} playlistName 429 + * @param {Track[]} tracks 430 + */ 431 + addTracksToPlaylist = async (playlistName, tracks) => { 432 + const output = this.$output.value; 433 + if (!output || !tracks.length) return; 434 + 435 + const col = output.playlistItems.collection(); 436 + const existing = col.state === "loaded" ? col.data : []; 437 + 438 + const existingKeys = new Set( 439 + existing 440 + .filter((item) => item.playlist === playlistName) 441 + .map((item) => { 442 + const a = item.criteria.find((c) => c.field === "tags.artist")?.value ?? ""; 443 + const t = item.criteria.find((c) => c.field === "tags.title")?.value ?? ""; 444 + return `${String(a).toLowerCase()}.${String(t).toLowerCase()}`; 445 + }), 446 + ); 447 + 448 + const transformations = /** @type {string[]} */ (["toLowerCase"]); 449 + const now = new Date().toISOString(); 450 + 451 + const newItems = tracks 452 + .filter((track) => { 453 + const key = `${String(track.tags?.artist ?? "").toLowerCase()}.${String(track.tags?.title ?? "").toLowerCase()}`; 454 + return !existingKeys.has(key); 455 + }) 456 + .map((track) => /** @type {import("~/definitions/types.d.ts").PlaylistItem} */ ({ 457 + $type: "sh.diffuse.output.playlistItem", 458 + id: TID.now(), 459 + playlist: playlistName, 460 + criteria: [ 461 + { field: "tags.artist", value: /** @type {unknown} */ (track.tags?.artist), transformations }, 462 + { field: "tags.title", value: /** @type {unknown} */ (track.tags?.title), transformations }, 463 + ], 464 + createdAt: now, 465 + updatedAt: now, 466 + })); 467 + 468 + if (!newItems.length) return; 469 + await output.playlistItems.save([...existing, ...newItems]); 470 + }; 471 + 472 + /** 473 + * @param {string} playlistName 474 + * @param {Track[]} tracks 475 + * @param {boolean} ordered 476 + */ 477 + createPlaylistWithTracks = async (playlistName, tracks, ordered) => { 478 + const output = this.$output.value; 479 + if (!output || !tracks.length) return; 480 + 481 + const col = output.playlistItems.collection(); 482 + const existing = col.state === "loaded" ? col.data : []; 483 + 484 + const transformations = /** @type {string[]} */ (["toLowerCase"]); 485 + const now = new Date().toISOString(); 486 + 487 + /** @type {import("~/definitions/types.d.ts").PlaylistItem[]} */ 488 + const newItems = []; 489 + let prevId = /** @type {string | undefined} */ (undefined); 490 + 491 + for (const track of tracks) { 492 + const id = TID.now(); 493 + newItems.push(/** @type {import("~/definitions/types.d.ts").PlaylistItem} */ ({ 494 + $type: "sh.diffuse.output.playlistItem", 495 + id, 496 + playlist: playlistName, 497 + criteria: [ 498 + { field: "tags.artist", value: /** @type {unknown} */ (track.tags?.artist), transformations }, 499 + { field: "tags.title", value: /** @type {unknown} */ (track.tags?.title), transformations }, 500 + ], 501 + ...(ordered ? { positionedAfter: prevId } : {}), 502 + createdAt: now, 503 + updatedAt: now, 504 + })); 505 + if (ordered) prevId = id; 506 + } 507 + 508 + await output.playlistItems.save([...existing, ...newItems]); 509 + }; 510 + 511 + /** 421 512 * @param {Function} html 422 513 * @param {string} menuId 423 514 * @param {Track[]} tracks ··· 426 517 const uris = tracks.map((t) => t.uri); 427 518 const allCached = uris.length > 0 && uris.every((u) => this.#cachedUris.value.has(u)); 428 519 return html` 429 - <div id="${menuId}" class="dropdown" popover> 520 + <div id="${menuId}" class="dropdown" popover @click="${(/** @type {MouseEvent} */ e) => e.stopPropagation()}"> 430 521 <button @click="${() => { 431 522 if (!tracks.length) return; 432 523 this.$queue.value?.add({ inFront: true, trackIds: tracks.map((t) => t.id) }); ··· 442 533 }}"> 443 534 <i class="ph-bold ph-clock-counter-clockwise"></i> 444 535 Add to queue 536 + </button> 537 + <button @click="${() => { 538 + if (!tracks.length) return; 539 + /** @type {HTMLElement | null} */ (this.root().querySelector(`#${menuId}`))?.hidePopover(); 540 + this.#playlistPickerState.value = { mode: "add", tracks }; 541 + }}"> 542 + <i class="ph-bold ph-playlist"></i> 543 + Add to playlist 445 544 </button> 446 545 <button @click="${async () => { 447 546 if (!uris.length) return; ··· 630 729 631 730 /** 632 731 * @param {Function} html 732 + */ 733 + #renderPlaylistPicker(html) { 734 + const state = this.#playlistPickerState.value; 735 + if (!state) return html``; 736 + 737 + const isCreate = state.mode === "create"; 738 + const title = state.mode === "filter" 739 + ? "Filter by playlist" 740 + : isCreate 741 + ? "New playlist" 742 + : "Add to playlist"; 743 + 744 + const groups = this.$groupedPlaylists(); 745 + 746 + const headerBackBtn = isCreate 747 + ? html` 748 + <button 749 + class="toolbar-icon-btn" 750 + @click="${() => { 751 + this.#playlistPickerState.value = { mode: "add", tracks: /** @type {any} */ (state).tracks }; 752 + }}" 753 + title="Back" 754 + > 755 + <i class="ph-bold ph-arrow-left"></i> 756 + </button> 757 + ` 758 + : ``; 759 + 760 + const closeBtn = html` 761 + <a 762 + class="toolbar-icon-btn toolbar-icon-btn--link" 763 + href="l/?path=facets%2Fdata%2Fplaylists%2Findex.html" 764 + target="_blank" 765 + title="Open playlist manager" 766 + > 767 + <i class="ph-bold ph-gear"></i> 768 + </a> 769 + <button 770 + class="toolbar-icon-btn" 771 + @click="${() => { this.#playlistPickerState.value = null; }}" 772 + title="Close" 773 + > 774 + <i class="ph-bold ph-x"></i> 775 + </button> 776 + `; 777 + 778 + const body = isCreate 779 + ? html` 780 + <form 781 + class="playlist-picker-create-form" 782 + @submit="${async (/** @type {SubmitEvent} */ e) => { 783 + e.preventDefault(); 784 + const form = /** @type {HTMLFormElement} */ (e.currentTarget); 785 + const name = /** @type {HTMLInputElement} */ (form.elements.namedItem(`playlist-name`))?.value.trim(); 786 + const ordered = /** @type {HTMLInputElement} */ (form.elements.namedItem(`playlist-ordered`))?.checked ?? false; 787 + if (!name) return; 788 + await this.createPlaylistWithTracks(name, /** @type {any} */ (state).tracks, ordered); 789 + this.#playlistPickerState.value = null; 790 + }}" 791 + > 792 + <label class="playlist-picker-create-label"> 793 + Name 794 + <input 795 + class="playlist-picker-create-input" 796 + name="playlist-name" 797 + type="text" 798 + placeholder="My playlist" 799 + autocomplete="off" 800 + autofocus 801 + required 802 + /> 803 + </label> 804 + <label class="playlist-picker-create-label playlist-picker-create-label--checkbox"> 805 + <input 806 + class="playlist-picker-create-checkbox" 807 + name="playlist-ordered" 808 + type="checkbox" 809 + /> 810 + Ordered 811 + </label> 812 + <button class="playlist-picker-create-submit" type="submit"> 813 + Create playlist 814 + </button> 815 + </form> 816 + ` 817 + : html` 818 + <div class="playlist-picker-list"> 819 + ${state.mode === "filter" 820 + ? html` 821 + <button 822 + class="playlist-picker-item ${!this.$scope.value?.playlist() 823 + ? `playlist-picker-item--active` 824 + : ``}" 825 + @click="${() => { 826 + this.setSelectedPlaylist(undefined); 827 + this.#playlistPickerState.value = null; 828 + }}" 829 + > 830 + All tracks 831 + </button> 832 + ` 833 + : ``} 834 + ${state.mode === "add" 835 + ? html` 836 + <button 837 + class="playlist-picker-item playlist-picker-item--create" 838 + @click="${() => { 839 + this.#playlistPickerState.value = { mode: "create", tracks: state.tracks }; 840 + }}" 841 + > 842 + <i class="ph-bold ph-plus"></i> 843 + Create new playlist 844 + </button> 845 + ${groups.length > 0 ? html`<div class="playlist-picker-divider"></div>` : ``} 846 + ` 847 + : ``} 848 + ${groups.length === 0 849 + ? (state.mode === "add" ? `` : html`<div class="playlist-picker-empty">No playlists yet</div>`) 850 + : groups.map(({ label, playlists }) => html` 851 + <div class="playlist-picker-group-label">${label}</div> 852 + ${playlists.map((p) => html` 853 + <button 854 + class="playlist-picker-item ${state.mode === `filter` && 855 + this.$scope.value?.playlist() === p.name 856 + ? `playlist-picker-item--active` 857 + : ``}" 858 + @click="${async () => { 859 + if (state.mode === "add") { 860 + await this.addTracksToPlaylist(p.name, state.tracks); 861 + } else { 862 + this.setSelectedPlaylist(p.name); 863 + } 864 + this.#playlistPickerState.value = null; 865 + }}" 866 + > 867 + ${p.name} 868 + </button> 869 + `)} 870 + `)} 871 + </div> 872 + `; 873 + 874 + return html` 875 + <div 876 + class="playlist-picker-overlay" 877 + @click="${(/** @type {MouseEvent} */ e) => { 878 + if (e.target === e.currentTarget) this.#playlistPickerState.value = null; 879 + }}" 880 + > 881 + <div class="playlist-picker-panel"> 882 + <div class="playlist-picker-header"> 883 + ${headerBackBtn} 884 + <span class="playlist-picker-title">${title}</span> 885 + ${closeBtn} 886 + </div> 887 + ${body} 888 + </div> 889 + </div> 890 + `; 891 + } 892 + 893 + /** 894 + * @param {Function} html 633 895 * @param {boolean} isLoading 634 896 */ 635 897 #renderCoverView(html, isLoading) { ··· 1215 1477 class="browser-button browser-button--playlist ${playlist 1216 1478 ? `browser-button--active` 1217 1479 : ``}" 1218 - popovertarget="playlist-menu" 1480 + @click="${() => { this.#playlistPickerState.value = { mode: "filter" }; }}" 1219 1481 > 1220 1482 <i class="ph-fill ph-playlist"></i> 1221 1483 <span>${playlist ?? `All tracks`}</span> 1222 1484 </button> 1223 - <div id="playlist-menu" class="dropdown" popover> 1224 - <button @click="${() => { 1225 - this.setSelectedPlaylist(undefined); 1226 - /** @type {HTMLElement | null} */ (this.root().querySelector( 1227 - `#playlist-menu`, 1228 - ))?.hidePopover(); 1229 - }}"> 1230 - All tracks 1231 - </button> 1232 - ${this.$groupedPlaylists().map((group) => 1233 - group.playlists.map((p) => 1234 - html` 1235 - <button @click="${() => { 1236 - this.setSelectedPlaylist(p.name); 1237 - /** @type {HTMLElement | null} */ (this.root().querySelector( 1238 - `#playlist-menu`, 1239 - ))?.hidePopover(); 1240 - }}"> 1241 - ${p.name} 1242 - </button> 1243 - ` 1244 - ) 1245 - )} 1246 - </div> 1247 1485 `; 1248 1486 } 1249 1487 ··· 1313 1551 ${viewMode === `cover` 1314 1552 ? this.#renderCoverView(html, isLoading) 1315 1553 : this.#renderListView(html, isLoading, sortBy)} 1554 + 1555 + ${this.#renderPlaylistPicker(html)} 1316 1556 `; 1317 1557 } 1318 1558 }
+3 -3
src/facets/themes/blur/facet/index.css
··· 1 - :root { 1 + /*:root { 2 2 --facet-bg-color: #140c04; 3 - } 3 + }*/ 4 4 5 5 body { 6 - background-color: var(--facet-bg-color); 6 + /*background-color: var(--facet-bg-color);*/ 7 7 color: var(--text-color); 8 8 display: flex; 9 9 flex-direction: column;