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.

feat: collab def + some playlist changes

+193 -87
+132 -65
src/common/playlist.js
··· 2 2 * @import {PlaylistItem, Track} from "@definitions/types.d.ts" 3 3 */ 4 4 5 + import { Temporal } from "@js-temporal/polyfill"; 6 + 7 + /** 8 + * Filter tracks by playlist membership using an indexed lookup. 9 + * 10 + * @param {Track[]} tracks 11 + * @param {PlaylistItem[]} playlistItems 12 + */ 13 + export function filterByPlaylist(tracks, playlistItems) { 14 + // Group playlist items by criteria shape, building a Set index per shape. 15 + const shapes = playlistItems 16 + .reduce( 17 + (acc, playlistItem) => { 18 + const shapeKey = playlistItem.criteria 19 + .map((c) => `${c.field}\0${(c.transformations ?? []).join(",")}`) 20 + .join("\0\0"); 21 + 22 + const group = acc.get(shapeKey) ?? acc 23 + .set(shapeKey, { criteria: playlistItem.criteria, keys: new Set() }) 24 + .get(shapeKey); 25 + 26 + group?.keys.add( 27 + playlistItem.criteria.map((c) => 28 + transform(c.value, c.transformations) 29 + ).join( 30 + "\0", 31 + ), 32 + ); 33 + 34 + return acc; 35 + }, 36 + /** @type {Map<string, { criteria: PlaylistItem["criteria"], keys: Set<string> }>} */ (new Map()), 37 + ) 38 + .values() 39 + .map((group) => ({ 40 + fields: group.criteria.map((c) => ({ 41 + parts: c.field.split("."), 42 + transformations: c.transformations, 43 + })), 44 + keys: group.keys, 45 + })) 46 + .toArray(); 47 + 48 + return tracks.filter((track) => 49 + shapes.some((shape) => 50 + shape.keys.has( 51 + shape.fields 52 + .map(({ parts, transformations }) => 53 + transform( 54 + parts.reduce((v, f) => v?.[f], /** @type {any} */ (track)), 55 + transformations, 56 + ) 57 + ) 58 + .join("\0"), 59 + ) 60 + ) 61 + ); 62 + } 63 + 5 64 /** 6 65 * Bundle playlist items into their respective playlists. 7 66 * ··· 20 79 playlistMap.set(item.playlist, { 21 80 items: [item], 22 81 name: item.playlist, 23 - unordered: item.position == null, 82 + unordered: item.positionedAfter == null, 24 83 }); 25 - } else if (item.position == null) { 84 + } else { 26 85 existing.items.push(item); 27 - existing.unordered = true; 86 + existing.unordered = existing.unordered === false 87 + ? false 88 + : item.positionedAfter == null; 28 89 } 29 90 } 30 91 ··· 32 93 } 33 94 34 95 /** 35 - * @param {any} val 36 - * @param {string[] | undefined} transformations 37 - */ 38 - function transform(val, transformations) { 39 - if (!val || !transformations) return val; 40 - return transformations.reduce((v, t) => { 41 - try { 42 - return v[t](); 43 - } catch (_) { 44 - return v; 45 - } 46 - }, val); 47 - } 48 - 49 - /** 50 96 * Check if a track matches the criteria of a playlist item. 51 97 * 52 98 * @param {Track} track ··· 78 124 } 79 125 80 126 /** 81 - * Filter tracks by playlist membership using an indexed lookup. 127 + * Sort playlist items by their `positionedAfter` linked-list order. 128 + * Items with no `positionedAfter` are placed first. 82 129 * 83 - * @param {Track[]} tracks 84 - * @param {PlaylistItem[]} playlistItems 130 + * @param {PlaylistItem[]} items 131 + * @returns {PlaylistItem[]} 85 132 */ 86 - export function filterByPlaylist(tracks, playlistItems) { 87 - // Group playlist items by criteria shape, building a Set index per shape. 88 - const shapes = playlistItems 89 - .reduce( 90 - (acc, playlistItem) => { 91 - const shapeKey = playlistItem.criteria 92 - .map((c) => `${c.field}\0${(c.transformations ?? []).join(",")}`) 93 - .join("\0\0"); 133 + export function sort(items) { 134 + if (items.length <= 1) return items; 135 + 136 + /** @type {Map<string | null, PlaylistItem[]>} */ 137 + const afterMap = new Map(); 94 138 95 - const group = acc.get(shapeKey) ?? acc 96 - .set(shapeKey, { criteria: playlistItem.criteria, keys: new Set() }) 97 - .get(shapeKey); 139 + for (const item of items) { 140 + const key = item.positionedAfter ?? null; 141 + const group = afterMap.get(key); 142 + if (group) { 143 + group.push(item); 144 + } else { 145 + afterMap.set(key, [item]); 146 + } 147 + } 98 148 99 - group?.keys.add( 100 - playlistItem.criteria.map((c) => 101 - transform(c.value, c.transformations) 102 - ).join( 103 - "\0", 104 - ), 149 + // Sort each group by updatedAt so collisions have a deterministic order. 150 + for (const group of afterMap.values()) { 151 + if (group.length > 1) { 152 + group.sort((a, b) => { 153 + if (!a.updatedAt || !b.updatedAt) return a.updatedAt ? 1 : -1; 154 + return Temporal.ZonedDateTime.compare( 155 + Temporal.ZonedDateTime.from(a.updatedAt), 156 + Temporal.ZonedDateTime.from(b.updatedAt), 105 157 ); 158 + }); 159 + } 160 + } 106 161 107 - return acc; 108 - }, 109 - /** @type {Map<string, { criteria: PlaylistItem["criteria"], keys: Set<string> }>} */ (new Map()), 110 - ) 111 - .values() 112 - .map((group) => ({ 113 - fields: group.criteria.map((c) => ({ 114 - parts: c.field.split("."), 115 - transformations: c.transformations, 116 - })), 117 - keys: group.keys, 118 - })) 119 - .toArray(); 162 + /** @type {PlaylistItem[]} */ 163 + const sorted = []; 164 + const visited = new Set(); 120 165 121 - return tracks.filter((track) => 122 - shapes.some((shape) => 123 - shape.keys.has( 124 - shape.fields 125 - .map(({ parts, transformations }) => 126 - transform( 127 - parts.reduce((v, f) => v?.[f], /** @type {any} */ (track)), 128 - transformations, 129 - ) 130 - ) 131 - .join("\0"), 132 - ) 133 - ) 134 - ); 166 + /** @type {PlaylistItem[]} */ 167 + const queue = [...(afterMap.get(null) ?? [])]; 168 + 169 + while (queue.length > 0) { 170 + const current = /** @type {PlaylistItem} */ (queue.shift()); 171 + if (visited.has(current.id)) continue; 172 + visited.add(current.id); 173 + sorted.push(current); 174 + 175 + const next = afterMap.get(current.id); 176 + if (next) queue.unshift(...next); 177 + } 178 + 179 + // Append any items not reachable from a head (e.g. broken chains). 180 + for (const item of items) { 181 + if (!visited.has(item.id)) { 182 + sorted.push(item); 183 + } 184 + } 185 + 186 + return sorted; 187 + } 188 + 189 + /** 190 + * @param {any} val 191 + * @param {string[] | undefined} transformations 192 + */ 193 + function transform(val, transformations) { 194 + if (!val || !transformations) return val; 195 + return transformations.reduce((v, t) => { 196 + try { 197 + return v[t](); 198 + } catch (_) { 199 + return v; 200 + } 201 + }, val); 135 202 }
+2 -1
src/components/orchestrator/favourites/worker.js
··· 1 + import { Temporal } from "@js-temporal/polyfill"; 1 2 import { ostiary, rpc } from "@common/worker.js"; 2 3 import { filterFavourites } from "./common.js"; 3 4 ··· 39 40 */ 40 41 function createFavouriteItem(track) { 41 42 const transformations = ["toLowerCase"]; 42 - const now = new Date().toISOString(); 43 + const now = Temporal.Now.zonedDateTimeISO().toString(); 43 44 44 45 return /** @type {PlaylistItem} */ ({ 45 46 $type: "sh.diffuse.output.playlistItem",
+1
src/definitions/index.ts
··· 1 + export * as ShDiffuseOutputCollaboration from "./types/sh/diffuse/output/collaboration.ts"; 1 2 export * as ShDiffuseOutputFacet from "./types/sh/diffuse/output/facet.ts"; 2 3 export * as ShDiffuseOutputPlaylistItem from "./types/sh/diffuse/output/playlistItem.ts"; 3 4 export * as ShDiffuseOutputTheme from "./types/sh/diffuse/output/theme.ts";
+49
src/definitions/output/collaboration.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.diffuse.output.collaboration", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "record": { 8 + "type": "object", 9 + "required": ["id", "collaborators", "subject"], 10 + "properties": { 11 + "id": { "type": "string" }, 12 + "collaborators": { 13 + "type": "array", 14 + "items": { "type": "ref", "ref": "#collaborator" } 15 + }, 16 + "createdAt": { "type": "string", "format": "datetime" }, 17 + "subject": { 18 + "type": "union", 19 + "refs": ["#playlist"] 20 + }, 21 + "updatedAt": { "type": "string", "format": "datetime" } 22 + } 23 + } 24 + }, 25 + "collaborator": { 26 + "type": "object", 27 + "required": ["id", "hint"], 28 + "properties": { 29 + "id": { "type": "string" }, 30 + "hint": { 31 + "type": "string", 32 + "description": "Hint at where this collaborator resides and/or what the id represents." 33 + } 34 + } 35 + }, 36 + "playlist": { 37 + "type": "object", 38 + "required": ["id", "hint", "kind"], 39 + "properties": { 40 + "id": { "type": "string" }, 41 + "hint": { 42 + "type": "string", 43 + "description": "Hint at where this collaborator resides and/or what the id represents." 44 + }, 45 + "kind": { "type": "string", "const": "playlist" } 46 + } 47 + } 48 + } 49 + }
+4 -1
src/definitions/output/playlistItem.json
··· 15 15 "items": { "type": "ref", "ref": "#criterion" } 16 16 }, 17 17 "playlist": { "type": "string" }, 18 - "position": { "type": "integer" }, 18 + "positionedAfter": { 19 + "type": "string", 20 + "description": "Id of the item that this item should be positioned after" 21 + }, 19 22 "updatedAt": { "type": "string", "format": "datetime" } 20 23 } 21 24 }
+2 -1
src/facets/tools/v3-import.html.txt
··· 79 79 </style> 80 80 81 81 <script type="module"> 82 + import { Temporal } from "@js-temporal/polyfill"; 82 83 import foundation from "./common/facets/foundation.js"; 83 84 84 85 // Setup ··· 162 163 if (!items || items.length === 0) return; 163 164 164 165 try { 165 - const now = new Date().toISOString(); 166 + const now = Temporal.Now.zonedDateTimeISO().toString(); 166 167 const existing = output.playlistItems.collection() ?? []; 167 168 const existingPlaylistNames = new Set(existing.map((p) => p.playlist)); 168 169
+3 -19
src/themes/webamp/browser/element.js
··· 1 - import { 2 - DiffuseElement, 3 - nothing, 4 - query, 5 - whenElementsDefined, 6 - } from "@common/element.js"; 1 + import { DiffuseElement, query, whenElementsDefined } from "@common/element.js"; 7 2 import { computed, signal, untracked } from "@common/signal.js"; 3 + import * as Playlist from "@common/playlist.js"; 8 4 9 5 /** 10 6 * @import {RenderArg} from "@common/element.d.ts" ··· 48 44 49 45 // Group items by playlist name 50 46 /** @type {Map<string, { name: string, unordered: boolean }>} */ 51 - const playlistMap = new Map(); 52 - 53 - for (const item of items) { 54 - const existing = playlistMap.get(item.playlist); 55 - if (!existing) { 56 - playlistMap.set(item.playlist, { 57 - name: item.playlist, 58 - unordered: item.position == null, 59 - }); 60 - } else if (item.position == null) { 61 - existing.unordered = true; 62 - } 63 - } 47 + const playlistMap = Playlist.gather(items); 64 48 65 49 const all = [...playlistMap.values()].sort((a, b) => 66 50 a.name.localeCompare(b.name)