// Mini players. Each [data-mw] wraps one <audio>; the script wires whichever controls it contains
// (play button, seek slider, mute button) and writes the elapsed or remaining time.
(() => {
const fmt = s => { s = Math.floor(s || 0); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
document.querySelectorAll('[data-mw]').forEach(root => {
const $ = s => root.querySelector(s);
const audio = $('audio'), play = $('[data-play]'), seek = $('[data-seek]'), cur = $('[data-cur]'), dur = $('[data-dur]'), left = $('[data-left]');
const mute = $('[data-mute]'), name = play.dataset.play;
let raf = 0;
const length = () => audio.duration || +root.dataset.length;
const sync = () => {
const t = audio.currentTime, d = length(), text = fmt(t) + ' of ' + fmt(d);
root.style.setProperty('--p', d ? t / d : 0);
if (cur) cur.textContent = fmt(t);
if (left) left.textContent = fmt(d - t) + ' left';
if (seek) {
seek.value = t;
if (seek.getAttribute('aria-valuetext') !== text) seek.setAttribute('aria-valuetext', text);
}
};
const tick = () => { sync(); raf = requestAnimationFrame(tick); };
const meta = () => { if (seek) seek.max = audio.duration; if (dur) dur.textContent = fmt(audio.duration); sync(); };
play.addEventListener('click', () => {
if (!audio.paused) return audio.pause();
document.querySelectorAll('audio').forEach(a => { if (a !== audio) a.pause(); });
audio.play().catch(() => {});
});
audio.addEventListener('play', () => { root.setAttribute('data-playing', ''); play.setAttribute('aria-label', 'Pause ' + name); tick(); });
audio.addEventListener('pause', () => { root.removeAttribute('data-playing'); play.setAttribute('aria-label', 'Play ' + name); cancelAnimationFrame(raf); sync(); });
audio.addEventListener('loadedmetadata', meta);
if (audio.readyState > 0) meta();
if (seek) seek.addEventListener('input', () => { audio.currentTime = seek.value; sync(); });
// Arrow keys jump 5 seconds; Home and End work natively.
if (seek) seek.addEventListener('keydown', e => {
const step = { ArrowLeft: -5, ArrowDown: -5, ArrowRight: 5, ArrowUp: 5 }[e.key];
if (!step) return;
e.preventDefault();
audio.currentTime = Math.min(Math.max(audio.currentTime + step, 0), length());
sync();
});
if (mute) {
mute.addEventListener('click', () => { audio.muted = !audio.muted; });
audio.addEventListener('volumechange', () => mute.setAttribute('aria-pressed', String(audio.muted)));
}
});
})();
About this set
Three small players for when the music should stay out of the way. The sticky mini player bar uses position: sticky with bottom: 0, so it docks to the bottom of the viewport while the page scrolls; it shows a cover, title and artist, the elapsed and total time, a mute button and a play button, and a 4 pixel progress line along its top edge is a real range input whose thumb appears on hover and keyboard focus. Below 420 pixels the times hide. The pill player is a single rounded line with a play button, the title and artist, the elapsed time and a round cover, and its background fills from the left as the track plays; in Bootstrap that fill is a .progress element behind the content. The floating ring widget wraps a cover-art play button in a 56 pixel conic-gradient ring that closes as the track plays, with the title, artist and time left beside it; give it position: fixed with a right and bottom offset to float it in a corner. The script writes one –p custom property from 0 to 1 that drives all three progress displays.
What’s included
Sticky bar that docks to the bottom of the viewport
Thin seek line whose thumb appears on hover and focus
Pill whose background fills as the track plays
Conic-gradient progress ring around a cover-art play button
Remaining time written as 0:16 left
Bootstrap version uses .card, .rounded-pill, .progress and .form-range
Accessibility
Each widget is a group named after the track. The play button is labelled Play or Pause with the track name, the progress line in the sticky bar is a range input with an aria-label and aria-valuetext, and the mute button uses aria-pressed. The pill fill and the ring are decorative because the elapsed or remaining time is shown as text. Keyboard focus draws a 2px outline on buttons and reveals the slider thumb with a ring; the thumb fade is removed under prefers-reduced-motion.
Customise it
Change --mw-accent, --mw-soft and --mw-track on .mw; --mw-soft is the pill fill and --mw-track the empty part of the line and ring. In Tailwind edit the --color-mw-* theme colours, and the size- utilities on the ring and buttons.