Homepage: seamless theme music that keeps playing on /members/
Adds the eom.dev theme (Strudel) as a 16-bar Web Audio loop (Opus, AAC fallback) with a Theme on/off button. Playback starts on the first interaction unless the visitor turned it off. While it plays, links between the homepage and membership pages are fetched and swapped in place, so the music doesn't restart; sign-in, checkout, billing and forms remain real navigations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 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 = '<span class="bars" aria-hidden="true"><i></i><i></i><i></i></span><span class="label"></span>';
|
||||
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();
|
||||
Reference in New Issue
Block a user