Integration guide
Add a player to your site
One script tag gives you a working player. The same script gives you a JavaScript API, so if none of the ready-made layouts suits your design you can build your own and still get playback, episode data and listening stats. No build step, no framework, no npm package.
01Get a player ID
A player is the thing you embed. It points at a whole podcast or at specific episodes, and it has a public ID you paste into your HTML.
- Open Account, then Audio players.
- Create a player, name it for your own reference, and add what it should play: a whole podcast, one episode, or several episodes in the order you choose.
- Copy the player ID.
The ID is meant to be public.It sits in your page source where anyone can read it, and that is fine. It grants one thing: permission to play the episodes you put in that player. It is not an API key, it reaches nothing else in your account, and deactivating the player revokes it everywhere at once.
Adding an episode later makes it appear in every embed of that player straight away. Nothing needs re-pasting.
02Paste two lines
Put the script anywhere on the page, and the div where you want the player.
<script src="https://www.obbo.me/player/obboplayer.js" async></script>
<div data-obbo-player="YOUR_PLAYER_ID"></div>That is a complete integration. The script finds every element with a data-obbo-player attribute and fills it in, whether the page has one player or ten. Loading the script twice is harmless.
Nothing downloads until someone presses play.The player fetches its episode list on load, but no audio is requested until a visitor actually starts one. Putting a player on a page costs your visitors nothing and never registers a play that did not happen.
03Pick a layout
Seven are built in. Choose one with data-obbo-ui; leave it off and you get whichever you selected when you made the player, so you can change your mind later without editing any pages.
| Value | What it is |
|---|---|
| list | The controls with your episode list underneath. For a whole podcast, or when someone might want an episode other than the newest. |
| bar | Just the controls. For an article or product page built around one episode. |
| card | Cover art at full size with the button on its edge. For sidebars and grids, where a horizontal bar looks stapled on. |
| inline | A play button and a clock, sized to sit inside a sentence rather than interrupt it. |
| dock | Pins to the bottom of the window once someone presses play, so the controls do not scroll away. Dismissible. |
| transcript | The episode as readable text that follows the audio. The spoken line is highlighted; click any line to jump there. |
| auto | The default. Follows the layout saved with the player. |
<div data-obbo-player="YOUR_PLAYER_ID"
data-obbo-ui="list"
data-obbo-theme="dark"></div>Every attribute
| data-obbo-player | Required. Your player ID. |
| data-obbo-ui | list, bar, card, inline, dock, transcript, or auto. |
| data-obbo-theme | light or dark. |
| data-obbo-episode | Start on a particular episode. It is selected, not played. |
| data-obbo-isolate | Render inside a shadow root, sealed off from your CSS. |
| data-obbo-locale | Language for the player's own buttons: en, es, da or ja. Episode titles come from your podcast either way. |
| data-obbo-language | Which language of the audio to play: en, es, da or ja. Defaults to your page's own lang, and plays the original when an episode has no render in that language. |
| data-obbo-autoadvance | Play the next episode when one ends. |
Several players on one page is fine.They share a single request for your episode list, and starting one pauses the others, so a visitor never ends up with two voices at once.
04Make it look like your site
The player renders into your page's own DOM, not a sealed frame. Your stylesheet reaches it and your fonts are inherited.
The quick way: nine variables
| --obbo-player-bg | #ffffff |
| --obbo-player-fg | #1c2733 |
| --obbo-player-muted | #66768a |
| --obbo-player-accent | #2f6fed |
| --obbo-player-accent-fg | #ffffff |
| --obbo-player-border | #e3e8ef |
| --obbo-player-row-hover | #f4f6f9 |
| --obbo-player-radius | 12px |
| --obbo-player-font | system stack |
.my-player {
--obbo-player-accent: #e5484d;
--obbo-player-radius: 4px;
--obbo-player-font: 'Söhne', system-ui, sans-serif;
}
@media (prefers-color-scheme: dark) {
.my-player {
--obbo-player-bg: #181d24;
--obbo-player-fg: #f2f5f8;
}
}The thorough way: class names
Every element carries a stable obbo- class, and every rule we ship is a single class selector, so your own rule wins without needing !important. The root also carries data-obbo-state, which lets you style the loading and error phases without reading our internals.
| .obbo-p | The player root. Carries data-obbo-state. |
| .obbo-play | Play and pause button. |
| .obbo-seek | The scrubber, an input of type range. |
| .obbo-title / .obbo-sub | Episode title and podcast name. |
| .obbo-art | Cover image. Hidden when the podcast has none. |
| .obbo-list / .obbo-row | Episode list and its rows. The playing row carries aria-current. |
| .obbo-note | The loading, empty and error message. |
.obbo-p[data-obbo-state="loading"] { opacity: 0.6; }
.obbo-p[data-obbo-state="error"] { display: none; }If your CSS is aggressive, seal it off.A heavy framework reset can reach into the player. Add data-obbo-isolate and it renders in a shadow root instead: immune to your stylesheet, though the nine variables still pass through, so you keep the colour controls and lose only the class-level ones.
05Build your own player
Skip the layouts entirely. createPlayer gives you episode data, playback and position; you write the markup. Listening still gets counted and expired audio links still recover, without either being your problem. The controls below are running the code underneath them.
<button id="play" disabled>Play</button>
<span id="title"></span>
<div id="track"><div id="fill"></div></div>
<script>
var player = Obbo.createPlayer({ widgetId: 'YOUR_PLAYER_ID' });
player.subscribe(function (state) {
play.disabled = state.status !== 'ready';
play.textContent = state.playing ? 'Pause' : 'Play';
title.textContent = state.episode ? state.episode.title : '';
fill.style.width = (state.position / state.duration * 100) + '%';
});
play.onclick = function () { player.toggle(); };
</script>Keep the button disabled until status is ready.On iOS, permission to play audio is tied to the tap that asked for it. A tap the player cannot act on yet is spent for nothing, and your visitor has to tap again.
06The follow-along transcript
The transcript layout puts the whole episode on your page as text, and keeps it in step with the audio. Every Obbo episode is transcribed with sentence-level timings as part of production, so there is nothing for you to prepare.
- The line being spoken is highlighted and scrolls itself into view. Scroll it yourself and it stops following, with a button to jump back — it will not fight you for the scrollbar.
- Clicking any line plays from there, which makes the episode quotable: a reader can find the sentence they want and hear it said.
- The text is real text in your page, so search engines index it and screen readers read it. An episode that would otherwise be an opaque audio box becomes content about your subject.
<div data-obbo-player="YOUR_PLAYER_ID"
data-obbo-ui="transcript"></div>Not switched on yet.Transcripts are produced for every episode but are not yet published on the embed API, so this layout currently shows the player with a note in place of the text. It will fill in on its own once we turn the field on — nothing on your page needs to change.
07API reference
Obbo.createPlayer(options)
| widgetId | Required. Your player ID. |
| locale | Language for built-in labels. Defaults to en. |
| autoAdvance | Continue to the next episode. Off by default. |
| exclusive | Pause other players on the page when this one starts. On by default. |
The player
| subscribe(fn) | Calls your function with the full state on every change, and once immediately. Returns a function that unsubscribes. |
| getState() | The current state, if you would rather pull than subscribe. |
| play(episodeId?) | Start playing, optionally switching episode first. |
| pause() / toggle() | The obvious things. |
| select(episodeId) | Switch episode without playing. Makes no request. |
| seek(seconds) | Jump to a position, in seconds. |
| setRate(rate) | Playback speed, from 0.75 to 2. |
| next() / previous() | Move through the episode list. |
| reload() | Fetch the episode list again. |
| destroy() | Stop and detach. Call it when your component unmounts. |
| media | The underlying audio element, for anything we have not thought of. |
The state
| status | loading, ready, empty or error. |
| error | Empty, or blocked when the browser refused without a tap, load_failed, or unavailable for a wrong or revoked ID. |
| episodes | Every episode in the player: title, description, number, date and duration. |
| episode | The selected one. Selected does not mean playing. |
| podcast | Title, company name and cover image. |
| playing | Whether audio is running right now. |
| position / duration | Both in seconds. |
| playable | Whether the selected episode can be played at all. |
Also on Obbo
| Obbo.formatTime(s) | Turns 65 into 1:05, the same way the built-in layouts do. |
| Obbo.ui.list(el, player, opts) | Mount a built-in layout yourself. Returns an object with destroy. |
| Obbo.mount(root?) | Scan for player elements and mount them. Call it after adding markup dynamically. |
| Obbo.unmountElement(el) | Tear one down. |
| Obbo.version | Worth quoting in a bug report. |
08React, Vue and friends
Do not rely on the automatic scan. A framework that re-renders the container will wipe out the player's markup and leave its event listeners behind. Mount it yourself and tear it down on unmount, into an element you never render into.
function Player({ widgetId }) {
const host = useRef(null);
useEffect(() => {
const player = Obbo.createPlayer({ widgetId });
const ui = Obbo.ui.list(host.current, player);
return () => { ui.destroy(); player.destroy(); };
}, [widgetId]);
return <div ref={host} />;
}Load the script once, in your document head or through a small loader, before the effect runs. The same applies to Vue's onMounted and onUnmounted, and to Svelte's onMount.
09Content-Security-Policy
If your site sends a Content-Security-Policy header, it needs three directives.
| script-src https://www.obbo.me | To load the player. |
| connect-src https://app.obbo.me | To fetch your episode list. |
| media-src https://app.obbo.me https://*.amazonaws.com | To stream the audio itself. |
media-src is the one people miss.Audio is served from storage on a different host than the API. Leave it out and the player looks completely healthy, right up until someone presses play.
10What gets counted
Listening on your site shows up in your Obbo analytics: how many times the player loaded, how many plays started, and how much was actually heard.
- Your visitors are never identified. No account, no login, and the player sets no cookie. Reports are attributed to you as the owner, not to whoever is listening.
- A page view is not a play. Nothing counts as a play until audio actually starts.
- Listening time is measured rather than assumed. Skipping ahead does not count as having heard what you skipped.
11When it does not work
Nothing appears at all
Check the element has data-obbo-player with your ID, and that the script tag is on the page. If you added the markup after the page loaded, call Obbo.mount() once afterwards.
It says the player is unavailable
The ID is wrong, or the player has been deactivated in your account. Both give the same message on purpose.
It says there are no episodes
The player is empty, or its episodes are still being produced. Only finished episodes are served.
It looks right but will not play
Almost always media-src missing from your Content-Security-Policy. Otherwise check state.error: blocked means the browser wanted a real tap first, which happens when playback is triggered by something other than a click.
It looks wrong
Your page's CSS is reaching into it, which is usually the point but occasionally is not. Add data-obbo-isolate to seal it off.
Still stuck?Send us the page address, your player ID and the value of Obbo.version at hello@obbo.me