From f482ac718586ed1603648d6b061547845fac8f8f Mon Sep 17 00:00:00 2001 From: Steven Vandevelde Date: Fri, 9 Oct 2026 19:44:24 +0200 Subject: [PATCH] feat: albums.app theme --- src/_data/facets.json | 8 + src/common/facets/constants.js | 1 + .../themes/albums-app/browser/element.css | 995 ++++++++ .../themes/albums-app/browser/element.js | 2008 +++++++++++++++++ src/facets/themes/albums-app/facet/facet.css | 53 + src/facets/themes/albums-app/facet/facet.js | 61 + src/facets/themes/albums-app/facet/index.html | 24 + src/facets/themes/albums-app/fonts.css | 15 + src/facets/themes/albums-app/variables.css | 13 + 9 files changed, 3178 insertions(+) create mode 100644 src/facets/themes/albums-app/browser/element.css create mode 100644 src/facets/themes/albums-app/browser/element.js create mode 100644 src/facets/themes/albums-app/facet/facet.css create mode 100644 src/facets/themes/albums-app/facet/facet.js create mode 100644 src/facets/themes/albums-app/facet/index.html create mode 100644 src/facets/themes/albums-app/fonts.css create mode 100644 src/facets/themes/albums-app/variables.css diff --git a/src/_data/facets.json b/src/_data/facets.json index 952d46d8..ea47a7cb 100644 --- a/src/_data/facets.json +++ b/src/_data/facets.json @@ -1,4 +1,12 @@ [ + { + "url": "facets/themes/albums-app/facet/index.tile", + "title": "Albums.app", + "category": "Themes", + "featured": true, + "author": "A theme by [@tokono.ma](https://bsky.app/profile/tokono.ma)", + "desc": "A theme based on the iPad version of the [Albums](https://www.albumstheapp.com/) app." + }, { "url": "facets/playback/audio-output/index.tile", "title": "Audio Output", diff --git a/src/common/facets/constants.js b/src/common/facets/constants.js index 874cb8a0..c499c768 100644 --- a/src/common/facets/constants.js +++ b/src/common/facets/constants.js @@ -12,6 +12,7 @@ export const INTERACTIVE = [ "facets/data/sources/index.tile", "facets/data/file-manager/index.tile", "facets/data/userdata/index.tile", + "facets/themes/albums-app/facet/index.tile", "facets/themes/blur/artwork-controller/facet/index.tile", "facets/themes/blur/facet/index.tile", "facets/themes/winamp/facet/index.tile", diff --git a/src/facets/themes/albums-app/browser/element.css b/src/facets/themes/albums-app/browser/element.css new file mode 100644 index 00000000..6573b6e0 --- /dev/null +++ b/src/facets/themes/albums-app/browser/element.css @@ -0,0 +1,995 @@ +:host { + background: var(--da-window); + color: var(--da-text); + display: block; + font-family: + "Inter", + ui-sans-serif, + system-ui, + -apple-system, + "Segoe UI", + Roboto, + sans-serif; + font-feature-settings: "cv05", "ss01"; + font-size: 0.8125rem; + height: 100%; + letter-spacing: -0.006em; + line-height: 1.4; + overflow: hidden; +} + +/*********************************** + * Window + ***********************************/ + +.da-window { + background: var(--da-window); + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +/* Titlebar */ + +.da-titlebar { + align-items: center; + border-bottom: 1px solid var(--da-border); + display: flex; + flex-shrink: 0; + gap: 0.375rem; + height: 2.75rem; + padding: 0 0.75rem; +} + +.da-titlebar__btn { + align-items: center; + background: transparent; + border: 0; + border-radius: 0.375rem; + color: var(--da-text-dim); + cursor: pointer; + display: flex; + font-family: inherit; + font-size: 0.875rem; + height: 1.75rem; + justify-content: center; + transition: + background-color 120ms, + color 120ms; + width: 1.75rem; +} + +.da-titlebar__btn:hover { + background: var(--da-surface); + color: var(--da-text); +} + +.da-titlebar__btn--active { + color: var(--da-text); +} + +a.da-titlebar__btn { + text-decoration: none; +} + +.da-titlebar__divider { + background: var(--da-hairline); + height: 1.125rem; + margin: 0 0.375rem; + width: 1px; +} + +.da-titlebar__title { + color: var(--da-text); + font-size: 0.8125rem; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.da-titlebar__spacer { + flex: 1; +} + +.da-titlebar__group { + color: var(--da-text-faint); + font-size: 0.75rem; + font-weight: 600; + margin-right: 0.25rem; +} + +/* Shell */ + +.da-shell { + display: flex; + flex: 1; + min-height: 0; +} + +/*********************************** + * Rail (library sidebar) + ***********************************/ + +.da-rail { + border-right: 1px solid var(--da-border); + display: flex; + flex: 0 1 15rem; + flex-direction: column; + min-height: 0; + min-width: 8.5rem; + max-width: 15rem; +} + +.da-rail__scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0.75rem 0.625rem 0.5rem; +} + +.da-rail__search, +.da-catalog__search { + align-items: center; + background: var(--da-surface); + border-radius: 0.5rem; + color: var(--da-text-faint); + display: flex; + flex-shrink: 0; + gap: 0.5rem; + margin-bottom: 0.75rem; + padding: 0.375rem 0.625rem; +} + +.da-rail__search:focus-within, +.da-catalog__search:focus-within { + outline: 1px solid var(--da-border); +} + +.da-rail__search i, +.da-catalog__search i { + flex-shrink: 0; + font-size: 0.75rem; +} + +.da-rail__search input, +.da-catalog__search input { + background: transparent; + border: 0; + color: var(--da-text); + flex: 1; + font-family: inherit; + font-size: inherit; + min-width: 0; + outline: none; + padding: 0; +} + +.da-rail__search input::placeholder, +.da-catalog__search input::placeholder { + color: var(--da-text-faint); +} + +.da-rail__search input[type="search"]::-webkit-search-cancel-button, +.da-catalog__search input[type="search"]::-webkit-search-cancel-button { + display: none; +} + +.da-rail__label { + align-items: center; + color: var(--da-text-dim); + display: flex; + font-size: 0.8125rem; + font-weight: 600; + gap: 0.5rem; + padding: 0.25rem 0.5rem 0.375rem; +} + +.da-rail__item { + align-items: center; + background: transparent; + border: 0; + border-radius: 0.375rem; + color: var(--da-text); + cursor: pointer; + display: flex; + font-family: inherit; + font-size: inherit; + gap: 0.5rem; + padding: 0.3125rem 0.5rem; + text-align: left; + transition: background-color 100ms; + width: 100%; +} + +.da-rail__item i { + color: var(--da-text-dim); + flex-shrink: 0; + font-size: 0.875rem; +} + +.da-rail__item span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.da-rail__item:hover { + background: var(--da-surface); +} + +.da-rail__item--active, +.da-rail__item--active:hover { + background: var(--da-surface-strong); +} + +.da-rail__item--active i { + color: var(--da-text); +} + +/* Mini player */ + +.da-rail__player { + border-top: 1px solid var(--da-border); + flex-shrink: 0; + padding: 0.625rem 0.75rem 0.75rem; +} + +.da-mini__head { + align-items: center; + display: flex; + gap: 0.5rem; + min-width: 0; +} + +.da-mini__art { + border-radius: 0.375rem; + flex-shrink: 0; + height: 2.25rem; + overflow: hidden; + width: 2.25rem; +} + +.da-mini__art img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.da-mini__art-placeholder { + align-items: center; + background: var(--da-surface); + color: var(--da-text-faint); + display: flex; + font-size: 0.875rem; + height: 100%; + justify-content: center; + width: 100%; +} + +.da-mini__meta { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.da-mini__title { + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.da-mini__artist { + color: var(--da-text-dim); + font-size: 0.65rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.da-mini__album { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.da-mini__progress { + cursor: pointer; + margin-top: 0.5rem; +} + +.da-mini__progress progress { + appearance: none; + background: rgb(255 255 255 / 0.28); + border: 0; + border-radius: 2px; + display: block; + height: 3px; + overflow: hidden; + width: 100%; +} + +.da-mini__progress progress[value]::-webkit-progress-value { + background: var(--da-text); +} + +.da-mini__progress progress[value]::-moz-progress-bar { + background: var(--da-text); +} + +.da-mini__timestamps { + color: var(--da-text-faint); + display: flex; + font-size: 0.55rem; + font-weight: 600; + justify-content: space-between; + margin-top: 0.35rem; +} + +.da-mini__controls { + align-items: center; + display: flex; + justify-content: space-between; + margin-top: 0.25rem; + padding: 0 0.25rem; +} + +.da-mini__controls button { + background: transparent; + border: 0; + color: var(--da-text); + cursor: pointer; + font-size: 1rem; + padding: 0.25rem; +} + +.da-mini__play { + align-items: center; + background: var(--da-text); + border-radius: 50%; + color: var(--da-window); + display: flex; + height: 2rem; + justify-content: center; + width: 2rem; +} + +.da-mini__play i { + font-size: 0.875rem; +} + +/*********************************** + * Catalog (playlists column) + ***********************************/ + +.da-catalog { + border-right: 1px solid var(--da-border); + display: flex; + flex: 0 1 16.2rem; + flex-direction: column; + min-height: 0; + min-width: 9.5rem; + max-width: 16.2rem; + position: relative; + transition: + flex-basis 200ms, + opacity 200ms; +} + +.da-catalog--collapsed { + border-right: 0; + flex-basis: 0; + min-width: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; +} + +.da-catalog__scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0.75rem 0.625rem 0.5rem; +} + +.da-catalog__label { + color: var(--da-text-dim); + font-size: 0.8125rem; + font-weight: 600; + padding: 0 0.5rem 0.375rem; +} + +.da-playlist-section__letter { + color: var(--da-text-faint); + font-size: 0.6875rem; + font-weight: 700; + padding: 0.625rem 0.5rem 0.25rem; +} + +.da-playlist-row { + align-items: center; + background: transparent; + border: 0; + border-bottom: 1px solid var(--da-hairline); + border-radius: 0; + color: var(--da-text); + cursor: pointer; + display: flex; + font-family: inherit; + font-size: inherit; + gap: 0.5rem; + padding: 0.4375rem 0.5rem; + text-align: left; + transition: background-color 100ms; + width: 100%; +} + +.da-playlist-row:hover { + background: var(--da-surface); +} + +.da-playlist-row--active { + background: var(--da-surface-strong); +} + +.da-playlist-thumb { + border-radius: 0.25rem; + flex-shrink: 0; + height: 1.75rem; + overflow: hidden; + width: 1.75rem; +} + +.da-playlist-thumb img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.da-playlist-thumb__placeholder { + align-items: center; + background: var(--da-surface-strong); + color: var(--da-text-faint); + display: flex; + font-size: 0.75rem; + height: 100%; + justify-content: center; + width: 100%; +} + +.da-playlist-row span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.da-catalog__empty { + color: var(--da-text-faint); + padding: 1rem 0.5rem; + text-align: center; +} + +/* Alphabet index */ + +.da-catalog__index { + align-items: center; + bottom: 0.5rem; + display: flex; + flex-direction: column; + gap: 1px; + pointer-events: none; + position: absolute; + right: 0.125rem; + top: 3.5rem; +} + +.da-catalog__index-letter { + background: transparent; + border: 0; + color: var(--da-text-faint); + cursor: pointer; + font-family: inherit; + font-size: 0.5625rem; + font-weight: 600; + line-height: 1; + padding: 1px 3px; + pointer-events: auto; +} + +.da-catalog__index-letter:hover { + color: var(--da-accent); +} + +/*********************************** + * Main pane + ***********************************/ + +.da-main { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +/* Header */ + +.da-header { + border-bottom: 1px solid var(--da-hairline); + flex-shrink: 0; + padding: 1.25rem 1.5rem 1rem; +} + +.da-header__row { + align-items: center; + display: flex; + gap: 0.625rem; + min-width: 0; +} + +.da-header__title { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.2; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.da-header__badge { + background: var(--da-accent); + border-radius: 999px; + color: #fff; + flex-shrink: 0; + font-size: 0.625rem; + font-weight: 600; + padding: 0.3rem 0.4375rem; + text-box: trim-both cap alphabetic; +} + +.da-header__subtitle { + color: var(--da-text-dim); + font-size: 0.75rem; + margin-top: 0.25rem; +} + +.da-header__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; + margin-top: 0.875rem; +} + +.da-pill { + align-items: center; + background: var(--da-surface); + border: 0; + border-radius: 999px; + color: var(--da-text); + cursor: pointer; + display: flex; + font-family: inherit; + font-size: 0.8125rem; + font-weight: 500; + gap: 0.4375rem; + padding: 0.5rem 1rem; + text-box: trim-both cap alphabetic; + transition: background-color 120ms; +} + +.da-pill i { + color: var(--da-text-dim); + font-size: 0.875rem; +} + +.da-pill:hover { + background: var(--da-surface-strong); +} + +.da-pill--active { + background: var(--da-surface-strong); + color: var(--da-accent); +} + +.da-pill--active i { + color: var(--da-accent); +} + +/* Cover grid */ + +.da-covers-panel { + flex: 1; + min-height: 0; + overflow-anchor: none; + overflow-y: auto; + padding: 1rem 1.5rem 1.5rem; +} + +.da-covers-label { + color: var(--da-text); + font-size: 1rem; + font-weight: 700; + letter-spacing: -0.01em; + padding-bottom: 0.75rem; +} + +.da-covers-virtual { + position: relative; +} + +.da-cover-row { + display: grid; + gap: 0 1rem; + left: 0; + position: absolute; + right: 0; +} + +.da-cover-group { + color: var(--da-text-faint); + font-size: 0.75rem; + font-weight: 600; + left: 0; + line-height: 1.4; + padding: 0.75rem 0 0.5rem; + position: absolute; + right: 0; +} + +.da-cover-card { + cursor: pointer; + min-width: 0; +} + +.da-cover-art { + aspect-ratio: 1; + background: var(--da-surface); + border-radius: 0.5rem; + box-shadow: 0 4px 12px rgb(0 0 0 / 0.35); + overflow: hidden; + transition: transform 150ms ease; +} + +.da-cover-card:hover .da-cover-art { + transform: scale(1.02); +} + +.da-cover-art img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.da-cover-placeholder, +.da-track-art-placeholder { + align-items: center; + background: var(--da-surface-strong); + color: var(--da-text-faint); + display: flex; + font-size: 1.5rem; + height: 100%; + justify-content: center; + width: 100%; +} + +.da-cover-info { + display: flex; + flex-direction: column; + gap: 0.0625rem; + margin-top: 0.5rem; + min-width: 0; +} + +.da-cover-info span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.da-cover-album { + font-size: 0.7rem; + font-weight: 600; +} + +.da-cover-artist { + color: var(--da-text-dim); + font-size: 0.6rem; +} + +.da-cover-time { + color: var(--da-text-faint); + font-size: 0.55rem; +} + +/* Empty state */ + +.da-empty { + align-items: center; + color: var(--da-text-faint); + display: flex; + flex-direction: column; + font-size: 0.875rem; + gap: 0.5rem; + height: 100%; + justify-content: center; + padding: 2rem; + text-align: center; +} + +.da-empty i { + font-size: 2rem; +} + +.da-empty p { + margin: 0; +} + +/* Track list */ + +.da-tracks-list { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; +} + +.da-tracks-header, +.da-track-row { + align-items: center; + display: grid; + gap: 0.75rem; + grid-template-columns: + minmax(0, 2.5fr) minmax(0, 1.5fr) minmax(0, 1.5fr) 3rem + 3.5rem; + padding: 0 1.5rem; +} + +.da-tracks-header { + border-bottom: 1px solid var(--da-hairline); + color: var(--da-text-faint); + flex-shrink: 0; + font-size: 0.75rem; + height: 2rem; +} + +.da-tracks-header button { + align-items: center; + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + display: flex; + font-family: inherit; + font-size: inherit; + gap: 0.25rem; + overflow: hidden; + padding: 0; + text-align: left; + transition: color 100ms; +} + +.da-tracks-header button:hover, +.da-tracks-header button.da-track-header--active { + color: var(--da-text); +} + +.da-tracks-header button i { + flex-shrink: 0; + font-size: 0.625rem; +} + +.da-track-header__title, +.da-track-header__artist, +.da-track-header__album { + min-width: 0; +} + +.da-track-header__time { + justify-self: end; +} + +.da-tracks-panel { + flex: 1; + min-height: 0; + overflow-anchor: none; + overflow-y: auto; +} + +.da-tracks-virtual { + position: relative; +} + +.da-track-row { + border-bottom: 1px solid var(--da-hairline); + height: 3rem; + left: 0; + position: absolute; + right: 0; + top: 0; + transition: background-color 100ms; +} + +.da-track-row:hover { + background: var(--da-surface); +} + +.da-track-row--current { + background: var(--da-surface); +} + +.da-track-row--current .da-track__title-text { + color: var(--da-accent); +} + +.da-track__title { + align-items: center; + display: flex; + gap: 0.5rem; + min-width: 0; +} + +.da-track__art { + border-radius: 0.25rem; + flex-shrink: 0; + height: 2rem; + overflow: hidden; + width: 2rem; +} + +.da-track__art img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.da-track__art .da-track-art-placeholder { + font-size: 0.75rem; +} + +.da-track__title-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.da-track__playing { + color: var(--da-accent); + flex-shrink: 0; + font-size: 0.75rem; +} + +.da-track__artist, +.da-track__album { + color: var(--da-text-dim); + overflow: hidden; +} + +.da-track__artist span, +.da-track__album span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.da-track__time { + color: var(--da-text-faint); + font-size: 0.75rem; + text-align: right; +} + +.da-track__actions { + display: flex; + gap: 0.125rem; + justify-content: flex-end; +} + +.da-track__action { + align-items: center; + background: transparent; + border: 0; + border-radius: 0.25rem; + color: var(--da-text-dim); + cursor: pointer; + display: flex; + font-size: 0.875rem; + height: 1.75rem; + justify-content: center; + opacity: 0; + transition: + background-color 100ms, + color 100ms; + width: 1.75rem; +} + +.da-track-row:hover .da-track__action { + opacity: 1; +} + +.da-track__action:hover { + background: var(--da-surface-strong); + color: var(--da-text); +} + +.da-track__action--active { + color: var(--da-accent); + opacity: 1; +} + +/*********************************** + * Scrollbars + ***********************************/ + +.da-rail__scroll, +.da-catalog__scroll, +.da-covers-panel, +.da-tracks-panel { + scrollbar-color: var(--da-surface-strong) transparent; + scrollbar-width: thin; +} + +.da-rail__scroll::-webkit-scrollbar, +.da-catalog__scroll::-webkit-scrollbar, +.da-covers-panel::-webkit-scrollbar, +.da-tracks-panel::-webkit-scrollbar { + height: 8px; + width: 8px; +} + +.da-rail__scroll::-webkit-scrollbar-thumb, +.da-catalog__scroll::-webkit-scrollbar-thumb, +.da-covers-panel::-webkit-scrollbar-thumb, +.da-tracks-panel::-webkit-scrollbar-thumb { + background: var(--da-surface-strong); + border-radius: 4px; +} + +/*********************************** + * Responsive + ***********************************/ + +@media (max-width: 48rem) { + .da-rail { + flex-basis: 11rem; + } + + /* Keep only the mini player on small screens */ + .da-rail__scroll { + display: none; + } + + .da-rail__player { + border-top: 0; + } + + .da-header { + padding: 1rem 1.25rem 0.875rem; + } + + .da-covers-panel { + padding: 1rem 1.25rem 1.25rem; + } + + .da-tracks-header, + .da-track-row { + grid-template-columns: minmax(0, 1fr) 3rem 3.5rem; + padding: 0 1.25rem; + } + + .da-track__artist, + .da-track__album, + .da-track-header__artist, + .da-track-header__album { + display: none; + } +} diff --git a/src/facets/themes/albums-app/browser/element.js b/src/facets/themes/albums-app/browser/element.js new file mode 100644 index 00000000..e19fccee --- /dev/null +++ b/src/facets/themes/albums-app/browser/element.js @@ -0,0 +1,2008 @@ +import { + defineElement, + DiffuseElement, + nothing, + 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" + */ + +const TRACK_ROW_HEIGHT = 48; +const TRACK_ROW_STRIDE = 48; +const OVERSCAN = 10; +const MAX_ART_CONCURRENT = 6; + +// Cover grid virtualization: cards are chunked into fixed-column rows, +// which are windowed like track rows. Column count and row height are +// measured from the rendered panel. +const GRID_MIN_CARD = 140; // 8.75rem, matches the CSS card minimum +const GRID_GAP_X = 16; // 1rem column gap +const GRID_ROW_GAP = 20; // 1.25rem gap between grid rows +const GRID_OVERSCAN_ROWS = 2; +const GRID_GROUP_HEIGHT = 37; // letter header, corrected after measure + +/** + * @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; +} + +/** + * @param {number | undefined} ms + * @returns {string} + */ +function formatDuration(ms) { + if (!ms || ms < 0) return "–:––"; + const totalSec = Math.round(ms / 1000); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; + return `${m}:${String(s).padStart(2, "0")}`; +} + +/** + * @param {number | undefined} seconds + * @returns {string} + */ +function formatClock(seconds) { + if (seconds === undefined || Number.isNaN(seconds)) return "0:00"; + const totalSec = Math.max(0, Math.round(seconds)); + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return `${m}:${String(s).padStart(2, "0")}`; +} + +/** + * Groups sorted rows by the first letter of their label. Rows without a key + * (the "Unknown album/artist" pseudo-entries for untagged tracks) sort before + * everything else, but belong under ♯ together with digit/symbol-named rows. + * @template {{ key: string; label: string }} R + * @param {R[]} rows + * @returns {{ letter: string; rows: R[] }[]} + */ +function groupByLetter(rows) { + /** @type {Map} */ + const groups = new Map(); + + for (const row of rows) { + const letter = row.key ? row.label.charAt(0).toUpperCase() : "#"; + const key = /[A-Z]/.test(letter) ? letter : "#"; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)?.push(row); + } + + return [...groups.entries()].map(([letter, rows]) => ({ + letter, + rows, + })); +} + +/** + * @typedef {{ type: "album"; albumKey: string; albumName: string; artist: string; track: Track }} AlbumItem + * @typedef {{ type: "artist"; artistKey: string; artistName: string; trackCount: number; track: Track }} ArtistItem + * @typedef {{ type: "albums" } | { type: "artists" } | { type: "tracks" } | { type: "playlist-tracks" } | AlbumItem | ArtistItem } View + */ + +const LIBRARY_ITEMS = [ + { type: "albums", label: "Albums", icon: "ph-vinyl-record" }, + { type: "artists", label: "Artists", icon: "ph-microphone-stage" }, + { type: "playlists", label: "Playlists", icon: "ph-playlist" }, + { type: "tracks", label: "Songs", icon: "ph-music-notes" }, +]; + +/** Which library item a view belongs to (drives rail + catalog state). */ +const RAIL_FOR_VIEW = /** @type {const} */ ({ + albums: "albums", + album: "albums", + artists: "artists", + artist: "artists", + "playlist-tracks": "playlists", + tracks: "tracks", +}); + +/** Sort fields per list column. */ +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"], +}; + +class Browser extends DiffuseElement { + static observedAttributes = ["group", "group-label"]; + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + } + + // SIGNALS - dependencies + + $artwork = signal( + /** @type {ArtworkElement | undefined} */ (undefined), + ); + + $coverGroups = signal( + /** @type {import("~/components/orchestrator/cover-groups/element.js").CLASS | undefined} */ (undefined), + ); + + $controller = signal( + /** @type {import("~/components/orchestrator/controller/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), + ); + + // SIGNALS - state + + #view = signal(/** @type {View} */ ({ type: "albums" })); + + #catalogCollapsed = signal(false); + + #playlistFilter = signal(""); + + #history = signal(/** @type {View[]} */ ([])); + #future = signal(/** @type {View[]} */ ([])); + + // Cover art cache (albums, artists and playlist thumbnails share it) + /** @type {Map} */ + #coverArtCache = new Map(); + /** @type {Set} */ + #pendingArtFetch = new Set(); + /** @type {{ key: string; track: Track }[]} */ + #artFetchQueue = []; + #artFetchActive = 0; + #artRenderScheduled = false; + /** @type {IntersectionObserver | undefined} */ + #rowArtObserver = undefined; + + // Cover grid virtual scroll state + #gridScrollTop = 0; + #gridViewportHeight = window.innerHeight; + #gridLabelHeight = 0; + #gridCols = 4; + #gridStride = 240; + #gridGroupHeight = GRID_GROUP_HEIGHT; + #gridLayoutDirty = false; + /** @type {{ label: string; groups: any[] }[] | null} */ + #gridSource = null; + #gridMode = ""; + /** @type {({ type: "group"; label: string } | { type: "row"; items: any[] })[]} */ + #gridItems = []; + /** @type {number[]} */ + #gridOffsets = []; + #renderedGridStart = -1; + #renderedGridEnd = -1; + + // Track list virtual scroll state + #scrollTop = 0; + #viewportHeight = 0; + #renderedStartIndex = -1; + #renderedEndIndex = -1; + #itemCount = 0; + #rowHeight = TRACK_ROW_HEIGHT; + #rowStride = TRACK_ROW_STRIDE; + /** @type {ResizeObserver | undefined} */ + #resizeObserver; + /** @type {HTMLElement | null} */ + #observedPanel = null; + /** @type {AbortController | undefined} */ + #scrollAbort; + /** @type {Track[] | undefined} */ + #renderedTracks = undefined; + + // COMPUTED + + $currentTracks = computed(() => { + const view = this.#view.value; + if (view.type === "album") { + return this.$tracksByAlbum().get(view.albumKey) ?? []; + } + if (view.type === "artist") { + return this.$tracksByArtist().get(view.artistKey) ?? []; + } + return this.$provider.value?.tracks() ?? []; + }); + + $tracksByAlbum = computed(() => { + /** @type {Map} */ + const map = new Map(); + for (const t of this.$provider.value?.tracks() ?? []) { + 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() ?? []) { + const key = String(t.tags?.artist ?? "").toLowerCase(); + if (!map.has(key)) map.set(key, []); + map.get(key)?.push(t); + } + return map; + }); + + $albumDurations = computed(() => { + /** @type {Map} */ + const map = new Map(); + for (const t of this.$provider.value?.tracks() ?? []) { + const key = String(t.tags?.album ?? "").toLowerCase(); + const entry = map.get(key) ?? { ms: 0, count: 0 }; + entry.ms += t.stats?.duration ?? 0; + entry.count++; + map.set(key, entry); + } + return map; + }); + + $sortedCoverGroups = computed(() => { + const groups = this.$coverGroups.value?.coverGroups() ?? []; + return this.#sortGroups(groups); + }); + + $sortedArtistGroups = computed(() => { + const groups = this.$coverGroups.value?.artistGroups() ?? []; + return this.#sortGroups(groups); + }); + + /** Flattened album rows for the catalog column. */ + $albumRows = computed(() => + this.$sortedCoverGroups().flatMap((g) => g.groups)); + + /** Flattened artist rows for the catalog column. */ + $artistRows = computed(() => + this.$sortedArtistGroups().flatMap((g) => g.groups)); + + $favouritesSet = computed(() => { + const items = this.$favourites.value?.playlistItems() ?? []; + return new Set( + items.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() + }`; + }), + ); + }); + + // 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 {import("~/components/orchestrator/controller/element.js").CLASS | null} */ + const controller = queryOptional( + this, + "controller-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 | null} */ + const favourites = queryOptional( + this, + "favourites-orchestrator-selector", + ); + + whenElementsDefined({ output, provider, queue, scope }).then(() => { + batch(() => { + this.$output.value = output; + this.$provider.value = provider; + this.$queue.value = queue; + this.$scope.value = scope; + }); + }); + + if (favourites) { + whenElementsDefined({ favourites }).then(() => { + this.$favourites.value = favourites; + }); + } + + if (artwork) { + whenElementsDefined({ artwork }).then(() => { + this.$artwork.value = artwork; + }); + } + + if (coverGroups) { + whenElementsDefined({ coverGroups }).then(() => { + this.$coverGroups.value = coverGroups; + }); + } + + if (controller) { + whenElementsDefined({ controller }).then(() => { + this.$controller.value = controller; + }); + } + + // Reset scroll when the track list changes + this.effect(() => { + const _ = this.$currentTracks(); + untracked(() => { + const panel = this.root().querySelector(".da-tracks-panel"); + if (panel) { + panel.scrollTo(0, 0); + this.#scrollTop = 0; + } + }); + }); + + // Observe catalog rows for lazy thumbnail loading + this.effect(() => { + const _albums = this.$albumRows(); + const _artists = this.$artistRows(); + const _filter = this.#playlistFilter.value; + const _view = this.#view.value; + const col = this.$output.value?.playlistItems.collection(); + const _playlists = col?.state === "loaded" ? col.data.length : undefined; + + untracked(() => { + requestAnimationFrame(() => this.#setupRowArtObserver()); + }); + }); + + this.#setupScrollListener(); + } + + /** + * @override + */ + disconnectedCallback() { + super.disconnectedCallback(); + this.#scrollAbort?.abort(); + this.#scrollAbort = undefined; + this.#resizeObserver?.disconnect(); + this.#resizeObserver = undefined; + this.#observedPanel = null; + this.#disconnectRowArtObserver(); + } + + // HELPERS + + /** + * @template {{ label: string; groups: { track: Track }[] }} G + * @param {G[]} groups + * @returns {G[]} + */ + #sortGroups(groups) { + const dir = this.$scope.value?.sortDirection() ?? "asc"; + if (dir !== "desc") return groups; + return groups.map((g) => ({ ...g, groups: [...g.groups].reverse() })); + } + + /** + * All tracks covered by the current view — used by the shuffle action. + * @returns {Track[]} + */ + #currentViewTracks() { + const view = this.#view.value; + if (view.type === "album") { + return this.$tracksByAlbum().get(view.albumKey) ?? []; + } + if (view.type === "artist") { + return this.$tracksByArtist().get(view.artistKey) ?? []; + } + return this.$provider.value?.tracks() ?? []; + } + + /** + * @returns {boolean} + */ + #isCollectionLoading() { + const col = this.$output.value?.tracks.collection(); + return !col || col.state !== "loaded"; + } + + // ACTIONS + + /** + * @param {View} view + */ + #navigateTo(view) { + this.#disconnectRowArtObserver(); + this.#history.value = [...this.#history.value, this.#view.value]; + this.#future.value = []; + this.#view.value = view; + this.#renderedTracks = undefined; + } + + goBack = () => { + const hist = this.#history.value; + if (hist.length === 0) return; + const prev = hist[hist.length - 1]; + if (!prev) return; + this.#disconnectRowArtObserver(); + this.#future.value = [this.#view.value, ...this.#future.value]; + this.#history.value = hist.slice(0, -1); + this.#view.value = prev; + this.#renderedTracks = undefined; + }; + + goForward = () => { + const fut = this.#future.value; + if (fut.length === 0) return; + const next = fut[0]; + if (!next) return; + this.#disconnectRowArtObserver(); + this.#history.value = [...this.#history.value, this.#view.value]; + this.#future.value = fut.slice(1); + this.#view.value = next; + this.#renderedTracks = undefined; + }; + + /** + * @param {string | undefined} playlist + */ + setSelectedPlaylist = (playlist) => { + this.$scope.value?.setPlaylist(playlist); + this.#view.value = { type: "playlist-tracks" }; + this.#renderedTracks = undefined; + }; + + /** + * @param {"albums" | "artists" | "playlists" | "tracks"} type + */ + #browse(type) { + const current = this.#view.value; + if (RAIL_FOR_VIEW[current.type] === type) return; + + // Library navigation means the whole collection, so lift any + // playlist filter that was active. + this.$scope.value?.setPlaylist(undefined); + this.#playlistFilter.value = ""; + + // The covers panel is shared between the albums/artists views — + // start from the top for the new section + const panel = /** @type {HTMLElement | null} */ ( + this.root().querySelector(".da-covers-panel") + ); + panel?.scrollTo(0, 0); + this.#gridScrollTop = 0; + + if (type === "albums") this.#navigateTo({ type: "albums" }); + else if (type === "artists") this.#navigateTo({ type: "artists" }); + else if (type === "playlists") { + this.#navigateTo({ type: "playlist-tracks" }); + } else this.#navigateTo({ type: "tracks" }); + } + + browseAlbums = () => this.#browse("albums"); + + browseArtists = () => this.#browse("artists"); + + browsePlaylists = () => this.#browse("playlists"); + + browseTracks = () => this.#browse("tracks"); + + toggleCatalog = () => { + this.#catalogCollapsed.value = !this.#catalogCollapsed.value; + }; + + /** + * @param {AlbumItem} item + */ + openAlbum = (item) => { + this.#navigateTo(item); + }; + + /** + * @param {ArtistItem} item + */ + openArtist = (item) => { + this.#navigateTo(item); + }; + + /** + * @param {Track} track + */ + playTrack = (track) => { + this.$queue.value?.add({ inFront: true, trackIds: [track.id] }); + this.$queue.value?.shift(); + }; + + /** + * @param {Track} track + */ + addToQueue = (track) => { + this.$queue.value?.add({ trackIds: [track.id] }); + }; + + /** + * @param {Track} track + */ + toggleFavourite = (track) => { + this.$favourites.value?.toggle(track); + }; + + /** + * Appends every track of the current view to the end of the queue. + */ + addViewToQueue = () => { + const tracks = this.#currentViewTracks(); + if (!tracks.length) return; + this.$queue.value?.add({ trackIds: tracks.map((t) => t.id) }); + }; + + /** + * Sort the track list by a column. Clicking the active column toggles the + * direction; clicking it again when descending reverts to the default sort. + * @param {"title" | "artist" | "album"} 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); + } + }; + + setSearchTerm = () => { + /** @type {HTMLInputElement | null} */ + const input = this.root().querySelector("#da-search-input"); + const term = input?.value?.trim(); + this.$scope.value?.setSearchTerm(term || undefined); + }; + + clearSearch = () => { + /** @type {HTMLInputElement | null} */ + const input = this.root().querySelector("#da-search-input"); + if (input) input.value = ""; + this.$scope.value?.setSearchTerm(undefined); + }; + + setPlaylistFilter = () => { + /** @type {HTMLInputElement | null} */ + const input = this.root().querySelector("#da-playlist-filter"); + this.#playlistFilter.value = input?.value ?? ""; + }; + + /** + * @param {string} letter + */ + jumpToLetter = (letter) => { + const panel = this.root().querySelector(".da-catalog__scroll"); + const section = this.root().querySelector( + `.da-playlist-section[data-letter="${letter}"]`, + ); + if (!panel || !section) return; + panel.scrollTo({ + top: /** @type {HTMLElement} */ (section).offsetTop, + behavior: "smooth", + }); + }; + + // MINI PLAYER + + currentTrack = () => this.$controller.value?.currentTrack(); + + isPlaying = () => this.$controller.value?.isPlaying() ?? false; + + playPause = () => { + const audioId = this.$controller.value?.$queue.value?.now()?.id; + if (this.isPlaying() && audioId) { + this.$controller.value?.$audio.value?.pause({ audioId }); + } else if (audioId) { + this.$controller.value?.$audio.value?.play({ audioId }); + } + }; + + next = () => { + this.$controller.value?.$queue.value?.shift(); + }; + + previous = () => { + this.$controller.value?.$queue.value?.unshift(); + }; + + /** + * @param {MouseEvent} event + */ + seek = (event) => { + const target = event.target + ? /** @type {HTMLProgressElement} */ (event.target) + : null; + const percentage = target ? event.offsetX / target.clientWidth : 0; + const audioId = this.$controller.value?.$queue.value?.now()?.id; + + if (audioId) { + this.$controller.value?.$audio.value?.seek({ audioId, percentage }); + } + }; + + // ARTWORK CACHE + + /** + * @param {string} key + * @param {Track} track + */ + #fetchAlbumArt(key, track) { + if (this.#coverArtCache.has(key)) return; + if (this.#pendingArtFetch.has(key)) return; + this.#pendingArtFetch.add(key); + this.#coverArtCache.set(key, undefined); + this.#artFetchQueue.push({ key, 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.key, job.track); + } + } + + /** + * @param {string} key + * @param {Track} track + */ + async #doFetchAlbumArt(key, 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(key, url); + } else { + this.#coverArtCache.set(key, null); + } + } catch { + // don't cache on error — let it be retried + this.#coverArtCache.delete(key); + } finally { + this.#pendingArtFetch.delete(key); + this.#artFetchActive--; + this.#drainArtQueue(); + } + this.#scheduleArtRender(); + } + + #scheduleArtRender() { + if (this.#artRenderScheduled) return; + this.#artRenderScheduled = true; + requestAnimationFrame(() => { + this.#artRenderScheduled = false; + this.forceRender(); + }); + } + + // COVER GRID VIRTUAL SCROLL + + /** + * Chunks the grouped cover entries into virtual rows of `#gridCols` + * cards and recomputes the cumulative height offsets. + * @param {{ label: string; groups: any[] }[]} groups + */ + #rebuildGridItems(groups) { + const cols = this.#gridCols; + + /** @type {({ type: "group"; label: string } | { type: "row"; items: any[] })[]} */ + const items = []; + + for (const { label, groups: entries } of groups) { + if (label) items.push({ type: "group", label }); + for (let i = 0; i < entries.length; i += cols) { + items.push({ type: "row", items: entries.slice(i, i + cols) }); + } + } + + this.#gridItems = items; + + const offsets = new Array(items.length + 1); + offsets[0] = 0; + let acc = 0; + for (let i = 0; i < items.length; i++) { + acc += items[i].type === "group" ? this.#gridGroupHeight : this.#gridStride; + offsets[i + 1] = acc; + } + this.#gridOffsets = offsets; + } + + /** + * Visible window of grid items for the current scroll position. + * Pure math — no DOM reads, so it's safe to call from the scroll + * handler. The `.da-covers-label` heading scrolls with the content, + * so its (cached) height offsets the virtual container. + * @returns {{ startIndex: number; endIndex: number }} + */ + #computeGridRange() { + const virtualTop = this.#gridScrollTop - this.#gridLabelHeight; + const over = GRID_OVERSCAN_ROWS * this.#gridStride; + + const items = this.#gridItems; + const offsets = this.#gridOffsets; + const top = virtualTop - over; + const bottom = virtualTop + this.#gridViewportHeight + over; + + let startIndex = 0; + while (startIndex < items.length && offsets[startIndex + 1] <= top) { + startIndex++; + } + + let endIndex = startIndex; + while (endIndex < items.length && offsets[endIndex] < bottom) { + endIndex++; + } + + return { startIndex, endIndex }; + } + + #renderIfGridWindowChanged() { + if (!this.#gridItems.length) return; + + const { startIndex, endIndex } = this.#computeGridRange(); + if ( + startIndex === this.#renderedGridStart && + endIndex === this.#renderedGridEnd + ) return; + + this.forceRender(); + } + + /** + * Measures the panel to derive the grid column count, and corrects the + * row/group heights against the rendered DOM. + */ + #measureGrid() { + const panel = this.root().querySelector(".da-covers-panel"); + if (!panel) return; + + let dirty = false; + + const label = /** @type {HTMLElement | null} */ ( + this.root().querySelector(".da-covers-label") + ); + if (label) { + const height = label.offsetHeight; + if (height && height !== this.#gridLabelHeight) { + this.#gridLabelHeight = height; + dirty = true; + } + } + + const styles = getComputedStyle(panel); + const avail = panel.clientWidth - + parseFloat(styles.paddingLeft) - parseFloat(styles.paddingRight); + const cols = Math.max( + 2, + Math.floor((avail + GRID_GAP_X) / (GRID_MIN_CARD + GRID_GAP_X)), + ); + if (cols !== this.#gridCols) { + this.#gridCols = cols; + dirty = true; + } + + const row = /** @type {HTMLElement | null} */ ( + this.root().querySelector(".da-cover-row") + ); + if (row) { + const stride = row.offsetHeight + GRID_ROW_GAP; + if (stride > GRID_ROW_GAP && stride !== this.#gridStride) { + this.#gridStride = stride; + dirty = true; + } + } + + const group = /** @type {HTMLElement | null} */ ( + this.root().querySelector(".da-cover-group") + ); + if (group) { + const height = group.offsetHeight; + if (height && height !== this.#gridGroupHeight) { + this.#gridGroupHeight = height; + dirty = true; + } + } + + if (dirty) { + this.#gridLayoutDirty = true; + this.forceRender(); + } + } + + /** + * Lazily fetch the thumbnail for a catalog row (playlist, album or artist). + * The row resolves its representative track off the render path. + * @param {string} key Art cache key + * @param {() => Track | undefined} resolveTrack + */ + #ensureRowArt(key, resolveTrack) { + if (this.#coverArtCache.has(key)) return; + if (this.#pendingArtFetch.has(key)) return; + + // Resolve off the render path — filtering can be expensive. + queueMicrotask(() => { + if (this.#coverArtCache.has(key)) return; + const track = resolveTrack(); + if (!track) { + this.#coverArtCache.set(key, null); + this.#scheduleArtRender(); + return; + } + this.#fetchAlbumArt(key, track); + }); + } + + #setupRowArtObserver() { + const root = this.root().querySelector(".da-catalog__scroll"); + if (!root) return; + + this.#rowArtObserver?.disconnect(); + + this.#rowArtObserver = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const el = /** @type {HTMLElement} */ (entry.target); + const key = el.dataset.artKey; + const trackId = el.dataset.artTrackId; + if (key) { + this.#ensureRowArt(key, () => + trackId + ? this.$provider.value?.tracks().find((t) => t.id === trackId) + : this.#resolvePlaylistTrack(el.dataset.playlist ?? "")); + this.#rowArtObserver?.unobserve(entry.target); + } + } + }, + { root, rootMargin: "100px" }, + ); + + for ( + const row of this.root().querySelectorAll("[data-art-key]") + ) { + this.#rowArtObserver.observe(row); + } + } + + /** + * First track matching a playlist, used as its thumbnail. + * @param {string} name + * @returns {Track | undefined} + */ + #resolvePlaylistTrack(name) { + const col = this.$output.value?.playlistItems.collection(); + const tracks = this.$provider.value?.tracks() ?? []; + const items = col?.state === "loaded" + ? col.data.filter((i) => i.playlist === name) + : []; + + if (!items.length) return undefined; + return Playlist.filterByPlaylist(tracks, items)[0]; + } + + #disconnectRowArtObserver() { + this.#rowArtObserver?.disconnect(); + this.#rowArtObserver = undefined; + } + + // SCROLL TRACKING + + /** + * Listen for scrolls of either scroll panel on the shadow root, in the + * capture phase (scroll doesn't bubble, but capture reaches + * descendants). The shadow root is stable across renders, so the + * listener survives panels being replaced. + */ + #setupScrollListener() { + if (this.#scrollAbort) return; + + this.#scrollAbort = new AbortController(); + + this.root().addEventListener( + "scroll", + (event) => { + const panel = event.target; + if (!(panel instanceof HTMLElement)) return; + + if (panel.classList.contains("da-covers-panel")) { + this.#gridScrollTop = panel.scrollTop; + this.#renderIfGridWindowChanged(); + } else if (panel.classList.contains("da-tracks-panel")) { + this.#scrollTop = panel.scrollTop; + this.#renderIfWindowChanged(); + } + }, + { capture: true, passive: true, signal: this.#scrollAbort.signal }, + ); + } + + /** + * Keep the resize observer pointed at the current scroll panel (it can + * be replaced when switching views) and pick up restored scroll + * positions. + */ + #syncViewportObserver() { + const panel = /** @type {HTMLElement | null} */ ( + this.root().querySelector(".da-tracks-panel") ?? + this.root().querySelector(".da-covers-panel") + ); + + if (!panel || panel === this.#observedPanel) return; + this.#observedPanel = panel; + + const isGrid = panel.classList.contains("da-covers-panel"); + + // The browser may have restored a scroll position + if (isGrid) { + this.#gridScrollTop = panel.scrollTop; + } else { + this.#scrollTop = panel.scrollTop; + } + + this.#resizeObserver?.disconnect(); + this.#resizeObserver = new ResizeObserver(() => { + // Defer past transient layouts — a panel mid-update can report 0 + requestAnimationFrame(() => { + const height = Math.min(panel.clientHeight, window.innerHeight); + if (height <= 0) return; + + if (isGrid) { + if (this.#gridViewportHeight === height) return; + this.#gridViewportHeight = height; + this.#renderIfGridWindowChanged(); + } else { + if (this.#viewportHeight === height) return; + this.#viewportHeight = height; + this.#renderIfWindowChanged(); + } + }); + }); + + this.#resizeObserver.observe(panel); + } + + // TRACK LIST VIRTUAL SCROLL + + #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; + const stride = this.#rowStride; + + const startIndex = Math.max( + 0, + Math.floor(scrollTop / stride) - OVERSCAN, + ); + const visibleCount = Math.ceil(viewportHeight / stride) + 2 * OVERSCAN; + return { + startIndex, + endIndex: Math.min(count, startIndex + visibleCount), + }; + } + + // RENDER + + /** + * @param {RenderArg} _ + */ + render({ html }) { + const viewTitle = this.#viewTitle(); + + // Keep observers pointed at the current scroll panel — it can be + // replaced by any re-render (e.g. when the library loads) + requestAnimationFrame(() => this.#syncViewportObserver()); + + return html` + + + + + + +
+ ${this.#renderTitlebar(html, viewTitle)} +
+ ${this.#renderRail(html)} + ${this.#renderCatalog(html)} + ${this.#renderMain(html)} +
+
+ `; + } + + /** + * Window title for the current view, shown in the titlebar. + * @returns {string} + */ + #viewTitle() { + const view = this.#view.value; + const playlist = this.$scope.value?.playlist(); + + if (view.type === "album") return view.albumName; + if (view.type === "artist") return view.artistName; + if (view.type === "artists") return "Artists"; + if (view.type === "tracks") return "Songs"; + if (view.type === "playlist-tracks") return playlist ?? "Playlists"; + return "Albums"; + } + + /** + * @param {Function} html + * @param {string} title + */ + #renderTitlebar(html, title) { + return html` +
+ + + + + + + + + + + + ${title} + +
+ + ${this.getAttribute("group-label") ?? + this.group} + + +
+ `; + } + + #emitNewDeck() { + this.dispatchEvent( + new CustomEvent("da-new-deck", { bubbles: true, composed: true }), + ); + } + + /** + * @param {Function} html + */ + #renderRail(html) { + const view = this.#view.value; + const railItem = RAIL_FOR_VIEW[view.type]; + + return html` + + `; + } + + /** + * @param {Function} html + */ + #renderMiniPlayer(html) { + const track = this.currentTrack(); + const isPlaying = this.isPlaying(); + const audioState = this.$controller.value?.audio(); + const progress = audioState?.progress() ?? 0; + const currentTime = audioState?.currentTime(); + const duration = audioState?.duration(); + const remaining = duration !== undefined && currentTime !== undefined + ? -(Math.max(0, duration - currentTime)) + : -0; + + const albumKey = String(track?.tags?.album ?? "").toLowerCase(); + const artUrl = track ? this.#coverArtCache.get(albumKey) : undefined; + + if (track && this.#coverArtCache.get(albumKey) === undefined) { + this.#fetchAlbumArt(albumKey, track); + } + + return html` +
+
+
+ ${artUrl + ? html`` + : html` +
+ +
+ `} +
+
+ ${track?.tags?.title ?? "Not playing"} + ${track?.tags?.artist ?? "—"} + ${track?.tags?.album + ? html`${track.tags.album}` + : nothing} +
+
+ +
+ +
+ + +
+
+ +
+ + + +
+
+ `; + } + + /** + * Which content the catalog column shows for the current view. + * `null` means the column is hidden (Songs view). + * @returns {"albums" | "artists" | "playlists" | null} + */ + #catalogMode() { + const railItem = RAIL_FOR_VIEW[this.#view.value.type]; + return railItem === "tracks" ? null : railItem; + } + + /** + * Filtered, letter-grouped rows for the catalog column. + * @returns {{ label: string; placeholder: string; emptyLabel: string; sections: { letter: string; rows: { key: string; label: string; artKey: string; trackId: string | undefined }[] }[] } | null} + */ + #catalogData() { + const mode = this.#catalogMode(); + if (!mode) return null; + + const filter = this.#playlistFilter.value.trim().toLowerCase(); + + /** @type {{ key: string; label: string; artKey: string; trackId: string | undefined }[]} */ + let rows; + let label; + let placeholder; + let emptyLabel; + + if (mode === "albums") { + rows = this.$albumRows().map((a) => ({ + key: a.albumKey, + label: a.albumName, + artKey: a.albumKey, + trackId: a.track.id, + })); + label = "Albums"; + placeholder = "Album name..."; + emptyLabel = "No albums"; + } else if (mode === "artists") { + rows = this.$artistRows().map((a) => ({ + key: a.artistKey, + label: a.artistName, + artKey: a.artistKey, + trackId: a.track.id, + })); + label = "Artists"; + placeholder = "Artist name..."; + emptyLabel = "No artists"; + } else { + const col = this.$output.value?.playlistItems.collection(); + rows = col?.state === "loaded" + ? [...Playlist.gather(col.data).values()] + .map((p) => p.name) + .sort((a, b) => a.localeCompare(b)) + .map((name) => ({ + key: name, + label: name, + artKey: `playlist:${name}`, + trackId: undefined, + })) + : []; + label = "Playlists"; + placeholder = "Playlist name..."; + emptyLabel = "No playlists yet"; + } + + if (filter) { + rows = rows.filter((r) => r.label.toLowerCase().includes(filter)); + emptyLabel = "No matches"; + } + + return { + label, + placeholder, + emptyLabel, + sections: groupByLetter(rows), + }; + } + + /** + * @param {Function} html + */ + #renderCatalog(html) { + const data = this.#catalogData(); + const collapsed = this.#catalogCollapsed.value; + + if (!data) return nothing; + + const currentView = this.#view.value; + const currentPlaylist = this.$scope.value?.playlist(); + const letters = data.sections.map((s) => s.letter); + + return html` +
+
+ + + ${data.sections.length > 0 + ? html` +
${data.label}
+ ${data.sections.map(({ letter, rows }) => html` +
+
${letter}
+ ${rows.map((row) => { + const isActive = currentView.type === "album" + ? currentView.albumKey === row.key + : currentView.type === "artist" + ? currentView.artistKey === row.key + : currentView.type === "playlist-tracks" && + currentPlaylist === row.key; + return html` + + `; + })} +
+ `)} + ` + : html` +
+

${data.emptyLabel}

+
+ `} +
+ + ${letters.length > 1 + ? html` + + ` + : nothing} +
+ `; + } + + /** + * Opens a catalog row: album detail, artist detail or playlist tracks. + * @param {{ key: string; label: string; artKey: string; trackId: string | undefined }} row + */ + #activateCatalogRow(row) { + const mode = this.#catalogMode(); + + if (mode === "albums") { + const item = this.$albumRows().find((a) => a.albumKey === row.key); + if (item) { + this.openAlbum({ type: "album", ...item }); + } + } else if (mode === "artists") { + const item = this.$artistRows().find((a) => a.artistKey === row.key); + if (item) { + this.openArtist({ type: "artist", ...item }); + } + } else { + this.setSelectedPlaylist(row.key); + } + } + + /** + * @param {Function} html + * @param {{ key: string; label: string; artKey: string; trackId: string | undefined }} row + */ + #renderCatalogThumb(html, row) { + const artUrl = this.#coverArtCache.get(row.artKey); + this.#ensureRowArt(row.artKey, () => + row.trackId + ? this.$provider.value?.tracks().find((t) => t.id === row.trackId) + : this.#resolvePlaylistTrack(row.key)); + + return html` +
+ ${artUrl + ? html`` + : html` +
+ +
+ `} +
+ `; + } + + /** + * @param {Function} html + */ + #renderMain(html) { + const view = this.#view.value; + + // Playlists view with nothing selected yet — prompt instead of a + // misleading all-tracks list. + if (view.type === "playlist-tracks" && !this.$scope.value?.playlist()) { + return html` +
+
+ +

Select a playlist

+
+
+ `; + } + + return html` +
+ ${this.#renderHeader(html)} + ${view.type === "albums" || view.type === "artists" + ? this.#renderCoverGrid(html) + : this.#renderTrackList(html)} +
+ `; + } + + /** + * @param {Function} html + */ + #renderHeader(html) { + const view = this.#view.value; + const playlist = this.$scope.value?.playlist(); + const searchTerm = this.$scope.value?.searchTerm(); + + /** @type {string} */ + let title; + /** @type {string} */ + let badge; + /** @type {string} */ + let subtitle; + + if (view.type === "album") { + title = view.albumName; + badge = "Album"; + const dur = this.$albumDurations().get(view.albumKey); + subtitle = [ + view.artist, + dur?.ms ? formatDuration(dur.ms) : `${dur?.count ?? 0} tracks`, + ].filter(Boolean).join(" — "); + } else if (view.type === "artist") { + title = view.artistName; + badge = "Artist"; + subtitle = `${view.trackCount} ${view.trackCount === 1 ? "track" : "tracks"}`; + } else if (view.type === "artists") { + const groups = this.$sortedArtistGroups(); + const count = groups.reduce((n, g) => n + g.groups.length, 0); + title = "Artists"; + badge = ""; + subtitle = `${count} ${count === 1 ? "artist" : "artists"}`; + } else if (view.type === "tracks") { + title = "Songs"; + badge = ""; + const count = this.$currentTracks().length; + subtitle = `${count} ${count === 1 ? "song" : "songs"}`; + } else if (view.type === "playlist-tracks") { + title = playlist ?? "Playlists"; + badge = "Playlist"; + const count = this.$currentTracks().length; + subtitle = `${count} ${count === 1 ? "song" : "songs"}`; + } else { + title = "Albums"; + badge = ""; + const count = this.$sortedCoverGroups().reduce( + (n, g) => n + g.groups.length, + 0, + ); + subtitle = `${count} ${count === 1 ? "release" : "releases"}`; + } + + return html` +
+
+

${title}

+ ${badge ? html`${badge}` : nothing} +
+
${subtitle}
+ +
+ + + ${searchTerm + ? html` + + ` + : nothing} +
+
+ `; + } + + /** + * @param {Function} html + */ + #renderCoverGrid(html) { + const view = this.#view.value; + if (view.type === "artists") return this.#renderArtistsGrid(html); + return this.#renderAlbumsGrid(html); + } + + /** + * @param {Function} html + */ + #renderAlbumsGrid(html) { + return this.#renderCardsGrid(html, "albums"); + } + + /** + * @param {Function} html + */ + #renderArtistsGrid(html) { + return this.#renderCardsGrid(html, "artists"); + } + + /** + * Virtualized cover grid: cards are chunked into fixed-column rows + * (#rebuildGridItems) which are windowed by scroll position, like the + * track list. + * @param {Function} html + * @param {"albums" | "artists"} mode + */ + #renderCardsGrid(html, mode) { + const isAlbums = mode === "albums"; + const groups = isAlbums + ? this.$sortedCoverGroups() + : this.$sortedArtistGroups(); + const totalCount = groups.reduce((n, g) => n + g.groups.length, 0); + + if (totalCount === 0) { + this.#renderedGridStart = -1; + this.#renderedGridEnd = -1; + return html` +
+ ${this.#renderEmptyState(html, isAlbums ? "No albums" : "No artists")} +
+ `; + } + + // Rebuild the virtual item list when the data or layout changed + if ( + groups !== this.#gridSource || mode !== this.#gridMode || + this.#gridLayoutDirty + ) { + this.#rebuildGridItems(groups); + this.#gridSource = groups; + this.#gridMode = mode; + this.#gridLayoutDirty = false; + } + + const { startIndex, endIndex } = this.#computeGridRange(); + this.#renderedGridStart = startIndex; + this.#renderedGridEnd = endIndex; + + const items = this.#gridItems; + const totalHeight = this.#gridOffsets[items.length] ?? 0; + + requestAnimationFrame(() => this.#measureGrid()); + + return html` +
+
${isAlbums ? "Albums" : "Artists"}
+
+ ${repeat( + items.slice(startIndex, endIndex).map((item, i) => ({ + item, + top: this.#gridOffsets[startIndex + i], + })), + (entry) => entry.item.type === "group" + ? `group-${entry.item.label}` + : `row-${mode}-${ + entry.item.items.map(( + /** @type {any} */ c, + ) => c.albumKey ?? c.artistKey).join("|")}`, + (entry) => entry.item.type === "group" + ? html`
${entry.item.label}
` + : html` +
+ ${entry.item.items.map((/** @type {any} */ item) => + this.#renderGridCard(html, mode, item))} +
+ `, + )} +
+
+ `; + } + + /** + * @param {Function} html + * @param {"albums" | "artists"} mode + * @param {any} item CoverGroup or ArtistGroup + */ + #renderGridCard(html, mode, item) { + const isAlbums = mode === "albums"; + const key = isAlbums ? item.albumKey : item.artistKey; + this.#fetchAlbumArt(key, item.track); + const artUrl = this.#coverArtCache.get(key); + + let time; + if (isAlbums) { + const dur = this.$albumDurations().get(item.albumKey); + time = html`${ + dur?.ms ? formatDuration(dur.ms) : `${dur?.count ?? 0} tracks` + }`; + } + + const onOpen = isAlbums + ? () => this.openAlbum({ + type: "album", + albumKey: item.albumKey, + albumName: item.albumName, + artist: item.artist, + track: item.track, + }) + : () => this.openArtist({ + type: "artist", + artistKey: item.artistKey, + artistName: item.artistName, + trackCount: item.trackCount, + track: item.track, + }); + + return html` +
+
+ ${artUrl + ? html` + ${isAlbums ? item.albumName : item.artistName} + ` + : html` +
+ +
+ `} +
+
+ ${isAlbums ? item.albumName : item.artistName} + ${isAlbums + ? html` + ${item.artist} + ${time} + ` + : html` + ${item.trackCount} + ${item.trackCount === 1 ? `track` : `tracks`} + `} +
+
+ `; + } + + /** + * @param {Function} html + * @param {string} message + */ + #renderEmptyState(html, message) { + const isLoading = this.#isCollectionLoading(); + + return html` +
+ +

${isLoading ? `Loading your library…` : message}

+
+ `; + } + + /** + * @param {Function} html + */ + #renderTrackList(html) { + const tracks = this.$currentTracks(); + + if (tracks.length === 0) { + this.#itemCount = 0; + this.#renderedTracks = undefined; + this.#renderedStartIndex = -1; + this.#renderedEndIndex = -1; + return html` +
+
+ ${this.#renderEmptyState(html, "No tracks")} +
+
+ `; + } + + if (tracks !== this.#renderedTracks) { + this.#renderedStartIndex = -1; + this.#renderedEndIndex = -1; + this.#renderedTracks = tracks; + } + + const count = tracks.length; + this.#itemCount = count; + const { startIndex, endIndex } = this.#computeWindow(count); + this.#renderedStartIndex = startIndex; + this.#renderedEndIndex = endIndex; + const totalSize = count * TRACK_ROW_STRIDE; + + // Column sort state (hidden when an ordered playlist dictates the order) + const playlistOrdered = /** @type {any} */ (this.$provider.value) + ?.playlistIsOrdered?.() ?? false; + const sortBy = this.$scope.value?.sortBy() ?? []; + const sortDirection = this.$scope.value?.sortDirection(); + 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"; + + const sortIcon = /** @param {string} col */ (col) => + sortedColumn === col + ? html`` + : nothing; + + return html` +
+
+ + + +
+ +
+
+
+
+
+ ${repeat( + tracks.slice(startIndex, endIndex).map((track, i) => ({ + track, + index: startIndex + i, + top: (startIndex + i) * TRACK_ROW_STRIDE, + })), + (entry) => `da-tr-${entry.track.id}`, + (entry) => this.#renderTrackRow(html, entry.track, entry.top), + )} +
+
+
+ `; + } + + /** + * @param {Function} html + * @param {Track} track + * @param {number} top + */ + #renderTrackRow(html, track, top) { + const albumKey = String(track.tags?.album ?? "").toLowerCase(); + this.#fetchAlbumArt(albumKey, track); + const artUrl = this.#coverArtCache.get(albumKey); + + const favKey = `${String(track.tags?.artist ?? "").toLowerCase()}|${ + String(track.tags?.title ?? "").toLowerCase() + }`; + const isFav = this.$favouritesSet().has(favKey); + + const currentId = this.currentTrack()?.id; + const isCurrent = currentId === track.id; + + return html` +
+
+
+ ${artUrl + ? html`` + : html` +
+ +
+ `} +
+ ${trackTitle(track)} + ${isCurrent + ? html`` + : nothing} +
+
+ ${track.tags?.artist ?? ""} +
+
+ ${track.tags?.album ?? ""} +
+
${formatDuration(track.stats?.duration)}
+
+ + +
+
+ `; + } +} + +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.length > 11 && bytes[8] === 0x57 && bytes[9] === 0x45 && + bytes[10] === 0x42 && bytes[11] === 0x50) { + return "image/webp"; + } + if (bytes[0] === 0x47 && bytes[1] === 0x49) return "image/gif"; + return "image/jpeg"; +} + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = Browser; +export const NAME = "da-browser"; + +defineElement(NAME, CLASS); diff --git a/src/facets/themes/albums-app/facet/facet.css b/src/facets/themes/albums-app/facet/facet.css new file mode 100644 index 00000000..0c1a6087 --- /dev/null +++ b/src/facets/themes/albums-app/facet/facet.css @@ -0,0 +1,53 @@ +html, +body { + height: 100%; + margin: 0; + overflow: hidden; + padding: 0; +} + +body { + background: var(--da-bg); + color: var(--da-text); + font-family: + "Inter", + ui-sans-serif, + system-ui, + -apple-system, + "Segoe UI", + Roboto, + sans-serif; +} + +#container { + height: 100%; +} + +main { + display: flex; + flex-direction: column; + height: 100%; + margin: 0 auto; + max-width: 150rem; + overflow: hidden; + padding: 0.75rem; + width: 100%; + + @media (min-width: 47.5rem) and (min-height: 40rem) { + padding: 1.25rem; + } + + @media (min-width: 75rem) and (min-height: 42.5rem) { + padding: 1.5rem; + } +} + +da-browser { + border-radius: 0.75rem; + box-shadow: + 0 0 0 1px rgb(255 255 255 / 0.08), + 0 24px 64px rgb(0 0 0 / 0.55); + flex: 1; + min-height: 0; + min-width: 0; +} diff --git a/src/facets/themes/albums-app/facet/facet.js b/src/facets/themes/albums-app/facet/facet.js new file mode 100644 index 00000000..132805ed --- /dev/null +++ b/src/facets/themes/albums-app/facet/facet.js @@ -0,0 +1,61 @@ +import foundation from "~/common/foundation.js"; + +// Set doc title +foundation.setup({ title: "Albums.app" }); + +//////////////////////////////////////////// +// 🚀 +//////////////////////////////////////////// + +await foundation.engine.queue(); +await foundation.engine.repeatShuffle(); +await foundation.engine.scope(); +await foundation.orchestrator.scopedTracks(); + +await foundation.orchestrator.sources(); +await foundation.orchestrator.processTracks({ disableWhenReady: true }); +await foundation.orchestrator.queueAudio(); +await foundation.orchestrator.controller(); +await foundation.orchestrator.mediaSession(); +await foundation.orchestrator.artwork(); +await foundation.orchestrator.coverGroups(); +await foundation.orchestrator.favourites(); +await foundation.configurator.input(); + +await import("~/facets/themes/albums-app/browser/element.js"); + +const groupLabel = foundation.GROUP === "facets" ? "Deck A" : foundation.GROUP; +const browser = document.querySelector("da-browser"); + +browser?.setAttribute("group", foundation.GROUP); +browser?.setAttribute("group-label", groupLabel); + +//////////////////////////////////////////// +// SHORTCUTS +//////////////////////////////////////////// + +// The titlebar's "new deck" button in the browser element bubbles this up +document.addEventListener("da-new-deck", async () => { + const state = await navigator.locks.query(); + const held = (state.held ?? []).flatMap((l) => l.name ? [l.name] : []); + + let nextGroup; + + if (!held.some((n) => n.includes("/Deck B"))) { + nextGroup = "Deck B"; + } else if (!held.some((n) => n.includes("/Deck C"))) { + nextGroup = "Deck C"; + } else { + return; + } + + const url = new URL(document.location.href); + url.searchParams.set("group", nextGroup); + window.open(url.toString(), "_blank"); +}); + +//////////////////////////////////////////// +// 🚀 +//////////////////////////////////////////// + +foundation.ready(); diff --git a/src/facets/themes/albums-app/facet/index.html b/src/facets/themes/albums-app/facet/index.html new file mode 100644 index 00000000..2c77febe --- /dev/null +++ b/src/facets/themes/albums-app/facet/index.html @@ -0,0 +1,24 @@ + + + + + + + + +
+ +
+ + diff --git a/src/facets/themes/albums-app/fonts.css b/src/facets/themes/albums-app/fonts.css new file mode 100644 index 00000000..37d9bfd9 --- /dev/null +++ b/src/facets/themes/albums-app/fonts.css @@ -0,0 +1,15 @@ +@font-face { + font-display: swap; + font-family: "Inter"; + font-style: normal; + font-weight: 100 900; + src: url("../../../fonts/InterVariable.woff2") format("woff2"); +} + +@font-face { + font-display: swap; + font-family: "Inter"; + font-style: italic; + font-weight: 100 900; + src: url("../../../fonts/InterVariable-Italic.woff2") format("woff2"); +} diff --git a/src/facets/themes/albums-app/variables.css b/src/facets/themes/albums-app/variables.css new file mode 100644 index 00000000..858985b1 --- /dev/null +++ b/src/facets/themes/albums-app/variables.css @@ -0,0 +1,13 @@ +:root, +:host { + --da-bg: #0b0b0c; + --da-window: #1e1e20; + --da-surface: #29292c; + --da-surface-strong: #363639; + --da-text: #f5f5f7; + --da-text-dim: #98989e; + --da-text-faint: #6e6e73; + --da-accent: #f5931e; + --da-border: rgb(255 255 255 / 0.07); + --da-hairline: rgb(255 255 255 / 0.06); +} -- 2.51.2