Skip to content

Core: Lifecycle & Setup

setup() is the one strict step every player library composed from the kit shares: call it once, and a fixed pipeline runs before anything else can happen.

The pipeline

setup(config) snapshots your config, wires every cross-cutting policy (visibility, network, wake lock, metrics, preload and transition orchestration), then runs an ordered async sequence: setupStartconfigResolvedpluginsRegisteringpluginsRegisteredstreamsReadyauthReadyplaylistResolving (only when the playlist is a URL) → playlistReadymediaReadyready. Each stage emits its own name on success or a matching <stage>Error event on failure, and a failure short-circuits the rest of the pipeline and rejects ready() — except playlist resolution, which never throws: a fetch or parse failure emits playlistError and the pipeline still proceeds to mediaReady and ready with an empty queue.

ready() returns a promise that resolves once the pipeline reaches ready, or rejects if a stage failed or dispose() ran first. It’s memoised, so calling it from multiple places in your player library returns the same promise rather than re-running anything.

Call setup() a second time on the same instance and it throws core:lifecycle/already-setup — that guard checks first and stays tripped even across a dispose() in between, so reuse after teardown needs a fresh instance, not a second setup() call. The narrower core:player/disposed guard only fires when setup() runs for the first time on an instance that was disposed before setup() ever ran once. There is exactly one way in.

The phase machine

Underneath the pipeline sits a closed set of phases: idlesetupreadyloading / starting / playing / paused / buffering / seeking / ended / stoppeddisposingdisposed. phase() reads the current one; the phase event fires { from, to } on every transition, so a UI layer or a plugin can drive an overlay off phase changes alone instead of piecing state together from a dozen separate flags.

Two guard methods sit on top of the phase machine, and every transport method (play, pause, stop, next, previous, and the rest) calls the first one before doing anything else:

  • _assertReady(): throws core:player/not-ready before setup() has run, or core:player/disposed after dispose().
  • setupState(): a coarser read for UI gating, one of SetupState.NOT_SETUP, SETTING_UP, READY, DISPOSED.

Composing a player-library author sees

Building a player library on the kit means your constructor calls initPlayerCoreState() and your prototype gets lifecycleMethods (among the rest of playerCoreMethods) stamped onto it via composeMixins. From that point, setup() / ready() / dispose() and the phase machine work exactly as described above, whether or not a media backend is attached yet. The example below composes the same minimal player from Quickstart, with no backend at all, and drives the whole lifecycle to prove it’s kit behavior, not something a backend has to supply.

TypeScript
/**
* The setup -> ready -> dispose lifecycle, straight from the kit.
*
* `setup()` runs a fixed pipeline (`setupStart` -> `configResolved` ->
* `pluginsRegistering` -> `pluginsRegistered` -> `streamsReady` -> `authReady`
* -> `playlistReady` -> `mediaReady` -> `ready`; `playlistResolving` also
* fires in between when the playlist is a URL) and drives the phase machine
* (`idle` -> `setup` -> `ready` -> ... -> `disposed`). `ready()` resolves once
* that pipeline settles; `dispose()` tears every policy subscription down and
* moves the player to `disposed`. `MinimalPlayer` has no media backend, so it
* reaches `ready` without ever loading or playing anything — proof that setup
* and lifecycle are pure kit behavior, not something a backend drives.
*/

import { minimalPlayer } from './minimal-player';

const player = minimalPlayer('lifecycle-demo');

// `phase` fires on every transition: idle -> setup -> ready -> ... -> disposed.
player.on('phase', ({ from, to }) => {
console.log(`phase: ${from} -> ${to}`);
});

// Pipeline stage events fire in order as setup() resolves each of them.
player.on('pluginsRegistering', () => console.log('registering plugins'));
player.on('mediaReady', () => console.log('media ready'));

player.setup({ logLevel: 'info' });

await player.ready();
console.log(player.phase()); // 'ready'

await player.dispose();
console.log(player.phase()); // 'disposed'

// A second setup() call throws instead of leaving the instance half-configured.
// The already-setup guard checks first and stays tripped across dispose(), so
// this is core:lifecycle/already-setup, not core:player/disposed.
try {
player.setup({});
} catch (err) {
console.log((err as Error).message); // 'core:lifecycle/already-setup: setup() called twice. Re-setup requires dispose() first.'
}

Disposal

dispose() is idempotent: a second call is a no-op. It dispatches beforeDispose first, and a listener can call preventDefault() to keep the player alive (disposePrevented fires instead). Otherwise it drains every cleanup callback the setup() policy wiring registered, in order, before emitting dispose and moving to disposed, so a handler observing the transition sees a consistent final state rather than a half-torn-down one.

Next steps

  • The Event Bus: the typed pub/sub primitive phase and every other event rides on.