diff --git a/specs/facets/themes/catalogue/SPEC.md b/specs/facets/themes/catalogue/SPEC.md new file mode 100644 index 00000000..010a44fb --- /dev/null +++ b/specs/facets/themes/catalogue/SPEC.md @@ -0,0 +1,18 @@ +# Catalogue theme + +A theme that's optimised for browsing through one's collection. + + +## Requirements + +- Focuses on a primarily visual approach to browsing the track collection. Uses cover art heavily. +- It starts with selecting the scope: search for a specific term or select a playlist. +- From there the user can browse through artists, albums or just see all songs/audio. +- Also must be able to see the queue and items played previously. +- There should also be a way to control the (playing) audio. + + +## Design + +- Do not opt for a typical audio player/browser design. +- I'm thinking grids and blocks. diff --git a/src/_data/facets.json b/src/_data/facets.json index 106c435a..8eada43e 100644 --- a/src/_data/facets.json +++ b/src/_data/facets.json @@ -34,6 +34,13 @@ "category": "Browsing", "desc": "Collection browser and search with favourite toggling, date grouping, and virtual scrolling." }, + { + "url": "facets/themes/catalogue-one/facet/index.html", + "title": "Catalogue One", + "category": "Themes", + "author": "A theme by [@tokono.ma](https://bsky.app/profile/tokono.ma)", + "desc": "A visual, cover-art-first browsing experience. Browse your collection as grids of albums and artists, manage the queue, and control playback." + }, { "url": "facets/themes/blur-classic/facet/index.html", "title": "Blur Classic", diff --git a/src/facets/themes/catalogue-one/facet/index.css b/src/facets/themes/catalogue-one/facet/index.css new file mode 100644 index 00000000..a73933f1 --- /dev/null +++ b/src/facets/themes/catalogue-one/facet/index.css @@ -0,0 +1,1048 @@ +/* =========================================== + Catalogue theme — grid & block aesthetic + =========================================== */ + +* { + box-sizing: border-box; +} + +body { + background: var(--cat-bg); + color: var(--cat-text); + display: flex; + flex-direction: column; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + sans-serif; + height: 100dvh; + margin: 0; + overflow: hidden; +} + +#container { + flex: 1; + min-height: 0; +} + +main { + display: flex; + flex-direction: column; + height: 100dvh; + overflow: hidden; +} + +/*********************************** + * Header — scope + tabs + ***********************************/ + +.cat-header { + align-items: center; + background: var(--cat-surface); + border-bottom: 1px solid var(--cat-border); + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + gap: var(--space-xs); + justify-content: space-between; + padding: var(--space-xs) var(--space-sm); +} + +.cat-scope { + align-items: center; + display: flex; + flex: 1; + gap: var(--space-xs); + min-width: 200px; +} + +.cat-search-wrap { + align-items: center; + background: var(--cat-bg); + border: 1px solid var(--cat-border); + border-radius: var(--radius-lg); + display: flex; + flex: 1; + max-width: 320px; + position: relative; + transition: border-color 150ms; +} + +.cat-search-wrap:focus-within { + border-color: var(--cat-accent); +} + +.cat-search-icon { + color: var(--cat-text-dim); + font-size: 14px; + left: 10px; + pointer-events: none; + position: absolute; +} + +.cat-search { + background: none; + border: none; + color: var(--cat-text); + flex: 1; + font-family: inherit; + font-size: var(--fs-xs); + outline: none; + padding: var(--space-2xs) var(--space-xs) var(--space-2xs) var(--space-lg); +} + +.cat-search::placeholder { + color: var(--cat-text-dim); +} + +.cat-search::-webkit-search-cancel-button { + display: none; +} + +.cat-playlist-select { + appearance: none; + background: var(--cat-bg) + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23999' viewBox='0 0 256 256'%3E%3Cpath d='M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z'/%3E%3C/svg%3E") + no-repeat right 8px center / 12px; + border: 1px solid var(--cat-border); + border-radius: var(--radius-lg); + color: var(--cat-text); + cursor: pointer; + font-family: inherit; + font-size: var(--fs-xs); + max-width: 200px; + padding: var(--space-2xs) var(--space-xl) var(--space-2xs) var(--space-xs); + transition: border-color 150ms; +} + +.cat-playlist-select:hover { + border-color: var(--cat-accent); +} + +.cat-playlist-select option { + background: var(--cat-surface); + color: var(--cat-text); +} + +.cat-tabs { + align-items: center; + display: flex; + gap: 2px; +} + +.cat-tab { + align-items: center; + background: none; + border: none; + border-radius: var(--radius-md); + color: var(--cat-text-dim); + cursor: pointer; + display: flex; + font-family: inherit; + font-size: var(--fs-xs); + font-weight: 600; + gap: var(--space-2xs); + padding: var(--space-2xs) var(--space-xs); + transition: + color 150ms, + background 150ms; +} + +.cat-tab i { + font-size: 14px; +} + +.cat-tab:hover { + color: var(--cat-text); + background: var(--cat-surface-hover); +} + +.cat-tab[data-active="t"] { + color: var(--cat-accent); + background: oklch(from var(--cat-accent) l c h / 0.12); +} + +/*********************************** + * Body — content + queue + ***********************************/ + +.cat-body { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.cat-content { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: var(--space-sm); + scrollbar-color: var(--cat-border) transparent; + scrollbar-width: thin; +} + +.cat-content::-webkit-scrollbar { + width: 6px; +} + +.cat-content::-webkit-scrollbar-thumb { + background: var(--cat-border); + border-radius: 3px; +} + +.cat-queue { + border-left: 1px solid var(--cat-border); + display: flex; + flex-direction: column; + flex-shrink: 0; + width: 320px; +} + +.cat-queue[hidden] { + display: none; +} + +@media (max-width: 48rem) { + .cat-queue { + background: var(--cat-bg); + bottom: 0; + left: 0; + position: fixed; + right: 0; + top: 0; + width: 100%; + z-index: 30; + } +} + +/*********************************** + * Cover art grid + ***********************************/ + +.cat-grid { + display: grid; + gap: var(--space-xs); + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); +} + +@media (min-width: 70rem) { + .cat-grid { + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + } +} + +.cat-card { + background: var(--cat-surface); + border: none; + border-radius: var(--radius-lg); + color: inherit; + cursor: pointer; + overflow: hidden; + padding: 0; + transition: + transform 200ms, + box-shadow 200ms; + width: 100%; +} + +.cat-card:hover { + box-shadow: var(--cat-shadow); + transform: translateY(-2px); +} + +.cat-card__art { + aspect-ratio: 1; + background: var(--cat-surface-hover); + display: flex; + overflow: hidden; + position: relative; +} + +.cat-card__art img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.cat-card__art-placeholder { + align-items: center; + color: var(--cat-text-dim); + display: flex; + font-size: 32px; + height: 100%; + justify-content: center; + width: 100%; +} + +.cat-card__info { + padding: var(--space-2xs) var(--space-xs); +} + +.cat-card__title { + font-size: var(--fs-xs); + font-weight: 600; + line-height: 1.3; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cat-card__subtitle { + color: var(--cat-text-dim); + font-size: var(--fs-2xs); + line-height: 1.3; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/*********************************** + * Detail view (album/artist tracks) + ***********************************/ + +.cat-detail { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.cat-detail__header { + align-items: flex-end; + display: flex; + gap: var(--space-md); +} + +@media (max-width: 48rem) { + .cat-detail__header { + align-items: center; + flex-direction: column; + gap: var(--space-sm); + text-align: center; + } + + .cat-detail__art { + height: 140px; + width: 140px; + } + + .cat-detail__title { + white-space: normal; + } + + .cat-detail__actions { + justify-content: center; + flex-wrap: wrap; + } + + .cat-track__menu { + opacity: 1; + } + + .cat-track__index { + width: 1.5rem; + } + + .cat-track { + gap: var(--space-2xs); + padding: var(--space-2xs); + } +} + +.cat-detail__art { + aspect-ratio: 1; + background: var(--cat-surface); + border-radius: var(--radius-lg); + flex-shrink: 0; + height: 180px; + overflow: hidden; + width: 180px; +} + +.cat-detail__art img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.cat-detail__art-placeholder { + align-items: center; + color: var(--cat-text-dim); + display: flex; + font-size: 48px; + height: 100%; + justify-content: center; + width: 100%; +} + +.cat-detail__meta { + flex: 1; + min-width: 0; + padding-bottom: var(--space-2xs); +} + +.cat-detail__title { + font-size: var(--fs-lg); + font-weight: 700; + line-height: 1.2; + margin: 0 0 var(--space-2xs) 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cat-detail__subtitle { + color: var(--cat-text-dim); + font-size: var(--fs-sm); + margin: 0 0 var(--space-xs) 0; +} + +.cat-detail__actions { + display: flex; + gap: var(--space-2xs); +} + +.cat-btn { + align-items: center; + background: var(--cat-accent); + border: none; + border-radius: var(--radius-md); + color: var(--cat-accent-text); + cursor: pointer; + display: flex; + font-family: inherit; + font-size: var(--fs-xs); + font-weight: 600; + gap: var(--space-2xs); + padding: var(--space-2xs) var(--space-sm); + transition: opacity 150ms; +} + +.cat-btn:hover { + opacity: 0.85; +} + +.cat-btn--secondary { + background: var(--cat-surface-hover); + color: var(--cat-text); + border: 1px solid var(--cat-border); +} + +.cat-back { + align-items: center; + background: none; + border: none; + color: var(--cat-text-dim); + cursor: pointer; + display: flex; + font-family: inherit; + font-size: var(--fs-xs); + font-weight: 600; + gap: var(--space-2xs); + padding: 0; + transition: color 150ms; +} + +.cat-back:hover { + color: var(--cat-text); +} + +.cat-track-list { + display: flex; + flex-direction: column; + list-style: none; + margin: 0; + padding: 0; +} + +.cat-track { + align-items: center; + border-radius: var(--radius-md); + cursor: pointer; + display: flex; + gap: var(--space-xs); + padding: var(--space-2xs) var(--space-xs); + transition: background 100ms; +} + +.cat-track:hover { + background: var(--cat-surface); +} + +.cat-track--current { + color: var(--cat-accent); +} + +.cat-track__index { + color: var(--cat-text-dim); + font-size: var(--fs-xs); + font-variant-numeric: tabular-nums; + text-align: right; + width: 2rem; +} + +.cat-track--current .cat-track__index { + color: var(--cat-accent); +} + +.cat-track__info { + flex: 1; + min-width: 0; +} + +.cat-track__title { + font-size: var(--fs-xs); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cat-track__artist { + color: var(--cat-text-dim); + font-size: var(--fs-2xs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cat-track__duration { + color: var(--cat-text-dim); + flex-shrink: 0; + font-size: var(--fs-2xs); + font-variant-numeric: tabular-nums; +} + +.cat-track__menu { + background: none; + border: none; + color: var(--cat-text-dim); + cursor: pointer; + font-size: 14px; + opacity: 0; + padding: var(--space-2xs); + transition: + opacity 150ms, + color 150ms; +} + +.cat-track:hover .cat-track__menu { + opacity: 1; +} + +.cat-track__menu:hover { + color: var(--cat-text); +} + +/*********************************** + * Songs list (flat list view) + ***********************************/ + +.cat-songs { + list-style: none; + margin: 0; + padding: 0; +} + +/*********************************** + * Empty state + ***********************************/ + +.cat-empty { + align-items: center; + color: var(--cat-text-dim); + display: flex; + flex-direction: column; + font-size: var(--fs-sm); + gap: var(--space-sm); + height: 100%; + justify-content: center; + text-align: center; +} + +.cat-empty i { + font-size: 48px; + opacity: 0.5; +} + +/*********************************** + * Queue panel + ***********************************/ + +.cat-queue__header { + align-items: center; + background: var(--cat-surface); + border-bottom: 1px solid var(--cat-border); + display: flex; + flex-shrink: 0; + font-size: var(--fs-xs); + font-weight: 700; + justify-content: space-between; + letter-spacing: var(--tracking-wide); + padding: var(--space-xs) var(--space-sm); + text-transform: uppercase; +} + +.cat-queue__clear { + align-items: center; + background: none; + border: 1px solid var(--cat-border); + border-radius: var(--radius-sm); + color: var(--cat-text-dim); + cursor: pointer; + display: flex; + font-family: inherit; + font-size: var(--fs-2xs); + font-weight: 600; + gap: var(--space-3xs); + padding: var(--space-3xs) var(--space-2xs); + transition: + color 150ms, + border-color 150ms; +} + +.cat-queue__clear:hover { + border-color: var(--cat-accent); + color: var(--cat-accent); +} + +.cat-queue__header-actions { + align-items: center; + display: flex; + gap: var(--space-2xs); +} + +.cat-queue__close { + align-items: center; + background: none; + border: 1px solid var(--cat-border); + border-radius: var(--radius-sm); + color: var(--cat-text-dim); + cursor: pointer; + display: none; + font-size: 14px; + height: 28px; + justify-content: center; + padding: 0; + transition: + color 150ms, + border-color 150ms; + width: 28px; +} + +.cat-queue__close:hover { + border-color: var(--cat-accent); + color: var(--cat-accent); +} + +@media (max-width: 48rem) { + .cat-queue__close { + display: flex; + } +} + +.cat-queue__list { + flex: 1; + min-height: 0; + overflow-y: auto; + scrollbar-color: var(--cat-border) transparent; + scrollbar-width: thin; +} + +@media (max-width: 48rem) { + .cat-queue__list { + padding-bottom: 80px; + } +} + +.cat-queue__section { + padding: var(--space-xs) var(--space-sm); +} + +.cat-queue__section-title { + color: var(--cat-text-dim); + font-size: var(--fs-2xs); + font-weight: 700; + letter-spacing: var(--tracking-wide); + margin: 0 0 var(--space-2xs) 0; + text-transform: uppercase; +} + +.cat-queue__item { + align-items: center; + border-radius: var(--radius-sm); + cursor: pointer; + display: flex; + gap: var(--space-2xs); + padding: var(--space-2xs) var(--space-2xs); + transition: background 100ms; +} + +.cat-queue__item:hover { + background: var(--cat-surface); +} + +.cat-queue__item--current { + background: oklch(from var(--cat-accent) l c h / 0.1); +} + +.cat-queue__item-art { + background: var(--cat-surface-hover); + border-radius: var(--radius-xs); + flex-shrink: 0; + height: 32px; + overflow: hidden; + width: 32px; +} + +.cat-queue__item-art img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.cat-queue__item-art-placeholder { + align-items: center; + display: flex; + height: 100%; + justify-content: center; + width: 100%; +} + +.cat-queue__item-art-placeholder i { + color: var(--cat-text-dim); + font-size: 12px; +} + +.cat-queue__item-info { + flex: 1; + min-width: 0; +} + +.cat-queue__item-title { + font-size: var(--fs-xs); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cat-queue__item-artist { + color: var(--cat-text-dim); + font-size: var(--fs-2xs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/*********************************** + * Player bar + ***********************************/ + +.cat-player { + align-items: center; + background: var(--cat-surface); + border-top: 1px solid var(--cat-border); + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + gap: var(--space-xs) var(--space-md); + min-height: 64px; + padding: var(--space-xs) var(--space-sm); +} + +@media (max-width: 48rem) { + .cat-player { + flex-wrap: wrap; + gap: var(--space-2xs); + padding: var(--space-2xs) var(--space-xs); + position: relative; + z-index: 40; + } + + .cat-player__info { + display: none; + } + + .cat-player__time { + display: none; + } + + .cat-player__progress { + flex-basis: 100%; + order: 1; + } + + .cat-player__seek { + height: 3px; + } + + .cat-player__art { + height: 40px; + width: 40px; + } + + .cat-player__art-placeholder i { + font-size: 14px; + } + + .cat-player__controls { + gap: var(--space-3xs); + } + + .cat-player__btn { + font-size: 14px; + padding: var(--space-3xs); + } + + .cat-player__btn--play { + height: 28px; + width: 28px; + font-size: 16px; + } +} + +.cat-player__art { + background: var(--cat-surface-hover); + border-radius: var(--radius-md); + flex-shrink: 0; + height: 48px; + overflow: hidden; + width: 48px; +} + +.cat-player__art img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.cat-player__art-placeholder { + align-items: center; + display: flex; + height: 100%; + justify-content: center; + width: 100%; +} + +.cat-player__art-placeholder i { + color: var(--cat-text-dim); + font-size: 16px; +} + +.cat-player__info { + flex-shrink: 0; + min-width: 0; + width: 180px; +} + +.cat-player__title { + font-size: var(--fs-xs); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cat-player__artist { + color: var(--cat-text-dim); + font-size: var(--fs-2xs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cat-player__controls { + align-items: center; + display: flex; + gap: var(--space-2xs); + justify-content: center; +} + +.cat-player__btn { + align-items: center; + background: none; + border: none; + color: var(--cat-text); + cursor: pointer; + display: flex; + font-size: 16px; + justify-content: center; + padding: var(--space-2xs); + transition: color 100ms; +} + +.cat-player__btn:hover { + color: var(--cat-accent); +} + +.cat-player__btn--play { + background: var(--cat-text); + border-radius: 50%; + color: var(--cat-bg); + font-size: 18px; + height: 32px; + width: 32px; +} + +.cat-player__btn--play:hover { + background: var(--cat-accent); + color: var(--cat-accent-text); +} + +.cat-player__btn--active { + color: var(--cat-accent); +} + +.cat-player__progress { + align-items: center; + display: flex; + flex: 1; + gap: var(--space-2xs); + min-width: 0; +} + +.cat-player__seek { + appearance: none; + background: var(--cat-border); + border-radius: 2px; + cursor: pointer; + flex: 1; + height: 4px; + margin: 0; + min-width: 30px; + outline: none; + position: relative; +} + +.cat-player__seek::-webkit-slider-thumb { + appearance: none; + background: var(--cat-accent); + border-radius: 50%; + height: 12px; + transition: transform 100ms; + width: 12px; +} + +.cat-player__seek::-moz-range-thumb { + background: var(--cat-accent); + border: none; + border-radius: 50%; + height: 12px; + width: 12px; +} + +.cat-player__time { + color: var(--cat-text-dim); + flex-shrink: 0; + font-size: var(--fs-2xs); + font-variant-numeric: tabular-nums; + min-width: 2.5rem; + text-align: center; +} + +.cat-player__time--remaining { + text-align: left; +} + +/*********************************** + * Queue toggle button + ***********************************/ + +.cat-queue-toggle { + align-items: center; + background: var(--cat-surface); + border: 1px solid var(--cat-border); + border-radius: var(--radius-lg); + bottom: calc(64px + var(--space-sm)); + color: var(--cat-text-dim); + cursor: pointer; + display: flex; + font-size: 18px; + height: 40px; + justify-content: center; + padding: 0; + position: fixed; + right: var(--space-sm); + transition: + color 150ms, + border-color 150ms; + width: 40px; + z-index: 40; +} + +.cat-queue-toggle:hover { + border-color: var(--cat-accent); + color: var(--cat-accent); +} + +.cat-queue-toggle[data-active="t"] { + border-color: var(--cat-accent); + color: var(--cat-accent); +} + +.cat-queue-toggle__badge { + background: var(--cat-accent); + border-radius: 50%; + color: var(--cat-accent-text); + font-size: 9px; + font-weight: 700; + height: 16px; + line-height: 16px; + position: absolute; + right: -4px; + text-align: center; + top: -4px; + width: 16px; +} + +/*********************************** + * Context menu + ***********************************/ + +.cat-menu { + background: var(--cat-surface); + border: 1px solid var(--cat-border); + border-radius: var(--radius-lg); + box-shadow: var(--cat-shadow); + display: flex; + flex-direction: column; + gap: 2px; + min-width: 200px; + padding: var(--space-2xs); + position: fixed; + z-index: 100; +} + +.cat-menu__item { + align-items: center; + background: none; + border: none; + border-radius: var(--radius-sm); + color: var(--cat-text); + cursor: pointer; + display: flex; + font-family: inherit; + font-size: var(--fs-xs); + gap: var(--space-2xs); + padding: var(--space-2xs) var(--space-xs); + text-align: left; + transition: background 100ms; + width: 100%; +} + +.cat-menu__item:hover { + background: var(--cat-surface-hover); +} + +.cat-menu__item i { + color: var(--cat-text-dim); + font-size: 14px; +} diff --git a/src/facets/themes/catalogue-one/facet/index.html b/src/facets/themes/catalogue-one/facet/index.html new file mode 100644 index 00000000..6057e70d --- /dev/null +++ b/src/facets/themes/catalogue-one/facet/index.html @@ -0,0 +1,51 @@ + + + + + + +
+
+
+
+ + +
+ +
+ + +
+ +
+
+ +
+ + +
+ + + + diff --git a/src/facets/themes/catalogue-one/facet/index.inline.js b/src/facets/themes/catalogue-one/facet/index.inline.js new file mode 100644 index 00000000..76c3d645 --- /dev/null +++ b/src/facets/themes/catalogue-one/facet/index.inline.js @@ -0,0 +1,1191 @@ +import { html, render as litRender } from "lit-html"; + +import foundation from "~/common/foundation.js"; +import { computed, effect, signal } from "~/common/signal.js"; +import * as Playlist from "~/common/playlist.js"; + +/** + * @import { Track } from "~/definitions/types.d.ts" + * @import { Item as QueueItem } from "@specs/components/engine/queue/types.d.ts" + */ + +// Set doc title +foundation.setup({ title: "Catalogue One | Diffuse" }); + +//////////////////////////////////////////// +// SETUP — engines & orchestrators +//////////////////////////////////////////// + +const [queue, repeatShuffle] = await Promise.all([ + foundation.engine.queue(), + foundation.engine.repeatShuffle(), +]); + +await Promise.all([ + customElements.whenDefined(queue.localName), + customElements.whenDefined(repeatShuffle.localName), +]); + +const scope = await foundation.engine.scope(); +const scopedTracks = await foundation.orchestrator.scopedTracks(); +const output = await foundation.orchestrator.output(); +await foundation.orchestrator.sources(); +await foundation.orchestrator.processTracks({ disableWhenReady: true }); +await foundation.orchestrator.queueAudio(); +const controller = await foundation.orchestrator.controller(); +const artwork = await foundation.orchestrator.artwork(); +const coverGroups = await foundation.orchestrator.coverGroups(); +const favourites = await foundation.orchestrator.favourites(); +await foundation.orchestrator.mediaSession(); +await foundation.configurator.input(); + +/** @type {import("~/components/orchestrator/controller/element.js").default} */ +const c = controller; + +await Promise.all([ + customElements.whenDefined(scopedTracks.localName), + customElements.whenDefined(output.localName), + customElements.whenDefined(controller.localName), + customElements.whenDefined(artwork.localName), + customElements.whenDefined(coverGroups.localName), + customElements.whenDefined(favourites.localName), +]); + +//////////////////////////////////////////// +// DOM ELEMENTS +//////////////////////////////////////////// + +const el = { + content: /** @type {HTMLElement} */ (document.querySelector("#content")), + queuePanel: + /** @type {HTMLElement} */ (document.querySelector("#queue-panel")), + queueToggle: /** @type {HTMLButtonElement} */ ( + document.querySelector("#queue-toggle") + ), + playerBar: /** @type {HTMLElement} */ (document.querySelector("#player-bar")), + search: /** @type {HTMLInputElement} */ (document.querySelector("#search")), + playlistSelect: /** @type {HTMLSelectElement} */ ( + document.querySelector("#playlist-select") + ), + tabs: /** @type {NodeListOf} */ ( + document.querySelectorAll(".cat-tab") + ), +}; + +//////////////////////////////////////////// +// STATE +//////////////////////////////////////////// + +const viewMode = signal( + /** @type {"albums" | "artists" | "songs"} */ ("albums"), +); + +const detailItem = signal( + /** @type {{ type: "album"; key: string } | { type: "artist"; key: string } | null} */ ( + null + ), +); + +const queueOpen = signal(false); + +//////////////////////////////////////////// +// HELPERS +//////////////////////////////////////////// + +/** @param {Track} t */ +const artistOf = (t) => + t.tags?.artist ?? t.tags?.albumartist ?? "Unknown Artist"; +/** @param {Track} t */ +const albumOf = (t) => t.tags?.album ?? "Unknown Album"; +/** @param {Track} t */ +const titleOf = (t) => t.tags?.title ?? t.id; + +/** @param {number} seconds */ +const 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 {Track} t */ +const trackDuration = (t) => { + const ms = t.stats?.duration; + if (!ms) return ""; + return formatTime(ms / 1000); +}; + +/** @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"; +} + +//////////////////////////////////////////// +// COMPUTED — tracks, groups, playlists +//////////////////////////////////////////// + +/** All tracks from the output collection. */ +const allTracks = computed(() => { + const col = output.tracks.collection(); + return col.state === "loaded" ? col.data : []; +}); + +/** Scoped + filtered tracks. */ +const tracks = computed(() => scopedTracks.tracks() ?? []); + +/** Playlists grouped by name. */ +const playlists = computed(() => { + const col = output.playlistItems.collection(); + if (col.state !== "loaded") return []; + const map = Playlist.gather(col.data); + return [...map.values()].sort((a, b) => a.name.localeCompare(b.name)); +}); + +/** Flat album list deduplicated from scoped tracks. */ +const albums = computed(() => { + const list = tracks(); + /** @type {Map} */ + const map = new Map(); + for (const t of list) { + const key = albumOf(t).toLowerCase(); + const existing = map.get(key); + if (existing) { + existing.count++; + } else { + map.set(key, { + key, + name: albumOf(t), + artist: artistOf(t), + track: t, + count: 1, + }); + } + } + return [...map.values()].sort((a, b) => a.name.localeCompare(b.name)); +}); + +/** Flat artist list deduplicated from scoped tracks. */ +const artists = computed(() => { + const list = tracks(); + /** @type {Map} */ + const map = new Map(); + for (const t of list) { + const key = artistOf(t).toLowerCase(); + const existing = map.get(key); + if (existing) { + existing.count++; + } else { + map.set(key, { + key, + name: artistOf(t), + track: t, + count: 1, + }); + } + } + return [...map.values()].sort((a, b) => a.name.localeCompare(b.name)); +}); + +/** Tracks for the currently-open detail view. */ +const detailTracks = computed(() => { + const item = detailItem.value; + if (!item) return []; + + const list = tracks(); + if (item.type === "album") { + return list + .filter((t) => albumOf(t).toLowerCase() === item.key) + .sort((a, b) => { + const da = a.tags?.disc?.no ?? 0; + const db = b.tags?.disc?.no ?? 0; + if (da !== db) return da - db; + const ta = a.tags?.track?.no ?? 0; + const tb = b.tags?.track?.no ?? 0; + if (ta !== tb) return ta - tb; + return titleOf(a).localeCompare(titleOf(b)); + }); + } else { + return list + .filter((t) => artistOf(t).toLowerCase() === item.key) + .sort((a, b) => { + const aa = albumOf(a).toLowerCase(); + const ab = albumOf(b).toLowerCase(); + if (aa !== ab) return aa.localeCompare(ab); + const da = a.tags?.disc?.no ?? 0; + const db = b.tags?.disc?.no ?? 0; + if (da !== db) return da - db; + const ta = a.tags?.track?.no ?? 0; + const tb = b.tags?.track?.no ?? 0; + if (ta !== tb) return ta - tb; + return titleOf(a).localeCompare(titleOf(b)); + }); + } +}); + +/** Queue items: [past..., now, future...] */ +const queueItems = computed(() => { + const past = queue.past(); + const now = queue.now(); + const future = queue.future(); + /** @type {(QueueItem & { state: "past" | "now" | "future" })[]} */ + const items = []; + for (const p of past) items.push({ ...p, state: "past" }); + if (now) items.push({ ...now, state: "now" }); + for (const f of future) items.push({ ...f, state: "future" }); + return items; +}); + +/** Total queue count for the badge. */ +const queueCount = computed(() => { + return queue.future().length + (queue.now() ? 1 : 0); +}); + +//////////////////////////////////////////// +// ARTWORK CACHE +//////////////////////////////////////////// + +/** @type {Map} */ +const artCache = new Map(); +/** @type {Set} */ +const pendingArt = new Set(); +/** @type {{ key: string; track: Track }[]} */ +const artQueue = []; +let artActive = 0; +const MAX_ART_CONCURRENT = 4; + +/** + * Fetch artwork for a given key (album or artist key) using a representative track. + * Results are cached as blob URLs (or null if no art found). + * @param {string} key + * @param {Track} track + */ +function fetchArt(key, track) { + if (artCache.has(key)) return; + if (pendingArt.has(key)) return; + pendingArt.add(key); + artQueue.push({ key, track }); + drainArtQueue(); +} + +function drainArtQueue() { + while (artActive < MAX_ART_CONCURRENT && artQueue.length > 0) { + const job = artQueue.shift(); + if (!job) break; + artActive++; + doFetchArt(job.key, job.track); + } +} + +/** + * @param {string} key + * @param {Track} track + */ +async function doFetchArt(key, track) { + try { + const timeout = new Promise((resolve) => + setTimeout(() => resolve(null), 15_000) + ); + const bytes = await Promise.race([artwork.get(track), timeout]); + if (bytes) { + const mime = detectMime(bytes); + const url = URL.createObjectURL(new Blob([bytes], { type: mime })); + artCache.set(key, url); + } else { + artCache.set(key, null); + } + } catch { + // don't cache on error — allow retry + } finally { + pendingArt.delete(key); + artActive--; + drainArtQueue(); + } + scheduleArtRender(); +} + +/** Re-render to show newly loaded artwork. */ +let artRenderRaf = false; +function scheduleArtRender() { + if (artRenderRaf) return; + artRenderRaf = true; + requestAnimationFrame(() => { + artRenderRaf = false; + renderContent(); + }); +} + +//////////////////////////////////////////// +// PLAYBACK ACTIONS +//////////////////////////////////////////// + +const playPause = () => { + const audioId = c.$queue.value?.now()?.id; + if (!audioId) return; + if (c.isPlaying()) c.$audio.value?.pause({ audioId }); + else c.$audio.value?.play({ audioId }); +}; + +const next = () => c.$queue.value?.shift(); +const previous = () => c.$queue.value?.unshift(); + +/** Play a track immediately. @param {string} trackId */ +const playTrack = (trackId) => { + const q = c.$queue.value; + if (!q) return; + q.add({ inFront: true, trackIds: [trackId] }); + q.shift(); +}; + +/** Play a list of tracks, replacing the upcoming queue. @param {string[]} trackIds */ +const playTracks = (trackIds) => { + if (trackIds.length === 0) return; + const q = c.$queue.value; + if (!q) return; + q.add({ inFront: true, trackIds }); + q.shift(); +}; + +/** Append tracks to the end of the queue. @param {string[]} trackIds */ +const addToQueue = (trackIds) => { + if (trackIds.length === 0) return; + const q = c.$queue.value; + if (!q) return; + q.add({ trackIds }); +}; + +/** Jump to a flat queue index. @param {number} idx */ +const playAtQueueIndex = (idx) => { + const q = c.$queue.value; + if (!q) return; + const pastLen = queue.past().length; + if (idx === pastLen) { + const audioId = queue.now()?.id; + if (audioId) { + c.$audio.value?.seek({ audioId, currentTime: 0 }); + c.$audio.value?.play({ audioId }); + } + return; + } + if (idx < pastLen) { + for (let i = 0; i < pastLen - idx; i++) q.unshift(); + } else { + for (let i = 0; i < idx - pastLen; i++) q.shift(); + } +}; + +/** @param {number} percentage 0..1 */ +const seekTo = (percentage) => { + const audioId = c.$queue.value?.now()?.id; + if (audioId) c.$audio.value?.seek({ audioId, percentage }); +}; + +/** Toggle favourite on a track. @param {Track} track */ +const toggleFavourite = (track) => { + favourites.toggle(track); +}; + +/** Find a track by ID from the full collection. @param {string} id */ +const findTrack = (id) => allTracks().find((t) => t.id === id); + +/** Is this track currently playing? @param {string} trackId */ +const isCurrentTrack = (trackId) => { + const now = queue.now(); + return !!now && now.id === trackId; +}; + +//////////////////////////////////////////// +// CONTEXT MENU +//////////////////////////////////////////// + +/** @type {{ x: number; y: number; track: Track } | null} */ +let contextMenuState = null; + +/** + * @param {number} x + * @param {number} y + * @param {Track} track + */ +function openContextMenu(x, y, track) { + contextMenuState = { x, y, track }; + renderContextMenu(); +} + +function closeContextMenu() { + contextMenuState = null; + renderContextMenu(); +} + +let contextMenuEl = /** @type {HTMLDivElement | null} */ (null); + +function renderContextMenu() { + if (contextMenuEl) { + contextMenuEl.remove(); + contextMenuEl = null; + } + if (!contextMenuState) return; + + const { x, y, track } = contextMenuState; + const isFav = favourites.isFavourite(track); + + contextMenuEl = document.createElement("div"); + contextMenuEl.className = "cat-menu"; + contextMenuEl.style.left = `${Math.min(x, globalThis.innerWidth - 220)}px`; + contextMenuEl.style.top = `${Math.min(y, globalThis.innerHeight - 200)}px`; + + litRender( + html` + + + + `, + contextMenuEl, + ); + + document.body.appendChild(contextMenuEl); +} + +// Close context menu on outside click +document.addEventListener("click", (e) => { + if ( + contextMenuEl && !contextMenuEl.contains(/** @type {Node} */ (e.target)) + ) { + closeContextMenu(); + } +}); + +document.addEventListener("keydown", (e) => { + if (e.key === "Escape") closeContextMenu(); +}); + +//////////////////////////////////////////// +// RENDER — content area +//////////////////////////////////////////// + +const artPlaceholder = html` +
+ +
+`; + +/** + * @param {string} key + * @param {Track} track + */ +function artBlock(key, track) { + fetchArt(key, track); + const url = artCache.get(key); + if (url) { + return html``; + } + return artPlaceholder; +} + +/** + * @param {string} key + * @param {Track} track + */ +function detailArtBlock(key, track) { + fetchArt(key, track); + const url = artCache.get(key); + if (url) { + return html``; + } + return html` +
+ +
+ `; +} + +function renderAlbumGrid() { + const list = albums(); + if (list.length === 0) return renderEmpty(); + + return html` +
+ ${list.map( + (album) => + html` + + `, + )} +
+ `; +} + +function renderArtistGrid() { + const list = artists(); + if (list.length === 0) return renderEmpty(); + + return html` +
+ ${list.map( + (artist) => + html` + + `, + )} +
+ `; +} + +function renderSongList() { + const list = tracks(); + if (list.length === 0) return renderEmpty(); + + return html` +
    + ${list.map( + (track, i) => + html` +
  • playTrack(track.id)} + @contextmenu=${/** @param {MouseEvent} e */ (e) => { + e.preventDefault(); + openContextMenu(e.clientX, e.clientY, track); + }} + > + ${i + 1} +
    +
    ${titleOf(track)}
    +
    ${artistOf(track)}
    +
    + ${trackDuration(track)} + +
  • + `, + )} +
+ `; +} + +function renderDetailView() { + const item = detailItem.value; + if (!item) return null; + + const list = detailTracks(); + if (list.length === 0) { + detailItem.value = null; + return null; + } + + const isAlbum = item.type === "album"; + const title = isAlbum ? albumOf(list[0]) : artistOf(list[0]); + const subtitle = isAlbum + ? artistOf(list[0]) + : `${list.length} track${list.length !== 1 ? "s" : ""}`; + + const artKey = item.key; + const firstTrack = list[0]; + const trackIds = list.map((t) => t.id); + + return html` +
+ + +
+
+ ${detailArtBlock(artKey, firstTrack)} +
+
+

${title}

+

${subtitle}

+
+ + +
+
+
+ +
    + ${list.map( + (track, i) => + html` +
  • playTrack(track.id)} + @contextmenu=${/** @param {MouseEvent} e */ (e) => { + e.preventDefault(); + openContextMenu(e.clientX, e.clientY, track); + }} + > + ${i + 1} +
    +
    ${titleOf(track)}
    + ${!isAlbum + ? html`
    ${ + albumOf(track) + }
    ` + : null} +
    + ${trackDuration(track)} + +
  • + `, + )} +
+
+ `; +} + +function renderEmpty() { + const term = scope.searchTerm(); + const playlist = scope.playlist(); + let msg = "No tracks found.\nAdd an audio source to get started."; + if (term) msg = `No tracks match "${term}".`; + else if (playlist) msg = "This playlist is empty."; + + return html` +
+ +

${msg}

+
+ `; +} + +function renderContent() { + const detail = detailItem.value; + const mode = viewMode.value; + + // Read scoped tracks to register signal dependency + const _tracks = tracks(); + void _tracks; + + if (detail) { + litRender(renderDetailView(), el.content); + return; + } + + if (mode === "albums") litRender(renderAlbumGrid(), el.content); + else if (mode === "artists") litRender(renderArtistGrid(), el.content); + else litRender(renderSongList(), el.content); +} + +//////////////////////////////////////////// +// RENDER — queue panel +//////////////////////////////////////////// + +function renderQueue() { + const items = queueItems(); + const past = items.filter((i) => i.state === "past"); + const now = items.filter((i) => i.state === "now"); + const future = items.filter((i) => i.state === "future"); + + /** + * @param {QueueItem & { state: "past" | "now" | "future" }} item + */ + const renderItem = (item) => { + const track = findTrack(item.id); + const title = track ? titleOf(track) : item.id; + const artist = track ? artistOf(track) : ""; + const flatIndex = items.indexOf(item); + + return html` +
playAtQueueIndex(flatIndex)} + > +
+ ${track + ? (() => { + const key = albumOf(track).toLowerCase(); + fetchArt(key, track); + const url = artCache.get(key); + return url ? html`` : html` +
+ `; + })() + : html` +
+ `} +
+
+
${title}
+
${artist}
+
+ ${item.state === "now" + ? html`` + : null} +
+ `; + }; + + return html` +
+ Queue +
+ ${future.length > 0 + ? html` + + ` + : null} + +
+
+
+ ${now.length > 0 + ? html` +
+

Now Playing

+ ${now.map(renderItem)} +
+ ` + : null} + ${future.length > 0 + ? html` +
+

Up Next

+ ${future.map(renderItem)} +
+ ` + : null} + ${past.length > 0 + ? html` +
+

History

+ ${past.map(renderItem)} +
+ ` + : null} + ${items.length === 0 + ? html` +
+

Queue is empty

+
+ ` + : null} +
+ `; +} + +function renderQueuePanel() { + const isOpen = queueOpen.value; + el.queuePanel.hidden = !isOpen; + el.queueToggle.setAttribute("data-active", isOpen ? "t" : "f"); + if (!isOpen) return; + litRender(renderQueue(), el.queuePanel); +} + +//////////////////////////////////////////// +// RENDER — player bar +//////////////////////////////////////////// + +function renderPlayer() { + const track = c.currentTrack(); + const audio = c.audio(); + const playing = c.isPlaying(); + const hasTrack = !!c.$queue.value?.now(); + + const currentTime = audio?.currentTime() ?? 0; + const duration = track?.stats?.duration + ? track.stats.duration / 1000 + : (audio?.duration() ?? 0); + const progress = duration > 0 ? currentTime / duration : 0; + + const repeat = repeatShuffle.repeat(); + const shuffle = repeatShuffle.shuffle(); + + const artKey = track ? albumOf(track).toLowerCase() : ""; + const artUrl = track ? artCache.get(artKey) : undefined; + + litRender( + html` +
+ ${hasTrack && track + ? (artUrl ? html`` : html` +
+ `) + : html` +
+ `} +
+ +
+
${hasTrack && track + ? titleOf(track) + : "Nothing playing"}
+
${hasTrack && track + ? artistOf(track) + : ""}
+
+ +
+ + + + + +
+ +
+ ${formatTime(currentTime)} + { + const val = parseFloat( + /** @type {HTMLInputElement} */ (e.target).value, + ); + seekTo(val / 100); + }} + /> + + ${duration > 0 ? formatTime(duration) : "0:00"} + +
+ `, + el.playerBar, + ); +} + +//////////////////////////////////////////// +// RENDER — playlist select +//////////////////////////////////////////// + +function renderPlaylistSelect() { + const list = playlists(); + const current = scope.playlist(); + + litRender( + html` + + ${list.map( + (p) => + html``, + )} + `, + el.playlistSelect, + ); +} + +//////////////////////////////////////////// +// TABS +//////////////////////////////////////////// + +function updateTabs() { + const mode = viewMode.value; + el.tabs.forEach((tab) => { + tab.setAttribute("data-active", tab.dataset.view === mode ? "t" : "f"); + }); +} + +el.tabs.forEach((tab) => { + tab.addEventListener("click", () => { + viewMode.value = /** @type {"albums" | "artists" | "songs"} */ ( + tab.dataset.view + ); + detailItem.value = null; + }); +}); + +//////////////////////////////////////////// +// SEARCH +//////////////////////////////////////////// + +/** @type {ReturnType} */ +let searchDebounce; +el.search.addEventListener("input", () => { + clearTimeout(searchDebounce); + const value = el.search.value; + searchDebounce = setTimeout(() => { + scope.setSearchTerm(value || undefined); + }, 250); +}); + +// Restore saved search term +const savedSearch = scope.searchTerm(); +if (savedSearch) el.search.value = savedSearch; + +//////////////////////////////////////////// +// PLAYLIST SELECT +//////////////////////////////////////////// + +el.playlistSelect.addEventListener("change", () => { + const value = el.playlistSelect.value; + scope.setPlaylist(value || undefined); + detailItem.value = null; +}); + +//////////////////////////////////////////// +// QUEUE TOGGLE +//////////////////////////////////////////// + +el.queueToggle.addEventListener("click", () => { + queueOpen.value = !queueOpen.value; +}); + +//////////////////////////////////////////// +// KEYBOARD SHORTCUTS +//////////////////////////////////////////// + +document.addEventListener("keydown", (e) => { + // Ignore when typing in inputs + const tag = (e.target instanceof HTMLElement) ? e.target.tagName : ""; + if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return; + + if (e.key === " ") { + e.preventDefault(); + playPause(); + } else if (e.key === "ArrowRight" && e.shiftKey) { + next(); + } else if (e.key === "ArrowLeft" && e.shiftKey) { + previous(); + } +}); + +//////////////////////////////////////////// +// EFFECTS — re-render on signal changes +//////////////////////////////////////////// + +// Content area +effect(() => { + // Register dependencies + viewMode.value; + detailItem.value; + const _tracks = tracks(); + const _albums = albums(); + const _artists = artists(); + void _tracks; + void _albums; + void _artists; + renderContent(); +}); + +// Tabs +effect(() => { + viewMode.value; + updateTabs(); +}); + +// Queue panel +effect(() => { + queueOpen.value; + const _items = queueItems(); + void _items; + renderQueuePanel(); +}); + +// Queue toggle badge +effect(() => { + const count = queueCount(); + const existing = el.queueToggle.querySelector(".cat-queue-toggle__badge"); + if (count > 0) { + if (!existing) { + const badge = document.createElement("span"); + badge.className = "cat-queue-toggle__badge"; + el.queueToggle.append(badge); + } + const badge = el.queueToggle.querySelector(".cat-queue-toggle__badge"); + if (badge) badge.textContent = String(count); + } else { + if (existing) existing.remove(); + } +}); + +// Player bar +effect(() => { + const _track = c.currentTrack(); + const _playing = c.isPlaying(); + const _audio = c.audio(); + const _repeat = repeatShuffle.repeat(); + const _shuffle = repeatShuffle.shuffle(); + void _track; + void _playing; + void _audio; + void _repeat; + void _shuffle; + renderPlayer(); +}); + +// Playlist select +effect(() => { + const _playlists = playlists(); + void _playlists; + renderPlaylistSelect(); +}); + +// Auto-fetch artwork for currently playing track +effect(() => { + const track = c.currentTrack(); + if (track) { + const key = albumOf(track).toLowerCase(); + fetchArt(key, track); + } +}); + +//////////////////////////////////////////// +// BOOT — auto-fill queue when empty +//////////////////////////////////////////// + +let autoFilled = false; + +effect(() => { + const fingerprint = queue.supplyFingerprint(); + const now = queue.now(); + const future = queue.future(); + + if (fingerprint === undefined) return; + if (now !== null || future.length > 0) return; + if (autoFilled) return; + + const all = scopedTracks.tracks(); + if (all.length === 0) return; + + autoFilled = true; + queue.supply({ trackIds: all.map((t) => t.id) }); + queue.fill({ augment: true, amount: 50, shuffled: true }); +}); + +//////////////////////////////////////////// +// 🚀 +//////////////////////////////////////////// + +foundation.ready(); diff --git a/src/facets/themes/catalogue-one/variables.css b/src/facets/themes/catalogue-one/variables.css new file mode 100644 index 00000000..b13189cc --- /dev/null +++ b/src/facets/themes/catalogue-one/variables.css @@ -0,0 +1,12 @@ +:root, +:host { + --cat-bg: oklch(14% 0.01 280); + --cat-surface: oklch(20% 0.012 280); + --cat-surface-hover: oklch(25% 0.012 280); + --cat-text: oklch(88% 0.005 280); + --cat-text-dim: oklch(58% 0.008 280); + --cat-accent: oklch(68% 0.18 300); + --cat-accent-text: oklch(98% 0 0); + --cat-border: oklch(30% 0.01 280); + --cat-shadow: 0 8px 32px oklch(0% 0 0 / 0.4); +}