/**
* 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();