import * as TID from "@atcute/tid"; import { defineElement, DiffuseElement, query, queryOptional, whenElementsDefined, } from "~/common/element.js"; import { batch, 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" * @import {ArtworkElement} from "@specs/components/artwork/types.d.ts" * @import {TrackPools} from "@specs/components/orchestrator/scoped-tracks/types.d.ts" */ /** * @param {Track} track */ function trackTitle(track) { if (track.tags?.title) return track.tags.title; const path = track.uri.split("?")[0]; const filename = path.split("/").filter(Boolean).at(-1); return filename ? decodeURIComponent(filename) : track.uri; } const MAX_ART_CONCURRENT = 8; const TRACK_ROW_HEIGHT = 40; const GROUP_HEADER_HEIGHT = 52; 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 DEFAULT_SORT = ["createdAt"]; const GROUP_BY_OPTIONS = [ { value: "firstLetter", label: "Group by first letter", icon: "ph-text-aa" }, { value: "directory", label: "Group by path", icon: "ph-folder" }, { value: "createdAt", label: "Group by processing date", icon: "ph-clock" }, { value: "tags.year", label: "Group by track year", icon: "ph-calendar" }, ]; /** * @typedef {{ type: "group"; label: string }} GroupItem * @typedef {{ type: "track"; track: Track }} TrackItem * @typedef {GroupItem | TrackItem} VirtualItem */ /** * @typedef {{ type: "album"; albumKey: string; albumName: string; artist: string; track: Track }} OpenAlbumItem * @typedef {{ type: "artist"; artistKey: string; artistName: string; trackCount: number; track: Track }} OpenArtistItem * @typedef {OpenAlbumItem | OpenArtistItem} OpenCoverItem */ class Browser extends DiffuseElement { constructor() { super(); this.attachShadow({ mode: "open" }); } // SIGNALS $artwork = signal( /** @type {ArtworkElement | undefined} */ (undefined), ); $coverGroups = signal( /** @type {import("~/components/orchestrator/cover-groups/element.js").CLASS | undefined} */ (undefined), ); $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), ); $input = signal( /** @type {import("~/components/configurator/input/element.js").CLASS | undefined} */ (undefined), ); #cachedUris = signal(/** @type {Set} */ (new Set())); // SIGNALS - Pt. 2 #viewMode = signal( /** @type {"list" | "cover"} */ ( localStorage.getItem("diffuse/browser/view-mode") === "list" ? "list" : "cover" ), ); #coverViewMode = signal(/** @type {"albums" | "artists"} */ ("albums")); #openCoverItem = signal(/** @type {OpenCoverItem | null} */ (null)); #playlistPickerState = signal( /** @type {{ mode: "filter" } | { mode: "add"; tracks: Track[] } | { mode: "create"; tracks: Track[] } | null} */ (null), ); // SIGNALS - Pt. 3 $albumTrackMap = computed(() => { /** @type {Map} */ const map = new Map(); for (const { groups } of this.$coverGroups.value?.coverGroups() ?? []) { for (const { albumKey, track } of groups) { map.set(albumKey, track); } } for (const { groups } of this.$coverGroups.value?.artistGroups() ?? []) { for (const { artistKey, track } of groups) { map.set(artistKey, track); } } return map; }); $tracksByAlbum = computed(() => { /** @type {Map} */ const map = new Map(); for (const t of this.$provider.value?.tracks().scoped ?? []) { const key = String(t.tags?.album ?? "").toLowerCase(); if (!map.has(key)) map.set(key, []); map.get(key)?.push(t); } return map; }); $tracksByArtist = computed(() => { /** @type {Map} */ const map = new Map(); for (const t of this.$provider.value?.tracks().scoped ?? []) { const key = String(t.tags?.artist ?? "").toLowerCase(); if (!map.has(key)) map.set(key, []); map.get(key)?.push(t); } return map; }); $favouritesSet = computed(() => { const favItems = this.$favourites.value?.playlistItems() ?? []; return new Set( favItems.map((item) => { const a = item.criteria.find((c) => c.field === "tags.artist"); const t = item.criteria.find((c) => c.field === "tags.title"); return `${String(a?.value ?? "").toLowerCase()}|${ String(t?.value ?? "").toLowerCase() }`; }), ); }); $sortedArtistGroups = computed(() => { const groups = this.$coverGroups.value?.artistGroups() ?? []; return this.$scope.value?.sortDirection() === "desc" ? groups.map((g) => ({ ...g, groups: [...g.groups].reverse() })) : groups; }); $sortedCoverGroups = computed(() => { const groups = this.$coverGroups.value?.coverGroups() ?? []; return this.$scope.value?.sortDirection() === "desc" ? groups.map((g) => ({ ...g, groups: [...g.groups].reverse() })) : groups; }); $detailTracks = computed(() => { const item = this.#openCoverItem.value; if (!item) return /** @type {Track[]} */ ([]); if (item.type === "album") { return this.$tracksByAlbum().get(item.albumKey) ?? []; } return this.$tracksByArtist().get(item.artistKey) ?? []; }); $groupedPlaylists = computed(() => { const col = this.$output.value?.playlistItems.collection(); if (!col || col.state !== "loaded" || !col.data.length) return []; const items = col.data; /** @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 /** @type {Map} */ #coverArtCache = new Map(); /** @type {Set} */ #pendingArtFetch = new Set(); /** @type {{ albumKey: string; track: Track }[]} */ #artFetchQueue = []; #artFetchActive = 0; #artRenderScheduled = false; /** @type {IntersectionObserver | undefined} */ #coverObserver = undefined; /** @type {Map} */ #pendingVisibleCards = new Map(); /** @type {ReturnType | undefined} */ #artFetchDebounce = undefined; /** @type {VirtualItem[]} */ #flatItems = []; /** @type {Track[] | undefined} */ #lastTracks = undefined; /** @type {{ label: string; tracks: Track[] }[] | undefined} */ #lastGroups = undefined; // Custom virtual scroll state #scrollTop = 0; #viewportHeight = 0; #renderedStartIndex = -1; #renderedEndIndex = -1; #itemCount = 0; /** @type {ResizeObserver | undefined} */ #resizeObserver; /** @type {AbortController | undefined} */ #scrollAbort; /** @type {IntersectionObserver | undefined} */ #scrollVisibilityObserver; /** @type {number | undefined} */ #scrollRenderRaf = undefined; /** @type {number[]} */ #offsets = []; /** @type {Track[] | undefined} */ #previousTracks = undefined; #coverScrollTop = 0; // LIFECYCLE /** * @override */ connectedCallback() { super.connectedCallback(); /** @type {import("~/components/configurator/artwork/element.js").CLASS | null} */ const artwork = queryOptional(this, "artwork-selector"); /** @type {import("~/components/orchestrator/cover-groups/element.js").CLASS | null} */ const coverGroups = queryOptional( this, "cover-groups-orchestrator-selector", ); /** @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"); whenElementsDefined({ output, provider, queue, scope, favourites }).then( () => { batch(() => { 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); }); } if (artwork) { whenElementsDefined({ artwork }).then(() => { this.$artwork.value = artwork; }); } if (coverGroups) { whenElementsDefined({ coverGroups }).then(() => { this.$coverGroups.value = coverGroups; }); } // Reset scroll when track list changes (skip the initial population) this.effect(() => { const tracks = this.$provider.value?.tracks().scoped; untracked(() => { if (this.#previousTracks === undefined) { this.#previousTracks = tracks; return; } this.#previousTracks = tracks; const panel = this.root().querySelector(".scroll-panel"); if (panel) { panel.scrollTo(0, 0); this.#scrollTop = 0; } }); }); // Re-observe cover cards when groups change (track filtering, sorting, search) this.effect(() => { const _coverGroups = this.$sortedCoverGroups(); const _artistGroups = this.$sortedArtistGroups(); if (this.#viewMode.value !== "cover") return; if (this.#openCoverItem.value) return; untracked(() => { requestAnimationFrame(() => { this.#setupCoverObserver(); this.#fetchVisibleCoverArt(); // Fallback: IntersectionObserver may not deliver initial callbacks // before paint, and layout may not be stable yet. Re-check after // a frame to catch any cards that were missed. requestAnimationFrame(() => this.#fetchVisibleCoverArt()); }); }); }); // Set up scroll tracking after first render, when .scroll-panel exists in the DOM. this.#setupScrollTracking(); } /** * @override */ disconnectedCallback() { super.disconnectedCallback(); this.#scrollAbort?.abort(); this.#scrollAbort = undefined; this.#scrollVisibilityObserver?.disconnect(); this.#scrollVisibilityObserver = undefined; this.#resizeObserver?.disconnect(); this.#resizeObserver = undefined; this.#disconnectCoverObserver(); if (this.#scrollRenderRaf !== undefined) { cancelAnimationFrame(this.#scrollRenderRaf); this.#scrollRenderRaf = undefined; } } // 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); }; clearSearch = () => { /** @type {HTMLInputElement | null} */ const input = this.root().querySelector("#search-input"); if (input) input.value = ""; this.$scope.value?.setSearchTerm(undefined); }; /** * @param {string | undefined} value */ setGroupBy = (value) => { this.$scope.value?.setGroupBy(value); }; /** * @param {string | undefined} value */ setSelectedPlaylist = (value) => { this.#disconnectCoverObserver(); this.$scope.value?.setPlaylist(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]); }; /** * @param {Function} html * @param {string} menuId * @param {Track[]} tracks */ #renderTrackMenu(html, menuId, tracks) { const uris = tracks.map((t) => t.uri); const allCached = uris.length > 0 && uris.every((u) => this.#cachedUris.value.has(u)); return html` `; } toggleViewMode = () => { if (this.#viewMode.value === "cover") { this.#disconnectCoverObserver(); this.#openCoverItem.value = null; } const next = this.#viewMode.value === "list" ? "cover" : "list"; localStorage.setItem("diffuse/browser/view-mode", next); this.#viewMode.value = next; if (next === "list") this.#setupScrollTracking(); }; /** * @param {"albums" | "artists"} mode */ setCoverViewMode = (mode) => { this.#openCoverItem.value = null; this.#disconnectCoverObserver(); this.#coverViewMode.value = mode; }; // HELPERS /** * Enqueue an artwork fetch if not already pending or cached. * @param {string} albumKey * @param {Track} track */ #fetchAlbumArt(albumKey, track) { if (this.#coverArtCache.has(albumKey)) return; if (this.#pendingArtFetch.has(albumKey)) return; this.#pendingArtFetch.add(albumKey); this.#artFetchQueue.push({ albumKey, track }); this.#drainArtQueue(); } #drainArtQueue() { while ( this.#artFetchActive < MAX_ART_CONCURRENT && this.#artFetchQueue.length > 0 ) { const job = this.#artFetchQueue.shift(); if (!job) break; this.#artFetchActive++; this.#doFetchAlbumArt(job.albumKey, job.track); } } /** * @param {string} albumKey * @param {Track} track */ async #doFetchAlbumArt(albumKey, track) { const artwork = this.$artwork.value; try { const timeout = new Promise( (resolve) => setTimeout(() => resolve(null), 30_000), ); const bytes = artwork ? await Promise.race([artwork.get(track), timeout]) : null; if (bytes) { const mime = detectMime(bytes); const url = URL.createObjectURL( new Blob([bytes], { type: mime }), ); this.#coverArtCache.set(albumKey, url); } else { this.#coverArtCache.set(albumKey, null); } } catch { // don't cache on error — let it be retried } finally { this.#pendingArtFetch.delete(albumKey); this.#artFetchActive--; this.#drainArtQueue(); } this.#scheduleArtRender(); } #scheduleArtRender() { if (this.#artRenderScheduled) return; this.#artRenderScheduled = true; requestAnimationFrame(() => { this.#artRenderScheduled = false; this.forceRender(); }); } #fetchVisibleCoverArt() { const panel = this.root().querySelector(".cover-scroll-panel"); if (!panel) return; const panelRect = panel.getBoundingClientRect(); const margin = 200; for ( const card of this.root().querySelectorAll( ".cover-card[data-album-key]", ) ) { const albumKey = /** @type {HTMLElement} */ (card).dataset.albumKey; if ( !albumKey || this.#coverArtCache.has(albumKey) || this.#pendingArtFetch.has(albumKey) ) continue; const cardRect = card.getBoundingClientRect(); if ( cardRect.bottom + margin < panelRect.top || cardRect.top - margin > panelRect.bottom ) continue; const track = this.$albumTrackMap().get(albumKey); if (track) this.#fetchAlbumArt(albumKey, track); } } #setupCoverObserver() { const root = this.root().querySelector(".cover-scroll-panel"); if (!root) return; if (!this.#coverObserver) { this.#coverObserver = new IntersectionObserver( (entries) => { let hasNew = false; for (const entry of entries) { if (!entry.isIntersecting) continue; hasNew = true; const albumKey = /** @type {HTMLElement} */ (entry.target).dataset.albumKey; if (!albumKey) continue; const track = this.$albumTrackMap().get(albumKey); if (track) this.#pendingVisibleCards.set(albumKey, track); this.#coverObserver?.unobserve(entry.target); } if (!hasNew) return; clearTimeout(this.#artFetchDebounce); this.#artFetchDebounce = setTimeout(() => { for (const [albumKey, track] of this.#pendingVisibleCards) { this.#fetchAlbumArt(albumKey, track); } this.#pendingVisibleCards.clear(); }, 150); }, { root, rootMargin: "200px" }, ); } for ( const card of this.root().querySelectorAll(".cover-card[data-album-key]") ) { const albumKey = /** @type {HTMLElement} */ (card).dataset.albumKey; if ( albumKey && (this.#coverArtCache.has(albumKey) || this.#pendingArtFetch.has(albumKey)) ) continue; this.#coverObserver.observe(card); } } #setupScrollTracking() { this.#scrollAbort?.abort(); this.#scrollAbort = new AbortController(); this.#scrollVisibilityObserver?.disconnect(); this.#scrollVisibilityObserver = undefined; this.#resizeObserver?.disconnect(); this.#resizeObserver = undefined; this.#scrollTop = 0; const abort = this.#scrollAbort; const attach = () => { if (abort.signal.aborted) return; const panel = this.root().querySelector(".scroll-panel"); if (!panel) return; panel.addEventListener( "scroll", () => { this.#scrollTop = panel.scrollTop; this.#renderIfWindowChanged(); }, { passive: true, signal: abort.signal }, ); 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); }; this.#scrollVisibilityObserver = new IntersectionObserver( (entries, observer) => { if (!entries[0].isIntersecting) return; observer.disconnect(); this.#scrollVisibilityObserver = undefined; requestAnimationFrame(attach); }, ); this.#scrollVisibilityObserver.observe(this); } #renderIfWindowChanged() { const { startIndex, endIndex } = this.#computeWindow(this.#itemCount); if ( startIndex === this.#renderedStartIndex && endIndex === this.#renderedEndIndex ) return; this.forceRender(); } /** * @param {number} count * @returns {{ startIndex: number; endIndex: number }} */ #computeWindow(count) { const scrollTop = this.#scrollTop; const viewportHeight = this.#viewportHeight; if (this.#offsets.length === 0) { const startIndex = Math.max( 0, Math.floor(scrollTop / TRACK_ROW_HEIGHT) - OVERSCAN, ); const visibleCount = Math.ceil(viewportHeight / TRACK_ROW_HEIGHT) + 2 * OVERSCAN; return { startIndex, endIndex: Math.min(count, startIndex + visibleCount), }; } const offsets = this.#offsets; const startIndex = Math.max(0, bisectRight(offsets, scrollTop) - OVERSCAN); const endIndex = Math.min( count, bisectRight(offsets, scrollTop + viewportHeight) + 1 + OVERSCAN, ); return { startIndex, endIndex }; } #buildOffsets() { const items = this.#flatItems; if (items.length === 0) { this.#offsets = []; return; } const offsets = new Array(items.length + 1); offsets[0] = 0; for (let i = 0; i < items.length; i++) { offsets[i + 1] = offsets[i] + (items[i].type === "group" ? GROUP_HEADER_HEIGHT : TRACK_ROW_HEIGHT); } this.#offsets = offsets; } #disconnectCoverObserver() { this.#coverObserver?.disconnect(); this.#coverObserver = undefined; clearTimeout(this.#artFetchDebounce); this.#pendingVisibleCards.clear(); } // RENDER /** * @param {Function} html */ #renderPlaylistPicker(html) { const state = this.#playlistPickerState.value; if (!state) { return html` `; } const isCreate = state.mode === "create"; const title = state.mode === "filter" ? "Filter by playlist" : isCreate ? "New playlist" : "Add to playlist"; const groups = this.$groupedPlaylists(); const headerBackBtn = isCreate ? html` ` : ``; const closeBtn = html` `; const body = isCreate ? html`
` : html`
${state.mode === "filter" ? html` ` : ``} ${state.mode === "add" ? html` ${groups.length > 0 ? html`
` : ``} ` : ``} ${groups.length === 0 ? (state.mode === "add" ? `` : html`
No playlists yet
`) : groups.map(({ label, playlists }) => html`
${label}
${playlists.map((p) => html` ` )} ` )}
`; return html`
${headerBackBtn} ${title} ${closeBtn}
${body}
`; } /** * @param {Function} html * @param {boolean} isLoading */ #renderCoverView(html, isLoading) { if (this.#openCoverItem.value) return this.#renderCoverDetail(html); const coverViewMode = this.#coverViewMode.value; const sortDirection = this.$scope.value?.sortDirection() ?? "asc"; const groups = coverViewMode === "artists" ? this.$sortedArtistGroups() : this.$sortedCoverGroups(); const totalCount = groups.reduce((n, g) => n + g.groups.length, 0); const countLabel = coverViewMode === "artists" ? `${totalCount} ${totalCount === 1 ? "artist" : "artists"}` : `${totalCount} ${totalCount === 1 ? "album" : "albums"}`; const tabs = html`
${countLabel}
`; if (isLoading) { return html` ${tabs}
Loading ...
`; } const albumTracksMap = this.$tracksByAlbum(); const artistTracksMap = this.$tracksByArtist(); if (coverViewMode === "artists") { requestAnimationFrame(() => this.#setupCoverObserver()); return html` ${tabs}
${this.$sortedArtistGroups().map(({ label, groups }, groupIndex) => html` ${label ? html`
${label}
` : ``}
${groups.map(({ artistKey, artistName, trackCount, track }) => { const artUrl = this.#coverArtCache.get(artistKey); const cardTracks = artistTracksMap.get(artistKey) ?? []; return html`
${artUrl ? html` ${artistName} ` : html`
`} ${this.#renderTrackMenu( html, `artist-card-menu-${track.id}`, cardTracks, )}
${artistName} ${trackCount} ${trackCount === 1 ? `track` : `tracks`}
`; })}
` )}
`; } // Albums mode requestAnimationFrame(() => this.#setupCoverObserver()); return html` ${tabs}
${this.$sortedCoverGroups().map(({ label, groups }, groupIndex) => html` ${label ? html`
${label}
` : ``}
${groups.map(({ albumKey, albumName, artist, track }) => { const artUrl = this.#coverArtCache.get(albumKey); const cardTracks = albumTracksMap.get(albumKey) ?? []; return html`
${artUrl ? html` ${albumName} ` : html`
`} ${this.#renderTrackMenu( html, `album-card-menu-${track.id}`, cardTracks, )}
${albumName} ${artist}
`; })}
` )}
`; } /** * @param {Function} html * @param {boolean} isLoading * @param {string[]} sortBy */ #renderListView(html, isLoading, sortBy) { const tracks = this.$provider.value?.tracks().scoped ?? []; const groups = /** @type {any} */ (this.$provider.value)?.groups?.(); 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; // Rebuild flat items and pixel offsets only when data reference changes if (groups !== this.#lastGroups || tracks !== this.#lastTracks) { this.#flatItems = groups ? buildFlatList(groups) : []; this.#buildOffsets(); this.#lastGroups = groups; this.#lastTracks = tracks; } const count = groups ? this.#flatItems.length : tracks.length; this.#itemCount = count; const { startIndex, endIndex } = this.#computeWindow(count); this.#renderedStartIndex = startIndex; this.#renderedEndIndex = endIndex; const totalSize = this.#offsets.length > 0 ? this.#offsets[this.#flatItems.length] : count * TRACK_ROW_HEIGHT; const favSet = this.$favouritesSet(); /** * @param {Track} track * @param {number} top * @param {number} index */ const renderTrackRow = (track, top, index) => { const key = `${String(track.tags?.artist ?? "").toLowerCase()}|${ String(track.tags?.title ?? "").toLowerCase() }`; const isFav = favSet.has(key); return html`
${playlistOrdered ? html`
${trackPositions?.get(track.id)}
` : ``}
${trackTitle(track)}
${track.tags?.artist}
${track.tags?.album}
${this.#renderTrackMenu(html, `list-track-menu-${track.id}`, [ track, ])}
`; }; return html`
${playlistOrdered ? html`
#
` : ``}
Title ${sortedColumn === `title` ? html` ` : ``}
Artist ${sortedColumn === `artist` ? html` ` : ``}
Album ${sortedColumn === `album` ? html` ` : ``}
${isLoading ? html`
Loading ...
` : repeat( buildWindowItems( groups, tracks, this.#flatItems, this.#offsets, startIndex, endIndex, ), (entry) => entry.key, (entry) => { if (entry.type === "group") { return html`
${entry.label}
`; } return renderTrackRow(entry.track, entry.top, entry.index); }, )}
`; } /** * @param {Function} html */ #renderCoverDetail(html) { const item = this.#openCoverItem.value; if (!item) { return html` `; } const detailTracks = this.$detailTracks(); const favSet = this.$favouritesSet(); let key = "", name = "", subtitle = ""; if (item.type === "album") { key = item.albumKey; name = item.albumName; subtitle = item.artist; } else { key = item.artistKey; name = item.artistName; subtitle = `${item.trackCount} ${ item.trackCount === 1 ? "track" : "tracks" }`; } const artUrl = this.#coverArtCache.get(key); return html`
${this.#renderTrackMenu(html, `album-actions-menu`, detailTracks)}
${artUrl ? html` ${name} ` : html`
`}
${name} ${subtitle}
${detailTracks.map((t, i) => { const favKey = `${String(t.tags?.artist ?? "").toLowerCase()}|${ String(t.tags?.title ?? "").toLowerCase() }`; const isFav = favSet.has(favKey); return html`
${trackTitle(t)}
${t.tags?.artist}
${this.#renderTrackMenu(html, `detail-track-menu-${t.id}`, [ t, ])}
`; })}
`; } /** * @param {RenderArg} _ */ /** * @param {Function} html * @param {string[]} sortBy * @param {string | undefined} groupBy */ #renderGroupByMenu(html, sortBy, groupBy) { return html` `; } /** * @param {Function} html * @param {string | undefined} playlist */ #renderPlaylistMenu(html, playlist) { return html` `; } /** * @param {RenderArg} _ */ render({ html }) { const isLoading = this.$output.value?.tracks?.collection().state !== "loaded"; const playlist = this.$scope.value?.playlist(); const groupBy = this.$scope.value?.groupBy(); const searchTerm = this.$scope.value?.searchTerm() ?? ""; const sortBy = this.$scope.value?.sortBy() ?? DEFAULT_SORT; const viewMode = this.#viewMode.value; return html`
${searchTerm ? html` ` : ``} ${this.#renderGroupByMenu(html, sortBy, groupBy)} ${this .#renderPlaylistMenu(html, playlist)}
${viewMode === `cover` ? this.#renderCoverView(html, isLoading) : this.#renderListView(html, isLoading, sortBy)} ${this .#renderPlaylistPicker(html)} `; } } export default Browser; //////////////////////////////////////////// // HELPERS //////////////////////////////////////////// /** * @param {Uint8Array} bytes * @returns {string} */ function detectMime(bytes) { if (bytes[0] === 0xFF && bytes[1] === 0xD8) return "image/jpeg"; if (bytes[0] === 0x89 && bytes[1] === 0x50) return "image/png"; if (bytes[0] === 0x47 && bytes[1] === 0x49) return "image/gif"; if (bytes[0] === 0x52 && bytes[1] === 0x49) return "image/webp"; return "image/jpeg"; } /** * Largest index i where sortedArr[i] <= target (sorted ascending). * Returns 0 when target is below all values. * @param {number[]} sortedArr * @param {number} target * @returns {number} */ function bisectRight(sortedArr, target) { let lo = 0, hi = sortedArr.length; while (lo < hi) { const mid = (lo + hi) >>> 1; if (sortedArr[mid] <= target) lo = mid + 1; else hi = mid; } return Math.max(0, lo - 1); } /** * @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; } /** * @typedef {{ type: "group"; key: string; label: string; index: number; top: number }} WindowGroupEntry * @typedef {{ type: "track"; key: string; track: Track; index: number; top: number }} WindowTrackEntry * @typedef {WindowGroupEntry | WindowTrackEntry} WindowEntry */ /** * @param {{ label: string; tracks: Track[] }[] | undefined} groups * @param {Track[]} tracks * @param {VirtualItem[]} flatItems * @param {number[]} offsets * @param {number} startIndex * @param {number} endIndex * @returns {WindowEntry[]} */ function buildWindowItems( groups, tracks, flatItems, offsets, startIndex, endIndex, ) { /** @type {WindowEntry[]} */ const entries = []; for (let i = startIndex; i < endIndex; i++) { const item = groups ? flatItems[i] : /** @type {VirtualItem} */ ({ type: "track", track: tracks[i] }); const top = offsets.length > 0 ? offsets[i] : i * TRACK_ROW_HEIGHT; if (!item) continue; if (item.type === "group") { entries.push({ type: "group", key: `group:${item.label}`, label: item.label, index: i, top, }); } else { entries.push({ type: "track", key: `track:${item.track.id}`, track: item.track, index: i, top, }); } } return entries; } //////////////////////////////////////////// // REGISTER //////////////////////////////////////////// export const CLASS = Browser; export const NAME = "db-browser"; defineElement(NAME, CLASS);