@playdeck/core

Framework-neutral player state, commands, events and the provider contract that Playdeck is built on. No DOM rendering, no React, no provider SDKs.

If you are building UI in React, start with @playdeck/react — it owns a controller for you and loads providers on demand, and it is the only renderer Playdeck ships. Use this package directly when you are writing a provider adapter, or hosting a player outside React: the same state, commands and provider contract, with nothing rendered for you.

pnpm add @playdeck/core

This package imports no React API and has no React dependency of any kind — the framework-free claim above holds at the manifest level too. If you do want the React primitives on top of it, @playdeck/react's peer range is >=19 <20: React 19 only.

What it gives you

import { PlayerController, detectSource } from '@playdeck/core';
import { createNativeProvider } from '@playdeck/provider-native';

declare const videoElement: HTMLVideoElement;

// Resolves a URL into an explicit source, or explains why it cannot — nothing
// is handed to a provider to fail later.
const source = detectSource('https://example.com/clip.mp4');
if (source.status === 'failure') throw new Error(source.guidance);

const controller = new PlayerController();
controller.setProvider(createNativeProvider(videoElement));

const unsubscribe = controller.subscribe((state) => {
  console.log(state.playback, state.currentTime, state.capabilities.seek);
});

// Commands are answered, never queued: `whenReady` is how you find out when
// one will land, rather than issuing it and hoping.
export const start = async (): Promise<void> => {
  if (await controller.whenReady()) await controller.play();
};

export const stop = (): void => {
  unsubscribe();
  controller.setProvider(undefined);
};

The two ideas worth knowing before the API list

A capability is not a boolean. Every entry in PlayerState.capabilities is an Availability: available on its own, or unknown or unavailable with a reason for it. The two negative-looking statuses are not degrees of one answer. unknown is undecided, so a control reading it renders nothing rather than something disabled. unavailable is "no". Each status has its own reason vocabulary, and the two do not overlap.

Undecided is not the same as pending: nothing promises that an unknown resolves. A provider may leave a capability there on purpose rather than pay what answering would cost, so a consumer should treat unknown as a state to render for, not a state to wait on.

Status reason What it says
available none The command can be issued. This status carries no reason at all.
unknown not-ready Nothing has answered yet: no provider is attached, or the attached one has not got as far as this question.
unknown provider-check A provider is attached and has not answered this one, and may never — resolving it can cost a request it declines to make unasked.
unavailable browser This engine exposes no such API, or the device refuses it — iOS pins media volume to its hardware switch.
unavailable policy The document, the permissions policy or an attribute on the media element forbids it.
unavailable provider The active provider has nothing to give here — an SDK that wires no such command, or a condition that is false right now, such as AirPlay before any receiver has announced itself. It flips when that changes.
unavailable provider-build The provider is able and the media does carry it, but the build of the third-party engine in use had that machinery compiled out. hls.js/light ships no subtitle controllers, so a manifest's subtitle tracks can be counted and never selected.
unavailable provider-plan The provider offers it, but not on the account behind this video. Vimeo's plan-gated features answer this way.
unavailable source The media itself has none: no rendition ladder, no chapters, nothing seekable to seek through.

So a control never has to guess what a provider can do, and never has to read a question nobody has answered as a "no".

Commands are answered, never queued. Every command resolves a CommandResult — { ok: true } or { ok: false, reason }. Before a provider declares itself ready, commands are refused with not-ready and nothing is replayed later. PlayerState.commandsReady and whenReady() are how you find out when a command will land; activation is not a substitute for either.

Exports

Values

Export What it is
PlayerController The controller: holds state, issues commands, emits events, owns a ProviderAdapter.
detectSource Resolves a string or explicit source object into a ResolvedPlayerSource, or an explained failure.
isPermittedSourceUrl Whether the library will carry a source URL to a provider — the one such decision, which detection consults.
resolveNetworkPath Normalises a protocol-relative URL (//host/...) to https:; returns every other value unchanged.
createInitialPlayerState The state a controller starts from — useful for server rendering and for test fixtures.
isNotice Whether a published error is a notice — a rejected value reported while the player carried on — or a failure.
getMediaSessionCoordinator The one coordinator for a given MediaSession, so several players arbitrate lock-screen ownership.
bindMediaSession Binds a controller's confirmed playback to a coordinator root, and routes its actions back.
textTrackLabel The label a provider should publish for a track, given its own label and language.
plainCueText The plain text a provider should publish for a cue, given its raw WebVTT payload — tags stripped, entities decoded.
isValidTextTrackLanguage Whether a string has a BCP 47 tag's shape (letters, digits and hyphens, bounded length) — Player.Root's preferredTextTrackLanguage gate.
notifySafely Notifies one listener so that its throw neither abandons the emit nor escapes into the caller.
createTimeBoundary The sanitised [startTime, endTime] window a provider enforces, and every question it answers.
deriveLiveState The isLive / atLiveEdge / offsetFromEdge derivation every adapter publishes live from.
liveStateEqual Whether two live states say the same thing — what an adapter checks before publishing a change.
deriveChapters The published Chapter collection, given what a provider reports and the media duration — end times included.
chaptersEqual Whether two chapter collections say the same thing — what an adapter checks before publishing a change.
isYouTubeVideoId Whether a value is a well-formed YouTube video id — what createYouTubeProvider validates a direct call with.
isVimeoVideoId Whether a value is a well-formed Vimeo video id — what createVimeoProvider validates a direct call with.
isVimeoHash Whether a value is a well-formed Vimeo privacy hash — what createVimeoProvider validates a direct call with.
isWistiaMediaId Whether a value is a well-formed Wistia media id — what createWistiaProvider validates a direct call with.
parseThumbnailCues Parses a seek-preview WebVTT file into its ordered, sprite-region-resolved cues.
thumbnailCueAt The cue covering a given time, matched half-open, or null.

Types

State and contract: PlayerState, PlayerCapabilities, Availability, CommandResult, CommandFailureReason, PlaybackState, PlayerProvider, PlayerQuality, TimeRange, TextTrack, TextTrackKind, TextTrackReadiness, TextCue, CaptionRendering, Chapter, ChapterInput, PlayerLiveState, PlayerError, PlayerErrorCategory, PlayerErrorSeverity, RefusedPlay, PlayerCommand, RefusedCommand, RefusedUrlSurface, PreProviderActivation, ThumbnailCue, ThumbnailRegion.

Events: PlayerEvent, PlayerEventType, PlayerEventDetailMap, PlayerEventFor, PlayerEventOrigin.

Sources: PlayerSource, ResolvedPlayerSource, VideoFileSource, HlsSource, HlsEngine, YouTubeSource, VimeoSource, WistiaSource, SourceDetectionResult, SourceDetectionSuccess, SourceDetectionFailure, SourceDetectionFailureReason.

Providers: ProviderAdapter, ProviderStatePatch, ProviderStateListener, ProviderEvent, ProviderEventFor, MediaDimensions, TimeBoundary, LiveDerivationInput.

Autoplay: AutoplayMode, AutoplayConfigurationOptions.

Media Session: MediaSessionLike, MediaSessionCoordinator, MediaSessionRoot, MediaSessionRootConfig, MediaSessionActions, MediaSessionBinding, MediaMetadataInput, MediaSessionArtwork, MediaSessionPositionState.

Source detection

detectSource accepts a URL string or an explicit source object, and validates both. A YouTube, Vimeo or Wistia URL only resolves if the host, path shape and id are all recognised; anything else fails with malformed-string, unsupported-string or invalid-source rather than being passed on to a provider to fail later. Wistia is the one exception, because it also serves plain media files on its own hosts: a Wistia URL that is not an embed shape is still read by file extension, so its HLS manifests and direct deliveries resolve as hls and video. Every accepted form, per provider, is listed in Provider setup.

import {
  detectSource,
  isPermittedSourceUrl,
  isVimeoHash,
  isVimeoVideoId,
  isWistiaMediaId,
  isYouTubeVideoId,
  resolveNetworkPath,
  unsupportedSourceFormat
} from '@playdeck/core';

// A URL only resolves if the host, path shape and id are all recognised.
const vimeo = detectSource('https://vimeo.com/76979871?h=8272103f6e');
if (vimeo.status === 'success' && vimeo.source.type === 'vimeo') {
  console.log(vimeo.source.videoId, vimeo.source.hash); // privacy hash kept
}

// Two `v` parameters: ambiguous, so it fails here rather than in a provider.
const ambiguous = detectSource(
  'https://www.youtube.com/watch?v=dQw4w9WgXcQ&v=other'
);
if (ambiguous.status === 'failure') {
  console.log(ambiguous.reason, ambiguous.guidance);
}

// Explicit source objects are validated too, and skip detection: the same
// scheme allowlist runs over their `src` values, so `javascript:` and `data:`
// cannot reach a provider by taking the object path.
export const explicit = detectSource({
  type: 'hls',
  src: '/master.m3u8',
  engine: 'hls.js'
});

// The decision detection consults, should you need to ask it yourself. Pass
// the type of the source the URL belongs to, or `undefined` for a bare string
// no type has been resolved for yet. The type is load-bearing: a `blob:`
// handle is for a video element to read, not for the HLS manifest loader to
// fetch, and never for an undetected string.
const objectUrl = URL.createObjectURL(new Blob([], { type: 'video/mp4' }));
console.log(isPermittedSourceUrl(objectUrl, 'video')); // true
console.log(isPermittedSourceUrl(objectUrl, 'hls')); // false
console.log(isPermittedSourceUrl(objectUrl, undefined)); // false

// The same per-provider id checks a factory runs on a direct call, should you
// need to validate an id before ever reaching `createYouTubeProvider`,
// `createVimeoProvider` or `createWistiaProvider` yourself.
console.log(isYouTubeVideoId('dQw4w9WgXcQ')); // true
console.log(isVimeoVideoId('76979871')); // true
console.log(isVimeoHash('8272103f6e')); // true
console.log(isWistiaMediaId('abc123')); // true

// A format this library recognises and does not play fails with its own reason,
// so the message a consumer reads can name what arrived rather than restate the
// list of accepted forms.
const dash = detectSource('https://cdn.example.com/stream.mpd');
if (dash.status === 'failure') {
  console.log(dash.reason); // 'unsupported-format'
}

// The same list, should you want to turn a URL down before setting it as a
// source. It names the format, and answers `undefined` for everything it does
// not refuse — including the formats this library plays.
console.log(unsupportedSourceFormat('https://cdn.example.com/stream.mpd')); // 'DASH'
console.log(unsupportedSourceFormat('https://cdn.example.com/master.m3u8')); // undefined

// The substitution `isPermittedSourceUrl` itself never performs, for a caller
// that validates a URL and then needs to write the same normalisation back.
console.log(resolveNetworkPath('//example.com/clip.mp4')); // 'https://example.com/clip.mp4'
console.log(resolveNetworkPath('https://example.com/clip.mp4')); // unchanged

One scheme allowlist governs both paths, and isPermittedSourceUrl is it. http:, https: and the scheme-less forms — protocol-relative, root-relative and relative paths — are permitted; blob: is permitted only for a video source, which is how a MediaSource or a picked File is handed over. Everything else, javascript:, data: and file: included, is rejected, whether it arrives as a string or inside an explicit source object, and however it is dressed up in the characters the URL parser removes before it parses. A URL carrying a raw tab, line feed or carriage return anywhere, or a C0 control (U+0000 to U+001F) or a space at either end, is rejected: the parser strips exactly those, so javascript:alert(1) names no scheme to validate yet loads as javascript: all the same. Rejecting such a URL rather than trimming it keeps the value that plays identical to the value that was validated. A protocol-relative URL resolves against https:, and the resolved source carries that resolution rather than the //host/... form — for a string and for every src inside an explicit source object alike. So a result's source may be a normalised copy of the object passed in; its input is always the caller's own object.

An HlsSource's engine is a request rather than a report. 'auto', which is also what an omitted engine means, asks for whichever engine the browser can serve; 'native' and 'hls.js' force one and fail the attach where the browser cannot provide it. PlayerState.hlsEngine is the answer: the HLS provider publishes it as it attaches, 'native' where the browser plays the manifest itself and 'hls.js' where Media Source Extensions carry it. It is null before that attach, null where engine selection failed and the state went to error instead, and null under every other provider — no other adapter publishes it — so a control reading it is asking which engine is playing this manifest, never whether the source is HLS. Attaching, swapping or detaching a provider rebuilds the snapshot from createInitialPlayerState(), which puts it back to null with everything else.

Starting state

createInitialPlayerState() is the state a controller starts from — what to render on a server, and what a test fixture should begin with.

isNotice() answers the question any consumer rendering PlayerState.error itself has to ask first: is this a notice — a value the player rejected while it carried on with a fall-back — or a failure? It is the same rule the controller and ErrorDisplay apply, so a custom error surface classifies an error exactly as the bundled one does.

import {
  createInitialPlayerState,
  isNotice,
  isValidTextTrackLanguage,
  plainCueText,
  textTrackLabel,
  type PlayerState
} from '@playdeck/core';

// The state a controller starts from. Safe to render on a server, where no
// provider exists yet — and the same state a test fixture should start from.
const initial = createInitialPlayerState();

console.log(initial.duration); // null — nothing has loaded
console.log(initial.capabilities.seek.status); // 'unknown', not 'unavailable'

// A control reading an `unknown` capability renders nothing rather than
// something disabled: the answer is not "no", it is "not yet".
export const seekIsUndecided = initial.capabilities.seek.status === 'unknown';

// Whether the published error is a notice — a rejected option reported while
// the player carries on with a fall-back — rather than something that stopped
// playback. Ask before covering the player: a notice must never be rendered as
// a failure, and only the lifecycle beside it tells the two apart.
export const rendersAsFailure = (state: PlayerState): boolean =>
  state.error !== null && !isNotice(state.error, state.lifecycle);

// The label a provider should publish for a track, given the track's own label
// and its language. Falls back to the language's own name, then to 'Unknown'.
export const labelled = textTrackLabel('', 'pt-BR'); // 'português (Brasil)'
export const named = textTrackLabel('Commentary', 'en'); // 'Commentary'

// The plain text a caption overlay should render for a cue: WebVTT tag spans
// removed, its character references decoded. Every provider runs its own cue
// payload through this before publishing it as `TextCue.text`.
export const cue = plainCueText('<v Bob><i>Look out</i> &amp; run'); // 'Look out & run'

// A BCP 47 tag's shape, not its registry membership: letters, digits and
// hyphens, bounded length. `Player.Root`'s `preferredTextTrackLanguage` runs
// every value through this before using it for matching or, on YouTube,
// folding it into the embed's `cc_lang_pref` var -- a value that fails is
// ignored exactly as an absent prop would be.
console.log(isValidTextTrackLanguage('en-GB')); // true
console.log(isValidTextTrackLanguage('en&autoplay=1')); // false

Activation, before there is a provider

PlayerState.activation is how far the player has got towards having a provider at all, which lifecycle cannot say on its own: a player nobody has asked to load anything yet and a player whose provider module is still being fetched are both outside the media's own lifecycle, and lifecycle calls them 'idle' and 'loading' without distinguishing either from what a loaded provider does. PreProviderActivation is the part of that field a caller may set through PlayerController.setActivation, which is to say the states that can be true while no provider exists. 'ready' is not one of them: it is a report the attached provider publishes about itself, and in the window this type describes there is nothing to make it.

That makes setActivation the entry point for a host that defers loading — until a viewport intersection, or until someone interacts. { activation: 'dormant' } is a player that has not begun, 'eligible' is one that has been asked to begin, and 'loading-provider' is one whose provider is on the way. { activation: 'error', error } is the attempt that never reached a provider at all, carrying the PlayerError that says why — a source that was refused, or a configuration under which the player will never load. The controller derives lifecycle from whichever is set and puts the error into the state's one error slot, clearing that slot on every activation that is not 'error', so a consumer moves one field rather than keeping two in agreement.

The call is ignored once a provider is attached, because the question it answers has been overtaken: a provider in hand is the answer, and setProvider publishes 'loading-provider' itself as part of the attach.

Origins and refusals

Every command carries an origin. The ones a viewer performs directly — starting, stopping and moving through playback — take one explicitly through the *WithOrigin entry points, while their untagged counterparts pass 'api' on the caller's behalf. The controls Playdeck ships tag what a person did as 'user', and autoplay's own attempt is tagged 'autoplay'. An origin is not bookkeeping: it is what separates the case these fields exist for — the viewer asked for something and nothing happened — from a programmatic call nobody was watching.

Where a seek came from

PlayerState.seekOrigin is where the seek in flight came from, and null whenever seeking is false: a seek that is not happening has no provenance. seeking keeps its plain boolean meaning, and this is the additive field beside it. A seek Playdeck was asked for is labelled with the origin its command carried, and a seek nobody asked for keeps the 'provider' the adapter stamps it with. A seek already under way keeps the origin it started with, so a patch that merely re-reports seeking never relabels it (#186).

A play that was refused

A refused play must not be silent. The caller receives a CommandResult and can act on it, but the party that presents a refusal is rarely the party that issued the command: a button hands the result nowhere, and the surface that would say something about it never sees one. A refusal that lived only in a returned promise would therefore leave a control that looked actionable, did nothing, and left no record anywhere that the viewer had asked. PlayerState.refusedPlay is that record: the PlayerEventOrigin the command carried, and the CommandFailureReason off the result unchanged, so a policy refusal is not read as a provider fault.

It states a condition rather than logging a moment. What it says is that the last play command issued against the media attached now was refused and nothing has played since, so it is cleared by the patch that confirms playback and by the provider changing, and by nothing else — a pause, a seek, a stall or an error does not make the refusal untrue. Commands settle out of order, and the condition holds through that: a refusal reported by a play that a later play replaced, or that playback was confirmed after, is never published at all, nor is one refused while playback is already 'playing', which would say nothing is playing while something is. The caller still receives its CommandResult unchanged in every one of those cases; it is this field that declines to state a thing that has stopped being true (#361).

The PlayerError such a result may also carry is deliberately absent here. The state has one error slot, and a refused play must not take it: isNotice and the bundled error surface present whatever is in that slot, and whether a refusal is worth covering the player with is a decision this library leaves to you. reason is the part to branch on, and the copy is yours to write.

refusedPlay sits beside autoplay rather than folded into it, and it replaces neither 'blocked' nor 'failed': autoplay reports the autoplay machine, and most of what it reports is progress through an attempt rather than any refusal at all, while this reports the command. An autoplay refused by policy therefore appears in both, and origin: 'autoplay' is what says which one it was. Ask this field about the refusal, and autoplay about autoplay.

A command refused before a provider attached

PlayerState.refusedCommand is the general half of the same idea: the PlayerCommand that was turned down, the origin it carried, and a reason that is the literal 'not-ready'. It answers "was anything I asked for refused before there was anything to ask it of" for every command in PlayerCommand, in one field rather than a slot per command, so nobody has to OR them together — the assembly refusedPlay exists to prevent. The window it describes is real for a consumer rather than a formality: a control that renders operable before a provider is attached will have its command refused, and without this field the caller is the only party that hears of it.

Its lifetime is the pre-attach window, and setProvider is the whole of its clearing rule. An attach withdraws the refusal in the same synchronous update that publishes activation: 'loading-provider', so no snapshot ever reports a provider in hand beside a refusal saying there was none; a swap and a detach clear it in the same place, because the state it was published into is being rebuilt either way — which means a detach ends the refusal without a provider having arrived. Nothing outside setProvider clears it: a later refusal replaces it, and a refusal nothing followed simply stands.

reason admits no other CommandFailureReason, because no other one has a clearing rule that would keep this a condition. unsupported is already published per command as PlayerCapabilities, and a blocked or a provider-error on a setVolume is a moment with no natural end.

origin is nullable here, and its null is not seekOrigin's. Only the commands with a *WithOrigin entry point can carry one; every other command shares one path with nothing to tag it with and carries null. Here that means the origin was never recorded — not that nobody asked.

retry is the one command a consumer can issue that this field never reports, and it is left out of PlayerCommand to keep it that way. It can be refused before a provider attaches and again when the provider changes underneath an attempt already in flight; publishing the first while the second stayed silent would make the field's absence mean two different things. Neither site publishes, so a refused retry is always read from its own CommandResult.

A play refused before a provider attaches fills this field and refusedPlay, deliberately, the way an autoplay refused by policy fills both refusedPlay and autoplay. The two do not end together: refusedPlay carries any reason and is cleared by confirmed playback, this one carries a single reason and is cleared by setProvider. Ask this field which command, and refusedPlay about the play (#484).

A URL prop the allowlist refused

RefusedUrlSurface names one consumer-supplied URL prop that the shared allowlist can refuse outside a provider — a poster, a text-track source, a Media Session artwork entry. The refusal itself is the quiet part: the value is dropped exactly as an absent prop would be, with no throw, no lifecycle change and nothing different on screen. PlayerController.reportRefusedUrl is what makes it audible, publishing a notice for as long as the refusal stands. Under @playdeck/react the primitives report their own surfaces, so a consumer composing them gets this without calling anything.

The union carries prop names and never the value that was refused, and the notice's message is built in core from the name alone. That is a deliberate narrowing rather than a shorthand: a refusal is reported from code holding a URL that an attacker may have chosen, a free-form parameter would invite passing it along "for context", and the destination is an error a monitoring system may log and ErrorDisplay may render. Naming the prop is also the more useful half — the prop is what an operator has to go and fix. A refused source does quote its value, and the difference is structural: a source is one prop holding one value, while a surface can be refused by several component instances at once, so no one value describes it.

reportRefusedUrl is a registration and not a setter. It returns a disposer, and the notice stands while any registration for any surface stands — which is what stops a sibling holding a permitted value for the same prop from withdrawing a refusal it never made, an ordinary situation the moment a responsive poster puts two images under one root. It is withdrawable at all because the notice states that a refusal stands right now rather than that one once happened: a consumer who replaced a poisoned CMS value with a good one must not keep the error forever, and an operator who cannot clear a security notice learns to ignore all of them.

What it publishes is a notice in isNotice's sense — non-fatal, configuration — and its severity is 'protective' whatever the surface decorates, because what fired is a security control and not a presentation option being ignored. So it never drives the lifecycle, it must not be rendered as a failure, and a provider reporting a cosmetic rejection cannot push it out of the state's one error slot. Where several surfaces stand at once, that slot takes the first of them in a fixed order held in core, never the order the reports arrived in: report order follows where a consumer placed its components and whether the pass was a mount or an update, and a notice whose wording changed for that reason would be unreadable to a monitoring system.

Autoplay

configureAutoplay(mode, options) takes an AutoplayMode, and the mode is a policy for one question: what should happen when the browser refuses an audible attempt?

AutoplayMode What it attempts
false Nothing, and PlayerState.autoplay stays 'idle'.
'muted' A muted attempt. Muting is part of the mode, so controlledMuted: false beside it is a configuration conflict rather than a case to resolve.
'audible' An audible attempt. A refusal is reported as it is, unretried.
'audible-then-muted' An audible attempt, and a muted retry of it where — and only where — the browser refused by policy (reason: 'blocked').

'muted' and 'audible' keep their strict meanings: neither ever changes what the other does. The retry belongs to 'audible-then-muted' alone, it is issued at most once, and it is issued only from a policy refusal — retrying a decode error or a provider fault muted would change nothing about why it failed (#306). Where the consumer controls muted and has set it to false, the recovery is suppressed rather than performed: an audible attempt under a controlled unmuted state is legitimate, and muting to recover would override a value the consumer owns, which this library never does. The attempt ends 'blocked' there, exactly as 'audible' would. Any mode may also go unattempted altogether, reporting autoplay: 'suppressed', where the viewer matches prefers-reduced-motion: reduce and AutoplayConfigurationOptions.ignoreReducedMotion was not set.

PlayerState.autoplayRecovered is how a consumer detects that the recovery is what played. It is true only where autoplay is 'started' because an audible attempt was refused by policy and the muted retry is what started playback, and false everywhere else — the in-flight retry included, since the recovery is recorded once playback has started and not when it is attempted. That is what tells a player muted because a consumer asked for a muted autoplay apart from one muted because the browser would have it no other way, and it is the state to offer an unmute affordance on: the viewer never chose this.

The controller derives autoplayRecovered and never takes it from a provider patch, for the reason refusedPlay is filled from the controller's own record: ProviderStatePatch is a Partial<PlayerState>, so the key is within every patch's reach, and a provider has no way to know an attempt before this one was refused. Deriving it here is what stops an adapter manufacturing a recovery that never happened, or erasing one that did.

Time boundary

createTimeBoundary() resolves a [startTime, endTime] window once and then answers every question a provider asks of it. The embed providers (YouTube, Vimeo, Wistia) have no trustworthy native end mechanism, so each one enforces the window from its own adapter — and this is what makes all three enforce it the same way.

import { createTimeBoundary } from '@playdeck/core';

// The `[startTime, endTime]` window a provider plays inside, sanitised once.
// A start that is absent, non-positive or non-finite is no start; an end that
// is absent, non-finite, or not above the start is no end.
const bounds = createTimeBoundary({ startTime: 30, endTime: 90 });

console.log(bounds.startTime, bounds.endTime); // 30 90 — the load hints

// Every question is asked against the duration, which caps the window: pass
// `null` or `undefined` before the media reports one.
export const startsAt = bounds.start(120); // 30 — where playback begins
export const endsAt = bounds.end(60); // 60 — the duration caps the end
export const reachedEnd = bounds.atEnd(120, 91); // true — publish `ended` here
export const seekTarget = bounds.clamp(120, 999); // 90 — seeks stay inside

// `clamp` answers for a position Playdeck was asked to go to; `correction`
// answers for one that simply arrived, which is what makes `startTime` a floor
// rather than a position applied once at load. Every answer is a `clamp` of the
// same time, so the two agree instead of correcting one position twice, and
// every answer is a fixed point, so the report a corrective seek produces asks
// for no correction of its own.
// It takes the port's own state, as `atWrap` does: nothing is corrected before
// the port has positioned the player.
const state = { loop: false, positioned: true };
export const pulledUp = bounds.correction(120, 5, state); // 30 — below floor
export const pulledBack = bounds.correction(120, 91, state); // 90 — past end
export const leftAlone = bounds.correction(120, 45, state); // undefined

// The two loop questions. A platform loop wraps to zero rather than to the
// start boundary, so a playhead behind the start of a positioned player is that
// wrap; and the platform's own end is only worth correcting when the window
// begins somewhere other than zero.
export const wrapped = bounds.atWrap(120, 5, { loop: true, positioned: true });
export const restarts = bounds.restartsAtStart(true); // true

// Which is why the floor defers rather than competes. The position `atWrap`
// just claimed is one `correction` declines, so a looping player is restarted
// and resumed by the loop rule instead of being slid onto the floor — and it
// declines on every path, not only the ones that ask the wrap guard first.
export const wrapsInstead = bounds.correction(120, 5, {
  loop: true,
  positioned: true
}); // undefined

// A nonsense window is dropped rather than reported: this plays the whole video.
export const unbounded = createTimeBoundary({ startTime: -1, endTime: 0 });

createTimeBoundary, and its TimeBoundary type, are also reachable at @playdeck/core/time-boundary — a second entry point carrying them and nothing else, built as its own bundle like @playdeck/core/thumbnails and @playdeck/core/media-session. The three embed providers (@playdeck/provider-vimeo, @playdeck/provider-wistia, @playdeck/provider-youtube) import createTimeBoundary from that subpath rather than from this package's main entry: each provider's own module reaches an app's build graph through a dynamic import() that app carries for every provider kind at once, so a bundler that places this package's main entry inside that app's eager chunk has to export whatever any sibling provider imports from it — createTimeBoundary included, even on a page composed with no embed provider at all. Importing it through its own subpath instead keeps it out of that eager chunk. @playdeck/core itself keeps exporting createTimeBoundary and TimeBoundary from its main entry, so a direct import that does not care changes nothing.

Live state

deriveLiveState is the one liveness derivation in the workspace, so PlayerState.live means the same thing whichever adapter published it. It reads provider signals and normalized state only — a duration, a seekable window, a playhead, and the provider's own live flag where it has one. A source URL, an id or a filename never decides: a name is a guess, and a guess published as state is a control that lies.

atEdgeThreshold is optional, and omitting it is how an adapter takes the shared tolerance. The constant itself is not exported, so no adapter carries a number of its own.

import { deriveLiveState, liveStateEqual } from '@playdeck/core';

// Liveness comes from what the provider reports — never from the URL, the id
// or a filename. `isLiveHint` is the provider's own answer where it has one;
// leave it undefined and an infinite duration decides instead.
export const live = deriveLiveState({
  isLiveHint: true,
  duration: Number.POSITIVE_INFINITY,
  seekable: [{ start: 120, end: 3600 }],
  currentTime: 3594
});

// -> { isLive: true, atLiveEdge: true, offsetFromEdge: 6 }. `null` means "not
// live, or not yet known" — a control should not claim either until it is.
export const atEdge = live?.atLiveEdge ?? false;

// Whole seconds, `0` at or ahead of the edge — the resolution `Time` renders
// at, and why `liveStateEqual` compares it: an unrounded float would differ
// on essentially every `timeupdate` and republish `live` many times a second.
export const behindBy = live?.offsetFromEdge ?? 0;

// Omitting `atEdgeThreshold` uses the shared tolerance every adapter uses.
// Pass one only to answer a different question than the players do.
const tight = deriveLiveState({
  isLiveHint: true,
  duration: Number.POSITIVE_INFINITY,
  seekable: [{ start: 120, end: 3600 }],
  currentTime: 3594,
  atEdgeThreshold: 2
});

// An adapter publishes `live` only when the value changes. This is that test.
export const changed = !liveStateEqual(live, tight);

Providers that cannot determine liveness leave live as null. That is not "this is on-demand" — it is "nobody has said", and a control should render neither claim until one arrives.

Live edge

capabilities.liveEdge says whether the active provider can report a live edge to seek to at all, told apart the same way capabilities.chapters is: unavailable with provider means the provider has no such surface for any source, and unavailable with source means this particular source is not live. PlayerController.seekToLiveEdge() is the command it gates: it refuses with not-ready — the same RefusedCommand shape every pre-attach refusal uses — whenever there is no provider, capabilities.liveEdge is not available, or the attached adapter implements no seekToLiveEdge at all. Where the provider can answer, it lands on the provider's own notion of the edge: @playdeck/provider-hls's hls.js engine seeks to hls.js's own liveSyncPosition, deliberately behind the raw seekable end, rather than a position this library would compute itself.

Chapters

PlayerState.chapters is the named divisions of the current video, ordered by startTime, and capabilities.chapters says whether the provider can report any at all — an empty collection means "none here", not "this provider cannot tell you". Playdeck publishes the vocabulary and draws none of it: a consumer maps a chapter to a position on the seek slider, which already takes children.

deriveChapters is the one derivation every adapter publishes through, so a chapter means the same thing whichever one reported it.

import { chaptersEqual, deriveChapters } from '@playdeck/core';

// A provider reports where a chapter begins and what it is called. Nothing
// reports where one ends, so `deriveChapters` is what decides: the list is
// ordered by `startTime`, and each chapter ends where the next one begins.
export const chapters = deriveChapters(
  [
    { id: 'ch2', title: 'The build', startTime: 132 },
    { id: 'ch1', title: 'Introduction', startTime: 0 }
  ],
  248
);

// -> 132. The last chapter takes the media duration, so this reads 248.
export const firstEnd = chapters[0]?.endTime;

// An unknown or endless duration leaves the last chapter open. `null`, never
// `Infinity`: an end nobody knows must not read as one somebody does.
export const openEnded = deriveChapters(
  [{ id: 'ch1', title: 'Live', startTime: 0 }],
  null
).at(-1)?.endTime;

// An adapter publishes `chapters` only when the collection changes — a
// duration report that moves nothing publishes nothing. This is that test.
export const closed = !chaptersEqual(
  chapters,
  deriveChapters([{ id: 'ch1', title: 'Introduction', startTime: 0 }], null)
);

Thumbnail preview

parseThumbnailCues reads the seek-preview flavour of WebVTT Vidstack, Media Chrome and Video.js all read the same way: each cue's payload is an image URL, optionally carrying a #xywh= (or #xywh=pixel:...) sprite-region fragment. A recognised fragment is resolved to a ThumbnailRegion in sprite pixels and stripped from the published url; an unrecognised one — including #xywh=percent:..., since nothing here knows the sprite's natural size to turn a percentage into pixels — is left on the URL, with region: null. A file that does not start WEBVTT, or a block this parser does not recognise as a cue, contributes no cue rather than throwing.

parseThumbnailCues takes a second argument, baseUrl: each cue's url is resolved against it the same way a browser resolves a relative URL found inside any other fetched document — against that document's own final address, not the page that requested it. This is what lets a sprite generator's ordinary relative image paths (sprite-0.jpg, or even //other-host/sprite-0.jpg) resolve against the thumbnails file itself rather than 404ing against the page. A caller that fetched the VTT file itself should pass the response's own final address, not the URL it requested the file with — those differ across a redirect, and it is the response's address a browser resolves a fetched document's own relative URLs against. Omit baseUrl and every cue's url publishes exactly as the file wrote it, relative forms included.

A cue's url is left unresolved rather than dropping the cue whenever resolution cannot answer for it cleanly: baseUrl was omitted; baseUrl is not itself a valid absolute URL; baseUrl uses a non-hierarchical scheme such as data: or blob:, which has no path a relative reference can resolve against; or the cue's own URL contains a raw tab, newline or other C0 control character. That last case exists because the URL parser removes such characters while resolving, so resolving first and checking the result after would let a cue's raw string smuggle a character the allowlist's own whitespace rule is designed to catch — parseThumbnailCues leaves that URL exactly as written instead, so the allowlist's verdict on it is unchanged.

thumbnailCueAt finds the cue covering a given time, matched half-open — [startTime, endTime) — so a boundary belongs to the cue it starts, not the one it ends. Playdeck publishes the parser and draws none of it itself: @playdeck/react's SeekSlider is the one consumer, loading and parsing a thumbnails URL lazily and rendering the active cue's region as its thumbnail part.

Both functions, and both types, are also reachable at @playdeck/core/thumbnails — a second entry point carrying the parser and nothing else. Import them from there when the code that uses them is loaded on demand: a bundler decides which chunk a module belongs in from which entry points reach it, so a parser that shares a module with the rest of core is emitted wherever the rest of core is. SeekSlider imports it from that subpath for exactly that reason, which is what lets a control bar that sets no thumbnails prop download none of the preview. @playdeck/core itself keeps exporting all four, so an import that does not care changes nothing.

import { parseThumbnailCues, thumbnailCueAt } from '@playdeck/core';

// The seek-preview flavour of WebVTT Vidstack, Media Chrome and Video.js all
// read: each cue's payload is an image URL, optionally carrying a `#xywh=`
// sprite-region fragment.
const vtt = [
  'WEBVTT',
  '',
  '00:00:00.000 --> 00:00:05.000',
  'sprite.jpg#xywh=0,0,160,90',
  '',
  '00:00:05.000 --> 00:00:10.000',
  'sprite.jpg#xywh=160,0,160,90'
].join('\n');

export const cues = parseThumbnailCues(vtt);

// -> { x: 0, y: 0, width: 160, height: 90 } — the fragment resolved to sprite
// pixels, not left as a CSS background-position, and stripped from `url`.
export const firstRegion = cues[0]?.region;

// Cues are matched half-open, `[startTime, endTime)`: 3 lands on the first
// tile, and 5 — the boundary between the two — already lands on the second.
export const atThree = thumbnailCueAt(cues, 3);
export const atFive = thumbnailCueAt(cues, 5);

// A time past every cue matches nothing.
export const none = thumbnailCueAt(cues, 99); // null

Text track selection

PlayerState.textTracks is what the provider found; selectedTextTrackId is which of them is on. A captions menu needs both, the way a quality menu needs qualities and selectedQualityId: the collection fills the rows and the selection checks one of them. capabilities.selectTextTrack answers whether a selection can be made at all, and it is the one to read first — while it is unknown a menu renders nothing rather than something disabled.

null there is a selection and not the absence of one: it is captions off, which is what selectTextTrack(null) asks for, and it is the value a snapshot starts from. Core publishes what the provider reports and derives nothing of its own, so the field moves when a selection is made, when a provider's own caption UI moves it, and when the track it named stops existing — the native and hls.js caption subsystems hold a chosen selection while its track survives a re-discovery and drop it to null when it does not, so a rediscovered track list cannot put a default track back over a viewer who turned captions off. Attaching, swapping or detaching a provider rebuilds the snapshot and resets this with everything else.

Which track is selected and who draws its cues are separate questions: captionRendering answers the second, and where a provider paints its own captions there is nothing for a consumer to draw.

Media dimensions

MediaDimensions is the media's own pixel size, not the box it is drawn into. An adapter that can report one implements subscribeDimensions, and the controller re-publishes what it sends through a subscribeDimensions of its own.

Neither is part of PlayerState, so a size arriving does not re-render every state consumer — a video that reports its intrinsic size on load would otherwise wake code that cares only about playback. undefined is how "not known" is said, and it is also how a size reported earlier is withdrawn.

An adapter with no size to report leaves the method off altogether, which is why it is optional on ProviderAdapter: an embed that never exposes the media element has nothing to measure, and saying nothing is more honest than publishing the iframe's box as though it were the media's.

Notifying subscribers

notifySafely() is how a provider adapter notifies one of its own listeners. An adapter's subscribe accepts any number of subscribers and promises each of them every notification, so no single listener may abandon an emit — and a listener that throws must not be reported as a provider failure (#233).

import {
  notifySafely,
  type ProviderEvent,
  type ProviderStatePatch,
  type ProviderStateListener
} from '@playdeck/core';

// What a provider adapter owes the subscribers it fans out to. `Set.forEach`
// stops at the first throw, so one broken listener would abandon the emit:
// every listener registered behind it misses that notification, and the throw
// escapes back into whatever called the emit — often a vendor SDK's own event
// dispatch, or the adapter's start path, where it would be reported as a
// provider load failure rather than as the consumer's bug it is.
const listeners = new Set<ProviderStateListener>();

export const subscribe = (listener: ProviderStateListener): (() => void) => {
  listeners.add(listener);
  return () => {
    listeners.delete(listener);
  };
};

// Isolated, not silenced: a listener that throws has its error rethrown on a
// fresh task, so it still reaches the page's uncaught-error handling the way a
// listener throwing at top level would.
export const emit = (
  patch: ProviderStatePatch,
  event?: ProviderEvent
): void => {
  listeners.forEach((listener) => notifySafely(listener, patch, event));
};

subscribe(() => {
  throw new Error('a subscriber defect');
});
const seen: string[] = [];
subscribe((patch) => {
  seen.push(patch.lifecycle ?? 'unchanged');
});

emit({ lifecycle: 'ready' });

console.log(seen); // ['ready'] — the subscriber behind the thrower still ran

Media Session

One coordinator per MediaSession, so several players on a page arbitrate lock-screen ownership instead of overwriting each other. bindMediaSession publishes a controller's confirmed playback to it and routes the lock screen's actions back.

import {
  PlayerController,
  bindMediaSession,
  getMediaSessionCoordinator
} from '@playdeck/core';

declare const controller: PlayerController;

// One coordinator per MediaSession. Two players on the same page arbitrate
// lock-screen ownership through it instead of overwriting each other.
const coordinator = getMediaSessionCoordinator(navigator.mediaSession);

// Binds this controller's *confirmed* playback to the coordinator, and routes
// the lock screen's play/pause/seek actions back to it.
const binding = bindMediaSession(controller, coordinator, {
  metadata: { title: 'Big Buck Bunny', artist: 'Blender Foundation' }
});

export const rename = (): void => binding.setMetadata({ title: 'Sintel' });

// Release when the player unmounts: ownership passes to whichever player is
// still playing.
export const release = (): void => binding.release();

Both functions, and both types, are also reachable at @playdeck/core/media-session — a second entry point carrying them and nothing else, built as its own bundle like @playdeck/core/thumbnails. Import them from there when the code that uses them is loaded on demand: @playdeck/react's Root reaches them through a dynamic import() inside the effect that binds the session, which never runs on the server or during render, and that is what keeps this module out of a page's eager chunk. @playdeck/core itself keeps exporting both from its main entry, so a direct import that does not care changes nothing.

License

MIT.