import * as TID from "@atcute/tid"; import { defineElement, DiffuseElement, query, queryOptional, whenElementsDefined, } from "~/common/element.js"; import { computed, signal, untracked } from "~/common/signal.js"; import * as Playlist from "~/common/playlist.js"; import { repeat } from "~/vendor/lit-html/directives/repeat.js"; /** * @import {RenderArg} from "~/common/element.d.ts" * @import {SignalReader} from "~/common/signal.d.ts"; * @import {Track} from "~/definitions/types.d.ts" * @import {OutputElement} from "@specs/components/output/types.d.ts" */ const ROW_HEIGHT = 14; const OVERSCAN = 10; /** @type {Record} */ const COLUMN_SORT = { title: ["tags.title"], artist: ["tags.artist", "tags.album", "tags.disc.no", "tags.track.no"], album: ["tags.album", "tags.disc.no", "tags.track.no"], }; const GROUP_BY_OPTIONS = [ { value: "firstLetter", label: "First letter" }, { value: "directory", label: "Path" }, { value: "createdAt", label: "Processing date" }, { value: "tags.year", label: "Track year" }, ]; /** * @typedef {{ type: "group"; label: string }} GroupItem * @typedef {{ type: "track"; track: Track }} TrackItem * @typedef {GroupItem | TrackItem} VirtualItem */ class Browser extends DiffuseElement { constructor() { super(); this.attachShadow({ mode: "open" }); } // SIGNALS $output = signal( /** @type {OutputElement | undefined} */ (undefined), ); $provider = signal( /** @type {DiffuseElement & { tracks: SignalReader } | undefined} */ (undefined), ); $queue = signal( /** @type {import("~/components/engine/queue/element.js").CLASS | undefined} */ (undefined), ); $scope = signal( /** @type {import("~/components/engine/scope/element.js").CLASS | undefined} */ (undefined), ); $favourites = signal( /** @type {import("~/components/orchestrator/favourites/element.js").CLASS | undefined} */ (undefined), ); $highlightedTrack = signal(/** @type {string | null} */ (null)); $highlightedTracks = signal(/** @type {Set} */ (new Set())); #anchorTrackId = /** @type {string | null} */ (null); $input = signal( /** @type {import("~/components/configurator/input/element.js").CLASS | undefined} */ (undefined), ); #cachedUris = signal(/** @type {Set} */ (new Set())); #selectedTracks = computed(() => { const tracks = this.$provider.value?.tracks() ?? []; const highlightedTracks = this.$highlightedTracks.value; if (highlightedTracks.size === 0) return []; return tracks.filter((t) => highlightedTracks.has(t.id)); }); #allCached = computed(() => { const selectedTracks = this.#selectedTracks(); if (selectedTracks.length === 0) return false; const cachedUris = this.#cachedUris.value; return selectedTracks.every((t) => cachedUris.has(t.uri)); }); #playlistPickerState = signal( /** @type {{ mode: "add"; tracks: Track[] } | { mode: "create"; tracks: Track[] } | null} */ (null), ); $groupedPlaylists = computed(() => { const col = this.$output.value?.playlistItems.collection(); if (!col || col.state !== "loaded" || !col.data.length) return []; const items = col.data; // Group items by playlist name /** @type {Map} */ const playlistMap = Playlist.gather(items); const all = [...playlistMap.values()].sort((a, b) => a.name.localeCompare(b.name) ); const ordered = all.filter((p) => !p.unordered); const unordered = all.filter((p) => p.unordered); return [ { label: "Ordered", playlists: ordered }, { label: "Unordered", playlists: unordered }, ].filter((g) => g.playlists.length > 0); }); // STATE #scrollTop = 0; #viewportHeight = 0; #renderedStartIndex = -1; #renderedEndIndex = -1; /** @type {VirtualItem[]} */ #flatItems = []; /** @type {Track[] | undefined} */ #lastTracks = undefined; /** @type {{ label: string; tracks: Track[] }[] | undefined} */ #lastGroups = undefined; /** @type {ResizeObserver | undefined} */ #resizeObserver; // LIFECYCLE /** * @override */ connectedCallback() { super.connectedCallback(); /** @type {OutputElement} */ const output = query(this, "output-selector"); /** @type {DiffuseElement & { tracks: SignalReader }} */ const provider = query(this, "tracks-selector"); /** @type {import("~/components/engine/queue/element.js").CLASS} */ const queue = query(this, "queue-engine-selector"); /** @type {import("~/components/engine/scope/element.js").CLASS} */ const scope = query(this, "scope-engine-selector"); /** @type {import("~/components/orchestrator/favourites/element.js").CLASS} */ const favourites = query(this, "favourites-orchestrator-selector"); /** @type {import("~/components/configurator/input/element.js").CLASS | null} */ const input = queryOptional(this, "input-selector"); // Wait for the above dependencies to be defined, then render again. whenElementsDefined({ output, provider, queue, scope, favourites }).then( () => { this.$output.value = output; this.$provider.value = provider; this.$queue.value = queue; this.$scope.value = scope; this.$favourites.value = favourites; }, ); if (input) { whenElementsDefined({ input }).then(async () => { this.$input.value = input; const uris = await input.listCached(); this.#cachedUris.value = new Set(uris); }); } // Effects this.effect(() => { const _results = this.$provider.value?.tracks(); untracked(() => { const panel = this.root().querySelector(".sunken-panel"); if (panel) { panel.scrollTo(0, 0); this.#scrollTop = 0; } }); }); // Scroll & resize tracking (set up once after first render) this.#setupScrollTracking(); this.effect(() => { const playlist = this.$scope.value?.playlist(); const select = this.root().querySelector("#playlist-select"); if (select) { /** @type {HTMLSelectElement} */ (select).value = playlist ?? ""; } }); this.effect(() => { const groupBy = this.$scope.value?.groupBy(); const select = this.root().querySelector("#groupby-select"); if (select) { const displayValue = groupBy?.startsWith("firstLetter:") ? "firstLetter" : (groupBy ?? ""); /** @type {HTMLSelectElement} */ (select).value = displayValue; } }); } /** * @override */ disconnectedCallback() { super.disconnectedCallback(); this.#resizeObserver?.disconnect(); } // SCROLL #setupScrollTracking() { requestAnimationFrame(() => { const panel = this.root().querySelector(".sunken-panel"); if (!panel) return; panel.addEventListener( "scroll", () => { this.#scrollTop = panel.scrollTop; this.#renderIfWindowChanged(); }, { passive: true }, ); this.#resizeObserver = new ResizeObserver((entries) => { // Cap to window.innerHeight to guard against measuring the scroll height // before shadow DOM stylesheets finish loading (when the panel has no // flex constraints and expands to its full virtual content size). this.#viewportHeight = Math.min( entries[0].contentRect.height, window.innerHeight, ); this.#renderIfWindowChanged(); }); this.#resizeObserver.observe(panel); }); } #computeWindow() { const startIndex = Math.max( 0, Math.floor(this.#scrollTop / ROW_HEIGHT) - OVERSCAN, ); const visibleCount = Math.ceil(this.#viewportHeight / ROW_HEIGHT) + 2 * OVERSCAN; return { startIndex, endIndex: startIndex + visibleCount }; } #renderIfWindowChanged() { const { startIndex, endIndex } = this.#computeWindow(); if ( startIndex === this.#renderedStartIndex && endIndex === this.#renderedEndIndex ) { return; } this.forceRender(); } // EVENTS /** * @param {Track} track */ playTrack(track) { this.$queue.value?.add({ inFront: true, trackIds: [track.id], }); this.$queue.value?.shift(); } setSearchTerm = () => { /** @type {HTMLInputElement | null} */ const input = this.root().querySelector("#search-input"); const term = input?.value?.trim(); this.$scope.value?.setSearchTerm(term); }; /** * @param {Event} event */ setSelectedPlaylist = (event) => { const value = /** @type {HTMLSelectElement} */ (event.currentTarget).value; this.$scope.value?.setPlaylist(value === "" ? undefined : value); }; /** * @param {Event} event */ setGroupBy = (event) => { const scope = this.$scope.value; if (!scope) return; const raw = /** @type {HTMLSelectElement} */ (event.currentTarget).value; if (!raw) { scope.setGroupBy(undefined); return; } const value = raw === "firstLetter" ? `firstLetter:${scope.sortBy()[0] ?? "tags.title"}` : raw; scope.setGroupBy(value); }; /** * @param {string} column */ sortByColumn = (column) => { const scope = this.$scope.value; if (!scope) return; const isActive = JSON.stringify(COLUMN_SORT[column]) === JSON.stringify(scope.sortBy()); if (isActive) { if (scope.sortDirection() === "desc") { scope.revertToDefaultSort(); } else { scope.setSortDirection("desc"); } } else { scope.setSortBy(COLUMN_SORT[column] ?? []); scope.setSortDirection(undefined); } }; /** * @param {Track} track */ toggleFavourite = (track) => { this.$favourites.value?.toggle(track); }; /** * @param {string} playlistName * @param {Track[]} tracks */ addTracksToPlaylist = async (playlistName, tracks) => { const output = this.$output.value; if (!output || !tracks.length) return; const col = output.playlistItems.collection(); const existing = col.state === "loaded" ? col.data : []; const existingKeys = new Set( existing .filter((item) => item.playlist === playlistName) .map((item) => { const a = item.criteria.find((c) => c.field === "tags.artist" )?.value ?? ""; const t = item.criteria.find((c) => c.field === "tags.title")?.value ?? ""; return `${String(a).toLowerCase()}.${String(t).toLowerCase()}`; }), ); const transformations = /** @type {string[]} */ (["toLowerCase"]); const now = new Date().toISOString(); const newItems = tracks .filter((track) => { const key = `${String(track.tags?.artist ?? "").toLowerCase()}.${ String(track.tags?.title ?? "").toLowerCase() }`; return !existingKeys.has(key); }) .map(( track, ) => /** @type {import("~/definitions/types.d.ts").PlaylistItem} */ ({ $type: "sh.diffuse.output.playlistItem", id: TID.now(), playlist: playlistName, criteria: [ { field: "tags.artist", value: /** @type {unknown} */ (track.tags?.artist), transformations, }, { field: "tags.title", value: /** @type {unknown} */ (track.tags?.title), transformations, }, ], createdAt: now, updatedAt: now, })); if (!newItems.length) return; await output.playlistItems.save([...existing, ...newItems]); }; /** * @param {string} playlistName * @param {Track[]} tracks * @param {boolean} ordered */ createPlaylistWithTracks = async (playlistName, tracks, ordered) => { const output = this.$output.value; if (!output || !tracks.length) return; const col = output.playlistItems.collection(); const existing = col.state === "loaded" ? col.data : []; const transformations = /** @type {string[]} */ (["toLowerCase"]); const now = new Date().toISOString(); /** @type {import("~/definitions/types.d.ts").PlaylistItem[]} */ const newItems = []; let prevId = /** @type {string | undefined} */ (undefined); for (const track of tracks) { const id = TID.now(); newItems.push( /** @type {import("~/definitions/types.d.ts").PlaylistItem} */ ({ $type: "sh.diffuse.output.playlistItem", id, playlist: playlistName, criteria: [ { field: "tags.artist", value: /** @type {unknown} */ (track.tags?.artist), transformations, }, { field: "tags.title", value: /** @type {unknown} */ (track.tags?.title), transformations, }, ], ...(ordered ? { positionedAfter: prevId } : {}), createdAt: now, updatedAt: now, }), ); if (ordered) prevId = id; } await output.playlistItems.save([...existing, ...newItems]); }; // RENDER /** * @param {Function} html */ #renderPlaylistPicker(html) { const state = this.#playlistPickerState.value; if (!state) { return html` `; } const isCreate = state.mode === "create"; const groups = this.$groupedPlaylists(); return html`
${isCreate ? `New playlist` : `Add to playlist`}
${isCreate ? html`
` : html`
${groups.length > 0 ? html`
` : ``} ${groups.map(({ label, playlists }) => html`
${label}
${playlists.map((p) => html` ` )} ` )}
`}
`; } /** * @param {RenderArg} _ */ render({ html }) { const highlighted = this.$highlightedTrack.value; const isLoading = this.$output.value?.tracks?.collection().state !== "loaded"; const tracks = this.$provider.value?.tracks() ?? []; const groups = /** @type {{ label: string; tracks: Track[] }[] | undefined} */ ( /** @type {any} */ (this.$provider.value)?.groups?.() ); const playlist = this.$scope.value?.playlist(); const groupBy = this.$scope.value?.groupBy(); const searchTerm = this.$scope.value?.searchTerm() ?? ""; const sortBy = this.$scope.value?.sortBy() ?? []; const sortDirection = this.$scope.value?.sortDirection(); const playlistOrdered = /** @type {any} */ (this.$provider.value) ?.playlistIsOrdered?.() ?? false; const sortedColumn = playlistOrdered ? undefined : Object.entries(COLUMN_SORT).find( ([, v]) => JSON.stringify(v) === JSON.stringify(sortBy), )?.[0]; const ariaSort = /** @param {string} col */ (col) => sortedColumn === col ? (sortDirection === "desc" ? "descending" : "ascending") : "none"; // 1-indexed position of each track within the playlist order const trackPositions = playlistOrdered ? new Map(tracks.map((t, i) => [t.id, i + 1])) : null; const colspan = playlistOrdered ? 4 : 3; // Virtual list if (groups !== this.#lastGroups || tracks !== this.#lastTracks) { this.#flatItems = groups ? buildFlatList(groups) : []; this.#lastGroups = groups; this.#lastTracks = tracks; } const totalItems = groups ? this.#flatItems.length : tracks.length; const { startIndex, endIndex: rawEnd } = this.#computeWindow(); const endIndex = Math.min(totalItems, rawEnd); this.#renderedStartIndex = startIndex; this.#renderedEndIndex = endIndex; const visibleItems = groups ? this.#flatItems.slice(startIndex, endIndex) : tracks.slice(startIndex, endIndex).map( (t) => /** @type {VirtualItem} */ ({ type: "track", track: t }), ); const totalHeight = totalItems * ROW_HEIGHT; const topPad = startIndex * ROW_HEIGHT; const highlightedTracks = this.$highlightedTracks.value; const selectedTracks = this.#selectedTracks(); const allCached = this.#allCached(); return html`
panel.scrollTop + panel.clientHeight) { panel.scrollTop = rowBottom - panel.clientHeight; } } }}" > ${playlistOrdered ? html`` : ``}
# Title Artist Album
${playlistOrdered ? html`` : ``} ${isLoading ? html` ${playlistOrdered ? html`` : ``} ` : repeat( visibleItems, (item) => item.type === "group" ? `group:${item.label}` : `track:${item.track.id}`, (item) => { if (item.type === "group") { return html` `; } const track = item.track; return html` ${playlistOrdered ? html`` : ``} `; })}
Loading ...
${item.label}
${trackPositions?.get(track.id)}${track.tags?.title} ${track.tags?.artist} ${track.tags?.album}
${this.$input.value ? html` ` : ``}
${this.#renderPlaylistPicker(html)} `; } } //////////////////////////////////////////// // HELPERS //////////////////////////////////////////// /** * @param {{ label: string; tracks: Track[] }[]} groups * @returns {VirtualItem[]} */ function buildFlatList(groups) { /** @type {VirtualItem[]} */ const items = []; for (const group of groups) { items.push({ type: "group", label: group.label }); for (const track of group.tracks) { items.push({ type: "track", track }); } } return items; } export default Browser; //////////////////////////////////////////// // REGISTER //////////////////////////////////////////// export const CLASS = Browser; export const NAME = "dtw-browser"; defineElement(NAME, CLASS);