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

---

## Actor/Reactor Pattern (from text track spike)

These decisions were made or confirmed during the text track architecture spike
(videojs/v10#1158). See [text-track-architecture.md](text-track-architecture.md) for
the full reference implementation and assessment.

---

### `monitor`-before-state ordering as a load-bearing guarantee

**Decision:** `monitor` effects in `createMachineReactor` always run before per-state effects.
This ordering guarantee is documented in `createMachineReactor`'s source and must be preserved.

**Rationale:** Per-state effects rely on invariants established by `monitor` functions.
When a `monitor` function returns a new state, the framework calls `transition()` and the
snapshot updates before any per-state effect fires — so per-state effects that no-op when
`snapshot.value !== expectedState` do so correctly without needing to re-check conditions
themselves.

**Caveat:** The guarantee is specific to `createMachineReactor`'s registration order. It depends
on the TC39 `signal-polyfill`'s `Watcher` preserving insertion order in `getPending()` —
not a formal guarantee of the TC39 Signals proposal.

---

### `deriveState` pattern for transition logic

**Decision:** Transition conditions live in a pure `deriveState` function, wrapped in a
`computed()` signal outside any effect body, consumed by the `monitor` field to drive
transitions. The `monitor` function returns the target state; the framework handles the
comparison and transition.

**Rationale:** Keeps the `monitor` function minimal and machine-readable; makes transition
conditions independently testable as a plain function; prevents the inline computed
anti-pattern (see [actor-reactor-factories.md](actor-reactor-factories.md)).

---

### Actors in owners as the lifecycle contract

**Decision:** Actors created by a reactor are written to the shared `owners` signal.
The engine's `destroy()` generically destroys any value in owners with a `destroy()`
method. The reactor does not destroy its own actors.

**Rationale:** Keeps reactor cleanup simple — no tracking of which actors were created,
no custom destroy logic. Gives the engine a single, uniform cleanup point. The tradeoff
is an implicit contract: callers using a reactor outside the engine must destroy actors
from owners before destroying the reactor.

---

### Entry-reset as a defensive pattern for actor-creating states

**Decision:** States that create actors (`'setting-up'`) and states that are reset points
(`'preconditions-unmet'`) both call `teardownActors()` on entry. `teardownActors` is a
guarded no-op when actors are already `undefined`, preventing spurious signal writes.

**Rationale:** Any transition to a reset state may arrive from a state where actors were
alive. Defensive teardown on *both* states eliminates the need to track "did I come from
an actor-alive state?" — the entry effect is always safe to run.

---

## 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 reactive throughput source 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 reactive throughput source 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.
