From b675c2db38261bac2bdf6cf6777464172c0af932 Mon Sep 17 00:00:00 2001 From: Steven Vandevelde Date: Thu, 16 Jul 2026 12:50:31 +0000 Subject: [PATCH] feat: incomplete tidal theme --- src/_data/facets.json | 22 ++++++++++++++++++++++ src/facets/themes/tidal/variables.css | 12 ++++++++++++ src/facets/themes/tidal/browser/element.css | 709 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/facets/themes/tidal/browser/element.js | 1263 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/facets/themes/tidal/facet/index.html | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/facets/themes/tidal/facet/index.inline.js | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/facets/themes/tidal/player/element.css | 360 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/facets/themes/tidal/player/element.js | 532 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/facets/themes/tidal/browser/facet/index.html | 23 +++++++++++++++++++++++ src/facets/themes/tidal/browser/facet/index.inline.js | 31 +++++++++++++++++++++++++++++++ src/facets/themes/tidal/player/facet/index.html | 23 +++++++++++++++++++++++ src/facets/themes/tidal/player/facet/index.inline.js | 29 +++++++++++++++++++++++++++++ 12 file(s) changed, 3136 insertion(s)(+), 0 deletion(s)(-) diff --git a/src/_data/facets.json b/src/_data/facets.json --- a/src/_data/facets.json +++ b/src/_data/facets.json @@ -167,6 +167,7 @@ "title": "iPod", "category": "Themes", "author": "A theme by [@tokono.ma](https://bsky.app/profile/tokono.ma)", + "featured": true, "desc": "A recreation of the click-wheel iPod UI. Spin the wheel to scroll, tap the ring for menu / prev / next / play, and press the centre button to select. Alternatively, use your keyboard arrow keys." }, { @@ -267,5 +268,26 @@ "title": "Winamp / Browser", "category": "Browsing", "desc": "Collection browser and search." + }, + { + "url": "facets/themes/tidal/facet/index.html", + "title": "Tidal", + "category": "Themes", + "incomplete": true, + "desc": "🚧 A Tidal-inspired browser. Pick a playlist from the sidebar, browse albums and artists, and play tracks." + }, + { + "url": "facets/themes/tidal/browser/facet/index.html", + "title": "Tidal / Browser", + "category": "Browsing", + "incomplete": true, + "desc": "🚧 Tidal-style collection browser and search with a playlist sidebar and album / artist grids." + }, + { + "url": "facets/themes/tidal/player/facet/index.html", + "title": "Tidal / Player", + "category": "Playback", + "incomplete": true, + "desc": "🚧 Tidal-style now-playing bar with playback controls, progress and volume." } ] diff --git a/src/facets/themes/tidal/variables.css b/src/facets/themes/tidal/variables.css new file mode 100644 --- /dev/null +++ b/src/facets/themes/tidal/variables.css @@ -0,0 +1,12 @@ +:root, +:host { + --td-bg: #000; + --td-sidebar: #242328; + --td-surface: #1d1d1f; + --td-surface-hover: #2a2a2d; + --td-text: #f9f9f9; + --td-text-dim: #a0a0a5; + --td-text-faint: #707075; + --td-accent: #a5fcf3; + --td-border: #1d1d1f; +} diff --git a/src/facets/themes/tidal/browser/element.css b/src/facets/themes/tidal/browser/element.css new file mode 100644 --- /dev/null +++ b/src/facets/themes/tidal/browser/element.css @@ -0,0 +1,709 @@ +:host { + --td-radius: 0.5rem; + --td-radius-sm: 0.375rem; + + background: var(--td-bg); + color: var(--td-text); + display: block; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + "Segoe UI", + Roboto, + sans-serif; + font-size: 0.75rem; + height: 100%; + overflow: hidden; +} + +/*********************************** + * Shell + ***********************************/ + +.td-shell { + display: flex; + height: 100%; + width: 100%; +} + +/*********************************** + * Sidebar + ***********************************/ + +.td-sidebar { + background: var(--td-sidebar); + flex-shrink: 0; + overflow: hidden; + padding: 0.75rem 0.5rem; + width: 16rem; +} + +.td-sidebar__scroll { + display: flex; + flex-direction: column; + gap: 0.125rem; + height: 100%; + overflow-y: auto; + padding-right: 0.25rem; +} + +.td-sidebar__label { + color: #97969b; + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.05em; + padding: 1rem 0.75rem 0.375rem; + text-transform: uppercase; +} + +.td-playlist-row { + align-items: center; + background: transparent; + border: 0; + border-radius: 0; + color: #faf9fe; + cursor: pointer; + display: flex; + font-family: inherit; + font-size: inherit; + font-weight: 500; + padding: 0.5rem 0.75rem; + text-align: left; + text-overflow: ellipsis; + transition: color 100ms; + white-space: nowrap; + width: 100%; +} + +.td-playlist-row span { + overflow: hidden; + text-overflow: ellipsis; +} + +.td-playlist-row:hover { + color: var(--td-text); +} + +.td-playlist-row--all { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.td-playlist-row--active, +.td-playlist-row--active:hover { + color: var(--td-accent); + font-weight: 700; +} + +/*********************************** + * Main column + ***********************************/ + +.td-main { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + overflow: hidden; + padding: 1rem 1.5rem 1.5rem; +} + +/*********************************** + * Toolbar + ***********************************/ + +.td-toolbar { + align-items: center; + display: flex; + flex-shrink: 0; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.td-toolbar__nav { + display: flex; + gap: 0.25rem; +} + +.td-toolbar__icon-btn { + align-items: center; + background: var(--td-surface); + border: 0; + border-radius: 50%; + color: var(--td-text); + cursor: pointer; + display: flex; + flex-shrink: 0; + font-size: 0.875rem; + height: 2rem; + justify-content: center; + transition: + background-color 100ms, + color 100ms, + opacity 100ms; + width: 2rem; +} + +.td-toolbar__icon-btn:hover:not(:disabled) { + background: var(--td-surface-hover); +} + +.td-toolbar__icon-btn:disabled { + cursor: not-allowed; + opacity: 0.35; +} + +.td-toolbar__icon-btn--inline { + background: transparent; + color: var(--td-text-dim); +} + +.td-toolbar__icon-btn--inline:hover { + background: var(--td-surface-hover); + color: var(--td-text); +} + +.td-toolbar__search { + align-items: center; + background: var(--td-surface); + border: 1px solid transparent; + border-radius: 999px; + color: var(--td-text-dim); + display: flex; + flex: 1; + gap: 0.5rem; + max-width: 24rem; + padding: 0.5rem 0.875rem; + transition: + background-color 100ms, + border-color 100ms; +} + +.td-toolbar__search:focus-within { + background: var(--td-surface-hover); + border-color: var(--td-border); +} + +.td-toolbar__search i { + flex-shrink: 0; + font-size: 0.875rem; +} + +.td-toolbar__search input { + background: transparent; + border: 0; + color: var(--td-text); + flex: 1; + font-family: inherit; + font-size: inherit; + min-width: 0; + outline: none; + padding: 0; +} + +.td-toolbar__search input::placeholder { + color: var(--td-text-dim); +} + +.td-toolbar__search input[type="search"]::-webkit-search-cancel-button { + display: none; +} + +/*********************************** + * Sub-categories + ***********************************/ + +.td-subcategories { + display: flex; + flex-shrink: 0; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.td-subcat { + align-items: center; + background: var(--td-surface); + border: 0; + border-radius: var(--td-radius); + color: var(--td-text); + cursor: pointer; + display: flex; + font-family: inherit; + font-size: inherit; + font-weight: 500; + gap: 0.625rem; + padding: 0.625rem 1rem; + transition: + background-color 100ms, + transform 100ms; +} + +.td-subcat i { + color: var(--td-text); + flex-shrink: 0; + font-size: 0.875rem; +} + +.td-subcat:hover { + background: var(--td-surface-hover); + transform: translateY(-1px); +} + +/*********************************** + * Detail header (album / artist) + ***********************************/ + +.td-detail-header { + align-items: flex-end; + display: flex; + flex-shrink: 0; + gap: 1.25rem; + margin-bottom: 1rem; +} + +.td-detail-art { + aspect-ratio: 1; + background: var(--td-surface); + border-radius: var(--td-radius); + flex-shrink: 0; + height: 8rem; + overflow: hidden; + width: 8rem; +} + +.td-detail-art img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.td-detail-meta { + display: flex; + flex-direction: column; + gap: 0.375rem; + min-width: 0; + padding-bottom: 0.25rem; +} + +.td-detail-name { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.td-detail-subtitle { + color: var(--td-text-dim); + font-size: inherit; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 40rem) { + .td-detail-header { + align-items: center; + flex-direction: column; + text-align: center; + } +} + +/*********************************** + * Track list + ***********************************/ + +.td-tracks-list { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; +} + +.td-tracks-header { + align-items: center; + color: var(--td-text-dim); + display: grid; + flex-shrink: 0; + font-size: 0.5625rem; + font-weight: 600; + gap: 0.75rem; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr) minmax(0, 2fr) 3.5rem 3.75rem; + letter-spacing: 0.06em; + padding: 0.5rem 0.75rem; + text-transform: uppercase; + user-select: none; +} + +.td-track-header__title { + padding-left: calc(2rem + 1rem); +} + +.td-track-header__time { + display: flex; + justify-content: flex-end; +} + +.td-track-header__time i { + font-size: 0.875rem; +} + +.td-tracks-panel { + flex: 1; + min-height: 0; + overflow-anchor: none; + overflow-y: auto; + user-select: none; +} + +.td-tracks-virtual { + contain: layout paint; + position: relative; +} + +.td-track-row { + align-items: center; + cursor: pointer; + display: grid; + gap: 0.75rem; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr) minmax(0, 2fr) 3.5rem 3.75rem; + height: 48px; + left: 0; + padding: 0 0.75rem; + position: absolute; + right: 0; +} + +.td-track__title { + align-items: center; + display: flex; + gap: 1rem; + min-width: 0; +} + +.td-track__art { + aspect-ratio: 1; + background: var(--td-surface); + border-radius: 0; + flex-shrink: 0; + height: 2rem; + overflow: hidden; + width: 2rem; +} + +.td-track__art img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.td-track-art-placeholder { + align-items: center; + color: var(--td-text-faint); + display: flex; + font-size: 0.875rem; + height: 100%; + justify-content: center; + width: 100%; +} + +.td-track__title-text { + font-size: 0.75rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.td-track__artist, +.td-track__album { + color: var(--td-text); + font-size: 0.75rem; + min-width: 0; + overflow: hidden; +} + +.td-track__artist span, +.td-track__album span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.td-track__time { + color: var(--td-text); + font-size: 0.6875rem; + font-variant-numeric: tabular-nums; + text-align: right; +} + +.td-track__actions { + align-items: center; + display: flex; + flex-shrink: 0; + gap: 0; + justify-content: flex-end; +} + +.td-track__action { + align-items: center; + background: transparent; + border: 0; + border-radius: 50%; + color: var(--td-text-dim); + cursor: pointer; + display: flex; + font-size: 0.875rem; + height: 1.5rem; + justify-content: center; + transition: + color 100ms, + background-color 100ms; + width: 1.75rem; +} + +.td-track__action:hover { + color: var(--td-text); +} + +.td-track__action--active { + color: var(--td-accent); +} + +.td-track__action--active:hover { + color: var(--td-accent); +} + +.td-empty { + align-items: center; + color: var(--td-text-dim); + display: flex; + flex-direction: column; + gap: 0.5rem; + height: 100%; + justify-content: center; + text-align: center; +} + +.td-empty i { + font-size: 2.5rem; + opacity: 0.5; +} + +/*********************************** + * Cover grid (albums / artists) + ***********************************/ + +.td-covers-header { + align-items: center; + color: var(--td-text-dim); + display: flex; + flex-shrink: 0; + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.04em; + margin-bottom: 0.5rem; + text-transform: uppercase; +} + +.td-covers-panel { + flex: 1; + min-height: 0; + overflow-y: auto; + padding-bottom: 1rem; +} + +.td-cover-group { + color: var(--td-text-dim); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.05em; + padding: 0.5rem 0 0.25rem; + text-transform: uppercase; +} + +.td-cover-group--top { + padding-top: 0; +} + +.td-cover-grid { + display: grid; + gap: 1rem 0.875rem; + grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); + padding: 0.25rem 0 0.5rem; +} + +.td-cover-card { + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0; + text-align: left; + transition: transform 150ms; + width: 100%; +} + +.td-cover-card:hover { + transform: translateY(-2px); +} + +.td-cover-art { + aspect-ratio: 1; + background: var(--td-surface); + border-radius: var(--td-radius); + overflow: hidden; + position: relative; + width: 100%; +} + +.td-cover-art img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.td-cover-placeholder { + align-items: center; + color: var(--td-text-faint); + display: flex; + font-size: 2.5rem; + height: 100%; + justify-content: center; + width: 100%; +} + +.td-cover-info { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; + padding: 0 0.25rem; +} + +.td-cover-album { + font-size: inherit; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.td-cover-artist { + color: var(--td-text-dim); + font-size: 0.75rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/*********************************** + * Scrollbars + ***********************************/ + +.td-tracks-panel::-webkit-scrollbar, +.td-covers-panel::-webkit-scrollbar, +.td-sidebar__scroll::-webkit-scrollbar { + width: 10px; +} + +.td-tracks-panel::-webkit-scrollbar-thumb, +.td-covers-panel::-webkit-scrollbar-thumb, +.td-sidebar__scroll::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 6px; +} + +.td-tracks-panel:hover::-webkit-scrollbar-thumb, +.td-covers-panel:hover::-webkit-scrollbar-thumb, +.td-sidebar__scroll:hover::-webkit-scrollbar-thumb { + background: var(--td-border); +} + +.td-tracks-panel, +.td-covers-panel, +.td-sidebar__scroll { + scrollbar-color: var(--td-border) transparent; + scrollbar-width: thin; +} + +/*********************************** + * Small screens + ***********************************/ + +@media (max-width: 56rem) { + .td-sidebar { + width: 13rem; + } + + .td-main { + padding: 0.75rem 1rem 1rem; + } +} + +@media (max-width: 48rem) { + .td-tracks-header, + .td-track-row { + gap: 0.5rem; + } +} + +@media (max-width: 40rem) { + .td-tracks-header, + .td-track-row { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr) 3.5rem 3.75rem; + } + + .td-track-header__album, + .td-track__album { + display: none; + } +} + +@media (max-width: 32rem) { + .td-tracks-header, + .td-track-row { + grid-template-columns: minmax(0, 1fr) 3.5rem 3.75rem; + } + + .td-track-header__artist, + .td-track__artist { + display: none; + } +} + +@media (max-width: 40rem) { + .td-shell { + flex-direction: column; + } + + .td-sidebar { + border-bottom: 1px solid var(--td-border); + border-right: 0; + max-height: 12rem; + padding: 0.5rem; + width: 100%; + } + + .td-main { + padding: 0.75rem; + } + + .td-toolbar__search { + max-width: none; + } + + .td-subcategories { + flex-wrap: wrap; + } +} diff --git a/src/facets/themes/tidal/browser/element.js b/src/facets/themes/tidal/browser/element.js new file mode 100644 --- /dev/null +++ b/src/facets/themes/tidal/browser/element.js @@ -0,0 +1,1263 @@ +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; + +/** + * @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; +} + +/** + * @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: "tracks"; playlist: string | undefined }} TracksView + * @typedef {{ type: "albums" } | { type: "artists" } | AlbumItem | ArtistItem | TracksView } View + */ + +class Browser extends DiffuseElement { + 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), + ); + + $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: "tracks", playlist: undefined }), + ); + + #history = signal(/** @type {View[]} */ ([])); + #future = signal(/** @type {View[]} */ ([])); + + // Cover art cache + /** @type {Map} */ + #coverArtCache = new Map(); + /** @type {Set} */ + #pendingArtFetch = new Set(); + /** @type {{ key: string; track: Track }[]} */ + #artFetchQueue = []; + #artFetchActive = 0; + #artRenderScheduled = false; + /** @type {IntersectionObserver | undefined} */ + #coverObserver = undefined; + /** @type {Map} */ + #pendingVisibleCards = new Map(); + /** @type {ReturnType | undefined} */ + #artFetchDebounce = undefined; + #coverScrollTop = 0; + + // 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 {AbortController | undefined} */ + #scrollAbort; + /** @type {IntersectionObserver | undefined} */ + #scrollVisibilityObserver; + /** @type {number | undefined} */ + #scrollRenderRaf = undefined; + /** @type {Track[] | undefined} */ + #renderedTracks = undefined; + + // COMPUTED + + $currentTracks = computed(() => { + const view = this.#view.value; + if (view.type === "tracks") { + return this.$provider.value?.tracks() ?? []; + } + return /** @type {Track[]} */ ([]); + }); + + $detailTracks = 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 /** @type {Track[]} */ ([]); + }); + + $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; + }); + + $sortedCoverGroups = computed(() => { + const groups = this.$coverGroups.value?.coverGroups() ?? []; + return this.#sortGroups(groups); + }); + + $sortedArtistGroups = computed(() => { + const groups = this.$coverGroups.value?.artistGroups() ?? []; + return this.#sortGroups(groups); + }); + + $groupedPlaylists = computed(() => { + const col = this.$output.value?.playlistItems.collection(); + if (!col || col.state !== "loaded" || !col.data.length) return []; + const items = col.data; + + /** @type {Map} */ + const playlistMap = Playlist.gather(items); + + const all = [...playlistMap.values()].sort((a, b) => + a.name.localeCompare(b.name) + ); + + return [{ label: "Playlists", playlists: all }]; + }); + + $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 {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; + }); + } + + // Reset scroll when track list changes + this.effect(() => { + const _ = this.$currentTracks(); + const _d = this.$detailTracks(); + untracked(() => { + const panel = this.root().querySelector(".td-tracks-panel"); + if (panel) { + panel.scrollTo(0, 0); + this.#scrollTop = 0; + } + }); + }); + + // Re-attach scroll tracking when returning to a track-listing view + // from the albums/artists grid (where .td-tracks-panel is removed) + this.effect(() => { + const view = this.#view.value; + if (view.type === "albums" || view.type === "artists") return; + + untracked(() => { + requestAnimationFrame(() => this.#setupScrollTracking()); + }); + }); + + // Re-observe cover cards when groups change + this.effect(() => { + const _cover = this.$sortedCoverGroups(); + const _artist = this.$sortedArtistGroups(); + const view = this.#view.value; + if (view.type !== "albums" && view.type !== "artists") return; + + untracked(() => { + requestAnimationFrame(() => { + this.#setupCoverObserver(); + this.#fetchVisibleCoverArt(); + requestAnimationFrame(() => this.#fetchVisibleCoverArt()); + }); + }); + }); + + this.#setupScrollTracking(); + } + + /** + * @override + */ + disconnectedCallback() { + super.disconnectedCallback(); + this.#scrollAbort?.abort(); + this.#scrollAbort = undefined; + this.#scrollVisibilityObserver?.disconnect(); + this.#scrollVisibilityObserver = undefined; + this.#resizeObserver?.disconnect(); + this.#resizeObserver = undefined; + this.#disconnectCoverObserver(); + if (this.#scrollRenderRaf !== undefined) { + cancelAnimationFrame(this.#scrollRenderRaf); + this.#scrollRenderRaf = undefined; + } + } + + // 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() })); + } + + // ACTIONS + + /** + * @param {string | undefined} playlist + */ + setSelectedPlaylist = (playlist) => { + this.#disconnectCoverObserver(); + this.#clearHistory(); + this.$scope.value?.setPlaylist(playlist); + this.#view.value = { type: "tracks", playlist }; + }; + + /** + * @param {View} view + */ + #navigateTo(view) { + this.#disconnectCoverObserver(); + 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.#disconnectCoverObserver(); + 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.#disconnectCoverObserver(); + this.#history.value = [...this.#history.value, this.#view.value]; + this.#future.value = fut.slice(1); + this.#view.value = next; + this.#renderedTracks = undefined; + }; + + #clearHistory() { + this.#history.value = []; + this.#future.value = []; + } + + /** + * @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); + }; + + setSearchTerm = () => { + /** @type {HTMLInputElement | null} */ + const input = this.root().querySelector("#td-search-input"); + const term = input?.value?.trim(); + this.$scope.value?.setSearchTerm(term || undefined); + }; + + clearSearch = () => { + /** @type {HTMLInputElement | null} */ + const input = this.root().querySelector("#td-search-input"); + if (input) input.value = ""; + this.$scope.value?.setSearchTerm(undefined); + }; + + /** + * @param {AlbumItem} item + */ + openAlbum = (item) => { + const panel = this.root().querySelector(".td-covers-panel"); + if (panel) this.#coverScrollTop = panel.scrollTop; + this.#navigateTo(item); + }; + + /** + * @param {ArtistItem} item + */ + openArtist = (item) => { + const panel = this.root().querySelector(".td-covers-panel"); + if (panel) this.#coverScrollTop = panel.scrollTop; + this.#navigateTo(item); + }; + + browseAlbums = () => { + this.#navigateTo({ type: "albums" }); + }; + + browseArtists = () => { + this.#navigateTo({ type: "artists" }); + }; + + // 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.#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 + } 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(); + }); + } + + #fetchVisibleCoverArt() { + const panel = this.root().querySelector(".td-covers-panel"); + if (!panel) return; + const panelRect = panel.getBoundingClientRect(); + const margin = 200; + for ( + const card of this.root().querySelectorAll( + ".td-cover-card[data-cover-key]", + ) + ) { + const el = /** @type {HTMLElement} */ (card); + const key = el.dataset.coverKey; + if ( + !key || this.#coverArtCache.has(key) || + this.#pendingArtFetch.has(key) + ) continue; + const cardRect = el.getBoundingClientRect(); + if ( + cardRect.bottom + margin < panelRect.top || + cardRect.top - margin > panelRect.bottom + ) continue; + const trackId = el.dataset.coverTrackId; + /** @type {Track | undefined} */ + const track = trackId + ? this.$provider.value?.tracks().find((t) => t.id === trackId) + : undefined; + if (track) this.#fetchAlbumArt(key, track); + } + } + + #setupCoverObserver() { + const root = this.root().querySelector(".td-covers-panel"); + if (!root) return; + + if (!this.#coverObserver) { + this.#coverObserver = new IntersectionObserver( + (entries) => { + let hasNew = false; + for (const entry of entries) { + if (!entry.isIntersecting) continue; + hasNew = true; + const key = + /** @type {HTMLElement} */ (entry.target).dataset.coverKey; + if (!key) continue; + const trackId = + /** @type {HTMLElement} */ (entry.target).dataset.coverTrackId; + /** @type {Track | undefined} */ + const track = trackId + ? this.$provider.value?.tracks().find((t) => t.id === trackId) + : undefined; + if (track) this.#pendingVisibleCards.set(key, track); + this.#coverObserver?.unobserve(entry.target); + } + if (!hasNew) return; + clearTimeout(this.#artFetchDebounce); + this.#artFetchDebounce = setTimeout(() => { + for (const [key, track] of this.#pendingVisibleCards) { + this.#fetchAlbumArt(key, track); + } + this.#pendingVisibleCards.clear(); + }, 150); + }, + { root, rootMargin: "200px" }, + ); + } + + for ( + const card of this.root().querySelectorAll( + ".td-cover-card[data-cover-key]", + ) + ) { + const key = /** @type {HTMLElement} */ (card).dataset.coverKey; + if ( + key && + (this.#coverArtCache.has(key) || this.#pendingArtFetch.has(key)) + ) continue; + this.#coverObserver.observe(card); + } + } + + #disconnectCoverObserver() { + this.#coverObserver?.disconnect(); + this.#coverObserver = undefined; + clearTimeout(this.#artFetchDebounce); + this.#pendingVisibleCards.clear(); + } + + // TRACK LIST VIRTUAL SCROLL + + #setupScrollTracking() { + this.#scrollAbort?.abort(); + this.#scrollAbort = new AbortController(); + this.#scrollVisibilityObserver?.disconnect(); + this.#scrollVisibilityObserver = undefined; + this.#resizeObserver?.disconnect(); + this.#resizeObserver = undefined; + this.#scrollTop = 0; + + const abort = this.#scrollAbort; + + const attach = () => { + if (abort.signal.aborted) return; + const panel = this.root().querySelector(".td-tracks-panel"); + if (!panel) return; + + panel.addEventListener( + "scroll", + () => { + this.#scrollTop = panel.scrollTop; + this.#renderIfWindowChanged(); + }, + { passive: true, signal: abort.signal }, + ); + + this.#resizeObserver = new ResizeObserver((entries) => { + this.#viewportHeight = Math.min( + entries[0].contentRect.height, + window.innerHeight, + ); + this.#renderIfWindowChanged(); + }); + + this.#resizeObserver.observe(panel); + }; + + this.#scrollVisibilityObserver = new IntersectionObserver( + (entries, observer) => { + if (!entries[0].isIntersecting) return; + observer.disconnect(); + this.#scrollVisibilityObserver = undefined; + requestAnimationFrame(attach); + }, + ); + + this.#scrollVisibilityObserver.observe(this); + } + + #renderIfWindowChanged() { + const { startIndex, endIndex } = this.#computeWindow(this.#itemCount); + + if ( + startIndex === this.#renderedStartIndex && + endIndex === this.#renderedEndIndex + ) return; + + this.forceRender(); + } + + /** + * @param {number} count + * @returns {{ startIndex: number; endIndex: number }} + */ + #computeWindow(count) { + const scrollTop = this.#scrollTop; + const viewportHeight = this.#viewportHeight; + 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 {Function} html + */ + #renderSidebar(html) { + const currentPlaylist = this.$scope.value?.playlist(); + const groups = this.$groupedPlaylists(); + const playlists = groups[0]?.playlists ?? []; + + return html` + + `; + } + + /** + * @param {Function} html + */ + #renderToolbar(html) { + const searchTerm = this.$scope.value?.searchTerm() ?? ""; + const canBack = this.#history.value.length > 0; + const canForward = this.#future.value.length > 0; + + return html` +
+
+ + +
+ +
+ `; + } + + /** + * @param {Function} html + */ + #renderSubCategories(html) { + const view = this.#view.value; + if (view.type !== "tracks") return nothing; + + return html` +
+ + +
+ `; + } + + /** + * @param {Function} html + */ + #renderDetailHeader(html) { + const view = this.#view.value; + if (view.type !== "album" && view.type !== "artist") return nothing; + + /** @type {string} */ + let key = ""; + /** @type {string} */ + let name = ""; + /** @type {string} */ + let subtitle = ""; + /** @type {Track} */ + let track; + if (view.type === "album") { + key = view.albumKey; + name = view.albumName; + subtitle = view.artist; + track = view.track; + } else { + key = view.artistKey; + name = view.artistName; + subtitle = `${view.trackCount} ${view.trackCount === 1 ? "track" : "tracks"}`; + track = view.track; + } + + const artUrl = this.#coverArtCache.get(key); + + return html` +
+
+ ${artUrl + ? html`${name}` + : html` +
+ +
+ `} +
+
+
${name}
+
${subtitle}
+
+
+ `; + } + + /** + * @param {Function} html + */ + #renderTrackList(html) { + const view = this.#view.value; + const tracks = view.type === "tracks" + ? this.$currentTracks() + : this.$detailTracks(); + + if (tracks.length === 0) { + this.#itemCount = 0; + this.#renderedTracks = undefined; + this.#renderedStartIndex = -1; + this.#renderedEndIndex = -1; + return html` +
+
+
Title
+
Artist
+
Album
+
+ +
+
+
+
+
+ +

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; + + return html` +
+
+
Title
+
Artist
+
Album
+
+ +
+
+
+
+
+ ${repeat( + tracks.slice(startIndex, endIndex).map((track, i) => ({ + track, + index: startIndex + i, + top: (startIndex + i) * TRACK_ROW_STRIDE, + })), + (entry) => `td-tr-${entry.track.id}`, + (entry) => this.#renderTrackRow(html, entry.track, entry.top, entry.index), + )} +
+
+
+ `; + } + + /** + * @param {Function} html + * @param {Track} track + * @param {number} top + * @param {number} index + */ + #renderTrackRow(html, track, top, index) { + 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); + + return html` +
+
+
+ ${artUrl + ? html`` + : html` +
+ +
+ `} +
+ ${trackTitle(track)} +
+
+ ${track.tags?.artist ?? ""} +
+
+ ${track.tags?.album ?? ""} +
+
${formatDuration(track)}
+
+ + +
+
+ `; + } + + /** + * @param {Function} html + */ + #renderCoverGrid(html) { + const view = this.#view.value; + if (view.type === "albums") return this.#renderAlbumsGrid(html); + if (view.type === "artists") return this.#renderArtistsGrid(html); + return nothing; + } + + /** + * @param {Function} html + */ + #renderAlbumsGrid(html) { + const groups = this.$sortedCoverGroups(); + const totalCount = groups.reduce((n, g) => n + g.groups.length, 0); + const totalLabel = `${totalCount} ${totalCount === 1 ? "album" : "albums"}`; + + requestAnimationFrame(() => this.#setupCoverObserver()); + + if (totalCount === 0) { + return html` +
+ +

No albums

+
+ `; + } + + return html` +
+ ${totalLabel} +
+
+ ${groups.map(({ label, groups: items }, groupIndex) => html` + ${label + ? html` +
+ ${label} +
+ ` + : nothing} +
+ ${items.map((/** @type {any} */ item) => { + const artUrl = this.#coverArtCache.get(item.albumKey); + return html` +
+
+ ${artUrl + ? html` + ${item.albumName} + ` + : html` +
+ +
+ `} +
+
+ ${item.albumName} + ${item.artist} +
+
+ `; + })} +
+ `)} +
+ `; + } + + /** + * @param {Function} html + */ + #renderArtistsGrid(html) { + const groups = this.$sortedArtistGroups(); + const totalCount = groups.reduce((n, g) => n + g.groups.length, 0); + const totalLabel = `${totalCount} ${totalCount === 1 + ? "artist" + : "artists"}`; + + requestAnimationFrame(() => this.#setupCoverObserver()); + + if (totalCount === 0) { + return html` +
+ +

No artists

+
+ `; + } + + return html` +
+ ${totalLabel} +
+
+ ${groups.map(({ label, groups: items }, groupIndex) => html` + ${label + ? html` +
+ ${label} +
+ ` + : nothing} +
+ ${items.map((/** @type {any} */ item) => { + const artUrl = this.#coverArtCache.get(item.artistKey); + return html` +
+
+ ${artUrl + ? html` + ${item.artistName} + ` + : html` +
+ +
+ `} +
+
+ ${item.artistName} + ${item.trackCount} + ${item.trackCount === 1 ? `track` : `tracks`} +
+
+ `; + })} +
+ `)} +
+ `; + } + + /** + * @param {Function} html + */ + #renderMain(html) { + const view = this.#view.value; + + return html` +
+ ${this.#renderToolbar(html)} ${view.type === "albums" || + view.type === "artists" + ? this.#renderCoverGrid(html) + : html` + ${this.#renderSubCategories(html)} ${view.type === "album" || + view.type === "artist" + ? this.#renderDetailHeader(html) + : nothing} ${this.#renderTrackList(html)} + `} +
+ `; + } + + /** + * @param {RenderArg} _ + */ + render({ html }) { + return html` + + + + + + +
+ ${this.#renderSidebar(html)} ${this.#renderMain(html)} +
+ `; + } +} + +export default Browser; + +//////////////////////////////////////////// +// HELPERS +//////////////////////////////////////////// + +/** + * @param {Uint8Array} bytes + * @returns {string} + */ +function detectMime(bytes) { + if (bytes[0] === 0xFF && bytes[1] === 0xD8) return "image/jpeg"; + if (bytes[0] === 0x89 && bytes[1] === 0x50) return "image/png"; + if (bytes[0] === 0x47 && bytes[1] === 0x49) return "image/gif"; + if (bytes[0] === 0x52 && bytes[1] === 0x49) return "image/webp"; + return "image/jpeg"; +} + +/** + * @param {Track} track + * @returns {string} + */ +function formatDuration(track) { + const ms = track.stats?.duration; + if (!ms) return "—"; + const totalSec = Math.round(ms / 1000); + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return `${m}:${String(s).padStart(2, "0")}`; +} + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = Browser; +export const NAME = "db-tidal-browser"; + +defineElement(NAME, CLASS); diff --git a/src/facets/themes/tidal/facet/index.html b/src/facets/themes/tidal/facet/index.html new file mode 100644 --- /dev/null +++ b/src/facets/themes/tidal/facet/index.html @@ -0,0 +1,70 @@ + + +
+ + + +
+ + diff --git a/src/facets/themes/tidal/facet/index.inline.js b/src/facets/themes/tidal/facet/index.inline.js new file mode 100644 --- /dev/null +++ b/src/facets/themes/tidal/facet/index.inline.js @@ -0,0 +1,62 @@ +import foundation from "~/common/foundation.js"; + +foundation.setup({ title: "Tidal | Diffuse" }); + +const [ + out, + que, + scp, + art, + cov, + fav, + trc, + rs, + audio, + ctrl, +] = await Promise.all([ + foundation.orchestrator.output(), + foundation.engine.queue(), + foundation.engine.scope(), + foundation.orchestrator.artwork(), + foundation.orchestrator.coverGroups(), + foundation.orchestrator.favourites(), + foundation.orchestrator.scopedTracks(), + foundation.engine.repeatShuffle(), + foundation.engine.audio(), + foundation.orchestrator.controller(), +]); + +await foundation.configurator.input(); +await foundation.orchestrator.sources(); +await foundation.orchestrator.processTracks({ disableWhenReady: true }); + +await foundation.orchestrator.queueAudio(); +await foundation.orchestrator.mediaSession(); + +await import("~/facets/themes/tidal/browser/element.js"); +await import("~/facets/themes/tidal/player/element.js"); + +// Browser +const browser = document.querySelector("db-tidal-browser"); +if (browser) { + browser.setAttribute("artwork-selector", art.selector); + browser.setAttribute("cover-groups-orchestrator-selector", cov.selector); + browser.setAttribute("favourites-orchestrator-selector", fav.selector); + browser.setAttribute("output-selector", out.selector); + browser.setAttribute("queue-engine-selector", que.selector); + browser.setAttribute("scope-engine-selector", scp.selector); + browser.setAttribute("tracks-selector", trc.selector); +} + +// Player +const player = document.querySelector("db-tidal-player"); +if (player) { + player.setAttribute("artwork-selector", art.selector); + player.setAttribute("controller-orchestrator-selector", ctrl.selector); + player.setAttribute("favourites-orchestrator-selector", fav.selector); + player.setAttribute("queue-engine-selector", que.selector); + player.setAttribute("repeat-shuffle-engine-selector", rs.selector); + player.setAttribute("output-selector", out.selector); +} + +foundation.ready(); diff --git a/src/facets/themes/tidal/player/element.css b/src/facets/themes/tidal/player/element.css new file mode 100644 --- /dev/null +++ b/src/facets/themes/tidal/player/element.css @@ -0,0 +1,360 @@ +:host { + display: block; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + "Segoe UI", + Roboto, + sans-serif; + font-size: 0.75rem; + height: 100%; +} + +/*********************************** + * Player bar + ***********************************/ + +.td-player { + align-items: center; + background: var(--td-sidebar); + box-shadow: 0 -0.25rem 0.75rem -0.25rem rgba(0, 0, 0, 0.5); + color: var(--td-text); + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr); + height: 100%; + padding: 0 1rem; + position: relative; + user-select: none; +} + +/*********************************** + * Left: now playing + ***********************************/ + +.td-player__left { + align-items: center; + display: flex; + gap: 0.75rem; + min-width: 0; +} + +.td-player__art { + aspect-ratio: 1; + background: var(--td-surface); + border-radius: var(--td-radius-sm); + flex-shrink: 0; + height: 3rem; + overflow: hidden; + width: 3rem; +} + +.td-player__art img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.td-player__art-placeholder { + align-items: center; + color: var(--td-text-faint); + display: flex; + font-size: 1rem; + height: 100%; + justify-content: center; + width: 100%; +} + +.td-player__meta { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.td-player__title { + font-size: 0.8125rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.td-player__artist { + color: var(--td-text-dim); + font-size: 0.6875rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.td-player__fav { + align-items: center; + background: transparent; + border: 0; + border-radius: 50%; + color: var(--td-text-dim); + cursor: pointer; + display: flex; + flex-shrink: 0; + font-size: 1rem; + height: 1.75rem; + justify-content: center; + transition: + color 100ms, + background-color 100ms; + width: 1.75rem; +} + +.td-player__fav:hover:not(:disabled) { + color: var(--td-text); +} + +.td-player__fav:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.td-player__fav--active { + color: var(--td-accent); +} + +.td-player__fav--active:hover { + color: var(--td-accent); +} + +/*********************************** + * Center: controls + progress + ***********************************/ + +.td-player__center { + align-items: center; + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: 0; +} + +.td-player__controls { + align-items: center; + display: flex; + gap: 0.5rem; +} + +.td-player__btn { + align-items: center; + background: transparent; + border: 0; + border-radius: 50%; + color: var(--td-text-dim); + cursor: pointer; + display: flex; + font-size: 0.875rem; + height: 1.75rem; + justify-content: center; + transition: + color 100ms, + background-color 100ms; + width: 1.75rem; +} + +.td-player__btn:hover:not(:disabled) { + color: var(--td-text); +} + +.td-player__btn:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.td-player__btn--active { + color: var(--td-accent); +} + +.td-player__btn--active:hover { + color: var(--td-accent); +} + +.td-player__play { + align-items: center; + background: var(--td-accent); + border: 0; + border-radius: 50%; + color: var(--td-bg); + cursor: pointer; + display: flex; + font-size: 1rem; + height: 2.25rem; + justify-content: center; + transition: + background-color 100ms, + transform 100ms; + width: 2.25rem; +} + +.td-player__play:hover:not(:disabled) { + filter: brightness(1.1); + transform: scale(1.05); +} + +.td-player__play:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.td-player__spin { + animation: td-spin 1s linear infinite; +} + +@keyframes td-spin { + to { + transform: rotate(360deg); + } +} + +.td-player__progress { + align-items: center; + display: flex; + gap: 0.5rem; + width: 100%; +} + +.td-player__time { + color: var(--td-text-dim); + flex-shrink: 0; + font-size: 0.6875rem; + font-variant-numeric: tabular-nums; + min-width: 2.25rem; + text-align: center; +} + +.td-player__seek { + background: var(--td-surface); + border-radius: 999px; + cursor: pointer; + flex: 1; + height: 0.25rem; + min-width: 4rem; + overflow: hidden; + position: relative; +} + +.td-player__seek-fill { + background: var(--td-text); + border-radius: 999px; + height: 100%; + pointer-events: none; + transition: width 200ms linear; +} + +.td-player__seek:hover .td-player__seek-fill { + background: var(--td-accent); +} + +/*********************************** + * Right: volume + actions + ***********************************/ + +.td-player__right { + align-items: center; + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.td-player__icon-btn { + align-items: center; + background: transparent; + border: 0; + border-radius: 50%; + color: var(--td-text-dim); + cursor: pointer; + display: flex; + font-size: 1rem; + height: 1.75rem; + justify-content: center; + transition: + color 100ms, + background-color 100ms; + width: 1.75rem; +} + +.td-player__icon-btn:hover { + color: var(--td-text); +} + +.td-player__right { + position: relative; +} + +.td-player__volume-popover { + align-items: center; + background: var(--td-surface); + border: 1px solid var(--td-border); + border-radius: 999px; + bottom: calc(100% + 0.5rem); + display: flex; + height: 5.5rem; + justify-content: center; + padding: 0.5rem 0.375rem; + position: absolute; + right: 0; + width: 1.5rem; + z-index: 10; +} + +.td-player__volume-bar { + background: var(--td-bg); + border-radius: 999px; + cursor: pointer; + flex: 1; + height: 100%; + overflow: hidden; + position: relative; + width: 0.25rem; +} + +.td-player__volume-fill { + background: var(--td-text); + border-radius: 999px; + bottom: 0; + left: 0; + position: absolute; + right: 0; + transition: height 200ms; + width: 100%; +} + +.td-player__volume-bar:hover .td-player__volume-fill { + background: var(--td-accent); +} + +/*********************************** + * Small screens + ***********************************/ + +@media (max-width: 56rem) { + .td-player { + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + gap: 0.75rem; + } + + .td-player__right { + display: none; + } +} + +@media (max-width: 40rem) { + .td-player { + grid-template-columns: 1fr; + padding: 0.5rem; + } + + .td-player__left, + .td-player__center { + width: 100%; + } +} diff --git a/src/facets/themes/tidal/player/element.js b/src/facets/themes/tidal/player/element.js new file mode 100644 --- /dev/null +++ b/src/facets/themes/tidal/player/element.js @@ -0,0 +1,532 @@ +import { + defineElement, + DiffuseElement, + query, + queryOptional, + whenElementsDefined, +} from "~/common/element.js"; +import { batch, signal } from "~/common/signal.js"; + +/** + * @import {RenderArg} from "~/common/element.d.ts" + * @import ArtworkOrchestrator from "~/components/orchestrator/artwork/element.js" + * @import ControllerOrchestrator from "~/components/orchestrator/controller/element.js" + * @import FavouritesOrchestrator from "~/components/orchestrator/favourites/element.js" + * @import QueueEngine from "~/components/engine/queue/element.js" + * @import RepeatShuffleEngine from "~/components/engine/repeat-shuffle/element.js" + * @import {InputElement} from "@specs/components/input/types.d.ts" + */ + +class Player extends DiffuseElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + } + + // SIGNALS + + $artwork = signal( + /** @type {ArtworkOrchestrator | undefined} */ (undefined), + ); + + $controller = signal( + /** @type {ControllerOrchestrator | undefined} */ (undefined), + ); + + $favourites = signal( + /** @type {FavouritesOrchestrator | undefined} */ (undefined), + ); + + $queue = signal( + /** @type {QueueEngine | undefined} */ (undefined), + ); + + $repeatShuffle = signal( + /** @type {RepeatShuffleEngine | undefined} */ (undefined), + ); + + $input = signal( + /** @type {InputElement | undefined} */ (undefined), + ); + + #artUrl = signal(/** @type {string | null | undefined} */ (undefined)); + #audioError = signal(false); + #isLoading = signal(false); + #lastArtKey = /** @type {string | undefined} */ (undefined); + #volumeOpen = signal(false); + #lastNonZeroVolume = signal(0.75); + /** @type {ReturnType | undefined} */ + #isLoadingTimeout = undefined; + /** @type {ReturnType | undefined} */ + #volumeCloseTimeout = undefined; + + // LIFECYCLE + + /** + * @override + */ + connectedCallback() { + super.connectedCallback(); + + /** @type {ArtworkOrchestrator} */ + const artwork = query(this, "artwork-selector"); + + /** @type {ControllerOrchestrator} */ + const controller = query(this, "controller-orchestrator-selector"); + + /** @type {FavouritesOrchestrator} */ + const favourites = query(this, "favourites-orchestrator-selector"); + + /** @type {QueueEngine} */ + const queue = query(this, "queue-engine-selector"); + + /** @type {RepeatShuffleEngine} */ + const repeatShuffle = query(this, "repeat-shuffle-engine-selector"); + + /** @type {InputElement | null} */ + const input = queryOptional(this, "input-selector"); + + whenElementsDefined({ + artwork, + controller, + favourites, + queue, + repeatShuffle, + }).then(() => { + batch(() => { + this.$artwork.value = artwork; + this.$controller.value = controller; + this.$favourites.value = favourites; + this.$queue.value = queue; + this.$repeatShuffle.value = repeatShuffle; + }); + }); + + if (input) { + whenElementsDefined({ input }).then(() => { + this.$input.value = input; + }); + } + + // Fetch artwork for the current track + this.effect(() => { + const track = this.$controller.value?.currentTrack(); + const artKey = track + ? String(track.tags?.album ?? "").toLowerCase() + : ""; + + if (!track || !artKey) { + this.#artUrl.value = undefined; + this.#lastArtKey = undefined; + return; + } + + if (this.#lastArtKey === artKey) return; + this.#lastArtKey = artKey; + this.#artUrl.value = undefined; + + this.$artwork.value?.get(track).then((bytes) => { + if (this.#lastArtKey !== artKey) return; + if (!bytes) { + this.#artUrl.value = null; + return; + } + const mime = detectMime(bytes); + const url = URL.createObjectURL( + new Blob([bytes], { type: mime }), + ); + this.#artUrl.value = url; + }); + }); + + // Loading state + this.effect(() => { + const ctrl = this.$controller.value; + const now = !!ctrl?.$queue.value?.now(); + const aud = ctrl?.audio(); + const state = aud?.loadingState(); + const isError = now && typeof state === "object" && state !== null && + "error" in state; + // When audio() is undefined (engine not yet created or destroyed), + // there is no audio to wait on — don't show the loading spinner. + const isLoading = now && !isError && state !== undefined && + state !== "loaded"; + + this.#audioError.value = !!isError; + + if (this.#isLoadingTimeout) { + clearTimeout(this.#isLoadingTimeout); + } + + if (isLoading) { + this.#isLoadingTimeout = setTimeout( + () => this.#isLoading.value = true, + 2000, + ); + } else { + this.#isLoading.value = false; + } + }); + } + + /** + * @override + */ + disconnectedCallback() { + super.disconnectedCallback(); + if (this.#isLoadingTimeout) { + clearTimeout(this.#isLoadingTimeout); + this.#isLoadingTimeout = undefined; + } + if (this.#volumeCloseTimeout) { + clearTimeout(this.#volumeCloseTimeout); + this.#volumeCloseTimeout = undefined; + } + } + + // ACTIONS + + playPause = () => { + const ctrl = this.$controller.value; + if (!ctrl) return; + const audioId = ctrl.$queue.value?.now()?.id; + if (!audioId) return; + if (ctrl.isPlaying()) { + ctrl.$audio.value?.pause({ audioId }); + } else { + ctrl.$audio.value?.play({ audioId }); + } + }; + + next = () => this.$queue.value?.shift(); + previous = () => this.$queue.value?.unshift(); + + toggleShuffle = () => { + const rs = this.$repeatShuffle.value; + if (rs) rs.setShuffle(!rs.shuffle()); + }; + + toggleRepeat = () => { + const rs = this.$repeatShuffle.value; + if (rs) rs.setRepeat(!rs.repeat()); + }; + + toggleFavourite = () => { + const track = this.$controller.value?.currentTrack(); + if (track) this.$favourites.value?.toggle(track); + }; + + reload = () => { + const ctrl = this.$controller.value; + if (!ctrl) return; + const audioId = ctrl.$queue.value?.now()?.id; + if (!audioId) return; + const progress = ctrl.audio()?.progress(); + ctrl.$audio.value?.reload({ audioId, play: true, progress }); + }; + + mute = () => { + const current = this.$controller.value?.$audio.value?.volume() ?? 1; + if (current > 0) { + this.#lastNonZeroVolume.value = current; + } + this.$controller.value?.$audio.value?.adjustVolume({ volume: 0 }); + }; + + fullVolume = () => { + this.$controller.value?.$audio.value?.adjustVolume({ volume: 1 }); + }; + + toggleMute = () => { + const vol = this.$controller.value?.$audio.value?.volume() ?? 1; + if (vol === 0) { + const restore = this.#lastNonZeroVolume.value > 0 + ? this.#lastNonZeroVolume.value + : 0.75; + this.$controller.value?.$audio.value?.adjustVolume({ + volume: restore, + }); + } else { + this.mute(); + } + }; + + openVolume = () => { + if (this.#volumeCloseTimeout) { + clearTimeout(this.#volumeCloseTimeout); + this.#volumeCloseTimeout = undefined; + } + this.#volumeOpen.value = true; + }; + + scheduleCloseVolume = () => { + if (this.#volumeCloseTimeout) { + clearTimeout(this.#volumeCloseTimeout); + } + this.#volumeCloseTimeout = setTimeout(() => { + this.#volumeOpen.value = false; + this.#volumeCloseTimeout = undefined; + }, 150); + }; + + cancelCloseVolume = () => { + if (this.#volumeCloseTimeout) { + clearTimeout(this.#volumeCloseTimeout); + this.#volumeCloseTimeout = undefined; + } + }; + + /** + * @param {MouseEvent} event + */ + setVolume = (event) => { + const target = /** @type {HTMLElement | null} */ (event.target); + if (!target) return; + const rect = target.getBoundingClientRect(); + const clickY = event.clientY - rect.top; + const height = rect.height; + const percentage = Math.max( + 0, + Math.min(1, 1 - clickY / height), + ); + if (percentage > 0) { + this.#lastNonZeroVolume.value = percentage; + } + this.$controller.value?.$audio.value?.adjustVolume({ volume: percentage }); + }; + + /** + * @param {MouseEvent} event + */ + seek = (event) => { + const target = event.target + ? /** @type {HTMLProgressElement} */ (event.target) + : null; + const percentage = target ? event.offsetX / target.clientWidth : 0; + const ctrl = this.$controller.value; + const audioId = ctrl?.$queue.value?.now()?.id; + if (audioId) { + ctrl?.$audio.value?.seek({ audioId, percentage }); + } + }; + + // RENDER + + /** + * @param {RenderArg} { html } + */ + render({ html }) { + const artUrl = this.#artUrl.value; + + return html` + + + + + +
+ +
+
+ ${artUrl + ? html`` + : html` +
+ +
+ `} +
+
+ + ${this.$controller.value?.currentTrack()?.tags?.title ?? ""} + + + ${this.$controller.value?.currentTrack()?.tags?.artist ?? ""} + +
+ +
+ + +
+
+ + + + + +
+
+ + ${formatTime( + this.$controller.value?.audio()?.currentTime() ?? 0, + )} + +
+
+
+ + ${(() => { + const audio = this.$controller.value?.audio(); + const track = this.$controller.value?.currentTrack(); + const dur = track?.stats?.duration + ? track.stats.duration / 1000 + : (audio?.duration() ?? 0); + return dur > 0 ? formatTime(dur) : "0:00"; + })()} + +
+
+ + +
+ + ${this.#volumeOpen.value + ? html` +
+
+
+
+
+ ` + : ``} +
+
+ `; + } +} + +/** + * @param {number} seconds + */ +function formatTime(seconds) { + if (!Number.isFinite(seconds) || seconds < 0) seconds = 0; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${String(s).padStart(2, "0")}`; +} + +/** + * @param {Uint8Array} bytes + */ +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"; +} + +export default Player; + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = Player; +export const NAME = "db-tidal-player"; + +defineElement(NAME, CLASS); diff --git a/src/facets/themes/tidal/browser/facet/index.html b/src/facets/themes/tidal/browser/facet/index.html new file mode 100644 --- /dev/null +++ b/src/facets/themes/tidal/browser/facet/index.html @@ -0,0 +1,23 @@ + + + diff --git a/src/facets/themes/tidal/browser/facet/index.inline.js b/src/facets/themes/tidal/browser/facet/index.inline.js new file mode 100644 --- /dev/null +++ b/src/facets/themes/tidal/browser/facet/index.inline.js @@ -0,0 +1,31 @@ +import foundation from "~/common/foundation.js"; +import BrowserElement from "~/facets/themes/tidal/browser/element.js"; + +foundation.setup({ title: "Tidal | Diffuse" }); + +const [out, que, scp, art, cov, fav, trc] = await Promise.all([ + foundation.orchestrator.output(), + foundation.engine.queue(), + foundation.engine.scope(), + foundation.orchestrator.artwork(), + foundation.orchestrator.coverGroups(), + foundation.orchestrator.favourites(), + foundation.orchestrator.scopedTracks(), +]); + +await foundation.configurator.input(); +await foundation.orchestrator.sources(); +await foundation.orchestrator.processTracks({ disableWhenReady: true }); + +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); + +(document.querySelector("#container") ?? document.body).append(el); + +foundation.ready(); diff --git a/src/facets/themes/tidal/player/facet/index.html b/src/facets/themes/tidal/player/facet/index.html new file mode 100644 --- /dev/null +++ b/src/facets/themes/tidal/player/facet/index.html @@ -0,0 +1,23 @@ + + + diff --git a/src/facets/themes/tidal/player/facet/index.inline.js b/src/facets/themes/tidal/player/facet/index.inline.js new file mode 100644 --- /dev/null +++ b/src/facets/themes/tidal/player/facet/index.inline.js @@ -0,0 +1,29 @@ +import foundation from "~/common/foundation.js"; +import PlayerElement from "~/facets/themes/tidal/player/element.js"; + +foundation.setup({ title: "Player | Tidal | Diffuse" }); + +// Setup the prerequisite elements +await foundation.orchestrator.queueAudio(); +await foundation.orchestrator.mediaSession(); + +const [out, que, rs, art, fav, ctrl] = await Promise.all([ + foundation.orchestrator.output(), + foundation.engine.queue(), + foundation.engine.repeatShuffle(), + foundation.orchestrator.artwork(), + foundation.orchestrator.favourites(), + foundation.orchestrator.controller(), +]); + +const el = new PlayerElement(); +el.setAttribute("artwork-selector", art.selector); +el.setAttribute("controller-orchestrator-selector", ctrl.selector); +el.setAttribute("favourites-orchestrator-selector", fav.selector); +el.setAttribute("queue-engine-selector", que.selector); +el.setAttribute("repeat-shuffle-engine-selector", rs.selector); +el.setAttribute("output-selector", out.selector); + +(document.querySelector("#container") ?? document.body).append(el); + +foundation.ready(); -- tangled.sh