How @scarlett-player/embed is built and how a host integrates it. The
authoritative reference for every supported data attribute is
packages/embed/README.md - this document links
to it rather than repeating the table, so the two cannot drift.
Quick Start
Build
pnpm --filter @scarlett-player/embed build # from the repo root
pnpm --filter @scarlett-player/embed dev # Vite dev server over demo.html
The build is rimraf dist && tsc && vite build && BUILD_VIDEO=true vite build && BUILD_AUDIO=true vite build: tsc emits the declarations, then Vite writes
three bundles into the same dist. emptyOutDir is pinned to false because
the three builds share that directory and the declarations are already in it.
Simplest integration
<script src="https://assets.thestreamplatform.com/scarlett-player/latest/embed.umd.cjs"></script>
<div data-sp src="https://example.com/video.m3u8"></div>
The player auto-initialises on DOMContentLoaded.
Package structure
packages/embed/
├── src/
│ ├── index.ts # Full build entry: global API + auto-init
│ ├── index-video.ts # Video-only build entry
│ ├── index-audio.ts # Audio-only build entry
│ ├── create-embed.ts # Player creation, global API, auto-init scan
│ ├── parser.ts # Data attribute parsing
│ ├── types.ts # EmbedConfig, ScarlettPlayerGlobal, PlayerType
│ └── version.ts # __PKG_VERSION__, replaced at build time
├── templates/
│ ├── laravel-embed.blade.php # Laravel Blade template for /v/{id}
│ └── EmbedController.php # Example Laravel controller
├── tests/ # embed, parser, iframe-error, version
├── demo.html # Interactive demo page
├── iframe.html # iframe embed helper (published alongside dist)
├── package.json
├── tsconfig.json
├── tsconfig.typecheck.json
├── vite.config.ts
└── vitest.config.ts
dist/ (generated)
├── embed.js / embed.umd.cjs # Full build (ESM / UMD)
├── embed.video.js / embed.video.umd.cjs # Video-only build
├── embed.audio.js / embed.audio.umd.cjs # Audio-only build
├── hls.<version>.js # Shared hls.js chunk (ESM only)
├── hls.light.<version>.js # Shared hls.js/light chunk
├── *.d.ts # Declarations, emitted by tsc
└── *.map # Source maps
The three builds
| Build | Entry | Plugins |
|---|---|---|
Full (embed) |
src/index.ts |
hls, native, ui, audio-ui, analytics, playlist, media-session, watermark, captions, gestures, share |
Video (embed.video) |
src/index-video.ts |
hls, native, ui, watermark, captions, gestures, share |
Audio (embed.audio) |
src/index-audio.ts |
hls/light, native, audio-ui, playlist, media-session |
Chapters and clips are not in any embed build; a host that wants them installs the packages and builds its own bundle.
All three assign the same window.ScarlettPlayer global, so a page loads
exactly one of them.
Why the hls.js chunks carry the version
latest/ is mutable and cached for an hour while v<version>/ is immutable, so
an unstamped latest/hls.js let a browser pair yesterday's cached chunk with a
freshly fetched latest/embed.js. Stamping the name means a cached bundle keeps
importing the exact chunk it was built against.
The full and video builds emit a byte-identical hls.<version>.js, and
guardSharedChunks() in vite.config.ts compares the bytes on every build so a
divergence fails the build instead of shipping whichever copy was written last.
scripts/check-embed-chunks.mjs then asserts in CI that every chunk a bundle
imports was actually emitted.
Three ways to embed
1. Drop-in script (simplest)
Auto-init scans for three selectors (PLAYER_SELECTORS in create-embed.ts):
[data-scarlett-player][data-sp].scarlett-player
Attributes are accepted in both a short and a prefixed form - src or
data-src, color or data-brand-color. See
packages/embed/README.md for the full list.
<!-- Simplest possible -->
<div data-sp src="video.m3u8"></div>
<!-- With branding -->
<div data-sp src="video.m3u8" color="#e50914"></div>
<!-- Full attribute names also work -->
<div data-scarlett-player data-src="video.m3u8" data-brand-color="#e50914"></div>
<!-- Audio player -->
<div data-sp data-type="audio" data-src="track.mp3" data-title="Track" data-artist="Artist"></div>
2. JavaScript API (more control)
create() is async - it resolves once the plugins are initialised and the
source is loaded. Await it before calling anything on the player.
const player = await ScarlettPlayer.create({
container: '#player',
src: 'video.m3u8',
brandColor: '#e50914',
autoplay: true,
muted: true,
});
player.play();
player.pause();
player.setVolume(0.5);
player.destroy();
The global surface is ScarlettPlayerGlobal in src/types.ts:
| Member | Type | Purpose |
|---|---|---|
create(options) |
Promise<ScarlettPlayer> |
Build one player programmatically |
initAll() |
Promise<void> |
Re-scan the DOM for player elements |
version |
string |
The embed package version |
availableTypes |
PlayerType[] |
Which of video / audio this build ships |
3. iframe embed (isolated)
iframe.html reads its configuration from the query string: src, poster,
autoplay, muted, loop, controls, start-time, playback-rate,
hide-delay, big-play-button, brand-color, primary-color,
background-color, share-url and embed-base-url.
<iframe
src="https://assets.thestreamplatform.com/scarlett-player/latest/iframe.html?src=VIDEO_URL&brand-color=%23e50914"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
></iframe>
Host applications can also route their own URLs to a page that renders the player - see the Laravel integration below.
Laravel integration for /v/{id} URLs
Working examples live in packages/embed/templates/
(EmbedController.php, laravel-embed.blade.php).
How it works
User loads: https://embed.thestreamplatform.com/v/abc123
↓
Laravel route: Route::get('/v/{video:uuid}', [EmbedController::class, 'video'])
↓
Controller looks up video in database
↓
Returns Blade template with player pre-configured:
- Stream URL from database
- Brand colors from tenant settings
- Poster, autoplay, etc.
Routes (add to routes/web.php)
Route::get('/v/{video:uuid}', [EmbedController::class, 'video']);
Route::get('/embed/{event:slug}', [EmbedController::class, 'event']);
Route::get('/live/{channel}', [EmbedController::class, 'live']);
Controller example
<?php
namespace App\Http\Controllers;
use App\Models\Event;
use App\Models\Video;
class EmbedController extends Controller
{
/**
* Embed a video by UUID
* URL: /v/abc123
*/
public function video(Video $video)
{
if (!$video->embeddable) {
abort(403, 'This video cannot be embedded');
}
return view('embed.player', [
'src' => $video->stream_url,
'title' => $video->title,
'poster' => $video->thumbnail_url,
'autoplay' => request()->boolean('autoplay'),
'muted' => request()->boolean('muted', request()->boolean('autoplay')),
'brandColor' => $video->tenant->brand_color,
'tenant' => $video->tenant,
]);
}
/**
* Embed a live event by slug
* URL: /embed/fight-night-2025
*/
public function event(Event $event)
{
if (!$event->is_public && !$event->isAccessibleBy(auth()->user())) {
abort(403, 'Access denied');
}
return view('embed.player', [
'src' => $event->live_stream_url ?? $event->replay_url,
'title' => $event->title,
'poster' => $event->poster_url,
'autoplay' => $event->is_live,
'muted' => $event->is_live,
'brandColor' => $event->tenant->brand_color,
'tenant' => $event->tenant,
]);
}
}
Blade template (resources/views/embed/player.blade.php)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $title ?? 'Video Player' }}</title>
<meta name="robots" content="noindex, nofollow">
@if(isset($poster))
<meta property="og:image" content="{{ $poster }}">
@endif
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
#player { width: 100%; height: 100%; }
</style>
</head>
<body>
<div id="player"></div>
<script src="{{ config('services.scarlett.cdn_url') }}/embed.umd.cjs"></script>
<script>
ScarlettPlayer.create(@json([
'container' => '#player',
'src' => $src,
'autoplay' => $autoplay ?? false,
'muted' => $muted ?? false,
'poster' => $poster ?? null,
'brandColor' => $brandColor ?? null,
]));
</script>
</body>
</html>
Multi-tenant branding
Each TSP client gets their own branded player:
<!-- Client A: Red -->
<div data-sp src="{{ $clientA->stream }}" color="{{ $clientA->brand_color }}"></div>
<!-- Client B: Blue -->
<div data-sp src="{{ $clientB->stream }}" color="{{ $clientB->brand_color }}"></div>
Embed code generator (Laravel)
public function generateEmbed(Event $event): string
{
$params = http_build_query([
'src' => $event->stream_url,
'brand-color' => $event->client->brand_color,
'autoplay' => 'true',
'muted' => 'true',
]);
$cdnUrl = config('services.scarlett.cdn_url');
return <<<HTML
<iframe
src="{$cdnUrl}/iframe.html?{$params}"
width="640" height="360"
frameborder="0" allowfullscreen
allow="autoplay; fullscreen; picture-in-picture"
></iframe>
HTML;
}
CDN deployment
Uploading is automated: release.yml hands packages/embed/dist/ and
iframe.html to a separate cdn job as a build artifact, which runs
scripts/upload-cdn.sh <version>. Uploading the artifact rather than rebuilding
keeps the two jobs publishing byte-identical files and lets a failed upload be
retried with "Re-run failed jobs". A local publish is the same script:
doppler run -- ./scripts/upload-cdn.sh 1.11.1
The script ends by checking the CDN against the origin: the versioned file
must carry the version, and a cache-busted latest/ must have the same ETag.
The edge's own copy of latest/ is reported but never failed on, because it
is written with a one-hour max-age and turns over on its own. The same
check runs on its own, without credentials, for any published version:
VERIFY_ONLY=1 ./scripts/upload-cdn.sh 1.16.1
Layout
assets.thestreamplatform.com/scarlett-player/
├── v<version>/ # immutable, max-age=31536000
│ ├── embed.js, embed.umd.cjs
│ ├── embed.video.js, embed.video.umd.cjs
│ ├── embed.audio.js, embed.audio.umd.cjs
│ ├── hls.<version>.js, hls.light.<version>.js
│ └── iframe.html
└── latest/ # mutable, max-age=3600
Usage
<!-- Pin a version (recommended) -->
<script src="https://assets.thestreamplatform.com/scarlett-player/v1.11.1/embed.umd.cjs"></script>
<!-- Or track latest -->
<script src="https://assets.thestreamplatform.com/scarlett-player/latest/embed.umd.cjs"></script>
Bundle size
Measured from a pnpm --filter @scarlett-player/embed build at 1.11.0. Gzip is
what a browser actually transfers.
| Entry | Raw | Gzip | Notes |
|---|---|---|---|
embed.umd.cjs |
737 KB | 215 KB | hls.js inlined - UMD cannot code-split |
embed.video.umd.cjs |
692 KB | 204 KB | hls.js inlined |
embed.audio.umd.cjs |
432 KB | 130 KB | hls.js/light inlined |
embed.js |
414 KB | 90 KB | + hls.<version>.js on first HLS source |
embed.video.js |
336 KB | 75 KB | + hls.<version>.js on first HLS source |
embed.audio.js + its chunk |
209 KB | 45 KB | + hls.light.<version>.js on first HLS source |
hls.<version>.js |
1089 KB | 228 KB | lazy, ESM builds only |
hls.light.<version>.js |
717 KB | 152 KB | lazy, audio build only |
Two things the table is showing rather than hiding:
- The ESM builds are not minified. Vite's library mode skips terser for the
esformat (if (config.build.lib && outputOptions.format === 'es') return nullinvite:terser), regardless ofbuild.minify. That is harmless for an npm consumer whose bundler minifies anyway, but these files are also served straight to browsers from the CDN, where the raw column is what leaves the origin. The UMD numbers are minified and are the fair comparison. - hls.js dominates. A page that never plays an
.m3u8never fetches the chunk in the ESM builds; a UMD page pays for it up front. Prefer the audio or video build over the full one when the page only needs one of them.
Sizes move with every dependency bump - re-measure rather than trusting this
table, and watch what Vite prints during pnpm build.
Optimisation tips
- CDN caching:
max-age=31536000on versioned paths (already set byupload-cdn.sh) - Preload:
<link rel="preload" href="embed.umd.cjs" as="script"> - Lazy load: load the script only when the player scrolls into view
Troubleshooting
Player not showing?
- Check browser console for errors
- Verify
srcURL is accessible - Ensure the element matches one of the three auto-init selectors
Colors not applying?
- Use valid CSS colors:
#ff0000,rgb(255,0,0) - Check attribute names (kebab-case)
Video not playing?
- Verify the HLS stream is valid (.m3u8)
- Check CORS headers on the stream
- Add
mutedfor autoplay (mobile requirement)
Audio player renders as video (or vice versa)?
- Set
data-type="audio"; the default isvideo - Confirm the build ships that type -
ScarlettPlayer.availableTypes
iframe not loading?
- Check CORS headers allow embedding
- URL-encode the
srcparameter - Verify
allow="autoplay; fullscreen"is set
Development
pnpm install
pnpm --filter @scarlett-player/embed dev # Vite dev server
pnpm --filter @scarlett-player/embed build # all three builds
pnpm --filter @scarlett-player/embed test # vitest
pnpm --filter @scarlett-player/embed typecheck
Adding a new data attribute
- Add it to
EmbedConfiginsrc/types.ts - Parse it in
src/parser.ts(both the short anddata-prefixed forms) - Consume it in
src/create-embed.ts - Cover it in
tests/parser.test.ts - Document it in
packages/embed/README.md- the authoritative table - Add it to
iframe.htmlif it should be settable from the query string - Update
demo.html
Releasing
The embed package releases with everything else: it is in the fixed Changesets
group, so it ships the same version number as the other eighteen packages.
Merging a changeset to main opens a chore: release packages PR; merging that
publishes to npm through trusted publishing (OIDC, no token), tags the release,
and runs the CDN upload described above. Versions are never bumped by hand and
npm publish is never run manually - see docs/contributing.md.
Before opening the PR:
-
pnpm validate(package-script guard, lint, build, package-type guard, typecheck, test) -
node scripts/check-package-artifacts.mjsandnode scripts/check-embed-chunks.mjsafter a build, if you touched the manifest or the embed build - Test
demo.htmlandiframe.htmllocally - Verify the UMD global still exposes
create,initAll,versionandavailableTypes(scripts/verify-browser.mjsscenario 6 pins this) - A changeset