A whole streaming player, in one iframe.
Point an iframe at a TMDB or IMDb id and it plays. Everything on this page is optional except the first snippet.
Quick start
Paste this. That is the whole integration.
- 1Drop in the iframe and point
srcat a title. - 2Keep the
allowandallowfullscreenattributes — fullscreen and picture-in-picture only work if the parent grants them. - 3Give it a 16:9 box. The player fills whatever you give it and never scrolls.
<iframe src="https://player.example.com/movie/550" style="width:100%;aspect-ratio:16/9;border:0" allow="autoplay; fullscreen; encrypted-media; picture-in-picture" allowfullscreen referrerpolicy="origin"></iframe>
Nothing loads until someone presses play. Until then the frame is a poster and about 80 KB of JavaScript — no video, no requests to our sources. Embed it on a listing page with ten titles and it stays cheap.
A TV episode
<iframe src="https://player.example.com/tv/1399/1/1" style="width:100%;aspect-ratio:16/9;border:0" allow="autoplay; fullscreen; encrypted-media; picture-in-picture" allowfullscreen></iframe> <!-- 1399 = Game of Thrones (TMDB), season 1, episode 1 -->
URL formats
Both id spaces, both shapes. If you already have code that builds URLs for another embed provider, it almost certainly works here unchanged.
| Form | Example |
|---|---|
| /movie/{id} | /movie/550 · /movie/tt0137523 |
| /tv/{id}/{season}/{episode} | /tv/1399/1/1 · /tv/tt0944947/1/1 |
| /embed/movie/{id} | Alias of the above, for drop-in replacement |
| /embed/tv/{id}/{season}/{episode} | Same |
| /?type=movie&tmdb={id} | Query form · id and imdb also accepted |
| /?type=tv&imdb={id}&season=1&episode=1 | Query form for series |
Ids: a TMDB id (digits) or an IMDb id (tt + digits). A series URL without a season and episode plays S01E01. Unknown query parameters are ignored rather than fatal, so copying a competitor's URL still gets you a player.
Options
Every one is a query parameter, and every one is optional. Booleans accept 1, true or a bare flag.
| Parameter | Value | What it does |
|---|---|---|
| t | seconds | Start position. ?t=930 opens at 15:30. |
| autoplay | boolean | Try to start without the poster press. Browsers block unmuted autoplay in a cross-origin frame, so treat this as a hint, not a guarantee. |
| poster | https URL | Your own artwork on the click-to-play screen. |
| sub | https URL | An extra subtitle track, listed first in the picker. WebVTT or SRT. |
| sub_lang | label | What to call that track — e.g. ?sub_lang=Español. |
| color | hex, no # | Accent for the whole frame — the play badge, the seek bar, focus rings and every control that takes the brand colour. ?color=e5a50a. Three or six digits. Your colour is kept; the labels on it flip between white and near-black by measured contrast, so an amber accent stays amber and reads at WCAG AA either way. |
| nextbutton | boolean | On by default for series. ?nextbutton=0 hides it. |
| autonext | boolean | Roll into the next episode when one ends. |
| server | number | Pin one source instead of letting the player choose. Read it off SOURCE_CHANGE first. |
| origin | your origin | Locks messaging to your site: events are posted only to this origin, and commands are accepted only from it. Recommended. |
// Episode 3, start at 2 minutes, auto-advance, your accent, locked to your origin https://player.example.com/tv/1399/1/3?t=120&autonext=1&color=e5a50a&origin=https://yoursite.com
Iframe setup
Five things decide whether the player works properly in your page.
- allow
autoplay; fullscreen; encrypted-media; picture-in-picture. These are parent-granted permissions — without them the buttons exist but do nothing.- allowfullscreen
- Still required by older engines alongside
allow="fullscreen". Cheap to keep. - referrerpolicy
origin. It sends ushttps://yoursite.comand never the page path — which is how the player knows whose site it is playing on, and so which titles to keep working for you.no-referrerhides that in Firefox and costs you the reporting; anything looser hands us a URL we do not want.- Your CSP
- If your site sends a
Content-Security-Policy, addframe-src https://player.example.comor the frame never loads. - no sandbox
- Leave the attribute off. The player is ad-supported and a sandboxed frame cannot open the advertising window, so it earns nothing. If your platform forces one, it must include
allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox— the last token is the one that matters, and without it the ad opens into your sandbox, does not count, and the player refuses to start.
Nothing else is required. The frame needs no extra attributes and no script on your page — drop it in, give it a box, and it plays. Add loading="lazy" if it sits below the fold.
Responsive without a wrapper
.embed-player {
width: 100%;
aspect-ratio: 16 / 9; /* no padding-top hack needed */
border: 0;
border-radius: 12px;
background: #000;
}Events & control
The player talks to your page over postMessage, in both directions. Every message carries source: "embed-player"and version: 1 — check it, because any frame can post to your window.
What the player sends you
| Event | data | When |
|---|---|---|
| READY | — | The frame is listening. Safe to send commands. |
| PLAY | — | The viewer pressed play on the poster. |
| SOURCE_CHANGE | { server, name } | A source answered and is now playing. |
| TIME_UPDATE | { currentTime, duration } | Once a second while playing — enough for your own resume. |
| NEXT_EPISODE | { season, episode } | The player moved to another episode (auto-next or its own drawer). Sync your list to it. |
| ENDED | — | Playback finished. |
| MEDIA_UNAVAILABLE | — | No source could serve this title. Show your own message or hide the frame. |
What you can send it
| Command | Shape |
|---|---|
| PLAY / PAUSE | { source, command: "PLAY" } |
| SEEK | { source, command: "SEEK", time: 300 } — seconds |
| SET_VOLUME | { source, command: "SET_VOLUME", volume: 0.5 } — 0–1 |
| SET_SERVER | { source, command: "SET_SERVER", server: 31 } |
| LOAD | { source, command: "LOAD", type, id, season?, episode? } |
Use LOAD for binge watching. Swapping episodes with a command keeps the frame alive, so the warm cache and the already-resolved source survive and the next episode starts noticeably faster than reloading src would.
const PLAYER = "https://player.example.com";
const frame = document.querySelector("#player");
// 1. Listen. Check the origin AND the source field.
window.addEventListener("message", (event) => {
if (event.origin !== PLAYER) return;
const msg = event.data;
if (!msg || msg.source !== "embed-player") return;
switch (msg.event) {
case "TIME_UPDATE":
saveProgress(msg.data.currentTime, msg.data.duration);
break;
case "NEXT_EPISODE":
highlightEpisode(msg.data.season, msg.data.episode);
break;
case "MEDIA_UNAVAILABLE":
showYourOwnFallback();
break;
}
});
// 2. Drive it. No reload — the frame keeps its warm cache.
function playEpisode(season, episode) {
frame.contentWindow.postMessage(
{ source: "embed-player", command: "LOAD",
type: "tv", id: "1399", season, episode },
PLAYER,
);
}Recipes
The same embed, in whatever your site is built with.
<div class="player-box">
<iframe id="player"
src="https://player.example.com/movie/550?origin=https://yoursite.com"
allow="autoplay; fullscreen; encrypted-media; picture-in-picture"
allowfullscreen loading="lazy"></iframe>
</div>
<style>
.player-box { width: 100%; max-width: 1100px; margin: 0 auto }
.player-box iframe { width: 100%; aspect-ratio: 16/9; border: 0; border-radius: 12px }
</style>import { useEffect, useRef } from "react";
const PLAYER = "https://player.example.com";
export function Player({ type, id, season, episode, onProgress }) {
const ref = useRef(null);
useEffect(() => {
function onMessage(event) {
if (event.origin !== PLAYER) return;
if (event.data?.source !== "embed-player") return;
if (event.data.event === "TIME_UPDATE") onProgress?.(event.data.data);
}
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [onProgress]);
// Episode swaps go over postMessage, so the frame is never remounted.
useEffect(() => {
ref.current?.contentWindow?.postMessage(
{ source: "embed-player", command: "LOAD", type, id, season, episode },
PLAYER,
);
}, [type, id, season, episode]);
const path = type === "tv" ? `/tv/${id}/${season}/${episode}` : `/movie/${id}`;
return (
<iframe
ref={ref}
src={`${PLAYER}${path}?origin=${location.origin}`}
allow="autoplay; fullscreen; encrypted-media; picture-in-picture"
allowFullScreen
style={{ width: "100%", aspectRatio: "16/9", border: 0 }}
/>
);
}<script setup>
import { computed, onMounted, onUnmounted } from "vue";
const PLAYER = "https://player.example.com";
const props = defineProps({ type: String, id: String, season: Number, episode: Number });
const emit = defineEmits(["progress", "unavailable"]);
const src = computed(() =>
props.type === "tv"
? `${PLAYER}/tv/${props.id}/${props.season}/${props.episode}`
: `${PLAYER}/movie/${props.id}`,
);
function onMessage(event) {
if (event.origin !== PLAYER) return;
const m = event.data;
if (m?.source !== "embed-player") return;
if (m.event === "TIME_UPDATE") emit("progress", m.data);
if (m.event === "MEDIA_UNAVAILABLE") emit("unavailable");
}
onMounted(() => window.addEventListener("message", onMessage));
onUnmounted(() => window.removeEventListener("message", onMessage));
</script>
<template>
<iframe :src="src" allowfullscreen
allow="autoplay; fullscreen; encrypted-media; picture-in-picture"
style="width:100%;aspect-ratio:16/9;border:0" />
</template>// functions.php — [player id="550"] or [player type="tv" id="1399" s="1" e="1"]
add_shortcode('player', function ($atts) {
$a = shortcode_atts(['type' => 'movie', 'id' => '', 's' => '1', 'e' => '1'], $atts);
if (!preg_match('/^(\d+|tt\d+)$/', $a['id'])) return '';
$path = $a['type'] === 'tv'
? "/tv/{$a['id']}/{$a['s']}/{$a['e']}"
: "/movie/{$a['id']}";
return sprintf(
'<iframe src="https://player.example.com%s" loading="lazy" allowfullscreen ' .
'allow="autoplay; fullscreen; encrypted-media; picture-in-picture" ' .
'style="width:100%%;aspect-ratio:16/9;border:0"></iframe>',
esc_attr($path),
);
});Troubleshooting
Nine times out of ten it is one of these five.
The frame never appears
Your own Content-Security-Policy is blocking it. Add frame-src https://player.example.com.
Fullscreen does nothing
Permissions are granted by the parent, not by us. Keep allow="fullscreen" and allowfullscreen.
It won't autoplay
No cross-origin frame may start unmuted audio without a gesture. The poster press is that gesture, which is why it is the default.
Resume position is forgotten
Browsers partition storage per embedding site, so preferences are per-partner and are dropped in private mode. Track progress from TIME_UPDATE if you need your own.
"This player was sandboxed"
Your iframe carries a sandbox attribute that blocks the advertising window funding the player, so playback is refused and the viewer is offered a link to watch in a tab of their own instead. Remove the attribute, or add allow-popups allow-popups-to-escape-sandbox to it.
You get this page instead of a player
Something fetched the URL as a top-level document — a server-side proxy, a preview bot, or a plain tab. Embed the URL in a real iframe; don't re-host or proxy it.
Good to know
- Availability varies by title. The player walks several sources before giving up; when they all miss you get
MEDIA_UNAVAILABLErather than a spinner forever. Listen for it. - The player is ad-supported, and the ads stay inside the frame. Nothing is injected into your page and your own layout is never touched. Inside the frame we keep playback preferences and one analytics cookie, both partitioned to your site by the browser.
- Do not add
sandboxto the iframe. It stops the frame from opening the advertising window the player is funded by, so a sandboxed embed earns nothing and will refuse to play. See Iframe setup. - We record your hostname, never your URL. Usage analytics count views, titles and which sites embed the player — the domain only, which is why the snippet carries
referrerpolicy="origin"rather than something that would send us a path. Going the other way, the frame is servedReferrer-Policy: no-referrer, so the hosts we resolve learn nothing about you at all. - Fair-use rate limits apply per viewer IP. Normal embedding never approaches them; scripted mass requests do.
Want to see it run before you paste anything?
Open the test player