Last week I was catching up on some podcasts and found myself sufficiently bemused by an episode of The Adventure Zone that I posted a Thought about it. In putting the Thought together I found myself searching around for ways to link to the specific episode I was talking about. Shortly after I finished that post I found myself thinking about building a a way to embed a podcast episode.
I've been a The Adventure Zone fan for like 5 years now. Each adventure has been unique and fun and engaging in their own way. The latest season "Steeplechase" has felt a little wayward getting started — it uses a rather novel game system, but man episode 24 went completely off the rails in the way that most T.A.Z. adventures inevitably go. I was laughing out loud the whole way through.
Such a great show.
(Steeplechase starts here, whole show starts here)
What Are Podcasts?
At its heart a podcast is simply a collection of audio or video files organized and distributed via the web using a RSS feed. The RSS feed provides metadata and links to the download location of the files which the Podcast player application uses to construct an interface for the user. RSS is a standardized XML file, and due to their overwhelming domination of the marketplace Apple maintains some extensions to the format specification that they require feeds to conform to. This means that we should be able to build an embedable player from just the contents of the RSS feed.
Building The Embeded Code
I started by designing the embedded widget. The design of the widget will inform what information I need to extract from the RSS feed. I put together a static example in HTML and CSS rather quickly and in the end what I needed was the show title, an episode title, the audio file URL and optionally a URL to either the specific show or episode I'm referencing.
<div class="podcast-embed-player">
<div class="cover"></div>
<div class="info">
<span class="podcast-title">${title}</span>
<span class="podcast-episode">${episode}</span>
<div class="controls">
<button class="smallBtn rewind"></button>
<button class="playPauseBtn paused"></button>
<button class="smallBtn forward"></button>
<input class="volume"
type="range"
min="0"
max="100"
value="100">
</div>
<div class="progress">
<span class="time elapsed">--:--</span>
<progress id="progress" max="100" value="0">
</progress>
<span class="time remain">--:--</span>
</div></div>
The HTML was pretty easy, the CSS took a little while longer to get right but in the end wasn't too bad. I managed to make something that blends in pretty well to the rest of the site. With the UI design work done I moved on to write the loader and a simple set of audio playback controls.
The loader was easy, I find the root element and attach a stylesheet to it
via an injected <style>
element. This was basically copied verbatim
from how embedded Thoughts work.
(function() {
const root = document.querySelector('.podcast-embed-player');
if (! root) { return; }
const css = document.createElement('style');
css.setAttribute('type', 'text/css');
css.innerHTML = `@import url("${css_url}");`;
root.appendChild(css);
})();
To create the audio player I first bind event handlers to the various control elements.
controls.querySelector('.forward').addEventListener('click', (evt) => {
audio.currentTime += 15.0;
});
controls.querySelector('.playPauseBtn')
.addEventListener('click', (evt) => {
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
});
controls.querySelector('.rewind').addEventListener('click', (evt) => {
audio.currentTime -= 15.0;
});
progress.addEventListener('click', (evt) => {
const seekTo = (evt.offsetX / evt.target.offsetWidth) *
audio.duration;
audio.currentTime = seekTo;
});
And then I bind handlers to the audio element events.
const onTimeChange = (evt) => {
const progress_el = root.querySelector('div.progress');
const elapsed_el = progress_el.querySelector('.elapsed');
const remain_el = progress_el.querySelector('.remain');
elapsed_el.innerHTML = prettyTime(evt.target.currentTime);
remain_el.innerHTML = prettyTime(
evt.target.duration - evt.target.currentTime
);
progress.value = 100 * (
evt.target.currentTime / evt.target.duration
);
};
audio.addEventListener('ended', (evt) => {
const button_el = root.querySelector('.playPauseBtn');
button_el.classList.remove('playing');
button_el.classList.add('paused');
});
audio.addEventListener('durationchange', onTimeChange);
audio.addEventListener('timeupdate', onTimeChange);
audio.addEventListener('pause', (evt) => {
const button_el = root.querySelector('.playPauseBtn');
button_el.classList.remove('playing');
button_el.classList.add('paused');
});
audio.addEventListener('play', (evt) => {
const button_el = root.querySelector('.playPauseBtn');
button_el.classList.remove('paused');
button_el.classList.add('playing');
});
Finally, I threw together a way to save the user's last volume setting to their browser's LocalStorage.
function loadSavedVolume()
{
if (! window.localStorage) { return 1.0; }
const rv = window.localStorage
.getItem('goingflying-podcast-embed');
if (! rv) { return 1.0; }
return rv / 100;
}
audio.volume = loadSavedVolume();
volume.addEventListener('change', (evt) => {
if (! window.localStorage) { return; }
window.localStorage.setItem(
'goingflying-podcast-embed',
audio.volume * 100
);
});
The resulting JavaScript file
is attached to the HTML fragment via a simple <script>
tag.
Building The UI
Now I want to to render out the HTML fragment to provide it to the user as a
copyable block of code. I do this by parsing the HTML template
into a Document instance and appending the information that we'd be getting
from the RSS feed. The saucy trick here is returning the constructed
Document
as a string by leveraging the
outerHTML
method. Armed with the values from the RSS feed I can just call
create_podcast_embed()
and get a string that I can pass into the srcdoc
attribute of an <iframe>
. The result is suitable for pasting into
blog posts and whatnot.
function create_podcast_embed(title, episode, logo, audio, link)
{
const podcast_template = `<html><body>
<div class="podcast-embed-player">
<div class="cover"></div>
<div class="info">
<span class="podcast-title">${title}</span>
<span class="podcast-episode">${episode}</span>
<div class="controls">
<button class="smallBtn rewind"></button>
<button class="playPauseBtn paused"></button>
<button class="smallBtn forward"></button>
<input class="volume"
type="range"
min="0"
max="100"
value="100">
</div>
<div class="progress">
<span class="time elapsed">--:--</span>
<progress id="progress" max="100" value="0">
</progress>
<span class="time remain">--:--</span>
</div></div>
<script src="https://www.going-flying.com/~mernisse/podcast-embed/podcast-player.js" crossorigin="anonymous"></script>
</body></html>`;
const dom = (new DOMParser()).parseFromString(
podcast_template,
'text/html'
);
const root = dom.querySelector('.podcast-embed-player');
const logo_container = root.querySelector('.cover');
const logo_el = document.createElement('img');
logo_el.src = logo;
logo_container.appendChild(logo_el);
const controls = root.querySelector('.controls');
const audio_el = document.createElement('audio');
audio_el.src = audio;
audio_el.setAttribute('preload', 'metadata');
controls.appendChild(audio_el);
if (link) {
const t = root.querySelector('.podcast-title');
const a = document.createElement('a');
a.setAttribute('href', link);
a.setAttribute('target', '_blank');
a.appendChild(document.createTextNode(t.innerHTML));
t.replaceChildren(a);
}
return dom.body.outerHTML;
}
const frame = document.createElement('iframe');
frame.style.border = 'none';
frame.style.height = '200px';
frame.style.width = '585px';
frame.srcdoc = create_podcast_embed(
show.dataset.title,
ep.dataset.title,
ep.dataset.logo,
ep.dataset.audio,
show.dataset.link
);
I whipped up a quick and dirty chooser UI that takes a URL, POSTs it to an API endpoint that gets a RSS URL and returns a JSON object. It takes the JSON data and creates a drop down with an event handler that constructs the embed.
function load_episode_chooser(data)
{
const base = document.querySelector('#podcastForm');
base.replaceChildren(base.firstElementChild);
const el = document.createElement('select');
el.dataset.title = data.title;
el.dataset.link = data.link;
el.id = 'episodeSelect';
for (const i in data.episodes) {
const option = new Option(data.episodes[i].title);
option.dataset.audio = data.episodes[i].audio;
option.dataset.logo = data.episodes[i].logo ||
data.default_logo;
option.dataset.title = data.episodes[i].title;
el.add(option);
}
el.addEventListener('change', (evt) => {
const show = evt.target;
const ep = show.options[show.selectedIndex];
const preview = document.querySelector('#elementContainer');
const source = document.querySelector('#sourceContainer');
const frag = create_podcast_embed(
show.dataset.title,
ep.dataset.title,
ep.dataset.logo,
ep.dataset.audio,
show.dataset.link
);
const frame = document.createElement('iframe');
frame.style.border = 'none';
frame.style.height = '200px';
frame.style.width = '585px';
frame.srcdoc = frag;
preview.replaceChildren(frame);
const copyButton = document.createElement('button');
copyButton.innerHTML = 'Copy code';
copyButton.addEventListener('click', async (evt) => {
evt.preventDefault();
try {
await navigator.clipboard.writeText(
frame.outerHTML
);
evt.target.innerHTML = 'Copied!';
window.setTimeout(() => {
evt.target.innerHTML = 'Copy code';
}, 2000);
} catch (e) {
console.error('Failed to copy.');
}
});
source.replaceChildren(copyButton);
});
const div = document.createElement('div');
const label = document.createElement('label');
const idiv = document.createElement('div');
div.classList.add('selectContainer');
div.appendChild(el);
label.setAttribute('for', 'episodeSelect');
label.innerHTML = 'Select Episode';
idiv.classList.add('input');
idiv.appendChild(label);
idiv.appendChild(div);
base.appendChild(idiv);
}
Fetching The Feed
For the sake of speed of deployment, Azure Functions provided a reasonable way to handle the work of fetching a RSS feed and extracting the desired metadata to a JSON object. I find serverless computing to be a really good fit for this sort of thing. Thanks to Feedparser and Requests the function ended up being really simple.
def main(req: func.HttpRequest) -> func.HttpResponse:
''' Generate a list of Podcast episodes from a feed. '''
if not req.get_json():
return func.HttpResponse(
json.dumps({'message': 'client error'}),
status_code=400,
mimetype='application/json'
)
body = req.get_json()
url = body.get('url')
if not url:
return func.HttpResponse(
json.dumps({'message': 'client error'}),
status_code=400,
mimetype='application/json'
)
resp = requests.get(url, headers={'User-Agent': UA})
if not resp.ok:
return func.HttpResponse(
json.dumps({
'code': resp.status_code,
'reason': resp.reason,
'message': 'url fetch error',
'url': resp.url
}),
status_code=500,
mimetype='application/json'
)
try:
parsed = {}
feed = feedparser.parse(resp.text)
parsed['default_logo'] = feed.feed.get('image', {}).get('href')
parsed['count'] = len(feed.entries)
parsed['episodes'] = []
parsed['link'] = feed.feed.get('link')
parsed['title'] = feed.feed.get('title')
for entry in feed.entries:
episode = {}
episode['audio'] = getEnclosure(entry)
episode['logo'] = entry.get('image', {}).get('href')
episode['title'] = entry.get('title')
if not episode['audio']:
continue
parsed['episodes'].append(episode)
except Exception:
return func.HttpResponse(
json.dumps({
'reason': str(e),
'message': 'feed parse error',
'url': resp.url
}),
status_code=500,
mimetype='application/json'
)
rv = json.dumps(parsed)
return func.HttpResponse(rv, mimetype='application/json')
As I tweaked the UI for creating the embed, I gilded the lily a bit, adding the ability to get the underlying RSS feed URL from Apple Podcasts or Google Podcasts links, since most podcast websites provide a subscription widget to one or both of those services while a diminishing number provide an easily found link to the RSS feed itself. The completed function can be found in my git repository.
Result
In a few short hours my efforts were rewarded with a really clean, usable, and fully featured way to embed a podcast episode from an arbitrary podcast feed. The nice part is that other than a short JavaScript and CSS file, my server isn't involved in displaying the embed so it could be used with very little impact on any number of third party sites. I expect I'll primarily use it in my own blog posts but I am always happy when the things that I build work out as useful. The git repositories for the Azure Function and the website are available if you find yourself needing to build something similar.