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(theme/winamp): browser selected track actions + remember window positions

+518 -8
+31 -5
src/components/engine/queue/worker.js
··· 29 29 /** 30 30 * @type {Actions['add']} 31 31 * 32 - * @example Appends tracks to the back of the queue 32 + * @example Adds tracks after the last manual entry, before any auto-filled items 33 33 * ```js 34 34 * import { add, $future } from "~/components/engine/queue/worker.js"; 35 35 * ··· 39 39 * if ($future.value[0].id !== "a") throw new Error("wrong first item"); 40 40 * if ($future.value[1].id !== "b") throw new Error("wrong second item"); 41 41 * if (!$future.value[0].manualEntry) throw new Error("items should be manualEntry: true"); 42 + * ``` 43 + * 44 + * @example Inserts before auto-filled items when they are present 45 + * ```js 46 + * import { add, $future } from "~/components/engine/queue/worker.js"; 47 + * 48 + * $future.value = [ 49 + * { id: "manual", manualEntry: true }, 50 + * { id: "auto", manualEntry: false }, 51 + * ]; 52 + * 53 + * add({ trackIds: ["new"] }); 54 + * 55 + * if ($future.value[0].id !== "manual") throw new Error("expected 'manual' first"); 56 + * if ($future.value[1].id !== "new") throw new Error("expected 'new' second"); 57 + * if ($future.value[2].id !== "auto") throw new Error("expected 'auto' last"); 42 58 * ``` 43 59 * 44 60 * @example Prepends tracks to the front with inFront: true 45 61 * ```js 46 62 * import { add, $future } from "~/components/engine/queue/worker.js"; 47 63 * 48 - * add({ inFront: false, trackIds: ["c"] }); 64 + * add({ trackIds: ["c"] }); 49 65 * add({ inFront: true, trackIds: ["a", "b"] }); 50 66 * 51 67 * if ($future.value[0].id !== "a") throw new Error("expected 'a' first"); ··· 58 74 return { id, manualEntry: true }; 59 75 }); 60 76 61 - $future.value = inFront 62 - ? [...items, ...$future.value] 63 - : [...$future.value, ...items]; 77 + if (inFront) { 78 + $future.value = [...items, ...$future.value]; 79 + } else { 80 + let lastManualIdx = -1; 81 + for (let i = 0; i < $future.value.length; i++) { 82 + if ($future.value[i].manualEntry) lastManualIdx = i; 83 + } 84 + $future.value = [ 85 + ...$future.value.slice(0, lastManualIdx + 1), 86 + ...items, 87 + ...$future.value.slice(lastManualIdx + 1), 88 + ]; 89 + } 64 90 } 65 91 66 92 /**
+2 -2
src/facets/themes/blur/browser/element.js
··· 115 115 116 116 #viewMode = signal( 117 117 /** @type {"list" | "cover"} */ ( 118 - localStorage.getItem("diffuse:browser:view-mode") === "list" 118 + localStorage.getItem("diffuse/browser/view-mode") === "list" 119 119 ? "list" 120 120 : "cover" 121 121 ), ··· 570 570 requestAnimationFrame(() => this.#setupVirtualizer()); 571 571 } 572 572 const next = this.#viewMode.value === "list" ? "cover" : "list"; 573 - localStorage.setItem("diffuse:browser:view-mode", next); 573 + localStorage.setItem("diffuse/browser/view-mode", next); 574 574 this.#viewMode.value = next; 575 575 }; 576 576
+421
src/facets/themes/winamp/browser/element.js
··· 1 + import * as TID from "@atcute/tid"; 2 + 1 3 import { 2 4 defineElement, 3 5 DiffuseElement, 4 6 query, 7 + queryOptional, 5 8 whenElementsDefined, 6 9 } from "~/common/element.js"; 7 10 import { computed, signal, untracked } from "~/common/signal.js"; ··· 50 53 51 54 $highlightedTrack = signal(/** @type {string | null} */ (null)); 52 55 56 + $input = signal( 57 + /** @type {import("~/components/configurator/input/element.js").CLASS | undefined} */ (undefined), 58 + ); 59 + 60 + #cachedUris = signal(/** @type {Set<string>} */ (new Set())); 61 + 62 + #playlistPickerState = signal( 63 + /** @type {{ mode: "add"; tracks: Track[] } | { mode: "create"; tracks: Track[] } | null} */ (null), 64 + ); 65 + 53 66 $groupedPlaylists = computed(() => { 54 67 const col = this.$output.value?.playlistItems.collection(); 55 68 if (!col || col.state !== "loaded" || !col.data.length) return []; ··· 102 115 /** @type {import("~/components/engine/scope/element.js").CLASS} */ 103 116 const scope = query(this, "scope-engine-selector"); 104 117 118 + /** @type {import("~/components/configurator/input/element.js").CLASS | null} */ 119 + const input = queryOptional(this, "input-selector"); 120 + 105 121 // Wait for the above dependencies to be defined, then render again. 106 122 whenElementsDefined({ output, provider, queue, scope }).then(() => { 107 123 this.$output.value = output; ··· 109 125 this.$queue.value = queue; 110 126 this.$scope.value = scope; 111 127 }); 128 + 129 + if (input) { 130 + whenElementsDefined({ input }).then(async () => { 131 + this.$input.value = input; 132 + const uris = await input.listCached(); 133 + this.#cachedUris.value = new Set(uris); 134 + }); 135 + } 112 136 113 137 // Effects 114 138 this.effect(() => { ··· 251 275 } 252 276 }; 253 277 278 + /** 279 + * @param {string} playlistName 280 + * @param {Track[]} tracks 281 + */ 282 + addTracksToPlaylist = async (playlistName, tracks) => { 283 + const output = this.$output.value; 284 + if (!output || !tracks.length) return; 285 + 286 + const col = output.playlistItems.collection(); 287 + const existing = col.state === "loaded" ? col.data : []; 288 + 289 + const existingKeys = new Set( 290 + existing 291 + .filter((item) => item.playlist === playlistName) 292 + .map((item) => { 293 + const a = item.criteria.find((c) => 294 + c.field === "tags.artist" 295 + )?.value ?? ""; 296 + const t = 297 + item.criteria.find((c) => c.field === "tags.title")?.value ?? ""; 298 + return `${String(a).toLowerCase()}.${String(t).toLowerCase()}`; 299 + }), 300 + ); 301 + 302 + const transformations = /** @type {string[]} */ (["toLowerCase"]); 303 + const now = new Date().toISOString(); 304 + 305 + const newItems = tracks 306 + .filter((track) => { 307 + const key = `${String(track.tags?.artist ?? "").toLowerCase()}.${ 308 + String(track.tags?.title ?? "").toLowerCase() 309 + }`; 310 + return !existingKeys.has(key); 311 + }) 312 + .map(( 313 + track, 314 + ) => /** @type {import("~/definitions/types.d.ts").PlaylistItem} */ ({ 315 + $type: "sh.diffuse.output.playlistItem", 316 + id: TID.now(), 317 + playlist: playlistName, 318 + criteria: [ 319 + { 320 + field: "tags.artist", 321 + value: /** @type {unknown} */ (track.tags?.artist), 322 + transformations, 323 + }, 324 + { 325 + field: "tags.title", 326 + value: /** @type {unknown} */ (track.tags?.title), 327 + transformations, 328 + }, 329 + ], 330 + createdAt: now, 331 + updatedAt: now, 332 + })); 333 + 334 + if (!newItems.length) return; 335 + await output.playlistItems.save([...existing, ...newItems]); 336 + }; 337 + 338 + /** 339 + * @param {string} playlistName 340 + * @param {Track[]} tracks 341 + * @param {boolean} ordered 342 + */ 343 + createPlaylistWithTracks = async (playlistName, tracks, ordered) => { 344 + const output = this.$output.value; 345 + if (!output || !tracks.length) return; 346 + 347 + const col = output.playlistItems.collection(); 348 + const existing = col.state === "loaded" ? col.data : []; 349 + 350 + const transformations = /** @type {string[]} */ (["toLowerCase"]); 351 + const now = new Date().toISOString(); 352 + 353 + /** @type {import("~/definitions/types.d.ts").PlaylistItem[]} */ 354 + const newItems = []; 355 + let prevId = /** @type {string | undefined} */ (undefined); 356 + 357 + for (const track of tracks) { 358 + const id = TID.now(); 359 + newItems.push( 360 + /** @type {import("~/definitions/types.d.ts").PlaylistItem} */ ({ 361 + $type: "sh.diffuse.output.playlistItem", 362 + id, 363 + playlist: playlistName, 364 + criteria: [ 365 + { 366 + field: "tags.artist", 367 + value: /** @type {unknown} */ (track.tags?.artist), 368 + transformations, 369 + }, 370 + { 371 + field: "tags.title", 372 + value: /** @type {unknown} */ (track.tags?.title), 373 + transformations, 374 + }, 375 + ], 376 + ...(ordered ? { positionedAfter: prevId } : {}), 377 + createdAt: now, 378 + updatedAt: now, 379 + }), 380 + ); 381 + if (ordered) prevId = id; 382 + } 383 + 384 + await output.playlistItems.save([...existing, ...newItems]); 385 + }; 386 + 254 387 // RENDER 255 388 256 389 /** 390 + * @param {Function} html 391 + */ 392 + #renderPlaylistPicker(html) { 393 + const state = this.#playlistPickerState.value; 394 + if (!state) { 395 + return html` 396 + 397 + `; 398 + } 399 + 400 + const isCreate = state.mode === "create"; 401 + const groups = this.$groupedPlaylists(); 402 + 403 + return html` 404 + <div 405 + class="picker-overlay" 406 + @click="${(/** @type {MouseEvent} */ e) => { 407 + if (e.target === e.currentTarget) { 408 + this.#playlistPickerState.value = null; 409 + } 410 + }}" 411 + > 412 + <div class="window picker-window"> 413 + <div class="title-bar"> 414 + <div class="title-bar-text">${isCreate 415 + ? `New playlist` 416 + : `Add to playlist`}</div> 417 + <div class="title-bar-controls"> 418 + <button aria-label="Close" @click="${() => { 419 + this.#playlistPickerState.value = null; 420 + }}"></button> 421 + </div> 422 + </div> 423 + <div class="window-body picker-body"> 424 + ${isCreate 425 + ? html` 426 + <form 427 + @submit="${async (/** @type {SubmitEvent} */ e) => { 428 + e.preventDefault(); 429 + const form = 430 + /** @type {HTMLFormElement} */ (e.currentTarget); 431 + const name = 432 + /** @type {HTMLInputElement} */ (form.elements.namedItem( 433 + `playlist-name`, 434 + ))?.value.trim(); 435 + const ordered = 436 + /** @type {HTMLInputElement} */ (form.elements.namedItem( 437 + `playlist-ordered`, 438 + ))?.checked ?? false; 439 + if (!name) return; 440 + await this.createPlaylistWithTracks( 441 + name, 442 + state.tracks, 443 + ordered, 444 + ); 445 + this.#playlistPickerState.value = null; 446 + }}" 447 + > 448 + <div class="field-row"> 449 + <label for="picker-playlist-name">Name:</label> 450 + <input 451 + id="picker-playlist-name" 452 + name="playlist-name" 453 + type="text" 454 + autofocus 455 + required 456 + /> 457 + </div> 458 + <div class="field-row"> 459 + <input 460 + id="picker-playlist-ordered" 461 + name="playlist-ordered" 462 + type="checkbox" 463 + /> 464 + <label for="picker-playlist-ordered">Ordered</label> 465 + </div> 466 + <div class="field-row picker-form-actions"> 467 + <button type="submit">Create</button> 468 + <button type="button" @click="${() => { 469 + this.#playlistPickerState.value = { 470 + mode: `add`, 471 + tracks: state.tracks, 472 + }; 473 + }}">Back</button> 474 + </div> 475 + </form> 476 + ` 477 + : html` 478 + <div class="picker-list"> 479 + <button 480 + class="picker-item picker-item--create" 481 + @click="${() => { 482 + this.#playlistPickerState.value = { 483 + mode: `create`, 484 + tracks: state.tracks, 485 + }; 486 + }}" 487 + > 488 + + Create new playlist 489 + </button> 490 + ${groups.length > 0 491 + ? html` 492 + <hr /> 493 + ` 494 + : ``} ${groups.map(({ label, playlists }) => 495 + html` 496 + <div class="picker-group-label">${label}</div> 497 + ${playlists.map((p) => 498 + html` 499 + <button 500 + class="picker-item" 501 + @click="${async () => { 502 + await this.addTracksToPlaylist( 503 + p.name, 504 + state.tracks, 505 + ); 506 + this.#playlistPickerState.value = null; 507 + }}" 508 + > 509 + ${p.name} 510 + </button> 511 + ` 512 + )} 513 + ` 514 + )} 515 + </div> 516 + `} 517 + </div> 518 + </div> 519 + </div> 520 + `; 521 + } 522 + 523 + /** 257 524 * @param {RenderArg} _ 258 525 */ 259 526 render({ html }) { ··· 287 554 const totalHeight = totalTracks * ROW_HEIGHT; 288 555 const topPad = startIndex * ROW_HEIGHT; 289 556 557 + const selectedTrack = highlighted 558 + ? tracks.find((t) => t.id === highlighted) 559 + : undefined; 560 + const isCached = selectedTrack 561 + ? this.#cachedUris.value.has(selectedTrack.uri) 562 + : false; 563 + 290 564 return html` 291 565 <link rel="stylesheet" href="vendor/98.css" /> 292 566 293 567 <style> 294 568 @import "./facets/themes/winamp/98-vars.css"; 569 + 570 + ::-webkit-scrollbar-button:vertical:start:increment, 571 + ::-webkit-scrollbar-button:vertical:end:decrement, 572 + ::-webkit-scrollbar-button:horizontal:start:increment, 573 + ::-webkit-scrollbar-button:horizontal:end:decrement { 574 + display: none; 575 + } 295 576 296 577 :host { 297 578 display: flex; ··· 378 659 overflow: hidden; 379 660 text-overflow: ellipsis; 380 661 } 662 + 663 + /*********************************** 664 + * ACTIONS 665 + ***********************************/ 666 + 667 + .actions-row { 668 + flex-wrap: wrap; 669 + margin-top: var(--element-spacing); 670 + } 671 + 672 + /*********************************** 673 + * PLAYLIST PICKER 674 + ***********************************/ 675 + 676 + .picker-overlay { 677 + align-items: center; 678 + background: rgba(0, 0, 0, 0.4); 679 + bottom: 0; 680 + display: flex; 681 + justify-content: center; 682 + left: 0; 683 + position: fixed; 684 + right: 0; 685 + top: 0; 686 + z-index: 100; 687 + } 688 + 689 + .picker-window { 690 + min-width: 240px; 691 + max-width: 320px; 692 + } 693 + 694 + .picker-body { 695 + display: flex; 696 + flex-direction: column; 697 + gap: 6px; 698 + 699 + form { 700 + margin-bottom: 0; 701 + } 702 + } 703 + 704 + .picker-list { 705 + border: 2px inset #dfdfdf; 706 + display: flex; 707 + flex-direction: column; 708 + max-height: 200px; 709 + overflow-y: auto; 710 + padding: 2px; 711 + } 712 + 713 + .picker-item { 714 + background: none; 715 + border: none; 716 + box-shadow: none; 717 + cursor: pointer; 718 + font-family: "Pixelated MS Sans Serif", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; 719 + padding: 2px 4px; 720 + text-align: left; 721 + width: 100%; 722 + 723 + &:hover { 724 + background: var(--dialog-blue); 725 + color: #fff; 726 + } 727 + } 728 + 729 + .picker-item--create { 730 + font-style: italic; 731 + } 732 + 733 + .picker-group-label { 734 + color: gray; 735 + font-size: 90%; 736 + margin-top: 4px; 737 + padding: 0 4px; 738 + user-select: none; 739 + } 740 + 741 + .picker-form-actions { 742 + justify-content: flex-end; 743 + margin-top: 4px; 744 + } 381 745 </style> 382 746 383 747 <search class="field-row"> ··· 470 834 </table> 471 835 </div> 472 836 </div> 837 + 838 + <div class="field-row actions-row"> 839 + <button 840 + ?disabled="${!selectedTrack}" 841 + @click="${() => { 842 + if (!selectedTrack) return; 843 + this.$queue.value?.add({ 844 + inFront: true, 845 + trackIds: [selectedTrack.id], 846 + }); 847 + }}" 848 + > 849 + Play next 850 + </button> 851 + <button 852 + ?disabled="${!selectedTrack}" 853 + @click="${() => { 854 + if (!selectedTrack) return; 855 + this.$queue.value?.add({ trackIds: [selectedTrack.id] }); 856 + }}" 857 + > 858 + Add to queue 859 + </button> 860 + <button 861 + ?disabled="${!selectedTrack}" 862 + @click="${() => { 863 + if (!selectedTrack) return; 864 + this.#playlistPickerState.value = { 865 + mode: `add`, 866 + tracks: [selectedTrack], 867 + }; 868 + }}" 869 + > 870 + Add to playlist 871 + </button> 872 + ${this.$input.value 873 + ? html` 874 + <button 875 + ?disabled="${!selectedTrack}" 876 + @click="${async () => { 877 + if (!selectedTrack) return; 878 + if (isCached) { 879 + await this.$input.value?.removeFromCache([selectedTrack.uri]); 880 + } else { 881 + await this.$input.value?.cache([selectedTrack.uri]); 882 + } 883 + const updated = await this.$input.value?.listCached(); 884 + if (updated) this.#cachedUris.value = new Set(updated); 885 + }}" 886 + > 887 + ${isCached ? `Remove from cache` : `Store in cache`} 888 + </button> 889 + ` 890 + : ``} 891 + </div> 892 + 893 + ${this.#renderPlaylistPicker(html)} 473 894 `; 474 895 } 475 896 }
+3 -1
src/facets/themes/winamp/browser/facet/index.inline.js
··· 4 4 // Set doc title 5 5 foundation.setup({ title: "Browser | Winamp | Diffuse" }); 6 6 7 - const [out, que, scp, trc] = await Promise.all([ 7 + const [out, que, scp, trc, inp] = await Promise.all([ 8 8 foundation.orchestrator.output(), 9 9 foundation.engine.queue(), 10 10 foundation.engine.scope(), 11 11 foundation.orchestrator.scopedTracks(), 12 + foundation.configurator.input(), 12 13 ]); 13 14 14 15 const el = new BrowserElement(); ··· 16 17 el.setAttribute("queue-engine-selector", que.selector); 17 18 el.setAttribute("scope-engine-selector", scp.selector); 18 19 el.setAttribute("tracks-selector", trc.selector); 20 + el.setAttribute("input-selector", inp.selector); 19 21 20 22 document.querySelector("#placeholder")?.replaceWith(el); 21 23
+60
src/facets/themes/winamp/window-manager/element.js
··· 12 12 // ELEMENT 13 13 //////////////////////////////////////////// 14 14 15 + const STORAGE_PREFIX = "diffuse/winamp/window/"; 16 + 15 17 class WindowManager extends DiffuseElement { 16 18 constructor() { 17 19 super(); ··· 26 28 $activeWindow = signal(/** @type {string | null} */ (null)); 27 29 #lastZindex = 1000; 28 30 31 + // STORAGE 32 + 33 + /** 34 + * @param {string} id 35 + * @param {string} left 36 + * @param {string} top 37 + */ 38 + #savePosition(id, left, top) { 39 + localStorage.setItem(`${STORAGE_PREFIX}${id}`, JSON.stringify({ left, top })); 40 + } 41 + 42 + /** 43 + * @param {string} id 44 + * @returns {{ left: string; top: string } | null} 45 + */ 46 + #loadPosition(id) { 47 + try { 48 + const raw = localStorage.getItem(`${STORAGE_PREFIX}${id}`); 49 + if (!raw) return null; 50 + return JSON.parse(raw); 51 + } catch { 52 + return null; 53 + } 54 + } 55 + 56 + #restorePositions() { 57 + this.querySelectorAll("dtw-window[id]").forEach((w) => { 58 + if (!(w instanceof HTMLElement) || !w.id) return; 59 + const pos = this.#loadPosition(w.id); 60 + if (pos) { 61 + w.style.left = pos.left; 62 + w.style.top = pos.top; 63 + } 64 + }); 65 + } 66 + 29 67 // LIFECYCLE 30 68 31 69 /** ··· 33 71 */ 34 72 async connectedCallback() { 35 73 super.connectedCallback(); 74 + 75 + this.#restorePositions(); 36 76 37 77 // Events 38 78 this.root().addEventListener("mousedown", this.focusOnWindow); ··· 130 170 document.removeEventListener("mousemove", moveFn); 131 171 document.removeEventListener("mouseup", stopMove); 132 172 document.removeEventListener("mouseleave", stopMove); 173 + 174 + const target = ogEvent.detail.element; 175 + if (target instanceof HTMLElement && target.id) { 176 + this.#savePosition(target.id, target.style.left, target.style.top); 177 + } 133 178 }; 134 179 135 180 document.addEventListener("mousemove", moveFn); ··· 167 212 this.activateWindow(id); 168 213 this.#lastZindex++; 169 214 w.style.zIndex = this.#lastZindex.toString(); 215 + 216 + if (!this.#loadPosition(id)) { 217 + const placeWindow = () => { 218 + const dialog = w.shadowRoot?.querySelector("dialog[open]"); 219 + if (!dialog) { requestAnimationFrame(placeWindow); return; } 220 + const { width, height } = dialog.getBoundingClientRect(); 221 + if (width === 0 || height === 0) { requestAnimationFrame(placeWindow); return; } 222 + const index = [...this.children].indexOf(w); 223 + const stagger = index * 12; 224 + w.style.left = `${Math.round(Math.max(0, (window.innerWidth - width) / 2) + stagger)}px`; 225 + w.style.top = `${Math.round(Math.max(0, (window.innerHeight - height) / 2) + stagger)}px`; 226 + this.#savePosition(id, w.style.left, w.style.top); 227 + }; 228 + requestAnimationFrame(placeWindow); 229 + } 170 230 } 171 231 } 172 232
+1
src/facets/themes/winamp/window/element.js
··· 41 41 dialog { 42 42 background: transparent; 43 43 border: 0; 44 + outline: none; 44 45 padding: 0; 45 46 } 46 47