davideisinger.com

My personal website
Log | Files | Refs | README

commit b8b480193befb97ec246f75c2d562ab2f91fd7ba
parent 6ee2232c9b5a534b46694afa1fdf7114f03b6e81
Author: David Eisinger <[email protected]>
Date:   Sun, 11 Oct 2026 08:32:55 -0400

Persist other tracks

Diffstat:
MREADME.md | 9+++++++--
Mtest/navigation.cjs | 38++++++++++++++++++++++++++++++++------
Mthemes/v2/assets/css/style.scss | 31+++++++++++++++++++++++++++++++
Mthemes/v2/assets/js/navigation.js | 8+-------
Athemes/v2/assets/js/player.js | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mthemes/v2/layouts/partials/head.html | 2++
Mthemes/v2/layouts/partials/header.html | 2+-
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>