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.

chore: sorting + grouping improvements

+86 -72
+80 -30
src/components/orchestrator/scoped-tracks/element.js
··· 233 233 const disabledSources = this.#disabledSources(); 234 234 const sortBy = this.#scope.value?.sortBy(); 235 235 const sortDirection = this.#scope.value?.sortDirection(); 236 + const groupBy = this.#scope.value?.groupBy(); 236 237 237 238 if ((await this.isLeader()) === false) return; 238 239 ··· 246 247 ); 247 248 } 248 249 249 - if (sortBy?.length) { 250 - const dir = sortDirection === "desc" ? -1 : 1; 251 - final = [...final].sort((a, b) => { 252 - for (const field of sortBy) { 253 - let aVal = /** @type {any} */ (a); 254 - let bVal = /** @type {any} */ (b); 250 + // When groupBy is active, sort by group key first using the group's 251 + // canonical direction (from GROUP_BY_SORT_OVERRIDES, or user's direction 252 + // for firstLetter). Within each group, sort by the user's sortBy and 253 + // sortDirection as normal. 254 + // 255 + // Schwartzian transform: precompute all keys once (O(N)) so the 256 + // comparator never re-parses URLs or re-splits dot-paths (O(N log N)). 257 + const groupOverride = groupBy 258 + ? GROUP_BY_SORT_OVERRIDES[groupBy] 259 + : undefined; 260 + const groupDir = 261 + (groupOverride?.sortDirection ?? sortDirection) === "desc" ? -1 : 1; 262 + const userFields = sortBy ?? []; 263 + const userDir = sortDirection === "desc" ? -1 : 1; 264 + const splitPaths = userFields.map((f) => f.split(".")); 255 265 256 - for (const key of field.split(".")) { 257 - aVal = aVal?.[key]; 258 - bVal = bVal?.[key]; 259 - } 266 + if (groupBy || userFields.length) { 267 + const decorated = final.map((track) => ({ 268 + track, 269 + groupKey: groupBy ? groupKeyLabel(track, groupBy).key : "", 270 + fieldVals: splitPaths.map((parts) => { 271 + let v = /** @type {any} */ (track); 272 + for (const p of parts) v = v?.[p]; 273 + return v; 274 + }), 275 + })); 260 276 261 - if (aVal == null && bVal == null) continue; 262 - if (aVal == null) return 1; 263 - if (bVal == null) return -1; 264 - 265 - const cmp = typeof aVal === "string" && typeof bVal === "string" 266 - ? aVal.localeCompare(bVal) 267 - : aVal < bVal 268 - ? -1 269 - : aVal > bVal 270 - ? 1 271 - : 0; 272 - 273 - if (cmp !== 0) return cmp * dir; 277 + decorated.sort((a, b) => { 278 + if (groupBy && a.groupKey !== b.groupKey) { 279 + if (!a.groupKey) return 1; 280 + if (!b.groupKey) return -1; 281 + return a.groupKey.localeCompare(b.groupKey) * groupDir; 274 282 } 275 - 283 + for (let i = 0; i < a.fieldVals.length; i++) { 284 + const cmp = compareValues(a.fieldVals[i], b.fieldVals[i]); 285 + if (cmp !== 0) return cmp * userDir; 286 + } 276 287 return 0; 277 288 }); 289 + 290 + final = decorated.map((d) => d.track); 278 291 } 279 292 280 293 this.#tracksFinal.set(final); ··· 289 302 //////////////////////////////////////////// 290 303 291 304 const MONTHS = [ 292 - "January", "February", "March", "April", "May", "June", 293 - "July", "August", "September", "October", "November", "December", 305 + "January", 306 + "February", 307 + "March", 308 + "April", 309 + "May", 310 + "June", 311 + "July", 312 + "August", 313 + "September", 314 + "October", 315 + "November", 316 + "December", 294 317 ]; 318 + 319 + /** @type {Record<string, { sortDirection: "asc" | "desc" }>} */ 320 + const GROUP_BY_SORT_OVERRIDES = { 321 + createdAt: { sortDirection: "desc" }, 322 + directory: { sortDirection: "asc" }, 323 + firstLetter: { sortDirection: "asc" }, 324 + "tags.year": { sortDirection: "desc" }, 325 + }; 295 326 296 327 /** 297 328 * @param {Track[]} tracks ··· 302 333 /** @type {{ label: string; tracks: Track[] }[]} */ 303 334 const groups = []; 304 335 let lastKey = /** @type {string | undefined} */ (undefined); 305 - let current = /** @type {{ label: string; tracks: Track[] } | undefined} */ (undefined); 336 + let current = 337 + /** @type {{ label: string; tracks: Track[] } | undefined} */ (undefined); 306 338 307 339 for (const track of tracks) { 308 340 const { key, label } = groupKeyLabel(track, groupBy); ··· 327 359 function groupKeyLabel(track, fieldPath) { 328 360 if (fieldPath === "createdAt") { 329 361 const iso = track.createdAt; 330 - if (!iso) return { key: "unknown", label: "Unknown" }; 362 + if (!iso) return { key: "", label: "Unknown" }; 331 363 const year = iso.slice(0, 4); 332 364 const month = iso.slice(5, 7); 333 365 return { ··· 363 395 // Generic dot-path extraction 364 396 let val = /** @type {any} */ (track); 365 397 for (const key of fieldPath.split(".")) val = val?.[key]; 366 - const str = val != null ? String(val) : "Unknown"; 367 - return { key: str, label: str }; 398 + const str = val != null ? String(val) : ""; 399 + return { key: str, label: str || "Unknown" }; 400 + } 401 + 402 + /** 403 + * @param {any} aVal 404 + * @param {any} bVal 405 + * @returns {number} 406 + */ 407 + function compareValues(aVal, bVal) { 408 + if (aVal == null && bVal == null) return 0; 409 + if (aVal == null) return 1; 410 + if (bVal == null) return -1; 411 + return typeof aVal === "string" && typeof bVal === "string" 412 + ? aVal.localeCompare(bVal) 413 + : aVal < bVal 414 + ? -1 415 + : aVal > bVal 416 + ? 1 417 + : 0; 368 418 } 369 419 370 420 ////////////////////////////////////////////
+6 -42
src/themes/blur/browser/element.js
··· 38 38 const DEFAULT_SORT = ["createdAt"]; 39 39 40 40 const GROUP_BY_OPTIONS = [ 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 + { value: "firstLetter", label: "Group by first letter", icon: "ph-text-aa" }, 42 + { value: "directory", label: "Group by path", icon: "ph-folder" }, 43 + { value: "createdAt", label: "Group by processing date", icon: "ph-clock" }, 44 + { value: "tags.year", label: "Group by track year", icon: "ph-calendar" }, 69 45 ]; 70 46 71 47 /** ··· 886 862 887 863 const allTracks = this.$provider.value?.tracks() ?? []; 888 864 889 - let key, name, subtitle, detailTracks; 865 + let key = "", name = "", subtitle = "", detailTracks = /** @type {Track[]} */ ([]); 890 866 891 867 if (item.type === "album") { 892 868 key = item.albumKey; ··· 1046 1022 </button> 1047 1023 <div id="groupby-menu" class="dropdown" popover> 1048 1024 ${GROUP_BY_OPTIONS.map( 1049 - ( 1050 - { 1051 - value, 1052 - label, 1053 - sortBy: optSortBy, 1054 - sortDirection: optSortDirection, 1055 - }, 1056 - ) => { 1025 + ({ value, label }) => { 1057 1026 const primarySortField = sortBy[0] ?? "tags.title"; 1058 1027 const resolvedValue = value === "firstLetter" 1059 1028 ? `firstLetter:${primarySortField}` ··· 1065 1034 <button 1066 1035 class="${isActive ? `dropdown-item--active` : ``}" 1067 1036 @click="${() => { 1068 - const scope = this.$scope.value; 1069 1037 if (isActive) { 1070 1038 this.setGroupBy(undefined); 1071 1039 } else { 1072 1040 this.setGroupBy(resolvedValue); 1073 - if (optSortBy && scope) { 1074 - scope.setSortBy(optSortBy); 1075 - scope.setSortDirection(optSortDirection); 1076 - } 1077 1041 } 1078 1042 /** @type {HTMLElement | null} */ (this.root().querySelector( 1079 1043 `#groupby-menu`,