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.

fix: improve performance scoped-tracks

+187 -69
+1 -1
.env
··· 1 1 ATPROTO_CLIENT_ID=http://127.0.0.1:3000/oauth-client-metadata.json 2 - #DISABLE_AUTOMATIC_TRACKS_PROCESSING=t 2 + DISABLE_AUTOMATIC_TRACKS_PROCESSING=t
+70
src/common/playlist.js
··· 3 3 */ 4 4 5 5 /** 6 + * @param {any} val 7 + * @param {string[] | undefined} transformations 8 + */ 9 + function transform(val, transformations) { 10 + if (!val || !transformations) return val; 11 + return transformations.reduce((v, t) => { 12 + try { 13 + return v[t](); 14 + } catch (_) { 15 + return v; 16 + } 17 + }, val); 18 + } 19 + 20 + /** 6 21 * Check if a track matches the criteria of a playlist item. 7 22 * 8 23 * @param {Track} track ··· 32 47 return critValue === value; 33 48 }); 34 49 } 50 + 51 + /** 52 + * Filter tracks by playlist membership using an indexed lookup. 53 + * 54 + * @param {Track[]} tracks 55 + * @param {Playlist} playlist 56 + */ 57 + export function filterByPlaylist(tracks, playlist) { 58 + // Group playlist items by criteria shape, building a Set index per shape. 59 + const shapes = playlist.items 60 + .reduce( 61 + (acc, item) => { 62 + const shapeKey = item.criteria 63 + .map((c) => `${c.field}\0${(c.transformations ?? []).join(",")}`) 64 + .join("\0\0"); 65 + 66 + const group = acc.get(shapeKey) ?? acc 67 + .set(shapeKey, { criteria: item.criteria, keys: new Set() }) 68 + .get(shapeKey); 69 + 70 + group?.keys.add( 71 + item.criteria.map((c) => transform(c.value, c.transformations)).join( 72 + "\0", 73 + ), 74 + ); 75 + 76 + return acc; 77 + }, 78 + /** @type {Map<string, { criteria: PlaylistItem["criteria"], keys: Set<string> }>} */ (new Map()), 79 + ) 80 + .values() 81 + .map((group) => ({ 82 + fields: group.criteria.map((c) => ({ 83 + parts: c.field.split("."), 84 + transformations: c.transformations, 85 + })), 86 + keys: group.keys, 87 + })) 88 + .toArray(); 89 + 90 + return tracks.filter((track) => 91 + shapes.some((shape) => 92 + shape.keys.has( 93 + shape.fields 94 + .map(({ parts, transformations }) => 95 + transform( 96 + parts.reduce((v, f) => v?.[f], /** @type {any} */ (track)), 97 + transformations, 98 + ) 99 + ) 100 + .join("\0"), 101 + ) 102 + ) 103 + ); 104 + }
+25 -8
src/components/orchestrator/scoped-tracks/element.js
··· 3 3 query, 4 4 queryOptional, 5 5 } from "@common/element.js"; 6 - import { match } from "@common/playlist.js"; 7 6 import { computed, signal, untracked } from "@common/signal.js"; 8 7 9 8 /** ··· 73 72 async connectedCallback() { 74 73 // Broadcast if needed 75 74 if (this.hasAttribute("group")) { 76 - this.broadcast(this.nameWithGroup, {}); 75 + const actions = this.broadcast(this.nameWithGroup, { 76 + setTracksSearch: { 77 + strategy: "replicate", 78 + fn: this.#tracksSearch.set, 79 + }, 80 + setTracksFinal: { 81 + strategy: "replicate", 82 + fn: this.#tracksFinal.set, 83 + }, 84 + }); 85 + 86 + if (actions) { 87 + this.#tracksSearch.set = actions.setTracksSearch; 88 + this.#tracksFinal.set = actions.setTracksFinal; 89 + } 77 90 } 78 91 79 92 // Super ··· 116 129 if ((await this.isLeader()) === false) return; 117 130 118 131 const searchResults = searchTerm 119 - ? await this.#search.value?.search({ term: searchTerm }) 132 + ? await this.#proxy.searchTracks({ term: searchTerm }) 120 133 : untracked(() => output.tracks.collection()); 121 134 122 - this.#tracksSearch.value = searchResults ?? output.tracks.collection(); 135 + this.#tracksSearch.set(searchResults); 123 136 }); 124 137 125 138 // Watch `#tracksSearch` + Playlist 126 - this.effect(() => { 139 + this.effect(async () => { 127 140 const tracks = this.#tracksSearch.value; 128 141 const playlist = this.#selectedPlaylist(); 129 142 130 - this.#tracksFinal.value = playlist 131 - ? tracks.filter((t) => playlist.items.some((item) => match(t, item))) 132 - : tracks; 143 + if ((await this.isLeader()) === false) return; 144 + 145 + this.#tracksFinal.set( 146 + playlist 147 + ? await this.#proxy.filterByPlaylist({ tracks, playlist }) 148 + : tracks, 149 + ); 133 150 }); 134 151 } 135 152
+6 -1
src/components/orchestrator/scoped-tracks/types.d.ts
··· 1 - import type { Track } from "@definitions/types.d.ts"; 1 + import type { SearchParams } from "@orama/orama"; 2 + 3 + import type { Playlist, Track } from "@definitions/types.d.ts"; 4 + import type { Schema } from "@components/processor/search/types.d.ts"; 2 5 3 6 export type Actions = { 7 + filterByPlaylist(args: { tracks: Track[]; playlist: Playlist }): Promise<Track[]>; 8 + searchTracks(params: SearchParams<Schema>): Promise<Track[]>; 4 9 supplyAvailable(tracks: Track[]): Promise<void>; 5 10 };
+21 -1
src/components/orchestrator/scoped-tracks/worker.js
··· 1 + import { filterByPlaylist as filterByPlaylistFn } from "@common/playlist.js"; 1 2 import { ostiary, rpc, workerProxy } from "@common/worker.js"; 2 3 3 4 /** ··· 42 43 await search.supply({ tracks: availableTracks }); 43 44 } 44 45 46 + /** 47 + * @type {ActionsWithTunnel<Actions>["searchTracks"]} 48 + */ 49 + export async function searchTracks({ data, ports }) { 50 + /** @type {ProxiedActions<SearchProcessorActions>} */ 51 + const search = workerProxy(() => ports.search); 52 + 53 + ports.search.start(); 54 + 55 + return await search.search(data); 56 + } 57 + 58 + /** 59 + * @type {ActionsWithTunnel<Actions>["filterByPlaylist"]} 60 + */ 61 + export async function filterByPlaylist({ data }) { 62 + return filterByPlaylistFn(data.tracks, data.playlist); 63 + } 64 + 45 65 //////////////////////////////////////////// 46 66 // ⚡️ 47 67 //////////////////////////////////////////// 48 68 49 69 ostiary((context) => { 50 - rpc(context, { supplyAvailable }); 70 + rpc(context, { filterByPlaylist, searchTracks, supplyAvailable }); 51 71 });
+58 -47
src/themes/webamp/browser/element.js
··· 1 - import { DiffuseElement, query, whenElementsDefined } from "@common/element.js"; 1 + import { 2 + DiffuseElement, 3 + nothing, 4 + query, 5 + whenElementsDefined, 6 + } from "@common/element.js"; 2 7 import { signal } from "@common/signal.js"; 3 8 import { highlightTableEntry } from "../common/ui.js"; 4 9 5 10 /** 6 11 * @import {RenderArg} from "@common/element.d.ts" 12 + * @import {SignalReader} from "@common/signal.d.ts"; 7 13 * @import {Track} from "@definitions/types.d.ts" 8 - * @import {InputElement} from "@components/input/types.d.ts" 9 14 * @import {OutputElement} from "@components/output/types.d.ts" 10 15 */ 11 16 12 17 class Browser extends DiffuseElement { 13 18 constructor() { 14 19 super(); 15 - 16 20 this.attachShadow({ mode: "open" }); 17 - this.performSearch = this.performSearch.bind(this); 18 21 } 19 22 20 23 // SIGNALS 21 24 22 - #collectionSize = signal(0); 23 - #searchResults = signal(/** @type {Track[]} */ ([])); 24 - 25 - $input = signal( 26 - /** @type {InputElement | undefined} */ (undefined), 27 - ); 28 - 29 25 $output = signal( 30 26 /** @type {OutputElement | undefined} */ (undefined), 31 27 ); ··· 34 30 /** @type {import("@components/engine/queue/element.js").CLASS | undefined} */ (undefined), 35 31 ); 36 32 37 - $search = signal( 38 - /** @type {import("@components/processor/search/element.js").CLASS | undefined} */ (undefined), 33 + $scope = signal( 34 + /** @type {import("@components/engine/scope/element.js").CLASS | undefined} */ (undefined), 35 + ); 36 + 37 + $provider = signal( 38 + /** @type {DiffuseElement & { tracks: SignalReader<Track[]> } | undefined} */ (undefined), 39 39 ); 40 40 41 41 // LIFECYCLE ··· 46 46 connectedCallback() { 47 47 super.connectedCallback(); 48 48 49 - /** @type {InputElement} */ 50 - const input = query(this, "input-selector"); 51 - 52 49 /** @type {OutputElement} */ 53 50 const output = query(this, "output-selector"); 54 51 52 + /** @type {DiffuseElement & { tracks: SignalReader<Track[]> }} */ 53 + const provider = query(this, "tracks-selector"); 54 + 55 55 /** @type {import("@components/engine/queue/element.js").CLASS} */ 56 56 const queue = query(this, "queue-engine-selector"); 57 57 58 - /** @type {import("@components/processor/search/element.js").CLASS} */ 59 - const search = query(this, "search-processor-selector"); 58 + /** @type {import("@components/engine/scope/element.js").CLASS} */ 59 + const scope = query(this, "scope-engine-selector"); 60 60 61 61 // Wait for the above dependencies to be defined, then render again. 62 - whenElementsDefined({ input, output, queue, search }).then(() => { 63 - this.$input.value = input; 62 + whenElementsDefined({ output, provider, queue, scope }).then(() => { 64 63 this.$output.value = output; 64 + this.$provider.value = provider; 65 65 this.$queue.value = queue; 66 - this.$search.value = search; 67 - 68 - this.effect(() => { 69 - const _ = search.supplyFingerprint(); 70 - this.performSearch(); 71 - }); 72 - 73 - this.effect(() => { 74 - const _trigger = output.tracks.state(); 75 - 76 - this.#collectionSize.value = output.tracks.collection().filter( 77 - (t) => t.kind !== "placeholder", 78 - ).length; 79 - }); 66 + this.$scope.value = scope; 80 67 }); 81 68 82 69 // Effects 83 70 this.effect(() => { 84 - const _results = this.#searchResults.value; 71 + const _results = this.$provider.value?.tracks(); 85 72 this.root().querySelector(".sunken-panel")?.scrollTo(0, 0); 86 73 }); 87 74 } ··· 100 87 this.$queue.value?.shift(); 101 88 } 102 89 103 - async performSearch() { 90 + setSearchTerm = () => { 104 91 /** @type {HTMLInputElement | null} */ 105 92 const input = this.root().querySelector("#search-input"); 106 93 const term = input?.value?.trim(); 107 94 108 - this.#searchResults.value = await this.$search.value?.search({ 109 - term: term, 110 - }) ?? []; 111 - } 95 + this.$scope.value?.setSearchTerm(term); 96 + }; 97 + 98 + /** 99 + * @param {Event} event 100 + */ 101 + setSelectedPlaylistId = (event) => { 102 + const id = /** @type {HTMLSelectElement} */ (event.currentTarget).value; 103 + 104 + this.$scope.value?.setPlaylistId(id === "" ? undefined : id); 105 + }; 112 106 113 107 // RENDER 114 108 ··· 116 110 * @param {RenderArg} _ 117 111 */ 118 112 render({ html }) { 119 - const isLoading = this.$output.value?.tracks?.state() !== "loaded" || 120 - (this.#collectionSize.value > 0 && 121 - this.$search.value?.supplyFingerprint() === undefined); 122 - 123 - const tracks = this.#searchResults.value; 113 + const isLoading = this.$output.value?.tracks?.state() !== "loaded"; 114 + const tracks = this.$provider.value?.tracks() ?? []; 115 + const playlistId = this.$scope.value?.playlistId(); 124 116 125 117 return html` 126 118 <link rel="stylesheet" href="styles/vendor/98.css" /> ··· 144 136 145 137 search input { 146 138 flex: 1; 139 + } 140 + 141 + search select { 142 + max-width: 33%; 147 143 } 148 144 149 145 /*********************************** ··· 186 182 </style> 187 183 188 184 <search class="field-row"> 189 - <label for="search-input">Search</label> 185 + <label for="search-input">Search:</label> 190 186 <input id="search-input" type="search" @change="${this 191 - .performSearch}" /> 187 + .setSearchTerm}" /> 188 + <label for="playlist-select">Playlist:</label> 189 + <select id="playlist-select" @change="${this.setSelectedPlaylistId}"> 190 + <option value="" ?selected="${!playlistId || 191 + playlistId === ``}">All tracks</option> 192 + ${this.$output.value?.playlists.collection().map((p) => 193 + html` 194 + <option 195 + value="${p.id}" 196 + ?selected="${p.id === playlistId}" 197 + > 198 + ${p.name} 199 + </option> 200 + ` 201 + ) ?? nothing} 202 + </select> 192 203 </search> 193 204 194 205 <div class="sunken-panel">
+6 -11
src/themes/webamp/browser/facet.html.txt
··· 1 1 <div class="window"> 2 - <div 3 - class="title-bar" 4 - > 2 + <div class="title-bar"> 5 3 <div class="title-bar-icon"> 6 4 <img src="images/icons/windows_98/directory_explorer-4.png" height="14" /> 7 5 </div> 8 - <div class="title-bar-text" draggable="false"> 9 - Browse collection 10 - </div> 6 + <div class="title-bar-text" draggable="false">Browse collection</div> 11 7 <div class="title-bar-controls"> 12 8 <button aria-label="Close" onclick="window.close()"></button> 13 9 </div> ··· 27 23 import foundation from "./common/facets/foundation.js"; 28 24 import BrowserElement from "./themes/webamp/browser/element.js"; 29 25 30 - foundation.features.fillQueueAutomatically(); 31 26 foundation.features.processInputs(); 32 27 foundation.features.searchThroughCollection(); 33 28 34 - const inp = foundation.orchestrator.input(); 35 29 const out = foundation.orchestrator.output(); 36 30 const que = foundation.engine.queue(); 37 - const sea = foundation.processor.search(); 31 + const scp = foundation.engine.scope(); 32 + const trc = foundation.orchestrator.scopedTracks(); 38 33 39 34 const el = new BrowserElement(); 40 - el.setAttribute("input-selector", inp.selector); 41 35 el.setAttribute("output-selector", out.selector); 42 36 el.setAttribute("queue-engine-selector", que.selector); 43 - el.setAttribute("search-processor-selector", sea.selector); 37 + el.setAttribute("scope-engine-selector", scp.selector); 38 + el.setAttribute("tracks-selector", trc.selector); 44 39 45 40 document.querySelector("#placeholder")?.replaceWith(el); 46 41 </script>