/** * eom.dev theme music for the homepage. * * Plays a seamless 16-bar loop of the eom.dev theme (Web Audio, so the loop has no * gap). While it plays, links between the homepage and the membership pages are * loaded in place (fetch + swap) instead of reloading the document, so the music * keeps going on /members/. Anything else (forms, sign-in, checkout, other sites) * is a normal navigation. * * Browsers block sound until the visitor interacts with the page, so playback * starts on the first click or key press unless the visitor has turned it off. * The choice is kept in localStorage ("eom-theme-music": "on" | "off"). */ const SOURCES = [ ['/static/audio/eom-theme-loop.ogg', 'audio/ogg; codecs=opus'], ['/static/audio/eom-theme-loop.m4a', 'audio/mp4; codecs=mp4a.40.2'], ]; const VOLUME = 0.3; const FADE_S = 1.5; const PREF_KEY = 'eom-theme-music'; const RESUME_KEY = 'eom-theme-music-position'; const PLAYER_ID = 'eom-theme-player'; // Membership routes that must be real navigations (redirects off-site, POST-only, webhooks). const HARD_NAV = /^\/members\/(login|logout|oidc|checkout|billing|stripe|shkeeper|crypto\/checkout)\b/; const storage = { get(key, area = localStorage) { try { return area.getItem(key); } catch { return null; } }, set(key, value, area = localStorage) { try { area.setItem(key, value); } catch { /* private mode */ } }, }; class ThemeMusic { constructor() { this.ctx = null; this.buffer = null; this.source = null; this.gain = null; this.startedAt = 0; // ctx time when the current source started this.offset = 0; // loop position (s) at startedAt this.playing = false; this.loading = null; } position() { if (!this.buffer) return 0; const t = this.playing ? this.offset + (this.ctx.currentTime - this.startedAt) : this.offset; return t % this.buffer.duration; } async load() { if (this.buffer) return this.buffer; this.loading ??= (async () => { this.ctx = new (window.AudioContext || window.webkitAudioContext)(); this.gain = this.ctx.createGain(); this.gain.gain.value = 0; this.gain.connect(this.ctx.destination); const probe = document.createElement('audio'); const candidates = SOURCES.filter(([, type]) => probe.canPlayType(type)); for (const [url] of candidates.length ? candidates : SOURCES) { try { const res = await fetch(url); if (!res.ok) continue; this.buffer = await this.ctx.decodeAudioData(await res.arrayBuffer()); return this.buffer; } catch { /* try the next format */ } } throw new Error('theme music could not be decoded'); })(); return this.loading; } async play(at = this.offset) { await this.load(); if (this.ctx.state === 'suspended') await this.ctx.resume(); if (this.ctx.state !== 'running') throw new Error('audio blocked until user interaction'); if (this.playing) return; const src = this.ctx.createBufferSource(); src.buffer = this.buffer; src.loop = true; src.connect(this.gain); this.offset = at % this.buffer.duration; this.startedAt = this.ctx.currentTime; src.start(0, this.offset); this.source = src; this.playing = true; const g = this.gain.gain; g.cancelScheduledValues(this.ctx.currentTime); g.setValueAtTime(g.value, this.ctx.currentTime); g.linearRampToValueAtTime(VOLUME, this.ctx.currentTime + FADE_S); } pause() { if (!this.playing) return; this.offset = this.position(); this.playing = false; const now = this.ctx.currentTime; const g = this.gain.gain; g.cancelScheduledValues(now); g.setValueAtTime(g.value, now); g.linearRampToValueAtTime(0, now + 0.3); this.source.stop(now + 0.32); this.source = null; } } const music = new ThemeMusic(); let button; let starting = false; // a start is in flight (audio context resuming) let renderedPath = location.pathname + location.search; function render() { if (!button) return; button.setAttribute('aria-pressed', String(music.playing)); button.title = music.playing ? 'Pause the eom.dev theme' : 'Play the eom.dev theme'; button.querySelector('.label').textContent = music.playing ? 'Theme on' : 'Theme off'; } async function start() { starting = true; try { await music.play(); } catch { /* still blocked; wait for a gesture */ } starting = false; render(); } function toggle() { if (music.playing) { music.pause(); storage.set(PREF_KEY, 'off'); render(); } else { storage.set(PREF_KEY, 'on'); start(); } } function mountButton() { if (document.getElementById(PLAYER_ID)) return; const style = document.createElement('style'); style.id = `${PLAYER_ID}-style`; style.textContent = ` #${PLAYER_ID}{position:fixed;right:1rem;bottom:1rem;z-index:1000;display:inline-flex;align-items:center;gap:.45rem; padding:.5rem .8rem;border-radius:999px;border:1px solid var(--border,#3c3d56);background:var(--surface-raised,#292940); color:var(--text,#cdd6f4);font:600 .85rem/1 system-ui,sans-serif;cursor:pointer;box-shadow:0 4px 18px rgb(0 0 0 / .25)} #${PLAYER_ID}:hover{border-color:var(--accent,#cba6f7)} #${PLAYER_ID}:focus-visible{outline:2px solid var(--accent,#cba6f7);outline-offset:2px} #${PLAYER_ID} .bars{display:inline-flex;gap:2px;align-items:flex-end;height:.9rem} #${PLAYER_ID} .bars i{width:3px;height:30%;background:var(--accent,#cba6f7);border-radius:1px} #${PLAYER_ID}[aria-pressed="true"] .bars i{animation:eomBars 1.07s ease-in-out infinite} #${PLAYER_ID}[aria-pressed="true"] .bars i:nth-child(2){animation-delay:-.27s} #${PLAYER_ID}[aria-pressed="true"] .bars i:nth-child(3){animation-delay:-.54s} @keyframes eomBars{0%,100%{height:30%}50%{height:100%}} @media (prefers-reduced-motion:reduce){#${PLAYER_ID} .bars i{animation:none!important;height:60%!important}} @media print{#${PLAYER_ID}{display:none}}`; document.head.append(style); button = document.createElement('button'); button.id = PLAYER_ID; button.type = 'button'; button.innerHTML = ''; button.addEventListener('click', toggle); document.body.append(button); render(); } /* ---------- in-place navigation while the music plays ---------- */ let navToken = 0; let moduleGeneration = 0; function softNavigable(url) { if (url.origin !== location.origin) return false; if (url.pathname === location.pathname && url.search === location.search && url.hash) return false; // same-page anchor if (HARD_NAV.test(url.pathname)) return false; return url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/members/'); } function onClick(event) { if (!(music.playing || starting) || event.defaultPrevented || event.button !== 0) return; if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; const link = event.target.closest?.('a[href]'); if (!link || (link.target && link.target !== '_self') || link.hasAttribute('download')) return; const url = new URL(link.href, location.href); if (!softNavigable(url)) return; event.preventDefault(); navigate(url, { push: true }); } async function adoptStylesheets(doc) { const wanted = [...doc.querySelectorAll('link[rel="stylesheet"]')].map(l => new URL(l.getAttribute('href'), location.href).href); const current = [...document.querySelectorAll('link[rel="stylesheet"]')]; // Load the new sheets first (no unstyled flash), then put every sheet in the new // page's order; cascade order matters (pure.css must come before home.css). const fresh = new Map(); for (const href of wanted) { if (current.some(l => l.href === href)) continue; const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; fresh.set(href, new Promise(resolve => { link.onload = link.onerror = () => resolve(link); })); document.head.append(link); } const added = new Map(await Promise.all([...fresh].map(async ([href, p]) => [href, await p]))); for (const link of current) if (!wanted.includes(link.href)) link.remove(); for (const href of wanted) document.head.append(added.get(href) ?? current.find(l => l.href === href)); } function runScripts(container) { for (const old of container.querySelectorAll('script')) { const type = old.getAttribute('type') || ''; if (type && type !== 'module' && type !== 'text/javascript') continue; // e.g. JSON-LD if (old.src && new URL(old.src, location.href).pathname === new URL(import.meta.url).pathname) { old.remove(); continue; } const fresh = document.createElement('script'); for (const { name, value } of old.attributes) fresh.setAttribute(name, value); if (old.src && type === 'module') { // A module URL only runs once per document; a new query makes it run again. const src = new URL(old.src, location.href); src.searchParams.set('nav', String(++moduleGeneration)); fresh.src = src.href; } fresh.textContent = old.textContent; old.replaceWith(fresh); } } async function navigate(url, { push }) { const token = ++navToken; let res; try { res = await fetch(url.href, { credentials: 'same-origin', headers: { Accept: 'text/html' } }); } catch { location.assign(url.href); return; } // e.g. redirected to another origin if (token !== navToken) return; const finalURL = new URL(res.url || url.href); if (!res.ok || !(res.headers.get('content-type') || '').includes('text/html') || finalURL.origin !== location.origin) { location.assign(url.href); return; } const doc = new DOMParser().parseFromString(await res.text(), 'text/html'); if (token !== navToken) return; finalURL.hash = url.hash; if (push) history.pushState({ eomTheme: true }, '', finalURL.href); renderedPath = finalURL.pathname + finalURL.search; await adoptStylesheets(doc); document.title = doc.title; for (const name of ['description', 'robots']) { const next = doc.querySelector(`meta[name="${name}"]`); const cur = document.querySelector(`meta[name="${name}"]`); if (next && cur) cur.content = next.content; else if (next) document.head.append(next.cloneNode()); else cur?.remove(); } document.documentElement.lang = doc.documentElement.lang || document.documentElement.lang; const keep = document.getElementById(PLAYER_ID); document.body.replaceChildren(...doc.body.childNodes); for (const { name, value } of doc.body.attributes) document.body.setAttribute(name, value); if (keep) document.body.append(keep); runScripts(document.body); const target = url.hash && document.getElementById(decodeURIComponent(url.hash.slice(1))); if (target) target.scrollIntoView(); else window.scrollTo(0, 0); (document.querySelector('main') || document.body).focus?.({ preventScroll: true }); } function onPopState() { if (location.pathname + location.search === renderedPath) return; // only the #hash changed if (!music.playing) { location.reload(); return; } navigate(new URL(location.href), { push: false }); } /* ---------- boot ---------- */ function boot() { if (window.__eomThemeMusic) return; // already running in this document window.__eomThemeMusic = music; mountButton(); document.addEventListener('click', onClick); window.addEventListener('popstate', onPopState); if (!history.state) history.replaceState({ eomTheme: true }, ''); // Keep the loop position across a real reload of the homepage. window.addEventListener('pagehide', () => { if (music.playing) storage.set(RESUME_KEY, JSON.stringify({ at: music.position(), t: Date.now() }), sessionStorage); else try { sessionStorage.removeItem(RESUME_KEY); } catch { /* private mode */ } }); try { const saved = JSON.parse(storage.get(RESUME_KEY, sessionStorage) || 'null'); if (saved && Date.now() - saved.t < 30_000) music.offset = saved.at + (Date.now() - saved.t) / 1000; } catch { /* ignore */ } if (storage.get(PREF_KEY) === 'off') return; music.load().catch(() => {}); // fetch/decode early so the first click starts instantly start(); // succeeds only where autoplay is allowed const onGesture = event => { if (event.target.closest?.(`#${PLAYER_ID}`)) return; // the toggle handles its own click for (const type of ['pointerdown', 'keydown']) removeEventListener(type, onGesture, true); if (!music.playing && storage.get(PREF_KEY) !== 'off') start(); }; for (const type of ['pointerdown', 'keydown']) addEventListener(type, onGesture, true); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true }); else boot();