13 KiB
status, date, definition
| status | date | definition |
|---|---|---|
| implemented | 2026-05-20 | sketched |
Source replacement
The engine's source-change capability: load a new source on an
already-attached engine without recreating it. Driven by overwriting
state.presentation with a new unresolved {url}; resolvePresentation
routes the FSM back through 'resolving' and downstream behaviors tear
down their per-source state via reactor state-exit. The cascade is
load-bearing — every behavior that gates on isResolvedPresentation
must honor the state-exit cleanup contract, or in-place replacement
breaks silently.
The canonical adapter (SimpleHlsMediaMixin.src) takes a different
path: destroy the engine and create a fresh one on every assignment.
Both paths work; the in-place path is the load-bearing one for
engine-internal reasoning, since the adapter's destroy path bypasses
the cascade entirely.
This doc captures the capability surface, the cleanup contract new behaviors must honor, and the verification that pins the in-place path against regression.
Status
- Composition:
createSimpleHlsEngine(HLS VoD) - Definition depth: sketched — capability surface, cleanup contract, and validation test all in place
- Cleanup contract (load-bearing): every behavior that gates on
isResolvedPresentationMUST tear down cleanly via reactor state-exit when presentation un-resolves. Setup behaviors detach DOM resources / destroy actors / clear context slots; async-fetch behaviors bindAbortControllerto state-exit. New behaviors that join the engine must follow this pattern or the in-place source-replacement validation test (engine.test.ts→ "cleanly replaces source in place via state.presentation overwrite") will fail.
Phases of complexity
Capability slices around the source-change contract. Each phase is a distinct engine behavior observable from outside.
| Phase | What | Notes |
|---|---|---|
| Initial source load | First source on a fresh engine: external write of state.presentation = { url } triggers resolve + full pipeline setup |
The unresolved → resolved transition that bootstraps everything |
| In-place source replacement | Overwrite state.presentation with a new { url } while a previous source is resolved / playing. resolvePresentation routes back through 'resolving'; downstream behaviors tear down via reactor state-exit; new source resolves and plays — same engine instance |
Validated end-to-end. MediaSource + buffer actors are fresh instances; in-flight fetches aborted via state-bound AbortControllers |
| Source unset | Set state.presentation to undefined. All presentation-gated behaviors transition to 'preconditions-unmet' and tear down. Engine is fresh-but-attached, ready for the next source |
The "no source" steady state; reachable from any resolved state |
| Destroy + recreate (canonical adapter path) | SimpleHlsMediaMixin.src destroys the current engine and creates a fresh one on every assignment. Re-attaches the media element to the new engine |
Canonical consumer-side mechanism. Bypasses the in-place cascade entirely. Tested via adapter.test.ts:115–150 |
| Per-source-identity slot lifecycle | loadActivated resets to false when source identity changes (URL or mediaElement); selected*TrackIds clear naturally on un-resolve (their pickers re-run against the new presentation); bandwidthState is intentionally preserved across source resets — sampling accumulates via the once-per-behavior createTrackedFetch |
ABR resume: bandwidth estimate carries over so the first segment of a new source picks an appropriate quality based on observed throughput |
What's not implemented
- Cross-codec source replacement — same-codec sources work (the
segment-loader's time-aligned dedup handles it); cross-codec
replacement would need
changeType()handling. Folds into the[buffer-flushing]and[5.1-surround-selection]/[hevc-variant-selection]candidates. - Source-error recovery state —
resolvePresentationcurrently surfaces fetch / parse failures viaconsole.error(with a TODO in the code: "route to a state-error slot once one exists"). Consumers have no observable signal for "source failed to load." - Per-source
bandwidthStatereset opt-in — preservation is the baked-in policy (ABR resume). No opt-out exists for testing or fresh-session scenarios that want a clean estimator. - Concurrent pre-fetch / hand-off — replacement is teardown-then- rebuild; the engine doesn't support pre-warming the next source while the current one plays. Playlist / queue semantics are out of scope.
Implementation surface
Composition: packages/spf/src/playback/engines/hls/engine.ts — the
source-replacement capability isn't owned by any single behavior; it
emerges from resolvePresentation's 4-state FSM + the cleanup contract
every presentation-gated behavior honors.
Orchestrator: resolvePresentation
(packages/spf/src/playback/behaviors/resolve-presentation.ts)
| State | What |
|---|---|
'preconditions-unmet' |
No presentation, or presentation has no URL |
'idle' |
URL present, unresolved, preload gate unmet — waits for loadActivated or non-blocking preload |
'resolving' |
URL present, unresolved, gate met. Entry starts fetch + binds AbortController to state-exit |
'resolved' |
state.presentation holds a resolved Presentation |
State transitions on state.presentation writes are the source-
replacement mechanism: overwriting a resolved presentation with a new
unresolved { url } routes the FSM 'resolved' → 'resolving' (assuming
gate open), aborting any prior in-flight fetch and starting fresh.
Cleanup contract — behaviors that ride the resolved/unresolved cascade:
| Behavior | What it tears down on state-exit |
|---|---|
setupMediaSource |
Aborts in-flight waitForMediaSourceOpen; detaches MediaSource from element; clears context.mediaSource |
setupVideoBufferActors / setupAudioBufferActors |
Destroys SegmentLoaderActor then SourceBufferActor (reverse order); clears context.{video,audio}{BufferActor,SegmentLoaderActor} |
setupTextTrackActors |
Destroys text-track actors; clears context slots |
updateMediaSourceDuration |
Aborts in-flight waitForMediaSourceOpen + waitForSourceBuffersReady |
endOfStream |
Aborts in-flight wait |
resolveVideoTrack / resolveAudioTrack / resolveTextTrack |
Aborts in-flight playlist fetch |
resolvePresentation |
Aborts in-flight manifest fetch via state-exit on the 'resolving' entry's AbortController return |
Per-source-identity-aware behaviors (transition independently on source change, not via the resolved/unresolved cascade):
| Behavior | Per-source reset |
|---|---|
trackLoadTriggers |
loadActivated reset to false on (mediaElement, presentation.url) identity change |
selectAudioTrack / selectTextTrack / switchVideoQuality |
Re-run pickers when presentation transitions to resolved with new content |
Cross-source preservation:
| Slot | Why preserved |
|---|---|
state.bandwidthState |
ABR resume — createTrackedFetch in setupVideoBufferActors is constructed once at behavior setup and accumulates EWMA across source resets |
state.preload |
Engine-wide preference, not source-specific |
state.currentTime |
DOM-side mirror via trackCurrentTime; resets on new presentation naturally (element currentTime resets on src change) |
Multi-writer state.presentation pipeline. Three writer domains,
non-overlapping aspects:
- Adapter / external — writes initial unresolved
{ url }(and overwrites with new{ url }for in-place replacement) resolvePresentation— writes resolvedPresentationwithid,selectionSets, etc.- Per-track resolvers +
calculatePresentationDuration—resolveVideoTrack/resolveAudioTrack/resolveTextTrackpatch resolved segment lists into their respective tracks;calculatePresentationDurationpatches induration. Each reads current value and writes a new one with their field added; none overwrites a field another owns.
Config surface
No engine-level config for source replacement itself. The
config.parsePresentation required by resolvePresentation is the
format-neutral parser hook (e.g., the HLS engine supplies
parseMultivariantPlaylist); it doesn't affect source-replacement
semantics — every replaced source runs through the same parser.
Verification
- Unit tests:
packages/spf/src/playback/engines/hls/tests/engine.test.ts→"cleanly replaces source in place via state.presentation overwrite"— end-to-end validation: mock fetch with two sources A and B, resolve source A through the full pipeline, capture identities ofmediaSource/videoBufferActor/audioBufferActor, overwritestate.presentationwith source B, verify B resolves and the captured identities differ from the new ones (proving teardown cascade ran)packages/spf/src/playback/engines/hls/tests/adapter.test.ts→"creates a new engine when src is set"/"destroys the old engine when src changes"/"re-attaches the media element to the new engine when src changes"— validates the canonical adapter destroy-recreate pathpackages/spf/src/playback/behaviors/dom/tests/track-load-triggers.test.ts—loadActivatedper-source-identity reset coverage
- Sandbox:
apps/sandbox/src/spf-segment-loading/— exercises initial source load + manual rendition switching (in-track, not source change)apps/sandbox/src/simple-hls-html//simple-hls-react/— adapter integration; src reassignment hits the destroy-recreate path
Open questions
- Error recovery surface.
resolvePresentationcurrently usesconsole.errorfor fetch / parse failures and has aTODO(error- management)for a state-error slot. The shape of this slot — single error vs per-source — affects how consumers respond to "source failed to load." - Adapter rationale. The canonical adapter destroys + recreates the engine on every src change instead of using in-place replacement. The reasoning (stricter isolation? simpler reasoning? guarding against cleanup-cascade bugs?) isn't documented in the code or commit history. If the in-place path is the engine's load-bearing capability for internal reasoning, why doesn't the adapter use it?
- Per-source
bandwidthStatereset opt-in. Preserving across sources is the right default for ABR resume, but a test / fresh- session escape hatch may earn its place when consumers start needing it.
Related features
- preload-modes — gates
resolvePresentation's'idle'→'resolving'transition; per-sourceloadActivatedreset (intrackLoadTriggers) is part of the source-identity-aware behavior surface this feature relies on. - mse-mms-pipeline —
setupMediaSourceis the canonical example of riding the resolver's resolved/unresolved lifecycle for cleanup; the feature doc calls this "structural" routing out explicitly. - buffer-management — segment-loader actors tear down via the same
cascade; in-flight fetches aborted; bandwidth sampling preserved
across sources via
createTrackedFetchconstructed once. - video-abr —
bandwidthStatepreservation policy lives here. Observable behavior: ABR resumes across source changes rather than re-bootstrapping frominitialBandwidth. - subtitles — text-track actors and selection clear on source un-resolve via the cleanup cascade.
- engine-adapter-integration (not yet documented, candidate) —
SimpleHlsMediaMixin's destroy-recreate path lives here. The adapter's choice to bypass in-place replacement is the feature-design decision to capture in that doc.
See also
- presentation-modeling.md —
architectural deep-dive on the format-neutral data shape, parser
interface, and per-track resolution layer. The multi-writer
state.presentationpipeline this feature characterizes is anchored in that doc's data model - clusters.md § Engine lifecycle —
cross-cutting concerns around engine instantiation, source loading,
and per-source identity resets (this feature +
preload-modes) - packages/spf/docs/hls-engine.md
— engine composition walkthrough (Stage 1 covers
resolvePresentation) - conventions/behaviors.md — reactor state-exit cleanup conventions
- conventions/signals.md — multi-writer
slot conventions (
state.presentationis the canonical pipeline- pattern example)