diff --git a/src/_data/facets.json b/src/_data/facets.json index ccd06ab5..56474701 100644 --- a/src/_data/facets.json +++ b/src/_data/facets.json @@ -34,6 +34,14 @@ "category": "Browsing", "desc": "Collection browser and search with favourite toggling, date grouping, and virtual scrolling." }, + { + "url": "facets/themes/blur-classic/facet/index.html", + "title": "Blur Classic", + "category": "Themes", + "featured": true, + "author": "A theme by [@tokono.ma](https://bsky.app/profile/tokono.ma)", + "desc": "The classic Diffuse browser and playback console on top of the Blur background system. A nostalgic, minimal audio bar with light indicators." + }, { "url": "facets/data/cache-tracks/index.html", "title": "Cache Tracks", diff --git a/src/facets/themes/blur-classic/controller/element.css b/src/facets/themes/blur-classic/controller/element.css new file mode 100644 index 00000000..00bea5a6 --- /dev/null +++ b/src/facets/themes/blur-classic/controller/element.css @@ -0,0 +1,160 @@ +:host { + --transition-durition: 750ms; + container-type: inline-size; + display: block; + font-size: var(--fs-xs); + font-size: calc(var(--fs-sm) * 0.85); +} + +main { + color: white; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + transition: + color var(--transition-durition), + opacity var(--transition-durition); +} + +/* Now playing */ + +.now-playing { + color: white; + font-style: italic; + font-weight: 400; + line-height: var(--leading-normal); + margin: var(--space-3xs) 0 0; + overflow: hidden; + padding: var(--space-sm) 0 var(--space-xs); + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} + +/* Progress bar */ + +.progress { + cursor: pointer; + padding: var(--space-2xs) 0; +} + +.progress__track { + background-color: rgba(255, 255, 255, 0.25); + border-radius: var(--radius-sm); + height: 3px; + overflow: hidden; +} + +.progress__fill { + background-color: rgba(255, 255, 255, 0.325); + height: 3px; + transition: width 250ms linear; +} + +/* Controls */ + +.controls { + align-items: center; + color: rgba(255, 255, 255, 0.9); + display: flex; + justify-content: space-between; + margin: var(--space-2xs); + padding: 0; + user-select: none; + + @container (min-width: 28em) { + justify-content: center; + } +} + +.control-btn { + align-items: center; + background: none; + border: 0; + color: inherit; + cursor: pointer; + display: flex; + flex-direction: column; + font-family: inherit; + margin: 0 var(--space-sm); + padding: 0 var(--space-3xs); + + @container (min-width: 42em) { + margin: 0 var(--space-md); + } +} + +.control-btn__icon { + align-items: center; + display: flex; + height: 20px; + justify-content: center; + margin: var(--space-2xs) 0; + + i { + line-height: 0; + } +} + +.control-btn__icon i.ph-repeat, +.control-btn__icon i.ph-shuffle { + font-size: 110%; +} + +.control-btn__icon i.ph-rewind, +.control-btn__icon i.ph-fast-forward { + font-size: 110%; +} + +/* Light indicators */ + +.light { + height: 4px; +} + +.light--placeholder { + height: 4px; + width: 0; +} + +.light--small { + border-radius: 50%; + height: 4px; + width: 4px; + background-color: rgba(255, 255, 255, 0.25); +} + +.light--small.light--on-blue { + background-color: rgb(157, 174, 255); +} + +.light--large { + border-radius: var(--radius-sm); + height: 4px; + position: relative; + width: 17px; + left: -2px; + background-color: rgba(255, 255, 255, 0.25); +} + +.light--large.light--on-green { + background-color: rgb(198, 254, 153); +} + +/* Play text */ + +.play-text { + font-family: Arial, ui-sans-serif, system-ui, sans-serif; + font-size: 85%; + font-weight: 700; + letter-spacing: 4px; + white-space: nowrap; +} + +/* Loading spinner */ + +.menu__loader { + cursor: pointer; +} diff --git a/src/facets/themes/blur-classic/controller/element.js b/src/facets/themes/blur-classic/controller/element.js new file mode 100644 index 00000000..89c94c7e --- /dev/null +++ b/src/facets/themes/blur-classic/controller/element.js @@ -0,0 +1,304 @@ +import { + defineElement, + DiffuseElement, + query, + whenElementsDefined, +} from "~/common/element.js"; + +import { signal } from "~/common/signal.js"; + +/** + * @import {RenderArg} from "~/common/element.d.ts" + * + * @import ControllerOrchestrator from "~/components/orchestrator/controller/element.js" + * @import RepeatShuffleEngine from "~/components/engine/repeat-shuffle/element.js" + */ + +/** + * Classic audio controller — a faithful recreation of the classic Diffuse + * console (commit 01ea0c8472b67187d03669327b353d9625c04ef0). + * + * Renders a now-playing label, a thin progress bar, and a row of transport + * buttons with light indicators above each one. No artwork, no volume slider, + * no favourites — just the essentials, exactly like the original. + */ +class ClassicController extends DiffuseElement { + static observedAttributes = ["group-label"]; + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + } + + // VARIABLES + + /** @type {ReturnType | undefined} */ + #isLoadingTimeout = undefined; + + // SIGNALS + + #audioError = signal(false); + #isLoading = signal(true); + + // SIGNALS - DEPENDENCIES + + $controller = signal( + /** @type {ControllerOrchestrator | undefined} */ (undefined), + ); + $repeatShuffle = signal( + /** @type {RepeatShuffleEngine | undefined} */ (undefined), + ); + + // SIGNALS - COMPUTED + + audio = () => this.$controller.value?.audio(); + currentTrack = () => this.$controller.value?.currentTrack(); + isPlaying = () => this.$controller.value?.isPlaying(); + + // LIFECYCLE + + /** + * @override + */ + connectedCallback() { + super.connectedCallback(); + + /** @type {ControllerOrchestrator} */ + const controller = query(this, "controller-orchestrator-selector"); + + /** @type {RepeatShuffleEngine} */ + const repeatShuffle = query(this, "repeat-shuffle-engine-selector"); + + whenElementsDefined({ + controller, + repeatShuffle, + }) + .then( + () => { + this.$controller.value = controller; + this.$repeatShuffle.value = repeatShuffle; + + this.effect(() => { + const now = !!this.$controller.value?.$queue.value?.now(); + const aud = this.audio()?.loadingState(); + const isError = now && typeof aud === "object" && aud !== null && + "error" in aud; + const isLoading = now && !isError && aud !== "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; + } + }); + }, + ); + } + + // EVENTS + + reload = () => { + const audioId = this.$controller.value?.$queue.value?.now()?.id; + if (audioId) { + const progress = this.audio()?.progress(); + this.$controller.value?.$audio.value?.reload({ + audioId, + play: true, + progress, + }); + } + }; + + next = () => { + this.$controller.value?.$queue.value?.shift(); + }; + + playPause = () => { + const audioId = this.$controller.value?.$queue.value?.now()?.id; + + if (this.isPlaying() && audioId) { + this.$controller.value?.$audio.value?.pause({ audioId }); + } else if (audioId) { + this.$controller.value?.$audio.value?.play({ audioId }); + } + }; + + previous = () => { + this.$controller.value?.$queue.value?.unshift(); + }; + + /** + * @param {MouseEvent} event + */ + seek = (event) => { + const target = event.currentTarget + ? /** @type {HTMLElement} */ (event.currentTarget) + : null; + const percentage = target ? event.offsetX / target.clientWidth : 0; + const audioId = this.$controller.value?.$queue.value?.now()?.id; + + if (audioId) { + this.$controller.value?.$audio.value?.seek({ audioId, percentage }); + } + }; + + toggleRepeat = () => { + const rs = this.$repeatShuffle.value; + if (rs) rs.setRepeat(!rs.repeat()); + }; + + toggleShuffle = () => { + const rs = this.$repeatShuffle.value; + if (rs) rs.setShuffle(!rs.shuffle()); + }; + + // RENDER + + /** + * @param {RenderArg} _ + */ + render({ html }) { + const track = this.currentTrack(); + const isRepeat = this.$repeatShuffle.value?.repeat() ?? false; + const isShuffle = this.$repeatShuffle.value?.shuffle() ?? false; + const playing = this.isPlaying(); + + // Now playing text + let nowPlayingText = "Diffuse"; + if (this.#audioError.value) { + nowPlayingText = "(!) An error occurred while decoding the audio"; + } else if (this.#isLoading.value) { + nowPlayingText = "Loading track ..."; + } else if (track) { + const artist = track.tags?.artist; + const title = track.tags?.title ?? ""; + nowPlayingText = artist ? `${artist} - ${title}` : title; + } + + const progress = (this.audio()?.progress() ?? 0) * 100; + + return html` + + + + + +
+ +

${nowPlayingText}

+ + +
+
+
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ `; + } +} + +export default ClassicController; + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = ClassicController; +export const NAME = "db-blur-classic-controller"; + +defineElement(NAME, CLASS); diff --git a/src/facets/themes/blur-classic/facet/index.css b/src/facets/themes/blur-classic/facet/index.css new file mode 100644 index 00000000..0ddba00a --- /dev/null +++ b/src/facets/themes/blur-classic/facet/index.css @@ -0,0 +1,402 @@ +body { + color: var(--text-color); + display: flex; + flex-direction: column; + overflow: hidden; + height: 100dvh; + transition-duration: 750ms; + transition-property: background-color; +} + +#container { + display: flex; + align-items: center; + height: 100vh; +} + +/*********************************** + * Background overlay + ***********************************/ + +#bg-overlay { + background-position: center; + background-size: cover; + inset: 0; + mix-blend-mode: luminosity; + opacity: 0; + pointer-events: none; + position: fixed; + transition: opacity 750ms; + z-index: -1; +} + +#bg-overlay.bg-overlay--visible { + opacity: 1; +} + +#bg-overlay.bg-overlay--no-mix { + mix-blend-mode: normal; +} + +#bg-overlay::after { + background: linear-gradient(#0000, rgba(0, 0, 0, 0.175)); + bottom: 0; + content: ""; + height: 16rem; + left: 0; + pointer-events: none; + position: absolute; + right: 0; +} + +/*********************************** + * Main + ***********************************/ + +main { + display: flex; + flex-direction: column; + height: 100%; + margin: 0 auto; + max-width: var(--container-5xl); + overflow: hidden; + overflow-y: auto; + padding: 0 var(--space-xs); + width: 100%; + + @media (min-width: 56rem) { + max-height: var(--container-5xl); + } + + @media (min-width: 70rem) { + padding: 0 var(--space-3xl); + } + + @media (min-width: 84rem) { + padding: 0 var(--space-3xl); + } + + @media (min-width: 126rem) { + padding: 0 var(--space-md); + } +} + +/*********************************** + * Elements + ***********************************/ + +db-blur-classic-controller, +db-browser, +#settings-panel { + border-radius: var(--radius-md); + overflow: hidden; + width: 100%; +} + +db-browser, +#settings-panel { + box-shadow: var(--box-shadow-md); +} + +db-browser { + flex: 1; + min-height: 350px; +} + +db-blur-classic-controller, +#settings-panel { + flex-shrink: 0; +} + +/*********************************** + * Settings panel + ***********************************/ + +#settings-panel { + display: none; +} + +main.settings-open db-blur-classic-controller { + display: none; +} + +main.settings-open #settings-panel { + display: flex; + flex-direction: column; + max-height: 50vh; +} + +.settings-scroll { + background: var(--bg-color); + color: var(--text-color); + flex: 1; + font-size: var(--fs-sm); + overflow-y: auto; + padding: var(--space-md); +} + +.settings-section { + &:not(:last-child) { + margin-bottom: var(--space-lg); + } + + h2 { + font-size: 65%; + font-weight: 500; + letter-spacing: var(--tracking-wider); + margin: 0 0 var(--space-xs) 0; + opacity: 0.4; + text-transform: uppercase; + } +} + +/* Background image grid */ + +.bg-images { + display: grid; + gap: var(--space-3xs); + grid-template-columns: repeat(5, 1fr); + margin-bottom: var(--space-2xs); +} + +.bg-thumb { + aspect-ratio: 16 / 10; + background: oklch(from var(--text-color) l c h / 0.07); + border: 0; + border-radius: var(--radius-sm); + color: white; + cursor: pointer; + overflow: hidden; + padding: 0; + position: relative; + transition: opacity 150ms; + + img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; + } + + .bg-thumb-check { + display: none; + } + + &[data-selected] .bg-thumb-check { + align-items: center; + background: oklch(from black l c h / 0.4); + display: flex; + inset: 0; + justify-content: center; + position: absolute; + } + + &:hover { + opacity: 0.8; + } +} + +/* Special option buttons */ + +.bg-special-options { + display: flex; + gap: var(--space-2xs); + margin-bottom: var(--space-2xs); +} + +.bg-special-btn { + align-items: center; + background: oklch(from var(--text-color) l c h / 0.07); + border: 2px solid oklch(from var(--text-color) l c h / 0.1); + border-radius: var(--radius-md); + color: oklch(from var(--text-color) l c h / 0.6); + cursor: pointer; + display: flex; + flex: 1; + flex-direction: column; + font-family: inherit; + gap: var(--space-2xs); + justify-content: center; + overflow: hidden; + padding: var(--space-xs) 0; + position: relative; + transition-duration: 150ms; + transition-property: border-color, color; + + i { + font-size: 120%; + line-height: 0.75; + } + + span { + font-size: 70%; + font-weight: 600; + letter-spacing: var(--tracking-wide); + text-box: trim-both cap alphabetic; + text-transform: uppercase; + } + + &[data-selected], + &:hover, + &:focus { + border-color: oklch(from var(--text-color) l c h / 0.4); + color: var(--text-color); + } + + /* Hide the color input; label click still activates it */ + input[type="color"] { + height: 1px; + left: 0; + opacity: 0; + pointer-events: none; + position: absolute; + top: 0; + width: 1px; + } +} + +/* URL input row */ + +.bg-url-row { + display: flex; + gap: var(--space-2xs); + + input { + background: oklch(from var(--text-color) l c h / 0.07); + border: 2px solid oklch(from var(--text-color) l c h / 0.1); + border-radius: var(--radius-md); + color: var(--text-color); + flex: 1; + font-family: inherit; + font-size: inherit; + padding: var(--space-xs) var(--space-sm); + + &::placeholder { + color: oklch(from var(--text-color) l c h / 0.4); + } + + &:focus { + border-color: oklch(from var(--text-color) l c h / 0.4); + outline: none; + } + } + + button { + background: oklch(from var(--text-color) l c h / 0.12); + border: 0; + border-radius: var(--radius-md); + color: var(--text-color); + cursor: pointer; + padding: var(--space-xs) var(--space-sm); + transition: background 150ms; + + &:hover { + background: oklch(from var(--text-color) l c h / 0.22); + } + } +} + +/* Mix toggle */ + +.bg-mix-btn { + align-items: center; + background: oklch(from var(--text-color) l c h / 0.07); + border: 2px solid oklch(from var(--text-color) l c h / 0.1); + border-radius: var(--radius-md); + color: oklch(from var(--text-color) l c h / 0.6); + cursor: pointer; + display: flex; + font-family: inherit; + font-size: 70%; + font-weight: 600; + gap: var(--space-2xs); + letter-spacing: var(--tracking-wide); + margin-top: var(--space-2xs); + padding: var(--space-xs) var(--space-sm); + text-transform: uppercase; + transition-duration: 150ms; + transition-property: border-color, color; + width: 100%; + + &[data-selected], + &:hover, + &:focus { + border-color: oklch(from var(--text-color) l c h / 0.4); + color: var(--text-color); + } +} + +/* Background color section */ + +.bg-color-row { + display: flex; + gap: var(--space-2xs); +} + +.bg-color-swatch { + border: 2px solid oklch(from var(--text-color) l c h / 0.1); + border-radius: var(--radius-md); + cursor: pointer; + display: block; + flex: 1; + min-height: 2.5rem; + overflow: hidden; + position: relative; + transition-duration: 150ms; + transition-property: border-color, background-color; + + input[type="color"] { + height: 1px; + left: 0; + opacity: 0; + pointer-events: none; + position: absolute; + top: 0; + width: 1px; + } + + &[data-selected] { + border-color: oklch(from var(--text-color) l c h / 0.4); + } +} + +/*********************************** + * Text menu + ***********************************/ + +#text-menu { + --menu-color: oklch(100% 0 0); + + align-items: center; + display: flex; + font-size: calc(var(--fs-xs) * 0.9); + font-weight: 600; + gap: var(--space-md); + justify-content: center; + letter-spacing: var(--tracking-widest); + mix-blend-mode: difference; + padding: var(--space-lg) 0; + text-transform: uppercase; + + button, + a { + background: none; + border: 0; + color: oklch(from var(--menu-color) l c h / 0.9); + cursor: pointer; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; + padding: 0; + text-decoration: none; + text-transform: inherit; + transition: color 250ms; + + &:hover, + &:focus, + &[data-active="t"] { + color: oklch(from var(--menu-color) l c h / 1); + } + } +} diff --git a/src/facets/themes/blur-classic/facet/index.html b/src/facets/themes/blur-classic/facet/index.html new file mode 100644 index 00000000..dce38df1 --- /dev/null +++ b/src/facets/themes/blur-classic/facet/index.html @@ -0,0 +1,88 @@ + + + + + + + + + +
+ +
+
+ + Sources + +
+ + + + + +
+
+
+

Background Image

+
+
+ + +
+ + +
+ +
+

Background Color

+
+ + +
+
+
+
+
+ + diff --git a/src/facets/themes/blur-classic/facet/index.inline.js b/src/facets/themes/blur-classic/facet/index.inline.js new file mode 100644 index 00000000..91864b85 --- /dev/null +++ b/src/facets/themes/blur-classic/facet/index.inline.js @@ -0,0 +1,434 @@ +import foundation from "~/common/foundation.js"; +import { data } from "~/common/output.js"; + +// Move #bg-overlay to document.body so it's not inside #container. +// #container fades in via an opacity transition (0→1), which creates an +// isolated stacking context while opacity < 1. That breaks mix-blend-mode +// on the overlay: it blends against transparent instead of the dark page +// background, causing a flash of regular image colors until opacity reaches 1. +const overlayEl = document.querySelector("#bg-overlay"); +if (overlayEl) document.body.appendChild(overlayEl); + +// Set doc title +foundation.setup({ title: "Blur Classic | Diffuse" }); + +//////////////////////////////////////////// +// 🚀 +//////////////////////////////////////////// + +await foundation.engine.queue(); +await foundation.engine.repeatShuffle(); +await foundation.engine.scope(); +await foundation.orchestrator.scopedTracks(); + +await foundation.orchestrator.sources(); +await foundation.orchestrator.processTracks({ disableWhenReady: true }); +await foundation.orchestrator.queueAudio(); +await foundation.orchestrator.controller(); +await foundation.orchestrator.mediaSession(); +await foundation.orchestrator.artwork(); +await foundation.orchestrator.coverGroups(); +await foundation.orchestrator.favourites(); +await foundation.configurator.input(); + +await import("~/facets/themes/blur-classic/controller/element.js"); +await import("~/facets/themes/blur/browser/element.js"); + +const groupLabel = foundation.GROUP === "facets" ? "Deck A" : foundation.GROUP; +const controller = document.querySelector("db-blur-classic-controller"); + +controller?.setAttribute("group", foundation.GROUP); +controller?.setAttribute("group-label", groupLabel); + +document.querySelector("db-browser")?.setAttribute("group", foundation.GROUP); + +//////////////////////////////////////////// +// BACKGROUND SETTINGS +//////////////////////////////////////////// + +const BACKGROUND_KEY = "sh.diffuse.theme.blur.background"; +const BACKGROUND_COLOR_KEY = "sh.diffuse.theme.blur.background-color"; +const BACKGROUND_MIX_KEY = "sh.diffuse.theme.blur.background-mix"; +const BG_IMAGE_COUNT = 30; + +const output = await foundation.orchestrator.output(); +await data(output.settings); + +// Apply stored image and color before fading in (with defaults for first run) +const storedBg = getSettingValue(BACKGROUND_KEY); +const storedBgColor = getSettingValue(BACKGROUND_COLOR_KEY); +const storedBgMix = getSettingValue(BACKGROUND_MIX_KEY); + +const activeBg = storedBg ?? "builtin:7"; +const activeMix = storedBgMix !== null ? storedBgMix === "true" : false; + +applyBackgroundMix(activeMix); +if (storedBgColor) applyBackgroundColor(storedBgColor); +applyBackgroundImage(activeBg); + +//////////////////////////////////////////// +// SHORTCUTS +//////////////////////////////////////////// + +document.querySelector("#btn-new-deck")?.addEventListener("click", async () => { + const state = await navigator.locks.query(); + const held = (state.held ?? []).flatMap((l) => l.name ? [l.name] : []); + + let nextGroup; + + if (!held.some((n) => n.includes("/Deck B"))) { + nextGroup = "Deck B"; + } else if (!held.some((n) => n.includes("/Deck C"))) { + nextGroup = "Deck C"; + } else { + return; + } + + const url = new URL(document.location.href); + url.searchParams.set("group", nextGroup); + window.open(url.toString(), "_blank"); +}); + +document.querySelector("#btn-settings")?.addEventListener("click", () => { + const main = document.querySelector("main"); + const btn = document.querySelector("#btn-settings"); + const isOpen = main?.classList.toggle("settings-open") ?? false; + btn?.setAttribute("data-active", isOpen ? "t" : "f"); +}); + +//////////////////////////////////////////// +// SETTINGS PANEL +//////////////////////////////////////////// + +// Populate background image grid +const bgGrid = document.querySelector("#bg-images"); + +if (bgGrid) { + for (let i = 1; i <= BG_IMAGE_COUNT; i++) { + const btn = document.createElement("button"); + btn.className = "bg-thumb"; + btn.dataset.value = `builtin:${i}`; + btn.title = `Background ${i}`; + + const img = document.createElement("img"); + img.src = `images/background/thumbnails/${i}.jpg`; + img.alt = `Background ${i}`; + img.loading = "lazy"; + + const check = document.createElement("i"); + check.className = "ph-bold ph-check bg-thumb-check"; + + btn.append(img, check); + btn.addEventListener("click", async () => { + const value = `builtin:${i}`; + await saveSetting(BACKGROUND_KEY, value); + await applyBackgroundImage(value); + updateImageSelected(value); + }); + + bgGrid.append(btn); + } +} + +// Reflect current selections in the UI +updateImageSelected(activeBg); +updateColorSelected(storedBgColor); +updateMixSelected(activeMix); + +// Image: None button +document.querySelector("#bg-none-btn")?.addEventListener("click", async () => { + await saveSetting(BACKGROUND_KEY, ""); + await applyBackgroundImage(""); + updateImageSelected(""); +}); + +// Image: URL toggle +document.querySelector("#bg-custom-btn")?.addEventListener("click", () => { + const row = /** @type {HTMLElement | null} */ ( + document.querySelector("#bg-url-row") + ); + if (row) row.hidden = !row.hidden; + document.querySelector("#bg-custom-btn")?.toggleAttribute( + "data-selected", + row ? !row.hidden : false, + ); +}); + +// Image: apply custom URL +document.querySelector("#bg-url-apply")?.addEventListener("click", async () => { + const input = /** @type {HTMLInputElement | null} */ ( + document.querySelector("#bg-url-input") + ); + const url = input?.value?.trim(); + if (!url) return; + const value = `url:${url}`; + await saveSetting(BACKGROUND_KEY, value); + await applyBackgroundImage(value); + updateImageSelected(value); +}); + +// Color: picker — label wraps the input, clicking opens the native picker +document.querySelector("#bg-color-picker")?.addEventListener( + "change", + async (e) => { + const color = /** @type {HTMLInputElement} */ (e.target).value; + await saveSetting(BACKGROUND_COLOR_KEY, color); + applyBackgroundColor(color); + updateColorSelected(color); + }, +); + +// Color: clear button +document.querySelector("#bg-color-clear-btn")?.addEventListener( + "click", + async () => { + await saveSetting(BACKGROUND_COLOR_KEY, ""); + applyBackgroundColor(""); + updateColorSelected(null); + }, +); + +// Mix: toggle +document.querySelector("#bg-mix-btn")?.addEventListener("click", async () => { + const isMixed = !(document.querySelector("#bg-overlay")?.classList.contains( + "bg-overlay--no-mix", + ) ?? false); + const next = !isMixed; + applyBackgroundMix(next); + updateMixSelected(next); + await saveSetting(BACKGROUND_MIX_KEY, next ? "true" : "false"); +}); + +//////////////////////////////////////////// +// 🚀 +//////////////////////////////////////////// + +foundation.ready(); + +//////////////////////////////////////////// +// 🛠️ HELPERS +//////////////////////////////////////////// + +/** + * Returns the stored value for a settings key, or null if absent. + * @param {string} key + * @returns {string | null} + */ +function getSettingValue(key) { + const col = output.settings.collection(); + if (col.state !== "loaded") return null; + return col.data.find((s) => s.key === key)?.value ?? null; +} + +/** + * Persist a value to a settings key. Pass "" to remove the setting. + * @param {string} key + * @param {string} value + */ +async function saveSetting(key, value) { + const col = output.settings.collection(); + if (col.state !== "loaded") return; + + const settings = col.data; + const existing = settings.find((s) => s.key === key); + + /** @type {import("~/definitions/types.d.ts").Setting[]} */ + let updated; + + if (!value) { + updated = settings.filter((s) => s.key !== key); + } else if (existing) { + updated = settings.map((s) => s.key === key ? { ...s, value } : s); + } else { + updated = [ + ...settings, + { + $type: /** @type {"sh.diffuse.output.setting"} */ ( + "sh.diffuse.output.setting" + ), + id: crypto.randomUUID(), + key, + value, + }, + ]; + } + + await output.settings.save(updated); +} + +/** + * Apply a background image value to #bg-overlay. Preloads before fading in. + * @param {string} value builtin:N | url:... | "" for none + */ +async function applyBackgroundImage(value) { + const overlay = /** @type {HTMLElement | null} */ ( + document.querySelector("#bg-overlay") + ); + + if (!overlay) return; + + const wasVisible = overlay.classList.contains("bg-overlay--visible"); + overlay.classList.remove("bg-overlay--visible"); + + // Wait for the fade-out to finish before swapping the image, so that + // backgroundImage never changes while the overlay is partially opaque + // (which would recreate the GPU layer and drop the blend mode for a frame). + // On initial load the overlay is already transparent so no wait is needed. + if (wasVisible) { + await new Promise((resolve) => { + overlay.addEventListener("transitionend", resolve, { once: true }); + }); + } + + if (!value) { + overlay.style.backgroundImage = ""; + return; + } + + let imageUrl = ""; + if (value.startsWith("builtin:")) { + imageUrl = `images/background/${value.slice(8)}.jpg`; + } else if (value.startsWith("url:")) { + imageUrl = value.slice(4); + } + + if (imageUrl) { + await new Promise((resolve) => { + const img = new Image(); + img.onload = resolve; + img.onerror = resolve; + img.src = imageUrl; + }); + + overlay.style.backgroundImage = `url('${imageUrl.replace(/'/g, "\\'")}')`; + overlay.style.backgroundPosition = value.startsWith("builtin:") + ? backgroundPositioning(`${value.slice(8)}.jpg`) + : ""; + + await new Promise((resolve) => { + requestAnimationFrame(() => resolve(undefined)); + }); + + overlay.classList.add("bg-overlay--visible"); + } +} + +/** + * Returns the background-position value for a given image filename. + * @param {string} filename + * @returns {string} + */ +function backgroundPositioning(filename) { + switch (filename) { + case "2.jpg": + return "center 68%"; + case "3.jpg": + return "center 30%"; + case "4.jpg": + return "center 96.125%"; + case "6.jpg": + return "center 40%"; + case "11.jpg": + return "center 67.25%"; + case "19.jpg": + return "center 13%"; + case "20.jpg": + return "center 39.75%"; + case "21.jpg": + return "center 52.5%"; + case "22.jpg": + return "center top"; + case "23.jpg": + return "center 92.5%"; + case "24.jpg": + return "center top"; + case "25.jpg": + return "center 50%"; + case "27.jpg": + return "center top"; + default: + return "center bottom"; + } +} + +/** + * Apply a background color value as the page background color. + * @param {string | null} color CSS color string, or null/empty to clear + */ +function applyBackgroundColor(color) { + if (color) { + document.documentElement.style.setProperty("--facet-bg-color", color); + } else { + document.documentElement.style.removeProperty("--facet-bg-color"); + } +} + +/** + * Highlight the active image selection in the settings panel. + * @param {string} value + */ +function updateImageSelected(value) { + document.querySelectorAll(".bg-thumb, #bg-none-btn, #bg-custom-btn").forEach( + (el) => el.removeAttribute("data-selected"), + ); + + if (!value) { + document.querySelector("#bg-none-btn")?.setAttribute("data-selected", ""); + } else if (value.startsWith("builtin:")) { + document + .querySelector(`.bg-thumb[data-value="${value}"]`) + ?.setAttribute("data-selected", ""); + } else if (value.startsWith("url:")) { + const input = /** @type {HTMLInputElement | null} */ ( + document.querySelector("#bg-url-input") + ); + if (input) input.value = value.slice(4); + const row = /** @type {HTMLElement | null} */ ( + document.querySelector("#bg-url-row") + ); + if (row) row.hidden = false; + document.querySelector("#bg-custom-btn")?.setAttribute("data-selected", ""); + } +} + +/** + * Toggle mix-blend-mode on #bg-overlay. + * @param {boolean} enabled + */ +function applyBackgroundMix(enabled) { + document.querySelector("#bg-overlay")?.classList.toggle( + "bg-overlay--no-mix", + !enabled, + ); +} + +/** + * Reflect the mix toggle state in the settings panel. + * @param {boolean} enabled + */ +function updateMixSelected(enabled) { + const btn = document.querySelector("#bg-mix-btn"); + btn?.toggleAttribute("data-selected", enabled); +} + +/** + * Highlight the active color selection and update the swatch. + * @param {string | null} color + */ +function updateColorSelected(color) { + const label = /** @type {HTMLElement | null} */ ( + document.querySelector("#bg-color-label") + ); + const picker = /** @type {HTMLInputElement | null} */ ( + document.querySelector("#bg-color-picker") + ); + + if (color) { + label?.setAttribute("data-selected", ""); + if (label) label.style.backgroundColor = color; + if (picker) picker.value = color; + } else { + label?.removeAttribute("data-selected"); + if (label) label.style.backgroundColor = ""; + } +}