# Scarlett Player > Open-source, plugin-based video and audio player for the web, written in TypeScript: HLS adaptive streaming, native formats, WHEP live playback, captions, chapters, clips and casting, with a Vue wrapper and a CDN embed. Published to npm as @scarlett-player/* packages, all at one version (currently 1.16.1). MIT licensed. # Scarlett Player - Architecture > Rendered at https://scarlettplayer.com/architecture/ · Source: https://github.com/Hackney-Enterprises-Inc/scarlett-player/blob/main/docs/architecture.md This describes the player as it is built, not as it was planned. Every class, method and event named here exists in `packages/*/src`. Where a name in an older revision of this document did not survive contact with the code (the plugin lifecycle hook has never been called `setup`, and no package name has ever carried a `plugin-` prefix), the name here is the one the code uses. ## System overview ``` +---------------------------------------------------------------+ | Host application | | createPlayer(options) -> ScarlettPlayer | | or the Vue wrapper (@scarlett-player/vue) | | or the CDN embed (@scarlett-player/embed) | +------------------------------+--------------------------------+ | +------------------------------v--------------------------------+ | @scarlett-player/core | | | | ScarlettPlayer public API, lifecycle, | | provider selection, load | | generations | | | | PluginManager register / init / destroy, | | plugin states, dependency | | order, canPlay() selection | | | | PluginAPI what a plugin is handed in | | init(api): state, events, | | container, scoped logger, | | cleanup registration | | | | EventBus typed pub/sub over | | PlayerEventMap, plus | | interceptors | | | | StateManager one Signal per state key, | | change subscribers, | | define() for plugin keys | | | | ErrorHandler classification, history, | | emission | | | | Logger levelled, scoped per plugin | +------------------------------+--------------------------------+ | +------------------------------v--------------------------------+ | Plugins (one npm package each) | | provider: hls, native, whep | | ui: ui, audio-ui | | feature: playlist, captions, chapters, clips, | | gestures, share, watermark, media-session, | | airplay, chromecast | | analytics: analytics | +---------------------------------------------------------------+ ``` Nothing outside the core layer is required: a host can build a player with a provider and nothing else. The four plugins that contribute control-bar controls (playlist, chapters, share, clips) declare `@scarlett-player/ui` as an OPTIONAL peer and register their controls through a dynamic import, so they keep working when no UI package is installed. `@scarlett-player/ui` exposes two extension seams, both through module-level registries rather than through `IPluginAPI`, and both feature-detected by their callers: - **The control registry** (`registerControl`) contributes a *button* to a slot in the bar, which the host's layout has to name for it to appear. - **The timeline registry** (`registerTimelineExtension`) contributes an editing *layer* over the playback rail: a positioned element with the rail's exact horizontal geometry, plus leases for holding the bar visible and for suppressing ordinary seeking while the extension owns a pointer. One extension per player, keyed by container. `@scarlett-player/clips` mounts its in/out handles there and falls back to a self-contained rail when the seam is absent, so an older UI peer degrades rather than failing. Both registries are keyed by the player container where per-player state is involved, which is what keeps two players on one page from driving each other's controls. ## Packages Nineteen packages, all published at one version by a fixed Changesets group. | Path | Package | |---|---| | `packages/core` | `@scarlett-player/core` | | `packages/vue` | `@scarlett-player/vue` | | `packages/embed` | `@scarlett-player/embed` | | `packages/plugins/hls` | `@scarlett-player/hls` | | `packages/plugins/native` | `@scarlett-player/native` | | `packages/plugins/whep` | `@scarlett-player/whep` | | `packages/plugins/ui` | `@scarlett-player/ui` | | `packages/plugins/audio-ui` | `@scarlett-player/audio-ui` | | `packages/plugins/playlist` | `@scarlett-player/playlist` | | `packages/plugins/captions` | `@scarlett-player/captions` | | `packages/plugins/chapters` | `@scarlett-player/chapters` | | `packages/plugins/gestures` | `@scarlett-player/gestures` | | `packages/plugins/share` | `@scarlett-player/share` | | `packages/plugins/clips` | `@scarlett-player/clips` | | `packages/plugins/watermark` | `@scarlett-player/watermark` | | `packages/plugins/media-session` | `@scarlett-player/media-session` | | `packages/plugins/airplay` | `@scarlett-player/airplay` | | `packages/plugins/chromecast` | `@scarlett-player/chromecast` | | `packages/plugins/analytics` | `@scarlett-player/analytics` | There is no React package and no presets package. Every directory under `packages/plugins/` is one of the sixteen plugin packages above; the empty placeholder directories that used to sit beside them were deleted on 2026-09-02. A name under `packages/plugins/` means a package only when it has a `package.json`. ## Lifecycle ### Construction `new ScarlettPlayer(options)` resolves the container (an `HTMLElement` or a CSS selector, throwing when neither resolves), builds the `EventBus`, `StateManager`, `Logger`, `ErrorHandler` and `PluginManager`, wires the three listeners that keep the `error` state key in sync (`error` sets it, `media:loaded` clears it, `media:error` is recorded through `ErrorHandler.record()` without flipping the state), wires the four fullscreen listeners through the private `wireFullscreenListeners()` (see Fullscreen below), and calls `PluginManager.register()` for each plugin in `options.plugins`. Registration is all the constructor does to plugins. No plugin's `init()` runs yet, and no source is loaded. ### Initialisation `init()` and `load()` both go through the private `ensureInitialized()`, which is idempotent and safe to call re-entrantly. One pass (`runInitialization()`): 1. Walk `PluginManager.getPluginIds()`. For every plugin that is not `type: 'provider'` and is still in the `registered` state, call `PluginManager.initPlugin()`. Plugins in any other state are skipped, so a plugin added through `registerPlugin()` after start-up is picked up by the next call and nothing is initialised twice. 2. `wireLifecycleListeners()`, guarded by the private `listenersWired` flag so the two listeners it installs exist exactly once no matter how many times `load()` runs. 3. Emit `player:ready`, guarded by the private `readyEmitted` flag so it is emitted at the end of the FIRST pass only. `ensureInitialized()` returns the in-flight promise when a pass is already running. That matters because one of the listeners wired in step 2 calls `load()`, which calls `ensureInitialized()` again: without the shared promise, `initPlugin()` would find a plugin in the `initializing` state and throw "possible circular dependency". `init()` is `ensureInitialized()` followed by a `load()` of `options.src` when one was given. `createPlayer(options)` is `new ScarlettPlayer(options)` plus `await player.init()`, and is the documented entry point. `load()` calls `ensureInitialized()` before it selects a provider, so constructing a player and calling `load()` without `init()` produces a fully wired player rather than a provider with no UI, no error overlay and no working playlist. That shape was widely copied out of the READMEs, which is why the auto-initialisation exists. Two listeners are installed by `wireLifecycleListeners()`: - `media:load-request` (emitted by the playlist plugin, among others): loads the requested source, then plays unless the payload says `autoplay: false`. It returns early while Chromecast is active, because the Chromecast plugin owns loading then. - `error:retry` (emitted by the UI error overlay's Try Again button): reloads through the normal provider path, then restores position, live streams at the live edge through `seekToLive()`, VOD at the previous `currentTime`. Both handlers re-check the destroyed flag after each await. They are unawaited async closures, so a read against a torn-down `StateManager` would surface as an unhandled rejection rather than a caught error. ### `player:ready` Emitted once, at the end of the first initialisation pass. It used to be the constructor's last statement, where no consumer and no plugin could have subscribed yet, so no listener could ever observe it. A host that wants the event subscribes between construction and the first `init()`/`load()`; a host using `createPlayer()` has the returned promise as its readiness signal and does not need the event at all. ### Loading a source `load(source)`: 1. Increments `loadGeneration` and captures the value. Every post-await step compares against it and bails when a newer `load()` (or a `destroy()`, which also increments the counter) has started. 2. Resets the playback state keys through `StateManager.update()` and clears `error`. 3. Destroys the previous provider through `PluginManager.destroyPlugin()`, which returns it to the `registered` state so it can be initialised again later. 4. `ensureInitialized()`. 5. `PluginManager.selectProvider(source)`. No provider means `ErrorHandler.throw(ErrorCode.PROVIDER_NOT_FOUND, ...)` and a return, not an exception. 6. `PluginManager.initPlugin()` for the selected provider only. Providers are initialised lazily, per source; every other plugin was initialised in step 4. 7. Writes `source` state (`src` plus the MIME type derived by the private `detectMimeType()`), calls the provider's `loadSource()`, and plays when the `autoplay` state key is set. Failures inside `load()` are reported, never thrown at the caller: when the error state is already populated (a provider that emitted a structured fatal error of its own) the catch only logs, so a specific code is not overwritten by a generic one. **When the returned promise settles.** `load()` awaits the provider's `loadSource()`, so on the two providers that reconnect - HLS and WHEP - it stays pending for as long as their scheduler keeps trying, up to `reconnectWindowMs` (five minutes by default). That is deliberate: a live stream that has not started yet, or is at its monitor cap, is a wait rather than a failure, and settling early would report an error while a reconnect is still running behind it. Hosts that need progress listen to `error:reconnecting`, `error:recovered` and fatal `error` rather than awaiting the promise; hosts that need a hard bound set a shorter `reconnectWindowMs` or race the promise themselves. Terminal failures - a refused token, a missing stream, an endpoint that does not speak the protocol - are not scheduled for reconnect and settle in seconds. ### Destruction `destroy()` increments `loadGeneration` so in-flight loads self-cancel through the mechanism `load()` already trusts, clears the pending seek-resume timeout, removes the four fullscreen listeners, emits `player:destroy`, then `PluginManager.destroyAll()`, `EventBus.destroy()` and `StateManager.destroy()`. Every public method calls the private `checkDestroyed()` first and throws on a destroyed player. The state getters do not: they read through `StateManager`, which raises its own destroyed-specific error rather than the misleading unknown-key one. ## PluginManager `register(plugin, config?)` validates the plugin (`id`, `name`, `version`, `type`, `init`, `destroy` all present and of the right kind), rejects a duplicate `id`, builds that plugin's `PluginAPI`, stores the record in the `registered` state and emits `plugin:registered`. `PluginState` is `registered`, `initializing`, `ready`, `error` or `destroyed`. `initPlugin(id)` returns immediately when the plugin is already `ready`, throws when it is `initializing` (the circular-dependency guard), initialises any entries in the plugin's `dependencies` array first, subscribes the plugin's optional `onStateChange` and `onError` hooks (unsubscribing them through `api.onDestroy()`), then awaits `plugin.init(api, config)`. Success emits `plugin:active`; a throw sets the `error` state, emits `plugin:error` and rethrows. `destroyPlugin(id)` awaits `plugin.destroy()`, runs the API's registered cleanup functions and resets the record to `registered` so the plugin can be initialised again. `initAll()` and `destroyAll()` walk `resolveDependencyOrder()`, a topological sort that throws `Circular dependency detected` with the cycle path; `destroyAll()` walks it in reverse. `getPlugin(id)` returns any registered plugin. `getReadyPlugin(id)` returns it only when it is `ready`, and is what `IPluginAPI.getPlugin()` is wired to, so a plugin can never reach another plugin that has not finished initialising. ### Provider selection `selectProvider(source)` takes the plugins with `type: 'provider'` in registration order and returns the first whose `canPlay(source)` returns true. There is no priority table and no scoring: registration order is the priority, so a host that wants HLS to win registers `createHLSPlugin()` before `createNativePlugin()`. - `@scarlett-player/hls`: `canPlay()` requires hls.js support or native HLS, and a source whose path ends in `.m3u8` or whose URL carries an mpegurl MIME hint. - `@scarlett-player/native`: `canPlay()` requires a known extension and a positive `HTMLMediaElement.canPlayType()` answer for the mapped MIME type. A source no provider accepts produces `ErrorCode.PROVIDER_NOT_FOUND`. ## Plugin interface ```ts interface Plugin { readonly id: string; readonly name: string; readonly version: string; readonly type: PluginType; readonly description?: string; readonly dependencies?: string[]; init(api: IPluginAPI, config?: TConfig): void | Promise; destroy(): void | Promise; onStateChange?(event: StateChangeEvent): void; onError?(error: Error): void; } type PluginType = 'provider' | 'ui' | 'feature' | 'analytics' | 'utility'; ``` The lifecycle hook is `init(api)`. There is no `setup()`, and `destroy()` is required, not optional. A provider adds `canPlay(src: string): boolean` and `loadSource(src: string): Promise`; `ScarlettPlayer` calls both by duck typing rather than through a separate interface, and proxies `getLevels()`, `setLevel()`, `getCurrentLevel()` and `getLiveInfo()` the same way, so a provider that implements none of them still works. Plugins expose an imperative API by hanging methods off the same object (`@scarlett-player/playlist` is the example: `add()`, `play()`, `next()`, `previous()`), which a host reaches through `player.getPlugin(id)`. `PluginFactory` is the exported type for the `createXPlugin(config?)` factory functions every package ships. ## IPluginAPI The whole surface a plugin is handed. `PluginAPI` in `packages/core/src/plugin-api.ts` is the implementation; the interface lives in `packages/core/src/types/plugin.ts`. | Member | Purpose | |---|---| | `pluginId` | The plugin's own id | | `container` | The player container element | | `logger` | `debug`/`info`/`warn`/`error`, prefixed with the plugin id | | `getState(key)` | Read one state key, typed by `StateValue` | | `setState(key, value)` | Write one state key | | `defineState(key, initialValue)` | Register a key this plugin owns, before first use | | `on(event, handler)` | Subscribe; returns an unsubscribe function | | `off(event, handler)` | Unsubscribe | | `emit(event, payload)` | Emit a typed event | | `getPlugin(id)` | Another plugin, only if it is `ready` | | `onDestroy(cleanup)` | Register a cleanup function | | `subscribeToState(callback)` | Every state change, as a `StateChangeEvent` | There is no `play()`, `pause()` or `seek()` on the API: a plugin drives playback by emitting `playback:play`, `playback:pause` or `playback:seeking`, which the active provider is subscribed to. That keeps plugins independent of which provider is loaded. `runCleanups()` and `getCleanupFns()` exist on the concrete `PluginAPI` for `PluginManager` to call; they are marked `@internal` and are not part of `IPluginAPI`. ## State `StateManager` holds one `Signal` per key. `DEFAULT_STATE` supplies the initial values and is typed against `CoreStateStore`, not `StateStore`: - `CoreStateStore` is the closed set of keys core owns, so `DEFAULT_STATE` can be exhaustive over exactly those keys. - `StateStore extends CoreStateStore` and is open. A plugin adds the state it owns by declaration merging into `StateStore`, so augmenting it cannot break core's own compilation with a "missing properties" error. At runtime the store is closed too: `get()` throws `Unknown state key` for a key nobody registered, which is a deliberate typo-catcher. A plugin therefore calls `api.defineState(key, initialValue)` in `init()` before first use. `define()` is idempotent: re-defining an existing key keeps the current value, because plugins re-run setup after a source change and that must not wipe live state. The initial value is remembered in `definedDefaults` so `reset()` and `resetKey()` work on plugin keys, which have no entry in `DEFAULT_STATE`. Reads and writes: `get(key)` (the `Signal`), `getValue(key)`, `set(key, value)`, `update(partial)`, `snapshot()` (a frozen `StateStore`), `reset()`, `resetKey(key)`. Subscriptions: `subscribeToKey(key, cb)` for one key, `subscribe(cb)` for every change. `ScarlettPlayer.getState()` returns `snapshot()`. After `destroy()`, `get()` throws a destroyed-specific message rather than the unknown-key one. Returning last-known values instead was considered and rejected: it masks the lifecycle bugs the throw exposes. The signal primitives (`Signal`, `signal`, `Computed`, `computed`, `effect`, and the `currentEffect` tracking helpers) are exported from core for consumers that want them directly. ## Events `PlayerEventMap` is the single typed map of event name to payload; `EventName`, `EventPayload` and `EventHandler` derive from it. Like `StateStore`, it is an interface, so a plugin adds its own events by declaration merging without a core change. Core owns these namespaces: `player:`, `playback:`, `media:`, `volume:`, `quality:`, `track:`, `fullscreen:`, `pip:`, `airplay:`, `chromecast:`, `live:`, `chapter:`, `gesture:`, `controls:`, `ui:`, `state:`, `plugin:`, `error:` and `playlist:`, plus the single unnamespaced `error`. A plugin namespaces its own events with its plugin id. `EventBus` provides `on`, `once`, `off`, `emit`, `emitAsync`, `intercept`, `removeAllListeners`, `listenerCount` and `destroy`. A handler that throws is caught and logged, so one bad listener cannot stop the others. An `EventInterceptor` runs before the handlers and can rewrite the payload or cancel the event by returning `null`; interceptors are enabled by default and can be turned off through `EventEmitterOptions`. ## Error and reconnect model `ErrorHandler` normalises anything thrown into a `PlayerError` (`code`, `message`, `fatal`, `timestamp`, optional `context`, `originalError` and `detail`), keeps a bounded history (ten entries by default), logs it at error level when fatal and warn level otherwise, and emits `error`. - `handle(error, context)` does all of that. - `record(error, context)` does everything except emit, for advisory channels: media element errors go through it so they are visible in `getHistory()` without flipping the error state that the retry flow reads. - `throw(code, message, options)` builds a `PlayerError` from an `ErrorCode` and handles it. It does not throw a JavaScript exception. `ErrorCode` covers source loading (`SOURCE_NOT_SUPPORTED`, `SOURCE_LOAD_FAILED`), providers (`PROVIDER_NOT_FOUND`, `PROVIDER_SETUP_FAILED`), plugins (`PLUGIN_SETUP_FAILED`, `PLUGIN_NOT_FOUND`), playback and media (`PLAYBACK_FAILED`, `MEDIA_DECODE_ERROR`, `MEDIA_NETWORK_ERROR`, `MEDIA_APPEND_ERROR`, `MEDIA_BUFFER_FULL`, `PLAYLIST_INVALID`) and `UNKNOWN_ERROR`. `SOURCE_NOT_SUPPORTED`, `PROVIDER_NOT_FOUND` and `MEDIA_DECODE_ERROR` are classified fatal by default. Providers attach diagnostics through `PlayerErrorDetail`: `type`, `retriesExhausted`, `attempts`, `reconnectExhausted`, `httpStatus` and `url`. `url` must be sanitised by the provider before it is set. The HLS plugin does that with its exported `sanitizeUrl()`, which strips the query string and the fragment and keeps origin plus pathname. Path segments are NOT made safe by it, so a consumer whose playback URLs carry a credential in the path scrubs the path on its own side before forwarding the value to telemetry. Recovery lives in the provider, not in core. In `@scarlett-player/hls`: - Bounded retries first, with jittered exponential backoff: `maxNetworkRetries` (default 3) and `maxMediaRetries` (default 2). Both budgets apply on the hls.js branch and, through `handleNativeFatalError()`, on the native Safari branch, where recovery means reloading the source and restoring the position captured at the first failure. The budgets reset once media flows again, so a long event's transient blips never accumulate. - `emitFatalError()` emits the fatal `error` and then calls `maybeScheduleReconnect()`, which hands over to the auto-reconnect scheduler only when playback had already started and the failure was a network or media one. `scheduleReconnectAttempt()` emits `error:reconnecting` (`{ attempt, delayMs, elapsedMs?, windowMs? }`) and `attemptReconnect()` rebuilds the pipeline, resuming VOD at the previous position and rejoining live at the edge. - Giving up is decided by a TIME WINDOW (`reconnectWindowMs`, default 300000ms), not by an attempt count, which is why the payload reports `elapsedMs`/`windowMs` and there is no `maxAttempts` to render against. - `emitReconnectExhausted()` closes the cycle exactly once, behind a latch that `cancelReconnect()` clears: it emits `error:reconnect-exhausted` (`{ attempts, elapsedMs, windowMs }`) and then a final fatal `error` carrying `detail.reconnectExhausted`. The final error deliberately does not go through `emitFatalError()`, which would re-enter the scheduler. The ordering guarantee a UI can rely on: one or more `error:reconnecting`, then exactly one of `error:recovered` or `error:reconnect-exhausted`. A consumer that shows a reconnecting state on the first can take it down on either terminator and will never be stranded. The UI plugin's `ErrorOverlay` renders viewer-facing copy per `ErrorCode`, shows the reconnecting state while the provider self-heals, and emits `error:retry` when Try Again is pressed, which core's own listener turns back into a `load()`. ## Live and low latency Five state keys describe a live stream - `live`, `liveEdge`, `seekableRange`, `liveLatency` and `lowLatencyMode` - and four events announce changes to them: `live:edgechange`, `live:latency`, `live:seekablerange` and `live:lowlatency`. **One writer.** `packages/plugins/hls/src/live-metrics.ts` is the only place that writes four of those five keys. `computeLiveMetrics(source)` measures, and `applyLiveMetrics(api, metrics)` writes and emits, each key only when its value actually changed. `hlsLevelLoaded` owns `live` itself and nothing else does. That rule exists because it was broken. `hlsLevelLoaded` used to compute an edge flag from the playlist and the `timeupdate` handler then recomputed it four times a second as `latency < 10` off `video.seekable` - the wrong source under MSE, where `seekable.start(0)` stays 0 instead of following the sliding window, and a threshold that is unconditionally true at a 2-4 second low-latency target. "GO LIVE" could not appear however far a viewer drifted. Anything that needs a new live reading calls `computeLiveMetrics`; nothing writes those keys directly. **Latency truth differs by path.** On hls.js (MSE), `hls.latency` is real wall-clock latency measured against `EXT-X-PROGRAM-DATE-TIME` drift where the manifest carries it, and `hls.targetLatency` derives from `PART-HOLD-BACK` / `HOLD-BACK`. On the native path (Safari/iOS) there is no latency API, so the distance to `video.seekable.end` stands in - a buffer distance, not a latency - and the edge threshold stays deliberately loose (the historical 10s) unless a target latency carried over from an hls.js session on the same source. A viewer is at the edge when `latency <= targetLatency + tolerance`, with `tolerance = max(1.5, partTarget ?? targetduration / 2)`. That formula is what decides when "GO LIVE" appears. **`lowLatencyMode` reports effect, not intent.** It is true only when the manifest carries `EXT-X-PART` or advertises `CAN-BLOCK-RELOAD=YES` *and* the host asked for low latency in the HLS plugin config. A flag set against a plain live manifest gets no badge, and neither does an LL manifest played without the flag - hls.js will not load its parts. **Rejoining the edge goes through core.** A control emits `live:seektolive`; `ScarlettPlayer` subscribes and calls `seekToLive()`, which prefers the provider's `liveSyncPosition` and only then falls back to `seekableRange.end` and `duration`. The two are not interchangeable under low latency: the end of the seekable range is past the last loaded part, and seeking there stalls. The UI's `LiveIndicator` used to seek there itself; it now carries no target at all. ## Fullscreen Core owns fullscreen since 1.8.0. `packages/core/src/fullscreen.ts` exports `enterFullscreen(container)`, `exitFullscreen(container)` and `isFullscreen(container)` as runtime exports, and every way in goes through them: `ScarlettPlayer.requestFullscreen()`, `exitFullscreen()` and `toggleFullscreen()`, the UI package's `FullscreenButton` and its `f` shortcut. Before that there were three implementations, and only the button carried the iPhone fallback, so `player.requestFullscreen()` (what the Vue wrapper and the `useScarlettPlayer` composable call) did nothing at all on an iPhone. - `enterFullscreen()` tries `Element.requestFullscreen`, then `webkitRequestFullscreen`, then the iPhone's `video.webkitEnterFullscreen()` on the container's video element, looked up on every call because a provider creates that element per source. Exhausting all three throws rather than resolving: a silent no-op would arm the optimistic write below and announce a transition that never happened. - `exitFullscreen()` checks the video's `webkitDisplayingFullscreen` FIRST and calls `webkitExitFullscreen()` there, because a WebKit that exposes `document.exitFullscreen` while the native player is up has no fullscreen element and would reject. Then `document.exitFullscreen`, then `webkitExitFullscreen`. - `isFullscreen()` reads the browser (`fullscreenElement`, `webkitFullscreenElement`, `webkitDisplayingFullscreen`), never the state key, so a stale key cannot invert a toggle. The `fullscreen` state key is written from real browser events. The player listens for `fullscreenchange` and `webkitfullscreenchange` on the document, and for `webkitbeginfullscreen` and `webkitendfullscreen` in the capture phase on its container, because those two are dispatched on the video element and do not bubble. Each one calls the private `setFullscreenState()`, which writes the key and emits `fullscreen:change` only when the value actually changed. The spec fires `fullscreenchange` before `requestFullscreen()` resolves, so the optimistic write that follows the await in `requestFullscreen()` and `exitFullscreen()` runs only where the browser stayed silent (the private `fullscreenAnnounced` flag): jsdom never fires the event, and neither does the iPhone's native player until it has finished opening. All four listeners are removed in `destroy()`. ## Data flow ``` host call or user gesture | v ScarlettPlayer method -> EventBus.emit(...) | | | v | interceptors (may rewrite or cancel) | | | v | plugin handlers, provider handlers v | StateManager.set/update <-------+ | v signal subscribers -> StateManager change subscribers | | v v plugin.onStateChange api.subscribeToState(...) | v UI controls redraw ``` Playback state is written by the provider from real media element events, not optimistically by the player: `play()` emits `playback:play` and lets the provider report what actually happened, because setting `playing: true` up front caused state to drift from the element. ## Build and distribution - `@scarlett-player/core`, `@scarlett-player/vue` and `@scarlett-player/embed` build with Vite; every plugin builds with tsup and emits its own declarations through `dts: true` in its `tsup.config.ts`, which also `define`s the package's own version for `src/version.ts`. Core's build is `rimraf dist tsconfig.tsbuildinfo && tsc && vite build`: `tsc` emits declarations only (`emitDeclarationOnly`) and Vite writes the runtime bundles into the same `dist`, so the Vite config pins `emptyOutDir: false`. Emptying `dist` between the two steps would delete the declarations that `types` and every plugin's tsconfig `paths` point at. - Every package restricts `files` to its build output, so nothing but `dist` is published (embed also ships its `iframe.html`). - hls.js is loaded lazily by `loadHlsJs()` through a dynamic `import`, so a page that never plays HLS never fetches it. `@scarlett-player/hls/light` is a second entry over the same factory (`src/create-hls-plugin.ts`) built on hls.js/light: no subtitles, no ID3, no DRM. - The playlist plugin registers its control-bar controls through `void import('@scarlett-player/ui')` and logs and continues when the UI package is absent, which is what makes it work headless. - Versioning is Changesets in fixed mode: all nineteen packages share one version number. ## Testing - Vitest per package, with jsdom. `pnpm test` fans out over the workspace. - Typechecking is a separate gate: vitest transpiles without type-checking, so a test that exercises a type contract proves nothing unless `tsc` also sees the file. Every build `tsconfig.json` scopes the program to `src`, so several packages carry a `tsconfig.typecheck.json` that adds the type-contract tests back in; core's copy documents the trap that `exclude` is inherited from the extended config and filters `include`, so it has to be restated. `scripts/check-package-scripts.mjs` fails the build when a workspace package declares no `typecheck` or `test` script, which is how the gap that left ten packages silently unchecked is kept closed. - Three further guards run after the build, each for a defect class that shipped green once: `scripts/check-package-artifacts.mjs` (a manifest advertising a path the build did not leave on disk), `scripts/check-embed-chunks.mjs` (an embed bundle importing a chunk that was never emitted) and `scripts/check-package-types.mjs` (a shipped `.d.ts` that exists but does not compile for a consumer). - `scripts/verify-browser.mjs` drives the built demo in a real headless Chrome through Playwright, covering what jsdom cannot: manifest failures, a mid-playback outage and automatic recovery, destroy-mid-append races against a locally generated HLS fixture, malformed live playlist refreshes, the shape of the `window.ScarlettPlayer` global the CDN embed publishes, control-bar reachability at phone widths (which needs a layout engine and a coarse pointer), and LL-HLS end to end against a rolling low-latency playlist assembled from the fixture's 0.5s part rendition. Fifty-nine checks across eight scenarios; CI runs it on pushes to `main` only, not on pull requests. - `scripts/hls-fixture.mjs` generates both renditions the harness plays: 2s segments for everything else, and 0.5s parts for the LL scenario. The parts are real segments on keyframe boundaries rather than byte-range slices of the 2s ones, so four of them concatenate to exactly their parent segment. Slicing instead was tried and produces truncated access units that Chromium rejects with `PIPELINE_ERROR_DECODE` a few seconds into part-driven playback. ## Browser support Chrome and Edge 80+, Firefox 78+, Safari 14+, iOS Safari 14+, Android Chrome 90+. The same list is in the root README; keep the two in step. ## See also - [Writing a plugin](https://scarlettplayer.com/plugin-authoring/index.md) - events, state and controls - [Contributing](https://scarlettplayer.com/contributing/index.md) - code standards, testing and review conventions - [README](https://github.com/Hackney-Enterprises-Inc/scarlett-player/blob/main/README.md) - installation, quick starts and the package table --- # Writing a Scarlett Player Plugin > Rendered at https://scarlettplayer.com/plugin-authoring/ · Source: https://github.com/Hackney-Enterprises-Inc/scarlett-player/blob/main/docs/plugin-authoring.md A plugin can add three things to the player: **events**, **state**, and **control-bar controls**. All three are open - a plugin package extends them without editing `@scarlett-player/core` or `@scarlett-player/ui`. This is what `@scarlett-player/captions`, `@scarlett-player/watermark` and friends do, and it is what any third-party package can do. ## The plugin object ```ts import type { IPluginAPI, Plugin, PluginType } from '@scarlett-player/core'; export function createExamplePlugin(config: ExampleConfig = {}): Plugin { let api: IPluginAPI | null = null; return { id: 'example', name: 'Example', version: '1.0.0', type: 'feature' as PluginType, init(pluginApi: IPluginAPI): void { api = pluginApi; // wire everything up here }, destroy(): void { api = null; }, }; } ``` Anything you attach in `init()` must come off again - via `api.onDestroy(fn)` or in `destroy()`. `api.on(...)` returns its own unsubscribe function, so `api.onDestroy(api.on(...))` at the point of subscription is the pattern that cannot be forgotten later. Feature, UI, analytics and utility plugins outlive individual media items: a source change does not re-create them. Provider plugins are the exception, and are destroyed and initialised again on every `load()`. ### When `init()` runs Non-provider plugins are initialised by the player's first `init()` or first `load()`, whichever happens first, in registration order. A plugin registered later with `player.registerPlugin()` is initialised by the next of those calls. Provider plugins are initialised lazily, per source, once `selectProvider()` has picked one. `player:ready` is emitted at the END of that first pass, after every non-provider plugin's `init()` has resolved. A plugin that subscribes to it inside its own `init()` therefore does receive it, and it fires exactly once for the life of the player. To expose an imperative API to the host, hang methods off the same object alongside `id`/`init`/`destroy`. `@scarlett-player/playlist` does this, which is why hosts can call `playlistPlugin.play(2)`. ## 1. Events `PlayerEventMap` is an interface, so you add your events by declaration merging. No core change, and `EventBus` never validates names at runtime. ```ts declare module '@scarlett-player/core' { interface PlayerEventMap { 'example:started': { at: number }; 'example:finished': void; } } api.emit('example:started', { at: 12 }); const unsubscribe = api.on('example:started', ({ at }) => { /* typed */ }); ``` **Namespace your events with your plugin id.** Core owns these namespaces in `PlayerEventMap`: `player:`, `playback:`, `media:`, `volume:`, `quality:`, `track:`, `fullscreen:`, `pip:`, `airplay:`, `chromecast:`, `live:`, `chapter:`, `gesture:`, `controls:`, `ui:`, `state:`, `plugin:`, `error:` and `playlist:`, plus the single unnamespaced `error`. Anything else is yours. `EventBus` never validates names at runtime, so a typo in an event name is silent. The state store is the opposite: it throws for a key nobody registered, which is why the next section exists. ## 2. State Two steps, because state is closed at runtime as well as in the type system - `getState`/`setState` throw for keys nobody registered, which catches typos on core keys. ```ts declare module '@scarlett-player/core' { interface StateStore { exampleSelection: { start: number; end: number } | null; } } init(pluginApi) { api = pluginApi; api.defineState('exampleSelection', null); // <- required before first use } ``` `defineState` is idempotent: re-defining an existing key keeps its current value. Plugins commonly re-run setup after a source change, and that must not wipe live state. **Namespace your state keys too** (`exampleSelection`, not `selection`). Note the split between `CoreStateStore` and `StateStore`: core's defaults are declared against `CoreStateStore`, so your augmentation cannot break core's own compilation. `reset()` and `resetKey()` restore plugin keys to the initial value you passed to `defineState`. ## 3. Controls Implement `Control`, then register a factory under a slot id. ```ts import { registerControl, type Control } from '@scarlett-player/ui'; import type { IPluginAPI } from '@scarlett-player/core'; class ExampleButton implements Control { private el: HTMLButtonElement; constructor(private api: IPluginAPI) { this.el = document.createElement('button'); this.el.className = 'sp-example'; this.el.setAttribute('aria-label', 'Example'); } render(): HTMLElement { return this.el; } update(): void { // Called on every state change. Hide rather than render something useless: this.el.style.display = this.api.getState('duration') > 0 ? '' : 'none'; } destroy(): void { this.el.remove(); } } registerControl('example', (api) => new ExampleButton(api)); ``` **Registering does not place the button anywhere.** The host opts in by listing the id in its layout: ```ts uiPlugin({ controls: ['play', 'volume', 'time', 'spacer', 'example', 'fullscreen'] }) ``` That is deliberate - a plugin cannot force itself into someone's control bar. `ControlSlot` is `BuiltinControlSlot | (string & {})`, so custom ids type-check while editors still autocomplete the built-ins. ### Registration order Plugin init order is **not** guaranteed. If your control registers after the UI plugin has built its control bar, the UI plugin notices and rebuilds - as long as your id is in the active layout. Registering an id nobody listed is inert. So both of these work: ```ts registerControl('example', factory); // at module import time init(api) { registerControl('example', factory); } // or during init ``` A factory that throws is caught and logged; the rest of the control bar still builds. ### Scope the registration when the factory closes over your plugin The registry is module-level, so a factory registered without an owner is shared by every player on the page. That is right for a stateless factory - it receives the per-player `api` and builds a fresh control from it. It is wrong the moment your factory captures the plugin instance: a chapter list, a playlist panel, a share sheet. Every player that installs your plugin registers again and overwrites the last, so the next rebuild in player A hands it player B's control, and the two players drive one element. Pass the player's container as the owner instead, and give the registration back on teardown: ```ts import { registerControl, unregisterControl } from '@scarlett-player/ui'; init(api) { const owner = api.container; // Capture the instance in a local. A factory is called long after init // returns, so it needs a reference it can close over. const self = this; // The factory still receives the per-player IPluginAPI - pass it on, and // hand the control whatever plugin state it needs alongside. A control that // takes plugin state declares it: `constructor(api: IPluginAPI, plugin: ExamplePlugin)`, // unlike the one-argument ExampleButton above. registerControl('example', (controlApi) => new ExamplePanel(controlApi, self), { owner }); // Only the id you registered. `unregisterControlsFor(owner)` drops every // control scoped to that container, including ones other plugins registered // against the same player, so your teardown would take theirs with it. api.onDestroy(() => unregisterControl('example', { owner })); } ``` A player prefers a factory it owns over a global one of the same id, so the two forms coexist. ### Shared stylesheets If your plugin injects one `
``` --- ## Multi-tenant branding Each TSP client gets their own branded player: ```html
``` ### Embed code generator (Laravel) ```php 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; } ``` --- ## 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 `. 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: ```bash 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: ```bash VERIFY_ONLY=1 ./scripts/upload-cdn.sh 1.16.1 ``` ### Layout ``` assets.thestreamplatform.com/scarlett-player/ ├── v/ # immutable, max-age=31536000 │ ├── embed.js, embed.umd.cjs │ ├── embed.video.js, embed.video.umd.cjs │ ├── embed.audio.js, embed.audio.umd.cjs │ ├── hls..js, hls.light..js │ └── iframe.html └── latest/ # mutable, max-age=3600 ``` ### Usage ```html ``` --- ## 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..js` on first HLS source | | `embed.video.js` | 336 KB | 75 KB | + `hls..js` on first HLS source | | `embed.audio.js` + its chunk | 209 KB | 45 KB | + `hls.light..js` on first HLS source | | `hls..js` | 1089 KB | 228 KB | lazy, ESM builds only | | `hls.light..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 `es` format (`if (config.build.lib && outputOptions.format === 'es') return null` in `vite:terser`), regardless of `build.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 `.m3u8` never 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 1. **CDN caching**: `max-age=31536000` on versioned paths (already set by `upload-cdn.sh`) 2. **Preload**: `` 3. **Lazy load**: load the script only when the player scrolls into view --- ## Troubleshooting ### Player not showing? - Check browser console for errors - Verify `src` URL 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 `muted` for autoplay (mobile requirement) ### Audio player renders as video (or vice versa)? - Set `data-type="audio"`; the default is `video` - Confirm the build ships that type - `ScarlettPlayer.availableTypes` ### iframe not loading? - Check CORS headers allow embedding - URL-encode the `src` parameter - Verify `allow="autoplay; fullscreen"` is set --- ## Development ```bash 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 1. Add it to `EmbedConfig` in `src/types.ts` 2. Parse it in `src/parser.ts` (both the short and `data-` prefixed forms) 3. Consume it in `src/create-embed.ts` 4. Cover it in `tests/parser.test.ts` 5. Document it in `packages/embed/README.md` - the authoritative table 6. Add it to `iframe.html` if it should be settable from the query string 7. 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.mjs` and `node scripts/check-embed-chunks.mjs` after a build, if you touched the manifest or the embed build - [ ] Test `demo.html` and `iframe.html` locally - [ ] Verify the UMD global still exposes `create`, `initAll`, `version` and `availableTypes` (`scripts/verify-browser.mjs` scenario 6 pins this) - [ ] A changeset --- # Scarlett Player - Development Guidelines > Rendered at https://scarlettplayer.com/contributing/ · Source: https://github.com/Hackney-Enterprises-Inc/scarlett-player/blob/main/docs/contributing.md Companion documents: [Architecture](https://scarlettplayer.com/architecture/index.md) (how the player is put together) and [Writing a plugin](https://scarlettplayer.com/plugin-authoring/index.md) (writing a plugin package). ## Code Standards ### TypeScript **Required**: - All code must be TypeScript - Strict mode enabled - Prefer `unknown` over `any`. The exceptions in the tree are deliberate and local: the duck-typed provider calls in `ScarlettPlayer` (`getLevels`, `setLevel`, `getLiveInfo`, `loadSource`) and third-party globals such as the Cast SDK. Keep the cast at the call site and say why in a comment - Explicit return types for public APIs - TSDoc on every exported function, class and public method, with the reasoning when the behaviour is not obvious from the code **Example**: ```typescript /** * Loads a media source into the player * @param src - The source URL or object * @returns Promise that resolves when source is loaded */ public async loadSource(src: string | SourceObject): Promise { // Implementation } ``` ### Naming Conventions **Classes**: PascalCase ```typescript class PluginManager { } class HLSPlugin { } ``` **Interfaces/Types**: PascalCase with descriptive names ```typescript interface PluginAPI { } type EventHandler = (data: any) => void; ``` **Functions/Methods**: camelCase ```typescript function loadSource() { } private setupPlugin() { } ``` **Constants**: UPPER_SNAKE_CASE ```typescript const MAX_RETRY_COUNT = 3; const DEFAULT_VOLUME = 1.0; ``` **Files**: kebab-case ``` plugin-manager.ts event-bus.ts hls-provider.ts ``` **Packages**: kebab-case with scope, and the scope carries no `plugin-` prefix ``` @scarlett-player/core @scarlett-player/hls @scarlett-player/media-session ``` ### Code Organization **File Structure**: ``` package/ ├── src/ │ ├── index.ts # Public exports │ ├── types.ts # TypeScript types │ ├── *.ts # Implementation files ├── tests/ │ └── *.test.ts # Test files ├── package.json ├── tsconfig.json # Build config; excludes tests ├── tsconfig.typecheck.json # Optional: adds type-contract tests to the program ├── vitest.config.ts └── README.md ``` `package.json` must declare a `typecheck` and a `test` script. `scripts/check-package-scripts.mjs` fails CI otherwise, because pnpm's recursive run silently skips a package that has neither. **Imports Order**: 1. External dependencies 2. Internal imports from core 3. Relative imports ```typescript // External import { createScope } from 'some-library'; // Internal core import type { IPluginAPI, Plugin } from '@scarlett-player/core'; // Relative import { HLSConfig } from './types'; import { loadLibrary } from './lib-loader'; ``` ### Error Handling **Always use Error objects**: ```typescript // Good throw new Error('Failed to load source'); // Bad throw 'Failed to load source'; ``` **Custom error classes for specific errors**: ```typescript class PluginError extends Error { constructor( public pluginName: string, message: string, public cause?: Error ) { super(`[${pluginName}] ${message}`); this.name = 'PluginError'; } } ``` **Try-catch for async operations**: ```typescript async loadSource(src: string): Promise { try { const response = await fetch(src); // ... } catch (error) { this.handleError(error); throw new PluginError('hls', 'Failed to load manifest', error); } } ``` ### Testing Requirements **Unit Tests**: - Every public method must have tests - Test happy path and error cases - Mock external dependencies **Test File Naming**: `*.test.ts` **Example**: ```typescript import { describe, it, expect } from 'vitest'; import { EventBus, Logger, PluginManager, StateManager } from '@scarlett-player/core'; describe('PluginManager', () => { const build = () => new PluginManager(new EventBus(), new StateManager(), new Logger(), { container: document.createElement('div'), }); const plugin = { id: 'test', name: 'Test', version: '1.0.0', type: 'feature' as const, init: () => {}, destroy: () => {}, }; it('registers a plugin', () => { const manager = build(); manager.register(plugin); expect(manager.hasPlugin('test')).toBe(true); expect(manager.getPluginState('test')).toBe('registered'); }); it('rejects a duplicate plugin id', () => { const manager = build(); manager.register(plugin); expect(() => manager.register(plugin)).toThrow('is already registered'); }); }); ``` Plugins are tested against a mock `IPluginAPI`, one typed helper per package rather than a cast at every call site. ### Documentation **README.md Required**: Every package must have: - Installation instructions - Usage examples - API documentation link - License **TSDoc for Public APIs**: ```typescript /** * Create an HLS provider plugin. * * @param config - Plugin configuration * @returns Plugin instance to hand to the player * * @example * ```typescript * const player = await createPlayer({ * container: '#video', * src: 'https://example.com/video.m3u8', * plugins: [createHLSPlugin()], * }); * ``` */ export function createHLSPlugin(config: HLSPluginConfig = {}): Plugin { // ... } ``` Every example in a docblock or a README constructs the player with `createPlayer()`. **CHANGELOG.md**: Changelogs are per package (`packages//CHANGELOG.md`) and generated by Changesets from the changeset files merged into `main`; never hand-edit them. The root `CHANGELOG.md` is only a pointer to the per-package files. Your changeset summary is what ends up in the changelog, so write it for a consumer. ## Plugin Development Guidelines `docs/plugin-authoring.md` is the full guide: events, state keys, control-bar controls, and the checklist for a new package. The shape in short: ### Plugin Structure ```typescript import type { IPluginAPI, Plugin, PluginType } from '@scarlett-player/core'; export interface MyPluginConfig { option1?: string; option2?: number; [key: string]: unknown; } const DEFAULT_CONFIG: MyPluginConfig = { option1: 'a' }; export function createMyPlugin(config: MyPluginConfig = {}): Plugin { const merged = { ...DEFAULT_CONFIG, ...config }; let api: IPluginAPI | null = null; return { id: 'my-plugin', name: 'My Plugin', version: '1.0.0', type: 'feature' as PluginType, init(pluginApi: IPluginAPI): void { api = pluginApi; // Register any state key this plugin owns, before first use api.defineState('myPluginActive', false); // Subscribe to events; on() returns an unsubscribe function api.onDestroy(api.on('playback:play', handlePlay)); api.onDestroy(api.on('playback:pause', handlePause)); // Every state change, as a StateChangeEvent api.onDestroy(api.subscribeToState(handleStateChange)); }, destroy(): void { api = null; }, }; } ``` The lifecycle hook is `init(api)`. There is no `setup` hook, and `destroy()` is required, not optional. Factory functions (`createMyPlugin()`) are the convention across the workspace; a class is fine as long as it satisfies the same `Plugin` interface. `IPluginAPI` is the type a plugin is handed; `PluginAPI` is core's concrete implementation and plugins should not depend on it. ### Plugin Best Practices 1. **Always cleanup in destroy()** - Remove event listeners - Clear timers/intervals - Destroy DOM elements - Release resources 2. **Use arrow functions for event handlers** - Preserves `this` context - Easier to remove listeners 3. **Validate configuration** ```typescript constructor(config: PluginConfig) { if (config.timeout < 0) { throw new Error('timeout must be >= 0'); } this.config = config; } ``` 4. **Emit events for plugin actions**, namespaced with the plugin id and declared by merging into `PlayerEventMap` ```typescript declare module '@scarlett-player/core' { interface PlayerEventMap { 'myplugin:actionCompleted': { data: string }; } } api.emit('myplugin:actionCompleted', { data: 'value' }); ``` 5. **Handle errors gracefully.** The `error` payload is a structured `PlayerError`, so emit one or let the player's `ErrorHandler` build it ```typescript try { await doSomething(); } catch (error) { api.emit('error', { code: ErrorCode.PLUGIN_SETUP_FAILED, message: (error as Error).message, fatal: false, timestamp: Date.now(), }); // Don't throw, let the player continue } ``` ## Git Workflow ### Branch Naming - `main` - the only long-lived branch; everything merges here - `feat/description` - New features - `fix/description` - Bug fixes - `refactor/description` - Refactoring - `docs/description` - Documentation `changeset-release/main` is created and owned by the release automation. Do not branch from it or push to it. ### Commit Messages Follow [Conventional Commits](https://www.conventionalcommits.org/): ``` type(scope): subject body (optional) footer (optional) ``` **Types**: - `feat`: New feature - `fix`: Bug fix - `docs`: Documentation - `style`: Code style (formatting) - `refactor`: Code refactoring - `test`: Tests - `chore`: Build/tooling **Examples**: ``` feat(core): add plugin priority system fix(hls): resolve memory leak in event listeners docs(api): update plugin API documentation test(core): add PluginManager integration tests ``` ### Pull Request Process 1. Create a branch from `main` 2. Make changes with tests 3. Update the documentation and the package README the change affects 4. Add a changeset (`pnpm changeset`) for anything that reaches a published package. Versions are never hand-edited: the group is fixed, so every package publishes at one version 5. Run `pnpm validate` (package-script guard, lint, build, package-type guard, typecheck, test) 6. Open a PR against `main` 7. Address review comments 8. Merge when approved ### What CI runs `.github/workflows/ci.yml` runs on every push and PR, in this order: 1. `pnpm run lint` 2. `pnpm run build` 3. `node scripts/check-package-artifacts.mjs`: every file a package manifest advertises (`main`, `module`, `types`, `exports`) exists in `dist/` 4. `node scripts/check-embed-chunks.mjs`: every chunk an embed bundle imports was actually emitted 5. `node scripts/check-package-types.mjs`: every shipped `.d.ts` actually compiles for a consumer, not merely exists 6. `node scripts/check-package-scripts.mjs`: every package declares `typecheck` and `test` 7. `pnpm run typecheck` 8. `pnpm run test` On a push to `main` — not on pull requests — CI then installs Chromium and ffmpeg, generates the HLS fixture with `node scripts/hls-fixture.mjs`, and runs `node scripts/verify-browser.mjs` against a freshly built demo bundle, which it discards afterwards. It is gated to `main` because the harness takes minutes and several scenarios assert against fixed waits tuned on a developer machine. `pnpm validate` covers everything except the two post-build guards (3 and 4); run those by hand after `pnpm build` when you touch a package manifest or the embed build. CI runs the test step on Node 24 with no extra flags: since the move to vitest 3 nothing about Node 24 needs working around. ### Releasing Merging a changeset to `main` makes `release.yml` open (or update) a `chore: release packages` PR on the `changeset-release/main` branch. Merging that PR versions all nineteen packages together, publishes each to npm through trusted publishing (OIDC, no token), tags `v`, creates the GitHub release, and uploads the embed bundles to the CDN through `scripts/upload-cdn.sh`. Versions are never bumped by hand. Never commit `demo/demo.bundle.js` or `docs/demo/demo.bundle.js`. The release workflow rebuilds and commits both on every push to `main`, so a bundle committed from a branch only produces binary merge conflicts with that commit. Build it locally to preview, then leave it out of the PR. Do not gitignore it either: the deployed `index.html` is stamped with a version and points at a file that has to exist in the repo. ## Code Review Checklist **Before Requesting Review**: - [ ] Code follows style guidelines - [ ] All tests pass - [ ] New code has tests (80%+ coverage) - [ ] Documentation updated - [ ] No console.log statements - [ ] TypeScript strict mode passes - [ ] Build succeeds **Reviewers Check**: - [ ] Code is understandable - [ ] Edge cases handled - [ ] Error handling appropriate - [ ] Performance considered - [ ] Security concerns addressed - [ ] Consistent with architecture - [ ] Tests are meaningful ## Performance Guidelines ### Bundle Size - Tree-shakeable exports; no side effects at module scope beyond control registration - No circular dependencies - Heavy dependencies load lazily. hls.js is fetched through a dynamic import inside `loadHlsJs()`, and the playlist plugin reaches the UI package with `void import('@scarlett-player/ui')` so it still works when that package is absent - Watch the sizes Vite and tsup print during `pnpm build` rather than adding a budget nobody enforces ### Runtime Performance - Use `requestAnimationFrame` for animations - Debounce high-frequency events - Avoid unnecessary re-renders - Lazy load heavy dependencies **Example**: ```typescript // Debounce resize handler private handleResize = debounce(() => { this.updateLayout(); }, 150); ``` ### Memory Management - Clean up event listeners - Clear timers and intervals - Remove DOM references - Avoid memory leaks Prefer `api.onDestroy(unsubscribe)` at the point of subscription over remembering to undo each one in `destroy()`: `PluginManager` runs those cleanups for you when the plugin is destroyed. ```typescript destroy(): void { // Clear timers clearInterval(interval); // Remove DOM element?.remove(); // Clear references api = null; element = null; } ``` ## Security Guidelines ### Input Validation ```typescript loadSource(src: string | SourceObject): void { // Validate URL if (typeof src === 'string' && !this.isValidUrl(src)) { throw new Error('Invalid source URL'); } // Sanitize if needed const sanitized = this.sanitizeUrl(src); } ``` ### XSS Prevention - Never use `innerHTML` with user content - Always sanitize URLs - Use textContent instead of innerHTML ```typescript // Bad element.innerHTML = userInput; // Good element.textContent = userInput; ``` ### CSP Compliance Plugins must document required CSP directives: ```typescript /** * Required CSP: * - script-src: For HLS.js loading * - media-src: For media sources * - connect-src: For manifest fetching */ ``` ## Accessibility Guidelines **WCAG 2.1 Level AA Required** ### Keyboard Navigation - All controls keyboard accessible - Logical tab order - Visible focus indicators - Keyboard shortcuts documented ### ARIA Attributes ```typescript button.setAttribute('aria-label', 'Play video'); button.setAttribute('aria-pressed', 'false'); slider.setAttribute('aria-valuemin', '0'); slider.setAttribute('aria-valuemax', '100'); slider.setAttribute('aria-valuenow', '50'); ``` ### Screen Readers - Meaningful labels - Status announcements - Error messages ```typescript // Announce state change const liveRegion = document.createElement('div'); liveRegion.setAttribute('aria-live', 'polite'); liveRegion.setAttribute('aria-atomic', 'true'); liveRegion.textContent = 'Video playing'; ``` ## Version Guidelines **Semantic Versioning** (SemVer), applied through Changesets in fixed mode: all nineteen packages share one version number, so a release publishes them together even where a package did not change. - MAJOR: Breaking changes - MINOR: New features (backwards compatible) - PATCH: Bug fixes **Pre-release versions**: - `0.x.x` - Initial development - `1.0.0-alpha.1` - Alpha - `1.0.0-beta.1` - Beta - `1.0.0-rc.1` - Release candidate - `1.0.0` - Stable ## License Guidelines **MIT License** for all packages Include attribution for Vidstack: ``` Portions of this software were inspired by Vidstack Player Copyright (c) 2023 Rahim Alwer MIT License - https://github.com/vidstack/player ``` ## Questions? When in doubt: 1. Read `docs/architecture.md` for how the pieces fit together 2. Read `docs/plugin-authoring.md` if the change is a plugin 3. Read the existing code: it is the specification, and a claim about how the player behaves is not worth making until it has been checked against the source 4. Reference Vidstack for patterns (not implementation) --- # Scarlett Player vs. Video.js > Rendered at https://scarlettplayer.com/vs-videojs/ · Source: https://github.com/Hackney-Enterprises-Inc/scarlett-player/blob/main/docs/scarlett-vs-videojs.md Scarlett Player replaced Video.js as the player behind [The Stream Platform](https://thestreamplatform.com). That was a fit decision for one Vue application with its own live-monitoring, clipping and analytics needs, not a verdict on Video.js. Both players are open source and modular, and both ship TypeScript types. This page sets out where they differ, with a source for every claim, so you can make the same call for your own project. **What we compared.** Scarlett Player 1.16.1 against the `video.js` package at 8.24.1 (the current v8 line) and the Video.js v10 packages (`@videojs/core`, `@videojs/html`, `@videojs/react`) at 10.0.0-rc.2, a release candidate. Versions and documentation were checked on September 22, 2026; both projects move, so check the linked sources before you rely on a detail. We have not run a size, startup or latency benchmark between the two, and this page makes no such claim. ## At a glance | | Scarlett Player | Video.js | | --- | --- | --- | | License | MIT | Apache-2.0 (`video.js` 8.24.1) | | Streaming formats | HLS (hls.js, or native HLS for AirPlay and browsers without MSE), native files, WHEP | HLS and DASH through VHS, bundled in the default v8 build | | DRM | Not shipped (planned) | Through `videojs-contrib-eme` | | WHEP (WebRTC) | First-party provider | Third-party plugins only | | Framework bindings | Vue 3 | v10: React components and HTML custom elements | | TypeScript | Written in TypeScript | v8 ships type declarations; v10 announces first-class TypeScript support | | Plugins | 19 first-party packages | A [plugin directory](https://legacy.videojs.org/plugins/); 381 npm packages carry the `videojs-plugin` keyword (September 22, 2026) | ## Architecture and framework integration Video.js v8 uses a player, component and plugin model. Its React guide imports `video.js`, creates the player in the component lifecycle, subscribes to events and disposes of the player on cleanup; it does not need a browser global or state polling. Plugins register through a shared API, and "advanced" plugins are classes with their own instances, state and lifecycle. [React integration](https://legacy.videojs.org/guides/react/), [plugin guide](https://legacy.videojs.org/guides/plugins/) Video.js v10 splits state, media and UI into separate parts, with composable features and optional skins. It ships React hooks and components and HTML custom elements. Its installation guide says the framework guides "use the HTML custom elements until we add first-party Vue and Svelte packages." Modular composition and TypeScript support are therefore not things only Scarlett offers. [v10 announcement](https://videojs.org/blog/videojs-v10-beta-hello-world-again), [v10 installation](https://videojs.org/docs/framework/react/how-to/installation) Scarlett keeps each state key in its own signal and gives every plugin an instance-specific API with state, events, the container and a logger. Its [Vue composable](https://github.com/Hackney-Enterprises-Inc/scarlett-player/blob/main/packages/vue/src/composables/useScarlettPlayer.ts) turns events and state subscriptions into Vue refs without polling. There is no first-party React package yet; a React wrapper and a Web Component wrapper are on the [roadmap](https://github.com/Hackney-Enterprises-Inc/scarlett-player/blob/main/README.md#roadmap). The player always needs a DOM container, even when you leave out its UI plugin. See [Architecture](https://scarlettplayer.com/architecture/index.md) for the full design. ## Streaming capabilities Video.js HTTP Streaming (VHS) plays HLS and DASH and is part of the default v8 build: `video.js` 8.24.1 depends on `@videojs/http-streaming`. A core-only build (`video.js/core`, or `dist/alt/video.core.js`) leaves VHS out; in 8.24.1 it is roughly 40% of the size of the full `dist/video.js` (unminified). DRM goes through `videojs-contrib-eme`, which supports Encrypted Media Extensions. [VHS](https://github.com/videojs/http-streaming), [contrib-eme](https://github.com/videojs/videojs-contrib-eme), [npm package](https://www.npmjs.com/package/video.js) Scarlett ships three playback providers: native files, HLS and WHEP. The HLS provider uses hls.js by default and switches to the browser's native HLS when AirPlay is active or Media Source Extensions are unavailable. There is no DASH provider and no DRM package; DRM support is on the roadmap. [Packages](https://github.com/Hackney-Enterprises-Inc/scarlett-player/blob/main/README.md#packages), [HLS provider](https://github.com/Hackney-Enterprises-Inc/scarlett-player/blob/main/packages/plugins/hls/README.md) Scarlett's [WHEP provider](https://github.com/Hackney-Enterprises-Inc/scarlett-player/blob/main/packages/plugins/whep/README.md) plays WebRTC streams from servers that answer a WHEP offer with a `201`. It does not handle server counter-offers, trickle ICE or ICE restarts. It is built for low-delay live monitoring; actual latency depends on your server and network. Neither Video.js v8 nor the v10 packages include a WHEP or WebRTC provider, and the `videojs` GitHub organization has no WHEP project. Third-party plugins fill the gap: Millicast's [`videojs-plugin-millicast-whep`](https://github.com/millicast/videojs-plugin-millicast-whep) plays WHEP, and Ant Media's [`videojs-webrtc-plugin`](https://github.com/ant-media/videojs-webrtc-plugin) plays WebRTC from Ant Media Server through that server's own signaling rather than WHEP. ## Dependencies and bundle size Scarlett's core package has no runtime npm dependencies, and every plugin is a separate package, so an application build includes only the plugins it imports. That does not make the whole stack dependency-free: the HLS provider needs `hls.js` as a peer dependency, and the controls and features you add all count toward your bundle. The [embed](https://scarlettplayer.com/embed/index.md) builds bundle a fixed set of plugins. [Core manifest](https://github.com/Hackney-Enterprises-Inc/scarlett-player/blob/main/packages/core/package.json), [HLS manifest](https://github.com/Hackney-Enterprises-Inc/scarlett-player/blob/main/packages/plugins/hls/package.json) Video.js v8 has its core-only build, and the v10 announcement describes much smaller compositions and an alternative streaming engine. Those are Video.js's own figures against Video.js v8, not a comparison with Scarlett. We have not measured equivalent builds of the two players, so this page does not say which one is smaller or faster. [v10 announcement](https://videojs.org/blog/videojs-v10-beta-hello-world-again) ## Which to choose Scarlett is worth evaluating if you build in Vue and want first-party WHEP playback, viewer clipping, QoE analytics sent to your own endpoint, and a typed plugin API in one set of packages. Video.js is the safer choice if you depend on DASH, EME-based DRM or existing Video.js plugins, all of which you would have to replace to move. React teams should compare Video.js v10's first-party React API with the custom integration Scarlett currently needs. Neither license removes the work of integrating and maintaining a player. Also compare the Apache-2.0 and MIT terms for the exact packages you would ship. See also: [Scarlett vs. Mux Player](https://scarlettplayer.com/vs-mux/index.md) and [Scarlett vs. Bitmovin Player](https://scarlettplayer.com/vs-bitmovin/index.md). --- # Scarlett Player vs. Mux Player > Rendered at https://scarlettplayer.com/vs-mux/ · Source: https://github.com/Hackney-Enterprises-Inc/scarlett-player/blob/main/docs/scarlett-vs-mux.md Mux Player is the web player Mux builds for its own video platform: it is tightly integrated with Mux Video hosting and Mux Data analytics, and it is customizable through themes and CSS. Scarlett Player is not tied to a hosting vendor: you assemble playback, controls and analytics from separate packages and point them at your own infrastructure. This page compares the two player libraries, with a source for every claim. Mux Video hosting, Mux Data and the hosted `player.mux.com` iframe are services with their own terms and are not compared here. **What we compared.** Scarlett Player 1.16.1 against `@mux/mux-player` and `@mux/mux-player-react` 3.13.4. Versions and documentation were checked on September 22, 2026; check the linked sources before you rely on a detail. We have not run a size, startup or latency benchmark between the two, and this page makes no such claim. ## At a glance | | Scarlett Player | Mux Player | | --- | --- | --- | | License | MIT | MIT (the player library; Mux services are billed separately) | | Built on | Plugin-based TypeScript core | Web Components: Media Chrome and the `mux-video` element | | Media sources | Any URL your host serves: native files, HLS, WHEP | Mux playback IDs, or a plain `src` URL | | Analytics | Optional plugin that posts to your own endpoint | Mux Data, on unless `disable-tracking` is set | | Framework bindings | Vue 3 | Official React wrapper | | Customization | Replace or omit the UI plugin; build controls on state and events | CSS variables, CSS parts and Media Chrome themes | ## Media sources and hosting Mux's FAQ asks "Do you support non-Mux HLS streams?" and answers that the player "is designed with the Mux Platform in mind", pointing to features such as timeline hover previews and descriptive Mux Data errors that depend on that coupling. The player's source does accept a plain media URL: its `src` setter works without a playback ID. So non-Mux sources play, but the Mux-specific features and support commitments are designed around Mux Video. [Mux Player FAQ](https://www.mux.com/docs/guides/player-faqs), [player source](https://github.com/muxinc/elements/blob/main/packages/mux-player/src/base.ts) Scarlett plays whatever URLs your host gives it through its native, HLS and WHEP providers, with no media vendor required. Codecs, CORS, authentication and protocol support still have to line up. The WHEP provider works with servers that answer a WHEP offer with a `201`, not with servers that send counter-offers. [Packages](https://github.com/Hackney-Enterprises-Inc/scarlett-player/blob/main/README.md#packages), [WHEP provider](https://github.com/Hackney-Enterprises-Inc/scarlett-player/blob/main/packages/plugins/whep/README.md) ## Analytics and data handling Mux Player reports to Mux Data. The HTML API's `disable-tracking` attribute ("Disables Mux Data tracking") defaults to `false`, so tracking runs unless you set it; React exposes the same switch as `disableTracking`. `disable-cookies` is a separate attribute and does not turn tracking off. `beacon-collection-domain` sends Mux Data beacons to a custom domain. [HTML API](https://www.mux.com/docs/guides/player-api-reference/html), [React API](https://www.mux.com/docs/guides/player-api-reference/react) Scarlett's [analytics plugin](https://github.com/Hackney-Enterprises-Inc/scarlett-player/blob/main/packages/plugins/analytics/README.md) is optional. When you add it, it requires a `beaconUrl` you provide and also accepts a custom transport. You decide where the data goes, and you own ingestion, storage, dashboards and consent handling. It is a client-side QoE collector, not a hosted analytics product. ## UI and framework integration Mux Player is built from Web Components: Media Chrome for the UI and the `mux-video` element for playback and Mux Data. `@mux/mux-player` 3.13.4 depends on `media-chrome`, `@mux/mux-video`, `@mux/playback-core` and `player.style`, and ships TypeScript declarations. `@mux/mux-player-react` is Mux's official React wrapper at the same version. [Architecture FAQ](https://www.mux.com/docs/guides/player-faqs), [manifest](https://github.com/muxinc/elements/blob/main/packages/mux-player/package.json), [React API](https://www.mux.com/docs/guides/player-api-reference/react) You customize Mux Player with CSS variables, exposed CSS parts and Media Chrome themes, which can change the layout as well as the styling, from an inline `