---
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 holds SPF's framework primitives (composition, signals, tasks, actors, reactors) — runtime-agnostic, with no DOM or network dependencies. The `media/` layer holds runtime-agnostic HAS-domain logic (ABR, buffer math, HLS parsing, CMAF-HAM types). The `dom/` layer wires browser platform APIs (MSE, fetch, HTMLMediaElement) into both.

---

## 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/actors/actor.ts` + `core/tasks/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 (`media/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 (`media/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 (`media/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/hls-engine.ts`, built on `core/composition/create-composition.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 | `syncPreload` | Bidirectional sync of `mediaElement.preload` ↔ `state.preload` (backfilled to `'metadata'` by default) before any buffering decisions |
| 0b | `trackPlaybackInitiated` | `play` event → `state.playbackInitiated = true` |
| 1 | `resolvePresentation` | Fetch multivariant playlist, parse tracks |
| 2 | `selectVideoTrack` / `selectAudioTrack` / `switchTextTrack` | 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 | `updateMediaSourceDuration` | 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 |
| 7–9 | 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:** `syncPreload` must be registered before behaviors that read `state.preload` (notably `resolvePresentation`, which gates manifest fetching on the preload value). The behavior backfills `state.preload` to its configured default on initial setup and keeps it bidirectionally synced with `mediaElement.preload` thereafter.

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