Self-hosting the podcast player
Every publish target already gets a fully working native audio player with zero setup. This page is for a plain WordPress site (no mylandingpage.ai plugin) that wants the upgraded custom player: bigger controls, skip -10s/+20s, a seekbar.
What this does
Blog-Maker publishes a podcast episode as a plain <figure class="bm-podcast-player"> containing a native <audio controls> element and a fallback link. That already works everywhere, no setup required.
The snippets below add a modern control bar on top of it: play/pause, skip -10s/+20s, a seekbar with elapsed/remaining time, and a mute button. It's a Progressive Enhancement: if you never add these files, visitors still get the plain native player. Nothing breaks either way.
Why self-hosted, not linked from blog-maker.com
We could offer this as a single <script src="https://blog-maker.com/podcast-enhancer.js">, but that means every visitor's browser makes a request to our servers on every page load, exposing their IP address to us for no reason they'd expect. Under GDPR that's a third-party data flow you'd need to disclose and often get consent for, purely for a UI upgrade.
So instead: copy both files onto your own server. Zero external requests, nothing to disclose, nothing to add to your cookie/consent setup. The JS itself makes no network calls of its own (no analytics, no tracking, no calls back to us) -- it only listens to events on the audio element already on your page.
The audio file itself: if you're on mylandingpage.ai or already have Blog-Maker sideloading the .mp3 into your own media library, it's already first-party. On a plain WordPress site without that, the audio still streams from blog-maker.com's CDN when a visitor presses play (not before -- preload="none", see the markup below). That's a deliberate, user-initiated request, the same trust model as embedding any third-party media player.
The markup you'll see (nothing to build here)
This is what Blog-Maker pushes into your post content automatically. You don't write this -- it's here so you know what the snippets below are targeting.
<figure class="bm-podcast-player" data-bm-podcast="2"> <figcaption>Podcast episode: Show Name</figcaption> <audio controls preload="none" src="https://blog-maker.com/audio/podcasts/..."></audio> <p><a href="https://blog-maker.com/audio/podcasts/..." rel="noopener">Play the episode as audio, right in your browser</a></p> </figure>
1. Save this as a CSS file on your server
Fixed colors, not your theme's own custom properties: a copy-paste snippet has to work standalone. Feel free to adjust the hex values to match your brand.
.bm-podcast-player {
margin: 1.75rem 0;
padding: 1rem 1.25rem;
border: 1px solid rgba(10, 57, 78, 0.16);
border-radius: 12px;
background: #eef1f3;
box-shadow: 0 1px 3px rgba(10, 57, 78, 0.08);
min-height: 132px;
}
@media (max-width: 640px) {
/* 132px was measured against the desktop (single-row) control bar. At
mobile widths the 6-item control bar wraps to two rows: live-measured
215px at 375px AND 320px viewport width (design-reviewer finding,
2026-08-24). 240px, not 220px: that measurement predated the
.bm-podcast-mute touch-target fix in this same commit, which grows
the wrapped row by another 8px -- two independent re-reviews both
live-measured the combined real height at 223px. */
.bm-podcast-player { min-height: 240px; }
}
/* Video gets no card wrap: the Bunny iframe already brings its own
modern controls, a bordered/padded/background box would just double
up on chrome the iframe already has. */
.bm-podcast-video { margin: 1.75rem 0; }
.bm-podcast-player figcaption,
.bm-podcast-video figcaption {
margin: 0 0 0.5rem;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.01em;
color: #4a6363;
}
.bm-podcast-player audio {
display: block;
width: 100%;
height: 40px;
}
.bm-podcast-player audio[data-bm-enhanced] { display: none; }
.bm-podcast-video iframe {
display: block;
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
border: 0;
border-radius: 8px;
}
.bm-podcast-player p,
.bm-podcast-video p { margin: 0.625rem 0 0; }
.bm-podcast-player p a,
.bm-podcast-video p a {
font-size: 14px;
font-weight: 500;
color: #c2410c;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
.bm-podcast-player p a:hover,
.bm-podcast-video p a:hover { color: #9a3412; }
.bm-podcast-player p a:focus-visible,
.bm-podcast-video p a:focus-visible {
outline: 2px solid #c2410c;
outline-offset: 2px;
border-radius: 4px;
}
.bm-podcast-controls { display: flex; align-items: center; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
.bm-podcast-btn {
display: inline-flex; align-items: center; justify-content: center;
flex: 0 0 auto; width: 36px; height: 36px; padding: 0; border: none;
border-radius: 999px; background: #e8edf0; color: #0a394e; cursor: pointer;
}
.bm-podcast-btn:hover { background: #dde4e8; }
.bm-podcast-btn:active { background: #ccd6db; }
.bm-podcast-btn:disabled { opacity: 0.7; cursor: default; }
.bm-podcast-btn:focus-visible { outline: 2px solid #c2410c; outline-offset: 2px; }
.bm-podcast-play {
width: 44px; height: 44px;
background: linear-gradient(to bottom right, #c2410c, #9a3412);
color: #ffffff;
}
.bm-podcast-play:hover { background: linear-gradient(to bottom right, #c2410c, #9a3412); filter: brightness(0.93); }
.bm-podcast-icon { width: 20px; height: 20px; fill: currentColor; }
.bm-podcast-play .bm-podcast-icon-pause { display: none; }
.bm-podcast-play.is-playing .bm-podcast-icon-play { display: none; }
.bm-podcast-play.is-playing .bm-podcast-icon-pause { display: block; }
.bm-podcast-mute .bm-podcast-icon-muted { display: none; }
.bm-podcast-mute.is-muted .bm-podcast-icon-vol { display: none; }
.bm-podcast-mute.is-muted .bm-podcast-icon-muted { display: block; }
.bm-podcast-skip { width: auto; min-height: 44px; padding: 0 10px; gap: 2px; }
.bm-podcast-skip .bm-podcast-icon { width: 18px; height: 18px; }
.bm-podcast-skip-n { font-size: 10px; font-weight: 700; }
.bm-podcast-time { flex: 0 0 auto; min-width: 32px; font-size: 12px; font-variant-numeric: tabular-nums; color: #4a6363; }
.bm-podcast-seek { flex: 1 1 auto; min-width: 60px; height: 4px; accent-color: #c2410c; cursor: pointer; }
.bm-podcast-seek::-webkit-slider-runnable-track { background: rgba(10, 57, 78, 0.18); border-radius: 2px; height: 4px; }
.bm-podcast-seek::-moz-range-track { background: rgba(10, 57, 78, 0.18); border-radius: 2px; height: 4px; }
.bm-podcast-seek::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #c2410c; cursor: pointer; margin-top: -5px; }
.bm-podcast-seek::-moz-range-thumb { width: 14px; height: 14px; border: none; border-radius: 50%; background: #c2410c; cursor: pointer; }
.bm-podcast-seek:disabled { cursor: default; opacity: 0.7; }
.bm-podcast-seek:focus-visible { outline: 2px solid #c2410c; outline-offset: 2px; }
.bm-podcast-mute { margin-left: 4px; min-width: 44px; min-height: 44px; }2. Save this as a JS file on your server
Verbatim, no build step needed. Load it with the defer attribute (or in the footer) so it never blocks page rendering.
/*
* Blog-Maker podcast player Enhancer.
*
* Progressive enhancement, vanilla, no dependencies: the base markup
* already works as a plain <audio controls> without this script. This
* script REPLACES the native controls with a custom UI: play/pause,
* seekbar, elapsed/remaining time, and skip -10s/+20s buttons.
*
* iOS Safari quirks handled explicitly:
* - Skip buttons and the seek input stay disabled until loadedmetadata
* fires, instead of quietly failing a seek against NaN/Infinity.
* - play() is only ever called from within a real user-gesture handler
* (the play button's own click listener), never programmatically.
*/
(function () {
"use strict";
function fmtTime(seconds) {
if (!isFinite(seconds) || seconds < 0) seconds = 0;
var m = Math.floor(seconds / 60);
var s = Math.floor(seconds % 60);
return m + ":" + (s < 10 ? "0" : "") + s;
}
// Icon visibility toggles on the BUTTON's own state class (is-playing,
// is-muted), never via `hidden` on the <svg> itself: SVGElement's
// `hidden` IDL reflection is inconsistent across engines. A class on an
// HTMLButtonElement is universally reliable.
var ICON_PLAY =
'<svg viewBox="0 0 24 24" class="bm-podcast-icon bm-podcast-icon-play" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>';
var ICON_PAUSE =
'<svg viewBox="0 0 24 24" class="bm-podcast-icon bm-podcast-icon-pause" aria-hidden="true"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>';
var ICON_BACK =
'<svg viewBox="0 0 24 24" class="bm-podcast-icon" aria-hidden="true"><path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/></svg>';
var ICON_FWD =
'<svg viewBox="0 0 24 24" class="bm-podcast-icon" aria-hidden="true"><path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/></svg>';
var ICON_VOL =
'<svg viewBox="0 0 24 24" class="bm-podcast-icon bm-podcast-icon-vol" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3z"/></svg>';
var ICON_MUTED =
'<svg viewBox="0 0 24 24" class="bm-podcast-icon bm-podcast-icon-muted" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3zm13.59 3l2.7-2.71-1.42-1.41L15.17 12l-2.7-2.71-1.41 1.41L13.76 13l-2.7 2.71 1.41 1.41L15.17 14l2.7 2.71 1.42-1.41L16.59 12z"/></svg>';
function enhance(audio) {
if (audio.dataset.bmEnhanced) return;
audio.dataset.bmEnhanced = "1";
var figure = audio.closest(".bm-podcast-player");
if (!figure) return;
audio.removeAttribute("controls");
var bar = document.createElement("div");
bar.className = "bm-podcast-controls";
bar.innerHTML =
'<button type="button" class="bm-podcast-btn bm-podcast-play" aria-label="Play">' +
ICON_PLAY + ICON_PAUSE + "</button>" +
'<button type="button" class="bm-podcast-btn bm-podcast-skip bm-podcast-skip-back" aria-label="Back 10 seconds" disabled>' +
ICON_BACK + '<span class="bm-podcast-skip-n">10</span></button>' +
'<button type="button" class="bm-podcast-btn bm-podcast-skip bm-podcast-skip-fwd" aria-label="Forward 20 seconds" disabled>' +
ICON_FWD + '<span class="bm-podcast-skip-n">20</span></button>' +
'<span class="bm-podcast-time bm-podcast-current">0:00</span>' +
'<input type="range" class="bm-podcast-seek" min="0" max="0" step="1" value="0" disabled aria-label="Seek position">' +
'<span class="bm-podcast-time bm-podcast-duration">0:00</span>' +
'<button type="button" class="bm-podcast-btn bm-podcast-mute" aria-label="Mute">' +
ICON_VOL + ICON_MUTED + "</button>";
// Insert right after the (now-hidden) audio, not appended to the end
// of the figure: the base markup already carries a <p><a> fallback
// link AFTER the audio, so appending here would put the bar below
// that link instead of above it (found live, 2026-08-26).
audio.insertAdjacentElement("afterend", bar);
var playBtn = bar.querySelector(".bm-podcast-play");
var backBtn = bar.querySelector(".bm-podcast-skip-back");
var fwdBtn = bar.querySelector(".bm-podcast-skip-fwd");
var curEl = bar.querySelector(".bm-podcast-current");
var durEl = bar.querySelector(".bm-podcast-duration");
var seek = bar.querySelector(".bm-podcast-seek");
var muteBtn = bar.querySelector(".bm-podcast-mute");
var scrubbing = false;
playBtn.addEventListener("click", function () {
if (audio.paused) {
// iOS Safari: preload="none" + no prior load() rejects the first
// play() silently. load() is synchronous, calling it right before
// play() keeps both in the same gesture.
if (audio.readyState === 0) audio.load();
audio.play().catch(function () {});
} else {
audio.pause();
}
});
audio.addEventListener("play", function () {
playBtn.classList.add("is-playing");
playBtn.setAttribute("aria-label", "Pause");
});
audio.addEventListener("pause", function () {
playBtn.classList.remove("is-playing");
playBtn.setAttribute("aria-label", "Play");
});
audio.addEventListener("ended", function () {
playBtn.classList.remove("is-playing");
playBtn.setAttribute("aria-label", "Play");
});
audio.addEventListener("loadedmetadata", function () {
var d = audio.duration;
if (isFinite(d) && d > 0) {
seek.max = String(Math.floor(d));
durEl.textContent = fmtTime(d);
seek.disabled = false;
backBtn.disabled = false;
fwdBtn.disabled = false;
}
});
audio.addEventListener("timeupdate", function () {
if (scrubbing) return;
curEl.textContent = fmtTime(audio.currentTime);
if (Number(seek.max) > 0) seek.value = String(Math.floor(audio.currentTime));
});
seek.addEventListener("input", function () {
scrubbing = true;
curEl.textContent = fmtTime(Number(seek.value));
});
seek.addEventListener("change", function () {
audio.currentTime = Number(seek.value);
scrubbing = false;
});
backBtn.addEventListener("click", function () {
audio.currentTime = Math.max(0, audio.currentTime - 10);
});
fwdBtn.addEventListener("click", function () {
var d = isFinite(audio.duration) ? audio.duration : audio.currentTime;
audio.currentTime = Math.min(d, audio.currentTime + 20);
});
muteBtn.addEventListener("click", function () {
audio.muted = !audio.muted;
muteBtn.classList.toggle("is-muted", audio.muted);
muteBtn.setAttribute("aria-label", audio.muted ? "Unmute" : "Mute");
});
audio.addEventListener("error", function () {
bar.remove();
delete audio.dataset.bmEnhanced;
audio.setAttribute("controls", "");
});
}
function scan(root) {
var scope = root || document;
var audios = scope.querySelectorAll(".bm-podcast-player audio");
for (var i = 0; i < audios.length; i++) enhance(audios[i]);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () { scan(); });
} else {
scan();
}
})();3. Load both files (WordPress example)
Add this to your theme's functions.php, adjusting the paths to wherever you saved the two files. The condition only loads the assets on an article that actually has a player, so every other page is completely unaffected.
// functions.php
add_action('wp_enqueue_scripts', function () {
global $post;
// This condition only checks for the AUDIO player marker. If you also
// publish podcast video episodes (figure.bm-podcast-video), broaden it to
// "|| strpos($post->post_content, 'bm-podcast-video') !== false" -- the
// CSS snippet above styles both, this JS only enhances audio (video
// embeds already have their own modern player controls).
if (!$post || strpos($post->post_content, 'bm-podcast-player') === false) return;
wp_enqueue_style(
'bm-podcast-player-styles',
get_stylesheet_directory_uri() . '/css/podcast-player.css',
[],
filemtime(get_stylesheet_directory() . '/css/podcast-player.css')
);
wp_enqueue_script(
'bm-podcast-enhancer',
get_stylesheet_directory_uri() . '/js/podcast-enhancer.js',
[],
filemtime(get_stylesheet_directory() . '/js/podcast-enhancer.js'),
['strategy' => 'defer', 'in_footer' => true]
);
});Keeping it up to date
These are copies of Blog-Maker's own canonical source. We won't silently change the CSS class names or the audio player markup (that would break every site that copied this snippet), but if we ship an improvement, check back here and re-copy. This page always reflects the current version.