A split-screen demo of an episode system built on an RSS.com feed, with no RSS.com embed anywhere in it.
Left: the editor screen — every field is always editable. Fetching an episode URL is an optional shortcut that fills them in; you can also type an episode from scratch and never touch RSS.com. Right: what visitors see, rendered from those stored records.
Live: https://oddbird.github.io/rss-episode-player-demo/
python3 -m http.server 8899Then open http://127.0.0.1:8899/. It has to be served over HTTP rather than
opened as a file:// URL.
There is no build step and no server code. Published episodes are kept in
localStorage, so the demo survives a reload; Reset to seed clears it.
A record is flat. Once published, an episode is just a row we own:
Every one of those fields is editable, including the audio and image URLs — so an episode can be entered entirely by hand, pointing at media hosted anywhere. Leave the audio URL empty and the episode still publishes, just with an inactive play button; leave the image URL empty and it gets a placeholder cover.
There is no memory of which fields came from RSS.com, because after publishing it does not matter. Fetch is only a prefill helper: it fills the form with the episode's current details so nobody types them by hand. Press it again on a different URL and the form simply refills. Nothing keeps syncing afterwards — to reset a field to what RSS.com says, re-fetch the episode.
The URL carries both the show and the episode, so there is no separate show setting. Paste a URL from any show and it works; the demo will happily mix episodes from different podcasts.
Open Stored record under any episode to see exactly what is kept.
There are two different URLs, and it matters which one we keep:
| URL | Signed? | Expires? | |
|---|---|---|---|
| What the feed gives us | content.rss.com/episodes/{show}/{ep}/{slug}/{file}.mp3 |
no | never |
| What that redirects to | rsscom.pdn.tritondigital.com/...?Expires=…&Signature=… |
yes | 10 hours |
The feed URL has no query string at all. Every request to it mints a fresh signed URL at RSS.com's edge — requested twice two seconds apart, it returns two different signatures, each valid for exactly 10 hours.
So: store the feed URL, hand it to <audio src>, let the browser follow the
redirect. No signing on our side, no regeneration per page render, no cron job
to refresh links. The 10-hour window belongs to a URL the browser resolves and
discards; it never reaches our database.
Because playback still travels through the tracking redirect, plays continue to count in RSS.com's analytics. Building our own player does not route around their stats.
The ID cannot be turned into an audio URL by string-building. The filename carries an upload timestamp and a random UUID:
content.rss.com/episodes/88524/1253951/podcasting101/2023_12_06_20_29_49_d52d5176-….mp3
^show ^episode ^slug ^upload time ^random
Only the middle segments are predictable. The filename has to be read from the feed — which is what pressing Fetch does. One request gets every episode, so one request covers whatever you paste. Nothing is cached — each fetch pulls the whole feed and keeps only the episode asked for.
The feed is served no-cache, so in production this should happen on save,
not on render. Resolve once, store the fields, serve pages from our own
database — exactly what the demo does.
media.rss.com serves the feed with access-control-allow-origin: *, on both
the actual request and the preflight. So the browser can read and parse it
directly, which is why this demo needs no proxy and no PHP — and why it could
ship later as a WordPress admin script rather than server code.
That said, fetching server-side in WordPress is still the better default:
wp_remote_get has no CORS constraint at all, it can be scheduled, and it keeps
working if RSS.com ever tightens those headers. The client-side path is proven
to work; it is not automatically the right choice.
- One shared audio element. Press play on a second episode and the first stops. Four embed iframes each own their own audio and expose no inbound message channel, so they play over each other.
- Titles wrap. The embed marquee-scrolls long titles sideways at card width.
- Durations before playback. Read from the record, so
preload="none"holds and no audio is fetched until someone presses play. The grid itself renders with no network calls at all — audio is the only thing ever fetched, and only on a click. While it loads, the play button spins and an indeterminate bar sweeps the scrub track; the same state shows if playback stalls to rebuffer. - Any design at all — the embed renders one fixed layout in one of three themes, and it is cross-origin, so CSS cannot reach in.
- Editing. Titles, descriptions, artwork, season and episode numbers are ours to change. The embed shows whatever RSS.com holds, and nothing else.
index.html |
the split screen |
admin.js |
feed fetching, the record store, the editor |
admin.css |
editor chrome |
player.js |
the front end: cards and shared playback |
player.css |
the site's own styles |
resolve_feed.py |
the same lookup done server-side, if we go that route |
episodes.json |
sample output of the above |
resolve_feed.py is not used by the page. It stands in for a WordPress save
hook and shows the shape of what would land in post meta:
python3 resolve_feed.py podcasting101 1253951 1207269- Verified in Chromium via a scripted browser. Worth a manual pass on Safari and a real phone before committing to the approach.
- Artwork is a URL, not an upload. Real WordPress would point this at the media library instead, which is the one field that would not stay a plain text input.
- Autoplay policies mean the first play must come from a user gesture. Fine here, since playback only ever starts from a click.
- If an episode is deleted at RSS.com, the stored URL 404s. Whatever refreshes the feed should flag episodes that have vanished.
- The main test show does not use season numbers, so
S2 E4renders asE32unless an editor types the season in. (The Spanish-language sample show does set seasons, which is why fetching from it fills that field.)
{ "id": "rec-1756150000000", "episode_id": "1048583", "show_slug": "podcasting101", "permalink": "https://rss.com/podcasts/podcasting101/1048583", "audio_url": "https://content.rss.com/episodes/…mp3", "artwork": "https://media.rss.com/…jpg", "title": "Growing a Show on Discord", "summary": "…", "season": 2, "number": 4, "duration_seconds": 2104, "published": "2023-08-02T12:00:00.000Z" }