diff --git a/src/common/playlist.js b/src/common/playlist.js index d2355a8e..d5ff3459 100644 --- a/src/common/playlist.js +++ b/src/common/playlist.js @@ -108,6 +108,96 @@ export function filterByPlaylist(tracks, playlistItems) { ); } +/** + * Order already-playlist-member tracks by their playlist's `positionedAfter` + * chain. Tracks are bucketed to the first playlist item (in chain order) that + * they match; each track appears only once. Tracks not matched by any item are + * dropped (the caller is expected to pre-filter with `filterByPlaylist`). + * + * When the playlist is unordered (or empty), the input tracks are returned + * unchanged so the caller can fall back to its own sort. + * + * @param {Track[]} tracks Tracks already known to belong to the playlist. + * @param {PlaylistItem[]} items Items of a single playlist. + * @returns {Track[]} + * + * @example Orders tracks by their playlist item chain + * ```js + * import { orderByPlaylist } from "~/common/playlist.js"; + * + * const tracks = [ + * { $type: "sh.diffuse.output.track", id: "t-b", uri: "http://x/b.mp3", tags: { title: "B" } }, + * { $type: "sh.diffuse.output.track", id: "t-a", uri: "http://x/a.mp3", tags: { title: "A" } }, + * { $type: "sh.diffuse.output.track", id: "t-c", uri: "http://x/c.mp3", tags: { title: "C" } }, + * ]; + * const items = [ + * { $type: "sh.diffuse.output.playlistItem", id: "i-b", playlist: "p", criteria: [{ field: "tags.title", value: "B" }], positionedAfter: "i-a" }, + * { $type: "sh.diffuse.output.playlistItem", id: "i-a", playlist: "p", criteria: [{ field: "tags.title", value: "A" }], positionedAfter: undefined }, + * { $type: "sh.diffuse.output.playlistItem", id: "i-c", playlist: "p", criteria: [{ field: "tags.title", value: "C" }], positionedAfter: "i-b" }, + * ]; + * // @ts-ignore + * const result = orderByPlaylist(tracks, items); + * if (result.map((t) => t.id).join(",") !== "t-a,t-b,t-c") throw new Error("should follow chain order"); + * ``` + * + * @example Returns tracks unchanged for unordered playlists + * ```js + * import { orderByPlaylist } from "~/common/playlist.js"; + * + * const tracks = [{ $type: "sh.diffuse.output.track", id: "t-a", uri: "http://x/a.mp3", tags: { title: "A" } }]; + * const items = [{ $type: "sh.diffuse.output.playlistItem", id: "i-a", playlist: "p", criteria: [{ field: "tags.title", value: "A" }] }]; + * // @ts-ignore + * const result = orderByPlaylist(tracks, items); + * if (result !== tracks) throw new Error("unordered playlists should return input untouched"); + * ``` + * + * @example A track matching multiple items appears only under the first + * ```js + * import { orderByPlaylist } from "~/common/playlist.js"; + * + * const tracks = [ + * { $type: "sh.diffuse.output.track", id: "t-a", uri: "http://x/a.mp3", tags: { artist: "X", title: "A" } }, + * ]; + * const items = [ + * { $type: "sh.diffuse.output.playlistItem", id: "i-a", playlist: "p", criteria: [{ field: "tags.artist", value: "X" }], positionedAfter: undefined }, + * { $type: "sh.diffuse.output.playlistItem", id: "i-b", playlist: "p", criteria: [{ field: "tags.title", value: "A" }], positionedAfter: "i-a" }, + * ]; + * // @ts-ignore + * const result = orderByPlaylist(tracks, items); + * if (result.length !== 1) throw new Error("track should appear only once"); + * ``` + */ +export function orderByPlaylist(tracks, items) { + if (!items.length) return tracks; + + // Detect ordered state for the (single) playlist the items belong to. + const grouped = gather(items); + const meta = grouped.values().next().value; + if (!meta || meta.unordered) return tracks; + + const orderedItems = sort(items); + const claimed = new Set(); + /** @type {Track[]} */ + const out = []; + + for (const item of orderedItems) { + let matchedAny = false; + for (const track of tracks) { + if (claimed.has(track.id)) continue; + if (match(track, item)) { + claimed.add(track.id); + out.push(track); + matchedAny = true; + } + } + // Items with no matching tracks don't contribute to the output but the + // chain still proceeds so following items can claim what they can. + void matchedAny; + } + + return out; +} + /** * Bundle playlist items into their respective playlists. * diff --git a/src/components/orchestrator/scoped-tracks/element.js b/src/components/orchestrator/scoped-tracks/element.js index ad2d6149..a9db0d6a 100644 --- a/src/components/orchestrator/scoped-tracks/element.js +++ b/src/components/orchestrator/scoped-tracks/element.js @@ -5,7 +5,7 @@ import { queryOptional, } from "~/common/element.js"; import { batch, computed, signal } from "~/common/signal.js"; -import { filterByPlaylist } from "~/common/playlist.js"; +import { filterByPlaylist, gather, orderByPlaylist } from "~/common/playlist.js"; import { safeDecodeURIComponent } from "~/common/utils.js"; import { listen } from "~/common/worker.js"; @@ -88,6 +88,26 @@ class ScopedTracksOrchestrator extends BroadcastableDiffuseElement { tracks = this.#tracksFinal.get; groups = this.#tracksGrouped; + /** + * A computed signal that reflects wether or not + * the selected playlist should be rendered in order + * (the order the items have in the playlist itself). + * + * It only will return true if all of the following is true: + * - A playlist is selected and that playlist is deemed ordered + * (at least one item has a non-empty `positionedAfter` property) + * - No groupBy scope was set + * - No sortBy scope was set or it was set to an empty list + */ + playlistIsOrdered = computed(() => { + const playlistItems = this.#selectedPlaylistItems(); + const playlistName = this.#scope.value?.playlist(); + + return playlistItems?.length && playlistName + ? gather(playlistItems).get(playlistName)?.unordered === false + : false; + }) + // LIFECYCLE /** @@ -260,6 +280,12 @@ class ScopedTracksOrchestrator extends BroadcastableDiffuseElement { ); } + if (this.playlistIsOrdered()) { + final = orderByPlaylist(final, /** @type {import("~/definitions/types.d.ts").PlaylistItem[]} */ (playlistItems)); + this.#tracksFinal.set(final); + return; + } + // When groupBy is active, sort by group key first using the group's // canonical direction (from GROUP_BY_SORT_OVERRIDES, or user's direction // for firstLetter). Within each group, sort by the user's sortBy and