Files
v10/internal/design/spf/features/engine-adapter-integration.md
T

12 KiB

status, date, definition
status date definition
implemented 2026-05-20 sketched

Engine-adapter integration

The engine's external-driving contract: shareSignals exposes the composition's writable + readonly signal refs to a consumer callback at setup time, and SimpleHlsMediaMixin is the canonical adapter that maps a WHATWG HTMLMediaElement-shaped API onto those refs. The audience for this feature is adapter authors and contributors who need to drive the engine from outside — not end users, who see the adapter's API only through whatever wraps it (e.g., packages/core's SimpleHlsMedia class).

The feature ships as a pair: the framework-level shareSignals mechanism + the canonical mixin. New adapter shapes (React hooks, RN bridges, etc.) would compose on top of shareSignals independently of the mixin.

Status

  • Composition: createSimpleHlsEngine (HLS VoD); shareSignals composed last so other behaviors' setups have run by the time the callback fires
  • Definition depth: sketched — capability surface and the adapter-rationale open question both documented

Phases of complexity

Phase What Notes
Writable signal refs via onSignalsReady shareSignals captures Signal<T> / ReadonlySignal<T> refs into a consumer-supplied callback at setup time. Generic over composition shape (makeShareSignals<S, C>()) Per-slot read/write intent is expressed at the use site (callers type captured refs as Signal<T> or ReadonlySignal<T>). Composed last in the engine so initial state writes are visible to the consumer
Mixin adapter pattern SimpleHlsMediaMixin is the canonical consumer: function-of-base-class structure (mix into any base), captures refs once in onSignalsReady, exposes a WHATWG HTMLMediaElement-shaped API mapping each setter/method to engine writes Downstream use: class SimpleHlsMedia extends SimpleHlsMediaMixin(HTMLVideoElementHost) {} in packages/core/src/dom/media/simple-hls/
Media element binding attach(el) writes context.mediaElement; detach() clears it. Engine persists across attach/detach cycles — only src reassignment or explicit destroy() tears it down Re-attach to a different element is supported. The engine is the durable state holder; mediaElement is a context slot
Source assignment via destroy + recreate Adapter's set src destroys the current engine and creates a fresh one, re-applies any explicit preload, re-attaches mediaElement to the new engine, and writes the new { url } Bypasses the in-place source-replacement path. Rationale not documented in code — see Open questions and source-replacement.md
Preload reflection set preload(value) writes W3C values to state.preload; clearing (preload = '') doesn't patch the current engine but is re-applied on the next src change. Pre-attach src + preload combinations are supported Extended preload values flow through state but don't reach the DOM (per preload-modes's sticky-extended-values semantics)
Programmatic play() with retry play() writes state.loadActivated = true (co-writer with trackLoadTriggers's DOM listener path) before invoking native play. Defensive retry: if native play rejects with "no supported sources" while src is pending, wait for loadstart (MSE attaches blob URL) and retry once The retry handles MSE pipeline timing — adapter doesn't know exactly when MSE setup attaches the blob URL. Listener canceled on src change

What's not implemented

  • Reactive change-notification surfaceonSignalsReady fires once at setup. Consumers wanting to react to state changes from outside the engine must keep refs and subscribe via SPF primitives (effect(), signal subscribe()). The adapter doesn't expose curated onPlay / onSrcChange / onError callbacks.
  • Multiple engine instances per adapter — one engine per adapter instance. No built-in pattern for multi-engine scenarios (picture-in-picture with two streams, A/B testing).
  • Non-HTMLMediaElement adapter shapes — React-friendly hooks, React Native bridges, etc. would compose on top of shareSignals independently. Today the canonical adapter is HTMLMediaElement- shaped via the mixin. No bracketed candidate features tracked yet; add when concrete need surfaces.
  • Curated state / error introspection — consumers can read signals.state.*.get() directly, but there's no adapter-level "current playback state" / "current error" shape that doesn't require knowing the engine's signal map.

Implementation surface

Composition: packages/spf/src/playback/engines/hls/engine.tsshareSignals is the last behavior in the composition. Instantiated once at module load:

const shareSignals = makeShareSignals<SimpleHlsEngineState, SimpleHlsEngineContext>();

// ...

return createComposition(
  [
    // ... all other behaviors ...
    shareSignals,
  ],
  { config, initialState }
);

Behavior factory:

Export File Role
makeShareSignals<S, C>() packages/spf/src/core/composition/share-signals.ts Generic behavior factory. Returns a Behavior<StateSignals<S>, ContextSignals<C>, ShareSignalsConfig<S, C>> whose setup invokes config.onSignalsReady?.({ state, context })
ShareSignalsConfig<S, C> same Config interface carrying the onSignalsReady callback

Canonical adapter:

Export File Role
SimpleHlsMediaMixin<Base> packages/spf/src/playback/engines/hls/adapter.ts Function-of-base-class mixin. Captures refs in onSignalsReady, exposes WHATWG HTMLMediaElement-shaped API
SimpleHlsMediaElement same Standalone subclass: SimpleHlsMediaMixin(class {}). Bare-bones reference instance
SimpleHlsMediaProps / SimpleHlsMediaAPI same The adapter's public-facing shape

Adapter ↔ engine state/context map:

Adapter call Engine write
attach(el) context.mediaElement.set(el)
detach() context.mediaElement.set(undefined)
destroy() engine.destroy()
set src(value) engine.destroy() → new engine → state.presentation.set({ url: value })
set preload(value) state.preload.set(value) (W3C values only; pre-empties stay engine-local)
play() state.loadActivated.set(true) → native play() with loadstart retry on "no supported sources"

Downstream consumer: packages/core/src/dom/media/simple-hls/index.ts:

export class SimpleHlsMedia extends SimpleHlsMediaMixin(HTMLVideoElementHost) {}

This is the canonical end consumer — used wherever the HTML player expects an HTMLMediaElement-shaped object backed by SPF.

Config surface

// ShareSignalsConfig<S, C>
{
  onSignalsReady?: (signals: {
    state: StateSignals<S>;
    context: ContextSignals<C>;
  }) => void;
}

The HLS engine config (SimpleHlsEngineConfig) extends ShareSignalsConfig<SimpleHlsEngineState, SimpleHlsEngineContext>, so onSignalsReady is part of the engine's config surface.

SimpleHlsMediaMixin's constructor takes optional config and threads it through to every engine instance (including the ones created on each set src).

Verification

  • Unit tests:
    • packages/spf/src/playback/engines/hls/tests/adapter.test.ts — extensive coverage: src assignment / re-assignment / clear, engine recreation on src change, mediaElement preservation across src changes, play retry on loadstart, preload propagation, attach/detach lifecycle
    • packages/spf/src/playback/engines/hls/tests/engine.test.ts → "allows patching state and owners from outside" — direct engine-level write surface (bypasses the mixin)
    • packages/spf/src/core/composition/tests/share-signals.test.ts — the behavior itself
  • Downstream usage:
    • packages/core/src/dom/media/simple-hls/index.tsSimpleHlsMedia consumer
  • Walkthrough:
    • packages/spf/docs/hls-engine.md Stage 10 — high-level coverage of the pattern

Open questions

  • Destroy-recreate vs in-place source replacement. The canonical adapter destroys + recreates the engine on every src change, even though the engine's behaviors support in-place state.presentation overwrite (validated by source-replacement's test). The rationale isn't documented in the code or commit history. Possible motivations: stricter isolation between sources; simpler reasoning per-engine; guarding against latent cleanup-cascade bugs. Worth resolving when the cost of either choice surfaces.
  • Callback timing semantics. shareSignals's JSDoc explicitly notes the callback fires while other behaviors are still in setup; reads inside the callback may yield only initial-seed values. The documented use is "capture refs, use later." Is read-at-setup-time ever a supported case, or always discouraged?
  • Mixin base-class genericity. SimpleHlsMediaMixin<Base extends Constructor<any>> accepts any base; today's only documented consumer is HTMLVideoElementHost. Other bases are structurally allowed but not exercised — if usage broadens, the contract may need tightening.
  • preload-modes — adapter set preload(value) and play() are external writers on state.preload and state.loadActivated respectively. The adapter's preload-clearing semantics (clear #preload but don't patch the current engine) interact with preload-modes's sticky-extended-values rule.
  • source-replacement — adapter set src is the canonical user-facing entry into source replacement. The adapter's destroy-recreate path bypasses the in-place reactor cascade — see source-replacement.md for the in-place contract and the same open question.
  • mse-mms-pipeline — adapter attach(el) binds the element MS attaches to. The engine handles the rest of the MS lifecycle via the resolved/unresolved cascade.
  • audio-playback / subtitles / video-abr / buffer-management — all driven by engine state the adapter writes through. The adapter doesn't expose these features' surfaces directly; consumers read engine state via the captured signal refs.

Use cases that compose this feature

  • audio-only-mode-override (partial — Phase 1 landed) — Phase 1 baseline constituent with an alternative adapter shape. The variant ships an independent SimpleHlsAudioOnlyMediaElement adapter (via SimpleHlsAudioOnlyMediaMixin) parallel to SimpleHlsMediaElement; the shareSignals mechanism + mixin pattern compose unchanged. The consumer-facing API matches the WHATWG HTMLMediaElement surface.
  • video-only-mode-override (coarse) — Phase 1 baseline constituent on the inverse axis. Ships an independent SimpleVideoOnlyHlsMediaElement-style adapter parallel to SimpleHlsMediaElement. Same shareSignals pattern; consumer-facing API differs from both default and audio-only-mode-override.

See also