commit b8b480193befb97ec246f75c2d562ab2f91fd7ba
parent 6ee2232c9b5a534b46694afa1fdf7114f03b6e81
Author: David Eisinger <[email protected]>
Date: Sun, 11 Oct 2026 08:32:55 -0400
Persist other tracks
Diffstat:
7 files changed, 166 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
@@ -31,8 +31,13 @@ position. External links, downloads, modified clicks, and links with
JavaScript or CSS assets fall back to a full page load. Without JavaScript, every
page and link still works normally; audio then stops when leaving the page.
-Only the header player persists between pages. Starting another audio player
-pauses the current one. Reloading the browser or leaving the site ends playback.
+The header switches to “Now playing” when playback starts. With JavaScript,
+the music page's players become play/pause buttons for that persistent player;
+selecting a track updates its title, source link, and audio in the header. The
+buttons reflect playback and elapsed time, even after leaving and returning to
+the music page. Without JavaScript, the native players remain available.
+Starting other embedded audio pauses the current player. Reloading the browser
+or leaving the site ends playback.
The script is bundled by Hugo; Node.js is not required to build or deploy it.
For browser regression tests, run `npm ci`, then `npm run test:navigation`.
diff --git a/test/navigation.cjs b/test/navigation.cjs
@@ -32,6 +32,9 @@ function write(name, text) {
write('content/journal/example/index.md', '---\ntitle: Example post\ndate: 2026-01-01\ntags: [dispatch]\n---\nHello.');
fs.copyFileSync(path.join(repo, 'content/journal/dispatch-44-october-2026/Night Moon.mp3'),
path.join(root, 'content/journal/example/Night Moon.mp3'));
+ write('content/journal/older/index.md', '---\ntitle: Older post\ndate: 2025-01-01\n---\nAn earlier recording.');
+ fs.copyFileSync(path.join(repo, 'content/journal/dispatch-44-october-2026/Night Moon.mp3'),
+ path.join(root, 'content/journal/older/Older Song.mp3'));
write('content/elsewhere/example/index.md', '---\ntitle: Elsewhere example\ndate: 2025-01-01\ncanonical_url: https://example.org/original\n---\nOriginal article.');
server = http.createServer((request, response) => {
const isFetch = request.headers.accept === 'text/html';
@@ -76,6 +79,7 @@ function write(name, text) {
originalAudio.addEventListener('emptied', () => audioInterruptions++);
});
await page.waitForFunction(() => originalAudio.currentTime > 0.1);
+ assert.equal(await page.locator('#latest-track-heading').textContent(), 'Now playing');
async function click(href) {
await page.evaluate(href => {
@@ -163,15 +167,34 @@ function write(name, text) {
});
}), Array(9).fill(false));
- // Starting a content player prevents overlapping audio with the header.
+ // The music-page buttons control the persistent player, including older songs.
await click('/music/');
- await page.locator('#content audio').first().evaluate(async audio => {
- audio.muted = true;
- await audio.play();
- });
+ assert.equal(await page.locator('#content audio:visible').count(), 0);
+ assert.equal(await page.locator('.track-toggle').count(), 2);
+ await page.getByRole('button', { name: 'Play Older Song', exact: true }).click();
+ await page.waitForFunction(() => !originalAudio.paused && originalAudio.currentTime > 0.1
+ && originalAudio.src.includes('Older%20Song.mp3'));
+ assert.equal(await page.locator('#site-header .track-title').textContent(), 'Older Song');
+ assert.equal(await page.locator('#site-header .track-source a').textContent(), 'Older post');
+ assert.equal(new URL(await page.locator('#site-header .track-source a').getAttribute('href'), origin).pathname,
+ '/journal/older/');
+ await page.getByRole('button', { name: 'Pause Older Song', exact: true }).click();
assert.equal(await page.evaluate(() => originalAudio.paused), true);
+ const pausedAt = await page.evaluate(() => originalAudio.currentTime);
await page.evaluate(() => originalAudio.play());
- assert.equal(await page.locator('#content audio').first().evaluate(audio => audio.paused), true);
+ await page.getByRole('button', { name: 'Pause Older Song', exact: true }).waitFor();
+ await click('/about/');
+ assert.equal(await page.evaluate(() => originalAudio === document.querySelector('#site-header audio')
+ && !originalAudio.paused), true);
+ await click('/music/');
+ await page.getByRole('button', { name: 'Pause Older Song', exact: true }).waitFor();
+ assert.ok(await page.evaluate(() => originalAudio.currentTime) >= pausedAt);
+ assert.equal(await page.locator('.track-toggle').count(), 2);
+ await page.getByRole('button', { name: 'Play Night Moon', exact: true }).click();
+ await page.getByRole('button', { name: 'Pause Night Moon', exact: true }).waitFor();
+ await page.getByRole('button', { name: 'Play Older Song', exact: true }).waitFor();
+ assert.equal(await page.locator('#site-header .track-title').textContent(), 'Night Moon');
+ assert.equal(await page.locator('#content audio').evaluateAll(players => players.every(audio => audio.paused)), true);
// A failed enhanced request must retry with normal document navigation.
failNextFetch = true;
@@ -191,6 +214,9 @@ function write(name, text) {
await plain.getByRole('link', { name: 'More about me →' }).click();
assert.equal(new URL(plain.url()).pathname, '/about/');
assert.equal(await plain.locator('#site-header audio').count(), 1);
+ await plain.getByRole('link', { name: 'More music →' }).click();
+ assert.equal(await plain.locator('#content audio:visible').count(), 2);
+ assert.equal(await plain.locator('.track-toggle').count(), 0);
await noJS.close();
await page.setViewportSize({ width: 390, height: 850 });
diff --git a/themes/v2/assets/css/style.scss b/themes/v2/assets/css/style.scss
@@ -376,6 +376,33 @@ pre {
// AUDIO
+.track-toggle {
+ flex: 0 0 104px;
+ padding: 8px 12px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ background: #eee;
+ color: #00a;
+ font: inherit;
+ font-size: 14px;
+ cursor: pointer;
+
+ &:hover {
+ background: #e3e3e3;
+ }
+
+ &:focus-visible {
+ outline: 2px solid #00a;
+ outline-offset: 3px;
+ }
+}
+
+.track-playback {
+ display: block;
+ color: #666;
+ font-size: 14px;
+}
+
.music-track {
align-items: center;
display: flex;
@@ -399,6 +426,10 @@ pre {
.music-track {
display: block;
+ .track-toggle {
+ margin-bottom: 1ch;
+ }
+
audio {
padding-bottom: 1ch;
}
diff --git a/themes/v2/assets/js/navigation.js b/themes/v2/assets/js/navigation.js
@@ -84,6 +84,7 @@
else history.pushState({ [stateKey]: { x: 0, y: 0 } }, '', destination.href);
currentURL = destination;
content.replaceChildren(...nextContent.childNodes);
+ document.dispatchEvent(new Event('site:navigated'));
document.querySelector('#site-footer').replaceWith(nextFooter);
document.title = next.title;
document.body.className = next.body.className;
@@ -147,11 +148,4 @@
navigate(url, { pop: true, savedScroll: event.state?.[stateKey] });
});
- // Avoid overlapping playback if a visitor starts another player on /music.
- document.addEventListener('play', event => {
- if (!(event.target instanceof HTMLAudioElement)) return;
- document.querySelectorAll('audio').forEach(audio => {
- if (audio !== event.target) audio.pause();
- });
- }, true);
})();
diff --git a/themes/v2/assets/js/player.js b/themes/v2/assets/js/player.js
@@ -0,0 +1,92 @@
+// Track-list buttons and the persistent header control one audio element.
+(() => {
+ const header = document.querySelector('#site-header');
+ const player = header?.querySelector('audio');
+ if (!player) return;
+ const heading = header.querySelector('#latest-track-heading');
+ const title = header.querySelector('.track-title');
+ const source = header.querySelector('.track-source a');
+ let controls = [];
+ let playRequest = 0;
+
+ function timestamp(seconds) {
+ if (!Number.isFinite(seconds)) return '0:00';
+ return `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, '0')}`;
+ }
+
+ function update() {
+ const playing = !player.paused && !player.ended;
+ for (const track of controls) {
+ const selected = player.src === track.url;
+ const active = selected && playing;
+ track.row.toggleAttribute('data-current-track', selected);
+ track.button.textContent = active ? 'Ⅱ Pause' : '▶ Play';
+ track.button.setAttribute('aria-label', `${active ? 'Pause' : 'Play'} ${track.title}`);
+ track.state.textContent = selected
+ ? `${player.error ? 'Unable to play' : player.ended ? 'Finished' : playing ? 'Playing' : player.currentTime > 0 ? 'Paused' : 'Ready'} · ${timestamp(player.currentTime)}`
+ : '';
+ }
+ }
+
+ async function select(track) {
+ const request = ++playRequest;
+ if (player.src === track.url && !player.paused && !player.ended) {
+ player.pause();
+ return;
+ }
+ if (player.src !== track.url) {
+ player.pause();
+ title.textContent = track.title;
+ source.textContent = track.sourceTitle;
+ source.href = track.sourceURL;
+ player.setAttribute('aria-label', track.title);
+ player.src = track.url;
+ }
+ try {
+ await player.play();
+ } catch (error) {
+ if (request !== playRequest || error.name === 'AbortError') return;
+ // Keep the native header controls available for retrying blocked playback.
+ track.state.textContent = `Unable to play ${track.title}. Try the player above.`;
+ }
+ }
+
+ function enhance() {
+ controls = [];
+ document.querySelectorAll('#content .music-track').forEach(row => {
+ const audio = row.querySelector('audio');
+ const trackTitle = row.querySelector('.track-title');
+ const trackSource = row.querySelector('.track-source a');
+ if (!audio || !trackTitle || !trackSource) return;
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'track-toggle';
+ const state = document.createElement('span');
+ state.className = 'track-playback';
+ const track = { row, button, state, url: audio.src, title: trackTitle.textContent,
+ sourceTitle: trackSource.textContent, sourceURL: trackSource.href };
+ button.addEventListener('click', () => select(track));
+ audio.hidden = true;
+ audio.before(button);
+ trackSource.closest('div').append(state);
+ controls.push(track);
+ });
+ update();
+ }
+
+ player.addEventListener('play', () => {
+ heading.textContent = 'Now playing';
+ update();
+ });
+ for (const event of ['pause', 'ended', 'timeupdate', 'loadedmetadata', 'emptied', 'error']) {
+ player.addEventListener(event, update);
+ }
+ document.addEventListener('play', event => {
+ if (!(event.target instanceof HTMLAudioElement)) return;
+ document.querySelectorAll('audio').forEach(audio => {
+ if (audio !== event.target) audio.pause();
+ });
+ }, true);
+ document.addEventListener('site:navigated', enhance);
+ enhance();
+})();
diff --git a/themes/v2/layouts/partials/head.html b/themes/v2/layouts/partials/head.html
@@ -10,6 +10,8 @@
<link rel="stylesheet" href="{{ $css.RelPermalink }}">
{{ $navigation := resources.Get "js/navigation.js" | minify | fingerprint }}
<script defer src="{{ $navigation.RelPermalink }}" data-site-navigation></script>
+{{ $player := resources.Get "js/player.js" | minify | fingerprint }}
+<script defer src="{{ $player.RelPermalink }}"></script>
<link rel="alternate" href="/index.xml" type="application/rss+xml" title="{{ .Site.Title }}" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
diff --git a/themes/v2/layouts/partials/header.html b/themes/v2/layouts/partials/header.html
@@ -19,7 +19,7 @@
{{ with $latestTrack }}
{{ $title := substr .track.Title 0 -4 }}
- <h2 class="home-music-heading home-music-cell" id="latest-track-heading">Latest track</h2>
+ <h2 class="home-music-heading home-music-cell" id="latest-track-heading" aria-live="polite">Latest track</h2>
<div class="home-music home-music-cell" role="group" aria-labelledby="latest-track-heading">
<p class="track-title">{{ $title }}</p>
<p class="track-source">From <a href="{{ .page.RelPermalink }}">{{ .page.Title }}</a></p>