A small, dependency-light audio player for static sites. Plays HLS (.m3u8)
via hls.js and plain audio files
(.mp3, etc.) — chosen automatically per track — with a track list, scrubber,
vertical volume, prev/next (no wrap-around), and an optional real-time spectrum
visualiser (unavailable for HLS on Safari — see
Visualiser and HLS in Safari).
It is framework-agnostic: vanilla JS, no build step, no dependencies of its own.
hls.js is only needed if you serve .m3u8 tracks.
| File | Purpose |
|---|---|
gh-audio-player.js |
Engine + UI. Auto-inits on [data-gh-audio-player]. |
gh-audio-player.css |
All styles. Theme via CSS custom properties. |
<link rel="stylesheet" href="/vendor/gh-audio-player/gh-audio-player.css">
<div class="gh-audio-player" data-gh-audio-player>
<div class="gh-audio-player__display">
<div class="gh-audio-player__now">
<span class="gh-audio-player__label">Now Playing</span>
<span class="gh-audio-player__title"></span>
</div>
<div class="gh-audio-player__controls">
<button class="gh-audio-player__btn gh-audio-player__btn--prev" aria-label="Previous track">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>
</button>
<button class="gh-audio-player__btn gh-audio-player__btn--play" aria-label="Play">
<svg class="icon-play" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
<svg class="icon-pause" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<button class="gh-audio-player__btn gh-audio-player__btn--next" aria-label="Next track">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6 18l8.5-6L6 6v12z"/><path d="M16 6h2v12h-2z"/></svg>
</button>
</div>
<div class="gh-audio-player__progress">
<span class="gh-audio-player__time gh-audio-player__time--current">0:00</span>
<input type="range" class="gh-audio-player__scrubber" min="0" max="100" step="0.1" value="0" aria-label="Seek">
<span class="gh-audio-player__time gh-audio-player__time--total">0:00</span>
</div>
<div class="gh-audio-player__volume">
<svg class="gh-audio-player__volume-icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>
<input type="range" class="gh-audio-player__volume-input" min="0" max="1" step="0.02" value="0.8" aria-label="Volume">
</div>
<canvas class="gh-audio-player__viz" aria-hidden="true"></canvas>
</div>
<ol class="gh-audio-player__tracklist" role="listbox" aria-label="Tracks">
<!-- one per track -->
<li class="gh-audio-player__track" tabindex="0" role="option" aria-selected="false"
data-src="/audio/album/track01.m3u8" data-title="Track One">
<span class="gh-audio-player__track-num">1</span>
<span class="gh-audio-player__track-title">Track One</span>
<span class="gh-audio-player__track-duration">3:45</span>
</li>
</ol>
</div>
<script src="/vendor/hls.min.js"></script> <!-- only if serving .m3u8 -->
<script src="/vendor/gh-audio-player/gh-audio-player.js"></script>
Each .gh-audio-player__track carries:
| Attribute | Meaning |
|---|---|
data-src |
Audio source. .m3u8 → HLS; anything else → set on <audio>.src. |
data-title |
Shown in the Now Playing panel. |
Track order = DOM order. The <audio> element is created and hidden by the JS;
you don't add it yourself.
window.GhAudioPlayer.init(element); // init one container
window.GhAudioPlayer.initAll(); // init every [data-gh-audio-player]
Useful if you inject markup after page load.
Set custom properties on .gh-audio-player (or any ancestor):
.gh-audio-player {
--gh-accent: #8B1A1A; /* play button, fills, visualiser */
--gh-accent-dark: #6b1414; /* play button hover */
--gh-text: #3D3530; /* primary text */
--gh-tan: #C4A882; /* "Now Playing" label */
--gh-bg: #ffffff; /* panel background */
--gh-radius: 6px;
--gh-font: Georgia, 'Times New Roman', serif;
}
Anything unset falls back to the defaults above. Faint greys are neutral
rgba() (tuned for light backgrounds).
.gh-audio-player__*) so it can share a page with
unrelated markup.The visualiser cannot work for HLS in Safari. WebKit — Safari on macOS, and
every browser on iOS (they're all WebKit underneath) — does not route HLS audio
into the Web Audio API. createMediaElementSource() on an element playing HLS
yields silence to the graph: the AnalyserNode returns all zeros while the track
is audibly playing. This holds whether the HLS is played natively
(<audio src="…m3u8">) or demuxed through hls.js / MSE — measured on both
macOS and iOS Safari. Chrome and Firefox are unaffected, because they run HLS
through MSE inside their own Web Audio-tappable pipeline.
So the player disables the visualiser and hides the canvas entirely when the
current track is HLS on WebKit. It doesn't even build the audio graph in that
case, so there is no dead or empty canvas — the Now Playing panel simply lays out
without it. Playback is completely unaffected. Non-HLS sources (e.g. .mp3) and
non-WebKit engines (Chrome, Firefox) run the visualiser normally.
The decision is isAppleWebKit() && /\.m3u8/, per track (see vizPossible() in
the JS). It is deliberately UA-based rather than a runtime "did we get any
samples?" probe: for long ambient tracks that open quietly, such a probe could
hide a working visualiser. The trade-off is that if Safari ever gains the
ability to tap HLS, the visualiser will stay dark here until this gate is removed.
The same WebKit quirk is why the vertical volume slider is built by rotating a plain horizontal range (
transform: rotate(-90deg)), rather than using awriting-mode: vertical-lrrange — Safari's support for the latter is unreliable (it ignores the custom track/thumb sizing).
Not done today — recorded as the natural path if a cross-browser visualiser is ever wanted.
A precomputed spectrum sidesteps the Safari/HLS problem entirely, because it
never touches Web Audio. At build time, analyse each track once (e.g. ffmpeg or
audiowaveform) into a small per-track array of band amplitudes; ship that as a
data file; and animate the bars from array[audio.currentTime]. No tapping, no
HLS restriction, works identically in every browser (including Safari), and the
payload is tiny.
It would be a fixed spectrum rather than a live FFT — deterministic, which is arguably fine for music — and it adds build tooling plus one data file per track.
Create a repo containing these files (e.g. gh-audio-player), then in each site:
git submodule add git@github.com:YOU/gh-audio-player.git src/assets/vendor/gh-audio-player
git commit -m "Add gh-audio-player submodule"
Fresh clones need:
git clone --recurse-submodules <site-repo>
# or, in an existing clone:
git submodule update --init --recursive
To pull in later fixes, from the site repo:
git submodule update --remote --merge src/assets/vendor/gh-audio-player
git commit -am "Bump gh-audio-player"
To make changes to the component itself:
cd src/assets/vendor/gh-audio-player
git checkout main # submodules start in detached HEAD
# edit, commit, push ...
cd -
git add src/assets/vendor/gh-audio-player
git commit -m "Bump gh-audio-player"
That final git add in the site repo stages a single SHA — not the files you
edited. Git tracks a submodule path as one entry whose entire content is the
commit hash it points at; the component's files live only on this player repo's
remote, never inside the site repo. So the two commits publish two different
things to two different places: the commit inside the submodule pushes the new
files to this remote, and the commit outside records which of those commits
the site repo should check out. A fresh clone then fetches the files from this
remote using that pinned hash.