@playdeck/provider-hls
v1.4.0
Published
HLS provider for Playdeck, over hls.js or the browser’s native HLS.
Maintainers
Readme
@playdeck/provider-hls
The HLS provider for Playdeck. Picks
between the browser's native HLS and hls.js, and delegates everything the
<video> element already answers to @playdeck/provider-native rather than
reimplementing it.
pnpm add @playdeck/provider-hlsThis package has no React dependency of its own: the HLS engine and its
native fallback are driven through a PlayerController, as in "Without React"
below. Where you do pair it with @playdeck/react's primitives, that
package's peer range is React 19 only.
@playdeck/react loads this for you when the source resolves to hls: an
.m3u8 path, or an explicit { type: 'hls' } source; see
Provider setup. hls.js
is a dependency but is imported dynamically, so it only reaches the network when
the hls.js engine is actually selected.
import * as Player from '@playdeck/react';
// An HLS source is an `.m3u8` URL in the `source` prop. `providerOptions.hls`
// takes a `build` name, never the loader function `createHlsProvider` itself
// accepts: a provider option bag is compared with `Object.is`, and a function
// passed inline would never reach a stable identity across renders. `'light'`
// is smaller and drops subtitles, alternate audio, CMCD, EME and Variable
// Substitution. See `@playdeck/provider-hls`'s README for the size and
// before choosing it over the default `'full'` build.
export const HlsClip = () => (
<Player.Root
providerOptions={{ hls: { build: 'light' } }}
source="https://example.com/master.m3u8"
>
<Player.Viewport>
<Player.Media />
<Player.Controls>
<Player.PlayButton />
<Player.SeekSlider />
<Player.Time type="current" />
<Player.FullscreenButton />
</Player.Controls>
{/* A source `detectSource` refuses, or (when an engine is pinned
through an explicit `{ type: 'hls', src, engine }` source) one the
browser cannot provide, is reported here with an explained error
rather than a silent fallback. */}
<Player.ErrorDisplay />
</Player.Viewport>
</Player.Root>
);Without React
Reach for this package directly when you are writing a provider adapter, or hosting a player somewhere other than React.
import { PlayerController } from '@playdeck/core';
import { createHlsProvider } from '@playdeck/provider-hls';
declare const videoElement: HTMLVideoElement;
const controller = new PlayerController();
// hls.js is a dependency but is imported dynamically, so it only reaches the
// network when the hls.js engine is actually the one selected.
controller.setProvider(
createHlsProvider(videoElement, { type: 'hls', src: '/master.m3u8' })
);
export const play = (): Promise<unknown> => controller.play();Engine selection
auto (the default) prefers native HLS where the browser has it, and falls back
to hls.js where Media Source Extensions exist. native and hls.js force one;
forcing an engine the browser cannot provide fails with an explained error
rather than silently falling back.
import {
createHlsProvider,
detectHlsEnvironment,
selectHlsEngine
} from '@playdeck/provider-hls';
declare const videoElement: HTMLVideoElement;
// What this browser offers, asked before anything is loaded.
const environment = detectHlsEnvironment(videoElement); // { nativeHls, mse }
// Forcing an engine the browser cannot provide fails with an explained error
// rather than silently falling back to the other one.
const selection = selectHlsEngine('hls.js', environment);
if (selection.engine === null) throw new Error(selection.error.message);
export const engine = selection.engine; // 'native' | 'hls.js'
export const provider = createHlsProvider(videoElement, {
type: 'hls',
src: '/master.m3u8',
engine: 'hls.js'
});Exports
| Export | What it is |
| ---------------------- | --------------------------------------------------------------------------------- |
| createHlsProvider | Builds the adapter over a <video> element and an HlsSource. |
| selectHlsEngine | The engine decision, given a requested engine and a detected environment. |
| detectHlsEnvironment | What the browser offers: native HLS, MSE, or neither. |
| deriveLiveState | The shared isLive / atLiveEdge derivation, re-exported from @playdeck/core. |
Types: HlsProviderOptions, HlsEnvironment, HlsEngineSelection,
LiveDerivationInput, HlsModuleLoader, HlsBuild, and the structural shapes
this adapter consumes from hls.js: HlsConstructorLike, HlsInstanceLike,
HlsConfigLike, HlsLevelLike, HlsSubtitleTrackLike, HlsParsedCueLike.
Supplying your own hls.js
loadHls replaces the dynamic import, for pinning a version, or serving it
from somewhere else:
import { createHlsProvider } from '@playdeck/provider-hls';
declare const videoElement: HTMLVideoElement;
export const provider = createHlsProvider(
videoElement,
{ type: 'hls', src: '/master.m3u8' },
{ loadHls: () => import('hls.js') }
);The module you return has to expose Hls.Events.LEVELS_UPDATED. hls.js prunes
levels during its own error recovery, and the published quality ladder has to
follow it; a build without that event would leave rungs in a menu that no longer
exist.
The light build
hls.js/light is a supported thing to return. It is about 53 KB gzip smaller
than the full build, and what it drops is alternate audio, subtitles, CMCD, EME
and Variable Substitution:
createHlsProvider(videoElement, source, {
loadHls: () => import('hls.js/light')
});build: 'light' does the same thing as a name rather than a loader (#579),
and it is the form @playdeck/react exposes through Player.Root:
createHlsProvider(videoElement, source, { build: 'light' });
// Reached through `Player.Root` the same way:
// <Player.Root providerOptions={{ hls: { build: 'light' } }} source={source}>loadHls stays the only way to pin an hls.js version or serve it from
somewhere other than this package's own bundled dependency, and reaching it
means mounting createHlsProvider directly the way this section's own example
does: a provider option bag Player.Root fans into compares its values with
Object.is, which a function passed inline can never do meaningfully twice.
Subtitles are the half that reaches this adapter. The light build still parses a
manifest's subtitle renditions and reports them once, and then never emits
SUBTITLE_TRACKS_UPDATED, because the controller that would has been compiled
out. So the tracks can be counted and never selected.
That is reported rather than hidden. On a manifest that declares subtitles, a build with no subtitle controller publishes:
capabilities.selectTextTrack; // { status: 'unavailable', reason: 'provider-build' }provider-build is its own reason because neither neighbour is true: the
provider is perfectly able (provider would be wrong) and the media does have
subtitles (source would be wrong).
What this changes is the honesty of the state, not what a viewer sees.
Player.CaptionsButton renders only on available, so it was already absent
here, but the capability read unknown / provider-check, meaning "still
checking", and it read that for the whole session because the check it was
waiting on was never going to report. An operator reading state, or a test
asserting on it, could not tell a build that cannot show captions from one that
had not finished looking. Now it can.
The detection is a synchronous read of Hls.DefaultConfig, where the full build
registers subtitleTrackController and the light build does not. A module
exposing no DefaultConfig at all is treated as capable, so an unrecognised
build behaves exactly as it did before this existed.
One wart, and it is hls.js's rather than this package's: the ./light subpath
carries no types condition in hls.js's export map, so import('hls.js/light')
resolves to any. It costs you nothing here, because loadHls is typed and the
any lands on a typed parameter, but your editor will not describe the module.
Take the light build when you know your manifests carry no subtitles, or when
you render captions yourself from a sidecar <track>, which
Player.Media's textTracks prop still serves on the hls.js engine. Keep the
full build otherwise: 53 KB is not worth a caption track your viewers needed.
What it reports honestly
selectQualityisavailableon the hls.js engine, with the ladder from the manifest carrying height, width and bitrate.autois not published as a rung: it isselectedQualityId: null. On the native engine the browser chooses and the capability isunavailable/source.- Live streams report
isLiveandatLiveEdgederived from the seekable window, not from a manifest tag. liveEdgeandseekToLiveEdgeareavailableon the hls.js engine once the source is live, hls.js's ownliveSyncPositionis finite and the seekable window is wide enough to scrub:seekToLiveEdge()lands there, never on the raw seekable end. On the native engine the edge is that raw seekable end, the only notion of it a plain media element has.- Captions on the hls.js engine come from hls.js, which is the sole owner:
sidecar
<track>children discovered by the native subsystem are dropped so the two cannot both claim the state. hls.js's parsed cues can carry WebVTT tags and character references, so cue markup reachesTextCue.textas plain text: tags stripped, entities decoded. On the native HLS engine captions come from the embedded native adapter, which decodes the same way. - Chapters are the native adapter's, on both engines. HLS carries no
chapters concept of its own, and
EXT-X-DATERANGEroutes into the metadata track, so akind="chapters"text track on the media element is the only source, and it is not caption state, so the hls.js engine does not drop it.
deriveLiveState is that derivation. It lives in
@playdeck/core,
where every adapter shares one copy, and is
re-exported here so a custom HLS adapter can reuse it:
import { deriveLiveState } from '@playdeck/provider-hls';
// Liveness is derived from what the stream reports: never from the URL or a
// filename. `isLiveHint` is hls.js's own answer where it has one; the native
// engine leaves it undefined and an infinite duration decides instead.
export const live = deriveLiveState({
isLiveHint: true,
duration: Number.POSITIVE_INFINITY,
seekable: [{ start: 120, end: 3600 }],
currentTime: 3598,
// hls.js's liveSyncPosition: the target edge, behind the raw seekable end.
liveEdge: 3594
});
// -> { isLive: true, atLiveEdge: true, offsetFromEdge: 0 }. `null` means "not
// live, or not yet known". A control should not claim either until it is.
export const atEdge = live?.atLiveEdge ?? false;License
MIT.
