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.

refactor: playlists -> playlist items

+359 -325
+1 -1
deno.jsonc
··· 184 184 }, 185 185 "test": { 186 186 "description": "Run tests", 187 - "command": "deno test -A --doc --ignore=README.md", 187 + "command": "deno test -A --doc --ignore=README.md --ignore=./docs/ --ignore=./dist/", 188 188 }, 189 189 }, 190 190 "compilerOptions": {
+39 -8
src/common/playlist.js
··· 1 1 /** 2 - * @import {Playlist, PlaylistItem, Track} from "@definitions/types.d.ts" 2 + * @import {PlaylistItem, Track} from "@definitions/types.d.ts" 3 + */ 4 + 5 + /** 6 + * Bundle playlist items into their respective playlists. 7 + * 8 + * @param {PlaylistItem[]} items 3 9 */ 10 + export function gather(items) { 11 + /** 12 + * @type {Map<string, { items: PlaylistItem[]; name: string; unordered: boolean }>} 13 + */ 14 + const playlistMap = new Map(); 15 + 16 + for (const item of items) { 17 + const existing = playlistMap.get(item.playlist); 18 + 19 + if (!existing) { 20 + playlistMap.set(item.playlist, { 21 + items: [item], 22 + name: item.playlist, 23 + unordered: item.position == null, 24 + }); 25 + } else if (item.position == null) { 26 + existing.items.push(item); 27 + existing.unordered = true; 28 + } 29 + } 30 + 31 + return playlistMap; 32 + } 4 33 5 34 /** 6 35 * @param {any} val ··· 52 81 * Filter tracks by playlist membership using an indexed lookup. 53 82 * 54 83 * @param {Track[]} tracks 55 - * @param {Playlist} playlist 84 + * @param {PlaylistItem[]} playlistItems 56 85 */ 57 - export function filterByPlaylist(tracks, playlist) { 86 + export function filterByPlaylist(tracks, playlistItems) { 58 87 // Group playlist items by criteria shape, building a Set index per shape. 59 - const shapes = playlist.items 88 + const shapes = playlistItems 60 89 .reduce( 61 - (acc, item) => { 62 - const shapeKey = item.criteria 90 + (acc, playlistItem) => { 91 + const shapeKey = playlistItem.criteria 63 92 .map((c) => `${c.field}\0${(c.transformations ?? []).join(",")}`) 64 93 .join("\0\0"); 65 94 66 95 const group = acc.get(shapeKey) ?? acc 67 - .set(shapeKey, { criteria: item.criteria, keys: new Set() }) 96 + .set(shapeKey, { criteria: playlistItem.criteria, keys: new Set() }) 68 97 .get(shapeKey); 69 98 70 99 group?.keys.add( 71 - item.criteria.map((c) => transform(c.value, c.transformations)).join( 100 + playlistItem.criteria.map((c) => 101 + transform(c.value, c.transformations) 102 + ).join( 72 103 "\0", 73 104 ), 74 105 );
+8 -8
src/components/configurator/output-fallback/element.js
··· 48 48 return this.#activeOutput.value?.facets.state() ?? "sleeping"; 49 49 }), 50 50 }, 51 - playlists: { 51 + playlistItems: { 52 52 collection: computed(() => { 53 - return this.#activeOutput.value?.playlists.collection(); 53 + return this.#activeOutput.value?.playlistItems.collection(); 54 54 }), 55 55 reload: () => { 56 56 const out = this.#activeOutput.value; 57 - if (out) return out.playlists.reload(); 57 + if (out) return out.playlistItems.reload(); 58 58 return Promise.resolve(); 59 59 }, 60 - save: async (newPlaylists) => { 61 - if (newPlaylists !== undefined) { 60 + save: async (newPlaylistItems) => { 61 + if (newPlaylistItems !== undefined) { 62 62 await Promise.all( 63 - this.#outputs.map((o) => o.playlists.save(newPlaylists)), 63 + this.#outputs.map((o) => o.playlistItems.save(newPlaylistItems)), 64 64 ); 65 65 } 66 66 }, 67 67 state: computed(() => { 68 - return this.#activeOutput.value?.playlists.state() ?? "sleeping"; 68 + return this.#activeOutput.value?.playlistItems.state() ?? "sleeping"; 69 69 }), 70 70 }, 71 71 themes: { ··· 117 117 }; 118 118 119 119 this.facets = manager.facets; 120 - this.playlists = manager.playlists; 120 + this.playlistItems = manager.playlistItems; 121 121 this.themes = manager.themes; 122 122 this.tracks = manager.tracks; 123 123 this.ready = manager.ready;
+15 -15
src/components/configurator/output/element.js
··· 5 5 import { batch, computed, signal } from "@common/signal.js"; 6 6 7 7 /** 8 - * @import {Facet, Playlist, Theme, Track} from "@definitions/types.d.ts" 8 + * @import {Facet, PlaylistItem, Theme, Track} from "@definitions/types.d.ts" 9 9 * @import {OutputManagerDeputy, OutputElement} from "@components/output/types.d.ts" 10 10 * 11 11 * @import {OutputConfiguratorElement} from "./types.d.ts" ··· 70 70 return this.#setupFinished.value ? "loaded" : "sleeping"; 71 71 }), 72 72 }, 73 - playlists: { 73 + playlistItems: { 74 74 collection: computed(() => { 75 75 const out = this.#selectedOutput.value; 76 - if (out) return out.playlists.collection(); 76 + if (out) return out.playlistItems.collection(); 77 77 78 78 const def = this.#defaultOutput.value; 79 - if (def) return def.playlists.collection(); 79 + if (def) return def.playlistItems.collection(); 80 80 81 - return this.#memory.playlists.value; 81 + return this.#memory.playlistItems.value; 82 82 }), 83 83 reload: () => { 84 84 const def = this.#defaultOutput.value; 85 - if (def) def.playlists.reload(); 85 + if (def) def.playlistItems.reload(); 86 86 87 87 const out = this.#selectedOutput.value; 88 - if (out) return out.playlists.reload(); 88 + if (out) return out.playlistItems.reload(); 89 89 90 90 return Promise.resolve(); 91 91 }, 92 - save: async (newPlaylists) => { 92 + save: async (newPlaylistItems) => { 93 93 const out = this.#selectedOutput.value; 94 - if (out) return await out.playlists.save(newPlaylists); 94 + if (out) return await out.playlistItems.save(newPlaylistItems); 95 95 96 96 const def = this.#defaultOutput.value; 97 - if (def) return await def.playlists.save(newPlaylists); 97 + if (def) return await def.playlistItems.save(newPlaylistItems); 98 98 99 - this.#memory.playlists.value = newPlaylists; 99 + this.#memory.playlistItems.value = newPlaylistItems; 100 100 }, 101 101 state: computed(() => { 102 102 const out = this.#selectedOutput.value; 103 - if (out) return out.playlists.state(); 103 + if (out) return out.playlistItems.state(); 104 104 105 105 const def = this.#defaultOutput.value; 106 - if (def) return def.playlists.state(); 106 + if (def) return def.playlistItems.state(); 107 107 108 108 return this.#setupFinished.value ? "loaded" : "sleeping"; 109 109 }), ··· 199 199 200 200 // Assign manager properties to class 201 201 this.facets = manager.facets; 202 - this.playlists = manager.playlists; 202 + this.playlistItems = manager.playlistItems; 203 203 this.themes = manager.themes; 204 204 this.tracks = manager.tracks; 205 205 this.ready = manager.ready; ··· 213 213 214 214 #memory = { 215 215 facets: signal(/** @type {Facet[]} */ ([])), 216 - playlists: signal(/** @type {Playlist[]} */ ([])), 216 + playlistItems: signal(/** @type {PlaylistItem[]} */ ([])), 217 217 themes: signal(/** @type {Theme[]} */ ([])), 218 218 tracks: signal(/** @type {Track[]} */ ([])), 219 219 };
+7 -7
src/components/engine/scope/element.js
··· 10 10 11 11 // SIGNALS 12 12 13 - #playlistId = signal(/** @type {string | undefined} */ (undefined)); 13 + #playlist = signal(/** @type {string | undefined} */ (undefined)); 14 14 #searchTerm = signal(/** @type {string | undefined} */ (undefined)); 15 15 16 - playlistId = this.#playlistId.get; 16 + playlist = this.#playlist.get; 17 17 searchTerm = this.#searchTerm.get; 18 18 19 19 // LIFECYCLE ··· 25 25 // Broadcast if needed 26 26 if (this.hasAttribute("group")) { 27 27 const actions = this.broadcast(this.nameWithGroup, { 28 - setPlaylistId: { strategy: "replicate", fn: this.setPlaylistId }, 28 + setPlaylist: { strategy: "replicate", fn: this.setPlaylist }, 29 29 setSearchTerm: { strategy: "replicate", fn: this.setSearchTerm }, 30 30 }); 31 31 32 32 if (actions) { 33 - this.setPlaylistId = actions.setPlaylistId; 33 + this.setPlaylist = actions.setPlaylist; 34 34 this.setSearchTerm = actions.setSearchTerm; 35 35 } 36 36 } ··· 42 42 const storagePrefix = 43 43 `${this.constructor.prototype.constructor.NAME}/${this.group}/`; 44 44 45 - this.#playlistId.value = 45 + this.#playlist.value = 46 46 localStorage.getItem(`${storagePrefix}/playlistId`) ?? undefined; 47 47 this.#searchTerm.value = 48 48 localStorage.getItem(`${storagePrefix}/searchTerm`) ?? undefined; ··· 50 50 // Effects 51 51 this.effect(() => { 52 52 const key = `${storagePrefix}/playlistId`; 53 - const val = this.#playlistId.value; 53 + const val = this.#playlist.value; 54 54 55 55 if (val) localStorage.setItem(key, val); 56 56 else localStorage.removeItem(key); ··· 68 68 // ACTIONS 69 69 70 70 /** @param {string | undefined} val */ 71 - setPlaylistId = async (val) => this.#playlistId.value = val; 71 + setPlaylist = async (val) => this.#playlist.value = val; 72 72 73 73 /** @param {string | undefined} val */ 74 74 setSearchTerm = async (val) => this.#searchTerm.value = val;
+5 -18
src/components/orchestrator/favourites/common.js
··· 1 1 /** 2 - * @import {Playlist} from "@definitions/types.d.ts" 3 - */ 4 - 5 - /** 6 - * Creates an empty favourites playlist structure. 2 + * Filter playlist items that belong to the favourites playlist. 7 3 * 8 - * @returns {Playlist} 4 + * @param {import("@definitions/types.d.ts").PlaylistItem[]} playlistItems 5 + * @returns {import("@definitions/types.d.ts").PlaylistItem[]} 9 6 */ 10 - export function createEmptyFavouritesPlaylist() { 11 - const now = new Date().toISOString(); 12 - 13 - return /** @type {Playlist} */ ({ 14 - $type: "sh.diffuse.output.playlist", 15 - id: "favourites", 16 - name: "Favourites", 17 - unordered: true, 18 - items: [], 19 - createdAt: now, 20 - updatedAt: now, 21 - }); 7 + export function filterFavourites(playlistItems) { 8 + return playlistItems.filter((item) => item.playlist === "Favourites"); 22 9 }
+17 -18
src/components/orchestrator/favourites/element.js
··· 1 1 import { BroadcastableDiffuseElement, query } from "@common/element.js"; 2 2 import { match as matchPlaylistItem } from "@common/playlist.js"; 3 3 import { computed, signal } from "@common/signal.js"; 4 - import { createEmptyFavouritesPlaylist } from "./common.js"; 4 + import { filterFavourites } from "./common.js"; 5 5 6 6 /** 7 7 * @import {Track} from "@definitions/types.d.ts" ··· 39 39 // STATE 40 40 41 41 /** 42 - * Returns the favourites playlist. 43 - * @returns {Playlist} 42 + * Returns the favourites playlist items. 44 43 */ 45 - playlist = computed(() => { 44 + playlistItems = computed(() => { 46 45 const output = this.#output.value; 47 - if (!output) return createEmptyFavouritesPlaylist(); 46 + if (!output) return []; 48 47 49 - const playlists = output.playlists.collection(); 50 - return playlists?.find((p) => p.id === "favourites") ?? 51 - createEmptyFavouritesPlaylist(); 48 + const playlistItems = output.playlistItems.collection(); 49 + return filterFavourites(playlistItems ?? []); 52 50 }); 53 51 54 52 // LIFECYCLE ··· 100 98 return; 101 99 } 102 100 103 - const playlists = output.playlists.collection(); 101 + const playlistItems = output.playlistItems.collection(); 104 102 const result = await this.#proxy.include({ 105 - playlists, 103 + playlistItems, 106 104 tracks: tracksArray, 107 105 }); 108 - if (result) await output.playlists.save(result); 106 + 107 + if (result) await output.playlistItems.save(result); 109 108 } 110 109 111 110 /** ··· 122 121 return; 123 122 } 124 123 125 - const playlists = output.playlists.collection(); 124 + const playlistItems = output.playlistItems.collection(); 126 125 const result = await this.#proxy.expel({ 127 - playlists, 126 + playlistItems, 128 127 tracks: tracksArray, 129 128 }); 130 129 131 - if (result) await output.playlists.save(result); 130 + if (result) await output.playlistItems.save(result); 132 131 } 133 132 134 133 /** ··· 146 145 return; 147 146 } 148 147 149 - const playlists = output.playlists.collection(); 148 + const playlistItems = output.playlistItems.collection(); 150 149 const result = await this.#proxy.toggle({ 151 - playlists, 150 + playlistItems, 152 151 tracks: tracksArray, 153 152 }); 154 153 155 - if (result) await output.playlists.save(result); 154 + if (result) await output.playlistItems.save(result); 156 155 } 157 156 158 157 // 🛠️ ··· 163 162 * @param {Track} track 164 163 */ 165 164 isFavourite(track) { 166 - return this.playlist().items.some((item) => { 165 + return this.playlistItems().some((item) => { 167 166 return matchPlaylistItem(track, item); 168 167 }); 169 168 }
+7 -7
src/components/orchestrator/favourites/types.d.ts
··· 1 - import type { Playlist, Track } from "@definitions/types.d.ts"; 1 + import type { PlaylistItem, Track } from "@definitions/types.d.ts"; 2 2 3 3 export type Actions = { 4 - include(args: { playlists: Playlist[]; tracks: Track[] }): Promise< 5 - Playlist[] | null 4 + include(args: { playlistItems: PlaylistItem[]; tracks: Track[] }): Promise< 5 + PlaylistItem[] | null 6 6 >; 7 - expel(args: { playlists: Playlist[]; tracks: Track[] }): Promise< 8 - Playlist[] | null 7 + expel(args: { playlistItems: PlaylistItem[]; tracks: Track[] }): Promise< 8 + PlaylistItem[] | null 9 9 >; 10 - toggle(args: { playlists: Playlist[]; tracks: Track[] }): Promise< 11 - Playlist[] | null 10 + toggle(args: { playlistItems: PlaylistItem[]; tracks: Track[] }): Promise< 11 + PlaylistItem[] | null 12 12 >; 13 13 };
+43 -73
src/components/orchestrator/favourites/worker.js
··· 1 1 import { ostiary, rpc } from "@common/worker.js"; 2 - import { createEmptyFavouritesPlaylist } from "./common.js"; 2 + import { filterFavourites } from "./common.js"; 3 3 4 4 /** 5 - * @import {Playlist, Track} from "@definitions/types.d.ts" 5 + * @import {PlaylistItem, Track} from "@definitions/types.d.ts" 6 6 * @import {Actions} from "./types.d.ts" 7 7 */ 8 8 ··· 21 21 22 22 /** 23 23 * Extract the matching key from a playlist item's criteria. 24 - * @param {{ criteria: { field: string; value: unknown }[] }} item 24 + * @param {PlaylistItem} item 25 25 * @returns {string} 26 26 */ 27 27 function itemMatchKey(item) { ··· 33 33 } 34 34 35 35 /** 36 - * Create criteria entries from a track's tags. 36 + * Create a favourites playlist item from a track. 37 37 * @param {Track} track 38 + * @returns {PlaylistItem} 38 39 */ 39 - function trackCriteria(track) { 40 + function createFavouriteItem(track) { 40 41 const transformations = ["toLowerCase"]; 42 + const now = new Date().toISOString(); 41 43 42 - return [ 43 - { 44 - field: "tags.artist", 45 - value: /** @type {unknown} */ (track.tags?.artist), 46 - transformations, 47 - }, 48 - { 49 - field: "tags.title", 50 - value: /** @type {unknown} */ (track.tags?.title), 51 - transformations, 52 - }, 53 - ]; 44 + return /** @type {PlaylistItem} */ ({ 45 + $type: "sh.diffuse.output.playlistItem", 46 + id: crypto.randomUUID(), 47 + playlist: "Favourites", 48 + criteria: [ 49 + { 50 + field: "tags.artist", 51 + value: /** @type {unknown} */ (track.tags?.artist), 52 + transformations, 53 + }, 54 + { 55 + field: "tags.title", 56 + value: /** @type {unknown} */ (track.tags?.title), 57 + transformations, 58 + }, 59 + ], 60 + createdAt: now, 61 + updatedAt: now, 62 + }); 54 63 } 55 64 56 65 //////////////////////////////////////////// ··· 61 70 * Add one or more tracks to favourites. 62 71 * @type {Actions["include"]} 63 72 */ 64 - export async function include({ playlists, tracks }) { 73 + export async function include({ playlistItems, tracks }) { 65 74 if (tracks.length === 0) return null; 66 75 67 - const favourites = playlists.find((p) => p.id === "favourites"); 76 + const favourites = filterFavourites(playlistItems); 68 77 69 - // Get existing favourite keys (artist + title) 70 78 const existingKeys = new Set( 71 - favourites?.items.map((item) => itemMatchKey(item)) ?? [], 79 + favourites.map((item) => itemMatchKey(item)), 72 80 ); 73 81 74 - // Filter out tracks that are already favourites 75 82 const newTracks = tracks.filter((track) => 76 83 !existingKeys.has(matchKey(track)) 77 84 ); 78 85 79 86 if (newTracks.length === 0) return null; 80 87 81 - // Create or update favourites playlist 82 - const now = new Date().toISOString(); 83 - const newItems = newTracks.map((track) => ({ 84 - criteria: trackCriteria(track), 85 - })); 86 - 87 - /** @type {Playlist} */ 88 - const updatedFavourites = favourites 89 - ? /** @type {Playlist} */ ({ 90 - ...favourites, 91 - items: [...favourites.items, ...newItems], 92 - updatedAt: now, 93 - }) 94 - : /** @type {Playlist} */ ({ 95 - ...createEmptyFavouritesPlaylist(), 96 - items: newItems, 97 - }); 88 + const newItems = newTracks.map((track) => createFavouriteItem(track)); 98 89 99 - const otherPlaylists = playlists.filter((p) => p.id !== "favourites"); 100 - return [...otherPlaylists, updatedFavourites]; 90 + return [...playlistItems, ...newItems]; 101 91 } 102 92 103 93 /** 104 94 * Remove one or more tracks from favourites. 105 95 * @type {Actions["expel"]} 106 96 */ 107 - export async function expel({ playlists, tracks }) { 97 + export async function expel({ playlistItems, tracks }) { 108 98 if (tracks.length === 0) return null; 109 99 110 - const favourites = playlists.find((p) => p.id === "favourites"); 111 - if (!favourites) return null; 112 - 113 - // Create set of track keys to remove 114 100 const keysToRemove = new Set(tracks.map((track) => matchKey(track))); 115 101 116 - // Filter out items matching the tracks to remove 117 - const updatedItems = favourites.items.filter((item) => 118 - !keysToRemove.has(itemMatchKey(item)) 102 + const updatedItems = playlistItems.filter((item) => 103 + item.playlist !== "Favourites" || !keysToRemove.has(itemMatchKey(item)) 119 104 ); 120 105 121 - // If nothing changed, don't save 122 - if (updatedItems.length === favourites.items.length) return null; 123 - 124 - const now = new Date().toISOString(); 106 + if (updatedItems.length === playlistItems.length) return null; 125 107 126 - /** @type {Playlist} */ 127 - const updatedFavourites = { 128 - ...favourites, 129 - items: updatedItems, 130 - updatedAt: now, 131 - }; 132 - 133 - const otherPlaylists = playlists.filter((p) => p.id !== "favourites"); 134 - return [...otherPlaylists, updatedFavourites]; 108 + return updatedItems; 135 109 } 136 110 137 111 /** 138 112 * Toggle favourite status for one or more tracks. 139 113 * @type {Actions["toggle"]} 140 114 */ 141 - export async function toggle({ playlists, tracks }) { 115 + export async function toggle({ playlistItems, tracks }) { 142 116 if (tracks.length === 0) return null; 143 117 144 - const favourites = playlists.find((p) => p.id === "favourites"); 118 + const favourites = filterFavourites(playlistItems); 145 119 146 - // Get existing favourite keys (artist + title) 147 120 const existingKeys = new Set( 148 - favourites?.items.map((item) => itemMatchKey(item)) ?? [], 121 + favourites.map((item) => itemMatchKey(item)), 149 122 ); 150 123 151 - // Separate tracks into those to add and those to remove 152 124 const toAdd = tracks.filter((track) => !existingKeys.has(matchKey(track))); 153 125 const toRemove = tracks.filter((track) => existingKeys.has(matchKey(track))); 154 126 155 - // Apply add then remove in sequence 156 - let result = playlists; 127 + let result = playlistItems; 157 128 158 129 if (toAdd.length > 0) { 159 - const added = await include({ playlists: result, tracks: toAdd }); 130 + const added = await include({ playlistItems: result, tracks: toAdd }); 160 131 if (added) result = added; 161 132 } 162 133 163 134 if (toRemove.length > 0) { 164 - const removed = await expel({ playlists: result, tracks: toRemove }); 135 + const removed = await expel({ playlistItems: result, tracks: toRemove }); 165 136 if (removed) result = removed; 166 137 } 167 138 168 - // If nothing changed, return null 169 - if (result === playlists) return null; 139 + if (result === playlistItems) return null; 170 140 return result; 171 141 } 172 142
+2 -2
src/components/orchestrator/output/element.js
··· 60 60 return this.output.facets; 61 61 } 62 62 63 - get playlists() { 64 - return this.output.playlists; 63 + get playlistItems() { 64 + return this.output.playlistItems; 65 65 } 66 66 67 67 get themes() {
+9 -8
src/components/orchestrator/scoped-tracks/element.js
··· 49 49 /** @type {import("@components/processor/search/element.js").CLASS | null} */ (null), 50 50 ); 51 51 52 - #selectedPlaylist = computed(() => { 53 - const playlistId = this.#scope.value?.playlistId(); 54 - if (!playlistId) return undefined; 55 - return this.#output.value?.playlists.collection().find((p) => 56 - p.id === playlistId 52 + #selectedPlaylistItems = computed(() => { 53 + const playlist = this.#scope.value?.playlist(); 54 + if (!playlist) return undefined; 55 + 56 + return this.#output.value?.playlistItems.collection().filter((p) => 57 + p.playlist === playlist 57 58 ); 58 59 }); 59 60 ··· 144 145 // Watch `#tracksSearch` + Playlist 145 146 this.effect(async () => { 146 147 const tracks = this.#tracksSearch.value; 147 - const playlist = this.#selectedPlaylist(); 148 + const playlistItems = this.#selectedPlaylistItems(); 148 149 149 150 if ((await this.isLeader()) === false) return; 150 151 151 - const final = playlist 152 - ? await this.#proxy.filterByPlaylist({ tracks, playlist }) 152 + const final = playlistItems?.length 153 + ? await this.#proxy.filterByPlaylist({ tracks, playlistItems }) 153 154 : tracks; 154 155 155 156 this.#tracksFinal.set(final);
+2 -2
src/components/orchestrator/scoped-tracks/types.d.ts
··· 1 1 import type { SearchParams } from "@orama/orama"; 2 2 3 - import type { Playlist, Track } from "@definitions/types.d.ts"; 3 + import type { PlaylistItem, Track } from "@definitions/types.d.ts"; 4 4 import type { Schema } from "@components/processor/search/types.d.ts"; 5 5 6 6 export type Actions = { 7 7 filterByPlaylist( 8 - args: { tracks: Track[]; playlist: Playlist }, 8 + args: { tracks: Track[]; playlistItems: PlaylistItem[] }, 9 9 ): Promise<Track[]>; 10 10 searchTracks(params: SearchParams<Schema>): Promise<Track[]>; 11 11 supply(tracks: Track[]): Promise<{ availableTracks: Track[] }>;
+1 -1
src/components/orchestrator/scoped-tracks/worker.js
··· 65 65 * @type {ActionsWithTunnel<Actions>["filterByPlaylist"]} 66 66 */ 67 67 export async function filterByPlaylist({ data }) { 68 - return filterByPlaylistFn(data.tracks, data.playlist); 68 + return filterByPlaylistFn(data.tracks, data.playlistItems); 69 69 } 70 70 71 71 ////////////////////////////////////////////
+6 -6
src/components/output/bytes/automerge-repo-server/element.js
··· 19 19 20 20 const COLLECTIONS = /** @type {const} */ ([ 21 21 "facets", 22 - "playlists", 22 + "playlistItems", 23 23 "themes", 24 24 "tracks", 25 25 ]); ··· 73 73 put: async (data) => this.#putBytes("facets", data), 74 74 }, 75 75 init: () => this.whenConnected(), 76 - playlists: { 77 - empty: () => this.#getBytes("playlists"), 78 - get: async () => this.#getBytes("playlists"), 79 - put: async (data) => this.#putBytes("playlists", data), 76 + playlistItems: { 77 + empty: () => this.#getBytes("playlistItems"), 78 + get: async () => this.#getBytes("playlistItems"), 79 + put: async (data) => this.#putBytes("playlistItems", data), 80 80 }, 81 81 themes: { 82 82 empty: () => this.#getBytes("themes"), ··· 93 93 this.#manager = outputManager(properties); 94 94 95 95 this.facets = this.#manager.facets; 96 - this.playlists = this.#manager.playlists; 96 + this.playlistItems = this.#manager.playlistItems; 97 97 this.themes = this.#manager.themes; 98 98 this.tracks = this.#manager.tracks; 99 99 this.ready = () => true;
+7 -5
src/components/output/bytes/s3/element.js
··· 41 41 put: (data) => this.#put("facets", data), 42 42 }, 43 43 init: () => this.whenConnected(), 44 - playlists: { 44 + playlistItems: { 45 45 empty: () => undefined, 46 - get: () => this.#get("playlists"), 47 - put: (data) => this.#put("playlists", data), 46 + get: () => this.#get("playlistItems"), 47 + put: (data) => this.#put("playlistItems", data), 48 48 }, 49 49 themes: { 50 50 empty: () => undefined, ··· 59 59 }); 60 60 61 61 this.facets = this.#manager.facets; 62 - this.playlists = this.#manager.playlists; 62 + this.playlistItems = this.#manager.playlistItems; 63 63 this.themes = this.#manager.themes; 64 64 this.tracks = this.#manager.tracks; 65 65 } ··· 167 167 } else { 168 168 // Listener 169 169 if (name === "facets") this.#manager.signals.facets.value = data; 170 - if (name === "playlists") this.#manager.signals.playlists.value = data; 170 + if (name === "playlistItems") { 171 + this.#manager.signals.playlistItems.value = data; 172 + } 171 173 if (name === "themes") this.#manager.signals.themes.value = data; 172 174 if (name === "tracks") this.#manager.signals.tracks.value = data; 173 175 }
+13 -13
src/components/output/common.js
··· 1 1 import { computed, signal, untracked } from "@common/signal.js"; 2 2 3 3 /** 4 - * @import {Facet, Playlist, Theme, Track} from "@definitions/types.d.ts" 4 + * @import {Facet, PlaylistItem, Theme, Track} from "@definitions/types.d.ts" 5 5 * @import {OutputManager, OutputManagerProperties} from "./types.d.ts" 6 6 */ 7 7 ··· 10 10 * @param {OutputManagerProperties<Encoding>} _ 11 11 * @returns {OutputManager<Encoding>} 12 12 */ 13 - export function outputManager({ init, facets, playlists, themes, tracks }) { 13 + export function outputManager({ init, facets, playlistItems, themes, tracks }) { 14 14 const c = signal( 15 15 /** @type {Encoding extends null ? Facet[] : Encoding} */ (facets 16 16 .empty()), ··· 20 20 ); 21 21 22 22 const pl = signal( 23 - /** @type {Encoding extends null ? Playlist[] : Encoding} */ (playlists 23 + /** @type {Encoding extends null ? PlaylistItem[] : Encoding} */ (playlistItems 24 24 .empty()), 25 25 ); 26 26 const pls = signal( ··· 48 48 cs.value = "loaded"; 49 49 } 50 50 51 - async function loadPlaylists() { 51 + async function loadPlaylistItems() { 52 52 if (init && (await init()) === false) return; 53 53 pls.value = "loading"; 54 - pl.value = await playlists.get(); 54 + pl.value = await playlistItems.get(); 55 55 pls.value = "loaded"; 56 56 } 57 57 ··· 83 83 }, 84 84 state: cs.get, 85 85 }, 86 - playlists: { 86 + playlistItems: { 87 87 collection: computed(() => { 88 - if (untracked(() => pls.value === "sleeping")) loadPlaylists(); 88 + if (untracked(() => pls.value === "sleeping")) loadPlaylistItems(); 89 89 return pl.value; 90 90 }), 91 - reload: loadPlaylists, 92 - save: async (newPlaylists) => { 93 - if (untracked(() => pls.value === "sleeping")) loadPlaylists(); 94 - pl.value = newPlaylists; 95 - await playlists.put(newPlaylists); 91 + reload: loadPlaylistItems, 92 + save: async (newPlaylistItems) => { 93 + if (untracked(() => pls.value === "sleeping")) loadPlaylistItems(); 94 + pl.value = newPlaylistItems; 95 + await playlistItems.put(newPlaylistItems); 96 96 }, 97 97 state: pls.get, 98 98 }, ··· 124 124 }, 125 125 signals: { 126 126 facets: c, 127 - playlists: pl, 127 + playlistItems: pl, 128 128 themes: th, 129 129 tracks: t, 130 130 },
+7 -5
src/components/output/polymorphic/indexed-db/element.js
··· 34 34 put: (data) => this.#put("facets", data), 35 35 }, 36 36 init: () => this.whenConnected(), 37 - playlists: { 37 + playlistItems: { 38 38 empty: () => undefined, 39 - get: () => this.#get("playlists"), 40 - put: (data) => this.#put("playlists", data), 39 + get: () => this.#get("playlistItems"), 40 + put: (data) => this.#put("playlistItems", data), 41 41 }, 42 42 themes: { 43 43 empty: () => undefined, ··· 52 52 }); 53 53 54 54 this.facets = this.#manager.facets; 55 - this.playlists = this.#manager.playlists; 55 + this.playlistItems = this.#manager.playlistItems; 56 56 this.themes = this.#manager.themes; 57 57 this.tracks = this.#manager.tracks; 58 58 this.ready = () => true; ··· 114 114 } else { 115 115 // Listener 116 116 if (name === "facets") this.#manager.signals.facets.value = data; 117 - if (name === "playlists") this.#manager.signals.playlists.value = data; 117 + if (name === "playlistItems") { 118 + this.#manager.signals.playlistItems.value = data; 119 + } 118 120 if (name === "themes") this.#manager.signals.themes.value = data; 119 121 if (name === "tracks") this.#manager.signals.tracks.value = data; 120 122 }
+6 -6
src/components/output/raw/atproto/element.js
··· 41 41 get: () => this.#listRecords("sh.diffuse.output.facet"), 42 42 put: (data) => this.#putRecords("sh.diffuse.output.facet", data), 43 43 }, 44 - playlists: { 44 + playlistItems: { 45 45 empty: () => [], 46 - get: () => this.#listRecords("sh.diffuse.output.playlist"), 47 - put: (data) => this.#putRecords("sh.diffuse.output.playlist", data), 46 + get: () => this.#listRecords("sh.diffuse.output.playlistItem"), 47 + put: (data) => this.#putRecords("sh.diffuse.output.playlistItem", data), 48 48 }, 49 49 themes: { 50 50 empty: () => [], ··· 59 59 }); 60 60 61 61 this.facets = this.#manager.facets; 62 - this.playlists = this.#manager.playlists; 62 + this.playlistItems = this.#manager.playlistItems; 63 63 this.themes = this.#manager.themes; 64 64 this.tracks = this.#manager.tracks; 65 65 } ··· 73 73 did = this.#did.get; 74 74 75 75 ready = computed(() => { 76 - return this.#did.value !== null && navigator.onLine 76 + return this.#did.value !== null && navigator.onLine; 77 77 }); 78 78 79 79 // LIFECYCLE ··· 279 279 /** @type {Record<string, Signal<unknown[]>>} */ 280 280 const collectionMap = { 281 281 "sh.diffuse.output.facet": this.#manager.signals.facets, 282 - "sh.diffuse.output.playlist": this.#manager.signals.playlists, 282 + "sh.diffuse.output.playlistItem": this.#manager.signals.playlistItems, 283 283 "sh.diffuse.output.theme": this.#manager.signals.themes, 284 284 "sh.diffuse.output.track": this.#manager.signals.tracks, 285 285 };
+14 -9
src/components/output/types.d.ts
··· 1 1 import type { Signal, SignalReader } from "@common/signal.d.ts"; 2 2 import type { DiffuseElement } from "@common/element.js"; 3 - import type { Facet, Playlist, Theme, Track } from "@definitions/types.d.ts"; 3 + import type { 4 + Facet, 5 + PlaylistItem, 6 + Theme, 7 + Track, 8 + } from "@definitions/types.d.ts"; 4 9 5 10 export type OutputElement<Encoding = null> = 6 11 & DiffuseElement ··· 19 24 ) => Promise<void>; 20 25 state: SignalReader<"loading" | "loaded" | "sleeping">; 21 26 }; 22 - playlists: { 23 - collection: SignalReader<Encoding extends null ? Playlist[] : Encoding>; 27 + playlistItems: { 28 + collection: SignalReader<Encoding extends null ? PlaylistItem[] : Encoding>; 24 29 reload: () => Promise<void>; 25 30 save: ( 26 - playlists: Encoding extends null ? Playlist[] : Encoding, 31 + playlistItems: Encoding extends null ? PlaylistItem[] : Encoding, 27 32 ) => Promise<void>; 28 33 state: SignalReader<"loading" | "loaded" | "sleeping">; 29 34 }; 30 35 signals: { 31 36 facets: Signal<Encoding extends null ? Facet[] : Encoding>; 32 - playlists: Signal<Encoding extends null ? Playlist[] : Encoding>; 37 + playlistItems: Signal<Encoding extends null ? PlaylistItem[] : Encoding>; 33 38 themes: Signal<Encoding extends null ? Theme[] : Encoding>; 34 39 tracks: Signal<Encoding extends null ? Track[] : Encoding>; 35 40 }; ··· 58 63 ): Promise<void>; 59 64 }; 60 65 init?: () => Promise<boolean>; 61 - playlists: { 62 - empty(): Encoding extends null ? Playlist[] : Encoding; 63 - get(): Promise<Encoding extends null ? Playlist[] : Encoding>; 66 + playlistItems: { 67 + empty(): Encoding extends null ? PlaylistItem[] : Encoding; 68 + get(): Promise<Encoding extends null ? PlaylistItem[] : Encoding>; 64 69 put( 65 - playlists: Encoding extends null ? Playlist[] : Encoding, 70 + playlistItems: Encoding extends null ? PlaylistItem[] : Encoding, 66 71 ): Promise<void>; 67 72 }; 68 73 themes: {
+7 -7
src/components/transformer/output/base.js
··· 59 59 return this.output.signal()?.facets.state() ?? "sleeping"; 60 60 }), 61 61 }, 62 - playlists: { 62 + playlistItems: { 63 63 collection: computed(() => { 64 - return this.output.signal()?.playlists?.collection(); 64 + return this.output.signal()?.playlistItems?.collection(); 65 65 }), 66 66 reload: () => { 67 - return this.output.signal()?.playlists?.reload() ?? 67 + return this.output.signal()?.playlistItems?.reload() ?? 68 68 Promise.resolve(); 69 69 }, 70 - save: async (newPlaylists) => { 71 - if (newPlaylists === undefined) return; 70 + save: async (newPlaylistItems) => { 71 + if (newPlaylistItems === undefined) return; 72 72 await this.output.whenDefined; 73 - await this.output.signal()?.playlists.save(newPlaylists); 73 + await this.output.signal()?.playlistItems.save(newPlaylistItems); 74 74 }, 75 75 state: computed(() => { 76 - return this.output.signal()?.playlists.state() ?? "sleeping"; 76 + return this.output.signal()?.playlistItems.state() ?? "sleeping"; 77 77 }), 78 78 }, 79 79 themes: {
+2 -2
src/components/transformer/output/bytes/automerge/constants.js
··· 2 2 import { base64 } from "iso-base/rfc4648"; 3 3 4 4 /** 5 - * @import { FacetsDocument, PlaylistsDocument, ThemesDocument, TracksDocument } from "./types.d.ts"; 5 + * @import { FacetsDocument, PlaylistItemsDocument, ThemesDocument, TracksDocument } from "./types.d.ts"; 6 6 */ 7 7 8 8 /** @type {Automerge.Doc<FacetsDocument>} */ ··· 12 12 ), 13 13 ); 14 14 15 - /** @type {Automerge.Doc<PlaylistsDocument>} */ 15 + /** @type {Automerge.Doc<PlaylistItemsDocument>} */ 16 16 export const INITIAL_PLAYLISTS_DOCUMENT = Automerge.load( 17 17 base64.decode( 18 18 "hW9Kg5IPZcsAeAEQIyp0LRYp0l9bpZKWJXTPlgGtUD/lrIatFjiIwoUdtJhh/sBQFIcpPppxduoIp1ArXwYBAgMCEwIjBkACVgIHFQwhAiMCNAFCAlYCgAECfwB/AX8Bf8eTqcwGfwB/B38KY29sbGVjdGlvbn8AfwEBfwJ/AH8AAA",
+10 -10
src/components/transformer/output/bytes/automerge/element.js
··· 17 17 /** 18 18 * @import { SignalReader } from "@common/signal.d.ts"; 19 19 * @import { OutputManagerDeputy } from "@components/output/types.d.ts" 20 - * @import { FacetsDocument, PlaylistsDocument, ThemesDocument, TracksDocument } from "./types.d.ts" 20 + * @import { FacetsDocument, PlaylistItemsDocument, ThemesDocument, TracksDocument } from "./types.d.ts" 21 21 */ 22 22 23 23 /** ··· 43 43 } 44 44 }); 45 45 46 - /** @type {SignalReader<Automerge.Doc<PlaylistsDocument>>} */ 46 + /** @type {SignalReader<Automerge.Doc<PlaylistItemsDocument>>} */ 47 47 const playlistsDocument = computed(() => { 48 - const value = base.playlists.collection(); 48 + const value = base.playlistItems.collection(); 49 49 50 50 if (isUint8Array(value)) { 51 51 return Automerge.load(value); ··· 105 105 await base.facets.save(bytes); 106 106 }, 107 107 }, 108 - playlists: { 109 - ...base.playlists, 108 + playlistItems: { 109 + ...base.playlistItems, 110 110 collection: computed(() => playlistsDocument().collection), 111 - save: async (newPlaylists) => { 111 + save: async (newPlaylistItems) => { 112 112 const doc = Automerge.change(playlistsDocument(), (d) => { 113 - const clonedCollection = newPlaylists.map((facet) => { 114 - return recursivelyCloneRecords(facet); 113 + const clonedCollection = newPlaylistItems.map((item) => { 114 + return recursivelyCloneRecords(item); 115 115 }); 116 116 117 117 d.collection = clonedCollection; 118 118 }); 119 119 120 120 const bytes = Automerge.save(doc); 121 - await base.playlists.save(bytes); 121 + await base.playlistItems.save(bytes); 122 122 }, 123 123 }, 124 124 themes: { ··· 162 162 163 163 // Assign manager properties to class 164 164 this.facets = manager.facets; 165 - this.playlists = manager.playlists; 165 + this.playlistItems = manager.playlistItems; 166 166 this.themes = manager.themes; 167 167 this.tracks = manager.tracks; 168 168 this.ready = manager.ready;
+7 -2
src/components/transformer/output/bytes/automerge/types.d.ts
··· 1 - import type { Facet, Playlist, Theme, Track } from "@definitions/types.d.ts"; 1 + import type { 2 + Facet, 3 + PlaylistItem, 4 + Theme, 5 + Track, 6 + } from "@definitions/types.d.ts"; 2 7 3 8 export type FacetsDocument = { collection: Facet[] }; 4 - export type PlaylistsDocument = { collection: Playlist[] }; 9 + export type PlaylistItemsDocument = { collection: PlaylistItem[] }; 5 10 export type ThemesDocument = { collection: Theme[] }; 6 11 export type TracksDocument = { collection: Track[] };
+9 -9
src/components/transformer/output/bytes/json/element.js
··· 3 3 4 4 /** 5 5 * @import { OutputManagerDeputy } from "@components/output/types.d.ts" 6 - * @import { Facet, Playlist, Theme, Track } from "@definitions/types.d.ts" 6 + * @import { Facet, PlaylistItem, Theme, Track } from "@definitions/types.d.ts" 7 7 */ 8 8 9 9 /** ··· 32 32 await base.facets.save(bytes); 33 33 }, 34 34 }, 35 - playlists: { 36 - ...base.playlists, 35 + playlistItems: { 36 + ...base.playlistItems, 37 37 collection: computed(() => { 38 - const data = base.playlists.collection(); 39 - /** @type {Playlist[]} */ 38 + const data = base.playlistItems.collection(); 39 + /** @type {PlaylistItem[]} */ 40 40 const c = parseArray(data); 41 41 return c; 42 42 }), 43 - save: async (newPlaylists) => { 44 - const json = JSON.stringify(newPlaylists); 43 + save: async (newPlaylistItems) => { 44 + const json = JSON.stringify(newPlaylistItems); 45 45 const encoder = new TextEncoder(); 46 46 const bytes = encoder.encode(json); 47 - await base.playlists.save(bytes); 47 + await base.playlistItems.save(bytes); 48 48 }, 49 49 }, 50 50 themes: { ··· 84 84 85 85 // Assign manager properties to class 86 86 this.facets = manager.facets; 87 - this.playlists = manager.playlists; 87 + this.playlistItems = manager.playlistItems; 88 88 this.themes = manager.themes; 89 89 this.tracks = manager.tracks; 90 90 this.ready = manager.ready;
+4 -4
src/components/transformer/output/refiner/default/element.js
··· 22 22 return base.facets.collection() ?? []; 23 23 }), 24 24 }, 25 - playlists: { 26 - ...base.playlists, 25 + playlistItems: { 26 + ...base.playlistItems, 27 27 collection: computed(() => { 28 - return base.playlists.collection() ?? []; 28 + return base.playlistItems.collection() ?? []; 29 29 }), 30 30 }, 31 31 themes: { ··· 51 51 52 52 // Assign manager properties to class 53 53 this.facets = manager.facets; 54 - this.playlists = manager.playlists; 54 + this.playlistItems = manager.playlistItems; 55 55 this.themes = manager.themes; 56 56 this.tracks = manager.tracks; 57 57 this.ready = manager.ready;
+7 -7
src/components/transformer/output/string/json/element.js
··· 27 27 await base.facets.save(json); 28 28 }, 29 29 }, 30 - playlists: { 31 - ...base.playlists, 30 + playlistItems: { 31 + ...base.playlistItems, 32 32 collection: computed(() => { 33 - const json = base.playlists.collection(); 33 + const json = base.playlistItems.collection(); 34 34 return typeof json === "string" ? parseArray(json) : []; 35 35 }), 36 - save: async (newPlaylists) => { 37 - const json = JSON.stringify(newPlaylists); 38 - await base.playlists.save(json); 36 + save: async (newPlaylistItems) => { 37 + const json = JSON.stringify(newPlaylistItems); 38 + await base.playlistItems.save(json); 39 39 }, 40 40 }, 41 41 themes: { ··· 67 67 68 68 // Assign manager properties to class 69 69 this.facets = manager.facets; 70 - this.playlists = manager.playlists; 70 + this.playlistItems = manager.playlistItems; 71 71 this.themes = manager.themes; 72 72 this.tracks = manager.tracks; 73 73 this.ready = manager.ready;
+15 -10
src/facets/tools/auto-queue.html.txt
··· 50 50 <script type="module"> 51 51 import foundation from "./common/facets/foundation.js"; 52 52 import { computed, effect } from "./common/signal.js"; 53 + import * as Playlist from "./common/playlist.js"; 53 54 54 55 const ACTIVE_CLASS = "button--active"; 55 56 ··· 87 88 88 89 // Playlist state 89 90 effect(() => { 90 - const playlists = output.playlists.collection().sort((a, b) => 91 - a.name.localeCompare(b.name) 92 - ); 91 + const items = output.playlistItems.collection(); 92 + const currentPlaylist = scope.playlist(); 93 93 94 - const currentId = scope.playlistId(); 95 - const ordered = playlists.filter((p) => !p.unordered); 96 - const unordered = playlists.filter((p) => p.unordered); 94 + // Group items by playlist name 95 + const playlistMap = Playlist.gather(items); 96 + const all = [...playlistMap.values()].sort((a, b) => a.name.localeCompare(b.name)); 97 + const ordered = all.filter((p) => !p.unordered); 98 + const unordered = all.filter((p) => p.unordered); 97 99 98 100 playlistSelect.innerHTML = `<option value="">All tracks</option>`; 99 101 100 - for (const [label, group] of [["Ordered", ordered], ["Unordered", unordered]]) { 102 + for (const [label, group] of [ 103 + ["Ordered", ordered], 104 + ["Unordered", unordered], 105 + ]) { 101 106 if (group.length === 0) continue; 102 107 103 108 const optgroup = document.createElement("optgroup"); ··· 105 110 106 111 for (const playlist of group) { 107 112 const option = document.createElement("option"); 108 - option.value = playlist.id; 113 + option.value = playlist.name; 109 114 option.textContent = playlist.name; 110 - option.selected = playlist.id === currentId; 115 + option.selected = playlist.name === currentPlaylist; 111 116 optgroup.appendChild(option); 112 117 } 113 118 ··· 116 121 }); 117 122 118 123 playlistSelect.onchange = () => { 119 - scope.setPlaylistId(playlistSelect.value.length ? playlistSelect.value : undefined); 124 + scope.setPlaylist(playlistSelect.value.length ? playlistSelect.value : undefined); 120 125 }; 121 126 </script>
+19 -16
src/facets/tools/v3-import.html.txt
··· 163 163 164 164 try { 165 165 const now = new Date().toISOString(); 166 - const existing = output.playlists.collection() ?? []; 166 + const existing = output.playlistItems.collection() ?? []; 167 + const existingPlaylistNames = new Set(existing.map((p) => p.playlist)); 167 168 168 - const newPlaylists = items 169 - .map((item) => ({ 170 - $type: "sh.diffuse.output.playlist", 171 - id: crypto.randomUUID(), 172 - name: item.name ?? "Untitled", 173 - unordered: !!item.collection, 174 - items: (item.tracks ?? []).map((track) => ({ 169 + const newPlaylistItems = items 170 + .filter((item) => !existingPlaylistNames.has(item.name ?? "Untitled")) 171 + .flatMap((item) => { 172 + const playlistName = item.name ?? "Untitled"; 173 + const isUnordered = !!item.collection; 174 + 175 + return (item.tracks ?? []).map((track, index) => ({ 176 + $type: "sh.diffuse.output.playlistItem", 177 + id: crypto.randomUUID(), 178 + playlist: playlistName, 179 + position: isUnordered ? undefined : index, 175 180 criteria: [ 176 181 { field: "tags.album", value: track.album ?? "", transformations: ["toLowerCase"] }, 177 182 { field: "tags.artist", value: track.artist ?? "", transformations: ["toLowerCase"] }, 178 183 { field: "tags.title", value: track.title ?? "", transformations: ["toLowerCase"] }, 179 184 ], 180 - })), 181 - createdAt: now, 182 - updatedAt: now, 183 - })) 184 - .filter((playlist) => { 185 - return existing.find((p) => p.name === playlist.name) === undefined; 185 + createdAt: now, 186 + updatedAt: now, 187 + })); 186 188 }); 187 189 188 - await output.playlists.save([...existing, ...newPlaylists]); 189 - showStatus(`Imported ${newPlaylists.length} playlist(s).`, "success"); 190 + await output.playlistItems.save([...existing, ...newPlaylistItems]); 191 + const playlistCount = new Set(newPlaylistItems.map((p) => p.playlist)).size; 192 + showStatus(`Imported ${playlistCount} playlist(s).`, "success"); 190 193 } catch (err) { 191 194 console.error("Import failed:", err); 192 195 showStatus(`Import failed: ${err.message}`, "error");
+3 -3
src/index.vto
··· 146 146 desc: > 147 147 Facet pointer or HTML snippet. 148 148 url: "definitions/output/facet.json" 149 - - title: "Output / Playlist" 149 + - title: "Output / Playlist Item" 150 150 desc: > 151 - Represents a collection of tracks, which may be ordered or unordered. Tracks are matched based on the given criteria. 152 - url: "definitions/output/playlist.json" 151 + Represents a single item in a playlist. Tracks are matched based on the given criteria. A playlist is formed by grouping items by their playlist property. 152 + url: "definitions/output/playlistItem.json" 153 153 - title: "Output / Progress" 154 154 desc: > 155 155 Used to track progress of (long) audio playback.
+1 -1
src/oauth-client-metadata.json
··· 3 3 "client_name": "Diffuse", 4 4 "client_uri": "https://elements.diffuse.sh", 5 5 "redirect_uris": ["https://elements.diffuse.sh/oauth/callback"], 6 - "scope": "atproto repo?collection=sh.diffuse.output.facet&collection=sh.diffuse.output.playlist&collection=sh.diffuse.output.theme&collection=sh.diffuse.output.track", 6 + "scope": "atproto repo?collection=sh.diffuse.output.facet&collection=sh.diffuse.output.playlistItem&collection=sh.diffuse.output.theme&collection=sh.diffuse.output.track", 7 7 "grant_types": ["authorization_code", "refresh_token"], 8 8 "response_types": ["code"], 9 9 "token_endpoint_auth_method": "none",
+42 -18
src/themes/webamp/browser/element.js
··· 43 43 $highlightedTrack = signal(/** @type {string | null} */ (null)); 44 44 45 45 $groupedPlaylists = computed(() => { 46 - const playlists = this.$output.value?.playlists.collection() 47 - ?.sort((a, b) => a.name.localeCompare(b.name)); 48 - if (!playlists) return []; 46 + const items = this.$output.value?.playlistItems.collection(); 47 + if (!items?.length) return []; 48 + 49 + // Group items by playlist name 50 + /** @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 + } 49 64 50 - const ordered = playlists.filter((p) => !p.unordered); 51 - const unordered = playlists.filter((p) => p.unordered); 65 + const all = [...playlistMap.values()].sort((a, b) => 66 + a.name.localeCompare(b.name) 67 + ); 68 + 69 + const ordered = all.filter((p) => !p.unordered); 70 + const unordered = all.filter((p) => p.unordered); 52 71 53 72 return [ 54 73 { label: "Ordered", playlists: ordered }, ··· 111 130 this.#setupScrollTracking(); 112 131 113 132 this.effect(() => { 114 - const playlistId = this.$scope.value?.playlistId(); 133 + const playlist = this.$scope.value?.playlist(); 115 134 const select = this.root().querySelector("#playlist-select"); 116 135 117 136 if (select) { 118 - /** @type {HTMLSelectElement} */ (select).value = playlistId ?? ""; 137 + /** @type {HTMLSelectElement} */ (select).value = playlist ?? ""; 119 138 } 120 139 }); 121 140 } ··· 207 226 /** 208 227 * @param {Event} event 209 228 */ 210 - setSelectedPlaylistId = (event) => { 211 - const id = /** @type {HTMLSelectElement} */ (event.currentTarget).value; 229 + setSelectedPlaylist = (event) => { 230 + const value = /** @type {HTMLSelectElement} */ (event.currentTarget).value; 212 231 213 - this.$scope.value?.setPlaylistId(id === "" ? undefined : id); 232 + this.$scope.value?.setPlaylist(value === "" ? undefined : value); 214 233 }; 215 234 216 235 // RENDER ··· 222 241 const highlighted = this.$highlightedTrack.value; 223 242 const isLoading = this.$output.value?.tracks?.state() !== "loaded"; 224 243 const tracks = this.$provider.value?.tracks() ?? []; 225 - const playlistId = this.$scope.value?.playlistId(); 244 + const playlist = this.$scope.value?.playlist(); 226 245 const searchTerm = this.$scope.value?.searchTerm() ?? ""; 227 246 228 247 // Virtual list ··· 314 333 315 334 <search class="field-row"> 316 335 <label for="search-input">Search:</label> 317 - <input id="search-input" type="search" @change="${this 318 - .setSearchTerm}" value="${searchTerm}" /> 336 + <input 337 + id="search-input" 338 + type="search" 339 + @change="${this 340 + .setSearchTerm}" 341 + value="${searchTerm}" 342 + /> 319 343 <label for="playlist-select">Playlist:</label> 320 - <select id="playlist-select" @change="${this.setSelectedPlaylistId}"> 321 - <option value="" ?selected="${!playlistId || 322 - playlistId === ``}">All tracks</option> 344 + <select id="playlist-select" @change="${this.setSelectedPlaylist}"> 345 + <option value="" ?selected="${!playlist || 346 + playlist === ``}">All tracks</option> 323 347 ${this.$groupedPlaylists().map((group) => 324 348 html` 325 349 <optgroup label="${group.label}"> 326 350 ${group.playlists.map((p) => 327 351 html` 328 352 <option 329 - value="${p.id}" 330 - ?selected="${p.id === playlistId}" 353 + value="${p.name}" 354 + ?selected="${p.name === playlist}" 331 355 > 332 356 ${p.name} 333 357 </option>
+24 -24
tests/components/orchestrator/favourites/test.ts
··· 28 28 29 29 await fav.include(tracks[0]); 30 30 31 - return fav.playlist(); 31 + return fav.playlistItems(); 32 32 }); 33 33 34 - expect(favourites.items.length).toBe(1); 35 - expect(favourites.items[0].criteria[0].value).toBe(tracks[0].tags?.artist); 36 - expect(favourites.items[0].criteria[1].value).toBe(tracks[0].tags?.title); 34 + expect(favourites.length).toBe(1); 35 + expect(favourites[0].criteria[0].value).toBe(tracks[0].tags?.artist); 36 + expect(favourites[0].criteria[1].value).toBe(tracks[0].tags?.title); 37 37 }); 38 38 39 39 it("includes multiple tracks", async () => { ··· 59 59 60 60 await fav.include(tracks); 61 61 62 - return fav.playlist(); 62 + return fav.playlistItems(); 63 63 }); 64 64 65 - expect(favourites.items.length).toBe(2); 66 - expect(favourites.items[0].criteria[0].value).toBe(tracks[0].tags?.artist); 67 - expect(favourites.items[0].criteria[1].value).toBe(tracks[0].tags?.title); 68 - expect(favourites.items[1].criteria[0].value).toBe(tracks[1].tags?.artist); 69 - expect(favourites.items[1].criteria[1].value).toBe(tracks[1].tags?.title); 65 + expect(favourites.length).toBe(2); 66 + expect(favourites[0].criteria[0].value).toBe(tracks[0].tags?.artist); 67 + expect(favourites[0].criteria[1].value).toBe(tracks[0].tags?.title); 68 + expect(favourites[1].criteria[0].value).toBe(tracks[1].tags?.artist); 69 + expect(favourites[1].criteria[1].value).toBe(tracks[1].tags?.title); 70 70 }); 71 71 72 72 it("does not include duplicate tracks", async () => { ··· 93 93 await fav.include(tracks[0]); 94 94 await fav.include(tracks[0]); 95 95 96 - return fav.playlist(); 96 + return fav.playlistItems(); 97 97 }); 98 98 99 - expect(favourites.items.length).toBe(1); 99 + expect(favourites.length).toBe(1); 100 100 }); 101 101 102 102 it("expels tracks", async () => { ··· 123 123 await fav.include(tracks); 124 124 await fav.expel(tracks[0]); 125 125 126 - return fav.playlist(); 126 + return fav.playlistItems(); 127 127 }); 128 128 129 - expect(favourites.items.length).toBe(1); 130 - expect(favourites.items[0].criteria[0].value).toBe(tracks[1].tags?.artist); 131 - expect(favourites.items[0].criteria[1].value).toBe(tracks[1].tags?.title); 129 + expect(favourites.length).toBe(1); 130 + expect(favourites[0].criteria[0].value).toBe(tracks[1].tags?.artist); 131 + expect(favourites[0].criteria[1].value).toBe(tracks[1].tags?.title); 132 132 }); 133 133 134 134 it("toggles tracks", async () => { ··· 154 154 155 155 // Toggle on 156 156 await fav.toggle(tracks[0]); 157 - const afterAdd = fav.playlist(); 157 + const afterAdd = fav.playlistItems(); 158 158 159 159 // Toggle off 160 160 await fav.toggle(tracks[0]); 161 - const afterRemove = fav.playlist(); 161 + const afterRemove = fav.playlistItems(); 162 162 163 163 return { 164 - afterAddCount: afterAdd.items.length, 165 - afterRemoveCount: afterRemove.items.length, 164 + afterAddCount: afterAdd.length, 165 + afterRemoveCount: afterRemove.length, 166 166 }; 167 167 }); 168 168 ··· 197 197 // Toggle both — should remove first, add second 198 198 await fav.toggle(tracks); 199 199 200 - return fav.playlist(); 200 + return fav.playlistItems(); 201 201 }); 202 202 203 - expect(favourites.items.length).toBe(1); 204 - expect(favourites.items[0].criteria[0].value).toBe(tracks[1].tags?.artist); 205 - expect(favourites.items[0].criteria[1].value).toBe(tracks[1].tags?.title); 203 + expect(favourites.length).toBe(1); 204 + expect(favourites[0].criteria[0].value).toBe(tracks[1].tags?.artist); 205 + expect(favourites[0].criteria[1].value).toBe(tracks[1].tags?.title); 206 206 }); 207 207 });