npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@playdeck/core

v1.0.0

Published

Framework-neutral player state, commands, events and provider contract for Playdeck.

Readme

@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.

Use it directly if you are wiring a player into something other than React, or writing a provider adapter. If you are building UI in React, use @playdeck/react, which owns a controller for you.

pnpm add @playdeck/core

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. | | 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 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. |

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.

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,
  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'

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 });

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 }. `null` means "not live, or not yet
// known" — a control should not claim either until it is.
export const atEdge = live?.atLiveEdge ?? false;

// 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.

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)
);

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();

License

MIT.