From 7624d90c94fc9731c43b1d6e2bee810c7f66ee6f Mon Sep 17 00:00:00 2001 From: Steven Vandevelde Date: Fri, 17 Apr 2026 23:04:10 +0200 Subject: [PATCH] feat: blur theme cover view --- src/common/foundation.js | 21 +- src/components/engine/queue/types.d.ts | 2 +- src/components/engine/queue/worker.js | 12 +- .../orchestrator/auto-queue/element.js | 2 +- .../orchestrator/cover-groups/element.js | 184 +++ src/styles/diffuse/facet.css | 2 +- src/themes/blur/browser/element.css | 301 ++++- src/themes/blur/browser/element.js | 1034 ++++++++++++++--- src/themes/blur/browser/facet/index.inline.js | 10 +- src/themes/blur/facet/index.html | 2 + src/themes/blur/facet/index.inline.js | 1 + tests/components/engine/queue/test.ts | 8 +- 12 files changed, 1393 insertions(+), 186 deletions(-) create mode 100644 src/components/orchestrator/cover-groups/element.js diff --git a/src/common/foundation.js b/src/common/foundation.js index d322f696..09bb7c9c 100644 --- a/src/common/foundation.js +++ b/src/common/foundation.js @@ -50,6 +50,9 @@ const signals = { controller: signal( /** @type {import("~/components/orchestrator/controller/element.js").CLASS | null} */ (null), ), + coverGroups: signal( + /** @type {import("~/components/orchestrator/cover-groups/element.js").CLASS | null} */ (null), + ), autoQueue: signal( /** @type {import("~/components/orchestrator/auto-queue/element.js").CLASS | null} */ (null), ), @@ -107,6 +110,7 @@ export const config = { orchestrator: { artwork, controller, + coverGroups, autoQueue, favourites, mediaSession, @@ -140,6 +144,7 @@ export const config = { orchestrator: { artwork: signals.orchestrator.artwork.get, controller: signals.orchestrator.controller.get, + coverGroups: signals.orchestrator.coverGroups.get, autoQueue: signals.orchestrator.autoQueue.get, favourites: signals.orchestrator.favourites.get, mediaSession: signals.orchestrator.mediaSession.get, @@ -303,7 +308,8 @@ async function scope() { return findExistingOrAdd(s, signals.engine.scope); } -// Orchestrators (cont.) +// Orchestrators + async function artwork() { const [{ CLASS: ArtworkOrchestrator }, ac] = await Promise.all([ import("~/components/orchestrator/artwork/element.js"), @@ -317,7 +323,6 @@ async function artwork() { return findExistingOrAdd(a, signals.orchestrator.artwork); } -// Orchestrators async function autoQueue() { const [{ CLASS: AutoQueueOrchestrator }, q, r, t] = await Promise.all([ import("~/components/orchestrator/auto-queue/element.js"), @@ -351,6 +356,18 @@ async function controller() { return findExistingOrAdd(co, signals.orchestrator.controller); } +async function coverGroups() { + const [{ CLASS: CoverGroupsOrchestrator }, t] = await Promise.all([ + import("~/components/orchestrator/cover-groups/element.js"), + scopedTracks(), + ]); + + const cgo = new CoverGroupsOrchestrator(); + cgo.setAttribute("tracks-selector", t.selector); + + return findExistingOrAdd(cgo, signals.orchestrator.coverGroups); +} + async function favourites() { const [{ CLASS: FavouritesOrchestrator }, o] = await Promise.all([ import("~/components/orchestrator/favourites/element.js"), diff --git a/src/components/engine/queue/types.d.ts b/src/components/engine/queue/types.d.ts index b58d4761..1d1d1369 100644 --- a/src/components/engine/queue/types.d.ts +++ b/src/components/engine/queue/types.d.ts @@ -5,7 +5,7 @@ export type Actions = { /** * Clear the `future()` items. */ - clear: (args: { manualOnly?: boolean }) => void; + clear: (args: { keepManual?: boolean }) => void; fill: ( args: { /** Always keep adding, even if the amount of non-manual items in the queue are passed the given `amount` */ diff --git a/src/components/engine/queue/worker.js b/src/components/engine/queue/worker.js index 655f87b1..d07a9fbc 100644 --- a/src/components/engine/queue/worker.js +++ b/src/components/engine/queue/worker.js @@ -66,7 +66,7 @@ export function add({ inFront, trackIds }) { /** * @type {Actions['clear']} * - * @example Keeps manual entries when manualOnly is true + * @example Keeps manual entries when keepManual is true * ```js * import { clear, $future } from "~/components/engine/queue/worker.js"; * @@ -74,13 +74,13 @@ export function add({ inFront, trackIds }) { * { id: "manual", manualEntry: true }, * { id: "auto", manualEntry: false }, * ]; - * clear({ manualOnly: true }); + * clear({ keepManual: true }); * * if ($future.value.length !== 1) throw new Error("expected 1 item remaining"); * if ($future.value[0].id !== "manual") throw new Error("manual entry should remain"); * ``` * - * @example Clears all items when manualOnly is false + * @example Clears all items when keepManual is false * ```js * import { clear, $future } from "~/components/engine/queue/worker.js"; * @@ -88,13 +88,13 @@ export function add({ inFront, trackIds }) { * { id: "manual", manualEntry: true }, * { id: "auto", manualEntry: false }, * ]; - * clear({ manualOnly: false }); + * clear({ keepManual: false }); * * if ($future.value.length !== 0) throw new Error("expected empty queue"); * ``` */ -export function clear({ manualOnly }) { - $future.value = manualOnly +export function clear({ keepManual }) { + $future.value = keepManual ? $future.value.filter((i) => i.manualEntry === true) : []; } diff --git a/src/components/orchestrator/auto-queue/element.js b/src/components/orchestrator/auto-queue/element.js index a035cc0f..98d69f9f 100644 --- a/src/components/orchestrator/auto-queue/element.js +++ b/src/components/orchestrator/auto-queue/element.js @@ -74,7 +74,7 @@ class AutoTracksOrchestrator extends BroadcastableDiffuseElement { if (shuffled !== lastShuffle || fingerprint !== lastFingerprint) { lastShuffle = shuffled; lastFingerprint = fingerprint; - queue.clear({ manualOnly: true }); + queue.clear({ keepManual: true }); } queue.fill({ amount: 10, shuffled: repeatShuffle.shuffle() }); diff --git a/src/components/orchestrator/cover-groups/element.js b/src/components/orchestrator/cover-groups/element.js new file mode 100644 index 00000000..41436843 --- /dev/null +++ b/src/components/orchestrator/cover-groups/element.js @@ -0,0 +1,184 @@ +import { defineElement, DiffuseElement, query } from "~/common/element.js"; +import { computed, signal } from "~/common/signal.js"; + +/** + * @import {SignalReader} from "~/common/signal.d.ts" + * @import {Track} from "~/definitions/types.d.ts" + */ + +//////////////////////////////////////////// +// ELEMENT +//////////////////////////////////////////// + +class CoverGroupsOrchestrator extends DiffuseElement { + static NAME = "diffuse/orchestrator/cover-groups"; + + // SIGNALS + + #provider = signal( + /** @type {DiffuseElement & { tracks: SignalReader } | null} */ (null), + ); + + // STATE + + artistGroups = computed(() => { + const groups = /** @type {any} */ (this.#provider.value)?.groups?.(); + const allTracks = this.#provider.value?.tracks() ?? []; + + // Total track counts per artist across all groups + /** @type {Map} */ + const totalCounts = new Map(); + for (const track of allTracks) { + const key = String(track.tags?.artist ?? "").toLowerCase(); + totalCounts.set(key, (totalCounts.get(key) ?? 0) + 1); + } + + /** @type {{ label: string; groups: ArtistGroup[] }[]} */ + const result = []; + + if (groups?.length) { + for ( + const group + of /** @type {{ label: string; tracks: Track[] }[]} */ (groups) + ) { + const artists = deduplicateArtists(group.tracks).map((a) => ({ + ...a, + trackCount: totalCounts.get(a.artistKey) ?? a.trackCount, + })); + if (artists.length) result.push({ label: group.label, groups: artists }); + } + } else { + const artists = deduplicateArtists(allTracks); + if (artists.length) result.push({ label: "", groups: artists }); + } + + return result; + }); + + coverGroups = computed(() => { + const groups = /** @type {any} */ (this.#provider.value)?.groups?.(); + + /** @type {{ label: string; groups: CoverGroup[] }[]} */ + const result = []; + + if (groups?.length) { + for ( + const group + of /** @type {{ label: string; tracks: Track[] }[]} */ (groups) + ) { + const albums = deduplicateAlbums(group.tracks); + if (albums.length) result.push({ label: group.label, groups: albums }); + } + } else { + const tracks = this.#provider.value?.tracks() ?? []; + const albums = deduplicateAlbums(tracks); + if (albums.length) result.push({ label: "", groups: albums }); + } + + return result; + }); + + // LIFECYCLE + + /** + * @override + */ + async connectedCallback() { + super.connectedCallback(); + + /** @type {DiffuseElement & { tracks: SignalReader }} */ + const provider = query(this, "tracks-selector"); + + await customElements.whenDefined(provider.localName); + this.#provider.value = provider; + } +} + +export default CoverGroupsOrchestrator; + +//////////////////////////////////////////// +// HELPERS +//////////////////////////////////////////// + +/** + * @typedef {{ albumKey: string; albumName: string; artist: string; track: Track }} CoverGroup + */ + +/** + * @typedef {{ artistKey: string; artistName: string; trackCount: number; track: Track }} ArtistGroup + */ + +/** + * @param {Track[]} tracks + * @returns {CoverGroup[]} + */ +function deduplicateAlbums(tracks) { + const sorted = [...tracks].sort((a, b) => { + const aAlbum = String(a.tags?.album ?? "").toLowerCase(); + const bAlbum = String(b.tags?.album ?? "").toLowerCase(); + return aAlbum.localeCompare(bAlbum); + }); + + /** @type {Map }>} */ + const albumMap = new Map(); + + for (const track of sorted) { + const albumKey = String(track.tags?.album ?? "").toLowerCase(); + const existing = albumMap.get(albumKey); + if (existing) { + existing.artists.add(track.tags?.artist ?? "Unknown artist"); + } else { + albumMap.set(albumKey, { + track, + artists: new Set([track.tags?.artist ?? "Unknown artist"]), + }); + } + } + + return [...albumMap.entries()].map(([albumKey, { track, artists }]) => ({ + albumKey, + albumName: track.tags?.album ?? "Unknown album", + artist: artists.size > 1 ? "Various Artists" : /** @type {string} */ ([...artists][0]), + track, + })); +} + +/** + * @param {Track[]} tracks + * @returns {ArtistGroup[]} + */ +function deduplicateArtists(tracks) { + /** @type {Map} */ + const map = new Map(); + + for (const track of tracks) { + const artistKey = String(track.tags?.artist ?? "").toLowerCase(); + const existing = map.get(artistKey); + if (existing) { + existing.tracks.push(track); + } else { + map.set(artistKey, { + artistName: track.tags?.artist ?? "Unknown artist", + tracks: [track], + }); + } + } + + return [...map.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([artistKey, { artistName, tracks }]) => ({ + artistKey, + artistName, + trackCount: tracks.length, + track: tracks[0], + })); +} + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = CoverGroupsOrchestrator; +export const NAME = "do-cover-groups"; + +defineElement(NAME, CLASS); diff --git a/src/styles/diffuse/facet.css b/src/styles/diffuse/facet.css index b7765dff..3e9e6399 100644 --- a/src/styles/diffuse/facet.css +++ b/src/styles/diffuse/facet.css @@ -321,7 +321,7 @@ p, font-family: inherit; font-size: inherit; font-weight: inherit; - gap: var(--space-xs); + gap: var(--space-2xs); min-width: var(--space-3xl); padding: var(--space-xs) var(--space-sm); text-align: left; diff --git a/src/themes/blur/browser/element.css b/src/themes/blur/browser/element.css index 6b063053..cc2b1556 100644 --- a/src/themes/blur/browser/element.css +++ b/src/themes/blur/browser/element.css @@ -67,32 +67,45 @@ .toolbar-actions { align-items: center; display: flex; + gap: var(--space-3xs); } -.playlist-btn { +.browser-button { align-items: center; background: transparent; border: 1px solid var(--border-color); border-radius: var(--radius-md); - color: inherit; + color: color-mix(in oklch, currentColor 50%, transparent); cursor: pointer; display: flex; font-family: inherit; font-size: 100%; gap: var(--space-2xs); + padding: var(--space-3xs) var(--space-2xs); + text-box: trim-both cap alphabetic; +} + +.browser-button i { + flex-shrink: 0; +} + +.browser-button--active { + background: color-mix(in oklch, currentColor 8%, transparent); + color: currentColor; +} + +.browser-button:hover:not(.browser-button--active) { + color: color-mix(in oklch, currentColor 75%, transparent); +} + +.browser-button--playlist { margin-left: var(--space-2xs); max-width: 12rem; overflow: hidden; - padding: var(--space-3xs) var(--space-2xs) calc(var(--space-3xs) - 1px); - text-box: trim-both cap alphabetic; text-overflow: ellipsis; white-space: nowrap; } -.playlist-btn i { - flex-shrink: 0; -} - .toolbar-icon-btn { align-items: center; background: transparent; @@ -348,3 +361,275 @@ color: color-mix(in oklch, currentColor 40%, transparent); padding: var(--space-lg) var(--space-md); } + +/*********************************** + * View mode toggle + ***********************************/ + +.toolbar-icon-btn--active { + color: currentColor; +} + +/*********************************** + * Cover tabs + ***********************************/ + +.cover-tabs { + align-items: center; + display: flex; + justify-content: space-between; + padding: var(--space-sm) var(--space-sm) var(--space-2xs); +} + +.cover-tabs-start { + align-items: center; + display: flex; + gap: var(--space-2xs); +} + +.cover-tabs-end { + align-items: center; + color: color-mix(in oklch, currentColor 40%, transparent); + display: flex; + font-size: 90%; + gap: var(--space-3xs); +} + +.cover-count { + text-box: trim-both cap alphabetic; +} + +/*********************************** + * Cover view + ***********************************/ + +.cover-scroll-panel { + flex: 1; + overflow-y: auto; + min-height: 0; + user-select: none; +} + +.cover-group-header { + align-items: center; + color: color-mix(in oklch, currentColor 45%, transparent); + display: flex; + font-size: 110%; + font-weight: 600; + gap: var(--space-2xs); + padding: var(--space-sm) var(--space-sm) var(--space-2xs); + + & > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.cover-group-header--top { + padding-top: var(--space-xs); +} + +.cover-grid { + display: grid; + gap: var(--space-sm); + grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); + padding: var(--space-2xs) var(--space-sm) var(--space-sm); +} + +.cover-card { + cursor: pointer; + display: flex; + flex-direction: column; + gap: var(--space-2xs); +} + +.cover-card:hover .cover-art img, +.cover-card:hover .cover-art-placeholder { + opacity: 0.85; +} + +.cover-art { + aspect-ratio: 1; + background: color-mix(in oklch, currentColor 6%, transparent); + border-radius: var(--radius-md); + overflow: hidden; + width: 100%; +} + +.cover-art img { + display: block; + height: 100%; + object-fit: cover; + transition: opacity 80ms; + width: 100%; +} + +.cover-art-placeholder { + align-items: center; + color: color-mix(in oklch, currentColor 20%, transparent); + display: flex; + font-size: 2rem; + height: 100%; + justify-content: center; + transition: opacity 80ms; + width: 100%; +} + +.cover-art-loading { + animation: cover-pulse 1.2s ease-in-out infinite; +} + +@keyframes cover-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +.cover-info { + display: flex; + flex-direction: column; + gap: 1px; + overflow: hidden; +} + +.cover-album { + display: block; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cover-artist { + color: color-mix(in oklch, currentColor 55%, transparent); + display: block; + font-size: 90%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/*********************************** + * Album detail view + ***********************************/ + +.album-detail { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.album-detail-actions { + align-items: center; + display: flex; + gap: var(--space-2xs); + padding: var(--space-sm) var(--space-xs) var(--space-2xs); +} + +.album-detail-main { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.album-detail-sidebar { + flex: 0 0 auto; + overflow-y: auto; + padding: var(--space-xs) var(--space-sm); + width: 13rem; +} + +.album-detail-art { + aspect-ratio: 1; + background: color-mix(in oklch, currentColor 6%, transparent); + border-radius: var(--radius-md); + overflow: hidden; + width: 100%; +} + +.album-detail-art img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.album-detail-info { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: var(--space-xs); + overflow: hidden; + padding: 0 var(--space-3xs); +} + +.album-detail-name { + display: block; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.album-detail-artist { + color: color-mix(in oklch, currentColor 55%, transparent); + display: block; + font-size: 90%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.album-detail-tracks { + flex: 1; + min-width: 0; + overflow-y: auto; + user-select: none; +} + +.album-track-row { + align-items: center; + cursor: pointer; + display: flex; + height: 40px; + padding: 0 var(--space-xs); + transition: background-color 80ms; +} + +.album-track-row--alt { + background-color: color-mix(in oklch, currentColor 1.5%, transparent); +} + +.album-track-row:hover { + background-color: color-mix(in oklch, currentColor 6%, var(--bg-color)); +} + +.album-track-row > div { + overflow: hidden; +} + +.album-track-row .col-fav { + align-items: center; + display: flex; + flex-shrink: 0; +} + +.album-track-row .col-title, +.album-track-row .col-artist { + padding-left: var(--space-2xs); +} + +.album-track-row span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/themes/blur/browser/element.js b/src/themes/blur/browser/element.js index 26bccc7b..45f235c1 100644 --- a/src/themes/blur/browser/element.js +++ b/src/themes/blur/browser/element.js @@ -2,6 +2,7 @@ import { defineElement, DiffuseElement, query, + queryOptional, whenElementsDefined, } from "~/common/element.js"; import { computed, signal, untracked } from "~/common/signal.js"; @@ -18,8 +19,11 @@ import { * @import {SignalReader} from "~/common/signal.d.ts"; * @import {Track} from "~/definitions/types.d.ts" * @import {OutputElement} from "~/components/output/types.d.ts" + * @import {ArtworkElement} from "~/components/artwork/types.d.ts" */ +const MAX_ART_CONCURRENT = 8; + const TRACK_ROW_HEIGHT = 40; const GROUP_HEADER_HEIGHT = 52; const OVERSCAN = 10; @@ -34,10 +38,34 @@ const COLUMN_SORT = { const DEFAULT_SORT = ["createdAt"]; const GROUP_BY_OPTIONS = [ - { value: "firstLetter", label: "Group by first letter", icon: "ph-text-aa", sortBy: null, sortDirection: /** @type {"asc" | "desc" | undefined} */ (undefined) }, - { value: "directory", label: "Group by path", icon: "ph-folder", sortBy: ["uri"], sortDirection: /** @type {"asc" | "desc" | undefined} */ (undefined) }, - { value: "createdAt", label: "Group by processing date", icon: "ph-clock", sortBy: ["createdAt"], sortDirection: /** @type {"asc" | "desc" | undefined} */ ("desc") }, - { value: "tags.year", label: "Group by track year", icon: "ph-calendar", sortBy: ["tags.year"], sortDirection: /** @type {"asc" | "desc" | undefined} */ ("desc") }, + { + value: "firstLetter", + label: "Group by first letter", + icon: "ph-text-aa", + sortBy: null, + sortDirection: /** @type {"asc" | "desc" | undefined} */ (undefined), + }, + { + value: "directory", + label: "Group by path", + icon: "ph-folder", + sortBy: ["uri"], + sortDirection: /** @type {"asc" | "desc" | undefined} */ (undefined), + }, + { + value: "createdAt", + label: "Group by processing date", + icon: "ph-clock", + sortBy: ["createdAt"], + sortDirection: /** @type {"asc" | "desc" | undefined} */ ("desc"), + }, + { + value: "tags.year", + label: "Group by track year", + icon: "ph-calendar", + sortBy: ["tags.year"], + sortDirection: /** @type {"asc" | "desc" | undefined} */ ("desc"), + }, ]; /** @@ -46,6 +74,12 @@ const GROUP_BY_OPTIONS = [ * @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(); @@ -54,6 +88,14 @@ class Browser extends DiffuseElement { // 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), ); @@ -74,6 +116,38 @@ class Browser extends DiffuseElement { /** @type {import("~/components/orchestrator/favourites/element.js").CLASS | undefined} */ (undefined), ); + // SIGNALS - Pt. 2 + + #viewMode = signal( + /** @type {"list" | "cover"} */ ( + localStorage.getItem("diffuse:browser:view-mode") === "cover" + ? "cover" + : "list" + ), + ); + + #coverViewMode = signal(/** @type {"albums" | "artists"} */ ("albums")); + + #openCoverItem = signal(/** @type {OpenCoverItem | 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; + }); + $groupedPlaylists = computed(() => { const col = this.$output.value?.playlistItems.collection(); if (!col || col.state !== "loaded" || !col.data.length) return []; @@ -97,6 +171,23 @@ class Browser extends DiffuseElement { // 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; + + #observedCards = new WeakSet(); + /** @type {VirtualItem[]} */ #flatItems = []; @@ -120,6 +211,15 @@ class Browser extends DiffuseElement { 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"); @@ -135,13 +235,27 @@ class Browser extends DiffuseElement { /** @type {import("~/components/orchestrator/favourites/element.js").CLASS} */ const favourites = query(this, "favourites-orchestrator-selector"); - 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; - }); + 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 (artwork) { + whenElementsDefined({ artwork }).then(() => { + this.$artwork.value = artwork; + }); + } + + if (coverGroups) { + whenElementsDefined({ coverGroups }).then(() => { + this.$coverGroups.value = coverGroups; + }); + } // Reset scroll when track list changes this.effect(() => { @@ -154,33 +268,7 @@ class Browser extends DiffuseElement { }); // Set up the virtualizer after the first render, when .scroll-panel exists in the DOM. - // This mirrors the winamp browser's #setupScrollTracking pattern. - requestAnimationFrame(() => { - const panel = this.root().querySelector(".scroll-panel"); - if (!panel) return; - - this.#virtualizer = new Virtualizer({ - count: 0, - getScrollElement: () => panel, - estimateSize: (i) => - this.#flatItems[i]?.type === "group" - ? GROUP_HEADER_HEIGHT - : TRACK_ROW_HEIGHT, - overscan: OVERSCAN, - observeElementRect, - observeElementOffset, - scrollToFn: elementScroll, - onChange: () => { - requestAnimationFrame(() => this.forceRender()); - }, - }); - - this.#virtualizerCleanup = this.#virtualizer._didMount(); - this.#virtualizer._willUpdate(); - - // Render now that the virtualizer is wired up - this.forceRender(); - }); + requestAnimationFrame(() => this.#setupVirtualizer()); } /** @@ -191,6 +279,7 @@ class Browser extends DiffuseElement { this.#virtualizerCleanup?.(); this.#virtualizerCleanup = undefined; this.#virtualizer = undefined; + this.#disconnectCoverObserver(); } // EVENTS @@ -260,20 +349,364 @@ class Browser extends DiffuseElement { this.$favourites.value?.toggle(track); }; + toggleViewMode = () => { + if (this.#viewMode.value === "cover") { + this.#disconnectCoverObserver(); + this.#openCoverItem.value = null; + requestAnimationFrame(() => this.#setupVirtualizer()); + } + const next = this.#viewMode.value === "list" ? "cover" : "list"; + localStorage.setItem("diffuse:browser:view-mode", next); + this.#viewMode.value = next; + }; + + /** + * @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 bytes = artwork ? await artwork.get(track) : null; + if (bytes) { + const mime = detectMime(bytes); + const url = URL.createObjectURL( + new Blob([/** @type {ArrayBuffer} */ (bytes.buffer)], { type: mime }), + ); + this.#coverArtCache.set(albumKey, url); + } else { + this.#coverArtCache.set(albumKey, null); + } + } catch { + this.#coverArtCache.set(albumKey, null); + } finally { + this.#artFetchActive--; + this.#drainArtQueue(); + } + this.#scheduleArtRender(); + } + + #scheduleArtRender() { + if (this.#artRenderScheduled) return; + this.#artRenderScheduled = true; + requestAnimationFrame(() => { + this.#artRenderScheduled = false; + this.forceRender(); + }); + } + + #setupCoverObserver() { + const root = this.root().querySelector(".cover-scroll-panel"); + if (!root) return; + + if (!this.#coverObserver) { + this.#coverObserver = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const albumKey = + /** @type {HTMLElement} */ (entry.target).dataset.albumKey; + if (!albumKey) continue; + const track = this.$albumTrackMap().get(albumKey); + if (track) this.#fetchAlbumArt(albumKey, track); + this.#coverObserver?.unobserve(entry.target); + } + }, + { root, rootMargin: "200px" }, + ); + } + + for ( + const card of this.root().querySelectorAll(".cover-card[data-album-key]") + ) { + if (this.#observedCards.has(card)) continue; + this.#observedCards.add(card); + this.#coverObserver.observe(card); + } + } + + #setupVirtualizer() { + const panel = this.root().querySelector(".scroll-panel"); + if (!panel) return; + + this.#virtualizerCleanup?.(); + + this.#virtualizer = new Virtualizer({ + count: 0, + getScrollElement: () => panel, + estimateSize: (i) => + this.#flatItems[i]?.type === "group" + ? GROUP_HEADER_HEIGHT + : TRACK_ROW_HEIGHT, + overscan: OVERSCAN, + observeElementRect, + observeElementOffset, + scrollToFn: elementScroll, + onChange: () => { + requestAnimationFrame(() => this.forceRender()); + }, + }); + + this.#virtualizerCleanup = this.#virtualizer._didMount(); + this.#virtualizer._willUpdate(); + this.forceRender(); + } + + #disconnectCoverObserver() { + this.#coverObserver?.disconnect(); + this.#coverObserver = undefined; + this.#observedCards = new WeakSet(); + } + // RENDER /** - * @param {RenderArg} _ + * @param {Function} html + * @param {boolean} isLoading */ - render({ html }) { - const isLoading = - this.$output.value?.tracks?.collection().state !== "loaded"; + #renderCoverView(html, isLoading) { + if (this.#openCoverItem.value) return this.#renderCoverDetail(html); + + const coverViewMode = this.#coverViewMode.value; + const sortDirection = this.$scope.value?.sortDirection() ?? "asc"; + + const totalCount = coverViewMode === "artists" + ? (this.$coverGroups.value?.artistGroups() ?? []).reduce((n, g) => n + g.groups.length, 0) + : (this.$coverGroups.value?.coverGroups() ?? []).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 ...
+ `; + } + + if (coverViewMode === "artists") { + const artistGroups = this.$coverGroups.value?.artistGroups() ?? []; + requestAnimationFrame(() => this.#setupCoverObserver()); + + return html` + ${tabs} +
+ ${artistGroups.map(({ label, groups }, groupIndex) => + html` + ${label + ? html` +
+ + ${label} +
+ ` + : ``} +
+ ${groups.map(({ artistKey, artistName, trackCount, track }) => { + const artUrl = this.#coverArtCache.get(artistKey); + return html` +
+
+ ${artUrl + ? html` + ${artistName} + ` + : artUrl === null + ? html` +
+ ` + : html` +
+ `} +
+
+ ${artistName} + ${trackCount} ${trackCount === + 1 + ? `track` + : `tracks`} +
+
+ `; + })} +
+ ` + )} +
+ `; + } + + // Albums mode + const coverGroups = this.$coverGroups.value?.coverGroups() ?? []; + + requestAnimationFrame(() => this.#setupCoverObserver()); + + return html` + ${tabs} +
+ ${coverGroups.map(({ label, groups }, groupIndex) => + html` + ${label + ? html` +
+ + ${label} +
+ ` + : ``} +
+ ${groups.map(({ albumKey, albumName, artist, track }) => { + const artUrl = this.#coverArtCache.get(albumKey); + return html` +
+
+ ${artUrl + ? html` + ${albumName} + ` + : artUrl === null + ? html` +
+ ` + : html` +
+ `} +
+
+ ${albumName} + ${artist} +
+
+ `; + })} +
+ ` + )} +
+ `; + } + + /** + * @param {Function} html + * @param {boolean} isLoading + * @param {string[]} sortBy + */ + #renderListView(html, isLoading, sortBy) { const tracks = this.$provider.value?.tracks() ?? []; - 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 groups = /** @type {any} */ (this.$provider.value)?.groups?.(); const sortDirection = this.$scope.value?.sortDirection(); const sortedColumn = Object.entries(COLUMN_SORT).find( @@ -285,8 +718,6 @@ class Browser extends DiffuseElement { ? (sortDirection === "desc" ? "descending" : "ascending") : "none"; - const groups = /** @type {any} */ (this.$provider.value)?.groups?.(); - // Rebuild flat items only when data reference changes if (groups !== this.#lastGroups || tracks !== this.#lastTracks) { this.#flatItems = groups ? buildFlatList(groups) : []; @@ -316,7 +747,9 @@ class Browser extends DiffuseElement { 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()}`; + return `${String(a?.value ?? "").toLowerCase()}|${ + String(t?.value ?? "").toLowerCase() + }`; }), ); @@ -326,18 +759,25 @@ class Browser extends DiffuseElement { * @param {number} index */ const renderTrackRow = (track, top, index) => { - const key = `${String(track.tags?.artist ?? "").toLowerCase()}|${String(track.tags?.title ?? "").toLowerCase()}`; + const key = `${String(track.tags?.artist ?? "").toLowerCase()}|${ + String(track.tags?.title ?? "").toLowerCase() + }`; const isFav = favSet.has(key); return html`
- ` : ``} - - - - -
-
-
- Title - ${sortedColumn === `title` - ? html`` + Title ${sortedColumn === `title` + ? html` + + ` : ``}
- Artist - ${sortedColumn === `artist` - ? html`` + Artist ${sortedColumn === `artist` + ? html` + + ` : ``}
- Album - ${sortedColumn === `album` - ? html`` + Album ${sortedColumn === `album` + ? html` + + ` : ``}
@@ -476,29 +843,364 @@ class Browser extends DiffuseElement {
${isLoading - ? html`
Loading ...
` + ? html` +
Loading ...
+ ` : virtualItems.map((vItem) => { - const item = groups - ? this.#flatItems[vItem.index] - : { type: /** @type {"track"} */ ("track"), track: tracks[vItem.index] }; + const item = groups ? this.#flatItems[vItem.index] : { + type: /** @type {"track"} */ ("track"), + track: tracks[vItem.index], + }; + + return item?.type === "group" + ? html` +
+ + ${item.label} +
+ ` + : item?.type === "track" + ? renderTrackRow(item.track, vItem.start, vItem.index) + : ``; + })} +
+
+ `; + } - return item?.type === "group" - ? html` -
{ + return String(t.tags?.album ?? "").toLowerCase() === key; + }); + } else { + key = item.artistKey; + name = item.artistName; + subtitle = `${item.trackCount} ${ + item.trackCount === 1 ? "track" : "tracks" + }`; + detailTracks = allTracks.filter((t) => + String(t.tags?.artist ?? "").toLowerCase() === key + ); + } + + const artUrl = this.#coverArtCache.get(key); + + const favItems = this.$favourites.value?.playlistItems() ?? []; + const favSet = new Set( + favItems.map((fav) => { + const a = fav.criteria.find((c) => c.field === "tags.artist"); + const t = fav.criteria.find((c) => c.field === "tags.title"); + return `${String(a?.value ?? "").toLowerCase()}|${ + String(t?.value ?? "").toLowerCase() + }`; + }), + ); + + const menuLabel = item.type === "album" ? "Play album" : "Play all"; + + return html` +
+
+ + + +
+
+
+
+ ${artUrl + ? html` + ${name} + ` + : artUrl === null + ? html` +
+ ` + : 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` +
+
+
+ + +
+
+ ${t.tags?.title} +
+
+ ${t.tags?.artist} +
+
+ `; + })} +
+
+
+ `; + } + + /** + * @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)} `; } } @@ -509,6 +1211,18 @@ 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"; +} + /** * @param {{ label: string; tracks: Track[] }[]} groups * @returns {VirtualItem[]} diff --git a/src/themes/blur/browser/facet/index.inline.js b/src/themes/blur/browser/facet/index.inline.js index 4d9bdd00..312cff18 100644 --- a/src/themes/blur/browser/facet/index.inline.js +++ b/src/themes/blur/browser/facet/index.inline.js @@ -4,12 +4,14 @@ import BrowserElement from "~/themes/blur/browser/element.js"; // Set doc title foundation.setup({ title: "Browser | Blur | Diffuse" }); -const [out, que, scp, trc, fav] = await Promise.all([ +const [out, que, scp, art, cov, fav, trc] = await Promise.all([ foundation.orchestrator.output(), foundation.engine.queue(), foundation.engine.scope(), - foundation.orchestrator.scopedTracks(), + foundation.orchestrator.artwork(), + foundation.orchestrator.coverGroups(), foundation.orchestrator.favourites(), + foundation.orchestrator.scopedTracks(), ]); // Default to grouping by date added @@ -17,11 +19,13 @@ const [out, que, scp, trc, fav] = await Promise.all([ if (!scp.groupBy()) scp.setGroupBy("createdAt"); const el = new BrowserElement(); +el.setAttribute("artwork-selector", art.selector); +el.setAttribute("cover-groups-orchestrator-selector", cov.selector); +el.setAttribute("favourites-orchestrator-selector", fav.selector); el.setAttribute("output-selector", out.selector); el.setAttribute("queue-engine-selector", que.selector); el.setAttribute("scope-engine-selector", scp.selector); el.setAttribute("tracks-selector", trc.selector); -el.setAttribute("favourites-orchestrator-selector", fav.selector); (document.querySelector("#container") ?? document.body).append(el); diff --git a/src/themes/blur/facet/index.html b/src/themes/blur/facet/index.html index e1eab21f..20d80502 100644 --- a/src/themes/blur/facet/index.html +++ b/src/themes/blur/facet/index.html @@ -34,6 +34,8 @@ queue-engine-selector="de-queue" scope-engine-selector="de-scope" favourites-orchestrator-selector="do-favourites" + artwork-selector="do-artwork" + cover-groups-orchestrator-selector="do-cover-groups" > { expect(items[1].id).toBe(tracks[1].id); }); - it("clears only auto-filled items when manualOnly is true", async () => { + it("clears only auto-filled items when keepManual is true", async () => { const items = await testWeb(async () => { const QueueEngine = await import("~/components/engine/queue/element.js"); const engine = new QueueEngine.CLASS(); @@ -94,7 +94,7 @@ describe("components/engine/queue", () => { await engine.supply({ trackIds: tracks.map((t) => t.id) }); await engine.add({ trackIds: [tracks[0].id] }); await engine.fill({ amount: 2, shuffled: false }); - await engine.clear({ manualOnly: true }); + await engine.clear({ keepManual: true }); return engine.future(); }); @@ -102,7 +102,7 @@ describe("components/engine/queue", () => { expect(items[0].manualEntry).toBe(true); }); - it("clears all items when manualOnly is false", async () => { + it("clears all items when keepManual is false", async () => { const count = await testWeb(async () => { const QueueEngine = await import("~/components/engine/queue/element.js"); const engine = new QueueEngine.CLASS(); @@ -112,7 +112,7 @@ describe("components/engine/queue", () => { const { tracks } = await import("~/testing/sample/tracks.js"); await engine.add({ trackIds: tracks.map((t) => t.id) }); - await engine.clear({ manualOnly: false }); + await engine.clear({ keepManual: false }); return (await engine.future()).length; }); -- 2.51.2