docs(design): add SPF living design docs (#899)

This commit is contained in:
Christian Pillsbury
2026-03-16 12:21:31 -07:00
committed by GitHub
parent 5bdbbec8a0
commit c782a0f0a1
4 changed files with 943 additions and 0 deletions
+253
View File
@@ -0,0 +1,253 @@
---
status: draft
date: 2026-03-11
---
# Architecture
> **This document describes the current SPF codebase as a snapshot in time — not the target design.** The architecture, implementation details, and component boundaries documented here are highly tentative and subject to significant change. The initial implementation captured useful lessons (source buffer coordination, end-of-stream timing, streaming response bodies, etc.), but the underlying architecture, primitives, and structure are expected to be substantially reworked in the near term. See [primitives.md](primitives.md) for the forward-looking design.
Internal structure of SPF.
## Overview
```
┌─────────────────────────────────────────────────────────┐
│ core/ (DOM-free) │
│ state ─ actor ─ task ─ HLS parser ─ ABR ─ buffer math │
└─────────────────────────────────────────────────────────┘
┌──────────────────────────▼──────────────────────────────┐
│ dom/ (browser) │
│ │
│ PlaybackEngine │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Reactors (features/) │ │
│ │ loadSegments · endOfStream · setupMediaSource │ │
│ │ setupSourceBuffers · qualitySwitching · ... │ │
│ └─────────────────┬────────────────────────────────┘ │
│ │ send messages │
│ ┌─────────────────▼──────────────┐ │
│ │ Actors │ │
│ │ SegmentLoaderActor │ │
│ │ SourceBufferActor (×2) │ │
│ └─────────────────┬──────────────┘ │
│ │ execute │
│ ┌─────────────────▼──────────────┐ │
│ │ MSE │ │
│ │ MediaSource · SourceBuffer │ │
│ └────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
The `core/` layer is runtime-agnostic — no DOM APIs, no fetch. The `dom/` layer wires browser platform APIs (MSE, fetch, HTMLMediaElement) into the core abstractions.
---
## Core Layer
### Reactive State (`core/state/create-state.ts`)
A batched, subscription-based state container. All feature coordination flows through state.
```ts
interface State<S> {
get(): S;
patch(partial: Partial<S>): void;
flush(): void;
subscribe(listener: Listener<S>): () => void;
}
```
**Key behavior:**
- `patch()` defers via `queueMicrotask` — multiple synchronous patches are coalesced into one notification.
- `flush()` drains the pending patch immediately. Call when downstream subscribers need to react before the next tick (e.g., ABR sampling).
- Selector subscriptions fire only when the selected slice changes, using a custom equality function.
### Actor (`core/actor.ts` + `core/task.ts`)
An actor owns a `snapshot` (status + context) and serializes its own work via a runner.
```ts
interface Actor<Context> {
snapshot: ActorSnapshot<Context>;
subscribe(listener: () => void): () => void;
}
```
**Task** — wraps an async function with an `AbortController`. Abortable at any point.
**SerialRunner** — executes tasks one at a time. Used by SourceBufferActor because the SourceBuffer API is inherently serial (one `appendBuffer` at a time).
**ConcurrentRunner** — deduplicates by ID. Used where parallel work is safe but duplicate tasks are wasteful.
### HLS Parsing (`core/hls/`)
Parses multivariant and media playlists into typed structures (`Presentation`, `Track`, `Segment`). URL resolution is handled separately in `resolve-url.ts`, making the parsers pure functions of text input.
### ABR (`core/abr/`)
Two components:
**EWMA** — fast/slow exponentially weighted moving average pair. The fast weight tracks recent conditions; the slow weight anchors against outliers. Exported estimate is the minimum of both (conservative).
**Quality selection** (`quality-selection.ts`) — given a bandwidth estimate and a list of tracks sorted by bitrate, picks the highest track whose bitrate fits within the estimate. Upgrades are subject to a `minUpgradeInterval` gate (default 8 s) to prevent oscillation; downgrades are immediate.
> **Zero-factor correction:** Raw EWMA starts near zero. The displayed/used estimate must apply `estimate / (1 - α^totalWeight)` to correct for the initialization bias.
### Buffer Math (`core/buffer/`)
**Forward buffer** (`forward-buffer.ts`) — computes the target load window: `[currentTime, currentTime + forwardBufferDuration]`. Also computes the flush point (segments behind `currentTime - backBufferDuration`).
**Back buffer** (`back-buffer.ts`) — computes the portion of the buffer to evict when the engine is under memory pressure.
---
## DOM Layer
### PlaybackEngine (`dom/playback-engine/engine.ts`)
The orchestration hub. Initializes all features in a fixed order, wiring shared state, owners, and a single event stream.
**Feature init order:**
| Step | Feature | Purpose |
|------|---------|---------|
| 0a | `syncPreloadAttribute` | Read `preload` attr from `<video>` into state before any buffering decisions |
| 0b | `trackPlaybackInitiated` | `play` event → `state.playbackInitiated = true` |
| 1 | `resolvePresentation` | Fetch multivariant playlist, parse tracks |
| 2 | `selectVideoTrack` / `selectAudioTrack` / `selectTextTrack` | Choose initial tracks |
| 3 | `resolveTrack` | Fetch media playlist for each selected track |
| 3.5 | `calculatePresentationDuration` | Derive duration from playlists |
| 4 | `setupMediaSource` | Create `MediaSource`, attach to `<video>` |
| 4.5 | `updateDuration` | Set `mediaSource.duration` |
| 5 | `setupSourceBuffers` | Create both `SourceBuffer` instances **together** |
| 5.5 | `trackCurrentTime` | Poll `currentTime`, update state |
| 5.75 | `switchQuality` (ABR) | Monitor bandwidth, update `selectedVideoTrackId` |
| 6 | `loadSegments` (video + audio) | Reactor: observe state → send to SegmentLoaderActor |
| 6.5 | `endOfStream` | Call `mediaSource.endOfStream()` when conditions met |
| 79 | text track features | Setup, cue loading, mode sync |
> **Step 5 note:** Both SourceBuffers are created in a single synchronous pass to avoid a Firefox bug where `mozHasAudio` stays `false` if the video buffer is created first and an audio track is added later. See [decisions.md](decisions.md#sourcebuffer-creation-order).
> **Step 0a note:** `syncPreloadAttribute` must run before `setupSourceBuffers` attaches the media element. The `syncPreloadAttribute` feature reads the element's `preload` attribute and writes it to state; if mediaElement is attached first, `setupSourceBuffers` reads the attribute before state is initialized.
### SegmentLoaderActor (`dom/features/segment-loader-actor.ts`)
Plans and executes segment fetches. Receives `{ type: 'load', track, range? }` messages.
**Planning** runs in three passes on each `load` message:
1. **Removes** — compute flush ranges for forward buffer overflow and back-buffer cleanup; queue `remove` tasks on the SourceBufferActor.
2. **Init** — if the actor's current `initTrackId` doesn't match the requested track, fetch and append the init segment first.
3. **Segments** — filter the track's segment list to those within the load window and not yet committed; fetch and append each.
**In-flight management**`inFlightInitTrackId` and `inFlightSegmentId` track ongoing work. When a new `load` message arrives mid-execution:
- **Continue** — if the in-flight task is still needed for the new message, let it finish; queue remaining tasks behind it.
- **Preempt** — if the in-flight task is no longer needed (e.g., track switch), abort it and replan from scratch.
### SourceBufferActor (`dom/media/source-buffer-actor.ts`)
Serializes all MSE operations for one `SourceBuffer`. Accepts three message types:
| Message | Payload | Effect |
|---------|---------|--------|
| `append-init` | `{ data: ArrayBuffer, trackId, ...meta }` | Sets `initTrackId`, appends to buffer |
| `append-segment` | `{ body: AsyncIterable<Uint8Array>, segmentId, ... }` | Streams chunks; sets `partial: true` on first chunk, clears on completion |
| `remove` | `{ start, end }` | Calls `SourceBuffer.remove()` |
**Context snapshot** — visible to reactors and `endOfStream`:
```ts
interface SourceBufferActorContext {
initTrackId?: string;
segments: SegmentRecord[]; // all fully or partially appended segments
bufferedRanges: TimeRange[];
status: 'idle' | 'updating' | 'destroyed';
}
interface SegmentRecord {
id: string;
startTime: number;
duration: number;
trackId: string;
trackBandwidth?: number;
partial?: boolean; // true while streaming, cleared on completion
}
```
`partial: true` means the segment is present but not complete. `endOfStream` excludes partial segments from its "last segment appended" check.
### Load Segments Reactor (`dom/features/load-segments.ts`)
Observes state and owner changes, decides when and what to load, and sends `load` messages to `SegmentLoaderActor`.
**Preload behavior:**
| Condition | Behavior |
|-----------|---------|
| `preload='none'` | Dormant until play |
| `preload='metadata'` | Fetch init segment only (no media segments) |
| `preload='auto'` | Full forward buffer |
**Post-play triggers:**
- Track ID changes (quality switch or user selection)
- Segment boundary crossings (not raw `currentTime` — avoids excessive re-evaluation)
**Bandwidth sampling bridge:** Each fetch callback reports bytes and elapsed time. The reactor holds local `throughput` state per track and syncs it into `state.bandwidthState` after each sample, then calls `state.flush()` so ABR (`switchQuality`) fires before the next fetch starts.
> The bandwidth bridge is a migration artifact. See [decisions.md](decisions.md#bandwidth-bridge).
### End of Stream (`dom/features/end-of-stream.ts`)
Monitors actors and state; calls `mediaSource.endOfStream()` when all conditions hold:
- `MediaSource.readyState === 'open'`
- `HTMLMediaElement.readyState >= HAVE_METADATA`
- SourceBuffers exist for all selected tracks
- **Both actors are idle** (no pending `SourceBuffer.updating`)
- Last segment (by ID) has been appended for each track and is not `partial`
- `currentTime >= lastSegment.startTime` (guards against re-triggering during back-buffer refills near the end)
Subscribes to actor snapshot changes (not just state) so it reacts immediately when actors go idle.
### Network (`dom/network/`)
**`chunked-stream-iterable.ts`** — adapts a `ReadableStream<Uint8Array>` into an `AsyncIterable<Uint8Array>`. Accumulates chunks until a `minChunkSize` threshold is met (default 128 KB), then yields. Always releases the reader lock in `finally`.
Init segments use `minChunkSize: Infinity`, forcing the full body to accumulate before yielding. This means init appends are atomic — the SourceBuffer sees the complete init segment in one call.
Audio segments currently also use `minChunkSize: Infinity` (effectively `arrayBuffer()` semantics). Video segments stream incrementally. See [decisions.md](decisions.md#streaming-body).
---
## Data Flow: Segment Load Lifecycle
```
state.patch({ presentation })
→ resolvePresentation fetches playlist
→ state.patch({ presentation: parsedPresentation })
→ selectVideoTrack picks initial track
→ resolveTrack fetches media playlist
→ loadSegments reactor fires
→ SegmentLoaderActor.send({ type: 'load', track })
→ planTasks() → [remove?, init?, ...segments]
→ init: fetch() → SourceBufferActor.send({ type: 'append-init' })
→ segment: fetch() → SourceBufferActor.send({ type: 'append-segment' })
→ chunks stream in → SourceBuffer.appendBuffer(chunk)
→ bandwidth sample → state.bandwidthState updated → flush()
→ switchQuality evaluates → may update selectedVideoTrackId
→ loadSegments re-fires → SegmentLoaderActor preempts or continues
→ last segment appended → endOfStream detects idle + complete
→ mediaSource.endOfStream()
```
---
## Constraints
- `SourceBuffer.appendBuffer()` and `SourceBuffer.remove()` are mutually exclusive — only one operation can be in flight per buffer at a time. `SerialRunner` enforces this.
- `MediaSource.endOfStream()` must not be called while any SourceBuffer is `updating`. `endOfStream` feature waits for actor idle.
- `SourceBuffer.remove()` re-opens a `'ended'` MediaSource (same behavior as `appendBuffer`). Guard `endOfStream` triggers against spurious re-entry.
- Firefox: both SourceBuffers must be created in the same synchronous execution context to avoid `mozHasAudio = false`. See step 5 above.
+283
View File
@@ -0,0 +1,283 @@
---
status: draft
date: 2026-03-11
---
# Design Decisions
> **This document reflects decisions made in the current SPF codebase — not settled long-term choices.** The implementation is highly tentative and expected to undergo significant architectural change. The decisions recorded here capture lessons learned from the initial pass (source buffer creation order, end-of-stream gating, streaming response bodies, etc.) and are worth preserving as context, but many will be revisited as the underlying primitives and architecture evolve. See [primitives.md](primitives.md) for the forward-looking design and its open questions.
Rationale behind SPF's key choices.
---
## Architecture
### Reactor / Actor Separation
**Decision:** Feature files are split into thin reactors (observe state → send messages) and stateful actors (execute work, own context). Reactors contain no async logic; actors contain no subscription logic.
**Alternatives:**
- **Monolithic feature classes** — common in traditional players; merge observation and execution in one class. Harder to test, harder to reason about what's in-flight.
- **Pure state machines** — encode all transitions as state; no actors. Eliminates side effects but makes async work (fetch, SourceBuffer) awkward to represent.
**Rationale:** MSE operations have inherent ordering constraints (one `appendBuffer` at a time). Actors model this naturally as a serial queue. Reactors stay simple because they do nothing async — if the actor is busy, the message waits in the queue.
---
### Actor Model for SourceBuffer
**Decision:** Each `SourceBuffer` is wrapped by a `SourceBufferActor` that serializes all operations through a `SerialRunner`. The actor owns a context snapshot (buffered segments, status) that other features can read synchronously.
**Alternatives:**
- **Direct SourceBuffer calls** — simpler initially, but requires callers to gate on `SourceBuffer.updating` everywhere. Spreads the serialization concern across multiple features.
- **Promise chain** — chain `.then()` calls on each operation. Loses the ability to inspect queue state or abort mid-chain.
**Rationale:** Centralizing serialization in the actor makes every caller simpler. The context snapshot — especially `status: 'idle' | 'updating'` and the `segments` list — is read by `endOfStream` and `loadSegments` without needing to query MSE directly.
---
### Single Event Stream Across Features
**Decision:** All features in `PlaybackEngine` share one typed event stream. Each feature casts its events via `@ts-expect-error` to fit the union type.
**Alternatives:**
- **Per-feature event streams** — cleaner types, but requires each feature to wire its own stream and increases object allocation.
- **No event stream** — features communicate only through state patches. Loses the ability to fire point-in-time events (e.g., "segment appended") without polluting state.
**Rationale:** Shared stream simplifies the wiring in `PlaybackEngine` without meaningful runtime cost. The type cast is localized to one line per feature.
---
## Segment Loading
### Three-Case Load Planning
**Decision:** `SegmentLoaderActor.planTasks()` runs in exactly three passes: removes first, then init, then media segments. The order is fixed.
**Alternatives:**
- **Interleaved planning** — decide removes and appends together. Harder to follow; removes must always precede appends for the same time range.
- **Single-pass with branching** — one loop that handles all cases. Conflates concerns; harder to test each case independently.
**Rationale:** The three cases map cleanly to the three things that can happen at any segment boundary: clean up stale buffer, switch to a new track's init, load new content. Fixed order prevents ordering bugs.
---
### In-Flight Preemption vs. Continuation
**Decision:** When a new `load` message arrives while work is in progress, the actor checks whether the in-flight task is still needed. If yes, it completes and queues remaining tasks (continue). If no, it aborts the in-flight task and replans (preempt).
**Alternatives:**
- **Always abort** — simpler logic; always replan from scratch on any new message. Wastes work when the in-flight segment is still needed (e.g., minor `currentTime` advance).
- **Always complete** — never abort in-flight work. Causes stale segments to be appended after a quality switch; requires later cleanup.
**Rationale:** Continue/preempt minimizes wasted network bytes while ensuring the buffer always reflects the current intent. Most `currentTime` advances continue; track switches preempt.
---
### Init Segment Atomicity
**Decision:** Init segments use `minChunkSize: Infinity` — the full response body is accumulated before appending.
**Alternatives:**
- **Stream init segments** — possible in theory, but init data must be complete before media segments can be decoded. A partial init append would likely cause a decode error.
**Rationale:** Init segments are small (typically < 1 KB). Atomicity avoids ordering issues with no meaningful cost.
---
### Streaming Body for Media Segments {#streaming-body}
**Decision:** Video media segments are streamed incrementally via `ChunkedStreamIterable` (default 128 KB chunks). Audio media segments currently use `minChunkSize: Infinity` (atomic, equivalent to `arrayBuffer()`).
**Alternatives:**
- **Full `arrayBuffer()` for all segments** — was the original approach; simpler abort semantics, no partial-segment state needed. But delays bandwidth sampling until the entire segment downloads and prevents mid-download abort.
- **Streaming for both video and audio** — the intended final state. Audio segments are short; streaming them would complicate the code for minimal benefit, but it would enable consistent abort semantics.
**Status: in flux.** Audio streaming is pending. The current asymmetry (video streams, audio does not) is a migration step. See [Open Questions](#audio-streaming).
---
### Partial Segment Tracking
**Decision:** When the first chunk of a segment is appended, the actor marks it `partial: true` in its context. This flag is cleared when the final chunk appends.
**Alternatives:**
- **Optimistic completion** — treat the segment as "done" once queued. Risks calling `endOfStream` while a segment is still in-flight.
- **Track by bytes** — compare `totalBytes` to `ContentLength`. Requires reliable `Content-Length` headers (not guaranteed with HLS).
**Rationale:** `partial` is a simple boolean derived from the actor's own execution state — no external dependencies. `endOfStream` checks `!partial` for the last segment, preventing premature stream end.
---
## ABR
### EWMA Bandwidth Estimation
**Decision:** Bandwidth estimation uses a fast/slow EWMA pair. The exported estimate is the minimum of both.
**Alternatives:**
- **Simple moving average** — easy to compute but slow to react to drops and prone to noise.
- **Percentile-based** — more robust to outliers, but requires keeping a sample window in memory.
- **Single EWMA** — one decay factor. Choosing fast vs. slow is a trade-off; the dual approach hedges.
**Rationale:** Conservative minimum of fast/slow is the standard approach (used in hls.js, Shaka, etc.). The fast EMA reacts quickly to drops; the slow EMA prevents overreaction to spikes. Taking the minimum biases toward caution, reducing stalls.
### Zero-Factor Correction
**Decision:** Displayed bandwidth estimates apply `estimate / (1 - α^totalWeight)` to correct for EWMA initialization bias.
**Context:** A freshly created EWMA has a near-zero estimate even before any samples arrive, because the accumulated weight starts at zero. Raw values are misleading in the UI and can cause ABR to under-select on the first quality decision.
**Rationale:** This is a standard EWMA correction. Without it, the first quality selection is always the lowest rendition regardless of actual network conditions.
---
### Upgrade Throttle
**Decision:** Quality upgrades are gated by `minUpgradeInterval` (default 8 s). Downgrades are immediate.
**Alternatives:**
- **Symmetric throttle** — gate both upgrades and downgrades. But slow downgrades during network drops cause buffer stalls.
- **No throttle** — react to every bandwidth sample. Causes oscillation when bandwidth fluctuates around a rendition threshold.
**Rationale:** Asymmetry matches the asymmetry in consequence: a missed upgrade just means slightly lower quality; a missed downgrade can cause a rebuffer. Immediate downgrades prevent stalls; gated upgrades prevent thrashing.
---
### `abrDisabled` Flag
**Decision:** Setting `state.abrDisabled = true` prevents `switchQuality` from updating `selectedVideoTrackId`.
**Alternatives:**
- **Separate `manualVideoTrackId` field** — explicit field for user-selected quality, with ABR writing to a different field. Cleaner separation; the player can show which track is "manually" vs. "automatically" selected.
**Status: provisional.** `abrDisabled` is a blunt instrument. The long-term design separates `manualVideoTrackId` from `abrVideoTrackId` so both can be tracked independently. See [Open Questions](#abr-track-fields).
---
## MSE Coordination
### SourceBuffer Creation Order {#sourcebuffer-creation-order}
**Decision:** Both the video and audio `SourceBuffer` are created in the same synchronous execution context (step 5 in `PlaybackEngine`), even if only one is needed at first.
**Context:** Firefox has a bug where `mozHasAudio` remains `false` if the video `SourceBuffer` is created before the audio `SourceBuffer` in a different task. This causes Firefox to believe the stream has no audio and mute/skip it.
**Alternatives:**
- **Lazy creation** — create each buffer only when the first segment for that track is ready. Cleaner conceptually but triggers the Firefox bug.
**Rationale:** Creating both buffers together is a workaround for a browser bug. The cost (one extra `SourceBuffer` created slightly early) is negligible. The fix is permanent until Firefox patches the underlying bug.
---
### `endOfStream` Actor-Idle Gate
**Decision:** `endOfStream` waits for both `SourceBufferActor` instances to report `status: 'idle'` before calling `mediaSource.endOfStream()`.
**Context:** `MediaSource.endOfStream()` must not be called while any `SourceBuffer.updating` is `true` — it throws a `DOMException`. The actors expose their status synchronously via their snapshot.
**Alternatives:**
- **Poll `SourceBuffer.updating` directly** — bypasses the actor abstraction. Introduces a direct DOM dependency in a feature that otherwise reads only from actor snapshots.
- **setTimeout/rAF delay** — unreliable; race condition if the buffer update finishes after the timer fires.
**Rationale:** Actor idle is the correct signal: it means all queued tasks have completed, not just the currently executing one. Subscribing to actor snapshots is instantaneous — no polling.
---
### `remove()` Re-opens `MediaSource`
**Decision:** Code that could call `endOfStream` must guard against spurious re-entry when `SourceBuffer.remove()` is called near end-of-stream.
**Context:** Calling `SourceBuffer.remove()` (and `appendBuffer()`) automatically transitions a `'ended'` `MediaSource` back to `'open'`. If `endOfStream` is watching actor state and immediately re-fires when actors go idle, a `remove()` operation after `endOfStream()` creates an infinite loop.
**Implementation:** `endOfStream` checks `currentTime >= lastSegment.startTime` before firing. Back-buffer cleanup removes segments behind `currentTime`, so this guard ensures we don't re-end after cleaning up the back buffer near end-of-stream.
---
## State Management
### Batched `patch()` with Explicit `flush()`
**Decision:** `state.patch()` defers updates via `queueMicrotask`. `state.flush()` is provided for cases where subscribers must react synchronously.
**Alternatives:**
- **Synchronous `patch()`** — subscribers fire immediately on every patch. Risks re-entrant subscription loops and makes it impossible to batch multiple simultaneous updates.
- **Manual batching only** — no automatic deferral; callers always batch explicitly. More control but more boilerplate.
**Rationale:** Automatic batching eliminates most accidental re-entrancy. `flush()` is the escape hatch for the cases (ABR bandwidth sampling) where timing matters.
---
### Bandwidth Bridge {#bandwidth-bridge}
**Decision:** `loadSegments` maintains local `throughput` state per track and syncs it to `state.bandwidthState` after each sample.
**Context:** This is a migration artifact. The long-term design has ABR read directly from a throughput observable rather than going through the global state. The bridge exists to decouple the refactor from the feature work.
**Status: temporary.** Remove once ABR reads from `throughput` directly. See [Open Questions](#abr-throughput).
---
## Open Questions
### Audio Streaming {#audio-streaming}
Audio segments currently use `minChunkSize: Infinity` (full atomic download). This prevents mid-download abort and delays bandwidth sampling for audio fetches.
**Options:**
- Stream audio like video (consistent abort semantics, better sampling)
- Keep atomic (audio segments are short; streaming buys little)
**Open:** Streaming audio would eliminate the asymmetry. The main blocker is that streaming requires partial-segment tracking, which is already in place. Worth revisiting once the video streaming path stabilizes.
---
### ABR Track Fields {#abr-track-fields}
`abrDisabled` is a boolean that suppresses all ABR. The desired model separates:
- `abrVideoTrackId` — the track ABR would choose
- `manualVideoTrackId` — the track the user explicitly selected
This lets the UI show "currently manual at 720p, ABR would choose 1080p" without having two separate play modes.
**Open:** Needs a state shape decision and migration path from `abrDisabled`.
---
### ABR Throughput Direct Read {#abr-throughput}
The bandwidth bridge (`loadSegments``state.bandwidthState``switchQuality`) introduces a round-trip through global state. ABR should eventually read from a throughput observable owned by the network layer, removing the bridge.
**Open:** Requires defining the throughput API in `core/` and wiring it through `dom/`.
---
### `SegmentLoaderActor` / `LoadTask` Naming
The current naming (`LoadTask`, related internals) is provisional. Better candidates: `SegmentLoaderOp`, `LoadOp`, or `SegmentFetchTask`.
**Open:** Rename before public API stabilizes.
---
### `endOfStream` Subscription Structure
The `endOfStream` feature currently subscribes to both actor snapshots and state separately, leading to some duplication in the condition checks. A cleaner approach would combine actor + state into a single derived selector.
**Open:** Refactor once the actor snapshot API stabilizes.
+130
View File
@@ -0,0 +1,130 @@
---
status: draft
date: 2026-03-11
---
# SPF — Streaming Playback Framework
> **This is a living design document for a highly tentative codebase.** The current implementation captures useful early lessons but is expected to undergo significant architectural change in the near term. [architecture.md](architecture.md) and [decisions.md](decisions.md) document the current state; [primitives.md](primitives.md) is the forward-looking design.
A lean, actor-based framework for HLS playback over MSE. Handles manifest parsing, quality selection, segment buffering, and end-of-stream coordination — without a monolithic player.
## Contents
| Document | Purpose |
| ---------------------------------- | ---------------------------------------------------------------- |
| [index.md](index.md) | Overview, problem, quick start, surface API |
| [primitives.md](primitives.md) | Foundational building blocks (Tasks, Actors, Reactors, State) |
| [architecture.md](architecture.md) | Current implementation: layers, components, data flow |
| [decisions.md](decisions.md) | Decided and open design decisions |
## Problem
MSE-based adaptive streaming requires coordinating several concerns that don't naturally belong together: fetching segments, feeding a SourceBuffer, switching quality mid-stream, tracking what's buffered, and signaling end-of-stream at the right moment. In traditional players these concerns collapse into one or two large stateful classes, creating tight coupling and making it difficult to reason about ordering, in-flight work, or test individual pieces.
HLS adds another dimension: multivariant playlists (choosing among renditions), media playlists (knowing which segments exist), and the need to react to bandwidth changes in real time. Audio and video have separate SourceBuffers and separate fetch lifecycles but must stay in sync.
SPF addresses this by decomposing the problem into three layers — reactive state, actors, and reactors — each with a single job.
## Solution Overview
SPF is structured around three layers:
1. **Reactive state** — a batched, selector-based store that drives everything. Features observe state slices and send messages to actors.
2. **Actors** — durable workers that own a queue and a context snapshot. Each actor serializes its own operations. The two key actors are `SourceBufferActor` (MSE operations) and `SegmentLoaderActor` (fetch + append planning).
3. **Reactors** — thin subscribers that translate state changes into actor messages. They contain no logic beyond "should I send a message, and what should it say?"
HLS parsing, ABR, and buffer math live in the `core/` layer, which is DOM-free and independently testable.
```
state (reactive)
│ observes
reactors (thin)
│ send messages
actors (stateful workers)
│ execute tasks
MSE (SourceBuffer, MediaSource)
```
## Quick Start
```ts
import { createPlaybackEngine } from '@videojs/spf';
const engine = createPlaybackEngine();
// Attach the media element (triggers SourceBuffer setup, segment loading, etc.)
engine.owners.patch({ mediaElement: videoElement });
// Load an HLS stream
engine.state.patch({ presentation: { url: 'https://example.com/stream.m3u8' } });
// Play
videoElement.play();
// Tear down
engine.destroy();
```
## Surface API
### createPlaybackEngine
```ts
function createPlaybackEngine(options?: PlaybackEngineOptions): PlaybackEngine;
```
The single entry point. Returns a `PlaybackEngine` that owns the reactive state, owners ref, and all internal actors.
### PlaybackEngine
```ts
interface PlaybackEngine {
state: State<PlaybackEngineState>;
owners: Owners<PlaybackEngineOwners>;
destroy(): void;
}
```
- `state` — patch to configure: `presentation`, `preload`, `selectedVideoTrackId`, `abrDisabled`, etc.
- `owners` — patch to inject platform dependencies: `mediaElement`, `mediaSource`, `videoBuffer`, `audioBuffer`.
- `destroy()` — tears down all actors, aborts all in-flight work.
### Key State Fields
```ts
interface PlaybackEngineState {
presentation?: Presentation; // loaded multivariant playlist
preload?: 'none' | 'metadata' | 'auto';
selectedVideoTrackId?: string;
selectedAudioTrackId?: string;
selectedTextTrackId?: string;
abrDisabled?: boolean; // suppress ABR for manual selection
bandwidthState?: BandwidthState; // current bandwidth estimate
currentTime?: number;
playbackInitiated?: boolean;
}
```
### Key Owner Fields
```ts
interface PlaybackEngineOwners {
mediaElement?: HTMLVideoElement;
mediaSource?: MediaSource;
videoBuffer?: SourceBuffer;
audioBuffer?: SourceBuffer;
videoBufferActor?: SourceBufferActor;
audioBufferActor?: SourceBufferActor;
}
```
## Related Docs
- [architecture.md](architecture.md) — how the layers connect
- [decisions.md](decisions.md) — why these choices
+277
View File
@@ -0,0 +1,277 @@
---
status: draft
date: 2026-03-11
---
# SPF Primitives
The five foundational building blocks of SPF. Most design decisions here are **open** — this document captures the intended shape and unresolved questions, not final answers.
---
## 1. Tasks
An ephemeral unit of async work. Promise-inspired but with more structure: a Task has an inspectable status, is abortable, and transitions through a well-defined finite set of states.
### Concept
A Task represents a single operation — a fetch, a SourceBuffer append, a remove — with a defined lifecycle. Unlike a raw `Promise`, a Task:
- Starts in a **pending state** before it runs — it exists before execution begins, which means it can be inspected, queued, replaced, or aborted before any work starts
- Can be **aborted** from outside at any point, with that signal propagated inward
- Exposes its **status synchronously** (callers can ask "is this running?" without awaiting)
- Carries a typed **`value`** and **`error`**, readable synchronously once the Task settles — no need to await the promise
- Has a **finite, well-known set of states**: `pending → running → done | error`
Calling **`run()`** transitions the Task from `pending` to `running` and returns a `Promise<TValue>`. A Task can be run directly, or via a TaskRunner — a helpful abstraction for aggregating and scheduling groups of related Tasks (see §2).
This is the core relationship between a Task and a plain async function. The Task interface defines `run()` as the boundary where execution begins. The primary class implementation expresses this by accepting a `(signal: AbortSignal) => Promise<TValue>` at construction time — a convenient way to define the work without subclassing — but any implementation of the Task interface is valid.
The pending state is one of the key distinctions from `Promise`. A Promise begins executing the moment it is created; a Task can be created, passed around, and queued without any work starting until a Runner decides to execute it.
`value` and `error` have an explicit **ordering guarantee**: `value` is written before `status` transitions to `done`; `error` is written before `status` transitions to `error`. Any reader observing a terminal status is guaranteed the corresponding field is already populated.
Currently, abort is not a separate terminal state — aborting causes the run function to throw, which lands the Task in `error`. See Open Questions for the tentative plan to change this.
Tasks can theoretically be composed in the same ways Promises can — sequentially (A's output becomes B's input), in parallel, or as a pipeline of transformations. This isn't currently done at the Task level; composition today happens at the function-and-Promise level, where one async function's result is passed into the next. Lifting that pattern to Tasks would make the composition explicit and give each step its own status, value, and abort handle.
A Task is ephemeral: once it reaches a terminal state, it stays there. It is not restarted.
### Relationship to Actors
A Task is the unit of work *inside* an Actor or Reactor. Actors plan and execute Tasks; they don't expose Tasks externally. A Task's status may or may not be part of the Actor's observable snapshot — that's a design choice for the Actor, not the Task.
### Current approach
`core/task.ts` — thin wrapper around a function with an `AbortController`. The shape is approximately right; the question is how much structure to add.
### Open questions
- **`aborted` as a distinct terminal state** — currently a Task that is aborted throws and lands in `error`, losing the distinction between "cancelled" and "failed". Tentatively, `aborted` should be a first-class terminal state: `pending → running → done | error | aborted`. The mechanism is straightforward — when `run()` catches a rejection, it checks whether the Task's abort signal is already aborted; if so, it transitions to `aborted` rather than `error`. This keeps the abort/error distinction out of the error value and makes it inspectable via `status` alone.
- **Where does "queued" state live?** A Task knows it is `pending`; a Runner knows which pending Tasks it holds. Whether "this Task is currently queued in Runner X" should be formally surfaced — on the Runner, on the Actor, or not at all — is an open question that spans §2 (TaskRunners) and §3 (Actors).
- **Task as Actor** — Tasks already have `status`, `value`, and `error`. Normalizing these into a single `snapshot` object would make a Task's shape closer to an Actor's. Tentatively, keep them separate: Tasks are ephemeral work units, Actors are long-lived stateful things, and collapsing that distinction adds complexity without clear benefit. Revisit if a unification becomes necessary or valuable. A related sub-question: even short of full unification, should `status + value + error` be grouped into a `snapshot` for consistency? Tentatively no, for the same reason — hold off unless a concrete need emerges.
---
## 2. TaskRunners
An abstraction that separates *what* runs (a Task) from *when and how* it runs. Different Runner strategies produce different scheduling and concurrency behaviors.
### Concept
A Runner accepts Tasks and decides when to execute them. The caller submits work without knowing when it will start. This decoupling makes it easy to swap scheduling strategies — for example, swapping serial execution for concurrent without changing the Task definition.
Known useful strategies:
- **Serial** — one Task at a time; queue the rest. Each Task runs independently — an error or abort in one does not prevent subsequent Tasks from running. (Contrast with a hypothetical *chained* runner, where Tasks would be linked and a failure would break the chain.)
- **Concurrent with deduplication** — run Tasks in parallel, but drop or replace if a Task with the same ID is already in-flight or queued.
- Others (priority, throttled, etc.) may emerge.
### Relationship to Tasks and Actors
Runners are internal to Actors and Reactors. An Actor may own one or more Runners (e.g., one serial Runner per SourceBuffer). Runners are not exposed externally; they're an implementation detail of how an Actor executes its work.
### Current approach
`SerialRunner` and `ConcurrentRunner` in `core/task.ts`. The core abstraction is right.
### Open questions
- **Runner state modeling and observability** — should a Runner formally model its pending and running Tasks (beyond just tracking them internally for `abortAll`)? If so, should that state be observable — and if observable, does it belong on the Runner itself or only surfaced via the owning Actor's snapshot? A related sub-question: should a Task briefly remain visible in a terminal state (`done` or `error`) before being removed, giving subscribers a notification window? Or are terminal Tasks removed immediately, with callers expected to observe results through other means (e.g., the Task's own `value`/`error`, or the Actor's snapshot)?
- **Runner composition** — can Runners be nested (a serial Runner of concurrent Runners)? Probably not needed now, but worth keeping in mind.
---
## 3. Actors
Long-lived instances that own state over time, receive messages, and use Tasks and Runners to execute work. The primary stateful workers in SPF.
### Concept
An Actor:
- Has an observable **snapshot** — a typed record of its current context (what's been buffered, what track is loaded, etc.) plus a **status** drawn from a finite state machine
- Receives **messages** via an explicit `send(message)` method — imperative input
- Executes work in response to messages using Tasks and Runners
- Is the sole owner and writer of its own state — reads and writes flow through the Actor's own snapshot; external state is not directly accessed
The snapshot is observable: other things (Reactors, `endOfStream`, the engine) can subscribe to Actor state changes without polling.
Actors should be **classes**. The current bespoke-closure approach makes it difficult to test, subclass, or inspect Actors in isolation. A class with a defined interface makes the contract explicit.
### Relationship to Reactors
An Actor does not know about state outside itself. It receives messages and produces state changes. Reactors observe external state and decide when and what to `send()` to Actors — the coordination layer lives in the Reactor, not the Actor.
### Current approach
The concept is approximately right in the current codebase, but implementations are bespoke closures rather than classes. They will need to be refactored into classes with a formal interface. Beyond that structural change, additional structure is likely to emerge — for example, an Actor may define an explicit message map from message type to Task, making the relationship between inputs and work more declarative and inspectable.
### Open questions
- **Snapshot as signal vs subscribable** — does the Actor expose `snapshot` as a **signal** (synchronously readable, tracked in reactive contexts) or as a **subscribable** (push-based, no current value without explicit storage)? This is tightly coupled to the Observable State decision (§5). The synchronous-inspection use case (e.g., `endOfStream` reading idle status without subscribing) slightly favors signals.
- **Message validity and handling** — whether a message is valid depends on the Actor's current status. Some messages may be invalid in certain states and should be rejected or ignored rather than queued. How each Actor defines valid messages per state, and what happens when an invalid message arrives (silent drop, error, warning), is left to the Actor's own finite state machine definition.
- **Error handling** — if a Task inside an Actor throws an unaborted error, does the Actor die, recover to an error state, or retry? No answer yet; depends on which Actors exist and what errors are recoverable.
- **Base class vs interface** — if Actors are classes, is there a base class (`BaseActor`) with common snapshot/status machinery, or just an interface that each Actor implements independently?
- **Scope of Actor dependencies** — should Actors be definitionally constrained to their own state plus explicitly passed-in dependencies (including other Actors, platform resources like a `SourceBuffer`, etc.), or should they be permitted to read from or write to shared global state (e.g., global owners, global events)? The current pattern has Actors receiving everything they need at construction time and interacting with other Actors via `send()` — one Actor's output becoming another's input. Allowing global state access would blur the boundary between Actor and Reactor (which exists precisely to mediate between global state and Actors). Tentatively: no — keep Actors self-contained; Reactors are the right place for global state coordination.
---
## 4. Reactors
Long-lived instances that *react* to observable state changes rather than receiving explicit messages. Like Actors, they have an observable snapshot with status and use Tasks and Runners for async work.
### Concept
A Reactor:
- Has an observable **snapshot** with **status** (same structure as an Actor)
- Is **driven by subscriptions** to external state — when observed state changes in a relevant way, the Reactor decides whether and how to respond
- Uses Tasks and Runners to execute work, just like an Actor
- Has **no `send()` method** — it cannot receive imperative messages
The key distinction from a plain effect or subscription: a Reactor has its own state machine and is a first-class observable thing. Other parts of the system can observe a Reactor's status ("is the segment loader currently loading?") without coupling to its internals.
Most of what currently lives in `dom/features/` as top-level functions are conceptually Reactors — they subscribe to state, do async work, and produce side effects. The missing piece is the formal status/snapshot structure.
### Relationship to Actors
A Reactor is typically the bridge between observable state and one or more Actors. It observes state, decides what message to send, and calls `actor.send(message)`. The Actor handles execution; the Reactor handles coordination.
### Current approach
The current codebase has top-level functions in `dom/features/` that gesture at the Reactor concept — they observe state and produce side effects — but lack the formal structure entirely: no class, no status, no snapshot, no defined lifecycle. These will need significant rework to become first-class Reactors.
### Open questions
- **Snapshot as signal vs subscribable** — same question as Actors (§3). Tightly coupled to §5.
- **Effect scheduling** — when observed state changes, does a Reactor's response fire synchronously within the same update batch, or always deferred? Synchronous firing is simpler but risks re-entrancy; deferral is safer but adds latency. This is closely tied to how the Observable State primitive handles scheduling.
- **Lifecycle ownership** — who creates and destroys Reactors? Currently the engine owns all of this explicitly. With a signal-based state primitive, Reactors could self-scope to a signal context and auto-dispose. Worth defining regardless.
- **Can a Reactor send to another Reactor?** — Probably not directly (that would make it an Actor). If cross-Reactor coordination is needed, it likely flows through state.
---
## 5. Observable State
The reactive primitive that drives everything. State that can be observed over time, derived from other state, and composed in complex ways. The most consequential open design question in SPF.
### Concept
Observable state needs to support:
- **(a) Mapping, filtering, distinctness** — deriving new state from existing state; only propagating when the value meaningfully changed
- **(b) Composition** — combining multiple state sources into derived state; expressing complex conditions as first-class values
- **(c) Subscriptions vs effects** — a clean distinction between "observe this value" and "run a side effect when this changes"
- **(d) Scheduling control** — not forcing async assumptions; ideally supporting different schedulers for different contexts
- **(e) Cacheable derived state** — computing a derived value once and reusing it until dependencies change (memoization)
- **(f) Abort/cleanup integration** — a natural way to cancel in-flight work when a subscription ends or a scope is destroyed
- **(g) Custom comparators** — controlling what counts as "changed" per-value rather than relying only on reference equality
### Signals
A **signal** is a value-over-time: it always has a current value, and subscribers are notified when that value changes. `computed()` (or `memo()`) creates derived signals with automatic dependency tracking and caching. `effect()` runs a side effect whenever accessed signals change and returns a cleanup.
**Addressing each requirement:**
- **(a)** `computed()` derives new state with automatic dependency tracking; filtering is expressed via conditional logic inside the computation. Distinctness is built in — computed values only propagate when the result changes.
- **(b)** `computed(() => fn(signalA(), signalB()))` — composition is natural and automatic; no explicit wiring of dependencies.
- **(c)** Reading a signal is observation; `effect()` is explicitly a side effect. The distinction is enforced at the call site.
- **(d)** Synchronous by default; how easily scheduling can be externalized varies by library. The TC39 Signals proposal separates "signal becomes dirty" from "effect re-runs" via a low-level `Watcher` API, leaving scheduling entirely to the caller. Libraries like `@preact/signals-core` run effects synchronously with `batch()` as the only grouping primitive, with limited room for a custom scheduler. Others (e.g. Vue's `watchEffect`) make scheduler policy configurable per-effect. This has direct implications for Reactors: synchronous effects fire mid-batch and require careful re-entrancy management; deferred scheduling is safer but less immediate.
- **(e)** `computed()` is lazy and automatically cached — re-evaluates only when a dependency changes. Sharing that cache across multiple use sites requires sharing the reference: a `computed()` defined once (e.g., at module scope or passed in at construction) and used in many places computes once. Two independently defined but structurally identical `computed()` calls are two independent nodes. A **shareable selector** pattern — exporting named derivations rather than defining inline anonymous functions at each use site — solves this, but is a convention rather than something the primitive enforces.
- **(f)** `effect()` returns a disposal function; wiring that to an `AbortController` is manual but straightforward.
- **(g)** Most implementations expose an `equals` option at signal or computed creation time.
**Overall:**
- **Always having a current value** forces explicit modeling of uninitialized state (e.g., `signal<TrackId | undefined>(undefined)`). Reading a signal that holds `undefined` in a context that doesn't handle it silently succeeds with the wrong value.
- **Reading outside a reactive context** silently returns the current value without setting up tracking — a footgun that requires discipline.
- **Shared derived state requires shared references** — the shareable selector pattern (define once, share the reference) works cleanly, but inline anonymous functions at each use site silently create independent computations. This is a convention concern: the primitive won't warn you, and the cost is redundant recomputation rather than correctness failures.
- Actor/Reactor snapshots as signals would make synchronous inspection (e.g., "is this actor idle right now?") natural.
### Observables
An **observable** is a sequence of values pushed to a subscriber over time. Composition uses operators (`map`, `filter`, `distinctUntilChanged`, `combineLatest`, etc.).
**An important framing note:** Looking at how SPF actually uses reactive state, every case is a *state over time* use case — current track, buffer state, bandwidth estimate, playback position. There are no pure event-stream use cases (actor message queues and network streams live inside Actors and Tasks, not in the observable state layer). This means in practice, observable state in SPF would be `BehaviorSubject`-based throughout — not cold streams. That reframes several of the concerns below.
**Addressing each requirement:**
- **(a)** `map()`, `filter()`, and `distinctUntilChanged()` — explicit and composable. `distinctUntilChanged()` accepts a custom comparator, similar to signals' `equals` option.
- **(b)** `combineLatest()`, `merge()`, `switchMap()`, etc. — powerful but requires explicit dependency wiring.
- **(c)** `tap()` inserts a side effect into a pipeline. It works, but the side effect is embedded within the composition rather than standing alongside it as `effect()` does — a different mental model that may feel awkward.
- **(d)** RxJS provides Schedulers for controlling delivery timing and backpressure strategies for handling fast producers. Customization depth warrants further investigation.
- **(e)** Derived state requires explicit `shareReplay(1)` + `distinctUntilChanged()` for caching, and must be carefully composed to avoid multiple independent upstream subscriptions. The same **shareable selector** pattern applies: a derived observable defined once and shared by reference is computed once; defined inline at each use site, it is computed independently each time. The cost of getting this wrong is higher than with signals since there is no automatic caching to fall back on — a carelessly duplicated `pipe()` chain creates multiple upstream subscriptions with no warning.
- **(f)** Unsubscribing cancels the chain; `takeUntil` is idiomatic for lifetime scoping. Mid-flight task concerns (e.g., aborting an in-flight fetch) live inside Actors and TaskRunners rather than in the observable composition itself, so this is largely a non-issue at the state layer.
- **(g)** `distinctUntilChanged(comparator)` accepts a custom equality function — comparable ergonomics to signals' `equals` option.
**Overall:**
- If SPF's observable state is always a `ReplaySubject(1)` with an initial value — functionally a `BehaviorSubject` — then "no current value" and "cold vs hot" are non-issues by design. Current value is always present; sources are always hot and shared. These concerns only apply if that convention breaks down, which is itself a discipline/enforcement question.
- **(e) Derived state caching** remains the sharpest concern. Base state is cached by the `ReplaySubject(1)`, but derived observables still require explicit `shareReplay(1)` + `distinctUntilChanged()`. The shareable selector pattern applies here too — but a duplicated `pipe()` chain doesn't just recompute: it creates multiple upstream subscriptions, which is a correctness concern rather than just an efficiency one.
- **Ergonomics** — `tap()` for effects and `shareReplay(1)` + `distinctUntilChanged()` for derived state are available but represent more ceremony than their signals equivalents. Contributors unfamiliar with RxJS idioms may find this harder to follow.
- TC39 Observable proposal is Stage 2 — closer to native than Signals (Stage 1).
### Mixing concerns
Using both signals and observables in the same system is possible but introduces friction at every boundary:
- **Signal → Observable**: wrap `effect()` in an Observable constructor. Loses synchronous scheduling guarantees; the observable subscriber sees updates asynchronously.
- **Observable → Signal**: subscribe in a side effect, write to a signal. Imports an async event into the synchronous reactive graph. Can cause "glitches" if the signal updates during a batch.
The risk is not that bridging is impossible — it's that every bridge is a potential source of subtle timing bugs, and bridges tend to multiply once the pattern is established. A system that uses both heavily will spend significant effort managing the boundary.
A disciplined hybrid could work: signals for state (current values, derived values, effects), observables only for event sequences where they're clearly superior (e.g., Actor message queues, network streams). The boundary must be explicitly defined and consistently enforced.
### Current approach
A minimal hand-rolled observable in `core/state/` and `core/reactive/`. The concept is directionally correct but the primitive is insufficient for SPF's needs: no operators, no caching, no scheduling control, manual dependency wiring. This will be replaced entirely — the current implementation should be treated as a placeholder that established the pattern, not a foundation to build on.
### Open questions
- **Signals vs observables as the canonical state primitive** — or a defined hybrid with explicit bridge points?
- **Home-grown vs. off-the-shelf** — given SPF's bundle size goals, a home-grown implementation that covers exactly what SPF needs is the most likely path, regardless of whether signals or observables are chosen. Off-the-shelf libraries are unlikely to satisfy both requirements simultaneously: full feature coverage and acceptable size. A possible exception is the TC39 Signals polyfill, which may prove small enough and well-aligned enough to be viable — but this isn't obvious yet and warrants evaluation.
- **Does "always having a current value" cause problems in practice?** The initialization question is solvable; the real question is whether reading-outside-reactive-context is a discipline problem or a design problem.
- **Scheduling model for Reactors** — if signal effects are synchronous, do Reactors fire mid-batch? If so, is that correct for all Reactors, or should some defer? Should the Reactor abstraction impose a scheduling policy, or leave it to the state primitive?
- **How does abort/cleanup compose with the state primitive?** An explicit answer here would clean up a lot of the current manual AbortController management scattered across features.
---
## Composition & Interop
How the five primitives fit together and the cross-cutting concerns that don't belong to any one of them.
### The dependency graph
```
Observable State
↑ reads/subscribes
Reactors ──send()──→ Actors
↑ both use ↑ both use
TaskRunners ←── Tasks
```
- **Tasks** have no dependencies on the other primitives — they're pure async work units.
- **TaskRunners** depend only on Tasks.
- **Actors** depend on TaskRunners and Tasks. They may expose their snapshot via Observable State (signal or subscribable).
- **Reactors** depend on Observable State (they subscribe to it) and on Actors (they send messages to them). They also use TaskRunners and Tasks for their own async work.
- **Observable State** is the substrate — everything else either reads from it, writes to it, or both.
### Lifecycle ownership
Currently the `PlaybackEngine` explicitly creates, wires, and destroys every Actor and Reactor in a defined order. This works but is imperative and order-sensitive.
An alternative: if Reactors self-scope to the reactive graph (e.g., signal effects are owned by a context that the engine controls), destroying the engine's reactive scope could automatically dispose all Reactors. Actors would still need explicit lifecycle management since they hold external resources (SourceBuffer, MediaSource).
This is not a decision yet — it's worth understanding what the Observable State primitive makes possible before committing to a lifecycle model.
### Scheduling coordination
The current `patch()` + `flush()` model exists because batching is needed for correctness (multiple synchronous patches shouldn't fire N subscriber callbacks), but immediate propagation is sometimes needed (bandwidth sampling must reach ABR before the next fetch starts).
Whatever Observable State primitive is chosen, SPF needs an explicit answer for: *when does a state change propagate to subscribers?* Options:
- **Always synchronous** (within batch): predictable, but requires careful batch discipline
- **Always deferred** (microtask): safe default, but requires explicit "flush" for time-sensitive paths
- **Configurable per-subscription**: most flexible, most complex
### Open questions
- **Engine as wiring vs engine as scope** — does the engine explicitly wire everything (current approach), or does it define a reactive scope that Reactors and Actors self-register into?
- **Consistent snapshot shape** — should Actors and Reactors share a base snapshot interface (both have `status`, both are subscribable)? This would let the engine treat them uniformly for lifecycle and observability.
- **Cross-Reactor state** — when a Reactor needs to know about another Reactor's status (e.g., "don't load segments if the media source isn't open yet"), does it read that Reactor's snapshot directly, or does all coordination flow through the shared state? Direct reads are simpler; state-mediated coordination is more decoupled.