diff --git a/.claude/plans/spf/reactor-signals-migration.md b/.claude/plans/spf/reactor-signals-migration.md new file mode 100644 index 00000000..1c53154e --- /dev/null +++ b/.claude/plans/spf/reactor-signals-migration.md @@ -0,0 +1,450 @@ +--- +status: complete +branch: poc/spf-signals +--- + +# SPF Reactor Signals Migration + +> **Incremental migration plan.** Follows the completed SourceBufferActor spike +> (`signals-poc.md`). Goal: migrate `dom/features/` reactors from `WritableState` +> subscriptions to `Signal.Computed` + `effect()`, one reactor at a time, without +> breaking anything in flight. + +## Background + +The SourceBufferActor spike validated: + +1. TC39 Signals (`signal-polyfill`) work cleanly for SPF's use cases +2. `Signal.Computed` tracks transitively through owner graphs — no manual re-subscription +3. `effect()` is a natural replacement for `subscribe()` in both sync and async contexts +4. The `WritableState → Signal` bridge is 2 lines and easy to localize + +The reactor layer — functions in `dom/features/` that translate state/owners changes +into actor messages or MSE operations — is the next migration target. These currently use +`WritableState.subscribe()`, `combineLatest()`, and selector equality functions. With +signals, these collapse into `computed()` + `effect()`. + +## Bridge Utilities + +Two helpers in `core/signals/bridge.ts` (alongside `effect.ts`): + +### `stateToSignal` — WritableState → Signal + +```ts +export function stateToSignal(state: WritableState): [Signal, () => void] +``` + +Bidirectional: WritableState → Signal (forward) and Signal → WritableState (reverse diff patch), with shallow-equal guard to prevent feedback loops. + +### `signalToState` — Signal → WritableState + +```ts +export function signalToState( + signal: ReadonlySignal, + state: WritableState, + map: (value: S) => T extends object ? Partial : T +): () => void +``` + +Both are **temporary scaffolding**. When all reactors are migrated and `WritableState` +is retired, both helpers are deleted. Do not treat them as permanent infrastructure. + +## Engine-Level Bridge + +`createPlaybackEngine` creates signal mirrors once and keeps them in sync for the engine +lifetime. Migrated reactors receive the signal versions; un-migrated ones continue +receiving `WritableState`. + +```ts +// In createPlaybackEngine, after state/owners are created: +const [stateSignal, cleanupStateSignal] = stateToSignal(state); +const [ownersSignal, cleanupOwnersSignal] = stateToSignal(owners); +``` + +Exposed on `PlaybackEngine` for testing and inspection: + +```ts +export interface PlaybackEngine { + state: WritableState; // authoritative (unchanged) + owners: WritableState; // authoritative (unchanged) + stateSignal: Signal; // mirror for migrated reactors + ownersSignal: Signal; // mirror for migrated reactors + events: EventStream; + destroy: () => void; +} +``` + +Bridge cleanups go into `destroy()`: + +```ts +destroy: () => { + cleanups.forEach(c => c()); + cleanupStateSignal(); + cleanupOwnersSignal(); + destroyVttParser(); +} +``` + +## Reactor Migration Patterns + +### Signature change + +```ts +// Before +function myReactor({ state, owners }: { + state: WritableState; + owners: WritableState; +}): () => void + +// After +function myReactor({ state, owners }: { + state: Signal; + owners: Signal; +}): () => void +``` + +### Translation table + +All signal primitives are imported from `core/signals/primitives` — do **not** import from `signal-polyfill` directly. Only `core/signals/effect.ts` and `core/signals/primitives.ts` touch the polyfill. + +| Before (WritableState) | After (Signals) | +|---|---| +| `state.subscribe(cb)` | `effect(() => { const s = state.get(); ... })` | +| `owners.subscribe(cb)` | `effect(() => { const o = owners.get(); ... })` | +| `combineLatest([state, owners]).subscribe(cb)` | `effect(() => { state.get(); owners.get(); ... })` — reads both in one effect | +| `subscribe(selector, listener, { equalityFn })` | `computed(() => selector(state.get()), { equals: equalityFn })` + `effect()` | +| `createState(x)` (local intermediate state) | `signal(x)` | +| `state.patch(...)` from inside listener | same — call directly from inside `effect()` | +| `combineLatest` + `@@INITIALIZE@@` bootstrap | gone — `computed()` evaluates lazily, no bootstrap needed | + +### Async effects (basic) + +Effects must be synchronous, but many reactors kick off async work. The pattern: the +effect detects the condition synchronously and **captures a snapshot** of current values, +then passes the snapshot to an async function. The async function does not read from +signals — it uses only the captured snapshot. + +```ts +let running = false; +let abort: AbortController | null = null; + +const cleanupEffect = effect(() => { + const s = stateSignal.get(); + const o = ownersSignal.get(); + if (!shouldRun(s, o) || running) return; + + running = true; + abort = new AbortController(); + runAsync(s, o, abort.signal).finally(() => { running = false; }); +}); + +return () => { + cleanupEffect(); + abort?.abort(); +}; +``` + +### Signal.Computed conditions + nested effects (preferred for multi-phase setup) + +Introduced in the `setupMediaSource` rewrite. Rather than a single effect with an async +body, derive each condition as a `Signal.Computed` and use a **nested effect** to sequence +dependent phases. This keeps the signal graph explicit and avoids async/flag complexity. + +Use this pattern when a reactor has: +- Distinct precondition phase (can we even start?) +- Distinct ready phase (has a side-effect completed and we can proceed?) +- A "don't re-run" guard based on observable DOM state rather than an internal flag + +```ts +// Preconditions as computed nodes +const canSetupSignal = computed(() => !!owners.get().mediaElement && !!state.get().presentation?.url); +// DOM-observable "already done" guard (avoids internal boolean flag) +const shouldSetupSignal = computed(() => !owners.get().mediaElement?.src); + +const cleanupEffect = effect(() => { + if (!canSetupSignal.get() || !shouldSetupSignal.get()) return; + + // Phase 1: synchronous setup + const mediaElement = owners.get().mediaElement as HTMLMediaElement; + const mediaSource = createMediaSource({ preferManaged: true }); + const mediaSourceReadyState = observeMediaSourceReadyState(mediaSource, abortController.signal); + attachMediaSource(mediaSource, mediaElement); + + // Phase 2: nested effect waits for async condition before writing back + const cleanupInner = effect(() => { + if (mediaSourceReadyState.get() !== 'open') return; + owners.set({ ...owners.get(), mediaSource, mediaSourceReadyState }); + }); + + return () => { cleanupInner(); }; +}); +``` + +Key properties: +- Outer effect re-runs when preconditions change; inner effect re-runs when `readyState` changes +- No `settingUp` flag — `shouldSetupSignal` reads observable DOM state (`mediaElement.src`) +- `AbortController` passed to `observeMediaSourceReadyState` for DOM listener cleanup +- Inner cleanup returned from outer effect body (cleaned up on outer re-run or teardown) + +### Local intermediate state + +`createState` used for local derived values within a reactor becomes `computed()` +(if purely derived) or `signal()` (if independently writable): + +```ts +// Before +const segmentsCanLoad = createState(false); +combineLatest([state, segmentLoader]).subscribe(([s, loader]) => { + segmentsCanLoad.patch(isResolvedTrack(getSelectedTrack(s, type)) && !!loader); +}); + +// After — no subscription, no local patch: just a computed +const segmentsCanLoad = computed(() => + isResolvedTrack(getSelectedTrack(stateSignal.get(), type)) && !!segmentLoaderSignal.get() +); +``` + +## MediaSource Reactivity + +`mediaSource.readyState` changes via DOM events (`sourceopen`, `sourceended`, +`sourceclose`), which are invisible to the signal graph without explicit bridging. + +### `observeMediaSourceReadyState` (in `dom/media/mediasource-setup.ts`) + +```ts +export function observeMediaSourceReadyState( + mediaSource: MediaSource, + abortSignal: AbortSignal +): ReadonlySignal { + const readyState = signal(mediaSource.readyState); + const update = () => readyState.set(mediaSource.readyState); + const options = { signal }; + mediaSource.addEventListener('sourceopen', update, options); + mediaSource.addEventListener('sourceended', update, options); + mediaSource.addEventListener('sourceclose', update, options); + return readyState; +} +``` + +Returns `ReadonlySignal` (no cleanup needed — `AbortSignal` removes listeners). + +### `mediaSourceReadyState` in owners + +Stored alongside `mediaSource` in `MediaSourceOwners` (and propagated to +`PlaybackEngineOwners`). Consumers use: + +```ts +const readyState = owners.mediaSourceReadyState?.get() ?? owners.mediaSource?.readyState; +``` + +The `??` fallback preserves tests that don't provide the signal. Currently used by: +- `shouldEndStream` in `end-of-stream.ts` +- `shouldUpdateDuration` in `update-duration.ts` + +This is the `SourceBufferActor.snapshot` pattern applied to `readyState` — a signal +nested inside an owners object, tracked transitively by `Signal.Computed`/`effect`. + +## Migration Order + +### ✅ Step 1 — Bridge utilities + engine wiring (infrastructure, no reactor changes) + +- **New**: `core/signals/bridge.ts` — `stateToSignal` + `signalToState` +- **Change**: `playback-engine/engine.ts` — create mirrors, expose on `PlaybackEngine` +- All existing tests pass unchanged — un-migrated reactors still receive `WritableState` + +### ✅ Step 2 — `endOfStream` + +- **Why first**: already uses signals internally with its own 4-line bridge; this just + moves that bridge to the engine level and simplifies the function signature +- **Write-back**: none — `endOfStream` does not patch state +- **Complexity**: low +- **Note**: updated to use `mediaSourceReadyState?.get() ?? mediaSource?.readyState` pattern + +### ✅ Step 3 — `trackCurrentTime` + +- **Why next**: simplest full migration; single `owners.subscribe()` → single `effect()` +- **Detail**: `owners` → `Signal.ReadonlyState`; `state` stays `WritableState` (write-back). + `lastMediaElement` guard still works in local scope. DOM event listeners unchanged. +- **Write-back**: `state.patch({ currentTime })` — direct call from inside effect, no + bridge needed +- **Complexity**: low + +### ✅ Step 4 — `updateDuration` + +- **Why**: proves the async-in-effect pattern (`combineLatest + async callback` → + `effect()` that detects condition then calls async helper with captured snapshot) +- **Write-back**: writes `mediaSource.duration` directly, not to state — no bridge needed +- **Complexity**: medium +- **Note**: updated to use `mediaSourceReadyState?.get() ?? mediaSource?.readyState` pattern + +### ✅ Step 5 — `setupMediaSource` + +- **What changed**: user rewrote to use the **Signal.Computed conditions + nested effects** + pattern (see above). Discarded: `settingUp` flag, `waitForSourceOpen`, async body. +- **New additions**: + - `observeMediaSourceReadyState` added to `mediasource-setup.ts` + - `mediaSourceReadyState: Signal.ReadonlyState` added to `MediaSourceOwners` + - `waitForSourceOpen` removed from source, barrel export, and tests +- **Write-back**: `owners.set({ ...owners.get(), mediaSource, mediaSourceReadyState })` from + inner nested effect when `readyState === 'open'` +- **Complexity**: medium → restructured as multi-phase with nested effect + +### ✅ Step 6 — `loadSegments` + +- **Why last**: most complex; has selector+equality (`loadingInputsEq`), two local + `createState` instances (`segmentsCanLoad`, `segmentLoader`), and a write-back to + `state.bandwidthState` that `switchQuality` depends on +- **Write-back**: `throughput` local signal → `state.bandwidthState` via `signalToState` + bridge (needed until `switchQuality` is also migrated; see comment in + `createTrackedFetch`) +- **Complexity**: high + +### ✅ Step 7 — `setupSourceBuffer` + +- **What changed**: replaced `combineLatest + subscribe + setupDone` with `computed() + effect()`. + Track types derived from `presentation.selectionSets` via `presentationTypesSignal` — no + hardcoded `['video', 'audio']`. Removed `canSetupBuffer` and `shouldSetupBuffer` helpers. + Coordination guarantee (all buffers created synchronously) preserved in effect body. +- **Write-back**: `owners.set(Object.assign({}, o, patch) as O)` from effect +- **Complexity**: medium + +### ✅ Step 8 — `trackPlaybackRate` + +- **What changed**: single effect with cleanup return — `return listen(mediaElement, 'ratechange', ...)`. + Removed `canTrackPlaybackRate` helper. Uses named `computed()` nodes for `mediaElement` and guard. +- **Write-back**: `state.patch({ playbackRate })` +- **Complexity**: low +- **Note**: this migration validated that `effect()` cleanup return values are called before re-run + and on disposal (now implemented, matching Preact/Maverick/Svelte 5). + +### ✅ Step 9 — `syncTextTrackModes` + +- **What changed**: `combineLatest + subscribe` → `effect()` with named `computed()` nodes for + `textTracks` and `selectedTextTrackId`. Guard checks `textTracks.size` only — not `selectedTextTrackId` + (so deselection still hides all tracks). Removed `canSyncTextTrackModes` helper. +- **Write-back**: none — writes DOM `.mode` directly +- **Complexity**: low + +### ✅ Step 10 — `setupTextTracks` + +- **What changed**: `combineLatest + subscribe + hasSetup` → `computed() + effect()`. Custom `equals` + on `modelTextTracksSignal` prevents re-runs when unrelated state (e.g. `selectedTextTrackId`) changes + but tracks haven't changed. `shouldSetup = !owners.get().textTracks` replaces `hasSetup` flag. + Cleanup uses `owners.get().textTracks?.forEach(el => el.remove())` at teardown — no closure array. +- **Write-back**: `owners.set({ ...owners.get(), textTracks: trackMap })` from effect +- **Complexity**: low–medium +- **Key pattern**: for one-shot effects, DOM cleanup belongs in the **outer disposal function**, not + returned from the effect body (which runs on every re-run). Return-from-effect is for repeatable + cleanup (e.g. `listen()` in `trackPlaybackRate`). + +### ✅ Side-quest — signals primitives module + +- **New**: `core/signals/primitives.ts` — single file that imports from `signal-polyfill` and + re-exports factory functions + types: `signal()`, `computed()`, `Signal`, `Computed`, + `ReadonlySignal`, `SignalOptions`. +- **Rule**: only `core/signals/effect.ts` and `core/signals/primitives.ts` may import from + `signal-polyfill`. All other files use `core/signals/primitives`. +- **Removed**: `declare module 'signal-polyfill'` augmentation for `ReadonlyState` — replaced + by `ReadonlySignal` exported from `primitives.ts`. +- **Bundle impact**: zero — factory wrappers are just thin callsites, tree-shaken away. + +### ✅ Step 11 — `trackPlaybackInitiated` + +- **Why**: dual subscriptions + event dispatch bridge; interesting because it responds to + two independent signals firing in sequence +- **Write-back**: `state.set({ ...current, playbackInitiated })` via version-counter computed +- **Complexity**: medium → explored two approaches; both committed for reference +- **Approach A — local signal + merge effect** (commit `fd5bd911`): + - A local `signal` is written by the reset effect (false) and the + play listener (true). A third merge effect reads it and patches state at merge time, + reading `state.get()` fresh so the spread is not stale. + - `undefined` sentinel suppresses the merge effect on startup. + - Hit an async bridge race condition: the forward bridge fires asynchronously, so a + `true` written by the play listener could be overwritten by a stale `false` from the + bridge flush before the merge effect ran. +- **Approach B — version-counter computed** (commit `a1fe1557`, current HEAD): + - `resetVersion` bumps on URL change or element swap; `playVersion` is set to + `resetVersion.get()` when play fires. `playbackInitiated = computed(() => playVersion.get() >= resetVersion.get())`. + - Pure computed — no writes on init, no race condition possible. + - Merge effect only writes on genuine transitions (idempotent guard preserved). +- **Key pattern (version counters)**: encode "last write wins" / merge-of-two-streams + semantics as a counter pair. The computed is pure; the only imperative writes are the + counter increments, which happen in well-defined reactive contexts. + +### ✅ Step 12 — `syncSelectedTextTrackFromDom` + +- **Write-back**: `state.set({ ...state.get(), selectedTextTrackId })` +- **Complexity**: medium + +### ✅ Step 13 — `loadTextTrackCues` + +- **Complexity**: high — async task orchestration per text track with per-track abort + +### ✅ Step 14 — Full engine migration (WritableState retired) + +The engine itself was migrated from `createState` (WritableState) to +`signal` (TC39 Signal). All bridge utilities, signal mirrors, and +`@@INITIALIZE@@` dispatch were removed. + +- **`PlaybackEngine` interface** now exposes `state: Signal` and + `owners: Signal` directly — `WritableState` is gone. +- **`stateToSignal` bridge** removed — no longer needed. +- **`EventStream`/`combineLatest`/`@@INITIALIZE@@`** fully eliminated from engine and all + orchestration functions. +- **Core features** (`resolvePresentation`, `resolveTrack`, `selectTracks`, + `resolvePresentation`, `switchQuality`) all migrated to accept `Signal` arguments. +- **`switchQuality` + `initialBandwidth`**: engine now passes + `{ defaultBandwidth: config.initialBandwidth }` to `switchQuality` when `initialBandwidth` + is configured, so both initial track selection and ABR use the same bandwidth estimate + at cold start. +- **`PlaybackEngineState.preload`** narrowed from `string` to `'auto' | 'metadata' | 'none'` + to satisfy `PresentationState` type compatibility. +- **All tests** updated to use `state.get()` / `state.set()` / `owners.get()` / `owners.set()`. + +## Exit Criteria — Met + +All reactors and the engine itself use TC39 Signals natively. `WritableState` and +`combineLatest` are retired from the playback engine. `PlaybackEngine.state` and +`.owners` are `Signal`. The bridge module (`core/signals/bridge.ts`) can be removed +when no other consumers remain. + +--- + +## Reactor Audit Checklist + +Steps 7–13 and the core feature migrations were done in bulk/one-shot subagent refactors +and warrant a careful manual pass. Key things to verify in each reactor: + +**Effect dependency reads** — signals must be read synchronously inside the effect body +to be tracked. Any `state.get()` / `owners.get()` inside an async callback is NOT tracked. + +**Cleanup pattern** — return cleanup from the effect body only for *repeatable* teardown +(e.g. `listen()` called every re-run). One-shot DOM cleanup (e.g. removing `` elements) +belongs in the *outer* `return () => {}` disposal, not inside the effect body. + +**Guard conditions** — preconditions that prevent re-running expensive work should be +`computed()` nodes or early-return guards at the top of the effect. An effect that doesn't +guard correctly will re-run on every unrelated signal change. + +**Idempotency** — with TC39 Signals, effects re-run whenever any source becomes potentially +stale — even if the computed value didn't change. Each reactor should be harmless on redundant +re-runs, or use a `computed()` with custom `equals` to suppress them. + +**Stale snapshot risk** — anywhere that captures `const x = state.get()` before an `await` +and then uses `x` post-await to write back. The snapshot may be stale by the time the write +happens. Prefer reading fresh inside `update()` or inside a nested effect. + +### Per-reactor notes + +| Reactor | File | Audit focus | +|---|---|---| +| `setupSourceBuffer` | `dom/features/setup-sourcebuffer.ts` | `presentationTypesSignal` equality; owners write-back via `Object.assign` | +| `syncTextTrackModes` | `dom/features/sync-text-track-modes.ts` | Effect runs on every `textTracks` change — verify it's idempotent | +| `setupTextTracks` | `dom/features/setup-text-tracks.ts` | `modelTextTracksSignal` custom equals; one-shot cleanup location | +| `trackPlaybackInitiated` | `dom/features/track-playback-initiated.ts` | Version-counter pair; merge-effect guard; no writes on startup | +| `syncSelectedTextTrackFromDom` | `dom/features/sync-selected-text-track-from-dom.ts` | DOM event listeners inside effect; `textBufferState` path | +| `loadTextTrackCues` | `dom/features/load-text-track-cues.ts` | Per-track abort controllers; async snapshot discipline; cleanup on track removal | +| `loadSegments` | `dom/features/load-segments.ts` | Seek-abort logic; `bandwidthState` write-back; `computed()` equality for loading inputs | +| `resolvePresentation` | `core/features/resolve-presentation.ts` | `syncPreloadAttribute` DOM listener cleanup; `shouldResolve` guard | +| `selectVideoTrack` | `core/features/select-tracks.ts` | `canSelectTrack` / `shouldSelectTrack` guards; no re-select on unrelated changes | +| `switchQuality` | `core/features/quality-switching.ts` | `defaultBandwidth` plumbing; no upgrade loop when bandwidth is stable | diff --git a/.claude/plans/spf/signals-poc.md b/.claude/plans/spf/signals-poc.md new file mode 100644 index 00000000..feb9e7cf --- /dev/null +++ b/.claude/plans/spf/signals-poc.md @@ -0,0 +1,262 @@ +--- +status: in-progress +branch: poc/spf-signals +--- + +# SPF Signals POC — SourceBufferActor Snapshot as a Signal + +> **This is an exploratory spike, not a committed implementation plan.** The goal is to get a feel +> for what a signals-based architecture looks like in practice, validate assumptions from +> `internal/design/spf/primitives.md` §5, and surface concrete answers to the open questions +> there. Nothing here is final. Findings should feed back into the primitives design doc. + +## Background + +The current SPF codebase uses a hand-rolled `createState` container (batched, push-based +subscribable) for both top-level state/owners and actor snapshots. `internal/design/spf/primitives.md` +identifies Observable State as the most consequential open design question and explicitly calls out +the TC39 Signals polyfill as worth evaluating. + +The specific pain point motivating this spike is in `end-of-stream.ts`: re-evaluating `shouldEndStream` +requires subscribing to two independent push-based systems (`state`/`owners` changes AND actor +snapshot changes). When `owners` changes and actors appear or are replaced, subscriptions must be +manually torn down and rebuilt. This `activeActorUnsubs` machinery is ~30 lines of bookkeeping that +would disappear if actor snapshots were signals — because a `computed()` transitively tracks through +`owners → actor.snapshotSignal` automatically, re-tracking when the actor reference changes. + +## Library Choice + +**`signal-polyfill`** (TC39 Stage 1 proposal reference implementation). + +Rationale: +- Directly answers the open question from `primitives.md`: "is the TC39 polyfill viable?" +- Has `equals` option on both `Signal.State` and `Signal.Computed` — matches SPF's per-selector + custom equality use cases +- `Signal.subtle.untrack()` and batching (`beginBatch`/`afterBatch`) are built-in +- Low-level `Watcher` API forces explicit scheduling decisions (a feature, not a bug — see below) +- No framework baggage + +Missing from the polyfill but trivial to build: a usable `effect(fn) → cleanup` wrapper (~20 lines +over `Watcher`). This is intentional: writing the scheduler makes the open scheduling question +concrete rather than hiding it behind library defaults. + +## Scope + +### In scope + +- `packages/spf/src/core/signals/effect.ts` — new file; minimal `effect()` helper +- `packages/spf/src/dom/media/source-buffer-actor.ts` — internal `createState` → `Signal.State`; + expose `snapshotSignal` on the returned actor; keep `snapshot` getter and `subscribe()` as bridges +- `packages/spf/src/dom/features/end-of-stream.ts` — add `WritableState → Signal` bridge for + `owners`/`state`; collapse `activeActorUnsubs` re-subscription machinery into a single + `computed()`; drive evaluation with `effect()` + +### Explicitly out of scope + +- `state`, `owners`, and `WritableState` — untouched; they bridge into signals at the consumer + call site, not at the source +- `load-segments.ts`, `segment-loader-actor.ts` — untouched (beyond call-site `.snapshot.get()` fixes) +- `engine.ts` — untouched +- Other actors — only `SourceBufferActor` migrated; `SignalActor` interface is defined for future use + +## Implementation Plan + +### 1. Install `signal-polyfill` + +Add to `packages/spf/package.json` dependencies. + +### 2. `core/signals/effect.ts` (new) + +Minimal `Watcher`-based `effect()`: + +```ts +import { Signal } from 'signal-polyfill'; + +const pending = new Set>(); + +const watcher = new Signal.subtle.Watcher(() => { + // Scheduling decision: queueMicrotask mirrors current createState.patch() default. + // Change to synchronous if tests reveal timing issues with flush() removal. + queueMicrotask(flush); +}); + +function flush() { + for (const c of watcher.getPending()) pending.add(c as Signal.Computed); + watcher.watch(); // re-arm + for (const c of pending) { pending.delete(c); c.get(); } +} + +export function effect(fn: () => void): () => void { + const c = new Signal.Computed(fn); + watcher.watch(c); + c.get(); // initial run + return () => watcher.unwatch(c); +} +``` + +The comment on scheduling is the key decision surface this file exposes. + +### 3. `source-buffer-actor.ts` — internal state → Signal; `SignalActor` interface + +Replace `createState` with `Signal.State`. All `state.patch({ ... })` +calls become `snapshotSignal.set({ ...snapshotSignal.get(), ... })`. + +The `state.flush()` calls are **dropped** — `Signal.State` notifies via microtask by default, +matching the prior `createState.patch()` behavior. + +`SourceBufferActor` extends `SignalActor` (defined in `actor.ts`). The old `Actor` interface +(with a plain `snapshot` value and `subscribe()`) is removed from this actor. `snapshot` IS the +signal — consumers call `.get()` and use `effect()`/`computed()` directly: + +```ts +export interface SignalActor { + readonly snapshot: Signal.ReadonlyState>; + destroy(): void; +} +``` + +The `subscribe()` method and plain-value `snapshot` getter are gone. Tests were updated to call +`actor.snapshot.get()` and `effect(() => actor.snapshot.get())` directly. + +**`Signal.ReadonlyState` module augmentation** (in `core/signals/effect.ts`): + +```ts +declare module 'signal-polyfill' { + namespace Signal { + type ReadonlyState = Omit, 'set'>; + } +} +``` + +Augmenting the `Signal` namespace keeps the type ergonomically consistent with `Signal.State` and +`Signal.Computed`. The internal `snapshotSignal` is `Signal.State` (writable); the public getter +returns `Signal.ReadonlyState` (`.set()` omitted by TypeScript), preventing consumers from writing +actor-internal state. + +### 4. `end-of-stream.ts` — bridge + computed + effect + +Add a `WritableState → Signal` bridge at the top of `endOfStream()`: + +```ts +const stateSignal = new Signal.State(state.current); +const ownersSignal = new Signal.State(owners.current); +const unsubState = state.subscribe(v => stateSignal.set(v)); +const unsubOwners = owners.subscribe(v => ownersSignal.set(v)); +``` + +Replace the `combineLatest` subscription + `activeActorUnsubs` machinery with a `computed()` that +tracks through `ownersSignal → actor.snapshotSignal` transitively: + +```ts +const shouldEnd = new Signal.Computed(() => shouldEndStream(stateSignal.get(), ownersSignal.get())); +``` + +`shouldEndStream` reads `actor.snapshot.status` and `actor.snapshot.context.segments` synchronously. +Those now delegate to `snapshotSignal.get()` inside the computed, so the computed auto-tracks the +actor snapshot without any explicit wiring. When `owners` changes and points to a new actor, the +computed re-tracks to the new actor's signal on next evaluation. + +Drive the side effect with `effect()`: + +```ts +let hasEnded = false; +const cleanupEffect = effect(() => { + if (!shouldEnd.get()) return; + if (hasEnded && ownersSignal.get().mediaSource?.readyState !== 'open') return; + hasEnded = true; + endOfStreamTask({ currentOwners: ownersSignal.get() }, {}).catch(console.error); +}); +``` + +Cleanup: + +```ts +return () => { + cleanupEffect(); + unsubState(); + unsubOwners(); +}; +``` + +The `destroyed` flag, `activeActorUnsubs` array, and the separate owners subscription for actor +re-registration are all gone. + +`waitForSourceBuffersReady` is kept as-is for now — it's used inside the async `endOfStreamTask` +and can be revisited later. + +## Open Questions This Spike Answers + +**1. Does transitive tracking work for actors that appear and disappear?** + +When `owners` gets a new `videoBufferActor`, the computed re-evaluates and tracks the new actor's +`snapshotSignal`. When an actor is removed (`undefined`), optional chaining short-circuits. If this +works cleanly in tests, it validates the core premise. + +**2. Does dropping `flush()` break ordering?** + +`applyResult` currently calls `state.flush()` to fire `end-of-stream`'s subscriber synchronously +after a task completes. With microtask-deferred effects, it fires on the next tick instead. +The hypothesis: this is fine since `endOfStreamTask` is async anyway. Tests will confirm or deny. +If synchronous notification turns out to be necessary, the `effect()` scheduler can be changed to +call `flush()` synchronously in the `Watcher` notify callback. + +**3. Does the `WritableState → Signal` bridge feel natural?** + +Two subscribe lines at the call site. Explicit and localized. If this is the right pattern, it +scales to other features as they migrate incrementally. + +**4. Does the `subscribe()` bridge on the actor behave correctly?** + +`waitForSourceBuffersReady` still uses `actor.subscribe()`. The bridge (an `effect` wrapping the +listener) exercises the bridge under the async code path. + +## Findings + +### `effect()` fires immediately — no separate "immediate fire" needed + +The `subscribe()` bridge initially called `listener(snapshotSignal.get())` explicitly before +returning `effect(() => listener(...))`. This caused a double-fire because `effect()` itself runs +synchronously on creation (calls `c.get()` immediately). Fix: remove the explicit call; the initial +effect run delivers the current value. This means the "fires immediately with current value" +semantic is intrinsic to `effect()`, not something callers need to arrange separately. + +### Dropping `flush()` did not break any tests or smoke tests + +The original `applyResult`, `handleError`, `onPartialContext`, and `destroy` all called +`state.flush()` for synchronous subscriber notification. These were simply removed — `Signal.State` +notifies effects via microtask by default (same as `createState.patch()`). All 707 tests pass and +the `/spf-segment-loading/` sandbox runs end-to-end with full playback, ABR, and end-of-stream. + +### Transitive tracking through `owners → snapshotSignal` works + +The `activeActorUnsubs` re-subscription machinery (~30 lines) in `end-of-stream.ts` was +replaced by a single `Signal.Computed`. When `owners` changes and introduces a new actor, +the computed re-evaluates and automatically tracks the new actor's `snapshotSignal`. No manual +teardown or re-registration of actor subscriptions required. + +### The `WritableState → Signal` bridge is minimal + +Two lines of setup per call site: +```ts +const stateSignal = new Signal.State(state.current); +const unsubState = state.subscribe(v => stateSignal.set(v)); +``` +This is explicit, localized, and easy to follow. It scales cleanly to incremental migration — +each feature can bridge independently without any central change. + +### `snapshot` IS the signal — no bridge needed + +The original plan preserved `snapshot` (plain value getter) and `subscribe()` as bridges so +existing callers wouldn't need to change. In practice, the call-site changes are mechanical +(`.snapshot.context` → `.snapshot.get().context`) and the bridge adds indirection with no payoff. +The cleaner design: `snapshot` IS the signal. Tests, sandbox, and all callers updated directly. +The `SignalActor` interface formalises this: `snapshot: Signal.ReadonlyState<...>`, no `subscribe`. + +`Signal.ReadonlyState` augments the `Signal` namespace (declared in `effect.ts`) rather than +introducing a separate `ReadonlySignal` alias. This keeps the naming convention consistent with +`Signal.State` / `Signal.Computed` and makes the relationship to the polyfill obvious. + +### Pre-existing test noise unchanged + +5 unhandled fetch-rejection errors in the test suite predate this spike (confirmed by +stashing changes and re-running). All 707 tests pass before and after. diff --git a/packages/sandbox/templates/spf-segment-loading/main.ts b/packages/sandbox/templates/spf-segment-loading/main.ts index b3ed7aa3..bd6958e5 100644 --- a/packages/sandbox/templates/spf-segment-loading/main.ts +++ b/packages/sandbox/templates/spf-segment-loading/main.ts @@ -8,7 +8,8 @@ import '@app/styles.css'; // autoplay=true Start with autoplay enabled // preload=auto|metadata|none Initial preload mode -import { createPlaybackEngine } from '@videojs/spf/playback-engine'; +import type { PlaybackEngineState } from '@videojs/spf/playback-engine'; +import { createPlaybackEngine, effect } from '@videojs/spf/playback-engine'; // ── DOM refs ────────────────────────────────────────────────────────────────── const video = document.getElementById('video') as HTMLVideoElement; @@ -56,7 +57,7 @@ function formatBandwidth(bps: number): string { return `${Math.round(bps / 1000)} Kbps`; } -function getVideoTracks(state: ReturnType) { +function getVideoTracks(state: PlaybackEngineState) { return state.presentation?.selectionSets?.find((s) => s.type === 'video')?.switchingSets[0]?.tracks ?? []; } @@ -75,7 +76,7 @@ function updateShareUrl() { function updateNowPlayingQuality() { if (!engine) return; - const segments = engine.owners.current.videoBufferActor?.snapshot.context.segments ?? []; + const segments = engine.owners.get().videoBufferActor?.snapshot.get().context.segments ?? []; const t = video.currentTime; const current = segments.find((s) => t >= s.startTime && t < s.startTime + s.duration); if (current?.trackBandwidth) { @@ -98,7 +99,7 @@ function correctedEstimate(estimate: number, totalWeight: number, halfLife: numb function updateThroughputDisplay() { if (!engine) return; - const bs = engine.state.current.bandwidthState; + const bs = engine.state.get().bandwidthState; if (!bs || bs.bytesSampled === 0) { throughputDiv.textContent = '📶 Throughput: no samples yet'; throughputDiv.className = ''; @@ -119,7 +120,7 @@ function updateThroughputDisplay() { function renderRenditionPicker() { if (!engine) return; - const state = engine.state.current; + const state = engine.state.get(); const tracks = getVideoTracks(state); const isManual = state.abrDisabled === true; @@ -143,7 +144,7 @@ function renderRenditionPicker() { enableBtn.textContent = 'Enable ABR'; enableBtn.addEventListener('click', () => { log('ABR re-enabled', 'success'); - engine.state.patch({ abrDisabled: false }); + engine.state.set({ ...engine.state.get(), abrDisabled: false }); }); statusRow.appendChild(enableBtn); } @@ -160,7 +161,7 @@ function renderRenditionPicker() { btn.title = track.id; btn.addEventListener('click', () => { log(`Manual rendition select: ${formatBandwidth(track.bandwidth)} (ABR disabled)`, 'warning'); - engine.state.patch({ selectedVideoTrackId: track.id, abrDisabled: true }); + engine.state.set({ ...engine.state.get(), selectedVideoTrackId: track.id, abrDisabled: true }); }); renditionButtonsDiv.appendChild(btn); } @@ -168,7 +169,7 @@ function renderRenditionPicker() { function renderResolutionStatus() { if (!engine) return; - const state = engine.state.current; + const state = engine.state.get(); const tracks = getVideoTracks(state); if (tracks.length === 0) { @@ -193,8 +194,8 @@ function inspectState() { stateDiv.innerHTML = '

State Inspector

Engine not initialized
'; return; } - const state = engine.state.current; - const owners = engine.owners.current; + const state = engine.state.get(); + const owners = engine.owners.get(); const videoBufferRanges = owners.videoBuffer ? Array.from( @@ -246,8 +247,8 @@ function inspectState() {
readyState: ${owners.mediaSource?.readyState ?? 'N/A'}

Buffer Model (actor context)

-
Video segments loaded: ${owners.videoBufferActor?.snapshot.context.segments.length ?? 0}
-
Audio segments loaded: ${owners.audioBufferActor?.snapshot.context.segments.length ?? 0}
+
Video segments loaded: ${owners.videoBufferActor?.snapshot.get().context.segments.length ?? 0}
+
Audio segments loaded: ${owners.audioBufferActor?.snapshot.get().context.segments.length ?? 0}

Video Element State

readyState: ${video.readyState}
@@ -259,42 +260,50 @@ function inspectState() { `; } -// ── Engine ──────────────────────────────────────────────────────────────────── +// ── Engine lifecycle ─────────────────────────────────────────────────────────── log('=== SPF Segment Loading POC Test ==='); log(`Stream: ${INITIAL_SRC}`); let engine: ReturnType; +let cleanupEffects: () => void = () => {}; + +function startEngine(src: string) { + cleanupEffects(); + if (engine) engine.destroy(); -try { engine = createPlaybackEngine({ initialBandwidth: 1_000_000 }); - - log('✓ Engine created', 'success'); (window as any).engine = engine; - (window as any).state = () => engine.state.current; - (window as any).owners = () => engine.owners.current; - log('Exposed as window.engine / window.state() / window.owners()'); + (window as any).state = () => engine.state.get(); + (window as any).owners = () => engine.owners.get(); - // ── State subscriptions ────────────────────────────────────────────────── + // ── Reactive effects ─────────────────────────────────────────────────────── + + // prev/prevOwners track one-time transitions for logging purposes. + // They are reset on each startEngine call so a new source logs correctly. const prev = { hasPresentation: false, selectedVideoTrackId: undefined as string | undefined, selectedAudioTrackId: undefined as string | undefined, selectedTextTrackId: undefined as string | undefined, }; + const prevOwners = { hasMediaSource: false, hasVideoBuffer: false, hasAudioBuffer: false }; + + // State logger + auto-select first text track + const stopStateLogger = effect(() => { + const state = engine.state.get(); - engine.state.subscribe((state) => { if (state.presentation && !prev.hasPresentation) { log('Presentation resolved'); prev.hasPresentation = true; } - // Auto-select first text track + // Auto-select first text track when presentation arrives if (state.presentation && !state.selectedTextTrackId && state.presentation.selectionSets) { const textSet = state.presentation.selectionSets.find((s) => s.type === 'text'); const firstText = textSet?.switchingSets?.[0]?.tracks?.[0]; if (firstText) { log(`Auto-selecting text track: ${firstText.id}`); - engine.state.patch({ selectedTextTrackId: firstText.id }); + engine.state.set({ ...engine.state.get(), selectedTextTrackId: firstText.id }); } } @@ -313,16 +322,18 @@ try { } }); - // Throughput display — update whenever bandwidthState changes - engine.state.subscribe( - (s) => s.bandwidthState, - () => updateThroughputDisplay() - ); + // Throughput + rendition picker + resolution status — re-render on any state change + const stopStateUI = effect(() => { + engine.state.get(); // track all state changes + updateThroughputDisplay(); + renderRenditionPicker(); + renderResolutionStatus(); + }); - // ── Owners subscriptions ───────────────────────────────────────────────── - const prevOwners = { hasMediaSource: false, hasVideoBuffer: false, hasAudioBuffer: false }; + // Owners logger + const stopOwnersLogger = effect(() => { + const owners = engine.owners.get(); - engine.owners.subscribe((owners) => { if (owners.mediaSource && !prevOwners.hasMediaSource) { log(`MediaSource created: ${owners.mediaSource.readyState}`, 'success'); prevOwners.hasMediaSource = true; @@ -341,7 +352,7 @@ try { }; owners.videoBuffer.addEventListener('updateend', () => { - const buf = owners.videoBuffer; + const buf = engine.owners.get().videoBuffer; if (!buf) return; const ranges: string[] = []; for (let i = 0; i < buf.buffered.length; i++) { @@ -364,7 +375,7 @@ try { }; owners.audioBuffer.addEventListener('updateend', () => { - const buf = owners.audioBuffer; + const buf = engine.owners.get().audioBuffer; if (!buf) return; const ranges: string[] = []; for (let i = 0; i < buf.buffered.length; i++) { @@ -375,39 +386,33 @@ try { } }); - // Re-render rendition picker when selection or ABR mode changes - engine.state.subscribe( - (s) => `${s.selectedVideoTrackId}|${s.abrDisabled}`, - () => renderRenditionPicker() - ); + cleanupEffects = () => { + stopStateLogger(); + stopStateUI(); + stopOwnersLogger(); + }; - // Re-render both panels when presentation changes - engine.state.subscribe( - (s) => s.presentation, - () => { - renderRenditionPicker(); - renderResolutionStatus(); - } - ); + log('✓ Engine created', 'success'); + log('Exposed as window.engine / window.state() / window.owners()'); + log('✓ Reactive effects active', 'success'); - log('✓ State subscriptions active', 'success'); - - // ── Wire media element ─────────────────────────────────────────────────── - video.muted = INITIAL_MUTED; - video.autoplay = INITIAL_AUTOPLAY; - // Set preload on the element BEFORE patching owners so syncPreloadAttribute + // ── Wire media element ────────────────────────────────────────────────────── + // Set preload on the element BEFORE wiring owners so syncPreloadAttribute // reads the correct value rather than the hardcoded "none" from the HTML. - video.preload = INITIAL_PRELOAD; - - engine.owners.patch({ mediaElement: video }); - engine.state.patch({ - presentation: { url: INITIAL_SRC }, - }); + video.preload = preloadSelect.value as 'auto' | 'metadata' | 'none'; + engine.owners.set({ mediaElement: video }); + engine.state.set({ ...engine.state.get(), presentation: { url: src } }); log('✓ Orchestration started', 'success'); // Auto-inspect periodically setInterval(inspectState, 3000); +} + +try { + video.muted = INITIAL_MUTED; + video.autoplay = INITIAL_AUTOPLAY; + startEngine(INITIAL_SRC); } catch (error) { log(`✗ Error creating engine: ${(error as Error).message}`, 'error'); console.error(error); @@ -433,13 +438,7 @@ setSrcBtn.addEventListener('click', () => { const url = srcInput.value.trim(); if (!url) return; log(`Setting src: ${url}`, 'info'); - engine.state.patch({ - presentation: { url }, - selectedVideoTrackId: undefined, - selectedAudioTrackId: undefined, - selectedTextTrackId: undefined, - abrDisabled: false, - }); + startEngine(url); updateShareUrl(); }); @@ -459,7 +458,7 @@ autoplayToggle.addEventListener('change', () => { preloadSelect.addEventListener('change', () => { const value = preloadSelect.value as 'auto' | 'metadata' | 'none'; - engine.state.patch({ preload: value }); + engine.state.set({ ...engine.state.get(), preload: value }); log(`Preload: ${value}`); updateShareUrl(); }); diff --git a/packages/spf/package.json b/packages/spf/package.json index f9bf65ec..d19bcc2e 100644 --- a/packages/spf/package.json +++ b/packages/spf/package.json @@ -50,7 +50,8 @@ "size:all": "node scripts/measure-size.js --all" }, "dependencies": { - "@videojs/utils": "workspace:*" + "@videojs/utils": "workspace:*", + "signal-polyfill": "^0.2.2" }, "devDependencies": { "@vitest/browser": "^4.1.0", diff --git a/packages/spf/src/all.ts b/packages/spf/src/all.ts index b0079012..98231380 100644 --- a/packages/spf/src/all.ts +++ b/packages/spf/src/all.ts @@ -10,21 +10,6 @@ // Re-export everything from public API export * from './index'; -// ============================================================================= -// State Management (O1) -// ============================================================================= - -export type { - SelectorListener, - SelectorOptions, - State, - StateConfig, - StateListener, - StateSelector, - WritableState, -} from './core/state/create-state'; -export { createState, isState } from './core/state/create-state'; - // ============================================================================= // HLS Parsing (P1, P2, P3) // ============================================================================= @@ -95,32 +80,15 @@ export { isCodecSupported, supportsManagedMediaSource, supportsMediaSource, - waitForSourceOpen, } from './dom/media/mediasource-setup'; export type { ResponseLike } from './dom/network/fetch'; export { fetchResolvable, getResponseText } from './dom/network/fetch'; -// ============================================================================= -// Events (F1) -// ============================================================================= - -export type { EventListener, EventStream } from './core/events/create-event-stream'; -export { createEventStream, isEventStream } from './core/events/create-event-stream'; - -// ============================================================================= -// Reactive Composition (F1) -// ============================================================================= - -export type { InferObservableValues, Observable } from './core/reactive/combine-latest'; -export { combineLatest } from './core/reactive/combine-latest'; - // ============================================================================= // Features (F1) // ============================================================================= export type { - PlatformOwners, - PresentationAction, PresentationState, UnresolvedPresentation, } from './core/features/resolve-presentation'; @@ -129,8 +97,9 @@ export { isUnresolved, resolvePresentation, shouldResolve, - syncPreloadAttribute, } from './core/features/resolve-presentation'; +export type { PlatformOwners } from './core/features/sync-preload-attribute'; +export { syncPreloadAttribute } from './core/features/sync-preload-attribute'; // ============================================================================= // Features (F9 - Quality Switching) diff --git a/packages/spf/src/core/actor.ts b/packages/spf/src/core/actor.ts index 327c8627..8f5c1a80 100644 --- a/packages/spf/src/core/actor.ts +++ b/packages/spf/src/core/actor.ts @@ -7,6 +7,8 @@ * holds arbitrary non-finite data. */ +import type { ReadonlySignal } from './signals/primitives'; + /** Complete actor snapshot: finite status + non-finite context. */ export interface ActorSnapshot { status: Status; @@ -29,3 +31,11 @@ export interface Actor { /** Tear down the actor. */ destroy(): void; } + +/** Generic actor interface: owns its snapshot and notifies observers. */ +export interface SignalActor { + /** Current snapshot. Readable and reactive; not writable by consumers. */ + readonly snapshot: ReadonlySignal>; + /** Tear down the actor. */ + destroy(): void; +} diff --git a/packages/spf/src/core/events/create-event-stream.ts b/packages/spf/src/core/events/create-event-stream.ts deleted file mode 100644 index 3796af1b..00000000 --- a/packages/spf/src/core/events/create-event-stream.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Minimal event stream with Observable-like shape. - * - * Simple Subject/Observable-like implementation for dispatching discrete events. - * Events are dispatched synchronously to all subscribers. - */ - -const EVENT_STREAM_SYMBOL = Symbol('@videojs/event-stream'); - -/** - * Event listener function. - */ -export type EventListener> = (event: T) => void; - -/** - * Event stream interface. - */ -export interface EventStream = { type: string }> { - [EVENT_STREAM_SYMBOL]: true; - dispatch(event: T): void; - subscribe(listener: EventListener): () => void; -} - -/** - * Creates a minimal event stream for dispatching discrete events. - * - * Events are dispatched synchronously to all subscribers. - * Conforms to Observable-like shape for future compatibility. - * - * Events must have a 'type' property for discriminated union type narrowing. - * - * @example - * ```ts - * type Action = { type: 'PLAY' } | { type: 'PAUSE' }; - * const events = createEventStream(); - * - * events.subscribe((action) => { - * if (action.type === 'PLAY') { - * // Type narrowed to { type: 'PLAY' } - * } - * }); - * - * events.dispatch({ type: 'PLAY' }); - * ``` - */ -export function createEventStream>(): EventStream { - const subscribers = new Set>(); - - return { - [EVENT_STREAM_SYMBOL]: true, - - dispatch(event: T): void { - // Snapshot subscribers to avoid notifying those added during dispatch - const current = Array.from(subscribers); - for (const listener of current) { - listener(event); - } - }, - - subscribe(listener: EventListener): () => void { - subscribers.add(listener); - return () => subscribers.delete(listener); - }, - }; -} - -/** - * Type guard to check if value is an EventStream. - */ -export function isEventStream(value: unknown): value is EventStream { - return typeof value === 'object' && value !== null && EVENT_STREAM_SYMBOL in value; -} diff --git a/packages/spf/src/core/events/tests/create-event-stream.test.ts b/packages/spf/src/core/events/tests/create-event-stream.test.ts deleted file mode 100644 index 84e74e6c..00000000 --- a/packages/spf/src/core/events/tests/create-event-stream.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { createEventStream, isEventStream } from '../create-event-stream'; - -describe('createEventStream', () => { - it('creates an event stream', () => { - const stream = createEventStream(); - - expect(stream).toBeDefined(); - expect(typeof stream.dispatch).toBe('function'); - expect(typeof stream.subscribe).toBe('function'); - }); - - it('notifies subscribers when event is dispatched', () => { - const stream = createEventStream<{ type: string; value: number }>(); - const listener = vi.fn(); - - stream.subscribe(listener); - stream.dispatch({ type: 'test', value: 42 }); - - expect(listener).toHaveBeenCalledOnce(); - expect(listener).toHaveBeenCalledWith({ type: 'test', value: 42 }); - }); - - it('notifies subscribers synchronously', () => { - const stream = createEventStream(); - let called = false; - - stream.subscribe(() => { - called = true; - }); - - stream.dispatch({ type: 'test' }); - - // Should be called immediately (synchronous) - expect(called).toBe(true); - }); - - it('notifies all subscribers', () => { - const stream = createEventStream(); - const listener1 = vi.fn(); - const listener2 = vi.fn(); - const listener3 = vi.fn(); - - stream.subscribe(listener1); - stream.subscribe(listener2); - stream.subscribe(listener3); - - stream.dispatch({ type: 'test' }); - - expect(listener1).toHaveBeenCalledOnce(); - expect(listener2).toHaveBeenCalledOnce(); - expect(listener3).toHaveBeenCalledOnce(); - }); - - it('returns unsubscribe function', () => { - const stream = createEventStream(); - const listener = vi.fn(); - - const unsubscribe = stream.subscribe(listener); - - expect(typeof unsubscribe).toBe('function'); - - unsubscribe(); - stream.dispatch({ type: 'test' }); - - expect(listener).not.toHaveBeenCalled(); - }); - - it('handles multiple dispatches', () => { - const stream = createEventStream<{ type: string; count: number }>(); - const listener = vi.fn(); - - stream.subscribe(listener); - - stream.dispatch({ type: 'first', count: 1 }); - stream.dispatch({ type: 'second', count: 2 }); - stream.dispatch({ type: 'third', count: 3 }); - - expect(listener).toHaveBeenCalledTimes(3); - expect(listener).toHaveBeenNthCalledWith(1, { type: 'first', count: 1 }); - expect(listener).toHaveBeenNthCalledWith(2, { type: 'second', count: 2 }); - expect(listener).toHaveBeenNthCalledWith(3, { type: 'third', count: 3 }); - }); - - it('allows unsubscribing during dispatch', () => { - const stream = createEventStream(); - const listener1 = vi.fn(); - const listener2 = vi.fn(); - - stream.subscribe(listener1); - const unsubscribe2 = stream.subscribe(listener2); - - stream.subscribe(() => { - unsubscribe2(); // Unsubscribe during dispatch - }); - - stream.dispatch({ type: 'test' }); - - // Listener2 should still be called (was subscribed when dispatch started) - expect(listener1).toHaveBeenCalledOnce(); - expect(listener2).toHaveBeenCalledOnce(); - - // But not on next dispatch - stream.dispatch({ type: 'test2' }); - expect(listener1).toHaveBeenCalledTimes(2); - expect(listener2).toHaveBeenCalledOnce(); - }); - - it('handles subscription during dispatch', () => { - const stream = createEventStream(); - const listener1 = vi.fn(); - const listener2 = vi.fn(); - - stream.subscribe(listener1); - stream.subscribe(() => { - // Subscribe during dispatch - stream.subscribe(listener2); - }); - - stream.dispatch({ type: 'test' }); - - expect(listener1).toHaveBeenCalledOnce(); - // Listener2 was added during dispatch, should not be called this time - expect(listener2).not.toHaveBeenCalled(); - - // But should be called on next dispatch - stream.dispatch({ type: 'test2' }); - expect(listener2).toHaveBeenCalledOnce(); - }); - - it('handles same listener subscribed multiple times', () => { - const stream = createEventStream(); - const listener = vi.fn(); - - stream.subscribe(listener); - stream.subscribe(listener); - stream.subscribe(listener); - - stream.dispatch({ type: 'test' }); - - // Set semantics: listener only called once - expect(listener).toHaveBeenCalledOnce(); - }); - - it('safely handles unsubscribe called multiple times', () => { - const stream = createEventStream(); - const listener = vi.fn(); - - const unsubscribe = stream.subscribe(listener); - - unsubscribe(); - unsubscribe(); // Should not throw - - stream.dispatch({ type: 'test' }); - expect(listener).not.toHaveBeenCalled(); - }); -}); - -describe('isEventStream', () => { - it('identifies event stream', () => { - const stream = createEventStream(); - - expect(isEventStream(stream)).toBe(true); - }); - - it('rejects non-stream objects', () => { - expect(isEventStream({})).toBe(false); - expect(isEventStream(null)).toBe(false); - expect(isEventStream(undefined)).toBe(false); - expect(isEventStream(42)).toBe(false); - expect(isEventStream('stream')).toBe(false); - }); - - it('rejects objects without symbol', () => { - const fake = { - dispatch: () => {}, - subscribe: () => () => {}, - }; - - expect(isEventStream(fake)).toBe(false); - }); -}); diff --git a/packages/spf/src/core/features/calculate-presentation-duration.ts b/packages/spf/src/core/features/calculate-presentation-duration.ts index 4dcf4525..f6e5fdca 100644 --- a/packages/spf/src/core/features/calculate-presentation-duration.ts +++ b/packages/spf/src/core/features/calculate-presentation-duration.ts @@ -1,5 +1,5 @@ -import { combineLatest } from '../reactive/combine-latest'; -import type { WritableState } from '../state/create-state'; +import { effect } from '../signals/effect'; +import type { Signal } from '../signals/primitives'; import type { AudioTrack, Presentation, VideoTrack } from '../types'; import { isResolvedTrack } from '../types'; import { getSelectedTrack } from '../utils/track-selection'; @@ -64,25 +64,24 @@ export function getDurationFromResolvedTracks(state: PresentationDurationState): /** * Calculate and set presentation duration from resolved tracks. */ -export function calculatePresentationDuration({ +export function calculatePresentationDuration({ state, }: { - state: WritableState; + state: Signal; }): () => void { - return combineLatest([state]).subscribe(([currentState]: [PresentationDurationState]) => { + return effect(() => { + const currentState = state.get(); if (!shouldCalculateDuration(currentState)) return; const duration = getDurationFromResolvedTracks(currentState); if (duration === undefined || !Number.isFinite(duration)) return; - const { presentation } = currentState; - - // Patch presentation with duration - state.patch({ + state.set({ + ...currentState, presentation: { - ...presentation!, + ...currentState.presentation!, duration, }, - }); + } as S); }); } diff --git a/packages/spf/src/core/features/quality-switching.ts b/packages/spf/src/core/features/quality-switching.ts index 3c1f6392..1a023d92 100644 --- a/packages/spf/src/core/features/quality-switching.ts +++ b/packages/spf/src/core/features/quality-switching.ts @@ -1,7 +1,8 @@ import type { BandwidthState } from '../abr/bandwidth-estimator'; import { getBandwidthEstimate } from '../abr/bandwidth-estimator'; import { selectQuality } from '../abr/quality-selection'; -import type { WritableState } from '../state/create-state'; +import { effect } from '../signals/effect'; +import { type Signal, update } from '../signals/primitives'; import type { Presentation, VideoSelectionSet } from '../types'; /** @@ -84,8 +85,8 @@ function getVideoTracks(presentation: Presentation) { * // Later, when done: * cleanup(); */ -export function switchQuality( - { state }: { state: WritableState }, +export function switchQuality( + { state }: { state: Signal }, config: QualitySwitchingConfig = {} ): () => void { const safetyMargin = config.safetyMargin ?? DEFAULT_SWITCHING_CONFIG.safetyMargin; @@ -99,7 +100,8 @@ export function switchQuality( let lastUpgradeTime = Date.now(); let firstMeaningfulFire = true; - return state.subscribe((currentState: QualitySwitchingState) => { + return effect(() => { + const currentState = state.get(); const { presentation, bandwidthState, selectedVideoTrackId, abrDisabled } = currentState; if (abrDisabled === true) return; @@ -128,6 +130,7 @@ export function switchQuality( lastUpgradeTime = now; } - state.patch({ selectedVideoTrackId: optimal.id }); + const patch: Partial = { selectedVideoTrackId: optimal.id }; + update(state, patch); }); } diff --git a/packages/spf/src/core/features/resolve-presentation.ts b/packages/spf/src/core/features/resolve-presentation.ts index 06e32708..3061d97b 100644 --- a/packages/spf/src/core/features/resolve-presentation.ts +++ b/packages/spf/src/core/features/resolve-presentation.ts @@ -1,9 +1,8 @@ import { fetchResolvable, getResponseText } from '../../dom/network/fetch'; -import type { EventStream } from '../events/create-event-stream'; import { parseMultivariantPlaylist } from '../hls/parse-multivariant'; -import { combineLatest } from '../reactive/combine-latest'; -import type { WritableState } from '../state/create-state'; -import type { AddressableObject, MediaElementLike, Presentation } from '../types'; +import { effect } from '../signals/effect'; +import { computed, type Signal, update } from '../signals/primitives'; +import type { AddressableObject, Presentation } from '../types'; /** * Unresolved presentation - has a URL but no data yet. @@ -17,13 +16,8 @@ export type UnresolvedPresentation = AddressableObject; export interface PresentationState { presentation?: UnresolvedPresentation | Presentation | undefined; preload?: 'auto' | 'metadata' | 'none' | undefined; -} - -/** - * Mutable platform objects. - */ -export interface PlatformOwners { - mediaElement?: MediaElementLike | undefined; + /** True once the user has initiated playback — enables resolution regardless of preload. */ + playbackInitiated?: boolean; } /** @@ -42,126 +36,78 @@ export function canResolve( } /** - * Determines if resolution conditions are met based on preload policy and event. + * Determines if resolution conditions are met based on preload policy and playback state. * * Resolution conditions: * - State-driven: preload is 'auto' or 'metadata' - * - Event-driven: play event + * - Playback-driven: playbackInitiated is true * * @param state - Current presentation state - * @param event - Current action/event * @returns true if resolution conditions are met */ -export function shouldResolve(state: PresentationState, event: PresentationAction): boolean { - const { preload } = state; +export function shouldResolve(state: PresentationState): boolean { + const { preload, playbackInitiated } = state; return ( // State-driven: preload allows (auto/metadata) ['auto', 'metadata'].includes(preload as any) || - // Event-driven: play event - event.type === 'play' + // Playback-driven: user has initiated playback + !!playbackInitiated ); } -/** - * Syncs preload attribute from mediaElement to state. - * - * Watches the owners state for mediaElement changes and copies the - * preload attribute to the immutable state. - * - * @param state - Immutable state container - * @param owners - Mutable platform objects container - * @returns Cleanup function to stop syncing - */ -export function syncPreloadAttribute( - state: WritableState, - owners: WritableState -): () => void { - return owners.subscribe((current) => { - // Only infer preload from the element when no explicit value has been set. - // An explicit value (set via SpfMedia.preload) always wins. - if (state.current.preload !== undefined) return; - const preload = current.mediaElement?.preload || undefined; - state.patch({ preload: preload as 'auto' | 'metadata' | 'none' | undefined }); - }); -} - -/** - * Action types for presentation resolution. - * Event names match HTMLMediaElement events (lowercase). - */ -export type PresentationAction = { type: 'play' } | { type: 'pause' } | { type: 'load'; url: string }; - /** * Resolves unresolved presentations using reactive composition. * - * Uses combineLatest to compose state + events, enabling both state-driven - * and event-driven resolution triggers. - * * Triggers resolution when: * - State-driven: Unresolved presentation + preload allows (auto/metadata) - * - Event-driven: PLAY event when preload="none" + * - Playback-driven: playbackInitiated is true * * @example * ```ts - * const state = createState({ presentation: undefined, preload: 'auto' }); - * const events = createEventStream(); + * const state = signal({ presentation: undefined, preload: 'auto', playbackInitiated: false }); * - * const cleanup = resolvePresentation({ state, events }); + * const cleanup = resolvePresentation({ state }); * * // State-driven: resolves immediately when preload allows - * state.patch({ presentation: { url: 'http://example.com/playlist.m3u8' } }); + * state.set({ ...state.get(), presentation: { url: 'http://example.com/playlist.m3u8' } }); * - * // Event-driven: resolves on PLAY when preload="none" - * state.patch({ preload: 'none', presentation: { url: '...' } }); - * events.dispatch({ type: 'PLAY' }); + * // Playback-driven: resolves when playbackInitiated is set + * state.set({ ...state.get(), preload: 'none', presentation: { url: '...' }, playbackInitiated: true }); * ``` */ -export function resolvePresentation({ - state, - events, -}: { - state: WritableState; - events: EventStream; -}): () => void { - // This is effectively a very simple finite state model. We can formalize this if needed. +export function resolvePresentation({ state }: { state: Signal }): () => void { + const canResolveSignal = computed(() => canResolve(state.get())); + const shouldResolveSignal = computed(() => shouldResolve(state.get())); + let resolving = false; let abortController: AbortController | null = null; - const cleanup = combineLatest([state, events]).subscribe(async ([currentState, event]) => { - if (!canResolve(currentState) || !shouldResolve(currentState, event) || resolving) return; + const cleanupEffect = effect(() => { + if (!canResolveSignal.get() || !shouldResolveSignal.get() || resolving) return; - try { - // This along with the resolving finite state (or more complex) could be pulled into its own abstraction. - // Set flag before async work - resolving = true; - abortController = new AbortController(); + const presentation = state.get().presentation as UnresolvedPresentation; + resolving = true; + abortController = new AbortController(); - const { presentation } = currentState; - // Fetch and parse playlist - const response = await fetchResolvable(presentation, { signal: abortController.signal }); - const text = await getResponseText(response); - const parsed = parseMultivariantPlaylist(text, presentation); - - // Update state with resolved presentation - state.patch({ - presentation: parsed, + fetchResolvable(presentation, { signal: abortController.signal }) + .then((response) => getResponseText(response)) + .then((text) => { + const parsed = parseMultivariantPlaylist(text, presentation); + const patch: Partial = { presentation: parsed }; + update(state, patch); + }) + .catch((error) => { + if (error instanceof Error && error.name === 'AbortError') return; + throw error; + }) + .finally(() => { + resolving = false; + abortController = null; }); - } catch (error) { - // Ignore AbortError - this is expected when cleanup happens - if (error instanceof Error && error.name === 'AbortError') { - return; - } - throw error; - } finally { - // Always clear flag - resolving = false; - abortController = null; - } }); - // Return cleanup function that aborts pending fetches return () => { abortController?.abort(); - cleanup(); + cleanupEffect(); }; } diff --git a/packages/spf/src/core/features/resolve-track.ts b/packages/spf/src/core/features/resolve-track.ts index 784d8aec..c0319b4d 100644 --- a/packages/spf/src/core/features/resolve-track.ts +++ b/packages/spf/src/core/features/resolve-track.ts @@ -1,8 +1,7 @@ import { fetchResolvable, getResponseText } from '../../dom/network/fetch'; -import type { EventStream } from '../events/create-event-stream'; import { parseMediaPlaylist } from '../hls/parse-media-playlist'; -import { combineLatest } from '../reactive/combine-latest'; -import type { WritableState } from '../state/create-state'; +import { effect } from '../signals/effect'; +import type { Signal } from '../signals/primitives'; import { ConcurrentRunner, Task } from '../task'; import type { Presentation, ResolvedTrack, TrackType } from '../types'; import { isResolvedTrack } from '../types'; @@ -28,20 +27,6 @@ export function canResolve( return !isResolvedTrack(track); } -/** - * Determines if track resolution conditions are met. - * - * Currently always returns true - conditions are checked by canResolveTrack() - * and resolving flag. Kept as placeholder for future conditional logic. - * - * @param state - Current track resolution state - * @param event - Current action/event - * @returns true (conditions checked elsewhere) - */ -export function shouldResolve(_state: TrackResolutionState, _event: TrackResolutionAction): boolean { - return true; -} - // ============================================================================ // Public API // ============================================================================ @@ -67,12 +52,6 @@ export function updateTrackInPresentation( } as Presentation; } -/** - * Action types for track resolution. - * Event names match HTMLMediaElement events (lowercase). - */ -export type TrackResolutionAction = { type: 'play' } | { type: 'pause' }; - /** * Configuration for track resolution. */ @@ -83,22 +62,15 @@ export interface TrackResolutionConfig { /** * Resolves unresolved tracks using reactive composition. * - * The subscribe closure is pure scheduling logic: it checks conditions and - * creates a Task for the selected track when appropriate. The ConcurrentRunner - * handles all concurrency concerns — deduplication, parallel execution, and - * cleanup. + * Reacts to state changes and schedules fetch tasks via ConcurrentRunner when + * a selected track is unresolved. The ConcurrentRunner handles deduplication, + * parallel execution, and cleanup. * * Generic version that works for video, audio, or text tracks based on config. * Type parameter T is inferred from config.type (use 'as const' for inference). */ -export function resolveTrack( - { - state, - events, - }: { - state: WritableState; - events: EventStream; - }, +export function resolveTrack( + { state }: { state: Signal }, config: TrackResolutionConfig ): () => void { // NOTE: This can/maybe will be pulled into a per-use case factory (e.g. something like createTaskRunner() with args TBD), @@ -106,8 +78,9 @@ export function resolveTrack( // to e.g. run concurrently (like we currently are), serially with a queue, or abort the previous task and replace it with the newly scheduled one. (CJP). const runner = new ConcurrentRunner(); - const cleanup = combineLatest([state, events]).subscribe(([currentState, event]) => { - if (!canResolve(currentState, config) || !shouldResolve(currentState, event)) return; + const cleanup = effect(() => { + const currentState = state.get(); + if (!canResolve(currentState, config)) return; const track = getSelectedTrack(currentState, config.type); if (!track) return; @@ -123,21 +96,15 @@ export function resolveTrack( const text = await getResponseText(response); const mediaTrack = parseMediaPlaylist(text, resolvedTrack); - // IMPORTANT: Read state.current.presentation at patch time, not from the + // IMPORTANT: Read state.get().presentation at write time, not from the // captured currentState snapshot. Multiple Tasks may be running concurrently // (one per track being resolved), so the snapshot is likely already stale by - // the time a task completes — a sibling task may have already patched the - // presentation with its own resolved track. Using the snapshot would overwrite - // that update and lose the other track's resolution. Reading live state ensures - // each task builds on top of whatever has been committed so far. - // - // This is a limitation of the current architecture: tasks receive a snapshot - // at scheduling time but need live state at write time. A future improvement - // could dispatch resolved tracks through a single serialised writer (e.g. a - // reducer pattern) to eliminate this hazard entirely. - const latestPresentation = state.current.presentation!; - const updatedPresentation = updateTrackInPresentation(latestPresentation, mediaTrack); - state.patch({ presentation: updatedPresentation }); + // the time a task completes — a sibling task may have already written the + // presentation with its own resolved track. Reading live state ensures each + // task builds on top of whatever has been committed so far. + const latest = state.get(); + const updatedPresentation = updateTrackInPresentation(latest.presentation!, mediaTrack); + state.set({ ...latest, presentation: updatedPresentation } as S); }, { id: track.id } ) diff --git a/packages/spf/src/core/features/select-tracks.ts b/packages/spf/src/core/features/select-tracks.ts index 4771524c..a04dff18 100644 --- a/packages/spf/src/core/features/select-tracks.ts +++ b/packages/spf/src/core/features/select-tracks.ts @@ -1,6 +1,6 @@ import { DEFAULT_QUALITY_CONFIG, selectQuality } from '../abr/quality-selection'; -import type { EventStream } from '../events/create-event-stream'; -import type { WritableState } from '../state/create-state'; +import { effect } from '../signals/effect'; +import { type Signal, update } from '../signals/primitives'; import type { AudioSelectionSet, Presentation, TrackType, VideoSelectionSet } from '../types'; import { SelectedTrackIdKeyByType } from '../utils/track-selection'; @@ -283,34 +283,22 @@ export function shouldSelectTrack( * { initialBandwidth: 2_000_000 } * ); */ -export function selectVideoTrack( - { - state, - }: { - state: WritableState; - owners: WritableState; - events: EventStream; - }, +export function selectVideoTrack( + { state }: { state: Signal }, config: VideoSelectionConfig = { type: 'video' } ): () => void { - let selecting = false; + return effect(() => { + const currentState = state.get(); + if (!canSelectTrack(currentState, config) || !shouldSelectTrack(currentState, config)) return; - return state.subscribe(async (currentState: TrackSelectionState) => { - if (!canSelectTrack(currentState, config) || !shouldSelectTrack(currentState, config) || selecting) return; + // Just to have basic functionality/POC, simply selecting the first track + const selectedTrackId = currentState.presentation?.selectionSets.find(({ type }) => type === config.type) + ?.switchingSets[0]?.tracks[0]?.id; - try { - selecting = true; - - // Just to have basic functionality/POC, simply selecting the first track - const selectedTrackId = currentState.presentation?.selectionSets.find(({ type }) => type === config.type) - ?.switchingSets[0]?.tracks[0]?.id; - - if (selectedTrackId) { - const selectedTrackKey = SelectedTrackIdKeyByType[config.type]; - state.patch({ [selectedTrackKey]: selectedTrackId }); - } - } finally { - selecting = false; + if (selectedTrackId) { + const selectedTrackKey = SelectedTrackIdKeyByType[config.type]; + const patch: Partial = { [selectedTrackKey]: selectedTrackId }; + update(state, patch); } }); } @@ -330,33 +318,21 @@ export function selectVideoTrack( * { preferredAudioLanguage: 'en' } * ); */ -export function selectAudioTrack( - { - state, - }: { - state: WritableState; - owners: WritableState; - events: EventStream; - }, +export function selectAudioTrack( + { state }: { state: Signal }, config: AudioSelectionConfig = { type: 'audio' } ): () => void { - let selecting = false; + return effect(() => { + const currentState = state.get(); + if (!canSelectTrack(currentState, config) || !shouldSelectTrack(currentState, config)) return; - return state.subscribe(async (currentState: TrackSelectionState) => { - if (!canSelectTrack(currentState, config) || !shouldSelectTrack(currentState, config) || selecting) return; + // Just to have basic functionality/POC, simply selecting the first track + const selectedTrackId = currentState.presentation?.selectionSets.find(({ type }) => type === 'audio') + ?.switchingSets[0]?.tracks[0]?.id; - try { - selecting = true; - - // Just to have basic functionality/POC, simply selecting the first track - const selectedTrackId = currentState.presentation?.selectionSets.find(({ type }) => type === 'audio') - ?.switchingSets[0]?.tracks[0]?.id; - - if (selectedTrackId) { - state.patch({ selectedAudioTrackId: selectedTrackId }); - } - } finally { - selecting = false; + if (selectedTrackId) { + const patch: Partial = { selectedAudioTrackId: selectedTrackId }; + update(state, patch); } }); } @@ -373,32 +349,20 @@ export function selectAudioTrack( * @example * const cleanup = selectTextTrack({ state, owners, events }, {}); */ -export function selectTextTrack( - { - state, - }: { - state: WritableState; - owners: WritableState; - events: EventStream; - }, +export function selectTextTrack( + { state }: { state: Signal }, config: TextSelectionConfig = { type: 'text' } ): () => void { - let selecting = false; + return effect(() => { + const currentState = state.get(); + if (!canSelectTrack(currentState, config) || !shouldSelectTrack(currentState, config)) return; - return state.subscribe(async (currentState: TrackSelectionState) => { - if (!canSelectTrack(currentState, config) || !shouldSelectTrack(currentState, config) || selecting) return; + // Text tracks are user opt-in - don't auto-select + const selectedTextTrackId = pickTextTrack(currentState.presentation!, config); - try { - selecting = true; - - // Text tracks are user opt-in - don't auto-select - const selectedTextTrackId = pickTextTrack(currentState.presentation!, config); - - if (selectedTextTrackId) { - state.patch({ selectedTextTrackId }); - } - } finally { - selecting = false; + if (selectedTextTrackId) { + const patch: Partial = { selectedTextTrackId }; + update(state, patch); } }); } diff --git a/packages/spf/src/core/features/sync-preload-attribute.ts b/packages/spf/src/core/features/sync-preload-attribute.ts new file mode 100644 index 00000000..ad73b4ae --- /dev/null +++ b/packages/spf/src/core/features/sync-preload-attribute.ts @@ -0,0 +1,38 @@ +import { effect } from '../signals/effect'; +import { computed, type Signal, update } from '../signals/primitives'; +import type { MediaElementLike } from '../types'; +import type { PresentationState } from './resolve-presentation'; + +/** + * Owners shape for preload attribute sync. + */ +export interface PlatformOwners { + mediaElement?: MediaElementLike | undefined; +} + +/** + * Syncs preload attribute from mediaElement to state. + * + * Watches the owners signal for mediaElement changes and copies the + * preload attribute to state when no explicit value has been set. + * An explicit value (set via SpfMedia.preload) always wins. + * + * @example + * const cleanup = syncPreloadAttribute({ state, owners }); + */ +export function syncPreloadAttribute({ + state, + owners, +}: { + state: Signal; + owners: Signal; +}): () => void { + const mediaElement = computed(() => owners.get().mediaElement); + return effect(() => { + if (state.get().preload !== undefined) return; + const preload = mediaElement.get()?.preload || undefined; + if (preload === undefined) return; + const patch: Partial = { preload: preload as 'auto' | 'metadata' | 'none' }; + update(state, patch); + }); +} diff --git a/packages/spf/src/core/features/tests/calculate-presentation-duration.test.ts b/packages/spf/src/core/features/tests/calculate-presentation-duration.test.ts index 323c3da3..c93eddda 100644 --- a/packages/spf/src/core/features/tests/calculate-presentation-duration.test.ts +++ b/packages/spf/src/core/features/tests/calculate-presentation-duration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { createState } from '../../state/create-state'; +import { signal } from '../../signals/primitives'; import type { AudioTrack, Presentation, VideoTrack } from '../../types'; import { calculatePresentationDuration, @@ -254,11 +254,12 @@ describe('getDurationFromResolvedTracks', () => { describe('calculatePresentationDuration', () => { it('sets presentation.duration from resolved video track', async () => { - const state = createState({}); + const state = signal({}); const cleanup = calculatePresentationDuration({ state }); - state.patch({ + state.set({ + ...state.get(), presentation: createPresentation({ video: [ { @@ -279,18 +280,19 @@ describe('calculatePresentationDuration', () => { }); await vi.waitFor(() => { - expect(state.current.presentation?.duration).toBe(120.5); + expect(state.get().presentation?.duration).toBe(120.5); }); cleanup(); }); it('sets presentation.duration from resolved audio track', async () => { - const state = createState({}); + const state = signal({}); const cleanup = calculatePresentationDuration({ state }); - state.patch({ + state.set({ + ...state.get(), presentation: createPresentation({ audio: [ { @@ -315,14 +317,14 @@ describe('calculatePresentationDuration', () => { }); await vi.waitFor(() => { - expect(state.current.presentation?.duration).toBe(90.75); + expect(state.get().presentation?.duration).toBe(90.75); }); cleanup(); }); it('does not recalculate when duration already set', async () => { - const state = createState({ + const state = signal({ presentation: createPresentation({ duration: 60, video: [ @@ -347,17 +349,18 @@ describe('calculatePresentationDuration', () => { await new Promise((resolve) => setTimeout(resolve, 50)); - expect(state.current.presentation?.duration).toBe(60); + expect(state.get().presentation?.duration).toBe(60); cleanup(); }); it('does not set invalid durations', async () => { - const state = createState({}); + const state = signal({}); const cleanup = calculatePresentationDuration({ state }); - state.patch({ + state.set({ + ...state.get(), presentation: createPresentation({ video: [ { @@ -379,7 +382,7 @@ describe('calculatePresentationDuration', () => { await new Promise((resolve) => setTimeout(resolve, 50)); - expect(state.current.presentation?.duration).toBeUndefined(); + expect(state.get().presentation?.duration).toBeUndefined(); cleanup(); }); diff --git a/packages/spf/src/core/features/tests/quality-switching.test.ts b/packages/spf/src/core/features/tests/quality-switching.test.ts index 2cd5e161..9f2fa282 100644 --- a/packages/spf/src/core/features/tests/quality-switching.test.ts +++ b/packages/spf/src/core/features/tests/quality-switching.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { BandwidthState } from '../../abr/bandwidth-estimator'; -import { createState } from '../../state/create-state'; +import { signal } from '../../signals/primitives'; import type { PartiallyResolvedVideoTrack, Presentation, VideoSelectionSet } from '../../types'; import { DEFAULT_SWITCHING_CONFIG, @@ -81,24 +81,24 @@ describe('switchQuality', () => { describe('no-op conditions', () => { it('does nothing without a presentation', () => { - const state = createState({ + const state = signal({ bandwidthState: createBandwidthState(3_000_000), selectedVideoTrackId: '720p', }); const cleanup = switchQuality({ state }); - expect(state.current.selectedVideoTrackId).toBe('720p'); + expect(state.get().selectedVideoTrackId).toBe('720p'); cleanup(); }); it('does nothing without bandwidthState', () => { - const state = createState({ + const state = signal({ presentation: createPresentation(tracks), selectedVideoTrackId: '360p', }); const cleanup = switchQuality({ state }); - expect(state.current.selectedVideoTrackId).toBe('360p'); + expect(state.get().selectedVideoTrackId).toBe('360p'); cleanup(); }); @@ -108,19 +108,19 @@ describe('switchQuality', () => { url: 'https://example.com/playlist.m3u8', selectionSets: [], }; - const state = createState({ + const state = signal({ presentation: emptyPresentation, bandwidthState: createBandwidthState(3_000_000), selectedVideoTrackId: '720p', }); const cleanup = switchQuality({ state }); - expect(state.current.selectedVideoTrackId).toBe('720p'); + expect(state.get().selectedVideoTrackId).toBe('720p'); cleanup(); }); it('does nothing when already on the optimal track', () => { - const state = createState({ + const state = signal({ presentation: createPresentation(tracks), // ~3 Mbps → optimal is 720p with default safety margin bandwidthState: createBandwidthState(3_000_000), @@ -128,14 +128,14 @@ describe('switchQuality', () => { }); const cleanup = switchQuality({ state }); - expect(state.current.selectedVideoTrackId).toBe('720p'); + expect(state.get().selectedVideoTrackId).toBe('720p'); cleanup(); }); }); describe('initial selection', () => { it('selects optimal track on first bandwidth estimate when no track is selected', () => { - const state = createState({ + const state = signal({ presentation: createPresentation(tracks), bandwidthState: createBandwidthState(3_000_000), // No selectedVideoTrackId yet @@ -144,32 +144,32 @@ describe('switchQuality', () => { const cleanup = switchQuality({ state }); // Initial subscriber fire is synchronous — result visible via #pending // 3 Mbps → 720p fits (needs 2.4M/0.85 ≈ 2.82 Mbps), 1080p doesn't - expect(state.current.selectedVideoTrackId).toBe('720p'); + expect(state.get().selectedVideoTrackId).toBe('720p'); cleanup(); }); }); describe('downgrade', () => { it('downgrades immediately when bandwidth drops below current quality threshold', async () => { - const state = createState({ + const state = signal({ presentation: createPresentation(tracks), bandwidthState: createBandwidthState(3_000_000), selectedVideoTrackId: '720p', }); const cleanup = switchQuality({ state }); - expect(state.current.selectedVideoTrackId).toBe('720p'); + expect(state.get().selectedVideoTrackId).toBe('720p'); // Bandwidth drops — 800 Kbps can only fit 360p (needs 600K/0.85 ≈ 706 Kbps) - state.patch({ bandwidthState: createBandwidthState(800_000) }); + state.set({ ...state.get(), bandwidthState: createBandwidthState(800_000) }); await flush(); - expect(state.current.selectedVideoTrackId).toBe('360p'); + expect(state.get().selectedVideoTrackId).toBe('360p'); cleanup(); }); it('does not apply upgrade interval to downgrades', async () => { - const state = createState({ + const state = signal({ presentation: createPresentation(tracks), bandwidthState: createBandwidthState(6_000_000), selectedVideoTrackId: '360p', @@ -177,12 +177,12 @@ describe('switchQuality', () => { const cleanup = switchQuality({ state }); // First fire (synchronous): upgrade 360p → 1080p since 6 Mbps fits - expect(state.current.selectedVideoTrackId).toBe('1080p'); + expect(state.get().selectedVideoTrackId).toBe('1080p'); // Immediately drop bandwidth (no time passes) — should still downgrade - state.patch({ bandwidthState: createBandwidthState(700_000) }); + state.set({ ...state.get(), bandwidthState: createBandwidthState(700_000) }); await flush(); - expect(state.current.selectedVideoTrackId).toBe('360p'); + expect(state.get().selectedVideoTrackId).toBe('360p'); cleanup(); }); @@ -190,33 +190,33 @@ describe('switchQuality', () => { describe('upgrade', () => { it('upgrades after bandwidth improves and minUpgradeInterval has passed', async () => { - const state = createState({ + const state = signal({ presentation: createPresentation(tracks), bandwidthState: createBandwidthState(800_000), selectedVideoTrackId: '360p', }); const cleanup = switchQuality({ state }); - expect(state.current.selectedVideoTrackId).toBe('360p'); + expect(state.get().selectedVideoTrackId).toBe('360p'); // Bandwidth improves but not enough time has passed since creation - state.patch({ bandwidthState: createBandwidthState(3_000_000) }); + state.set({ ...state.get(), bandwidthState: createBandwidthState(3_000_000) }); await flush(); - expect(state.current.selectedVideoTrackId).toBe('360p'); // blocked + expect(state.get().selectedVideoTrackId).toBe('360p'); // blocked // Advance time past the upgrade interval vi.advanceTimersByTime(DEFAULT_SWITCHING_CONFIG.minUpgradeInterval + 1); // Trigger another state change to re-evaluate - state.patch({ bandwidthState: createBandwidthState(3_000_000) }); + state.set({ ...state.get(), bandwidthState: createBandwidthState(3_000_000) }); await flush(); - expect(state.current.selectedVideoTrackId).toBe('720p'); + expect(state.get().selectedVideoTrackId).toBe('720p'); cleanup(); }); it('upgrades immediately on first switch (no prior upgrade)', () => { - const state = createState({ + const state = signal({ presentation: createPresentation(tracks), bandwidthState: createBandwidthState(3_000_000), selectedVideoTrackId: '360p', @@ -224,12 +224,12 @@ describe('switchQuality', () => { const cleanup = switchQuality({ state }); // Initial fire is synchronous — upgrade happens in the subscribe callback - expect(state.current.selectedVideoTrackId).toBe('720p'); + expect(state.get().selectedVideoTrackId).toBe('720p'); cleanup(); }); it('blocks second upgrade until interval passes', async () => { - const state = createState({ + const state = signal({ presentation: createPresentation(tracks), bandwidthState: createBandwidthState(3_000_000), selectedVideoTrackId: '360p', @@ -237,19 +237,19 @@ describe('switchQuality', () => { const cleanup = switchQuality({ state }); // First upgrade: 360p → 720p (initial synchronous fire, no cooldown) - expect(state.current.selectedVideoTrackId).toBe('720p'); + expect(state.get().selectedVideoTrackId).toBe('720p'); // Bandwidth jumps to 6 Mbps shortly after — 1080p desired but blocked vi.advanceTimersByTime(1000); - state.patch({ bandwidthState: createBandwidthState(6_000_000) }); + state.set({ ...state.get(), bandwidthState: createBandwidthState(6_000_000) }); await flush(); - expect(state.current.selectedVideoTrackId).toBe('720p'); // blocked + expect(state.get().selectedVideoTrackId).toBe('720p'); // blocked // After interval, upgrade proceeds vi.advanceTimersByTime(DEFAULT_SWITCHING_CONFIG.minUpgradeInterval + 1); - state.patch({ bandwidthState: createBandwidthState(6_000_000) }); + state.set({ ...state.get(), bandwidthState: createBandwidthState(6_000_000) }); await flush(); - expect(state.current.selectedVideoTrackId).toBe('1080p'); + expect(state.get().selectedVideoTrackId).toBe('1080p'); cleanup(); }); @@ -257,7 +257,7 @@ describe('switchQuality', () => { describe('configuration', () => { it('uses custom safetyMargin', () => { - const state = createState({ + const state = signal({ presentation: createPresentation(tracks), // With safetyMargin=1.0, requiredBandwidth == trackBandwidth (no headroom) // 3 Mbps: 720p (2.4M) fits, 1080p (4.8M) does not @@ -267,12 +267,12 @@ describe('switchQuality', () => { const config: QualitySwitchingConfig = { safetyMargin: 1.0 }; const cleanup = switchQuality({ state }, config); - expect(state.current.selectedVideoTrackId).toBe('720p'); + expect(state.get().selectedVideoTrackId).toBe('720p'); cleanup(); }); it('uses custom minUpgradeInterval', async () => { - const state = createState({ + const state = signal({ presentation: createPresentation(tracks), bandwidthState: createBandwidthState(800_000), selectedVideoTrackId: '360p', @@ -282,15 +282,15 @@ describe('switchQuality', () => { const cleanup = switchQuality({ state }, config); // Bandwidth improves — blocked by interval - state.patch({ bandwidthState: createBandwidthState(3_000_000) }); + state.set({ ...state.get(), bandwidthState: createBandwidthState(3_000_000) }); await flush(); - expect(state.current.selectedVideoTrackId).toBe('360p'); // blocked + expect(state.get().selectedVideoTrackId).toBe('360p'); // blocked // Advance only 2000ms (custom interval) vi.advanceTimersByTime(2001); - state.patch({ bandwidthState: createBandwidthState(3_000_000) }); + state.set({ ...state.get(), bandwidthState: createBandwidthState(3_000_000) }); await flush(); - expect(state.current.selectedVideoTrackId).toBe('720p'); + expect(state.get().selectedVideoTrackId).toBe('720p'); cleanup(); }); @@ -304,7 +304,7 @@ describe('switchQuality', () => { bytesSampled: 0, // No samples → hasGoodEstimate is false }; - const state = createState({ + const state = signal({ presentation: createPresentation(tracks), bandwidthState: unreliableState, selectedVideoTrackId: '360p', @@ -314,14 +314,14 @@ describe('switchQuality', () => { // 1080p (needs 4.8M/0.85 ≈ 5.65M) does not → selects 720p const config: QualitySwitchingConfig = { defaultBandwidth: 5_000_000 }; const cleanup = switchQuality({ state }, config); - expect(state.current.selectedVideoTrackId).toBe('720p'); + expect(state.get().selectedVideoTrackId).toBe('720p'); cleanup(); }); }); describe('cleanup', () => { it('stops reacting after cleanup', () => { - const state = createState({ + const state = signal({ presentation: createPresentation(tracks), bandwidthState: createBandwidthState(800_000), selectedVideoTrackId: '360p', @@ -332,9 +332,9 @@ describe('switchQuality', () => { // After cleanup, bandwidth changes should not trigger switches vi.advanceTimersByTime(DEFAULT_SWITCHING_CONFIG.minUpgradeInterval + 1); - state.patch({ bandwidthState: createBandwidthState(6_000_000) }); + state.set({ ...state.get(), bandwidthState: createBandwidthState(6_000_000) }); // No await needed — no subscriber fires after cleanup - expect(state.current.selectedVideoTrackId).toBe('360p'); + expect(state.get().selectedVideoTrackId).toBe('360p'); }); }); }); diff --git a/packages/spf/src/core/features/tests/resolve-presentation.test.ts b/packages/spf/src/core/features/tests/resolve-presentation.test.ts index 10349232..da2a861c 100644 --- a/packages/spf/src/core/features/tests/resolve-presentation.test.ts +++ b/packages/spf/src/core/features/tests/resolve-presentation.test.ts @@ -1,9 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createEventStream } from '../../events/create-event-stream'; -import { createState } from '../../state/create-state'; -import type { AddressableObject, MediaElementLike, Presentation } from '../../types'; -import type { PlatformOwners, PresentationAction } from '../resolve-presentation'; -import { isUnresolved, resolvePresentation, shouldResolve, syncPreloadAttribute } from '../resolve-presentation'; +import { signal } from '../../signals/primitives'; +import type { AddressableObject, Presentation } from '../../types'; +import { isUnresolved, resolvePresentation, shouldResolve } from '../resolve-presentation'; describe('resolvePresentation', () => { afterEach(() => { @@ -16,13 +14,11 @@ describe('resolvePresentation', () => { preload?: 'auto' | 'metadata' | 'none' | undefined; } - const state = createState({ + const state = signal({ presentation: undefined, preload: 'auto', }); - const events = createEventStream(); - // Mock fetch vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(`#EXTM3U @@ -33,24 +29,21 @@ variant2.m3u8`) ); // Act - const cleanup = resolvePresentation({ state, events }); + const cleanup = resolvePresentation({ state }); - // Dispatch initial neutral event to prime combineLatest - events.dispatch({ type: 'pause' }); - - // Trigger resolution by patching unresolved presentation - state.patch({ presentation: { url: 'http://example.com/playlist.m3u8' } }); + // Trigger resolution by setting unresolved presentation + state.set({ ...state.get(), presentation: { url: 'http://example.com/playlist.m3u8' } }); // Wait for resolution await vi.waitFor(() => { - const current = state.current; + const current = state.get(); expect(current.presentation).toBeDefined(); expect(current.presentation).toHaveProperty('id'); expect(current.presentation).toHaveProperty('selectionSets'); }); // Assert - const resolved = state.current.presentation as Presentation; + const resolved = state.get().presentation as Presentation; expect(resolved.url).toBe('http://example.com/playlist.m3u8'); expect(resolved.selectionSets).toBeDefined(); expect(resolved.selectionSets.length).toBeGreaterThan(0); @@ -73,7 +66,7 @@ variant2.m3u8`) volume: number; } - const state = createState({ + const state = signal({ presentation: undefined, preload: 'auto', volume: 1.0, @@ -86,32 +79,28 @@ variant1.m3u8`) ); // Act - const events = createEventStream(); - const cleanup = resolvePresentation({ state, events }); - - // Dispatch initial neutral event to prime combineLatest - events.dispatch({ type: 'pause' }); + const cleanup = resolvePresentation({ state }); // Change volume before resolution - state.patch({ volume: 0.5 }); + state.set({ ...state.get(), volume: 0.5 }); // Wait a bit to ensure no fetch triggered await new Promise((resolve) => setTimeout(resolve, 50)); expect(fetchSpy).not.toHaveBeenCalled(); // Now add unresolved presentation - state.patch({ presentation: { url: 'http://example.com/playlist.m3u8' } }); + state.set({ ...state.get(), presentation: { url: 'http://example.com/playlist.m3u8' } }); // Wait for resolution await vi.waitFor(() => { - expect(state.current.presentation).toHaveProperty('id'); + expect(state.get().presentation).toHaveProperty('id'); }); expect(fetchSpy).toHaveBeenCalledOnce(); fetchSpy.mockClear(); // Change volume after resolution - state.patch({ volume: 0.8 }); + state.set({ ...state.get(), volume: 0.8 }); // Wait a bit to ensure no additional fetch await new Promise((resolve) => setTimeout(resolve, 50)); @@ -135,27 +124,23 @@ variant1.m3u8`) ); // State starts with unresolved presentation - const state = createState({ + const state = signal({ presentation: { url: 'http://example.com/initial.m3u8' }, preload: 'auto', }); // Act - const events = createEventStream(); - const cleanup = resolvePresentation({ state, events }); - - // Dispatch initial neutral event to prime combineLatest - events.dispatch({ type: 'pause' }); + const cleanup = resolvePresentation({ state }); // Wait for resolution (should happen automatically) await vi.waitFor(() => { - const current = state.current; + const current = state.get(); expect(current.presentation).toHaveProperty('id'); expect(current.presentation).toHaveProperty('selectionSets'); }); // Assert - const resolved = state.current.presentation as Presentation; + const resolved = state.get().presentation as Presentation; expect(resolved.url).toBe('http://example.com/initial.m3u8'); expect(resolved.selectionSets).toBeDefined(); @@ -202,24 +187,20 @@ variant1.m3u8`) }; // State starts with resolved presentation - const state = createState({ + const state = signal({ presentation: resolvedPresentation, preload: 'auto', }); // Act - const events = createEventStream(); - const cleanup = resolvePresentation({ state, events }); - - // Dispatch initial neutral event to prime combineLatest - events.dispatch({ type: 'pause' }); + const cleanup = resolvePresentation({ state }); // Wait a bit to ensure no fetch triggered await new Promise((resolve) => setTimeout(resolve, 50)); // Assert - should not fetch expect(fetchSpy).not.toHaveBeenCalled(); - expect(state.current.presentation).toBe(resolvedPresentation); + expect(state.get().presentation).toBe(resolvedPresentation); // Cleanup cleanup(); @@ -246,31 +227,27 @@ variant2.m3u8`) startTime: 0, }; - const state = createState({ + const state = signal({ presentation: resolvedPresentation, preload: 'auto', }); // Act - const events = createEventStream(); - const cleanup = resolvePresentation({ state, events }); - - // Dispatch initial neutral event to prime combineLatest - events.dispatch({ type: 'pause' }); + const cleanup = resolvePresentation({ state }); // Replace with unresolved presentation - state.patch({ presentation: { url: 'http://example.com/second.m3u8' } }); + state.set({ ...state.get(), presentation: { url: 'http://example.com/second.m3u8' } }); // Wait for resolution await vi.waitFor(() => { - const current = state.current; + const current = state.get(); expect(current.presentation).toHaveProperty('id'); expect((current.presentation as Presentation).url).toBe('http://example.com/second.m3u8'); }); // Assert - should have fetched and resolved new presentation expect(fetchSpy).toHaveBeenCalledOnce(); - const resolved = state.current.presentation as Presentation; + const resolved = state.get().presentation as Presentation; expect(resolved.url).toBe('http://example.com/second.m3u8'); expect(resolved.selectionSets).toBeDefined(); @@ -291,19 +268,15 @@ variant2.m3u8`) variant1.m3u8`) ); - const state = createState({ + const state = signal({ presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto', }); - const events = createEventStream(); - const cleanup = resolvePresentation({ state, events }); - - // Dispatch initial neutral event to prime combineLatest - events.dispatch({ type: 'pause' }); + const cleanup = resolvePresentation({ state }); await vi.waitFor(() => { - expect(state.current.presentation).toHaveProperty('id'); + expect(state.get().presentation).toHaveProperty('id'); }); cleanup(); @@ -321,19 +294,15 @@ variant1.m3u8`) variant1.m3u8`) ); - const state = createState({ + const state = signal({ presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'metadata', }); - const events = createEventStream(); - const cleanup = resolvePresentation({ state, events }); - - // Dispatch initial neutral event to prime combineLatest - events.dispatch({ type: 'pause' }); + const cleanup = resolvePresentation({ state }); await vi.waitFor(() => { - expect(state.current.presentation).toHaveProperty('id'); + expect(state.get().presentation).toHaveProperty('id'); }); cleanup(); @@ -347,21 +316,17 @@ variant1.m3u8`) const fetchSpy = vi.spyOn(globalThis, 'fetch'); - const state = createState({ + const state = signal({ presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'none', }); - const events = createEventStream(); - const cleanup = resolvePresentation({ state, events }); - - // Dispatch initial neutral event to prime combineLatest - events.dispatch({ type: 'pause' }); + const cleanup = resolvePresentation({ state }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(fetchSpy).not.toHaveBeenCalled(); - expect(state.current.presentation).toEqual({ url: 'http://example.com/playlist.m3u8' }); + expect(state.get().presentation).toEqual({ url: 'http://example.com/playlist.m3u8' }); cleanup(); }); @@ -374,31 +339,28 @@ variant1.m3u8`) const fetchSpy = vi.spyOn(globalThis, 'fetch'); - const state = createState({ + const state = signal({ presentation: { url: 'http://example.com/playlist.m3u8' }, preload: undefined, }); - const events = createEventStream(); - const cleanup = resolvePresentation({ state, events }); - - // Dispatch initial neutral event to prime combineLatest - events.dispatch({ type: 'pause' }); + const cleanup = resolvePresentation({ state }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(fetchSpy).not.toHaveBeenCalled(); - expect(state.current.presentation).toEqual({ url: 'http://example.com/playlist.m3u8' }); + expect(state.get().presentation).toEqual({ url: 'http://example.com/playlist.m3u8' }); cleanup(); }); }); - describe('event-driven resolution with combineLatest', () => { - it('resolves on PLAY event when preload is "none"', async () => { + describe('playbackInitiated resolution', () => { + it('resolves when playbackInitiated is set to true with preload "none"', async () => { interface State { presentation?: AddressableObject | Presentation | undefined; preload?: 'auto' | 'metadata' | 'none' | undefined; + playbackInitiated?: boolean; } const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( @@ -407,28 +369,22 @@ variant1.m3u8`) variant1.m3u8`) ); - const state = createState({ + const state = signal({ presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'none', }); - const events = createEventStream(); + const cleanup = resolvePresentation({ state }); - // Use resolvePresentation with combineLatest composition - const cleanup = resolvePresentation({ state, events }); - - // Dispatch initial neutral event to prime combineLatest - events.dispatch({ type: 'pause' }); - - // Initially shouldn't fetch (preload="none") + // Initially shouldn't fetch (preload="none", not yet initiated) expect(fetchSpy).not.toHaveBeenCalled(); - // Dispatch PLAY event - events.dispatch({ type: 'play' }); + // Set playbackInitiated (replaces the old 'play' event dispatch) + state.set({ ...state.get(), playbackInitiated: true }); // Wait for resolution await vi.waitFor(() => { - expect(state.current.presentation).toHaveProperty('id'); + expect(state.get().presentation).toHaveProperty('id'); }); expect(fetchSpy).toHaveBeenCalledOnce(); @@ -436,127 +392,26 @@ variant1.m3u8`) cleanup(); }); - it('does not resolve on non-PLAY events', async () => { + it('does not resolve when playbackInitiated is false with preload "none"', async () => { interface State { presentation?: AddressableObject | Presentation | undefined; preload?: 'auto' | 'metadata' | 'none' | undefined; + playbackInitiated?: boolean; } const fetchSpy = vi.spyOn(globalThis, 'fetch'); - const state = createState({ + const state = signal({ presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'none', }); - const events = createEventStream(); - - const cleanup = resolvePresentation({ state, events }); - - // Dispatch PAUSE event (not PLAY) - this primes combineLatest but shouldn't trigger resolution - events.dispatch({ type: 'pause' }); + const cleanup = resolvePresentation({ state }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(fetchSpy).not.toHaveBeenCalled(); - expect(state.current.presentation).toEqual({ url: 'http://example.com/playlist.m3u8' }); - - cleanup(); - }); - }); - - describe('syncPreloadAttribute', () => { - it('syncs preload from mediaElement to state', () => { - interface State { - presentation?: AddressableObject | Presentation | undefined; - preload?: 'auto' | 'metadata' | 'none' | undefined; - } - - interface Owners { - mediaElement?: MediaElementLike | undefined; - } - - const state = createState({ - presentation: undefined, - preload: undefined, - }); - - // Start with media element already set - const video = { preload: 'auto' } as PlatformOwners['mediaElement']; - const owners = createState({ - mediaElement: video, - }); - - // Sync should pick up existing mediaElement on subscription - const cleanup = syncPreloadAttribute(state, owners); - - expect(state.current.preload).toBe('auto'); - - cleanup(); - }); - - it('does not override preload when mediaElement changes and preload is already set', () => { - interface State { - presentation?: AddressableObject | Presentation | undefined; - preload?: 'auto' | 'metadata' | 'none' | undefined; - } - - interface Owners { - mediaElement?: MediaElementLike | undefined; - } - - const state = createState({ - presentation: undefined, - preload: undefined, - }); - - const video = { preload: 'auto' } as PlatformOwners['mediaElement']; - const owners = createState({ - mediaElement: video, - }); - - // Start syncing — initial inference from element - const cleanup = syncPreloadAttribute(state, owners); - - expect(state.current.preload).toBe('auto'); - - // Swap to a different mediaElement with a different preload value. - // Since preload is already set, the new element's value is ignored. - const updatedVideo = { preload: 'metadata' } as PlatformOwners['mediaElement']; - owners.patch({ mediaElement: updatedVideo }); - owners.flush(); - state.flush(); - - expect(state.current.preload).toBe('auto'); - - cleanup(); - }); - - it('does not clear preload when mediaElement is removed and preload is already set', () => { - interface State { - presentation?: AddressableObject | Presentation | undefined; - preload?: 'auto' | 'metadata' | 'none' | undefined; - } - - interface Owners { - mediaElement?: MediaElementLike | undefined; - } - - const state = createState({ - presentation: undefined, - preload: 'auto', - }); - - const owners = createState({ - mediaElement: undefined, - }); - - const cleanup = syncPreloadAttribute(state, owners); - - owners.patch({ mediaElement: undefined }); - - // Preload was already set — removing the element does not clear it. - expect(state.current.preload).toBe('auto'); + expect(state.get().presentation).toEqual({ url: 'http://example.com/playlist.m3u8' }); cleanup(); }); @@ -575,28 +430,23 @@ variant1.m3u8`) variant1.m3u8`) ); - const state = createState({ + const state = signal({ presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto', }); - const events = createEventStream(); - const cleanup = resolvePresentation({ state, events }); + const cleanup = resolvePresentation({ state }); - // Prime combineLatest and trigger first resolution - events.dispatch({ type: 'pause' }); - - // Rapidly dispatch more events while resolution is in progress - events.dispatch({ type: 'play' }); - events.dispatch({ type: 'pause' }); - events.dispatch({ type: 'play' }); + // Rapidly trigger additional state changes while resolution is in progress + state.set({ ...state.get(), preload: 'auto' }); + state.set({ ...state.get(), preload: 'auto' }); // Wait for resolution await vi.waitFor(() => { - expect(state.current.presentation).toHaveProperty('id'); + expect(state.get().presentation).toHaveProperty('id'); }); - // Should only fetch once despite multiple events + // Should only fetch once despite multiple state changes expect(fetchSpy).toHaveBeenCalledOnce(); cleanup(); @@ -615,29 +465,26 @@ variant1.m3u8`) variant1.m3u8`) ); - const state = createState({ + const state = signal({ presentation: { url: 'http://example.com/first.m3u8' }, preload: 'auto', }); - const events = createEventStream(); - const cleanup = resolvePresentation({ state, events }); - - events.dispatch({ type: 'pause' }); + const cleanup = resolvePresentation({ state }); // Wait for first resolution await vi.waitFor(() => { - expect(state.current.presentation).toHaveProperty('id'); + expect(state.get().presentation).toHaveProperty('id'); }); expect(fetchSpy).toHaveBeenCalledOnce(); // Now load a different presentation - state.patch({ presentation: { url: 'http://example.com/second.m3u8' } }); + state.set({ ...state.get(), presentation: { url: 'http://example.com/second.m3u8' } }); // Wait for second resolution await vi.waitFor(() => { - const pres = state.current.presentation as Presentation; + const pres = state.get().presentation as Presentation; expect(pres.url).toBe('http://example.com/second.m3u8'); }); @@ -651,55 +498,38 @@ variant1.m3u8`) describe('shouldResolve', () => { it('returns true when preload is "auto"', () => { - const result = shouldResolve( - { presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto' }, - { type: 'pause' } - ); + const result = shouldResolve({ presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto' }); expect(result).toBe(true); }); it('returns true when preload is "metadata"', () => { - const result = shouldResolve( - { presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'metadata' }, - { type: 'pause' } - ); + const result = shouldResolve({ presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'metadata' }); expect(result).toBe(true); }); - it('returns true on PLAY event when preload is "none"', () => { - const result = shouldResolve( - { presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'none' }, - { type: 'play' } - ); + it('returns true when playbackInitiated is true with preload "none"', () => { + const result = shouldResolve({ + presentation: { url: 'http://example.com/playlist.m3u8' }, + preload: 'none', + playbackInitiated: true, + }); expect(result).toBe(true); }); - it('returns false on non-PLAY event when preload is "none"', () => { - const result = shouldResolve( - { presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'none' }, - { type: 'pause' } - ); + it('returns false when preload is "none" and playbackInitiated is false', () => { + const result = shouldResolve({ presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'none' }); expect(result).toBe(false); }); it('returns false when preload is undefined', () => { - const result = shouldResolve( - { presentation: { url: 'http://example.com/playlist.m3u8' }, preload: undefined }, - { type: 'pause' } - ); - - expect(result).toBe(false); - }); - - it('returns false on LOAD event when preload is "none"', () => { - const result = shouldResolve( - { presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'none' }, - { type: 'load', url: 'http://example.com/other.m3u8' } - ); + const result = shouldResolve({ + presentation: { url: 'http://example.com/playlist.m3u8' }, + preload: undefined, + }); expect(result).toBe(false); }); diff --git a/packages/spf/src/core/features/tests/resolve-track.test.ts b/packages/spf/src/core/features/tests/resolve-track.test.ts index 380e1b3d..d7375fb9 100644 --- a/packages/spf/src/core/features/tests/resolve-track.test.ts +++ b/packages/spf/src/core/features/tests/resolve-track.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createEventStream } from '../../events/create-event-stream'; -import { createState } from '../../state/create-state'; +import { signal } from '../../signals/primitives'; import type { PartiallyResolvedAudioTrack, PartiallyResolvedTextTrack, @@ -8,7 +7,7 @@ import type { Presentation, } from '../../types'; import { isResolvedTrack } from '../../types'; -import type { TrackResolutionAction, TrackResolutionState } from '../resolve-track'; +import type { TrackResolutionState } from '../resolve-track'; import { resolveTrack } from '../resolve-track'; afterEach(() => { @@ -46,13 +45,11 @@ describe('resolveTrack (video)', () => { startTime: 0, }; - const state = createState({ + const state = signal({ presentation, selectedVideoTrackId: 'track-1', }); - const events = createEventStream(); - // Mock fetch to return media playlist with absolute URLs vi.spyOn(globalThis, 'fetch').mockImplementation( async () => @@ -67,18 +64,17 @@ http://example.com/segment2.m4s ); // Act - const cleanup = resolveTrack({ state, events }, { type: 'video' as const }); - events.dispatch({ type: 'pause' }); + const cleanup = resolveTrack({ state }, { type: 'video' as const }); // Wait for resolution await vi.waitFor(() => { - const { presentation: currentPres } = state.current; + const { presentation: currentPres } = state.get(); const track = findTrackById(currentPres!, 'track-1'); expect(isResolvedTrack(track!)).toBe(true); }); // Assert - track should now be resolved with segments - const resolvedPres = state.current.presentation!; + const resolvedPres = state.get().presentation!; const resolvedTrack = findTrackById(resolvedPres, 'track-1'); expect(isResolvedTrack(resolvedTrack!)).toBe(true); @@ -129,14 +125,12 @@ http://example.com/segment2.m4s startTime: 0, }; - const state = createState({ + const state = signal({ presentation, selectedVideoTrackId: 'track-1', }); - const events = createEventStream(); - const cleanup = resolveTrack({ state, events }, { type: 'video' as const }); - events.dispatch({ type: 'pause' }); + const cleanup = resolveTrack({ state }, { type: 'video' as const }); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -155,14 +149,12 @@ http://example.com/segment2.m4s startTime: 0, }; - const state = createState({ + const state = signal({ presentation, selectedVideoTrackId: undefined, }); - const events = createEventStream(); - const cleanup = resolveTrack({ state, events }, { type: 'video' as const }); - events.dispatch({ type: 'pause' }); + const cleanup = resolveTrack({ state }, { type: 'video' as const }); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -206,13 +198,11 @@ describe('resolveTrack (audio)', () => { startTime: 0, }; - const state = createState({ + const state = signal({ presentation, selectedAudioTrackId: 'audio-1', }); - const events = createEventStream(); - vi.spyOn(globalThis, 'fetch').mockImplementation( async () => new Response(`#EXTM3U @@ -223,16 +213,15 @@ http://example.com/segment1.m4s #EXT-X-ENDLIST`) ); - const cleanup = resolveTrack({ state, events }, { type: 'audio' as const }); - events.dispatch({ type: 'pause' }); + const cleanup = resolveTrack({ state }, { type: 'audio' as const }); await vi.waitFor(() => { - const { presentation: currentPres } = state.current; + const { presentation: currentPres } = state.get(); const track = findTrackById(currentPres!, 'audio-1'); expect(isResolvedTrack(track!)).toBe(true); }); - const resolvedPres = state.current.presentation!; + const resolvedPres = state.get().presentation!; const resolvedTrack = findTrackById(resolvedPres, 'audio-1'); expect(isResolvedTrack(resolvedTrack!)).toBe(true); @@ -278,13 +267,11 @@ describe('resolveTrack (text)', () => { startTime: 0, }; - const state = createState({ + const state = signal({ presentation, selectedTextTrackId: 'text-1', }); - const events = createEventStream(); - vi.spyOn(globalThis, 'fetch').mockImplementation( async () => new Response(`#EXTM3U @@ -298,16 +285,15 @@ http://example.com/subtitle1.webvtt #EXT-X-ENDLIST`) ); - const cleanup = resolveTrack({ state, events }, { type: 'text' as const }); - events.dispatch({ type: 'pause' }); + const cleanup = resolveTrack({ state }, { type: 'text' as const }); await vi.waitFor(() => { - const { presentation: currentPres } = state.current; + const { presentation: currentPres } = state.get(); const track = findTrackById(currentPres!, 'text-1'); expect(isResolvedTrack(track!)).toBe(true); }); - const resolvedPres = state.current.presentation!; + const resolvedPres = state.get().presentation!; const resolvedTrack = findTrackById(resolvedPres, 'text-1'); expect(isResolvedTrack(resolvedTrack!)).toBe(true); @@ -354,11 +340,10 @@ describe('resolveTrack — concurrent resolution', () => { startTime: 0, }; - const state = createState({ + const state = signal({ presentation, selectedVideoTrackId: 'track-a', }); - const events = createEventStream(); const makePlaylist = (segUrl: string) => `#EXTM3U #EXT-X-TARGETDURATION:10 @@ -373,15 +358,14 @@ ${segUrl} return Promise.resolve(new Response(makePlaylist('http://example.com/b-seg1.m4s'))); }); - const cleanup = resolveTrack({ state, events }, { type: 'video' as const }); - events.dispatch({ type: 'pause' }); + const cleanup = resolveTrack({ state }, { type: 'video' as const }); // While track-a resolution is in flight, switch to track-b. - state.patch({ selectedVideoTrackId: 'track-b' }); + state.set({ ...state.get(), selectedVideoTrackId: 'track-b' }); // Both tracks should be resolved (concurrently, neither aborts the other). await vi.waitFor(() => { - const pres = state.current.presentation!; + const pres = state.get().presentation!; expect(isResolvedTrack(findTrackById(pres, 'track-a')!)).toBe(true); expect(isResolvedTrack(findTrackById(pres, 'track-b')!)).toBe(true); }); @@ -420,11 +404,10 @@ ${segUrl} startTime: 0, }; - const state = createState({ + const state = signal({ presentation, selectedVideoTrackId: 'track-a', }); - const events = createEventStream(); const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation( async () => @@ -435,15 +418,14 @@ http://example.com/a-seg1.m4s #EXT-X-ENDLIST`) ); - const cleanup = resolveTrack({ state, events }, { type: 'video' as const }); - events.dispatch({ type: 'pause' }); + const cleanup = resolveTrack({ state }, { type: 'video' as const }); // Trigger multiple state changes while track-a is resolving. - state.patch({ selectedVideoTrackId: 'track-a' }); - state.patch({ selectedVideoTrackId: 'track-a' }); + state.set({ ...state.get(), selectedVideoTrackId: 'track-a' }); + state.set({ ...state.get(), selectedVideoTrackId: 'track-a' }); await vi.waitFor(() => { - expect(isResolvedTrack(findTrackById(state.current.presentation!, 'track-a')!)).toBe(true); + expect(isResolvedTrack(findTrackById(state.get().presentation!, 'track-a')!)).toBe(true); }); // Should only have been fetched once despite multiple state triggers. diff --git a/packages/spf/src/core/features/tests/select-tracks.test.ts b/packages/spf/src/core/features/tests/select-tracks.test.ts index b12291c0..7be27792 100644 --- a/packages/spf/src/core/features/tests/select-tracks.test.ts +++ b/packages/spf/src/core/features/tests/select-tracks.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createEventStream } from '../../events/create-event-stream'; -import { createState } from '../../state/create-state'; +import { signal } from '../../signals/primitives'; import type { AudioSelectionSet, PartiallyResolvedAudioTrack, @@ -16,8 +15,6 @@ import { selectAudioTrack, selectTextTrack, selectVideoTrack, - type TrackSelectionAction, - type TrackSelectionOwners, type TrackSelectionState, } from '../select-tracks'; @@ -634,17 +631,14 @@ describe('selectVideoTrack', () => { const presentation = createPresentation({ video: videoTracks }); - const state = createState({ presentation }); - const owners = createState({}); - const events = createEventStream(); + const state = signal({ presentation }); - const cleanup = selectVideoTrack({ state, owners, events }, { type: 'video' }); - events.dispatch({ type: 'presentation-loaded' }); + const cleanup = selectVideoTrack({ state }, { type: 'video' }); // Wait for selection await new Promise((resolve) => setTimeout(resolve, 50)); - expect(state.current.selectedVideoTrackId).toBe('video-360p'); + expect(state.get().selectedVideoTrackId).toBe('video-360p'); cleanup(); }); @@ -652,18 +646,16 @@ describe('selectVideoTrack', () => { it('does not select when video track already selected', async () => { const presentation = createPresentation({ video: [] }); - const state = createState({ + const state = signal({ presentation, selectedVideoTrackId: 'existing-video', }); - const owners = createState({}); - const events = createEventStream(); - const cleanup = selectVideoTrack({ state, owners, events }, { type: 'video' }); + const cleanup = selectVideoTrack({ state }, { type: 'video' }); await new Promise((resolve) => setTimeout(resolve, 50)); - expect(state.current.selectedVideoTrackId).toBe('existing-video'); + expect(state.get().selectedVideoTrackId).toBe('existing-video'); cleanup(); }); @@ -690,16 +682,13 @@ describe('selectVideoTrack', () => { const presentation = createPresentation({ video: videoTracks }); - const state = createState({ presentation }); - const owners = createState({}); - const events = createEventStream(); + const state = signal({ presentation }); - const cleanup = selectVideoTrack({ state, owners, events }, { initialBandwidth: 3_000_000, type: 'video' }); - events.dispatch({ type: 'presentation-loaded' }); + const cleanup = selectVideoTrack({ state }, { initialBandwidth: 3_000_000, type: 'video' }); await new Promise((resolve) => setTimeout(resolve, 50)); - expect(state.current.selectedVideoTrackId).toBe('video-720p'); + expect(state.get().selectedVideoTrackId).toBe('video-720p'); cleanup(); }); @@ -724,16 +713,13 @@ describe('selectAudioTrack', () => { const presentation = createPresentation({ audio: audioTracks }); - const state = createState({ presentation }); - const owners = createState({}); - const events = createEventStream(); + const state = signal({ presentation }); - const cleanup = selectAudioTrack({ state, owners, events }, { type: 'audio' }); - events.dispatch({ type: 'presentation-loaded' }); + const cleanup = selectAudioTrack({ state }, { type: 'audio' }); await new Promise((resolve) => setTimeout(resolve, 50)); - expect(state.current.selectedAudioTrackId).toBe('audio-en'); + expect(state.get().selectedAudioTrackId).toBe('audio-en'); cleanup(); }); @@ -741,18 +727,16 @@ describe('selectAudioTrack', () => { it('does not select when audio track already selected', async () => { const presentation = createPresentation({ audio: [] }); - const state = createState({ + const state = signal({ presentation, selectedAudioTrackId: 'existing-audio', }); - const owners = createState({}); - const events = createEventStream(); - const cleanup = selectAudioTrack({ state, owners, events }, { type: 'audio' }); + const cleanup = selectAudioTrack({ state }, { type: 'audio' }); await new Promise((resolve) => setTimeout(resolve, 50)); - expect(state.current.selectedAudioTrackId).toBe('existing-audio'); + expect(state.get().selectedAudioTrackId).toBe('existing-audio'); cleanup(); }); @@ -789,16 +773,13 @@ describe('selectAudioTrack', () => { const presentation = createPresentation({ audio: audioTracks }); - const state = createState({ presentation }); - const owners = createState({}); - const events = createEventStream(); + const state = signal({ presentation }); - const cleanup = selectAudioTrack({ state, owners, events }, { type: 'audio', preferredAudioLanguage: 'es' }); - events.dispatch({ type: 'presentation-loaded' }); + const cleanup = selectAudioTrack({ state }, { type: 'audio', preferredAudioLanguage: 'es' }); await new Promise((resolve) => setTimeout(resolve, 50)); - expect(state.current.selectedAudioTrackId).toBe('audio-es'); + expect(state.get().selectedAudioTrackId).toBe('audio-es'); cleanup(); }); @@ -821,15 +802,13 @@ describe('selectTextTrack', () => { const presentation = createPresentation({ text: textTracks }); - const state = createState({ presentation }); - const owners = createState({}); - const events = createEventStream(); + const state = signal({ presentation }); - const cleanup = selectTextTrack({ state, owners, events }, { type: 'text' }); + const cleanup = selectTextTrack({ state }, { type: 'text' }); await new Promise((resolve) => setTimeout(resolve, 50)); - expect(state.current.selectedTextTrackId).toBeUndefined(); + expect(state.get().selectedTextTrackId).toBeUndefined(); cleanup(); }); diff --git a/packages/spf/src/core/features/tests/sync-preload-attribute.test.ts b/packages/spf/src/core/features/tests/sync-preload-attribute.test.ts new file mode 100644 index 00000000..95840860 --- /dev/null +++ b/packages/spf/src/core/features/tests/sync-preload-attribute.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from 'vitest'; +import { signal } from '../../signals/primitives'; +import type { AddressableObject, Presentation } from '../../types'; +import { syncPreloadAttribute } from '../sync-preload-attribute'; + +describe('syncPreloadAttribute', () => { + it('syncs preload from mediaElement to state', async () => { + interface State { + presentation?: AddressableObject | Presentation | undefined; + preload?: 'auto' | 'metadata' | 'none' | undefined; + } + + interface Owners { + mediaElement?: HTMLMediaElement | undefined; + } + + const state = signal({ + presentation: undefined, + preload: undefined, + }); + + // Start with media element already set + const video = { preload: 'auto' } as HTMLMediaElement; + const owners = signal({ + mediaElement: video, + }); + + // Sync should pick up existing mediaElement on first effect fire (synchronous) + const cleanup = syncPreloadAttribute({ state, owners }); + + expect(state.get().preload).toBe('auto'); + + cleanup(); + }); + + it('does not override preload when mediaElement changes and preload is already set', async () => { + interface State { + presentation?: AddressableObject | Presentation | undefined; + preload?: 'auto' | 'metadata' | 'none' | undefined; + } + + interface Owners { + mediaElement?: HTMLMediaElement | undefined; + } + + const state = signal({ + presentation: undefined, + preload: undefined, + }); + + const video = { preload: 'auto' } as HTMLMediaElement; + const owners = signal({ + mediaElement: video, + }); + + // Start syncing — initial inference from element (synchronous) + const cleanup = syncPreloadAttribute({ state, owners }); + + expect(state.get().preload).toBe('auto'); + + // Swap to a different mediaElement with a different preload value. + // Since preload is already set, the new element's value is ignored. + const updatedVideo = { preload: 'metadata' } as HTMLMediaElement; + owners.set({ ...owners.get(), mediaElement: updatedVideo }); + + await vi.waitFor(() => { + // preload should remain 'auto' — new element's value is not applied once preload is set + expect(state.get().preload).toBe('auto'); + }); + + cleanup(); + }); + + it('does not loop when mediaElement is absent and preload is unset', () => { + interface State { + preload?: 'auto' | 'metadata' | 'none' | undefined; + } + + interface Owners { + mediaElement?: HTMLMediaElement | undefined; + } + + const state = signal({ preload: undefined }); + const owners = signal({ mediaElement: undefined }); + + // Should not throw / hang — the effect must exit without writing when + // both preload and mediaElement are absent. + const cleanup = syncPreloadAttribute({ state, owners }); + + expect(state.get().preload).toBeUndefined(); + + cleanup(); + }); + + it('does not loop when mediaElement has no preload attribute and preload is unset', () => { + interface State { + preload?: 'auto' | 'metadata' | 'none' | undefined; + } + + interface Owners { + mediaElement?: HTMLMediaElement | undefined; + } + + const state = signal({ preload: undefined }); + // Browsers return '' when the preload attribute is absent + const owners = signal({ mediaElement: { preload: '' } as HTMLMediaElement }); + + const cleanup = syncPreloadAttribute({ state, owners }); + + expect(state.get().preload).toBeUndefined(); + + cleanup(); + }); + + it('does not clear preload when mediaElement is removed and preload is already set', async () => { + interface State { + presentation?: AddressableObject | Presentation | undefined; + preload?: 'auto' | 'metadata' | 'none' | undefined; + } + + interface Owners { + mediaElement?: HTMLMediaElement | undefined; + } + + const state = signal({ + presentation: undefined, + preload: 'auto', + }); + + const owners = signal({ + mediaElement: undefined, + }); + + const cleanup = syncPreloadAttribute({ state, owners }); + + owners.set({ ...owners.get(), mediaElement: undefined }); + + // Preload was already set — removing the element does not clear it. + expect(state.get().preload).toBe('auto'); + + cleanup(); + }); +}); diff --git a/packages/spf/src/core/reactive/combine-latest.ts b/packages/spf/src/core/reactive/combine-latest.ts deleted file mode 100644 index f7835cd5..00000000 --- a/packages/spf/src/core/reactive/combine-latest.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Minimal combineLatest operator for reactive composition. - * - * Combines multiple Observable-like sources and emits an array of their latest values - * whenever any source emits. - */ - -/** - * Observable-like interface. - * Both State and EventStream conform to this shape. - */ -export interface Observable { - subscribe(listener: (value: T) => void): () => void; -} - -/** - * Infer value types from array of Observables. - */ -export type InferObservableValues[]> = { - [K in keyof T]: T[K] extends Observable ? V : never; -}; - -/** - * Options for selector-based subscriptions on a combined observable. - */ -export interface SelectorOptions { - equalityFn?: (a: R, b: R) => boolean; -} - -/** - * Observable returned by combineLatest — supports both plain and - * selector-based subscriptions. - */ -export interface CombinedObservable extends Observable { - subscribe(listener: (value: T) => void): () => void; - subscribe(selector: (value: T) => R, listener: (value: R) => void, options?: SelectorOptions): () => void; -} - -/** - * Combines multiple Observable sources into a single Observable. - * - * Emits an array of latest values whenever any source emits. - * Only emits after all sources have emitted at least once. - * - * Supports selector-based subscriptions (fires only when the selected value - * changes, per the optional equalityFn) mirroring the createState API. - * - * @param sources - Array of Observable sources - * @returns Combined Observable - * - * @example - * ```ts - * const state = createState({ count: 0 }); - * const events = createEventStream(); - * - * combineLatest([state, events]).subscribe(([state, event]) => { - * if (event.type === 'PLAY' && state.count > 0) { - * // React to event + state condition - * } - * }); - * ``` - * - * @example Selector subscription - * ```ts - * combineLatest([state, owners]).subscribe( - * ([s, o]) => deriveKey(s, o), - * (key) => { ... }, - * { equalityFn: keyEq } - * ); - * ``` - */ -export function combineLatest[]>( - sources: [...T] -): CombinedObservable> { - type Values = InferObservableValues; - - const subscribeToSources = (listener: (values: Values) => void): (() => void) => { - const latest: unknown[] = new Array(sources.length); - const hasValue: boolean[] = new Array(sources.length).fill(false); - const unsubscribers: Array<() => void> = []; - - for (let i = 0; i < sources.length; i++) { - const unsubscribe = sources[i]!.subscribe((value) => { - latest[i] = value; - hasValue[i] = true; - if (hasValue.every((has) => has)) { - listener([...latest] as Values); - } - }); - unsubscribers.push(unsubscribe); - } - - return () => { - for (const unsubscribe of unsubscribers) { - unsubscribe(); - } - }; - }; - - return { - subscribe( - listenerOrSelector: ((values: Values) => void) | ((values: Values) => R), - maybeListener?: (value: R) => void, - options?: SelectorOptions - ): () => void { - if (maybeListener === undefined) { - return subscribeToSources(listenerOrSelector as (values: Values) => void); - } - - const selector = listenerOrSelector as (values: Values) => R; - const listener = maybeListener; - const equalityFn = options?.equalityFn ?? Object.is; - - let prevSelected: R | undefined; - let initialized = false; - - return subscribeToSources((values) => { - const nextSelected = selector(values); - if (!initialized || !equalityFn(prevSelected as R, nextSelected)) { - prevSelected = nextSelected; - initialized = true; - listener(nextSelected); - } - }); - }, - }; -} diff --git a/packages/spf/src/core/reactive/tests/combine-latest.test.ts b/packages/spf/src/core/reactive/tests/combine-latest.test.ts deleted file mode 100644 index 9dacf799..00000000 --- a/packages/spf/src/core/reactive/tests/combine-latest.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { createEventStream } from '../../events/create-event-stream'; -import { createState } from '../../state/create-state'; -import { combineLatest } from '../combine-latest'; - -describe('combineLatest', () => { - it('combines state and event stream', () => { - const state = createState({ count: 0 }); - const events = createEventStream<{ type: 'increment' }>(); - - const combined = combineLatest([state, events]); - - expect(combined).toBeDefined(); - expect(typeof combined.subscribe).toBe('function'); - }); - - it('emits after all sources have emitted', () => { - const state = createState({ count: 0 }); - const events = createEventStream(); - const listener = vi.fn(); - - const combined = combineLatest([state, events]); - combined.subscribe(listener); - listener.mockClear(); // Clear initial call - - // Both have emitted (state immediately, waiting for event) - events.dispatch({ type: 'test' }); - - expect(listener).toHaveBeenCalledOnce(); - expect(listener).toHaveBeenCalledWith([{ count: 0 }, { type: 'test' }]); - }); - - it('emits when state changes', () => { - const state = createState({ count: 0 }); - const events = createEventStream(); - const listener = vi.fn(); - - const combined = combineLatest([state, events]); - combined.subscribe(listener); - listener.mockClear(); - - // Trigger initial event emission - events.dispatch({ type: 'init' }); - listener.mockClear(); - - // Change state - state.patch({ count: 1 }); - state.flush(); - - expect(listener).toHaveBeenCalledOnce(); - expect(listener).toHaveBeenCalledWith([{ count: 1 }, { type: 'init' }]); - }); - - it('emits when event is dispatched', () => { - const state = createState({ count: 5 }); - const events = createEventStream<{ type: string; value: number }>(); - const listener = vi.fn(); - - const combined = combineLatest([state, events]); - combined.subscribe(listener); - listener.mockClear(); - - // Trigger initial event - events.dispatch({ type: 'first', value: 1 }); - listener.mockClear(); - - // Dispatch another event - events.dispatch({ type: 'second', value: 2 }); - - expect(listener).toHaveBeenCalledOnce(); - expect(listener).toHaveBeenCalledWith([{ count: 5 }, { type: 'second', value: 2 }]); - }); - - it('emits latest values from all sources', () => { - const state = createState({ count: 0 }); - const events = createEventStream(); - const listener = vi.fn(); - - const combined = combineLatest([state, events]); - combined.subscribe(listener); - listener.mockClear(); - - // Initial event - events.dispatch({ type: 'first' }); - listener.mockClear(); - - // Update state - state.patch({ count: 1 }); - state.flush(); - - // Latest from both: count=1, type='first' - expect(listener).toHaveBeenCalledWith([{ count: 1 }, { type: 'first' }]); - - listener.mockClear(); - - // Dispatch new event - events.dispatch({ type: 'second' }); - - // Latest from both: count=1 (unchanged), type='second' - expect(listener).toHaveBeenCalledWith([{ count: 1 }, { type: 'second' }]); - }); - - it('returns unsubscribe function', () => { - const state = createState({ count: 0 }); - const events = createEventStream(); - const listener = vi.fn(); - - const combined = combineLatest([state, events]); - const unsubscribe = combined.subscribe(listener); - - expect(typeof unsubscribe).toBe('function'); - - unsubscribe(); - listener.mockClear(); - - state.patch({ count: 1 }); - state.flush(); - - expect(listener).not.toHaveBeenCalled(); - }); - - it('unsubscribes from all inner sources', () => { - const state = createState({ count: 0 }); - const events = createEventStream(); - const listener = vi.fn(); - - const combined = combineLatest([state, events]); - const unsubscribe = combined.subscribe(listener); - - unsubscribe(); - listener.mockClear(); - - // Neither should trigger - state.patch({ count: 1 }); - state.flush(); - events.dispatch({ type: 'test' }); - - expect(listener).not.toHaveBeenCalled(); - }); - - it('supports multiple subscribers', () => { - const state = createState({ count: 0 }); - const events = createEventStream(); - const listener1 = vi.fn(); - const listener2 = vi.fn(); - - const combined = combineLatest([state, events]); - combined.subscribe(listener1); - combined.subscribe(listener2); - - listener1.mockClear(); - listener2.mockClear(); - - events.dispatch({ type: 'test' }); - - expect(listener1).toHaveBeenCalledOnce(); - expect(listener2).toHaveBeenCalledOnce(); - }); - - it('combines three sources', () => { - const state = createState({ count: 0 }); - const events = createEventStream(); - const owners = createState({ element: null as object | null }); - - const listener = vi.fn(); - - const combined = combineLatest([state, events, owners]); - combined.subscribe(listener); - listener.mockClear(); - - // Trigger emissions from all sources - events.dispatch({ type: 'init' }); - listener.mockClear(); - - state.patch({ count: 1 }); - state.flush(); - - expect(listener).toHaveBeenCalledWith([{ count: 1 }, { type: 'init' }, { element: null }]); - }); -}); diff --git a/packages/spf/src/core/signals/effect.ts b/packages/spf/src/core/signals/effect.ts new file mode 100644 index 00000000..25773099 --- /dev/null +++ b/packages/spf/src/core/signals/effect.ts @@ -0,0 +1,43 @@ +import { Signal } from 'signal-polyfill'; + +// Computeds waiting to re-run after their dependencies changed. +const pending = new Set>(); + +const watcher = new Signal.subtle.Watcher(() => { + queueMicrotask(runPending); +}); + +function runPending() { + for (const c of watcher.getPending()) { + pending.add(c as Signal.Computed); + } + watcher.watch(); // re-arm before running effects, in case they write signals + for (const c of pending) { + pending.delete(c); + c.get(); // re-run the effect body + } +} + +/** + * Run a side effect whenever its signal dependencies change. + * + * Executes immediately (synchronous initial run), then re-runs on the next + * microtask after any dependency changes. If the callback returns a function, + * it is called before each re-run and when the effect is stopped — the same + * cleanup contract as Preact Signals, Maverick Signals, and Svelte 5 $effect. + * + * Returns a cleanup function that stops the effect. + */ +export function effect(fn: () => (() => void) | void): () => void { + let cleanup: (() => void) | void; + const c = new Signal.Computed(() => { + if (typeof cleanup === 'function') cleanup(); + cleanup = fn(); + }); + watcher.watch(c); + c.get(); // initial run + return () => { + watcher.unwatch(c); + if (typeof cleanup === 'function') cleanup(); + }; +} diff --git a/packages/spf/src/core/signals/primitives.ts b/packages/spf/src/core/signals/primitives.ts new file mode 100644 index 00000000..66b59dec --- /dev/null +++ b/packages/spf/src/core/signals/primitives.ts @@ -0,0 +1,41 @@ +import { Signal as SignalNS } from 'signal-polyfill'; + +/** Read a signal value without tracking it as a dependency. */ +export const untrack: (fn: () => T) => T = SignalNS.subtle.untrack; + +/** A writable reactive value (read + write). */ +export type Signal = SignalNS.State; + +/** A derived reactive value that re-evaluates when its dependencies change (read-only). */ +export type Computed = SignalNS.Computed; + +/** A read-only view of a reactive value. */ +export type ReadonlySignal = Omit, 'set'>; + +export interface SignalOptions { + equals?: (t: T, t2: T) => boolean; +} + +/** Create a writable reactive value. */ +export function signal(initialValue: T, options?: SignalOptions): Signal { + return new SignalNS.State(initialValue, options as SignalNS.Options); +} + +/** Create a computed reactive value. */ +export function computed(fn: () => T, options?: SignalOptions): Computed { + return new SignalNS.Computed(fn, options as SignalNS.Options); +} + +/** + * Update a writable signal. Accepts either a partial object to merge into the + * current state, or an updater function that receives the current state and + * returns the next state. + * + * @example + * update(state, { playbackRate: 2 }); + * update(state, (s) => ({ ...s, count: s.count + 1 })); + */ +export function update(signal: Signal, updater: Partial | ((current: T) => T)): void { + const current = untrack(() => signal.get()); + signal.set(typeof updater === 'function' ? updater(current) : { ...current, ...updater }); +} diff --git a/packages/spf/src/core/signals/tests/primitives.test.ts b/packages/spf/src/core/signals/tests/primitives.test.ts new file mode 100644 index 00000000..c5b473b1 --- /dev/null +++ b/packages/spf/src/core/signals/tests/primitives.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { signal, update } from '../primitives'; + +describe('update', () => { + it('merges a partial object into current state', () => { + const s = signal({ a: 1, b: 2 }); + update(s, { b: 3 }); + expect(s.get()).toEqual({ a: 1, b: 3 }); + }); + + it('preserves unmentioned keys when merging partial', () => { + const s = signal({ x: 10, y: 20, z: 30 }); + update(s, { y: 99 }); + expect(s.get()).toEqual({ x: 10, y: 99, z: 30 }); + }); + + it('applies an updater function with the current state', () => { + const s = signal({ count: 0 }); + update(s, (current) => ({ ...current, count: current.count + 1 })); + expect(s.get().count).toBe(1); + }); + + it('updater function receives the latest state value', () => { + const s = signal({ count: 5 }); + update(s, (current) => ({ ...current, count: current.count * 2 })); + expect(s.get().count).toBe(10); + }); + + it('empty partial leaves state unchanged', () => { + const s = signal({ a: 1 }); + update(s, {}); + expect(s.get()).toEqual({ a: 1 }); + }); +}); diff --git a/packages/spf/src/core/state/create-state.ts b/packages/spf/src/core/state/create-state.ts deleted file mode 100644 index 875ca3d0..00000000 --- a/packages/spf/src/core/state/create-state.ts +++ /dev/null @@ -1,304 +0,0 @@ -/** - * Reactive state container with selectors, custom equality, and batched updates. - * - * Manages both immutable state values and mutable object references (e.g., HTMLMediaElement). - */ - -// ============================================================================= -// Symbol for type identification -// ============================================================================= - -const STATE_SYMBOL = Symbol('@videojs/spf/state'); - -// ============================================================================= -// Types -// ============================================================================= - -/** - * State change listener for full-state subscriptions. - */ -export type StateListener = (current: T) => void; - -/** - * Selector function to derive a value from state. - */ -export type StateSelector = (state: T) => R; - -/** - * Listener for selector-based subscriptions. - */ -export type SelectorListener = (current: R) => void; - -/** - * Options for selector subscriptions. - */ -export interface SelectorOptions { - /** - * Custom equality function for selected value. - * Defaults to Object.is - */ - equalityFn?: (a: R, b: R) => boolean; -} - -/** - * Configuration for creating a state container. - */ -export interface StateConfig { - /** - * Custom equality function for change detection. - * Defaults to Object.is for top-level comparison. - */ - equalityFn?: (a: T, b: T) => boolean; -} - -/** - * Read-only state interface. - */ -export interface State { - /** Symbol for type identification */ - readonly [STATE_SYMBOL]: true; - - /** Current state snapshot */ - readonly current: T; - - /** Subscribe to state changes */ - subscribe(listener: StateListener): () => void; -} - -/** - * Writable state interface with patch capability. - */ -export interface WritableState extends State { - /** Update state with partial object (for object state) or full value (for primitive state) */ - patch(partial: T extends object ? Partial : T): void; - - /** Subscribe to full-state changes */ - subscribe(listener: StateListener): () => void; - - /** Subscribe to selected value changes */ - subscribe(selector: StateSelector, listener: SelectorListener, options?: SelectorOptions): () => void; - - /** Manually flush pending updates */ - flush(): void; -} - -/** - * Entry for selector subscriptions. - */ -interface SelectorEntry { - selector: StateSelector; - listener: SelectorListener; - options: SelectorOptions; -} - -// ============================================================================= -// Default equality function -// ============================================================================= - -/** - * Default equality function using Object.is. - */ -function defaultEquality(a: T, b: T): boolean { - return Object.is(a, b); -} - -// ============================================================================= -// Implementation -// ============================================================================= - -/** - * State container implementation. - */ -class StateContainer implements WritableState { - [STATE_SYMBOL] = true as const; - - #current: T; - #pending: T | null = null; - #pendingFlush = false; - #equalityFn: (a: T, b: T) => boolean; - - // Full-state listeners - #listeners = new Set>(); - - // Selector-based listeners - #selectorListeners = new Set>(); - - constructor(initial: T, config?: StateConfig) { - this.#current = typeof initial === 'object' && initial !== null ? ({ ...initial } as T) : initial; - this.#equalityFn = config?.equalityFn ?? defaultEquality; - } - - get current(): T { - // Return pending state if available (for chained patches) - return this.#pending ?? this.#current; - } - - patch(partial: T extends object ? Partial : T): void { - const base = this.#pending ?? this.#current; - - if (typeof base !== 'object' || base === null) { - // Primitive state: replace the whole value - const value = partial as T; - if (!Object.is(base, value)) { - this.#pending = value; - this.#scheduleFlush(); - } - return; - } - - const next = { ...base } as T; - let changed = false; - - // Apply partial updates with change detection - for (const key in partial) { - if (!Object.hasOwn(partial as any, key)) continue; - - const value = (partial as Partial)[key as keyof T]; - - if (!Object.is((base as Partial)[key as keyof T], value)) { - (next as Partial)[key as keyof T] = value; - changed = true; - } - } - - if (changed) { - this.#pending = next; - this.#scheduleFlush(); - } - } - - subscribe(listener: StateListener): () => void; - subscribe(selector: StateSelector, listener: SelectorListener, options?: SelectorOptions): () => void; - subscribe( - selectorOrListener: StateListener | StateSelector, - maybeListener?: SelectorListener, - options?: SelectorOptions - ): () => void { - // Overload 1: Full-state subscription - if (maybeListener === undefined) { - const listener = selectorOrListener as StateListener; - this.#listeners.add(listener); - - // Fire immediately with current state - listener(this.current); - - return () => { - this.#listeners.delete(listener); - }; - } - - // Overload 2: Selector subscription - const selector = selectorOrListener as StateSelector; - const listener = maybeListener; - const opts = options ?? {}; - - const entry: SelectorEntry = { - selector: selector as StateSelector, - listener: listener as SelectorListener, - options: opts as SelectorOptions, - }; - - this.#selectorListeners.add(entry); - - // Fire immediately with current selected value - const selected = selector(this.current); - listener(selected); - - return () => { - this.#selectorListeners.delete(entry); - }; - } - - flush(): void { - if (this.#pending === null) return; - - const prev = this.#current; - const next = this.#pending; - this.#pending = null; - this.#pendingFlush = false; - - // Check if state actually changed using custom equality - if (this.#equalityFn(prev, next)) return; - - this.#current = next; - - // Notify full-state listeners - for (const listener of this.#listeners) { - listener(this.#current); - } - - // Notify selector listeners - for (const entry of this.#selectorListeners) { - const prevSelected = entry.selector(prev); - const nextSelected = entry.selector(this.#current); - - const equalityFn = entry.options.equalityFn ?? Object.is; - - if (!equalityFn(prevSelected, nextSelected)) { - entry.listener(nextSelected); - } - } - } - - #scheduleFlush(): void { - if (this.#pendingFlush) return; - this.#pendingFlush = true; - queueMicrotask(() => this.flush()); - } -} - -// ============================================================================= -// Factory function -// ============================================================================= - -/** - * Create a reactive state container. - * - * @example - * ```typescript - * const state = createState({ count: 0 }); - * - * // Subscribe to changes - * state.subscribe((current, prev) => { - * console.log('Changed:', prev, '->', current); - * }); - * - * // Updates are batched - * state.patch({ count: 1 }); - * state.patch({ count: 2 }); - * // Only one notification fires (with count: 2) - * ``` - * - * @example Selector subscriptions - * ```typescript - * const state = createState({ count: 0, name: 'test' }); - * - * // Only notified when count changes - * state.subscribe( - * s => s.count, - * (current, prev) => console.log(current, prev) - * ); - * ``` - * - * @example Custom equality - * ```typescript - * const state = createState( - * { count: 0, name: 'test' }, - * { equalityFn: (a, b) => a.count === b.count } - * ); - * ``` - */ -export function createState(initial: T, config?: StateConfig): WritableState { - return new StateContainer(initial, config); -} - -// ============================================================================= -// Type guard -// ============================================================================= - -/** - * Check if a value is a State container. - */ -export function isState(value: unknown): value is State { - return typeof value === 'object' && value !== null && STATE_SYMBOL in value; -} diff --git a/packages/spf/src/core/state/tests/create-state.test.ts b/packages/spf/src/core/state/tests/create-state.test.ts deleted file mode 100644 index 8af6bee4..00000000 --- a/packages/spf/src/core/state/tests/create-state.test.ts +++ /dev/null @@ -1,947 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { StateConfig } from '../create-state'; -import { createState, isState } from '../create-state'; - -describe('createState', () => { - describe('factory function', () => { - it('creates a state container with initial value', () => { - const state = createState({ count: 0 }); - - expect(state.current).toEqual({ count: 0 }); - }); - - it('accepts optional config parameter', () => { - const config: StateConfig<{ count: number }> = { - equalityFn: (a, b) => a.count === b.count, - }; - const state = createState({ count: 0 }, config); - - expect(state.current).toEqual({ count: 0 }); - }); - - it('does not use initial object reference directly', () => { - const initial = { count: 0 }; - const state = createState(initial); - - // Should be a new object (frozen copy), not the same reference - expect(state.current).not.toBe(initial); - expect(state.current).toEqual(initial); - }); - - it('protects against mutation of initial object', () => { - const initial = { count: 0 }; - const state = createState(initial); - - // Mutating the original should not affect state - initial.count = 999; - - expect(state.current.count).toBe(0); - }); - - it('creates independent state containers', () => { - const state1 = createState({ value: 1 }); - const state2 = createState({ value: 2 }); - - expect(state1.current).not.toBe(state2.current); - expect(state1.current.value).toBe(1); - expect(state2.current.value).toBe(2); - }); - }); - - describe('patch', () => { - it('updates state with partial object', () => { - const state = createState({ count: 0, name: 'test' }); - - state.patch({ count: 1 }); - - expect(state.current).toEqual({ count: 1, name: 'test' }); - }); - - it('creates new reference after patch', () => { - const state = createState({ count: 0 }); - const before = state.current; - - state.patch({ count: 1 }); - const after = state.current; - - expect(after).not.toBe(before); - }); - - it('uses Object.is for change detection', () => { - const state = createState({ count: 0 }); - const listener = vi.fn(); - state.subscribe(listener); - listener.mockClear(); // Clear initial call - - // Same value (no change) - state.patch({ count: 0 }); - state.flush(); - expect(listener).not.toHaveBeenCalled(); - - // Different value (change) - state.patch({ count: 1 }); - state.flush(); - expect(listener).toHaveBeenCalledTimes(1); - }); - - it('handles multiple property updates in one patch', () => { - const state = createState({ a: 1, b: 2, c: 3 }); - - state.patch({ a: 10, c: 30 }); - - expect(state.current).toEqual({ a: 10, b: 2, c: 30 }); - }); - - it('does not trigger update when no values change', () => { - const state = createState({ count: 0, name: 'test' }); - const listener = vi.fn(); - state.subscribe(listener); - listener.mockClear(); // Clear initial call - const before = state.current; - - state.patch({ count: 0, name: 'test' }); - state.flush(); - - expect(state.current).toBe(before); - expect(listener).not.toHaveBeenCalled(); - }); - - it('handles undefined values', () => { - const state = createState<{ value?: number | undefined }>({ value: 42 }); - - state.patch({ value: undefined }); - - expect(state.current.value).toBeUndefined(); - }); - - it('handles null values', () => { - const state = createState<{ value: number | null }>({ value: 42 }); - - state.patch({ value: null }); - - expect(state.current.value).toBeNull(); - }); - - it('ignores inherited properties from partial', () => { - const state = createState({ count: 0 }); - const prototype = { inherited: 'value' }; - const partial = Object.create(prototype); - partial.count = 1; - - state.patch(partial); - - expect(state.current).toEqual({ count: 1 }); - expect('inherited' in state.current).toBe(false); - }); - }); - - describe('subscribe', () => { - it('notifies subscriber on state change', () => { - const state = createState({ count: 0 }); - const listener = vi.fn(); - - state.subscribe(listener); - listener.mockClear(); // Clear initial call - state.patch({ count: 1 }); - state.flush(); - - expect(listener).toHaveBeenCalledTimes(1); - }); - - it('returns unsubscribe function', () => { - const state = createState({ count: 0 }); - const listener = vi.fn(); - - const unsubscribe = state.subscribe(listener); - expect(typeof unsubscribe).toBe('function'); - - unsubscribe(); - state.patch({ count: 1 }); - state.flush(); - - expect(listener).toHaveBeenCalledTimes(1); // Only initial call - }); - - it('allows multiple subscribers', () => { - const state = createState({ count: 0 }); - const listener1 = vi.fn(); - const listener2 = vi.fn(); - - state.subscribe(listener1); - state.subscribe(listener2); - listener1.mockClear(); // Clear initial call - listener2.mockClear(); // Clear initial call - state.patch({ count: 1 }); - state.flush(); - - expect(listener1).toHaveBeenCalledTimes(1); - expect(listener2).toHaveBeenCalledTimes(1); - }); - - it('does not notify unsubscribed listeners', () => { - const state = createState({ count: 0 }); - const listener1 = vi.fn(); - const listener2 = vi.fn(); - - state.subscribe(listener1); - const unsubscribe2 = state.subscribe(listener2); - - unsubscribe2(); - listener1.mockClear(); // Clear initial call - state.patch({ count: 1 }); - state.flush(); - - expect(listener1).toHaveBeenCalledTimes(1); - expect(listener2).toHaveBeenCalledTimes(1); // Only initial call - }); - - it('handles same listener subscribed multiple times', () => { - const state = createState({ count: 0 }); - const listener = vi.fn(); - - state.subscribe(listener); - state.subscribe(listener); - listener.mockClear(); // Clear initial call - state.patch({ count: 1 }); - state.flush(); - - // Set semantics: listener only called once even if added multiple times - expect(listener).toHaveBeenCalledTimes(1); - }); - - it('safely handles unsubscribe called multiple times', () => { - const state = createState({ count: 0 }); - const listener = vi.fn(); - - const unsubscribe = state.subscribe(listener); - - unsubscribe(); - unsubscribe(); // Should not throw - - state.patch({ count: 1 }); - state.flush(); - - expect(listener).toHaveBeenCalledTimes(1); // Only initial call - }); - }); - - describe('batching via microtask', () => { - it('batches multiple patches into single notification', async () => { - const state = createState({ count: 0 }); - const listener = vi.fn(); - - state.subscribe(listener); - listener.mockClear(); // Clear initial call - - // Multiple patches in same tick - state.patch({ count: 1 }); - state.patch({ count: 2 }); - state.patch({ count: 3 }); - - // Not notified yet - expect(listener).not.toHaveBeenCalled(); - - // Wait for microtask - await Promise.resolve(); - - // Single notification after flush - expect(listener).toHaveBeenCalledTimes(1); - expect(state.current.count).toBe(3); - }); - - it('batches updates across multiple state containers', async () => { - const state1 = createState({ value: 0 }); - const state2 = createState({ value: 0 }); - const listener1 = vi.fn(); - const listener2 = vi.fn(); - - state1.subscribe(listener1); - state2.subscribe(listener2); - listener1.mockClear(); // Clear initial call - listener2.mockClear(); // Clear initial call - - state1.patch({ value: 1 }); - state2.patch({ value: 2 }); - - expect(listener1).not.toHaveBeenCalled(); - expect(listener2).not.toHaveBeenCalled(); - - await Promise.resolve(); - - expect(listener1).toHaveBeenCalledTimes(1); - expect(listener2).toHaveBeenCalledTimes(1); - }); - - it('schedules flush only once for multiple patches', async () => { - const state = createState({ count: 0 }); - const listener = vi.fn(); - - state.subscribe(listener); - listener.mockClear(); // Clear initial call - - state.patch({ count: 1 }); - state.patch({ count: 2 }); - state.patch({ count: 3 }); - - await Promise.resolve(); - - // Only one notification despite three patches - expect(listener).toHaveBeenCalledTimes(1); - }); - - it('notifies once when consecutive patches use identical values', async () => { - const state = createState({ count: 0 }); - const listener = vi.fn(); - - state.subscribe(listener); - listener.mockClear(); // Clear initial call - - // Multiple patches to the same new value - state.patch({ count: 5 }); - state.patch({ count: 5 }); // Same value - state.patch({ count: 5 }); // Same value - - await Promise.resolve(); - - // Should notify once (0 → 5), not three times - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ count: 5 })); - }); - - it('notifies once when consecutive identical patches with manual flush', () => { - const state = createState({ count: 0 }); - const listener = vi.fn(); - - state.subscribe(listener); - listener.mockClear(); // Clear initial call - - // Multiple patches to the same new value - state.patch({ count: 10 }); - state.patch({ count: 10 }); // Same value - state.patch({ count: 10 }); // Same value - - state.flush(); - - // Should notify once (0 → 10) - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ count: 10 })); - }); - - it('does not notify when all patches are to current value', async () => { - const state = createState({ count: 5 }); - const listener = vi.fn(); - - state.subscribe(listener); - listener.mockClear(); // Clear initial call - - // Multiple patches to the same value as initial - state.patch({ count: 5 }); - state.patch({ count: 5 }); - state.patch({ count: 5 }); - - await Promise.resolve(); - - // Should not notify - no actual change from initial state - expect(listener).not.toHaveBeenCalled(); - }); - }); - - describe('flush', () => { - it('manually triggers pending notifications', () => { - const state = createState({ count: 0 }); - const listener = vi.fn(); - - state.subscribe(listener); - listener.mockClear(); // Clear initial call - state.patch({ count: 1 }); - - expect(listener).not.toHaveBeenCalled(); - - state.flush(); - - expect(listener).toHaveBeenCalledTimes(1); - }); - - it('clears pending state after flush', () => { - const state = createState({ count: 0 }); - const listener = vi.fn(); - - state.subscribe(listener); - listener.mockClear(); // Clear initial call - state.patch({ count: 1 }); - state.flush(); - - expect(listener).toHaveBeenCalledTimes(1); - - // Second flush should not notify again - state.flush(); - expect(listener).toHaveBeenCalledTimes(1); - }); - - it('does not re-notify after automatic microtask flush', async () => { - const state = createState({ count: 0 }); - const listener = vi.fn(); - - state.subscribe(listener); - listener.mockClear(); // Clear initial call - state.patch({ count: 1 }); - - // Wait for automatic microtask flush - await Promise.resolve(); - - expect(listener).toHaveBeenCalledTimes(1); - - // Manual flush should not re-notify - state.flush(); - expect(listener).toHaveBeenCalledTimes(1); - }); - - it('can be called multiple times safely', () => { - const state = createState({ count: 0 }); - const listener = vi.fn(); - - state.subscribe(listener); - listener.mockClear(); // Clear initial call - - state.flush(); - state.flush(); - state.flush(); - - expect(listener).not.toHaveBeenCalled(); - }); - - it('handles interleaved patches and flushes', () => { - const state = createState({ count: 0 }); - const listener = vi.fn(); - - state.subscribe(listener); - listener.mockClear(); // Clear initial call - - state.patch({ count: 1 }); - state.flush(); - expect(listener).toHaveBeenCalledTimes(1); - - state.patch({ count: 2 }); - state.flush(); - expect(listener).toHaveBeenCalledTimes(2); - - state.patch({ count: 3 }); - state.flush(); - expect(listener).toHaveBeenCalledTimes(3); - }); - - it('only flushes its own pending notifications', () => { - const state1 = createState({ value: 0 }); - const state2 = createState({ value: 0 }); - const listener1 = vi.fn(); - const listener2 = vi.fn(); - - state1.subscribe(listener1); - state2.subscribe(listener2); - listener1.mockClear(); // Clear initial call - listener2.mockClear(); // Clear initial call - - state1.patch({ value: 1 }); - state2.patch({ value: 2 }); - - // Each state flushes independently - state1.flush(); - expect(listener1).toHaveBeenCalledTimes(1); - expect(listener2).not.toHaveBeenCalled(); - - state2.flush(); - expect(listener2).toHaveBeenCalledTimes(1); - }); - }); - - describe('type safety', () => { - it('enforces type-safe state access', () => { - interface AppState { - count: number; - name: string; - } - - const state = createState({ count: 0, name: 'test' }); - - // TypeScript should allow valid access - const count: number = state.current.count; - const name: string = state.current.name; - - expect(count).toBe(0); - expect(name).toBe('test'); - }); - - it('enforces type-safe partial updates', () => { - interface AppState { - count: number; - name: string; - } - - const state = createState({ count: 0, name: 'test' }); - - // TypeScript should allow partial updates - state.patch({ count: 1 }); - state.patch({ name: 'updated' }); - state.patch({ count: 2, name: 'both' }); - - expect(state.current).toEqual({ count: 2, name: 'both' }); - }); - - it('provides current state access', () => { - const state = createState({ count: 0 }); - const current: { count: number } = state.current; - - expect(current.count).toBe(0); - }); - }); - - describe('selectors', () => { - it('subscribes to derived state with selector', () => { - const state = createState({ count: 0, name: 'test' }); - const listener = vi.fn(); - const selector = (s: { count: number; name: string }) => s.count; - - state.subscribe(selector, listener); - listener.mockClear(); // Clear initial call - state.patch({ count: 1 }); - state.flush(); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(1); - }); - - it('only notifies when selected value changes', () => { - const state = createState({ count: 0, name: 'test' }); - const listener = vi.fn(); - const selector = (s: { count: number; name: string }) => s.count; - - state.subscribe(selector, listener); - listener.mockClear(); // Clear initial call - - // Change name (not count) - state.patch({ name: 'updated' }); - state.flush(); - expect(listener).not.toHaveBeenCalled(); - - // Change count - state.patch({ count: 1 }); - state.flush(); - expect(listener).toHaveBeenCalledTimes(1); - }); - - it('returns unsubscribe function for selector subscriptions', () => { - const state = createState({ count: 0, name: 'test' }); - const listener = vi.fn(); - const selector = (s: { count: number; name: string }) => s.count; - - const unsubscribe = state.subscribe(selector, listener); - - unsubscribe(); - state.patch({ count: 1 }); - state.flush(); - - expect(listener).toHaveBeenCalledTimes(1); // Only initial call - }); - - it('supports multiple selector subscriptions', () => { - const state = createState({ count: 0, name: 'test' }); - const countListener = vi.fn(); - const nameListener = vi.fn(); - const countSelector = (s: { count: number; name: string }) => s.count; - const nameSelector = (s: { count: number; name: string }) => s.name; - - state.subscribe(countSelector, countListener); - state.subscribe(nameSelector, nameListener); - countListener.mockClear(); // Clear initial call - nameListener.mockClear(); // Clear initial call - - state.patch({ count: 1 }); - state.flush(); - - expect(countListener).toHaveBeenCalledTimes(1); - expect(nameListener).not.toHaveBeenCalled(); - - state.patch({ name: 'updated' }); - state.flush(); - - expect(countListener).toHaveBeenCalledTimes(1); - expect(nameListener).toHaveBeenCalledTimes(1); - }); - - it('uses Object.is by default for selector equality', () => { - const state = createState({ nested: { value: 1 } }); - const listener = vi.fn(); - const selector = (s: { nested: { value: number } }) => s.nested; - - state.subscribe(selector, listener); - listener.mockClear(); // Clear initial call - - // Same object reference - const obj = { value: 1 }; - state.patch({ nested: obj }); - state.flush(); - expect(listener).toHaveBeenCalledTimes(1); - - // Same reference again (no change) - state.patch({ nested: obj }); - state.flush(); - expect(listener).toHaveBeenCalledTimes(1); - - // Different reference (even with same value) - state.patch({ nested: { value: 1 } }); - state.flush(); - expect(listener).toHaveBeenCalledTimes(2); - }); - - it('uses custom equality function for selector', () => { - const state = createState({ nested: { value: 1 } }); - const listener = vi.fn(); - const selector = (s: { nested: { value: number } }) => s.nested; - const equalityFn = (a: { value: number }, b: { value: number }) => a.value === b.value; - - state.subscribe(selector, listener, { equalityFn }); - listener.mockClear(); // Clear initial call - - // Different reference but same value - state.patch({ nested: { value: 1 } }); - state.flush(); - - // Should not notify (custom equality says equal) - expect(listener).not.toHaveBeenCalled(); - - // Different value - state.patch({ nested: { value: 2 } }); - state.flush(); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({ value: 2 }); - }); - - it('handles selector returning primitives', () => { - const state = createState({ count: 0, name: 'test' }); - const listener = vi.fn(); - const selector = (s: { count: number; name: string }) => s.count * 2; - - state.subscribe(selector, listener); - listener.mockClear(); // Clear initial call - state.patch({ count: 5 }); - state.flush(); - - expect(listener).toHaveBeenCalledWith(10); - }); - - it('handles selector returning objects', () => { - const state = createState({ a: 1, b: 2, c: 3 }); - const listener = vi.fn(); - const selector = (s: { a: number; b: number; c: number }) => ({ - sum: s.a + s.b, - }); - - state.subscribe(selector, listener); - listener.mockClear(); // Clear initial call - state.patch({ a: 10 }); - state.flush(); - - expect(listener).toHaveBeenCalledWith({ sum: 12 }); - }); - - it('batches selector notifications', async () => { - const state = createState({ count: 0, name: 'test' }); - const listener = vi.fn(); - const selector = (s: { count: number; name: string }) => s.count; - - state.subscribe(selector, listener); - listener.mockClear(); // Clear initial call - - state.patch({ count: 1 }); - state.patch({ count: 2 }); - state.patch({ count: 3 }); - - await Promise.resolve(); - - // Only one notification after batching - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(3); - }); - - it('can mix full-state and selector subscriptions', () => { - const state = createState({ count: 0, name: 'test' }); - const fullListener = vi.fn(); - const selectorListener = vi.fn(); - const selector = (s: { count: number; name: string }) => s.count; - - state.subscribe(fullListener); - state.subscribe(selector, selectorListener); - fullListener.mockClear(); // Clear initial call - selectorListener.mockClear(); // Clear initial call - - state.patch({ count: 1 }); - state.flush(); - - expect(fullListener).toHaveBeenCalledTimes(1); - expect(selectorListener).toHaveBeenCalledTimes(1); - - fullListener.mockClear(); - selectorListener.mockClear(); - - // Change name (selector doesn't care) - state.patch({ name: 'updated' }); - state.flush(); - - expect(fullListener).toHaveBeenCalledTimes(1); - expect(selectorListener).not.toHaveBeenCalled(); - }); - }); - - describe('custom equality', () => { - it('uses Object.is by default for change detection', () => { - const state = createState({ count: 0 }); - const listener = vi.fn(); - state.subscribe(listener); - listener.mockClear(); // Clear initial call - - // Object.is(NaN, NaN) returns true (unlike ===) - state.patch({ count: Number.NaN }); - state.flush(); - expect(listener).toHaveBeenCalledTimes(1); - - state.patch({ count: Number.NaN }); - state.flush(); - // NaN equals NaN with Object.is, so no notification - expect(listener).toHaveBeenCalledTimes(1); - - // But changing to a different value triggers - state.patch({ count: 0 }); - state.flush(); - expect(listener).toHaveBeenCalledTimes(2); - }); - - it('accepts custom equality function in config', () => { - const equalityFn = vi.fn( - (a: { count: number; name: string }, b: { count: number; name: string }) => a.count === b.count - ); - - const state = createState({ count: 0, name: 'test' }, { equalityFn }); - const listener = vi.fn(); - state.subscribe(listener); - listener.mockClear(); // Clear initial call - - // Change name but not count - state.patch({ name: 'updated' }); - state.flush(); - - expect(equalityFn).toHaveBeenCalled(); - // Custom equality says they're equal (same count) - expect(listener).not.toHaveBeenCalled(); - - // Change count - state.patch({ count: 1 }); - state.flush(); - - expect(listener).toHaveBeenCalledTimes(1); - }); - - it('does not notify when batched patches result in equivalent state', () => { - const equalityFn = (a: { count: number; name: string }, b: { count: number; name: string }) => - a.count === b.count; - - const state = createState({ count: 0, name: 'test' }, { equalityFn }); - const listener = vi.fn(); - state.subscribe(listener); - listener.mockClear(); // Clear initial call - - // Multiple patches that end up at same count - state.patch({ count: 1 }); - state.patch({ count: 2 }); - state.patch({ count: 0 }); // Back to original count - - state.flush(); - - // Should not notify - final state is equivalent to initial state - expect(listener).not.toHaveBeenCalled(); - }); - - it('does not notify when batched patches result in equivalent state (async)', async () => { - const equalityFn = (a: { count: number; name: string }, b: { count: number; name: string }) => - a.count === b.count; - - const state = createState({ count: 0, name: 'test' }, { equalityFn }); - const listener = vi.fn(); - state.subscribe(listener); - listener.mockClear(); // Clear initial call - - // Multiple patches in same tick - state.patch({ count: 5 }); - state.patch({ name: 'changed' }); - state.patch({ count: 0 }); // Back to original count - - // Wait for automatic flush - await Promise.resolve(); - - // Should not notify - final count equals initial count - expect(listener).not.toHaveBeenCalled(); - }); - - it('only notifies once when batched patches result in different state', () => { - const equalityFn = (a: { count: number; name: string }, b: { count: number; name: string }) => - a.count === b.count; - - const state = createState({ count: 0, name: 'test' }, { equalityFn }); - const listener = vi.fn(); - state.subscribe(listener); - listener.mockClear(); // Clear initial call - - // Multiple patches ending at different count - state.patch({ count: 1 }); - state.patch({ count: 2 }); - state.patch({ count: 3 }); - state.patch({ name: 'updated' }); - - state.flush(); - - // Should notify once with final state - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ count: 3, name: 'updated' })); - }); - - it('uses shallow equality when specified', () => { - // Simple shallow equality implementation for test - const shallowEqual = >(a: T, b: T): boolean => { - const keysA = Object.keys(a); - const keysB = Object.keys(b); - if (keysA.length !== keysB.length) return false; - return keysA.every((key) => Object.is(a[key], b[key])); - }; - - const state = createState({ count: 0, name: 'test' }, { equalityFn: shallowEqual }); - const listener = vi.fn(); - state.subscribe(listener); - listener.mockClear(); // Clear initial call - - // Same values (shallow equal) - state.patch({ count: 0, name: 'test' }); - state.flush(); - - expect(listener).not.toHaveBeenCalled(); - - // Different value - state.patch({ count: 1 }); - state.flush(); - - expect(listener).toHaveBeenCalledTimes(1); - }); - - it('does not notify when batched patches with shallow equality end at same state', () => { - const shallowEqual = >(a: T, b: T): boolean => { - const keysA = Object.keys(a); - const keysB = Object.keys(b); - if (keysA.length !== keysB.length) return false; - return keysA.every((key) => Object.is(a[key], b[key])); - }; - - const state = createState({ count: 0, name: 'test' }, { equalityFn: shallowEqual }); - const listener = vi.fn(); - state.subscribe(listener); - listener.mockClear(); // Clear initial call - - // Multiple patches ending at original values - state.patch({ count: 5 }); - state.patch({ name: 'changed' }); - state.patch({ count: 0, name: 'test' }); // Back to original - - state.flush(); - - // Should not notify - shallow equal to initial state - expect(listener).not.toHaveBeenCalled(); - }); - }); - - describe('isState', () => { - it('returns true for state container', () => { - const state = createState({ count: 0 }); - - expect(isState(state)).toBe(true); - }); - - it('returns false for plain object', () => { - const obj = { count: 0 }; - - expect(isState(obj)).toBe(false); - }); - - it('returns false for null', () => { - expect(isState(null)).toBe(false); - }); - - it('returns false for undefined', () => { - expect(isState(undefined)).toBe(false); - }); - - it('returns false for primitives', () => { - expect(isState(42)).toBe(false); - expect(isState('string')).toBe(false); - expect(isState(true)).toBe(false); - }); - }); - - describe('edge cases', () => { - it('handles empty state object', () => { - const state = createState({}); - - expect(state.current).toEqual({}); - }); - - it('handles state with array values', () => { - const state = createState({ items: [1, 2, 3] }); - - state.patch({ items: [4, 5, 6] }); - - expect(state.current.items).toEqual([4, 5, 6]); - }); - - it('handles state with nested objects', () => { - const state = createState({ nested: { value: 42 } }); - - state.patch({ nested: { value: 100 } }); - - expect(state.current.nested.value).toBe(100); - }); - - it('handles rapid subscribe/unsubscribe', () => { - const state = createState({ count: 0 }); - const listener = vi.fn(); - - const unsubscribe = state.subscribe(listener); - unsubscribe(); - - state.subscribe(listener); - listener.mockClear(); // Clear initial call - state.patch({ count: 1 }); - state.flush(); - - expect(listener).toHaveBeenCalledTimes(1); - }); - - it('handles patch during notification', () => { - const state = createState({ count: 0 }); - const listener = vi.fn(() => { - if (state.current.count === 1) { - state.patch({ count: 2 }); - } - }); - - state.subscribe(listener); - listener.mockClear(); // Clear initial call - state.patch({ count: 1 }); - state.flush(); - - // First notification - expect(listener).toHaveBeenCalledTimes(1); - expect(state.current.count).toBe(2); - - // Second flush for patch made during notification - state.flush(); - expect(listener).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/packages/spf/src/dom/features/end-of-stream.ts b/packages/spf/src/dom/features/end-of-stream.ts index d8a002ef..46ccd54f 100644 --- a/packages/spf/src/dom/features/end-of-stream.ts +++ b/packages/spf/src/dom/features/end-of-stream.ts @@ -1,5 +1,5 @@ -import { combineLatest } from '../../core/reactive/combine-latest'; -import type { WritableState } from '../../core/state/create-state'; +import { effect } from '../../core/signals/effect'; +import { computed, type ReadonlySignal, type Signal } from '../../core/signals/primitives'; import type { Presentation } from '../../core/types'; import { isResolvedTrack } from '../../core/types'; import { getSelectedTrack, type TrackSelectionState } from '../../core/utils/track-selection'; @@ -11,6 +11,8 @@ export interface EndOfStreamState extends TrackSelectionState { export interface EndOfStreamOwners { mediaSource?: MediaSource; + /** Reactive mirror of `mediaSource.readyState` — updated via DOM events. */ + mediaSourceReadyState?: ReadonlySignal; mediaElement?: HTMLMediaElement | undefined; videoBuffer?: SourceBuffer; audioBuffer?: SourceBuffer; @@ -63,7 +65,7 @@ function isLastSegmentAppended(segments: readonly { id: string }[], actor: Sourc if (!lastSeg) return false; // A partial segment is still streaming — the last segment is not ready until // its entry is present and not marked partial. - return actor?.snapshot.context.segments.some((s) => s.id === lastSeg.id && !s.partial) ?? false; + return actor?.snapshot.get().context.segments.some((s) => s.id === lastSeg.id && !s.partial) ?? false; } /** @@ -107,10 +109,12 @@ export function canEndStream(state: EndOfStreamState, owners: EndOfStreamOwners) export function shouldEndStream(state: EndOfStreamState, owners: EndOfStreamOwners): boolean { if (!canEndStream(state, owners)) return false; - const { mediaSource, mediaElement } = owners; + const { mediaElement } = owners; - // MediaSource must be open - if (mediaSource!.readyState !== 'open') return false; + // MediaSource must be open — use reactive readyState signal when available + // so the computed re-evaluates when readyState changes (e.g. 'ended' → 'open' on seek-back). + const readyState = owners.mediaSourceReadyState?.get() ?? owners.mediaSource?.readyState; + if (readyState !== 'open') return false; // CRITICAL: MediaElement must have metadata before calling endOfStream // Calling endOfStream before HAVE_METADATA causes DEMUXER_ERROR @@ -130,8 +134,8 @@ export function shouldEndStream(state: EndOfStreamState, owners: EndOfStreamOwne // SourceBufferActors must be idle — setting duration while a SourceBuffer is // updating throws InvalidStateError. The actor subscriber in endOfStream() will // re-evaluate when each actor transitions back to idle. - if (owners.videoBufferActor?.snapshot.status === 'updating') return false; - if (owners.audioBufferActor?.snapshot.status === 'updating') return false; + if (owners.videoBufferActor?.snapshot.get().status === 'updating') return false; + if (owners.audioBufferActor?.snapshot.get().status === 'updating') return false; // Last segment must be appended for each selected track if (!hasLastSegmentLoaded(state, owners)) return false; @@ -165,7 +169,7 @@ export function shouldEndStream(state: EndOfStreamState, owners: EndOfStreamOwne */ function waitForSourceBuffersReady(owners: EndOfStreamOwners): Promise { const updatingActors = [owners.videoBufferActor, owners.audioBufferActor].filter( - (actor): actor is SourceBufferActor => actor !== undefined && actor.snapshot.status === 'updating' + (actor): actor is SourceBufferActor => actor !== undefined && actor.snapshot.get().status === 'updating' ); if (updatingActors.length === 0) return Promise.resolve(); @@ -174,10 +178,20 @@ function waitForSourceBuffersReady(owners: EndOfStreamOwners): Promise { updatingActors.map( (actor) => new Promise((resolve) => { - const unsub = actor.subscribe((snapshot) => { - if (snapshot.status !== 'updating') { - unsub(); - resolve(); + // effect() runs its body synchronously on creation, then re-runs on the + // next microtask after any dependency changes. If the actor is already + // idle by the time effect() is called, resolve fires immediately and + // cleanup is scheduled via queueMicrotask to avoid unwatching during + // the watcher notification cycle. + let cleanup: (() => void) | undefined; + let resolved = false; + cleanup = effect(() => { + if (actor.snapshot.get().status !== 'updating') { + if (!resolved) { + resolved = true; + resolve(); + } + queueMicrotask(() => cleanup?.()); } }); }) @@ -248,71 +262,40 @@ const endOfStreamTask = async ( * What becomes blocked is calling endOfStream() again, addSourceBuffer(), * and MediaSource.duration updates. */ -export function endOfStream({ +export function endOfStream({ state, owners, }: { - state: WritableState; - owners: WritableState; + state: Signal; + owners: Signal; }): () => void { - let hasEnded = false; - let destroyed = false; - const activeActorUnsubs: Array<() => void> = []; + // Derived condition. Transitively tracks through owners into each actor's + // snapshot signal — when owners changes and points to a new actor, this computed + // re-tracks to the new actor's signal on next evaluation. + // shouldEndStream calls actor.snapshot.get() inside the computed body, so those + // reads are automatically tracked by the Signal.Computed dependency graph. + const shouldEnd = computed(() => shouldEndStream(state.get(), owners.get())); - const runEvaluate = async () => { - if (destroyed) return; - const currentState = state.current; - const currentOwners = owners.current; + let hasEnded = false; + + const cleanupEffect = effect(() => { + if (!shouldEnd.get()) return; + const currentOwners = owners.get(); if (hasEnded) { // Per the MSE spec, calling appendBuffer() on a SourceBuffer when // readyState is 'ended' automatically transitions it back to 'open'. // This happens on seek-back after end-of-stream — allow endOfStream() // to be called again once the last segment is reloaded. - if (currentOwners.mediaSource?.readyState !== 'open') return; + const readyState = currentOwners.mediaSourceReadyState?.get() ?? currentOwners.mediaSource?.readyState; + if (readyState !== 'open') return; hasEnded = false; } - if (!shouldEndStream(currentState, currentOwners)) return; // Set flag before awaiting to close the re-entry window between // endOfStream() being called and the async task completing. hasEnded = true; - try { - await endOfStreamTask({ currentOwners }, {}); - } catch (error) { - console.error('Failed to call endOfStream:', error); - } - }; - - // Subscribe to actor snapshot changes so endOfStream re-evaluates when a - // segment is appended (actor context updated), not just on state/owners changes. - // Re-subscribe whenever owners changes (actors may have appeared or changed). - const cleanupOwners = owners.subscribe((currentOwners) => { - activeActorUnsubs.forEach((u) => u()); - activeActorUnsubs.length = 0; - - for (const actor of [currentOwners.videoBufferActor, currentOwners.audioBufferActor]) { - if (!actor) continue; - // Skip the immediate fire — the combineLatest subscription handles the - // initial evaluation. Only react to subsequent snapshot changes. - let isFirst = true; - activeActorUnsubs.push( - actor.subscribe(() => { - if (isFirst) { - isFirst = false; - return; - } - runEvaluate(); - }) - ); - } + endOfStreamTask({ currentOwners }, {}).catch((error) => console.error('Failed to call endOfStream:', error)); }); - const cleanupCombineLatest = combineLatest([state, owners]).subscribe(async () => runEvaluate()); - - return () => { - destroyed = true; - activeActorUnsubs.forEach((u) => u()); - cleanupOwners(); - cleanupCombineLatest(); - }; + return cleanupEffect; } diff --git a/packages/spf/src/dom/features/load-segments.ts b/packages/spf/src/dom/features/load-segments.ts index ddb417e9..5d521c7b 100644 --- a/packages/spf/src/dom/features/load-segments.ts +++ b/packages/spf/src/dom/features/load-segments.ts @@ -1,7 +1,7 @@ -import { combineLatest } from '../../all'; import { type BandwidthState, sampleBandwidth } from '../../core/abr/bandwidth-estimator'; import { DEFAULT_FORWARD_BUFFER_CONFIG } from '../../core/buffer/forward-buffer'; -import { createState, type WritableState } from '../../core/state/create-state'; +import { effect } from '../../core/signals/effect'; +import { computed, type Signal, signal } from '../../core/signals/primitives'; import type { AddressableObject, Presentation, ResolvedTrack } from '../../core/types'; import { isResolvedTrack } from '../../core/types'; import { getSelectedTrack, type TrackSelectionState } from '../../core/utils/track-selection'; @@ -44,7 +44,7 @@ const ActorKeyByType = { type FetchOptions = RequestInit & ChunkedStreamIterableOptions; function createTrackedFetch( - throughput: WritableState, + throughput: Signal, onSample?: (next: BandwidthState) => void ): (addressable: AddressableObject, options?: FetchOptions) => Promise> { return async (addressable, options) => { @@ -60,9 +60,8 @@ function createTrackedFetch( ...(minChunkSize !== undefined ? [{ minChunkSize }] : []) )) { const elapsed = performance.now() - chunkStart; - const next = sampleBandwidth(throughput.current, elapsed, chunk.byteLength); - throughput.patch(next); - throughput.flush(); + const next = sampleBandwidth(throughput.get(), elapsed, chunk.byteLength); + throughput.set(next); onSample?.(next); yield chunk; chunkStart = performance.now(); @@ -237,27 +236,17 @@ function loadingInputsEq(prevState: LoadingInputs, curState: LoadingInputs): boo * @example * const cleanup = loadSegments({ state, owners }, { type: 'video' }); */ -export function loadSegments( - { - state, - owners, - }: { - state: WritableState; - owners: WritableState; - }, +export function loadSegments( + { state, owners }: { state: Signal; owners: Signal }, config: { type: MediaTrackType } ): () => void { const { type } = config; const actorKey = ActorKeyByType[type]; - // Local throughput state — owns BandwidthState for this track's fetch loop. - // Sampling is handled transparently inside fetchBytes; callers never touch it. - // - // MIGRATION BRIDGE: the onSample callback below keeps global state.bandwidthState - // in sync so ABR (selectVideoTrack) continues to work unchanged. Remove this - // bridge once ABR reads from throughput directly. - const initialBandwidth = state.current.bandwidthState; - const throughput = createState( + // Local throughput signal — used by createTrackedFetch to sample bandwidth + // per chunk and propagate back to the shared state for ABR. + const initialBandwidth = state.get().bandwidthState; + const throughput = signal( initialBandwidth ?? { fastEstimate: 0, fastTotalWeight: 0, @@ -273,72 +262,85 @@ export function loadSegments( throughput, initialBandwidth !== undefined ? (next) => { - state.patch({ bandwidthState: next }); - // Flush immediately so switchQuality sees the new estimate before the - // next segment fetch starts, rather than waiting for the microtask queue. - state.flush(); + state.set(Object.assign({}, state.get(), { bandwidthState: next }) as S); } : undefined ) : fetchStream; - const segmentLoader = createState(undefined); + // Local segment loader — signal so segmentsCanLoad can track it reactively + const segmentLoader = signal(undefined); - // SegmentLoaderActor lifecycle — watch only the actor key in owners so this - // subscription does not fire on unrelated owners changes. - const unsubActorLifecycle = owners.subscribe( - (o) => o[actorKey], - (actor) => { - if (actor) { - segmentLoader.patch(createSegmentLoaderActor(actor, fetchBytes)); - } else if (!actor && segmentLoader.current) { - segmentLoader.current.destroy(); - segmentLoader.patch(undefined); - } - return () => { - segmentLoader.current?.destroy(); - segmentLoader.patch(undefined); - }; + // Computed: isolates the specific actor key so the lifecycle effect only + // re-runs when that actor reference changes, not on unrelated owners updates. + const actorSource = computed(() => owners.get()[actorKey]); + + // Actor lifecycle — destroy and recreate SegmentLoaderActor when the + // upstream SourceBufferActor is replaced (quality switch) or removed. + let currentLoader: SegmentLoaderActor | undefined; + const cleanupActorLifecycle = effect(() => { + const actor = actorSource.get(); + + if (currentLoader) { + currentLoader.destroy(); + segmentLoader.set(undefined); + currentLoader = undefined; } - ); - const segmentsCanLoad = createState(false); - const unsubscribeCanLoadSegments = combineLatest([state, segmentLoader]).subscribe( - ([currentState, currentSegmentLoader]) => { - const track = getSelectedTrack(currentState, type); - const trackResolved = !!track && isResolvedTrack(track); - const segmentLoaderActorExists = !!currentSegmentLoader; - segmentsCanLoad.patch(trackResolved && segmentLoaderActorExists); + if (actor) { + const loader = createSegmentLoaderActor(actor, fetchBytes); + currentLoader = loader; + segmentLoader.set(loader); } - ); + }); - const unsubscribeShouldLoadSegments = combineLatest([segmentsCanLoad, state]).subscribe( - ([segmentsCanLoad, state]) => selectLoadingInputs([segmentsCanLoad, state], type), - ({ preload, playbackInitiated, currentTime, track }) => { - const fullMode = preload === 'auto' || !!playbackInitiated; + // Derived: true only when we have both a resolved track and a ready loader + const segmentsCanLoad = computed(() => { + const track = getSelectedTrack(state.get(), type); + return !!track && isResolvedTrack(track) && !!segmentLoader.get(); + }); - if (!fullMode) { - // Metadata mode: init only, no range. + // Loading inputs — selectLoadingInputs is cheap; let the effect body apply + // loadingInputsEq rather than the computed, so the first run always fires + // regardless of the initial state. + const loadingInputs = computed(() => selectLoadingInputs([segmentsCanLoad.get(), state.get()], type)); + + // Load effect — prevInputs guards against redundant messages using the same + // equality semantics as the former combineLatest selector subscription. + // prevInputs is updated only when we actually send a message so that the + // transition segmentsCanLoad: false → true always fires (loadingInputsEq + // returns true for that transition, so we must not skip it here). + let prevInputs: LoadingInputs | undefined; + const cleanupLoadEffect = effect(() => { + const inputs = loadingInputs.get(); + if (prevInputs !== undefined && loadingInputsEq(prevInputs, inputs)) return; + + const { preload, playbackInitiated, currentTime, track, segmentsCanLoad: canLoad } = inputs; + if (!canLoad) return; + + prevInputs = inputs; + + const fullMode = preload === 'auto' || !!playbackInitiated; + if (!fullMode) { + // Metadata mode: init only, no range. + /** @ts-expect-error */ + segmentLoader.get()?.send({ type: 'load', track }); + } else { + segmentLoader.get()?.send({ + type: 'load', /** @ts-expect-error */ - segmentLoader.current?.send({ type: 'load', track }); - } else { - segmentLoader.current?.send({ - type: 'load', - /** @ts-expect-error */ - track, - range: { - start: currentTime as number, - end: (currentTime as number) + DEFAULT_FORWARD_BUFFER_CONFIG.bufferDuration, - }, - }); - } - }, - { equalityFn: loadingInputsEq } - ); + track, + range: { + start: currentTime as number, + end: (currentTime as number) + DEFAULT_FORWARD_BUFFER_CONFIG.bufferDuration, + }, + }); + } + }); return () => { - unsubscribeCanLoadSegments(); - unsubscribeShouldLoadSegments(); - unsubActorLifecycle(); + cleanupActorLifecycle(); + cleanupLoadEffect(); + currentLoader?.destroy(); }; } diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index f93bb4e7..a569efe3 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -1,32 +1,26 @@ import { getSegmentsToLoad } from '../../core/buffer/forward-buffer'; -import { combineLatest } from '../../core/reactive/combine-latest'; -import type { WritableState } from '../../core/state/create-state'; +import { effect } from '../../core/signals/effect'; +import { computed, type Signal } from '../../core/signals/primitives'; import type { Presentation, Segment, TextTrack } from '../../core/types'; import { isResolvedTrack } from '../../core/types'; import { parseVttSegment } from '../text/parse-vtt-segment'; -function isDuplicateCue(cue: VTTCue, textTrack: globalThis.TextTrack): boolean { - const { cues } = textTrack; - if (!cues) return false; - for (let i = 0; i < cues.length; i++) { - const existing = cues[i] as VTTCue; - if (existing.startTime === cue.startTime && existing.endTime === cue.endTime && existing.text === cue.text) { - return true; - } - } - return false; +const CueKeys = ['startTime', 'endTime', 'text'] as const; +function isDuplicateCue(cue: VTTCue, existingCues: globalThis.TextTrack['cues']): boolean { + return Array.prototype.some.call(existingCues ?? [], (existingCue) => { + return CueKeys.every((k) => existingCue[k] === cue[k]); + }); } const loadVttSegmentTask = async ( { segment }: { segment: Segment }, - context: { textTrack: globalThis.TextTrack } + { textTrack }: { textTrack: globalThis.TextTrack } ): Promise => { const cues = await parseVttSegment(segment.url); - for (const cue of cues) { - if (!isDuplicateCue(cue, context.textTrack)) { - context.textTrack.addCue(cue); - } - } + cues.forEach((cue) => { + if (isDuplicateCue(cue, textTrack.cues)) return; + textTrack.addCue(cue); + }); }; // ============================================================================ @@ -36,12 +30,12 @@ const loadVttSegmentTask = async ( /** * Load text track cues task (composite - orchestrates VTT segment subtasks). */ -const loadTextTrackCuesTask = async ( - { currentState }: { currentState: TextTrackCueLoadingState }, +const loadTextTrackCuesTask = async ( + { currentState }: { currentState: S }, context: { signal: AbortSignal; textTrack: globalThis.TextTrack; - state: WritableState; + state: Signal; } ): Promise => { const track = findSelectedTextTrack(currentState); @@ -72,14 +66,16 @@ const loadTextTrackCuesTask = async ( // Record the loaded segment in shared state — mirrors bufferState for // audio/video and supports N text tracks keyed by track ID. - const latest = context.state.current.textBufferState ?? {}; + const latestState = context.state.get(); + const latest = latestState.textBufferState ?? {}; const trackState = latest[trackId] ?? { segments: [] }; - context.state.patch({ + context.state.set({ + ...latestState, textBufferState: { ...latest, [trackId]: { segments: [...trackState.segments, { id: segment.id }] }, }, - }); + } as S); } catch (error) { if (error instanceof Error && error.name === 'AbortError') break; console.error('Failed to load VTT segment:', error); @@ -236,47 +232,49 @@ export function shouldLoadTextTrackCues(state: TextTrackCueLoadingState, owners: * @example * const cleanup = loadTextTrackCues({ state, owners }); */ -export function loadTextTrackCues({ +export function loadTextTrackCues({ state, owners, }: { - state: WritableState; - owners: WritableState; + state: Signal; + owners: Signal; }): () => void { let currentTask: Promise | null = null; let abortController: AbortController | null = null; let lastTrackId: string | undefined; - const cleanup = combineLatest([state, owners]).subscribe( - async ([currentState, currentOwners]: [TextTrackCueLoadingState, TextTrackCueLoadingOwners]) => { - // Abort any in-progress task when the selected track changes. - // The new track's textBufferState entry will be empty, so the task - // naturally starts fresh without needing any explicit reset. - if (currentState.selectedTextTrackId !== lastTrackId) { - lastTrackId = currentState.selectedTextTrackId; - abortController?.abort(); - currentTask = null; - } + const selectedTrackId = computed(() => state.get().selectedTextTrackId); - if (currentTask) return; // Task already in progress - if (!shouldLoadTextTrackCues(currentState, currentOwners)) return; + const cleanupEffect = effect(() => { + const s = state.get(); + const o = owners.get(); - const textTrack = getSelectedTextTrackFromOwners(currentState, currentOwners); - if (!textTrack) return; - - // Create abort controller and invoke task - abortController = new AbortController(); - currentTask = loadTextTrackCuesTask( - { currentState }, - { signal: abortController.signal, textTrack, state } - ).finally(() => { - currentTask = null; - }); + // Abort any in-progress task when the selected track changes. + // The new track's textBufferState entry will be empty, so the task + // naturally starts fresh without needing any explicit reset. + if (selectedTrackId.get() !== lastTrackId) { + lastTrackId = selectedTrackId.get(); + abortController?.abort(); + currentTask = null; } - ); + + if (currentTask) return; + if (!shouldLoadTextTrackCues(s, o)) return; + + const textTrack = getSelectedTextTrackFromOwners(s, o); + if (!textTrack) return; + + abortController = new AbortController(); + currentTask = loadTextTrackCuesTask( + { currentState: s }, + { signal: abortController.signal, textTrack, state } + ).finally(() => { + currentTask = null; + }); + }); return () => { abortController?.abort(); - cleanup(); + cleanupEffect(); }; } diff --git a/packages/spf/src/dom/features/segment-loader-actor.ts b/packages/spf/src/dom/features/segment-loader-actor.ts index 767e5dbf..3aa10899 100644 --- a/packages/spf/src/dom/features/segment-loader-actor.ts +++ b/packages/spf/src/dom/features/segment-loader-actor.ts @@ -122,7 +122,12 @@ export function createSegmentLoaderActor( const getBufferedSegments = (allSegments: readonly Segment[]): Segment[] => { // Exclude partial segments — they are still being streamed and must not be // treated as fully buffered for load planning or buffer window calculations. - const bufferedIds = new Set(sourceBufferActor.snapshot.context.segments.filter((s) => !s.partial).map((s) => s.id)); + const bufferedIds = new Set( + sourceBufferActor.snapshot + .get() + .context.segments.filter((s) => !s.partial) + .map((s) => s.id) + ); return allSegments.filter((s) => bufferedIds.has(s.id)); }; @@ -143,7 +148,7 @@ export function createSegmentLoaderActor( */ const planTasks = (message: SegmentLoaderMessage): LoadTask[] => { const { track, range } = message; - const actorCtx = sourceBufferActor.snapshot.context; + const actorCtx = sourceBufferActor.snapshot.get().context; const bufferedSegments = getBufferedSegments(track.segments); const currentTime = range?.start ?? 0; const tasks: LoadTask[] = []; diff --git a/packages/spf/src/dom/features/setup-mediasource.ts b/packages/spf/src/dom/features/setup-mediasource.ts index 20c6e07d..40cac954 100644 --- a/packages/spf/src/dom/features/setup-mediasource.ts +++ b/packages/spf/src/dom/features/setup-mediasource.ts @@ -1,8 +1,7 @@ -import { isNil } from '@videojs/utils/predicate'; -import { combineLatest } from '../../core/reactive/combine-latest'; -import type { WritableState } from '../../core/state/create-state'; +import { effect } from '../../core/signals/effect'; +import { computed, type ReadonlySignal, type Signal } from '../../core/signals/primitives'; import type { Presentation } from '../../core/types'; -import { attachMediaSource, createMediaSource, waitForSourceOpen } from '../media/mediasource-setup'; +import { attachMediaSource, createMediaSource, observeMediaSourceReadyState } from '../media/mediasource-setup'; /** * State shape required for MediaSource setup. @@ -17,22 +16,8 @@ export interface MediaSourceState { export interface MediaSourceOwners { mediaElement?: HTMLMediaElement | undefined; mediaSource?: MediaSource; -} - -/** - * Check if we have the minimum requirements to create MediaSource. - */ -export function canSetup(state: MediaSourceState, owners: MediaSourceOwners): boolean { - return !isNil(owners.mediaElement) && !isNil(state.presentation?.url); -} - -/** - * Check if we should proceed with MediaSource creation. - * Placeholder for future conditions (e.g., checking if already created). - */ -export function shouldSetup(_state: MediaSourceState, owners: MediaSourceOwners): boolean { - // Don't create if we already have one - return isNil(owners.mediaSource); + /** Reactive mirror of `mediaSource.readyState` — updated via DOM events. */ + mediaSourceReadyState?: ReadonlySignal; } /** @@ -44,46 +29,52 @@ export function shouldSetup(_state: MediaSourceState, owners: MediaSourceOwners) * * Updates owners.mediaSource after successful setup. */ -export function setupMediaSource({ +export function setupMediaSource({ state, owners, }: { - state: WritableState; - owners: WritableState; + state: Signal; + owners: Signal; }): () => void { - let settingUp = false; - let abortController: AbortController | null = null; + const abortController = new AbortController(); - const unsubscribe = combineLatest([state, owners]).subscribe( - async ([currentState, currentOwners]: [MediaSourceState, MediaSourceOwners]) => { - if (!canSetup(currentState, currentOwners) || !shouldSetup(currentState, currentOwners) || settingUp) return; + // Get the latest mediaElement (even if nullish) + const mediaElementSignal = computed(() => owners.get().mediaElement); + // Get the latest presentationUrl (even if nullish) + const presentationUrlSignal = computed(() => state.get().presentation?.url); - try { - settingUp = true; - abortController = new AbortController(); + const canSetupSignal = computed(() => !!mediaElementSignal.get() && !!presentationUrlSignal.get()); - // Prefer ManagedMediaSource when available (Safari 17+). attachMediaSource - // handles the srcObject path and sets disableRemotePlayback automatically. - const mediaSource = createMediaSource({ preferManaged: true }); + const mediaElementSrcSignal = computed(() => mediaElementSignal.get()?.src); + const mediaSourceSignal = computed(() => owners.get().mediaSource); + const shouldSetupSignal = computed(() => !mediaElementSrcSignal.get()); - // Attach to element - attachMediaSource(mediaSource, currentOwners.mediaElement!); + // NOTE: This should be cleaner and less brittle if/when Reactors have their own internal finite state. This is planned as followup work. + // (here, e.g. something like: "preconditions_unmet"|"pending"|"setting_up"|"tearing_down"|"set_up") + // This should also avoid needing nested effect(). + const cleanupEffect = effect(() => { + if (!canSetupSignal.get() || !shouldSetupSignal.get()) return; + const mediaElement = mediaElementSignal.get() as HTMLMediaElement; + const { signal } = abortController; - // Wait for sourceopen — aborted if destroy() is called before it fires - await waitForSourceOpen(mediaSource, abortController.signal); + const mediaSource = createMediaSource({ preferManaged: true }); + // NOTE: Consider making MediaSource an Actor and using this in it. + const mediaSourceReadyState = observeMediaSourceReadyState(mediaSource, signal); + attachMediaSource(mediaSource, mediaElement); - owners.patch({ mediaSource }); - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') return; - throw error; - } finally { - settingUp = false; - } - } - ); + const cleanupOwnersUpdateEffect = effect(() => { + // If we already have a MediaSource or the *internal* mediaSource is not yet fully attached, wait to add it to owners; + if (!!mediaSourceSignal.get() || mediaSourceReadyState.get() !== 'open') return; + owners.set(Object.assign({}, owners.get(), { mediaSource, mediaSourceReadyState }) as O); + }); + + return () => { + cleanupOwnersUpdateEffect(); + }; + }); return () => { abortController?.abort(); - unsubscribe(); + cleanupEffect(); }; } diff --git a/packages/spf/src/dom/features/setup-sourcebuffer.ts b/packages/spf/src/dom/features/setup-sourcebuffer.ts index 344b6356..7b3e7b4f 100644 --- a/packages/spf/src/dom/features/setup-sourcebuffer.ts +++ b/packages/spf/src/dom/features/setup-sourcebuffer.ts @@ -1,6 +1,5 @@ -import { isUndefined } from '@videojs/utils/predicate'; -import { combineLatest } from '../../core/reactive/combine-latest'; -import type { WritableState } from '../../core/state/create-state'; +import { effect } from '../../core/signals/effect'; +import { computed, type Signal, update } from '../../core/signals/primitives'; import type { Presentation, ResolvedTrack } from '../../core/types'; import { isResolvedTrack } from '../../core/types'; import { BufferKeyByType, getSelectedTrack, type TrackSelectionState } from '../../core/utils/track-selection'; @@ -13,6 +12,11 @@ import { createSourceBufferActor, type SourceBufferActor } from '../media/source */ export type MediaTrackType = 'video' | 'audio'; +const ActorKeyByType = { + video: 'videoBufferActor', + audio: 'audioBufferActor', +} as const satisfies Record; + /** * State shape for SourceBuffer setup. */ @@ -48,112 +52,74 @@ export function buildMimeCodec(track: ResolvedTrack): string { return `${track.mimeType}; codecs="${codecString}"`; } -/** - * Check if we can setup SourceBuffer for track type. - * - * Requires: - * - MediaSource exists in owners - * - Track is selected - * - * Note: We don't check mediaSource.readyState because owners holds references - * to mutable objects. Changes to properties on those objects won't trigger - * observations. Instead, setupMediaSource only patches owners.mediaSource after - * it's already open, so if it exists in owners, it's ready to use. - * - * Note: Track does not need to be resolved yet. The orchestration will wait - * for the track to be resolved (via resolveTrack) before creating the SourceBuffer. - */ -export function canSetupBuffer(state: SourceBufferState, owners: SourceBufferOwners, type: MediaTrackType): boolean { - // MediaSource exists (already open - see note above) - if (!owners.mediaSource) { - return false; - } - - // Need selected track (doesn't need to be resolved yet) - const track = getSelectedTrack(state, type); - if (!track) { - return false; - } - - return true; -} - -/** - * Check if we should create SourceBuffer (not already created). - */ -export function shouldSetupBuffer(owners: SourceBufferOwners, type: MediaTrackType): boolean { - const bufferKey = BufferKeyByType[type]; - return isUndefined(owners[bufferKey]); -} - /** * Setup all needed SourceBuffers as a single coordinated operation. * - * Waits until ALL selected tracks (video and/or audio) are resolved with - * codecs, then creates every SourceBuffer in one synchronous block before - * patching owners. This guarantees that downstream consumers (e.g. - * loadSegments) never see a partial set of SourceBuffers — preventing the - * Firefox bug where appending to a video SourceBuffer before the audio - * SourceBuffer exists causes mozHasAudio to be permanently false. + * Waits until ALL media tracks in the presentation are resolved with codecs, + * then creates every SourceBuffer in one synchronous block before setting + * owners. This guarantees that downstream consumers (e.g. loadSegments) never + * see a partial set of SourceBuffers — preventing the Firefox bug where + * appending to a video SourceBuffer before the audio SourceBuffer exists + * causes mozHasAudio to be permanently false. * * Handles video-only, audio-only, and combined presentations correctly: - * only the tracks that are actually selected are waited on and created. + * track types are derived from the presentation rather than hardcoded. * * @example * const cleanup = setupSourceBuffers({ state, owners }); */ -export function setupSourceBuffers({ +export function setupSourceBuffers({ state, owners, }: { - state: WritableState; - owners: WritableState; + state: Signal; + owners: Signal; }): () => void { - let setupDone = false; + // Derive which media track types this presentation actually contains + const presentationTypesSignal = computed((): MediaTrackType[] => { + const { presentation } = state.get(); + if (!presentation || !('selectionSets' in presentation)) return []; + return presentation.selectionSets + .map(({ type }) => type) + .filter((type): type is MediaTrackType => type === 'video' || type === 'audio'); + }); - const cleanup = combineLatest([state, owners]).subscribe( - async ([currentState, currentOwners]: [SourceBufferState, SourceBufferOwners]) => { - if (setupDone) return; - if (!currentOwners.mediaSource) return; + // All presentation track types must have a selected, resolved track with codecs + const canSetupSignal = computed(() => { + const types = presentationTypesSignal.get(); + if (!owners.get().mediaSource || types.length === 0) return false; + const s = state.get(); + return types.every((type) => { + const track = getSelectedTrack(s, type); + return track && isResolvedTrack(track) && !!track.codecs?.length; + }); + }); - const videoSelected = !!currentState.selectedVideoTrackId; - const audioSelected = !!currentState.selectedAudioTrackId; + // DOM-observable "already done" guard — once all presentation buffers exist in owners, setup has run + const shouldSetupSignal = computed(() => { + const o = owners.get(); + return presentationTypesSignal.get().every((type) => !o[BufferKeyByType[type]]); + }); - if (!videoSelected && !audioSelected) return; + const cleanupEffect = effect(() => { + if (!canSetupSignal.get() || !shouldSetupSignal.get()) return; - const videoTrack = videoSelected ? getSelectedTrack(currentState, 'video') : null; - const audioTrack = audioSelected ? getSelectedTrack(currentState, 'audio') : null; + const s = state.get(); + const o = owners.get(); - // Wait until every selected track is resolved with codecs before - // creating any SourceBuffer. This is the coordination guarantee. - if (videoSelected && (!videoTrack || !isResolvedTrack(videoTrack) || !videoTrack.codecs?.length)) return; - if (audioSelected && (!audioTrack || !isResolvedTrack(audioTrack) || !audioTrack.codecs?.length)) return; + // Create all SourceBuffers synchronously — no await between addSourceBuffer + // calls — then set owners once so subscribers see all buffers simultaneously. + const patch: Partial = {}; - setupDone = true; - - // Create all SourceBuffers synchronously — no await between addSourceBuffer - // calls — then patch owners once so subscribers see all buffers simultaneously. - const patch: Partial = {}; - - if (videoSelected && videoTrack && isResolvedTrack(videoTrack)) { - const buffer = createSourceBuffer(currentOwners.mediaSource!, buildMimeCodec(videoTrack)); - patch.videoBuffer = buffer; - patch.videoBufferActor = createSourceBufferActor(buffer); - } - - if (audioSelected && audioTrack && isResolvedTrack(audioTrack)) { - const buffer = createSourceBuffer(currentOwners.mediaSource!, buildMimeCodec(audioTrack)); - patch.audioBuffer = buffer; - patch.audioBufferActor = createSourceBufferActor(buffer); - } - - owners.patch(patch); - - // Wait a frame to allow async state updates to flush before downstream - // orchestrations (loadSegments) begin reacting to the new owners. - await new Promise((resolve) => requestAnimationFrame(resolve)); + for (const type of presentationTypesSignal.get()) { + const track = getSelectedTrack(s, type) as ResolvedTrack; + const buffer = createSourceBuffer(o.mediaSource!, buildMimeCodec(track)); + patch[BufferKeyByType[type]] = buffer; + patch[ActorKeyByType[type]] = createSourceBufferActor(buffer); } - ); - return cleanup; + update(owners, patch); + }); + + return cleanupEffect; } diff --git a/packages/spf/src/dom/features/setup-text-tracks.ts b/packages/spf/src/dom/features/setup-text-tracks.ts index 817a3db7..9c6c71fa 100644 --- a/packages/spf/src/dom/features/setup-text-tracks.ts +++ b/packages/spf/src/dom/features/setup-text-tracks.ts @@ -1,6 +1,6 @@ -import { combineLatest } from '../../core/reactive/combine-latest'; -import type { WritableState } from '../../core/state/create-state'; -import type { PartiallyResolvedTextTrack, Presentation } from '../../core/types'; +import { effect } from '../../core/signals/effect'; +import { computed, type Signal, update } from '../../core/signals/primitives'; +import type { PartiallyResolvedTextTrack, Presentation, TextTrack } from '../../core/types'; /** * State shape for text track setup. @@ -18,43 +18,13 @@ export interface TextTrackOwners { textTracks?: Map; } -/** - * Get all text tracks from presentation. - */ -function getTextTracks(presentation?: Presentation): PartiallyResolvedTextTrack[] { - if (!presentation?.selectionSets) return []; - - const textSet = presentation.selectionSets.find((set) => set.type === 'text'); - if (!textSet?.switchingSets?.[0]?.tracks) return []; - - return textSet.switchingSets[0].tracks as PartiallyResolvedTextTrack[]; -} - -/** - * Check if we can setup text tracks. - * - * Requires: - * - mediaElement exists - * - presentation has text tracks to setup - */ -export function canSetupTextTracks(state: TextTrackState, owners: TextTrackOwners): boolean { - return !!owners.mediaElement && !!getTextTracks(state.presentation).length; -} - -/** - * Check if we should setup text tracks (not already set up). - */ -export function shouldSetupTextTracks(owners: TextTrackOwners): boolean { - return !owners.textTracks; -} - /** * Create a track element for a text track. * * Note: We use DOM elements instead of the TextTrack JS API * because there's no way to remove TextTracks added via addTextTrack(). */ -function createTrackElement(track: PartiallyResolvedTextTrack): HTMLTrackElement { +function createTrackElement(track: PartiallyResolvedTextTrack | TextTrack): HTMLTrackElement { const trackElement = document.createElement('track'); trackElement.id = track.id; @@ -91,48 +61,60 @@ function createTrackElement(track: PartiallyResolvedTextTrack): HTMLTrackElement * @example * const cleanup = setupTextTracks({ state, owners }); */ -export function setupTextTracks({ +export function setupTextTracks({ state, owners, }: { - state: WritableState; - owners: WritableState; + state: Signal; + owners: Signal; }): () => void { - let hasSetup = false; - let createdTracks: HTMLTrackElement[] = []; + const modelTextTracksSignal = computed( + // NOTE: This assumes exactly one selection set and switching set for TextTracks (CJP) + () => + state.get().presentation?.selectionSets?.find((selectionSet) => selectionSet.type === 'text')?.switchingSets[0] + ?.tracks, + { + /** @TODO Make generic and abstract away for Array | undefined (CJP) */ + equals(prevTextTracks, nextTextTracks) { + if (prevTextTracks === nextTextTracks) return true; + if (typeof prevTextTracks !== typeof nextTextTracks) return false; + if (prevTextTracks?.length !== nextTextTracks?.length) return false; + // NOTE: This could probably be optimized, but the set should generally be small (CJP) + return ( + !!nextTextTracks && + nextTextTracks.every((nextTextTrack) => + prevTextTracks?.some((prevTextTrack) => prevTextTrack.id === nextTextTrack.id) + ) + ); + }, + } + ); + const ownerTextTracksSignal = computed(() => owners.get().textTracks); + const mediaElementSignal = computed(() => owners.get().mediaElement); - const unsubscribe = combineLatest([state, owners]).subscribe(([s, o]: [TextTrackState, TextTrackOwners]) => { - // Check orchestration conditions - if (hasSetup) return; // Only run once - if (!canSetupTextTracks(s, o) || !shouldSetupTextTracks(o)) return; + const canSetupTextTracksSignal = computed(() => !!mediaElementSignal.get() && modelTextTracksSignal.get()?.length); + const shouldSetupTextTracksSignal = computed(() => !ownerTextTracksSignal.get()); - // Mark as setup before doing any work to prevent re-entry - hasSetup = true; - - const textTracks = getTextTracks(s.presentation); - if (textTracks.length === 0) return; + const cleanupEffect = effect(() => { + if (!canSetupTextTracksSignal.get() || !shouldSetupTextTracksSignal.get()) return; + const mediaElement = mediaElementSignal.get(); + const modelTextTracks = modelTextTracksSignal.get() as TextTrack[]; const trackMap = new Map(); + modelTextTracks.forEach((modelTextTrack) => { + const trackElement = createTrackElement(modelTextTrack); + mediaElement!.appendChild(trackElement); + trackMap.set(modelTextTrack.id, trackElement); + }); - // Create track elements for all text tracks - for (const track of textTracks) { - const trackElement = createTrackElement(track); - o.mediaElement!.appendChild(trackElement); - trackMap.set(track.id, trackElement); - createdTracks.push(trackElement); + if (trackMap.size) { + const patch: Partial = { textTracks: trackMap }; + update(owners, patch); } - - // Update owners with track element map - owners.patch({ textTracks: trackMap }); }); - // Return cleanup function return () => { - // Remove all created track elements - for (const trackElement of createdTracks) { - trackElement.remove(); - } - createdTracks = []; - unsubscribe(); + owners.get().textTracks?.forEach((trackElement) => trackElement.remove()); + cleanupEffect(); }; } diff --git a/packages/spf/src/dom/features/sync-selected-text-track-from-dom.ts b/packages/spf/src/dom/features/sync-selected-text-track-from-dom.ts index 9df3e7c2..e651aed2 100644 --- a/packages/spf/src/dom/features/sync-selected-text-track-from-dom.ts +++ b/packages/spf/src/dom/features/sync-selected-text-track-from-dom.ts @@ -1,5 +1,6 @@ import { listen } from '@videojs/utils/dom'; -import type { WritableState } from '../../core/state/create-state'; +import { effect } from '../../core/signals/effect'; +import { computed, type Signal, update } from '../../core/signals/primitives'; import type { TextTrackBufferState } from './load-text-track-cues'; /** @@ -39,43 +40,33 @@ export interface SelectedTextTrackFromDomOwners { * @example * const cleanup = syncSelectedTextTrackFromDom({ state, owners }); */ -export function syncSelectedTextTrackFromDom({ - state, - owners, -}: { - state: WritableState; - owners: WritableState; -}): () => void { - let lastMediaElement: HTMLMediaElement | undefined; - let removeListener: (() => void) | null = null; +export function syncSelectedTextTrackFromDom< + S extends SelectedTextTrackFromDomState, + O extends SelectedTextTrackFromDomOwners, +>({ state, owners }: { state: Signal; owners: Signal }): () => void { + const mediaElement = computed(() => owners.get().mediaElement); - const unsubscribe = owners.subscribe((currentOwners) => { - const { mediaElement } = currentOwners; + return effect(() => { + const el = mediaElement.get(); + if (!el) return; - if (mediaElement === lastMediaElement) return; - - removeListener?.(); - removeListener = null; - lastMediaElement = mediaElement; - - if (!mediaElement) return; - - const sync = () => { - const showingTrack = Array.from(mediaElement.textTracks).find( + return listen(el.textTracks, 'change', () => { + const showingTrack = Array.from(el.textTracks).find( (t) => t.mode === 'showing' && (t.kind === 'subtitles' || t.kind === 'captions') ); // showingTrack.id is set from the SPF presentation track ID by setupTextTracks. // Fall back to undefined for empty-string IDs (non-SPF-managed tracks). const newId = showingTrack?.id || undefined; - const current = state.current; + const current = state.get(); - // Guard against redundant patches — e.g. syncTextTrackModes confirming the + // Guard against redundant writes — e.g. syncTextTrackModes confirming the // current selection, which would otherwise create a feedback loop. if (current.selectedTextTrackId === newId) return; if (newId) { - state.patch({ selectedTextTrackId: newId }); + const patch: Partial = { selectedTextTrackId: newId }; + update(state, patch); } else { // When deselecting, clear the textBufferState entry for the previous track. // Setting mode to 'disabled' (as toggleSubtitles() does) clears native cues @@ -85,18 +76,16 @@ export function syncSelectedTextTrackFromDom({ if (prevId && current.textBufferState?.[prevId]) { const next = { ...current.textBufferState }; delete next[prevId]; - state.patch({ selectedTextTrackId: undefined, textBufferState: next }); + const patch: Partial = { + selectedTextTrackId: undefined, + textBufferState: next, + }; + update(state, patch); } else { - state.patch({ selectedTextTrackId: undefined }); + const patch: Partial = { selectedTextTrackId: undefined }; + update(state, patch); } } - }; - - removeListener = listen(mediaElement.textTracks, 'change', sync); + }); }); - - return () => { - removeListener?.(); - unsubscribe(); - }; } diff --git a/packages/spf/src/dom/features/sync-text-track-modes.ts b/packages/spf/src/dom/features/sync-text-track-modes.ts index 05ec494c..97b7481b 100644 --- a/packages/spf/src/dom/features/sync-text-track-modes.ts +++ b/packages/spf/src/dom/features/sync-text-track-modes.ts @@ -1,5 +1,5 @@ -import { combineLatest } from '../../core/reactive/combine-latest'; -import type { WritableState } from '../../core/state/create-state'; +import { effect } from '../../core/signals/effect'; +import { computed, type Signal } from '../../core/signals/primitives'; /** * State shape for text track mode synchronization. @@ -15,16 +15,6 @@ export interface TextTrackModeOwners { textTracks?: Map; } -/** - * Check if we can sync text track modes. - * - * Requires: - * - textTracks map exists (track elements created) - */ -export function canSyncTextTrackModes(owners: TextTrackModeOwners): boolean { - return !!owners.textTracks && owners.textTracks.size > 0; -} - /** * Sync text track modes orchestration. * @@ -39,28 +29,26 @@ export function canSyncTextTrackModes(owners: TextTrackModeOwners): boolean { * @example * const cleanup = syncTextTrackModes({ state, owners }); */ -export function syncTextTrackModes({ +export function syncTextTrackModes({ state, owners, }: { - state: WritableState; - owners: WritableState; + state: Signal; + owners: Signal; }): () => void { - return combineLatest([state, owners]).subscribe(([s, o]: [TextTrackModeState, TextTrackModeOwners]) => { - // Check orchestration conditions - if (!canSyncTextTrackModes(o)) return; + const textTracksSignal = computed(() => owners.get().textTracks); + const selectedTextTrackIdSignal = computed(() => state.get().selectedTextTrackId); - const selectedId = s.selectedTextTrackId; + const canSyncTextTrackModes = computed(() => !!textTracksSignal.get()?.size); - // Update all track element modes - for (const [trackId, trackElement] of o.textTracks!) { - if (trackId === selectedId) { - // Selected track: show it - trackElement.track.mode = 'showing'; - } else { - // Other tracks: hide them (but keep available in menu) - trackElement.track.mode = 'hidden'; - } + return effect(() => { + if (!canSyncTextTrackModes.get()) return; + /** @TODO refactor TextTracks owners model. Should simply use id. Also should use corresponding TextTrack (JS) element if possible (CJP) */ + const textTracks = textTracksSignal.get() as Map; + const selectedTextTrackId = selectedTextTrackIdSignal.get() as string; + + for (const [trackId, trackElement] of textTracks) { + trackElement.track.mode = trackId === selectedTextTrackId ? 'showing' : 'hidden'; } }); } diff --git a/packages/spf/src/dom/features/tests/end-of-stream.test.ts b/packages/spf/src/dom/features/tests/end-of-stream.test.ts index bd34a012..42418db9 100644 --- a/packages/spf/src/dom/features/tests/end-of-stream.test.ts +++ b/packages/spf/src/dom/features/tests/end-of-stream.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { createState } from '../../../core/state/create-state'; +import { signal } from '../../../core/signals/primitives'; import type { Presentation, Segment, VideoTrack } from '../../../core/types'; import { createSourceBufferActor, type SourceBufferActor } from '../../media/source-buffer-actor'; import { @@ -11,6 +11,13 @@ import { shouldEndStream, } from '../end-of-stream'; +function setupEndOfStream(initialState: EndOfStreamState, initialOwners: EndOfStreamOwners) { + const state = signal(initialState); + const owners = signal(initialOwners); + const cleanup = endOfStream({ state, owners }); + return { state, owners, cleanup }; +} + // ============================================================================ // Mock helpers // ============================================================================ @@ -361,22 +368,18 @@ describe('endOfStream', () => { const track = makeResolvedVideoTrack(4); const mockMs = makeMediaSource(); - const state = createState({ - selectedVideoTrackId: 'video-1', - presentation: makePresentation(track), - }); - const owners = createState({ - mediaSource: mockMs, - videoBuffer: makeSourceBuffer(), - videoBufferActor: makeActorWithSegments(['seg-0', 'seg-1', 'seg-2', 'seg-3']), - }); - - const cleanup = endOfStream({ state, owners }); + const { cleanup } = setupEndOfStream( + { selectedVideoTrackId: 'video-1', presentation: makePresentation(track) }, + { + mediaSource: mockMs, + videoBuffer: makeSourceBuffer(), + videoBufferActor: makeActorWithSegments(['seg-0', 'seg-1', 'seg-2', 'seg-3']), + } + ); await vi.waitFor(() => { expect(mockMs.endOfStream).toHaveBeenCalledTimes(1); }); - cleanup(); }); @@ -384,23 +387,19 @@ describe('endOfStream', () => { const track = makeResolvedVideoTrack(10); const mockMs = makeMediaSource(); - const state = createState({ - selectedVideoTrackId: 'video-1', - presentation: makePresentation(track), - }); // Back buffer flushed, only last few segments remain — last segment still present - const owners = createState({ - mediaSource: mockMs, - videoBuffer: makeSourceBuffer(), - videoBufferActor: makeActorWithSegments(['seg-7', 'seg-8', 'seg-9']), - }); - - const cleanup = endOfStream({ state, owners }); + const { cleanup } = setupEndOfStream( + { selectedVideoTrackId: 'video-1', presentation: makePresentation(track) }, + { + mediaSource: mockMs, + videoBuffer: makeSourceBuffer(), + videoBufferActor: makeActorWithSegments(['seg-7', 'seg-8', 'seg-9']), + } + ); await vi.waitFor(() => { expect(mockMs.endOfStream).toHaveBeenCalledTimes(1); }); - cleanup(); }); @@ -413,28 +412,24 @@ describe('endOfStream', () => { (mockMs as unknown as { readyState: string }).readyState = 'ended'; }); - const state = createState({ - selectedVideoTrackId: 'video-1', - presentation: makePresentation(track), - }); - const owners = createState({ - mediaSource: mockMs, - videoBuffer: makeSourceBuffer(), - videoBufferActor: makeActorWithSegments(['seg-0', 'seg-1', 'seg-2', 'seg-3']), - }); - - const cleanup = endOfStream({ state, owners }); + const { state, cleanup } = setupEndOfStream( + { selectedVideoTrackId: 'video-1', presentation: makePresentation(track) }, + { + mediaSource: mockMs, + videoBuffer: makeSourceBuffer(), + videoBufferActor: makeActorWithSegments(['seg-0', 'seg-1', 'seg-2', 'seg-3']), + } + ); await vi.waitFor(() => { expect(mockMs.endOfStream).toHaveBeenCalledTimes(1); }); // Trigger another state change — readyState is 'ended' so must not call again - state.patch({ presentation: makePresentation(track) }); + state.set({ ...state.get(), presentation: makePresentation(track) }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(mockMs.endOfStream).toHaveBeenCalledTimes(1); - cleanup(); }); @@ -442,56 +437,56 @@ describe('endOfStream', () => { const track = makeResolvedVideoTrack(4); const mockMs = makeMediaSource(); - const state = createState({ - selectedVideoTrackId: 'video-1', - presentation: makePresentation(track), - }); - const owners = createState({ - mediaSource: mockMs, - videoBuffer: makeSourceBuffer(), - videoBufferActor: makeActorWithSegments(['seg-0', 'seg-1']), // missing seg-3 - }); - - const cleanup = endOfStream({ state, owners }); + const { cleanup } = setupEndOfStream( + { selectedVideoTrackId: 'video-1', presentation: makePresentation(track) }, + { + mediaSource: mockMs, + videoBuffer: makeSourceBuffer(), + videoBufferActor: makeActorWithSegments(['seg-0', 'seg-1']), + } // missing seg-3 + ); await new Promise((resolve) => setTimeout(resolve, 50)); expect(mockMs.endOfStream).not.toHaveBeenCalled(); - cleanup(); }); it('calls endOfStream() again after seek-back re-opens the MediaSource', async () => { const track = makeResolvedVideoTrack(4); const mockMs = makeMediaSource(); + // Reactive readyState signal — drives re-evaluation when MSE transitions readyState. + const msReadyState = signal('open'); - const state = createState({ - selectedVideoTrackId: 'video-1', - presentation: makePresentation(track), - }); - const owners = createState({ - mediaSource: mockMs, - videoBuffer: makeSourceBuffer(), - videoBufferActor: makeActorWithSegments(['seg-0', 'seg-1', 'seg-2', 'seg-3']), + // Simulate real MSE: endOfStream() transitions readyState to 'ended'. + (mockMs.endOfStream as ReturnType).mockImplementation(() => { + (mockMs as unknown as { readyState: string }).readyState = 'ended'; + msReadyState.set('ended'); }); - const cleanup = endOfStream({ state, owners }); + const { cleanup } = setupEndOfStream( + { selectedVideoTrackId: 'video-1', presentation: makePresentation(track) }, + { + mediaSource: mockMs, + mediaSourceReadyState: msReadyState, + videoBuffer: makeSourceBuffer(), + videoBufferActor: makeActorWithSegments(['seg-0', 'seg-1', 'seg-2', 'seg-3']), + } + ); - // First play-through: endOfStream() is called + // First play-through: endOfStream() is called, readyState transitions to 'ended'. await vi.waitFor(() => { expect(mockMs.endOfStream).toHaveBeenCalledTimes(1); }); - // Simulate seek-back: appendBuffer() re-opens the MediaSource per MSE spec + // Simulate seek-back: appendBuffer() re-opens the MediaSource per MSE spec. + // The reactive signal update drives re-evaluation — no owners.patch needed. (mockMs as unknown as { readyState: string }).readyState = 'open'; + msReadyState.set('open'); - // Patch owners with a new actor that still has the last segment - owners.patch({ videoBufferActor: makeActorWithSegments(['seg-2', 'seg-3']) }); - - // endOfStream() should be called again + // endOfStream() should be called again driven purely by the readyState signal. await vi.waitFor(() => { expect(mockMs.endOfStream).toHaveBeenCalledTimes(2); }); - cleanup(); }); @@ -499,17 +494,14 @@ describe('endOfStream', () => { const track = makeResolvedVideoTrack(4); const mockMs = makeMediaSource(); - const state = createState({ - selectedVideoTrackId: 'video-1', - presentation: makePresentation(track), - }); - const owners = createState({ - mediaSource: mockMs, - videoBuffer: makeSourceBuffer(), - videoBufferActor: makeActorWithSegments(['seg-0', 'seg-1', 'seg-2', 'seg-3']), - }); - - const cleanup = endOfStream({ state, owners }); + const { state, cleanup } = setupEndOfStream( + { selectedVideoTrackId: 'video-1', presentation: makePresentation(track) }, + { + mediaSource: mockMs, + videoBuffer: makeSourceBuffer(), + videoBufferActor: makeActorWithSegments(['seg-0', 'seg-1', 'seg-2', 'seg-3']), + } + ); await vi.waitFor(() => { expect(mockMs.endOfStream).toHaveBeenCalledTimes(1); @@ -519,11 +511,10 @@ describe('endOfStream', () => { (mockMs as unknown as { readyState: string }).readyState = 'ended'; // Trigger a state change — should not call endOfStream() again - state.patch({ presentation: makePresentation(track) }); + state.set({ ...state.get(), presentation: makePresentation(track) }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(mockMs.endOfStream).toHaveBeenCalledTimes(1); - cleanup(); }); @@ -532,11 +523,6 @@ describe('endOfStream', () => { const mockMs = makeMediaSource(); const neverAborted = new AbortController().signal; - const state = createState({ - selectedVideoTrackId: 'video-1', - presentation: makePresentation(track), - }); - // Start with only first two segments loaded const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer, { @@ -546,13 +532,11 @@ describe('endOfStream', () => { { id: 'seg-1', startTime: 2.5, duration: 2.5, trackId: 'video-1' }, ], }); - const owners = createState({ - mediaSource: mockMs, - videoBuffer: sourceBuffer, - videoBufferActor: actor, - }); - const cleanup = endOfStream({ state, owners }); + const { cleanup } = setupEndOfStream( + { selectedVideoTrackId: 'video-1', presentation: makePresentation(track) }, + { mediaSource: mockMs, videoBuffer: sourceBuffer, videoBufferActor: actor } + ); await new Promise((resolve) => setTimeout(resolve, 20)); expect(mockMs.endOfStream).not.toHaveBeenCalled(); @@ -578,7 +562,6 @@ describe('endOfStream', () => { await vi.waitFor(() => { expect(mockMs.endOfStream).toHaveBeenCalledTimes(1); }); - cleanup(); }); }); diff --git a/packages/spf/src/dom/features/tests/load-segments.test.ts b/packages/spf/src/dom/features/tests/load-segments.test.ts index 578f4582..49e1ef73 100644 --- a/packages/spf/src/dom/features/tests/load-segments.test.ts +++ b/packages/spf/src/dom/features/tests/load-segments.test.ts @@ -3,9 +3,10 @@ */ import { describe, expect, it, vi } from 'vitest'; +import { signal } from '../../../core/signals/primitives'; import type { Segment } from '../../../core/types'; import { createSourceBufferActor } from '../../media/source-buffer-actor'; -import type { SegmentLoadingOwners, SegmentLoadingState } from '../load-segments'; +import { loadSegments, type SegmentLoadingOwners, type SegmentLoadingState } from '../load-segments'; // --------------------------------------------------------------------------- // Helpers @@ -120,6 +121,17 @@ function makeSourceBufferWithActor( return { sourceBuffer, actor }; } +function setupLoadSegments( + initialState: SegmentLoadingState, + initialOwners: SegmentLoadingOwners, + type: 'video' | 'audio' +) { + const state = signal(initialState); + const owners = signal(initialOwners); + const cleanup = loadSegments({ state, owners }, { type }); + return { state, owners, cleanup }; +} + // --------------------------------------------------------------------------- // loadSegments orchestration — forward buffer behaviour // --------------------------------------------------------------------------- @@ -140,27 +152,24 @@ describe('loadSegments orchestration (F5)', () => { return Promise.resolve(new Response(new ArrayBuffer(100))); }); - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - const track = makeResolvedVideoTrack(segments); - const state = cs({ - preload: 'auto', - selectedVideoTrackId: 'track-1', - currentTime: 0, - presentation: { - id: 'p1', - url: 'http://example.com/playlist.m3u8', - startTime: 0, - duration: 40, - selectionSets: [{ id: 'ss1', type: 'video', switchingSets: [{ id: 'sw1', type: 'video', tracks: [track] }] }], - }, - }); - const { sourceBuffer, actor } = makeSourceBufferWithActor(); - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - - const cleanup = loadSegments({ state, owners }, { type: 'video' }); + const { cleanup } = setupLoadSegments( + { + preload: 'auto', + selectedVideoTrackId: 'track-1', + currentTime: 0, + presentation: { + id: 'p1', + url: 'http://example.com/playlist.m3u8', + startTime: 0, + duration: 40, + selectionSets: [{ id: 'ss1', type: 'video', switchingSets: [{ id: 'sw1', type: 'video', tracks: [track] }] }], + }, + }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); await vi.waitFor(() => { expect(fetchedUrls).toContain('http://example.com/init.mp4'); @@ -183,27 +192,25 @@ describe('loadSegments orchestration (F5)', () => { return Promise.resolve(new Response(new ArrayBuffer(100))); }); - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - const track = makeResolvedVideoTrack(segments); - const state = cs({ - preload: 'auto', - selectedVideoTrackId: 'track-1', - currentTime: 0, - presentation: { - id: 'p1', - url: 'http://example.com/playlist.m3u8', - startTime: 0, - duration: 10, - selectionSets: [{ id: 'ss1', type: 'video', switchingSets: [{ id: 'sw1', type: 'video', tracks: [track] }] }], - }, - }); - // Init already loaded for this track — actor context has initTrackId set const { sourceBuffer, actor } = makeSourceBufferWithActor([], [], 'track-1'); - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - const cleanup = loadSegments({ state, owners }, { type: 'video' }); + const { cleanup } = setupLoadSegments( + { + preload: 'auto', + selectedVideoTrackId: 'track-1', + currentTime: 0, + presentation: { + id: 'p1', + url: 'http://example.com/playlist.m3u8', + startTime: 0, + duration: 10, + selectionSets: [{ id: 'ss1', type: 'video', switchingSets: [{ id: 'sw1', type: 'video', tracks: [track] }] }], + }, + }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); await vi.waitFor(() => { expect(fetchedUrls).toContain('http://example.com/s1.m4s'); @@ -223,31 +230,29 @@ describe('loadSegments orchestration (F5)', () => { return Promise.resolve(new Response(new ArrayBuffer(100))); }); - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - const track = makeResolvedVideoTrack(segments); - const state = cs({ - preload: 'auto', - selectedVideoTrackId: 'track-1', - currentTime: 0, - presentation: { - id: 'p1', - url: 'http://example.com/playlist.m3u8', - startTime: 0, - duration: 30, - selectionSets: [{ id: 'ss1', type: 'video', switchingSets: [{ id: 'sw1', type: 'video', tracks: [track] }] }], - }, - }); - // s1 already loaded — actor pre-seeded with s1 and init const { sourceBuffer, actor } = makeSourceBufferWithActor( [[0, 10]], [{ id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }], 'track-1' ); - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - const cleanup = loadSegments({ state, owners }, { type: 'video' }); + const { cleanup } = setupLoadSegments( + { + preload: 'auto', + selectedVideoTrackId: 'track-1', + currentTime: 0, + presentation: { + id: 'p1', + url: 'http://example.com/playlist.m3u8', + startTime: 0, + duration: 30, + selectionSets: [{ id: 'ss1', type: 'video', switchingSets: [{ id: 'sw1', type: 'video', tracks: [track] }] }], + }, + }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); await vi.waitFor(() => { expect(fetchedUrls).toContain('http://example.com/s2.m4s'); @@ -291,18 +296,12 @@ describe('loadSegments orchestration (metadata mode)', () => { return Promise.resolve(new Response(new ArrayBuffer(100))); }); - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - - const state = cs({ - preload: 'metadata', - selectedVideoTrackId: 'track-1', - presentation: makePresentation(segments), - }); const { sourceBuffer, actor } = makeSourceBufferWithActor(); - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - - const cleanup = loadSegments({ state, owners }, { type: 'video' }); + const { cleanup } = setupLoadSegments( + { preload: 'metadata', selectedVideoTrackId: 'track-1', presentation: makePresentation(segments) }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); await vi.waitFor( () => { @@ -324,27 +323,21 @@ describe('loadSegments orchestration (metadata mode)', () => { globalThis.fetch = vi.fn().mockImplementation(() => Promise.resolve(new Response(new ArrayBuffer(100)))); - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - - const state = cs({ - preload: 'metadata', - selectedVideoTrackId: 'track-1', - presentation: makePresentation(segments), - }); const { sourceBuffer, actor } = makeSourceBufferWithActor(); - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - - const cleanup = loadSegments({ state, owners }, { type: 'video' }); + const { owners, cleanup } = setupLoadSegments( + { preload: 'metadata', selectedVideoTrackId: 'track-1', presentation: makePresentation(segments) }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); await vi.waitFor( () => { - expect(owners.current.videoBufferActor?.snapshot.context.initTrackId).toBe('track-1'); + expect(owners.get().videoBufferActor?.snapshot.get().context.initTrackId).toBe('track-1'); }, { timeout: 2000 } ); - expect(owners.current.videoBufferActor?.snapshot.context.segments.length ?? 0).toBe(0); + expect(owners.get().videoBufferActor?.snapshot.get().context.segments.length ?? 0).toBe(0); cleanup(); }); @@ -359,29 +352,23 @@ describe('loadSegments orchestration (metadata mode)', () => { return Promise.resolve(new Response(new ArrayBuffer(100))); }); - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - - const state = cs({ - preload: 'metadata', - selectedVideoTrackId: 'track-1', - presentation: makePresentation(segments), - }); const { sourceBuffer, actor } = makeSourceBufferWithActor(); - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - - const cleanup = loadSegments({ state, owners }, { type: 'video' }); + const { state, owners, cleanup } = setupLoadSegments( + { preload: 'metadata', selectedVideoTrackId: 'track-1', presentation: makePresentation(segments) }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); await vi.waitFor( () => { - expect(owners.current.videoBufferActor?.snapshot.context.initTrackId).toBe('track-1'); + expect(owners.get().videoBufferActor?.snapshot.get().context.initTrackId).toBe('track-1'); }, { timeout: 2000 } ); expect(fetchedUrls).not.toContain('http://example.com/s1.m4s'); - state.patch({ playbackInitiated: true }); + state.set({ ...state.get(), playbackInitiated: true }); await vi.waitFor( () => { @@ -446,24 +433,22 @@ describe('loadSegments seek handling', () => { const { fetch, fetchedUrls, resolve } = makeControllableFetch(); globalThis.fetch = fetch; - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - - const state = cs({ - preload: 'auto', - playbackInitiated: true, // seeks are a post-play concern; currentTime only tracked when playing - selectedVideoTrackId: 'track-1', - currentTime: 0, - presentation: makePresentation(segments), - }); const { sourceBuffer, actor } = makeSourceBufferWithActor(); - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - - const cleanup = loadSegments({ state, owners }, { type: 'video' }); + const { state, cleanup } = setupLoadSegments( + { + preload: 'auto', + playbackInitiated: true, // seeks are a post-play concern; currentTime only tracked when playing + selectedVideoTrackId: 'track-1', + currentTime: 0, + presentation: makePresentation(segments), + }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); await vi.waitFor(() => expect(fetchedUrls).toContain('http://example.com/init.mp4')); - state.patch({ currentTime: 60 }); + state.set({ ...state.get(), currentTime: 60 }); resolve('http://example.com/init.mp4'); @@ -483,25 +468,23 @@ describe('loadSegments seek handling', () => { const { fetch, fetchedUrls, resolveAll } = makeControllableFetch(); globalThis.fetch = fetch; - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - - const state = cs({ - preload: 'auto', - playbackInitiated: true, // seeks are a post-play concern; currentTime only tracked when playing - selectedVideoTrackId: 'track-1', - currentTime: 0, - presentation: makePresentation(segments), - }); const { sourceBuffer, actor } = makeSourceBufferWithActor(); - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - - const cleanup = loadSegments({ state, owners }, { type: 'video' }); + const { state, cleanup } = setupLoadSegments( + { + preload: 'auto', + playbackInitiated: true, // seeks are a post-play concern; currentTime only tracked when playing + selectedVideoTrackId: 'track-1', + currentTime: 0, + presentation: makePresentation(segments), + }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); await vi.waitFor(() => expect(fetchedUrls).toContain('http://example.com/init.mp4')); - state.patch({ currentTime: 60 }); - state.patch({ currentTime: 90 }); + state.set({ ...state.get(), currentTime: 60 }); + state.set({ ...state.get(), currentTime: 90 }); resolveAll(); @@ -527,23 +510,21 @@ describe('loadSegments seek handling', () => { return Promise.resolve(new Response(new ArrayBuffer(100))); }); - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - - const state = cs({ - preload: 'auto', - playbackInitiated: true, // currentTime advances are only tracked post-play - selectedVideoTrackId: 'track-1', - currentTime: 0, - presentation: makePresentation(segments), - }); const { sourceBuffer, actor } = makeSourceBufferWithActor(); - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); + const { state, cleanup } = setupLoadSegments( + { + preload: 'auto', + playbackInitiated: true, // currentTime advances are only tracked post-play + selectedVideoTrackId: 'track-1', + currentTime: 0, + presentation: makePresentation(segments), + }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); - const cleanup = loadSegments({ state, owners }, { type: 'video' }); - - state.patch({ currentTime: 2 }); - state.patch({ currentTime: 4 }); + state.set({ ...state.get() }); + state.set({ ...state.get() }); await vi.waitFor( () => { @@ -608,9 +589,6 @@ describe('loadSegments back buffer flushing', () => { const { fetch, resolveAll } = makeControllableFetch(); globalThis.fetch = fetch; - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - // s1–s4 already loaded, currentTime jumped to 40s const { sourceBuffer, actor } = makeSourceBufferWithActor( [[0, 40]], @@ -622,16 +600,11 @@ describe('loadSegments back buffer flushing', () => { ], 'track-1' ); - - const state = cs({ - preload: 'auto', - selectedVideoTrackId: 'track-1', - currentTime: 40, - presentation: makePresentationF6(segments), - }); - - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - const cleanup = loadSegments({ state, owners }, { type: 'video' }); + const { cleanup } = setupLoadSegments( + { preload: 'auto', selectedVideoTrackId: 'track-1', currentTime: 40, presentation: makePresentationF6(segments) }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); resolveAll(); @@ -651,39 +624,36 @@ describe('loadSegments back buffer flushing', () => { globalThis.fetch = vi.fn().mockImplementation(() => Promise.resolve(new Response(new ArrayBuffer(100)))); - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - // s1 already loaded, currentTime=10 const { sourceBuffer, actor } = makeSourceBufferWithActor( [[0, 10]], [{ id: 's1', startTime: 0, duration: 10, trackId: 'track-1' }], 'track-1' ); - - const state = cs({ - preload: 'auto', - selectedVideoTrackId: 'track-1', - currentTime: 10, - presentation: { - id: 'p1', - url: 'http://example.com/playlist.m3u8', - startTime: 0, - duration: 30, - selectionSets: [ - { - id: 'ss1', - type: 'video' as const, - switchingSets: [{ id: 'sw1', type: 'video' as const, tracks: [makeResolvedVideoTrack(segments)] }], - }, - ], + const { owners, cleanup } = setupLoadSegments( + { + preload: 'auto', + selectedVideoTrackId: 'track-1', + currentTime: 10, + presentation: { + id: 'p1', + url: 'http://example.com/playlist.m3u8', + startTime: 0, + duration: 30, + selectionSets: [ + { + id: 'ss1', + type: 'video' as const, + switchingSets: [{ id: 'sw1', type: 'video' as const, tracks: [makeResolvedVideoTrack(segments)] }], + }, + ], + }, }, - }); + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - const cleanup = loadSegments({ state, owners }, { type: 'video' }); - - await vi.waitFor(() => (owners.current.videoBufferActor?.snapshot.context.segments.length ?? 0) > 1, { + await vi.waitFor(() => (owners.get().videoBufferActor?.snapshot.get().context.segments.length ?? 0) > 1, { timeout: 3000, }); @@ -703,9 +673,6 @@ describe('loadSegments back buffer flushing', () => { globalThis.fetch = vi.fn().mockImplementation(() => Promise.resolve(new Response(new ArrayBuffer(100)))); - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - const { sourceBuffer, actor } = makeSourceBufferWithActor( [[0, 40]], [ @@ -716,20 +683,19 @@ describe('loadSegments back buffer flushing', () => { ], 'track-1' ); - - const state = cs({ - preload: 'auto', - selectedVideoTrackId: 'track-1', - currentTime: 40, - presentation: makePresentationF6(segments), - }); - - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - const cleanup = loadSegments({ state, owners }, { type: 'video' }); + const { owners, cleanup } = setupLoadSegments( + { preload: 'auto', selectedVideoTrackId: 'track-1', currentTime: 40, presentation: makePresentationF6(segments) }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); await vi.waitFor( () => { - const ids = owners.current.videoBufferActor?.snapshot.context.segments.map((s) => s.id) ?? []; + const ids = + owners + .get() + .videoBufferActor?.snapshot.get() + .context.segments.map((s) => s.id) ?? []; expect(ids).not.toContain('s1'); expect(ids).not.toContain('s2'); }, @@ -787,25 +753,17 @@ describe('loadSegments forward buffer flushing', () => { const { fetch, resolveAll } = makeControllableFetchFwd(); globalThis.fetch = fetch; - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - // All 5 segments pre-loaded; seeded with initial buffered range [0, 50] const { sourceBuffer, actor } = makeSourceBufferWithActor( [[0, 50]], segments.map((s) => ({ id: s.id, startTime: s.startTime, duration: s.duration, trackId: 'track-1' })), 'track-1' ); - - const state = cs({ - preload: 'auto', - selectedVideoTrackId: 'track-1', - currentTime: 0, - presentation: makePresentationFwd(segments), - }); - - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - const cleanup = loadSegments({ state, owners }, { type: 'video' }); + const { cleanup } = setupLoadSegments( + { preload: 'auto', selectedVideoTrackId: 'track-1', currentTime: 0, presentation: makePresentationFwd(segments) }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); setTimeout(resolveAll, 10); @@ -832,31 +790,27 @@ describe('loadSegments forward buffer flushing', () => { const { fetch, resolveAll } = makeControllableFetchFwd(); globalThis.fetch = fetch; - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - // Seed buffered ranges to match the pre-seeded actor context const { sourceBuffer, actor } = makeSourceBufferWithActor( [[0, 50]], segments.map((s) => ({ id: s.id, startTime: s.startTime, duration: s.duration, trackId: 'track-1' })), 'track-1' ); - - const state = cs({ - preload: 'auto', - selectedVideoTrackId: 'track-1', - currentTime: 0, - presentation: makePresentationFwd(segments), - }); - - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - const cleanup = loadSegments({ state, owners }, { type: 'video' }); + const { owners, cleanup } = setupLoadSegments( + { preload: 'auto', selectedVideoTrackId: 'track-1', currentTime: 0, presentation: makePresentationFwd(segments) }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); setTimeout(resolveAll, 10); await vi.waitFor( () => { - const ids = owners.current.videoBufferActor?.snapshot.context.segments.map((s) => s.id) ?? []; + const ids = + owners + .get() + .videoBufferActor?.snapshot.get() + .context.segments.map((s) => s.id) ?? []; expect(ids).not.toContain('s4'); expect(ids).not.toContain('s5'); expect(ids).toContain('s1'); @@ -873,24 +827,16 @@ describe('loadSegments forward buffer flushing', () => { const { fetch, resolveAll } = makeControllableFetchFwd(); globalThis.fetch = fetch; - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - const { sourceBuffer, actor } = makeSourceBufferWithActor( [[0, 30]], segments.map((s) => ({ id: s.id, startTime: s.startTime, duration: s.duration, trackId: 'track-1' })), 'track-1' ); - - const state = cs({ - preload: 'auto', - selectedVideoTrackId: 'track-1', - currentTime: 0, - presentation: makePresentationFwd(segments), - }); - - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - const cleanup = loadSegments({ state, owners }, { type: 'video' }); + const { cleanup } = setupLoadSegments( + { preload: 'auto', selectedVideoTrackId: 'track-1', currentTime: 0, presentation: makePresentationFwd(segments) }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); setTimeout(resolveAll, 10); @@ -932,9 +878,6 @@ describe('loadSegments byte-range segment fetching', () => { return Promise.resolve(new Response(new ArrayBuffer(100))); }); - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - const track = { type: 'video' as const, id: 'track-1', @@ -948,27 +891,27 @@ describe('loadSegments byte-range segment fetching', () => { duration: 12, }; - const state = cs({ - preload: 'auto', - selectedVideoTrackId: 'track-1', - currentTime: 0, - presentation: { - id: 'p1', - url: 'http://example.com/playlist.m3u8', - startTime: 0, - duration: 12, - selectionSets: [{ id: 'ss1', type: 'video', switchingSets: [{ id: 'sw1', type: 'video', tracks: [track] }] }], - }, - }); - const { sourceBuffer, actor } = makeSourceBufferWithActor(); - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - - const cleanup = loadSegments({ state, owners }, { type: 'video' }); + const { owners, cleanup } = setupLoadSegments( + { + preload: 'auto', + selectedVideoTrackId: 'track-1', + currentTime: 0, + presentation: { + id: 'p1', + url: 'http://example.com/playlist.m3u8', + startTime: 0, + duration: 12, + selectionSets: [{ id: 'ss1', type: 'video', switchingSets: [{ id: 'sw1', type: 'video', tracks: [track] }] }], + }, + }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); await vi.waitFor( () => { - expect(owners.current.videoBufferActor?.snapshot.context.segments).toHaveLength(2); + expect(owners.get().videoBufferActor?.snapshot.get().context.segments).toHaveLength(2); }, { timeout: 3000 } ); @@ -990,36 +933,33 @@ describe('loadSegments byte-range segment fetching', () => { return Promise.resolve(new Response(new ArrayBuffer(100))); }); - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - - const state = cs({ - preload: 'auto', - selectedVideoTrackId: 'track-1', - currentTime: 0, - presentation: { - id: 'p1', - url: 'http://example.com/playlist.m3u8', - startTime: 0, - duration: 10, - selectionSets: [ - { - id: 'ss1', - type: 'video', - switchingSets: [{ id: 'sw1', type: 'video', tracks: [makeResolvedVideoTrack(segments)] }], - }, - ], - }, - }); - const { sourceBuffer, actor } = makeSourceBufferWithActor(); - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - - const cleanup = loadSegments({ state, owners }, { type: 'video' }); + const { owners, cleanup } = setupLoadSegments( + { + preload: 'auto', + selectedVideoTrackId: 'track-1', + currentTime: 0, + presentation: { + id: 'p1', + url: 'http://example.com/playlist.m3u8', + startTime: 0, + duration: 10, + selectionSets: [ + { + id: 'ss1', + type: 'video', + switchingSets: [{ id: 'sw1', type: 'video', tracks: [makeResolvedVideoTrack(segments)] }], + }, + ], + }, + }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); await vi.waitFor( () => { - expect(owners.current.videoBufferActor?.snapshot.context.segments).toHaveLength(1); + expect(owners.get().videoBufferActor?.snapshot.get().context.segments).toHaveLength(1); }, { timeout: 3000 } ); @@ -1054,9 +994,6 @@ describe('loadSegments bandwidth tracking', () => { globalThis.fetch = makeStreamingFetch(chunks); - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - const segment = { id: 's1', url: 'http://example.com/s1.m4s', startTime: 0, duration: 10 }; const track = { type: 'video' as const, @@ -1080,37 +1017,37 @@ describe('loadSegments bandwidth tracking', () => { bytesSampled: 0, }; - const state = cs({ - preload: 'auto', - selectedVideoTrackId: 'track-1', - currentTime: 0, - bandwidthState: initialBandwidth, - presentation: { - id: 'p1', - url: 'http://example.com/playlist.m3u8', - startTime: 0, - duration: 10, - selectionSets: [{ id: 'ss1', type: 'video', switchingSets: [{ id: 'sw1', type: 'video', tracks: [track] }] }], - }, - }); - const { sourceBuffer, actor } = makeSourceBufferWithActor(); - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - - const cleanup = loadSegments({ state, owners }, { type: 'video' }); + const { state, owners, cleanup } = setupLoadSegments( + { + preload: 'auto', + selectedVideoTrackId: 'track-1', + currentTime: 0, + bandwidthState: initialBandwidth, + presentation: { + id: 'p1', + url: 'http://example.com/playlist.m3u8', + startTime: 0, + duration: 10, + selectionSets: [{ id: 'ss1', type: 'video', switchingSets: [{ id: 'sw1', type: 'video', tracks: [track] }] }], + }, + }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); // Wait for both init and segment to be appended (actor context will have 1 segment) await vi.waitFor( () => { - expect(owners.current.videoBufferActor?.snapshot.context.segments).toHaveLength(1); + expect(owners.get().videoBufferActor?.snapshot.get().context.segments).toHaveLength(1); }, { timeout: 3000 } ); // All bytes (init + segment) should be counted in bytesSampled const totalExpected = chunkSize * numChunks * 2; // init fetch + segment fetch, each 3×50KB - expect(state.current.bandwidthState?.bytesSampled).toBeGreaterThan(0); - expect(state.current.bandwidthState?.bytesSampled).toBeLessThanOrEqual(totalExpected); + expect(state.get().bandwidthState?.bytesSampled).toBeGreaterThan(0); + expect(state.get().bandwidthState?.bytesSampled).toBeLessThanOrEqual(totalExpected); cleanup(); }); @@ -1130,9 +1067,6 @@ describe('loadSegments bandwidth tracking', () => { return Promise.resolve(new Response(body)); }); - const { loadSegments } = await import('../load-segments'); - const { createState: cs } = await import('../../../core/state/create-state'); - const segment = { id: 's1', url: 'http://example.com/s1.m4s', startTime: 0, duration: 10 }; const track = { type: 'video' as const, @@ -1147,27 +1081,27 @@ describe('loadSegments bandwidth tracking', () => { duration: 10, }; - const state = cs({ - preload: 'auto', - selectedVideoTrackId: 'track-1', - currentTime: 0, - presentation: { - id: 'p1', - url: 'http://example.com/playlist.m3u8', - startTime: 0, - duration: 10, - selectionSets: [{ id: 'ss1', type: 'video', switchingSets: [{ id: 'sw1', type: 'video', tracks: [track] }] }], - }, - }); - const { sourceBuffer, actor } = makeSourceBufferWithActor(); - const owners = cs({ videoBuffer: sourceBuffer, videoBufferActor: actor }); - - const cleanup = loadSegments({ state, owners }, { type: 'video' }); + const { owners, cleanup } = setupLoadSegments( + { + preload: 'auto', + selectedVideoTrackId: 'track-1', + currentTime: 0, + presentation: { + id: 'p1', + url: 'http://example.com/playlist.m3u8', + startTime: 0, + duration: 10, + selectionSets: [{ id: 'ss1', type: 'video', switchingSets: [{ id: 'sw1', type: 'video', tracks: [track] }] }], + }, + }, + { videoBuffer: sourceBuffer, videoBufferActor: actor }, + 'video' + ); await vi.waitFor( () => { - expect(owners.current.videoBufferActor?.snapshot.context.segments).toHaveLength(1); + expect(owners.get().videoBufferActor?.snapshot.get().context.segments).toHaveLength(1); }, { timeout: 3000 } ); diff --git a/packages/spf/src/dom/features/tests/load-text-track-cues.test.ts b/packages/spf/src/dom/features/tests/load-text-track-cues.test.ts index 4c4a9eeb..d10c1495 100644 --- a/packages/spf/src/dom/features/tests/load-text-track-cues.test.ts +++ b/packages/spf/src/dom/features/tests/load-text-track-cues.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createState } from '../../../core/state/create-state'; +import { signal } from '../../../core/signals/primitives'; import type { Presentation, Segment, TextTrack } from '../../../core/types'; import { canLoadTextTrackCues, @@ -9,6 +9,13 @@ import { type TextTrackCueLoadingState, } from '../load-text-track-cues'; +function setupLoadTextTrackCues(initialState: TextTrackCueLoadingState, initialOwners: TextTrackCueLoadingOwners) { + const state = signal(initialState); + const owners = signal(initialOwners); + const cleanup = loadTextTrackCues({ state, owners }); + return { state, owners, cleanup }; +} + // Mock parseVttSegment vi.mock('../../text/parse-vtt-segment', () => ({ parseVttSegment: vi.fn((url: string) => { @@ -196,15 +203,13 @@ describe('loadTextTrackCues', () => { const { trackElement, addCueSpy } = makeTrackWithPersistentCues(); - const state = createState({ - selectedTextTrackId: 'text-1', - presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(3) }]), - }); - const owners = createState({ - textTracks: new Map([['text-1', trackElement]]), - }); - - const cleanup = loadTextTrackCues({ state, owners }); + const { cleanup } = setupLoadTextTrackCues( + { + selectedTextTrackId: 'text-1', + presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(3) }]), + }, + { textTracks: new Map([['text-1', trackElement]]) } + ); await new Promise((resolve) => setTimeout(resolve, 50)); expect(addCueSpy).toHaveBeenCalledTimes(3); @@ -220,15 +225,13 @@ describe('loadTextTrackCues', () => { const { trackElement, addCueSpy } = makeTrackWithPersistentCues(); - const state = createState({ - selectedTextTrackId: 'text-1', - presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(2) }]), - }); - const owners = createState({ - textTracks: new Map([['text-1', trackElement]]), - }); - - const cleanup = loadTextTrackCues({ state, owners }); + const { cleanup } = setupLoadTextTrackCues( + { + selectedTextTrackId: 'text-1', + presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(2) }]), + }, + { textTracks: new Map([['text-1', trackElement]]) } + ); await new Promise((resolve) => setTimeout(resolve, 50)); expect(addCueSpy).toHaveBeenCalledTimes(1); @@ -243,15 +246,13 @@ describe('loadTextTrackCues', () => { const { trackElement, addCueSpy } = makeTrackWithPersistentCues(); - const state = createState({ - selectedTextTrackId: 'text-1', - presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(2) }]), - }); - const owners = createState({ - textTracks: new Map([['text-1', trackElement]]), - }); - - const cleanup = loadTextTrackCues({ state, owners }); + const { cleanup } = setupLoadTextTrackCues( + { + selectedTextTrackId: 'text-1', + presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(2) }]), + }, + { textTracks: new Map([['text-1', trackElement]]) } + ); await new Promise((resolve) => setTimeout(resolve, 50)); expect(addCueSpy).toHaveBeenCalledTimes(2); @@ -266,15 +267,13 @@ describe('loadTextTrackCues', () => { const { trackElement, addCueSpy } = makeTrackWithPersistentCues(); - const state = createState({ - selectedTextTrackId: 'text-1', - presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(2) }]), - }); - const owners = createState({ - textTracks: new Map([['text-1', trackElement]]), - }); - - const cleanup = loadTextTrackCues({ state, owners }); + const { cleanup } = setupLoadTextTrackCues( + { + selectedTextTrackId: 'text-1', + presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(2) }]), + }, + { textTracks: new Map([['text-1', trackElement]]) } + ); await new Promise((resolve) => setTimeout(resolve, 50)); // 3 unique cues — boundary cue added only once @@ -284,14 +283,7 @@ describe('loadTextTrackCues', () => { }); it('does nothing when track not selected', async () => { - const state = createState({ - presentation: createMockPresentation([]), - }); - const owners = createState({ - textTracks: new Map(), - }); - - const cleanup = loadTextTrackCues({ state, owners }); + const { cleanup } = setupLoadTextTrackCues({ presentation: createMockPresentation([]) }, { textTracks: new Map() }); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -307,20 +299,13 @@ describe('loadTextTrackCues', () => { video.appendChild(trackElement); trackElement.track.mode = 'hidden'; // Enable cue access - const state = createState({ - selectedTextTrackId: 'text-1', - presentation: createMockPresentation([ - { - id: 'text-1', - segments: createMockSegments(1), - }, - ]), - }); - const owners = createState({ - textTracks: new Map([['text-1', trackElement]]), - }); - - const cleanup = loadTextTrackCues({ state, owners }); + const { cleanup } = setupLoadTextTrackCues( + { + selectedTextTrackId: 'text-1', + presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(1) }]), + }, + { textTracks: new Map([['text-1', trackElement]]) } + ); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -337,20 +322,13 @@ describe('loadTextTrackCues', () => { video.appendChild(trackElement); trackElement.track.mode = 'hidden'; // Enable cue access - const state = createState({ - selectedTextTrackId: 'text-1', - presentation: createMockPresentation([ - { - id: 'text-1', - segments: createMockSegments(3), - }, - ]), - }); - const owners = createState({ - textTracks: new Map([['text-1', trackElement]]), - }); - - const cleanup = loadTextTrackCues({ state, owners }); + const { cleanup } = setupLoadTextTrackCues( + { + selectedTextTrackId: 'text-1', + presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(3) }]), + }, + { textTracks: new Map([['text-1', trackElement]]) } + ); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -373,26 +351,24 @@ describe('loadTextTrackCues', () => { video.appendChild(trackElement); trackElement.track.mode = 'hidden'; // Enable cue access - const state = createState({ - selectedTextTrackId: 'text-1', - presentation: createMockPresentation([ - { - id: 'text-1', - segments: [ - { id: 'seg-0', url: 'https://example.com/segment-0.vtt', duration: 10, startTime: 0 }, - { id: 'seg-1', url: 'https://example.com/fail.vtt', duration: 10, startTime: 10 }, - { id: 'seg-2', url: 'https://example.com/segment-2.vtt', duration: 10, startTime: 20 }, - ] as Segment[], - }, - ]), - }); - const owners = createState({ - textTracks: new Map([['text-1', trackElement]]), - }); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const cleanup = loadTextTrackCues({ state, owners }); + const { cleanup } = setupLoadTextTrackCues( + { + selectedTextTrackId: 'text-1', + presentation: createMockPresentation([ + { + id: 'text-1', + segments: [ + { id: 'seg-0', url: 'https://example.com/segment-0.vtt', duration: 10, startTime: 0 }, + { id: 'seg-1', url: 'https://example.com/fail.vtt', duration: 10, startTime: 10 }, + { id: 'seg-2', url: 'https://example.com/segment-2.vtt', duration: 10, startTime: 20 }, + ] as Segment[], + }, + ]), + }, + { textTracks: new Map([['text-1', trackElement]]) } + ); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -418,20 +394,13 @@ describe('loadTextTrackCues', () => { const video = document.createElement('video'); video.appendChild(trackElement); - const state = createState({ - selectedTextTrackId: 'text-999', - presentation: createMockPresentation([ - { - id: 'text-1', - segments: createMockSegments(1), - }, - ]), - }); - const owners = createState({ - textTracks: new Map([['text-999', trackElement]]), - }); - - const cleanup = loadTextTrackCues({ state, owners }); + const { cleanup } = setupLoadTextTrackCues( + { + selectedTextTrackId: 'text-999', + presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(1) }]), + }, + { textTracks: new Map([['text-999', trackElement]]) } + ); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -451,26 +420,20 @@ describe('loadTextTrackCues', () => { video.appendChild(trackElement); trackElement.track.mode = 'hidden'; - const state = createState({ - selectedTextTrackId: 'text-1', - currentTime, - presentation: createMockPresentation([ - { - id: 'text-1', - segments: createMockSegments(5), // 5 × 10s segments: 0,10,20,30,40 - }, - ]), - }); - const owners = createState({ - textTracks: new Map([['text-1', trackElement]]), - }); + const { state, owners, cleanup } = setupLoadTextTrackCues( + { + selectedTextTrackId: 'text-1', + currentTime, + presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(5) }]), + }, + { textTracks: new Map([['text-1', trackElement]]) } + ); - return { state, owners, trackElement }; + return { state, owners, cleanup, trackElement }; } it('only fetches segments within the forward buffer window at the initial position', async () => { - const { state, owners } = makeWindowingSetup(0); - const cleanup = loadTextTrackCues({ state, owners }); + const { cleanup } = makeWindowingSetup(0); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -487,8 +450,7 @@ describe('loadTextTrackCues', () => { }); it('fetches new in-window segments when currentTime advances', async () => { - const { state, owners } = makeWindowingSetup(0); - const cleanup = loadTextTrackCues({ state, owners }); + const { state, cleanup } = makeWindowingSetup(0); // Wait for initial window load (seg-0..seg-2) await new Promise((resolve) => setTimeout(resolve, 50)); @@ -497,7 +459,7 @@ describe('loadTextTrackCues', () => { expect(parseVttSegment).toHaveBeenCalledTimes(3); // Advance currentTime so seg-3 and seg-4 enter the window [15, 45) - state.patch({ currentTime: 15 }); + state.set({ ...state.get(), currentTime: 15 }); await vi.waitFor(() => { expect(parseVttSegment).toHaveBeenCalledTimes(5); @@ -510,8 +472,7 @@ describe('loadTextTrackCues', () => { }); it('does not re-fetch already-loaded segments when the window advances', async () => { - const { state, owners } = makeWindowingSetup(0); - const cleanup = loadTextTrackCues({ state, owners }); + const { state, cleanup } = makeWindowingSetup(0); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -519,7 +480,7 @@ describe('loadTextTrackCues', () => { const callsBefore = (parseVttSegment as ReturnType).mock.calls.map((c) => c[0]); expect(callsBefore).toContain('https://example.com/segment-0.vtt'); - state.patch({ currentTime: 15 }); + state.set({ ...state.get(), currentTime: 15 }); await vi.waitFor(() => { expect(parseVttSegment).toHaveBeenCalledTimes(5); }); @@ -534,14 +495,13 @@ describe('loadTextTrackCues', () => { }); it('fetches all segments immediately when the track fits in one window', async () => { - const { state, owners } = makeWindowingSetup(0); + const { state, cleanup } = makeWindowingSetup(0); // Override to a 3-segment track (0,10,20) — all fit in [0,30) - state.patch({ + state.set({ + ...state.get(), presentation: createMockPresentation([{ id: 'text-1', segments: createMockSegments(3) }]), }); - const cleanup = loadTextTrackCues({ state, owners }); - await new Promise((resolve) => setTimeout(resolve, 50)); const { parseVttSegment } = await import('../../text/parse-vtt-segment'); diff --git a/packages/spf/src/dom/features/tests/setup-mediasource.test.ts b/packages/spf/src/dom/features/tests/setup-mediasource.test.ts index 5153938f..529aaa6e 100644 --- a/packages/spf/src/dom/features/tests/setup-mediasource.test.ts +++ b/packages/spf/src/dom/features/tests/setup-mediasource.test.ts @@ -1,89 +1,32 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createState } from '../../../core/state/create-state'; +import { signal } from '../../../core/signals/primitives'; import type { Presentation } from '../../../core/types'; -import { - canSetup, - type MediaSourceOwners, - type MediaSourceState, - setupMediaSource, - shouldSetup, -} from '../setup-mediasource'; +import { type MediaSourceOwners, type MediaSourceState, setupMediaSource } from '../setup-mediasource'; -// Mock the DOM utilities +// Mock the DOM utilities. +// observeMediaSourceReadyState returns 'open' immediately — simulates the +// MediaSource having opened, which is the condition the inner effect waits on +// before writing to owners. vi.mock('../../media/mediasource-setup', () => ({ createMediaSource: vi.fn(() => ({ addEventListener: vi.fn(), removeEventListener: vi.fn(), - readyState: 'closed', + readyState: 'open', })), attachMediaSource: vi.fn(() => ({ detach: vi.fn(), })), - waitForSourceOpen: vi.fn(() => Promise.resolve()), + observeMediaSourceReadyState: vi.fn((_ms: MediaSource, _signal: AbortSignal) => ({ + get: () => 'open' as MediaSource['readyState'], + })), })); -describe('canSetup', () => { - it('returns true when mediaElement and presentation.url exist', () => { - const state: MediaSourceState = { - presentation: { url: 'https://example.com/video.m3u8' } as Presentation, - }; - const owners: MediaSourceOwners = { - mediaElement: {} as HTMLMediaElement, - }; - - expect(canSetup(state, owners)).toBe(true); - }); - - it('returns false when mediaElement is missing', () => { - const state: MediaSourceState = { - presentation: { url: 'https://example.com/video.m3u8' } as Presentation, - }; - const owners: MediaSourceOwners = {}; - - expect(canSetup(state, owners)).toBe(false); - }); - - it('returns false when presentation is missing', () => { - const state: MediaSourceState = {}; - const owners: MediaSourceOwners = { - mediaElement: {} as HTMLMediaElement, - }; - - expect(canSetup(state, owners)).toBe(false); - }); - - it('returns false when presentation.url is missing', () => { - const state: MediaSourceState = { - presentation: {} as Presentation, - }; - const owners: MediaSourceOwners = { - mediaElement: {} as HTMLMediaElement, - }; - - expect(canSetup(state, owners)).toBe(false); - }); -}); - -describe('shouldSetup', () => { - it('returns true when mediaSource does not exist', () => { - const state: MediaSourceState = {}; - const owners: MediaSourceOwners = { - mediaElement: {} as HTMLMediaElement, - }; - - expect(shouldSetup(state, owners)).toBe(true); - }); - - it('returns false when mediaSource already exists', () => { - const state: MediaSourceState = {}; - const owners: MediaSourceOwners = { - mediaElement: {} as HTMLMediaElement, - mediaSource: {} as MediaSource, - }; - - expect(shouldSetup(state, owners)).toBe(false); - }); -}); +function setupSetupMediaSource(initialState: MediaSourceState, initialOwners: MediaSourceOwners) { + const state = signal(initialState); + const owners = signal(initialOwners); + const cleanup = setupMediaSource({ state, owners }); + return { state, owners, cleanup }; +} describe('setupMediaSource', () => { beforeEach(() => { @@ -93,18 +36,14 @@ describe('setupMediaSource', () => { it('creates MediaSource when mediaElement and presentation.url exist', async () => { const { createMediaSource } = await import('../../media/mediasource-setup'); - const state = createState({}); - const owners = createState({}); + const { state, owners, cleanup } = setupSetupMediaSource({}, {}); - const cleanup = setupMediaSource({ state, owners }); - - // Set up conditions - owners.patch({ mediaElement: {} as HTMLMediaElement }); - state.patch({ + owners.set({ ...owners.get(), mediaElement: {} as HTMLMediaElement }); + state.set({ + ...state.get(), presentation: { url: 'https://example.com/video.m3u8' } as Presentation, }); - // Wait for async operation await vi.waitFor(() => { expect(createMediaSource).toHaveBeenCalledWith({ preferManaged: true }); }); @@ -118,18 +57,16 @@ describe('setupMediaSource', () => { const mockMediaSource = { addEventListener: vi.fn(), removeEventListener: vi.fn(), - readyState: 'closed', + readyState: 'open', }; vi.mocked(createMediaSource).mockReturnValue(mockMediaSource as unknown as MediaSource); - const state = createState({}); - const owners = createState({}); + const { state, owners, cleanup } = setupSetupMediaSource({}, {}); const mediaElement = {} as HTMLMediaElement; - const cleanup = setupMediaSource({ state, owners }); - - owners.patch({ mediaElement }); - state.patch({ + owners.set({ ...owners.get(), mediaElement }); + state.set({ + ...state.get(), presentation: { url: 'https://example.com/video.m3u8' } as Presentation, }); @@ -140,49 +77,27 @@ describe('setupMediaSource', () => { cleanup(); }); - it('waits for sourceopen event', async () => { - const { waitForSourceOpen } = await import('../../media/mediasource-setup'); - - const state = createState({}); - const owners = createState({}); - - const cleanup = setupMediaSource({ state, owners }); - - owners.patch({ mediaElement: {} as HTMLMediaElement }); - state.patch({ - presentation: { url: 'https://example.com/video.m3u8' } as Presentation, - }); - - await vi.waitFor(() => { - expect(waitForSourceOpen).toHaveBeenCalled(); - }); - - cleanup(); - }); - - it('updates owners with mediaSource reference', async () => { + it('updates owners with mediaSource and mediaSourceReadyState', async () => { const { createMediaSource } = await import('../../media/mediasource-setup'); const mockMediaSource = { addEventListener: vi.fn(), removeEventListener: vi.fn(), - readyState: 'closed', + readyState: 'open', }; vi.mocked(createMediaSource).mockReturnValue(mockMediaSource as unknown as MediaSource); - const state = createState({}); - const owners = createState({}); + const { state, owners, cleanup } = setupSetupMediaSource({}, {}); - const cleanup = setupMediaSource({ state, owners }); - - owners.patch({ mediaElement: {} as HTMLMediaElement }); - state.patch({ + owners.set({ ...owners.get(), mediaElement: {} as HTMLMediaElement }); + state.set({ + ...state.get(), presentation: { url: 'https://example.com/video.m3u8' } as Presentation, }); await vi.waitFor(() => { - const currentOwners = owners.current; - expect(currentOwners.mediaSource).toBe(mockMediaSource); + expect(owners.get().mediaSource).toBe(mockMediaSource); + expect(owners.get().mediaSourceReadyState).toBeDefined(); }); cleanup(); @@ -191,16 +106,13 @@ describe('setupMediaSource', () => { it('does not create MediaSource if mediaElement is missing', async () => { const { createMediaSource } = await import('../../media/mediasource-setup'); - const state = createState({}); - const owners = createState({}); + const { state, cleanup } = setupSetupMediaSource({}, {}); - const cleanup = setupMediaSource({ state, owners }); - - state.patch({ + state.set({ + ...state.get(), presentation: { url: 'https://example.com/video.m3u8' } as Presentation, }); - // Wait a bit to ensure no async operations occur await new Promise((resolve) => setTimeout(resolve, 50)); expect(createMediaSource).not.toHaveBeenCalled(); @@ -211,14 +123,10 @@ describe('setupMediaSource', () => { it('does not create MediaSource if presentation.url is missing', async () => { const { createMediaSource } = await import('../../media/mediasource-setup'); - const state = createState({}); - const owners = createState({}); + const { owners, cleanup } = setupSetupMediaSource({}, {}); - const cleanup = setupMediaSource({ state, owners }); + owners.set({ ...owners.get(), mediaElement: {} as HTMLMediaElement }); - owners.patch({ mediaElement: {} as HTMLMediaElement }); - - // Wait a bit to ensure no async operations occur await new Promise((resolve) => setTimeout(resolve, 50)); expect(createMediaSource).not.toHaveBeenCalled(); @@ -229,27 +137,23 @@ describe('setupMediaSource', () => { it('does not create multiple MediaSources (deduplication)', async () => { const { createMediaSource } = await import('../../media/mediasource-setup'); - const state = createState({}); - const owners = createState({}); + const { state, owners, cleanup } = setupSetupMediaSource({}, {}); - const cleanup = setupMediaSource({ state, owners }); - - owners.patch({ mediaElement: {} as HTMLMediaElement }); - state.patch({ + owners.set({ ...owners.get(), mediaElement: {} as HTMLMediaElement }); + state.set({ + ...state.get(), presentation: { url: 'https://example.com/video.m3u8' } as Presentation, }); - // Wait for first creation await vi.waitFor(() => { expect(createMediaSource).toHaveBeenCalledTimes(1); }); - // Trigger another update (same data) - state.patch({ + state.set({ + ...state.get(), presentation: { url: 'https://example.com/video.m3u8' } as Presentation, }); - // Wait a bit to ensure no additional calls await new Promise((resolve) => setTimeout(resolve, 50)); expect(createMediaSource).toHaveBeenCalledTimes(1); diff --git a/packages/spf/src/dom/features/tests/setup-sourcebuffer.test.ts b/packages/spf/src/dom/features/tests/setup-sourcebuffer.test.ts index aa0d661a..3b734952 100644 --- a/packages/spf/src/dom/features/tests/setup-sourcebuffer.test.ts +++ b/packages/spf/src/dom/features/tests/setup-sourcebuffer.test.ts @@ -1,13 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createState } from '../../../core/state/create-state'; +import { signal } from '../../../core/signals/primitives'; import type { AudioTrack, Presentation, VideoTrack } from '../../../core/types'; import { buildMimeCodec, - canSetupBuffer, type SourceBufferOwners, type SourceBufferState, setupSourceBuffers, - shouldSetupBuffer, } from '../setup-sourcebuffer'; // Mock the DOM utilities @@ -137,106 +135,12 @@ describe('buildMimeCodec', () => { }); }); -describe('canSetupBuffer', () => { - it('returns true when MediaSource exists and video track selected', () => { - const videoTrack = createResolvedVideoTrack(); - const state: SourceBufferState = { - presentation: createPresentationWithTracks({ video: videoTrack }), - selectedVideoTrackId: 'video-1', - }; - const owners: SourceBufferOwners = { - mediaSource: {} as MediaSource, - }; - - expect(canSetupBuffer(state, owners, 'video')).toBe(true); - }); - - it('returns false when MediaSource is missing', () => { - const videoTrack = createResolvedVideoTrack(); - const state: SourceBufferState = { - presentation: createPresentationWithTracks({ video: videoTrack }), - selectedVideoTrackId: 'video-1', - }; - const owners: SourceBufferOwners = {}; - - expect(canSetupBuffer(state, owners, 'video')).toBe(false); - }); - - it('returns false when track not selected', () => { - const state: SourceBufferState = { - presentation: createPresentationWithTracks({}), - }; - const owners: SourceBufferOwners = { - mediaSource: {} as MediaSource, - }; - - expect(canSetupBuffer(state, owners, 'video')).toBe(false); - }); - - it('returns true when track selected but not yet resolved', () => { - const unresolvedTrack = { - type: 'video' as const, - id: 'video-1', - url: 'http://example.com/video.m3u8', - bandwidth: 1000000, - mimeType: 'video/mp4', - codecs: ['avc1.42E01E'], - }; - const presentation: Presentation = { - id: 'pres-1', - url: 'http://example.com/playlist.m3u8', - selectionSets: [ - { - id: 'video-set', - type: 'video', - switchingSets: [ - { - id: 'video-switching', - type: 'video', - tracks: [unresolvedTrack], - }, - ], - }, - ], - startTime: 0, - }; - const state: SourceBufferState = { - presentation, - selectedVideoTrackId: 'video-1', - }; - const owners: SourceBufferOwners = { - mediaSource: {} as MediaSource, - }; - - expect(canSetupBuffer(state, owners, 'video')).toBe(true); - }); -}); - -describe('shouldSetupBuffer', () => { - it('returns true when video buffer does not exist', () => { - const owners: SourceBufferOwners = {}; - expect(shouldSetupBuffer(owners, 'video')).toBe(true); - }); - - it('returns false when video buffer already exists', () => { - const owners: SourceBufferOwners = { - videoBuffer: {} as SourceBuffer, - }; - expect(shouldSetupBuffer(owners, 'video')).toBe(false); - }); - - it('returns true when audio buffer does not exist', () => { - const owners: SourceBufferOwners = {}; - expect(shouldSetupBuffer(owners, 'audio')).toBe(true); - }); - - it('returns false when audio buffer already exists', () => { - const owners: SourceBufferOwners = { - audioBuffer: {} as SourceBuffer, - }; - expect(shouldSetupBuffer(owners, 'audio')).toBe(false); - }); -}); +function setupSetupSourceBuffers(initialState: SourceBufferState = {}, initialOwners: SourceBufferOwners = {}) { + const state = signal(initialState); + const owners = signal(initialOwners); + const cleanup = setupSourceBuffers({ state, owners }); + return { state, owners, cleanup }; +} describe('setupSourceBuffers', () => { beforeEach(() => { @@ -247,21 +151,20 @@ describe('setupSourceBuffers', () => { const { createSourceBuffer } = await import('../../media/mediasource-setup'); const videoTrack = createResolvedVideoTrack(); - const state = createState({}); - const owners = createState({}); - const cleanup = setupSourceBuffers({ state, owners }); + const { state, owners, cleanup } = setupSetupSourceBuffers(); const mediaSource = {} as MediaSource; - owners.patch({ mediaSource }); - state.patch({ + owners.set({ ...owners.get(), mediaSource }); + state.set({ + ...state.get(), presentation: createPresentationWithTracks({ video: videoTrack }), selectedVideoTrackId: 'video-1', }); await vi.waitFor(() => { expect(createSourceBuffer).toHaveBeenCalledWith(mediaSource, 'video/mp4; codecs="avc1.42E01E"'); - expect(owners.current.videoBuffer).toBeDefined(); - expect(owners.current.audioBuffer).toBeUndefined(); + expect(owners.get().videoBuffer).toBeDefined(); + expect(owners.get().audioBuffer).toBeUndefined(); }); cleanup(); @@ -271,21 +174,20 @@ describe('setupSourceBuffers', () => { const { createSourceBuffer } = await import('../../media/mediasource-setup'); const audioTrack = createResolvedAudioTrack(); - const state = createState({}); - const owners = createState({}); - const cleanup = setupSourceBuffers({ state, owners }); + const { state, owners, cleanup } = setupSetupSourceBuffers(); const mediaSource = {} as MediaSource; - owners.patch({ mediaSource }); - state.patch({ + owners.set({ ...owners.get(), mediaSource }); + state.set({ + ...state.get(), presentation: createPresentationWithTracks({ audio: audioTrack }), selectedAudioTrackId: 'audio-1', }); await vi.waitFor(() => { expect(createSourceBuffer).toHaveBeenCalledWith(mediaSource, 'audio/mp4; codecs="mp4a.40.2"'); - expect(owners.current.audioBuffer).toBeDefined(); - expect(owners.current.videoBuffer).toBeUndefined(); + expect(owners.get().audioBuffer).toBeDefined(); + expect(owners.get().videoBuffer).toBeUndefined(); }); cleanup(); @@ -296,13 +198,12 @@ describe('setupSourceBuffers', () => { const videoTrack = createResolvedVideoTrack(); const audioTrack = createResolvedAudioTrack(); - const state = createState({}); - const owners = createState({}); - const cleanup = setupSourceBuffers({ state, owners }); + const { state, owners, cleanup } = setupSetupSourceBuffers(); const mediaSource = {} as MediaSource; - owners.patch({ mediaSource }); - state.patch({ + owners.set({ ...owners.get(), mediaSource }); + state.set({ + ...state.get(), presentation: createPresentationWithTracks({ video: videoTrack, audio: audioTrack }), selectedVideoTrackId: 'video-1', selectedAudioTrackId: 'audio-1', @@ -312,8 +213,8 @@ describe('setupSourceBuffers', () => { expect(createSourceBuffer).toHaveBeenCalledTimes(2); expect(createSourceBuffer).toHaveBeenCalledWith(mediaSource, 'video/mp4; codecs="avc1.42E01E"'); expect(createSourceBuffer).toHaveBeenCalledWith(mediaSource, 'audio/mp4; codecs="mp4a.40.2"'); - expect(owners.current.videoBuffer).toBeDefined(); - expect(owners.current.audioBuffer).toBeDefined(); + expect(owners.get().videoBuffer).toBeDefined(); + expect(owners.get().audioBuffer).toBeDefined(); }); cleanup(); @@ -333,13 +234,12 @@ describe('setupSourceBuffers', () => { ...unresolvedAudioPartial } = unresolvedAudio; - const state = createState({}); - const owners = createState({}); - const cleanup = setupSourceBuffers({ state, owners }); + const { state, owners, cleanup } = setupSetupSourceBuffers(); - owners.patch({ mediaSource: {} as MediaSource }); + owners.set({ ...owners.get(), mediaSource: {} as MediaSource }); // Both track IDs selected, but audio track is not yet resolved - state.patch({ + state.set({ + ...state.get(), presentation: createPresentationWithTracks({ video: videoTrack, audio: unresolvedAudioPartial as AudioTrack, @@ -353,14 +253,15 @@ describe('setupSourceBuffers', () => { expect(createSourceBuffer).not.toHaveBeenCalled(); // Now audio resolves - state.patch({ + state.set({ + ...state.get(), presentation: createPresentationWithTracks({ video: videoTrack, audio: unresolvedAudio }), }); await vi.waitFor(() => { expect(createSourceBuffer).toHaveBeenCalledTimes(2); - expect(owners.current.videoBuffer).toBeDefined(); - expect(owners.current.audioBuffer).toBeDefined(); + expect(owners.get().videoBuffer).toBeDefined(); + expect(owners.get().audioBuffer).toBeDefined(); }); cleanup(); @@ -370,12 +271,11 @@ describe('setupSourceBuffers', () => { const { createSourceBuffer } = await import('../../media/mediasource-setup'); const videoTrack: VideoTrack = { ...createResolvedVideoTrack(), codecs: [] }; - const state = createState({}); - const owners = createState({}); - const cleanup = setupSourceBuffers({ state, owners }); + const { state, owners, cleanup } = setupSetupSourceBuffers(); - owners.patch({ mediaSource: {} as MediaSource }); - state.patch({ + owners.set({ ...owners.get(), mediaSource: {} as MediaSource }); + state.set({ + ...state.get(), presentation: createPresentationWithTracks({ video: videoTrack }), selectedVideoTrackId: 'video-1', }); @@ -390,19 +290,18 @@ describe('setupSourceBuffers', () => { const { createSourceBuffer } = await import('../../media/mediasource-setup'); const videoTrack = createResolvedVideoTrack(); - const state = createState({}); - const owners = createState({}); - const cleanup = setupSourceBuffers({ state, owners }); + const { state, owners, cleanup } = setupSetupSourceBuffers(); - owners.patch({ mediaSource: {} as MediaSource }); - state.patch({ + owners.set({ ...owners.get(), mediaSource: {} as MediaSource }); + state.set({ + ...state.get(), presentation: createPresentationWithTracks({ video: videoTrack }), selectedVideoTrackId: 'video-1', }); await vi.waitFor(() => expect(createSourceBuffer).toHaveBeenCalledTimes(1)); - state.patch({ presentation: createPresentationWithTracks({ video: videoTrack }) }); + state.set({ ...state.get(), presentation: createPresentationWithTracks({ video: videoTrack }) }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(createSourceBuffer).toHaveBeenCalledTimes(1); @@ -413,11 +312,10 @@ describe('setupSourceBuffers', () => { const { createSourceBuffer } = await import('../../media/mediasource-setup'); const videoTrack = createResolvedVideoTrack(); - const state = createState({}); - const owners = createState({}); - const cleanup = setupSourceBuffers({ state, owners }); + const { state, cleanup } = setupSetupSourceBuffers(); - state.patch({ + state.set({ + ...state.get(), presentation: createPresentationWithTracks({ video: videoTrack }), selectedVideoTrackId: 'video-1', }); diff --git a/packages/spf/src/dom/features/tests/setup-text-tracks.test.ts b/packages/spf/src/dom/features/tests/setup-text-tracks.test.ts index f1918e7d..85b03dbb 100644 --- a/packages/spf/src/dom/features/tests/setup-text-tracks.test.ts +++ b/packages/spf/src/dom/features/tests/setup-text-tracks.test.ts @@ -1,286 +1,169 @@ import { describe, expect, it } from 'vitest'; -import { createState } from '../../../core/state/create-state'; +import { signal } from '../../../core/signals/primitives'; import type { Presentation, TextSelectionSet } from '../../../core/types'; -import { - canSetupTextTracks, - setupTextTracks, - shouldSetupTextTracks, - type TextTrackOwners, - type TextTrackState, -} from '../setup-text-tracks'; +import { setupTextTracks, type TextTrackOwners, type TextTrackState } from '../setup-text-tracks'; -describe('setupTextTracks', () => { - describe('canSetupTextTracks', () => { - it('returns false when no mediaElement', () => { - const state: TextTrackState = { - presentation: { - id: 'pres-1', - url: 'http://example.com/playlist.m3u8', - selectionSets: [], - }, - }; - const owners: TextTrackOwners = {}; +function setupSetupTextTracks(initialState: TextTrackState = {}, initialOwners: TextTrackOwners = {}) { + const state = signal(initialState); + const owners = signal(initialOwners); + const cleanup = setupTextTracks({ state, owners }); + return { state, owners, cleanup }; +} - expect(canSetupTextTracks(state, owners)).toBe(false); - }); - - it('returns false when no presentation', () => { - const state: TextTrackState = {}; - const owners: TextTrackOwners = { - mediaElement: document.createElement('video'), - }; - - expect(canSetupTextTracks(state, owners)).toBe(false); - }); - - it('returns false when presentation not resolved (no selectionSets)', () => { - const state: TextTrackState = { - presentation: { - id: 'pres-1', - url: 'http://example.com/playlist.m3u8', - selectionSets: [], - }, - }; - const owners: TextTrackOwners = { - mediaElement: document.createElement('video'), - }; - - expect(canSetupTextTracks(state, owners)).toBe(false); - }); - - it('returns true when mediaElement and presentation resolved with text tracks', () => { - const state: TextTrackState = { - presentation: { - id: 'pres-1', - url: 'http://example.com/playlist.m3u8', - selectionSets: [ +const textPresentation: Presentation = { + id: 'pres-1', + url: 'http://example.com/playlist.m3u8', + selectionSets: [ + { + id: 'text-set', + type: 'text', + switchingSets: [ + { + id: 'text-switching', + type: 'text', + tracks: [ { - id: 'text-set', type: 'text', - switchingSets: [ - { - id: 'text-switching', - type: 'text', - tracks: [ - { - type: 'text', - id: 'text-en', - url: 'http://example.com/text-en.m3u8', - bandwidth: 256, - mimeType: 'text/vtt', - codecs: [], - groupId: 'subs', - label: 'English', - kind: 'subtitles', - language: 'en', - }, - ], - }, - ], + id: 'text-en', + url: 'http://example.com/text-en.m3u8', + bandwidth: 256, + mimeType: 'text/vtt', + codecs: [], + groupId: 'subs', + label: 'English', + kind: 'subtitles', + language: 'en', + }, + { + type: 'text', + id: 'text-es', + url: 'http://example.com/text-es.m3u8', + bandwidth: 256, + mimeType: 'text/vtt', + codecs: [], + groupId: 'subs', + label: 'Spanish', + kind: 'subtitles', + language: 'es', + default: true, }, ], }, - }; - const owners: TextTrackOwners = { - mediaElement: document.createElement('video'), - }; + ], + } as TextSelectionSet, + ], +}; - expect(canSetupTextTracks(state, owners)).toBe(true); - }); +describe('setupTextTracks', () => { + it('creates track elements when mediaElement and presentation ready', async () => { + const mediaElement = document.createElement('video'); + + const { owners, cleanup } = setupSetupTextTracks({ presentation: textPresentation }, { mediaElement }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(owners.get().textTracks?.size).toBe(2); + expect(mediaElement.children.length).toBe(2); + + const track1 = mediaElement.children[0] as HTMLTrackElement; + expect(track1.tagName).toBe('TRACK'); + expect(track1.id).toBe('text-en'); + expect(track1.kind).toBe('subtitles'); + expect(track1.label).toBe('English'); + expect(track1.srclang).toBe('en'); + expect(track1.src).toBe('http://example.com/text-en.m3u8'); + expect(track1.default).toBe(false); + + const track2 = mediaElement.children[1] as HTMLTrackElement; + expect(track2.tagName).toBe('TRACK'); + expect(track2.id).toBe('text-es'); + expect(track2.kind).toBe('subtitles'); + expect(track2.label).toBe('Spanish'); + expect(track2.srclang).toBe('es'); + expect(track2.src).toBe('http://example.com/text-es.m3u8'); + expect(track2.default).toBe(true); + + cleanup(); }); - describe('shouldSetupTextTracks', () => { - it('returns true when textTracks not set', () => { - const owners: TextTrackOwners = { - mediaElement: document.createElement('video'), - }; + it('waits for mediaElement before creating tracks', async () => { + const { owners, cleanup } = setupSetupTextTracks({ presentation: textPresentation }); - expect(shouldSetupTextTracks(owners)).toBe(true); - }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(owners.get().textTracks).toBeUndefined(); - it('returns false when textTracks already set', () => { - const owners: TextTrackOwners = { - mediaElement: document.createElement('video'), - textTracks: new Map(), - }; + const mediaElement = document.createElement('video'); + owners.set({ ...owners.get(), mediaElement }); - expect(shouldSetupTextTracks(owners)).toBe(false); - }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(owners.get().textTracks?.size).toBe(2); + + cleanup(); }); - describe('setupTextTracks orchestration', () => { - it('creates track elements when mediaElement and presentation ready', async () => { - const presentation: Presentation = { - id: 'pres-1', - url: 'http://example.com/playlist.m3u8', - selectionSets: [ - { - id: 'text-set', - type: 'text', - switchingSets: [ - { - id: 'text-switching', - type: 'text', - tracks: [ - { - type: 'text', - id: 'text-en', - url: 'http://example.com/text-en.m3u8', - bandwidth: 256, - mimeType: 'text/vtt', - codecs: [], - groupId: 'subs', - label: 'English', - kind: 'subtitles', - language: 'en', - }, - { - type: 'text', - id: 'text-es', - url: 'http://example.com/text-es.m3u8', - bandwidth: 256, - mimeType: 'text/vtt', - codecs: [], - groupId: 'subs', - label: 'Spanish', - kind: 'subtitles', - language: 'es', - default: true, - }, - ], - }, - ], - } as TextSelectionSet, - ], - }; + it('waits for presentation before creating tracks', async () => { + const mediaElement = document.createElement('video'); + const { state, owners, cleanup } = setupSetupTextTracks({}, { mediaElement }); - const state = createState({}); - const owners = createState({}); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(owners.get().textTracks).toBeUndefined(); - const cleanup = setupTextTracks({ state, owners }); + state.set({ ...state.get(), presentation: textPresentation }); - // Patch owners first (mediaElement) - const mediaElement = document.createElement('video'); - owners.patch({ mediaElement }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(owners.get().textTracks?.size).toBe(2); - // Then patch state (presentation) - state.patch({ presentation }); + cleanup(); + }); - // Wait for orchestration to run - await new Promise((resolve) => setTimeout(resolve, 50)); + it('does not create track elements when no text tracks in presentation', async () => { + const presentation: Presentation = { + id: 'pres-1', + url: 'http://example.com/playlist.m3u8', + selectionSets: [], + }; - // Verify track elements created - expect(owners.current.textTracks).toBeDefined(); - expect(owners.current.textTracks?.size).toBe(2); + const mediaElement = document.createElement('video'); + const { owners, cleanup } = setupSetupTextTracks({ presentation }, { mediaElement }); - // Verify track elements in DOM - expect(mediaElement.children.length).toBe(2); + await new Promise((resolve) => setTimeout(resolve, 50)); - // Verify first track element - const track1 = mediaElement.children[0] as HTMLTrackElement; - expect(track1.tagName).toBe('TRACK'); - expect(track1.id).toBe('text-en'); - expect(track1.kind).toBe('subtitles'); - expect(track1.label).toBe('English'); - expect(track1.srclang).toBe('en'); - expect(track1.src).toBe('http://example.com/text-en.m3u8'); - expect(track1.default).toBe(false); + expect(owners.get().textTracks).toBeUndefined(); + expect(mediaElement.children.length).toBe(0); - // Verify second track element - const track2 = mediaElement.children[1] as HTMLTrackElement; - expect(track2.tagName).toBe('TRACK'); - expect(track2.id).toBe('text-es'); - expect(track2.kind).toBe('subtitles'); - expect(track2.label).toBe('Spanish'); - expect(track2.srclang).toBe('es'); - expect(track2.src).toBe('http://example.com/text-es.m3u8'); - expect(track2.default).toBe(true); + cleanup(); + }); - cleanup(); - }); + it('only runs once (idempotent)', async () => { + const mediaElement = document.createElement('video'); - it('does not create track elements when no text tracks', async () => { - const presentation: Presentation = { - id: 'pres-1', - url: 'http://example.com/playlist.m3u8', - selectionSets: [], - }; + const { state, owners, cleanup } = setupSetupTextTracks({ presentation: textPresentation }, { mediaElement }); - const state = createState({}); - const owners = createState({}); + await new Promise((resolve) => setTimeout(resolve, 50)); - const cleanup = setupTextTracks({ state, owners }); + const firstTrackMap = owners.get().textTracks; + expect(firstTrackMap?.size).toBe(2); + expect(mediaElement.children.length).toBe(2); - const mediaElement = document.createElement('video'); - owners.patch({ mediaElement }); - state.patch({ presentation }); + state.set({ ...state.get(), selectedTextTrackId: 'text-en' }); - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 50)); - // Should not create textTracks map when no tracks - expect(owners.current.textTracks).toBeUndefined(); - expect(mediaElement.children.length).toBe(0); + expect(owners.get().textTracks).toBe(firstTrackMap); + expect(mediaElement.children.length).toBe(2); - cleanup(); - }); + cleanup(); + }); - it('only runs once (idempotent)', async () => { - const presentation: Presentation = { - id: 'pres-1', - url: 'http://example.com/playlist.m3u8', - selectionSets: [ - { - id: 'text-set', - type: 'text', - switchingSets: [ - { - id: 'text-switching', - type: 'text', - tracks: [ - { - type: 'text', - id: 'text-en', - url: 'http://example.com/text-en.m3u8', - bandwidth: 256, - mimeType: 'text/vtt', - codecs: [], - groupId: 'subs', - label: 'English', - kind: 'subtitles', - language: 'en', - }, - ], - }, - ], - } as TextSelectionSet, - ], - }; + it('removes track elements on cleanup', async () => { + const mediaElement = document.createElement('video'); - const state = createState({}); - const owners = createState({}); + const { cleanup } = setupSetupTextTracks({ presentation: textPresentation }, { mediaElement }); - const cleanup = setupTextTracks({ state, owners }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(mediaElement.children.length).toBe(2); - const mediaElement = document.createElement('video'); - owners.patch({ mediaElement }); - state.patch({ presentation }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - const firstTrackMap = owners.current.textTracks; - expect(firstTrackMap?.size).toBe(1); - expect(mediaElement.children.length).toBe(1); - - // Trigger another state update - state.patch({ selectedTextTrackId: 'text-en' }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - // Should not create duplicate track elements - expect(owners.current.textTracks).toBe(firstTrackMap); - expect(mediaElement.children.length).toBe(1); - - cleanup(); - }); + cleanup(); + expect(mediaElement.children.length).toBe(0); }); }); diff --git a/packages/spf/src/dom/features/tests/sync-selected-text-track-from-dom.test.ts b/packages/spf/src/dom/features/tests/sync-selected-text-track-from-dom.test.ts index 3ac255ac..b32c45d8 100644 --- a/packages/spf/src/dom/features/tests/sync-selected-text-track-from-dom.test.ts +++ b/packages/spf/src/dom/features/tests/sync-selected-text-track-from-dom.test.ts @@ -1,11 +1,18 @@ import { describe, expect, it, vi } from 'vitest'; -import { createState } from '../../../core/state/create-state'; +import { signal } from '../../../core/signals/primitives'; import { type SelectedTextTrackFromDomOwners, type SelectedTextTrackFromDomState, syncSelectedTextTrackFromDom, } from '../sync-selected-text-track-from-dom'; +function setup(initialState: SelectedTextTrackFromDomState = {}, initialOwners: SelectedTextTrackFromDomOwners = {}) { + const state = signal(initialState); + const owners = signal(initialOwners); + const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + return { state, owners, cleanup }; +} + function createSubtitleTrack(mediaElement: HTMLMediaElement, id: string): HTMLTrackElement { const trackEl = document.createElement('track'); trackEl.kind = 'subtitles'; @@ -18,14 +25,11 @@ function createSubtitleTrack(mediaElement: HTMLMediaElement, id: string): HTMLTr describe('syncSelectedTextTrackFromDom', () => { it('does nothing when no mediaElement', async () => { - const state = createState({}); - const owners = createState({}); - - const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + const { state, cleanup } = setup(); await new Promise((resolve) => setTimeout(resolve, 30)); - expect(state.current.selectedTextTrackId).toBeUndefined(); + expect(state.get().selectedTextTrackId).toBeUndefined(); cleanup(); }); @@ -34,20 +38,15 @@ describe('syncSelectedTextTrackFromDom', () => { const mediaElement = document.createElement('video'); const trackEl = createSubtitleTrack(mediaElement, 'track-en'); - // Wait for track to initialize await new Promise((resolve) => setTimeout(resolve, 50)); - const state = createState({}); - const owners = createState({ mediaElement }); + const { state, cleanup } = setup({}, { mediaElement }); - const cleanup = syncSelectedTextTrackFromDom({ state, owners }); - - // Simulate toggleSubtitles() setting the track to 'showing' trackEl.track.mode = 'showing'; mediaElement.textTracks.dispatchEvent(new Event('change')); await vi.waitFor(() => { - expect(state.current.selectedTextTrackId).toBe('track-en'); + expect(state.get().selectedTextTrackId).toBe('track-en'); }); cleanup(); @@ -63,16 +62,13 @@ describe('syncSelectedTextTrackFromDom', () => { await new Promise((resolve) => setTimeout(resolve, 50)); - const state = createState({}); - const owners = createState({ mediaElement }); - - const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + const { state, cleanup } = setup({}, { mediaElement }); trackEl.track.mode = 'showing'; mediaElement.textTracks.dispatchEvent(new Event('change')); await vi.waitFor(() => { - expect(state.current.selectedTextTrackId).toBe('track-cc'); + expect(state.get().selectedTextTrackId).toBe('track-cc'); }); cleanup(); @@ -84,46 +80,40 @@ describe('syncSelectedTextTrackFromDom', () => { await new Promise((resolve) => setTimeout(resolve, 50)); - const state = createState({ selectedTextTrackId: 'track-en' }); - const owners = createState({ mediaElement }); + const { state, cleanup } = setup({ selectedTextTrackId: 'track-en' }, { mediaElement }); - const cleanup = syncSelectedTextTrackFromDom({ state, owners }); - - // Simulate toggleSubtitles() disabling captions trackEl.track.mode = 'disabled'; mediaElement.textTracks.dispatchEvent(new Event('change')); await vi.waitFor(() => { - expect(state.current.selectedTextTrackId).toBeUndefined(); + expect(state.get().selectedTextTrackId).toBeUndefined(); }); cleanup(); }); it('clears textBufferState for the deselected track when disabling', async () => { - // When mode='disabled' clears native cues, textBufferState must be reset so - // segments are re-fetched if the user re-enables captions. const mediaElement = document.createElement('video'); const trackEl = createSubtitleTrack(mediaElement, 'track-en'); await new Promise((resolve) => setTimeout(resolve, 50)); - const state = createState({ - selectedTextTrackId: 'track-en', - textBufferState: { - 'track-en': { segments: [{ id: 'seg-0' }, { id: 'seg-1' }] }, + const { state, cleanup } = setup( + { + selectedTextTrackId: 'track-en', + textBufferState: { + 'track-en': { segments: [{ id: 'seg-0' }, { id: 'seg-1' }] }, + }, }, - }); - const owners = createState({ mediaElement }); - - const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + { mediaElement } + ); trackEl.track.mode = 'disabled'; mediaElement.textTracks.dispatchEvent(new Event('change')); await vi.waitFor(() => { - expect(state.current.selectedTextTrackId).toBeUndefined(); - expect(state.current.textBufferState?.['track-en']).toBeUndefined(); + expect(state.get().selectedTextTrackId).toBeUndefined(); + expect(state.get().textBufferState?.['track-en']).toBeUndefined(); }); cleanup(); @@ -135,25 +125,25 @@ describe('syncSelectedTextTrackFromDom', () => { await new Promise((resolve) => setTimeout(resolve, 50)); - const state = createState({ - selectedTextTrackId: 'track-en', - textBufferState: { - 'track-en': { segments: [{ id: 'seg-0' }] }, - 'track-fr': { segments: [{ id: 'seg-0' }] }, + const { state, cleanup } = setup( + { + selectedTextTrackId: 'track-en', + textBufferState: { + 'track-en': { segments: [{ id: 'seg-0' }] }, + 'track-fr': { segments: [{ id: 'seg-0' }] }, + }, }, - }); - const owners = createState({ mediaElement }); - - const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + { mediaElement } + ); trackEl.track.mode = 'disabled'; mediaElement.textTracks.dispatchEvent(new Event('change')); await vi.waitFor(() => { - expect(state.current.textBufferState?.['track-en']).toBeUndefined(); + expect(state.get().textBufferState?.['track-en']).toBeUndefined(); }); - expect(state.current.textBufferState?.['track-fr']).toEqual({ segments: [{ id: 'seg-0' }] }); + expect(state.get().textBufferState?.['track-fr']).toEqual({ segments: [{ id: 'seg-0' }] }); cleanup(); }); @@ -168,18 +158,14 @@ describe('syncSelectedTextTrackFromDom', () => { await new Promise((resolve) => setTimeout(resolve, 50)); - const state = createState({}); - const owners = createState({ mediaElement }); + const { state, cleanup } = setup({}, { mediaElement }); - const cleanup = syncSelectedTextTrackFromDom({ state, owners }); - - // Chapters track going 'showing' should not affect selectedTextTrackId chapterEl.track.mode = 'showing'; mediaElement.textTracks.dispatchEvent(new Event('change')); await new Promise((resolve) => setTimeout(resolve, 30)); - expect(state.current.selectedTextTrackId).toBeUndefined(); + expect(state.get().selectedTextTrackId).toBeUndefined(); cleanup(); }); @@ -190,66 +176,61 @@ describe('syncSelectedTextTrackFromDom', () => { await new Promise((resolve) => setTimeout(resolve, 50)); - const state = createState({ selectedTextTrackId: 'track-en' }); - const owners = createState({ mediaElement }); + const stateSignal = signal({ selectedTextTrackId: 'track-en' }); + const ownersSignal = signal({ mediaElement }); - const patchSpy = vi.spyOn(state, 'patch'); + const setSpy = vi.spyOn(stateSignal, 'set'); - const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + const cleanupEffect = syncSelectedTextTrackFromDom({ state: stateSignal, owners: ownersSignal }); - // Simulate syncTextTrackModes confirming the track as 'showing' trackEl.track.mode = 'showing'; mediaElement.textTracks.dispatchEvent(new Event('change')); await new Promise((resolve) => setTimeout(resolve, 30)); - expect(patchSpy).not.toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); - cleanup(); + cleanupEffect(); }); it('does not re-register listener when owners updates but mediaElement is unchanged', async () => { const mediaElement = document.createElement('video'); - const state = createState({}); - const owners = createState({ mediaElement }); + const stateSignal = signal({}); + const ownersSignal = signal({ mediaElement }); const addEventListenerSpy = vi.spyOn(mediaElement.textTracks, 'addEventListener'); - const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + const cleanupEffect = syncSelectedTextTrackFromDom({ state: stateSignal, owners: ownersSignal }); await new Promise((resolve) => setTimeout(resolve, 20)); const callsBefore = addEventListenerSpy.mock.calls.length; - // Patch an unrelated owner - owners.patch({ videoBuffer: {} }); + ownersSignal.set({ ...ownersSignal.get(), videoBuffer: {} as any }); await new Promise((resolve) => setTimeout(resolve, 20)); expect(addEventListenerSpy.mock.calls.length).toBe(callsBefore); - cleanup(); + cleanupEffect(); }); it('starts listening when mediaElement is added later', async () => { - const state = createState({}); - const owners = createState({}); - - const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + const { state, owners, cleanup } = setup(); await new Promise((resolve) => setTimeout(resolve, 20)); - expect(state.current.selectedTextTrackId).toBeUndefined(); + expect(state.get().selectedTextTrackId).toBeUndefined(); const mediaElement = document.createElement('video'); const trackEl = createSubtitleTrack(mediaElement, 'track-en'); await new Promise((resolve) => setTimeout(resolve, 50)); - owners.patch({ mediaElement }); + owners.set({ ...owners.get(), mediaElement }); trackEl.track.mode = 'showing'; mediaElement.textTracks.dispatchEvent(new Event('change')); await vi.waitFor(() => { - expect(state.current.selectedTextTrackId).toBe('track-en'); + expect(state.get().selectedTextTrackId).toBe('track-en'); }); cleanup(); @@ -264,28 +245,22 @@ describe('syncSelectedTextTrackFromDom', () => { await new Promise((resolve) => setTimeout(resolve, 50)); - const state = createState({}); - const owners = createState({ mediaElement: element1 }); + const { state, owners, cleanup } = setup({}, { mediaElement: element1 }); - const cleanup = syncSelectedTextTrackFromDom({ state, owners }); - - // Switch to element2 - owners.patch({ mediaElement: element2 }); + owners.set({ ...owners.get(), mediaElement: element2 }); await new Promise((resolve) => setTimeout(resolve, 20)); - // Events on old element should no longer affect state trackEl1.track.mode = 'showing'; element1.textTracks.dispatchEvent(new Event('change')); await new Promise((resolve) => setTimeout(resolve, 30)); - expect(state.current.selectedTextTrackId).toBeUndefined(); + expect(state.get().selectedTextTrackId).toBeUndefined(); - // Events on new element should work trackEl2.track.mode = 'showing'; element2.textTracks.dispatchEvent(new Event('change')); await vi.waitFor(() => { - expect(state.current.selectedTextTrackId).toBe('track-fr'); + expect(state.get().selectedTextTrackId).toBe('track-fr'); }); cleanup(); @@ -297,16 +272,13 @@ describe('syncSelectedTextTrackFromDom', () => { await new Promise((resolve) => setTimeout(resolve, 50)); - const state = createState({}); - const owners = createState({ mediaElement }); - - const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + const { state, cleanup } = setup({}, { mediaElement }); cleanup(); trackEl.track.mode = 'showing'; mediaElement.textTracks.dispatchEvent(new Event('change')); await new Promise((resolve) => setTimeout(resolve, 30)); - expect(state.current.selectedTextTrackId).toBeUndefined(); + expect(state.get().selectedTextTrackId).toBeUndefined(); }); }); diff --git a/packages/spf/src/dom/features/tests/sync-text-track-modes.test.ts b/packages/spf/src/dom/features/tests/sync-text-track-modes.test.ts index d8e02024..4ae6ef82 100644 --- a/packages/spf/src/dom/features/tests/sync-text-track-modes.test.ts +++ b/packages/spf/src/dom/features/tests/sync-text-track-modes.test.ts @@ -1,204 +1,155 @@ import { describe, expect, it } from 'vitest'; -import { createState } from '../../../core/state/create-state'; -import { - canSyncTextTrackModes, - syncTextTrackModes, - type TextTrackModeOwners, - type TextTrackModeState, -} from '../sync-text-track-modes'; +import { signal } from '../../../core/signals/primitives'; +import { syncTextTrackModes, type TextTrackModeOwners, type TextTrackModeState } from '../sync-text-track-modes'; + +function setupSyncTextTrackModes(initialState: TextTrackModeState = {}, initialOwners: TextTrackModeOwners = {}) { + const state = signal(initialState); + const owners = signal(initialOwners); + const cleanup = syncTextTrackModes({ state, owners }); + return { state, owners, cleanup }; +} describe('syncTextTrackModes', () => { - describe('canSyncTextTrackModes', () => { - it('returns false when no textTracks map', () => { - const owners: TextTrackModeOwners = {}; + it('sets selected track mode to "showing"', async () => { + const mediaElement = document.createElement('video'); - expect(canSyncTextTrackModes(owners)).toBe(false); - }); + const track1 = document.createElement('track'); + track1.kind = 'subtitles'; + track1.label = 'English'; + track1.src = 'data:text/vtt,'; + mediaElement.appendChild(track1); - it('returns false when textTracks map is empty', () => { - const owners: TextTrackModeOwners = { - textTracks: new Map(), - }; + const track2 = document.createElement('track'); + track2.kind = 'subtitles'; + track2.label = 'Spanish'; + track2.src = 'data:text/vtt,'; + mediaElement.appendChild(track2); - expect(canSyncTextTrackModes(owners)).toBe(false); - }); + await new Promise((resolve) => setTimeout(resolve, 50)); - it('returns true when textTracks map has entries', () => { - const trackElement = document.createElement('track'); - const owners: TextTrackModeOwners = { - textTracks: new Map([['track-1', trackElement]]), - }; + const { state, cleanup } = setupSyncTextTrackModes( + {}, + { + textTracks: new Map([ + ['track-en', track1], + ['track-es', track2], + ]), + } + ); - expect(canSyncTextTrackModes(owners)).toBe(true); - }); + state.set({ ...state.get(), selectedTextTrackId: 'track-en' }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(track1.track.mode).toBe('showing'); + expect(track2.track.mode).toBe('hidden'); + + cleanup(); }); - describe('syncTextTrackModes orchestration', () => { - it('sets selected track mode to "showing"', async () => { - const mediaElement = document.createElement('video'); + it('switches active track when selection changes', async () => { + const mediaElement = document.createElement('video'); - // Create track elements - const track1 = document.createElement('track'); - track1.kind = 'subtitles'; - track1.label = 'English'; - track1.src = 'data:text/vtt,'; // Use data URL to avoid network request - mediaElement.appendChild(track1); + const track1 = document.createElement('track'); + track1.kind = 'subtitles'; + track1.src = 'data:text/vtt,'; + mediaElement.appendChild(track1); - const track2 = document.createElement('track'); - track2.kind = 'subtitles'; - track2.label = 'Spanish'; - track2.src = 'data:text/vtt,'; - mediaElement.appendChild(track2); + const track2 = document.createElement('track'); + track2.kind = 'subtitles'; + track2.src = 'data:text/vtt,'; + mediaElement.appendChild(track2); - // Wait for tracks to be ready - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 50)); - const state = createState({}); - const owners = createState({ + const { state, cleanup } = setupSyncTextTrackModes( + {}, + { textTracks: new Map([ ['track-en', track1], ['track-es', track2], ]), - }); + } + ); - const cleanup = syncTextTrackModes({ state, owners }); + state.set({ ...state.get(), selectedTextTrackId: 'track-en' }); + await new Promise((resolve) => setTimeout(resolve, 50)); - // Select first track - state.patch({ selectedTextTrackId: 'track-en' }); + expect(track1.track.mode).toBe('showing'); + expect(track2.track.mode).toBe('hidden'); - // Wait for orchestration to run - await new Promise((resolve) => setTimeout(resolve, 50)); + state.set({ ...state.get(), selectedTextTrackId: 'track-es' }); + await new Promise((resolve) => setTimeout(resolve, 50)); - // Verify track modes - expect(track1.track.mode).toBe('showing'); - expect(track2.track.mode).toBe('hidden'); + expect(track1.track.mode).toBe('hidden'); + expect(track2.track.mode).toBe('showing'); - cleanup(); - }); + cleanup(); + }); - it('switches active track when selection changes', async () => { - const mediaElement = document.createElement('video'); + it('hides all tracks when no selection', async () => { + const mediaElement = document.createElement('video'); - const track1 = document.createElement('track'); - track1.kind = 'subtitles'; - track1.src = 'data:text/vtt,'; - mediaElement.appendChild(track1); + const track1 = document.createElement('track'); + track1.kind = 'subtitles'; + track1.src = 'data:text/vtt,'; + mediaElement.appendChild(track1); - const track2 = document.createElement('track'); - track2.kind = 'subtitles'; - track2.src = 'data:text/vtt,'; - mediaElement.appendChild(track2); + const track2 = document.createElement('track'); + track2.kind = 'subtitles'; + track2.src = 'data:text/vtt,'; + mediaElement.appendChild(track2); - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 50)); - const state = createState({}); - const owners = createState({ + const { state, cleanup } = setupSyncTextTrackModes( + {}, + { textTracks: new Map([ ['track-en', track1], ['track-es', track2], ]), - }); + } + ); - const cleanup = syncTextTrackModes({ state, owners }); + state.set({ ...state.get(), selectedTextTrackId: 'track-en' }); + await new Promise((resolve) => setTimeout(resolve, 50)); - // Select first track - state.patch({ selectedTextTrackId: 'track-en' }); - await new Promise((resolve) => setTimeout(resolve, 50)); + expect(track1.track.mode).toBe('showing'); - expect(track1.track.mode).toBe('showing'); - expect(track2.track.mode).toBe('hidden'); + state.set({ ...state.get(), selectedTextTrackId: undefined }); + await new Promise((resolve) => setTimeout(resolve, 50)); - // Switch to second track - state.patch({ selectedTextTrackId: 'track-es' }); - await new Promise((resolve) => setTimeout(resolve, 50)); + expect(track1.track.mode).toBe('hidden'); + expect(track2.track.mode).toBe('hidden'); - expect(track1.track.mode).toBe('hidden'); - expect(track2.track.mode).toBe('showing'); + cleanup(); + }); - cleanup(); - }); + it('does nothing when textTracks not available', async () => { + const { cleanup } = setupSyncTextTrackModes({ selectedTextTrackId: 'track-en' }); - it('hides all tracks when no selection', async () => { - const mediaElement = document.createElement('video'); + await new Promise((resolve) => setTimeout(resolve, 50)); - const track1 = document.createElement('track'); - track1.kind = 'subtitles'; - track1.src = 'data:text/vtt,'; - mediaElement.appendChild(track1); + cleanup(); + }); - const track2 = document.createElement('track'); - track2.kind = 'subtitles'; - track2.src = 'data:text/vtt,'; - mediaElement.appendChild(track2); + it('handles track selection before track elements created', async () => { + const mediaElement = document.createElement('video'); - await new Promise((resolve) => setTimeout(resolve, 50)); + const track1 = document.createElement('track'); + track1.kind = 'subtitles'; + track1.src = 'data:text/vtt,'; + mediaElement.appendChild(track1); - const state = createState({}); - const owners = createState({ - textTracks: new Map([ - ['track-en', track1], - ['track-es', track2], - ]), - }); + await new Promise((resolve) => setTimeout(resolve, 50)); - const cleanup = syncTextTrackModes({ state, owners }); + const { owners, cleanup } = setupSyncTextTrackModes({ selectedTextTrackId: 'track-en' }); - // First select a track - state.patch({ selectedTextTrackId: 'track-en' }); - await new Promise((resolve) => setTimeout(resolve, 50)); + owners.set({ ...owners.get(), textTracks: new Map([['track-en', track1]]) }); + await new Promise((resolve) => setTimeout(resolve, 50)); - expect(track1.track.mode).toBe('showing'); + expect(track1.track.mode).toBe('showing'); - // Deselect (set to undefined) - state.patch({ selectedTextTrackId: undefined }); - await new Promise((resolve) => setTimeout(resolve, 50)); - - // Both should be hidden - expect(track1.track.mode).toBe('hidden'); - expect(track2.track.mode).toBe('hidden'); - - cleanup(); - }); - - it('does nothing when textTracks not available', async () => { - const state = createState({ selectedTextTrackId: 'track-en' }); - const owners = createState({}); - - // Should not throw - const cleanup = syncTextTrackModes({ state, owners }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - cleanup(); - }); - - it('handles track selection before track elements created', async () => { - const mediaElement = document.createElement('video'); - - const track1 = document.createElement('track'); - track1.kind = 'subtitles'; - track1.src = 'data:text/vtt,'; - mediaElement.appendChild(track1); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - // Select track BEFORE creating owners with textTracks - const state = createState({ selectedTextTrackId: 'track-en' }); - const owners = createState({}); - - const cleanup = syncTextTrackModes({ state, owners }); - - // Later, add textTracks - owners.patch({ - textTracks: new Map([['track-en', track1]]), - }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - // Should activate the track - expect(track1.track.mode).toBe('showing'); - - cleanup(); - }); + cleanup(); }); }); diff --git a/packages/spf/src/dom/features/tests/track-current-time.test.ts b/packages/spf/src/dom/features/tests/track-current-time.test.ts index 7e35e553..4465198b 100644 --- a/packages/spf/src/dom/features/tests/track-current-time.test.ts +++ b/packages/spf/src/dom/features/tests/track-current-time.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { createState } from '../../../core/state/create-state'; +import { signal } from '../../../core/signals/primitives'; import { type CurrentTimeOwners, type CurrentTimeState, @@ -7,6 +7,13 @@ import { trackCurrentTime, } from '../track-current-time'; +function setupTrackCurrentTime(initialState: CurrentTimeState, initialOwners: CurrentTimeOwners) { + const state = signal(initialState); + const owners = signal(initialOwners); + const cleanup = trackCurrentTime({ state, owners }); + return { state, owners, cleanup }; +} + describe('trackCurrentTime', () => { describe('canTrackCurrentTime', () => { it('returns false when no mediaElement', () => { @@ -29,13 +36,10 @@ describe('trackCurrentTime', () => { const mediaElement = document.createElement('video'); Object.defineProperty(mediaElement, 'currentTime', { value: 5.5, writable: true }); - const state = createState({}); - const owners = createState({ mediaElement }); - - const cleanup = trackCurrentTime({ state, owners }); + const { state, cleanup } = setupTrackCurrentTime({}, { mediaElement }); await vi.waitFor(() => { - expect(state.current.currentTime).toBe(5.5); + expect(state.get().currentTime).toBe(5.5); }); cleanup(); @@ -45,17 +49,14 @@ describe('trackCurrentTime', () => { const mediaElement = document.createElement('video'); Object.defineProperty(mediaElement, 'currentTime', { value: 0, writable: true }); - const state = createState({}); - const owners = createState({ mediaElement }); - - const cleanup = trackCurrentTime({ state, owners }); + const { state, cleanup } = setupTrackCurrentTime({}, { mediaElement }); // Simulate playback progress (mediaElement as any).currentTime = 10.0; mediaElement.dispatchEvent(new Event('timeupdate')); await vi.waitFor(() => { - expect(state.current.currentTime).toBe(10.0); + expect(state.get().currentTime).toBe(10.0); }); cleanup(); @@ -65,18 +66,15 @@ describe('trackCurrentTime', () => { const mediaElement = document.createElement('video'); Object.defineProperty(mediaElement, 'currentTime', { value: 0, writable: true }); - const state = createState({}); - const owners = createState({ mediaElement }); - - const cleanup = trackCurrentTime({ state, owners }); + const { state, cleanup } = setupTrackCurrentTime({}, { mediaElement }); (mediaElement as any).currentTime = 3.0; mediaElement.dispatchEvent(new Event('timeupdate')); - await vi.waitFor(() => expect(state.current.currentTime).toBe(3.0)); + await vi.waitFor(() => expect(state.get().currentTime).toBe(3.0)); (mediaElement as any).currentTime = 7.5; mediaElement.dispatchEvent(new Event('timeupdate')); - await vi.waitFor(() => expect(state.current.currentTime).toBe(7.5)); + await vi.waitFor(() => expect(state.get().currentTime).toBe(7.5)); cleanup(); }); @@ -87,18 +85,16 @@ describe('trackCurrentTime', () => { const addEventListenerSpy = vi.spyOn(mediaElement, 'addEventListener'); - // Use an owners shape that has an extra field to simulate the playback engine - const state = createState({}); - const owners = createState({ mediaElement }); - + const state = signal({}); + const owners = signal({ mediaElement }); const cleanup = trackCurrentTime({ state, owners }); - await vi.waitFor(() => expect(state.current.currentTime).toBe(5)); + await vi.waitFor(() => expect(state.get().currentTime).toBe(5)); const callsBefore = addEventListenerSpy.mock.calls.length; - // Patch an unrelated owner — mediaElement is the same object - owners.patch({ videoBuffer: {} }); + // Set an unrelated owner field — mediaElement is the same object + owners.set({ ...owners.get(), videoBuffer: {} }); await new Promise((resolve) => setTimeout(resolve, 30)); // No new listeners should have been added @@ -108,33 +104,27 @@ describe('trackCurrentTime', () => { }); it('does nothing when no mediaElement', async () => { - const state = createState({}); - const owners = createState({}); - - const cleanup = trackCurrentTime({ state, owners }); + const { state, cleanup } = setupTrackCurrentTime({}, {}); await new Promise((resolve) => setTimeout(resolve, 50)); - expect(state.current.currentTime).toBeUndefined(); + expect(state.get().currentTime).toBeUndefined(); cleanup(); }); it('starts tracking when mediaElement is added later', async () => { - const state = createState({}); - const owners = createState({}); - - const cleanup = trackCurrentTime({ state, owners }); + const { state, owners, cleanup } = setupTrackCurrentTime({}, {}); await new Promise((resolve) => setTimeout(resolve, 20)); - expect(state.current.currentTime).toBeUndefined(); + expect(state.get().currentTime).toBeUndefined(); const mediaElement = document.createElement('video'); Object.defineProperty(mediaElement, 'currentTime', { value: 2.0, writable: true }); - owners.patch({ mediaElement }); + owners.set({ ...owners.get(), mediaElement }); await vi.waitFor(() => { - expect(state.current.currentTime).toBe(2.0); + expect(state.get().currentTime).toBe(2.0); }); cleanup(); @@ -146,23 +136,20 @@ describe('trackCurrentTime', () => { const element2 = document.createElement('video'); Object.defineProperty(element2, 'currentTime', { value: 20.0, writable: true }); - const state = createState({}); - const owners = createState({ mediaElement: element1 }); + const { state, owners, cleanup } = setupTrackCurrentTime({}, { mediaElement: element1 }); - const cleanup = trackCurrentTime({ state, owners }); - - await vi.waitFor(() => expect(state.current.currentTime).toBe(1.0)); + await vi.waitFor(() => expect(state.get().currentTime).toBe(1.0)); // Replace with new element - owners.patch({ mediaElement: element2 }); - await vi.waitFor(() => expect(state.current.currentTime).toBe(20.0)); + owners.set({ ...owners.get(), mediaElement: element2 }); + await vi.waitFor(() => expect(state.get().currentTime).toBe(20.0)); // Old element timeupdate should no longer affect state (element1 as any).currentTime = 99.0; element1.dispatchEvent(new Event('timeupdate')); await new Promise((resolve) => setTimeout(resolve, 30)); - expect(state.current.currentTime).toBe(20.0); + expect(state.get().currentTime).toBe(20.0); cleanup(); }); @@ -171,17 +158,14 @@ describe('trackCurrentTime', () => { const mediaElement = document.createElement('video'); Object.defineProperty(mediaElement, 'currentTime', { value: 0, writable: true }); - const state = createState({}); - const owners = createState({ mediaElement }); - - const cleanup = trackCurrentTime({ state, owners }); + const { state, cleanup } = setupTrackCurrentTime({}, { mediaElement }); // Simulate a seek while paused — timeupdate does not fire, only seeking does (mediaElement as any).currentTime = 60.0; mediaElement.dispatchEvent(new Event('seeking')); await vi.waitFor(() => { - expect(state.current.currentTime).toBe(60.0); + expect(state.get().currentTime).toBe(60.0); }); cleanup(); @@ -191,12 +175,9 @@ describe('trackCurrentTime', () => { const mediaElement = document.createElement('video'); Object.defineProperty(mediaElement, 'currentTime', { value: 0, writable: true }); - const state = createState({}); - const owners = createState({ mediaElement }); + const { state, cleanup } = setupTrackCurrentTime({}, { mediaElement }); - const cleanup = trackCurrentTime({ state, owners }); - - await vi.waitFor(() => expect(state.current.currentTime).toBe(0)); + await vi.waitFor(() => expect(state.get().currentTime).toBe(0)); cleanup(); @@ -205,7 +186,7 @@ describe('trackCurrentTime', () => { mediaElement.dispatchEvent(new Event('seeking')); await new Promise((resolve) => setTimeout(resolve, 30)); - expect(state.current.currentTime).toBe(0); + expect(state.get().currentTime).toBe(0); }); }); }); diff --git a/packages/spf/src/dom/features/tests/track-playback-initiated.test.ts b/packages/spf/src/dom/features/tests/track-playback-initiated.test.ts index 035d3ed7..0ef1195a 100644 --- a/packages/spf/src/dom/features/tests/track-playback-initiated.test.ts +++ b/packages/spf/src/dom/features/tests/track-playback-initiated.test.ts @@ -1,108 +1,108 @@ import { describe, expect, it, vi } from 'vitest'; -import { createEventStream } from '../../../core/events/create-event-stream'; -import type { PresentationAction } from '../../../core/features/resolve-presentation'; -import { createState } from '../../../core/state/create-state'; +import { signal } from '../../../core/signals/primitives'; import { type PlaybackInitiatedOwners, type PlaybackInitiatedState, trackPlaybackInitiated, } from '../track-playback-initiated'; +function setupTrackPlaybackInitiated( + initialState: PlaybackInitiatedState = {}, + initialOwners: PlaybackInitiatedOwners = {} +) { + const state = signal(initialState); + const owners = signal(initialOwners); + const cleanup = trackPlaybackInitiated({ state, owners }); + return { state, owners, cleanup }; +} + describe('trackPlaybackInitiated', () => { it('sets playbackInitiated to true when mediaElement fires play event', async () => { const mediaElement = document.createElement('video'); - const state = createState({}); - const owners = createState({ mediaElement }); - const events = createEventStream(); - - const cleanup = trackPlaybackInitiated({ state, owners, events }); + const { state, cleanup } = setupTrackPlaybackInitiated( + { presentation: { url: 'http://example.com/stream.m3u8' } }, + { mediaElement } + ); mediaElement.dispatchEvent(new Event('play')); await new Promise((resolve) => setTimeout(resolve, 10)); - expect(state.current.playbackInitiated).toBe(true); - cleanup(); - }); - - it('dispatches play action to event stream', async () => { - const mediaElement = document.createElement('video'); - const state = createState({}); - const owners = createState({ mediaElement }); - const events = createEventStream(); - - const dispatched: PresentationAction[] = []; - events.subscribe((action) => dispatched.push(action)); - - const cleanup = trackPlaybackInitiated({ state, owners, events }); - - mediaElement.dispatchEvent(new Event('play')); - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(dispatched).toContainEqual({ type: 'play' }); + expect(state.get().playbackInitiated).toBe(true); cleanup(); }); it('resets playbackInitiated to false when presentation URL changes', async () => { const mediaElement = document.createElement('video'); - const state = createState({ - presentation: { url: 'http://example.com/stream1.m3u8' }, - playbackInitiated: true, - }); - const owners = createState({ mediaElement }); - const events = createEventStream(); + const { state, cleanup } = setupTrackPlaybackInitiated( + { presentation: { url: 'http://example.com/stream1.m3u8' } }, + { mediaElement } + ); - const cleanup = trackPlaybackInitiated({ state, owners, events }); + mediaElement.dispatchEvent(new Event('play')); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(state.get().playbackInitiated).toBe(true); - state.patch({ presentation: { url: 'http://example.com/stream2.m3u8' } }); + state.set({ ...state.get(), presentation: { url: 'http://example.com/stream2.m3u8' } }); await new Promise((resolve) => setTimeout(resolve, 20)); - expect(state.current.playbackInitiated).toBe(false); + expect(state.get().playbackInitiated).toBe(false); + cleanup(); + }); + + it('sets playbackInitiated back to true if play fires after a URL change (e.g. autoplay)', async () => { + const mediaElement = document.createElement('video'); + const { state, cleanup } = setupTrackPlaybackInitiated( + { presentation: { url: 'http://example.com/stream1.m3u8' } }, + { mediaElement } + ); + + mediaElement.dispatchEvent(new Event('play')); + await new Promise((resolve) => setTimeout(resolve, 10)); + + state.set({ ...state.get(), presentation: { url: 'http://example.com/stream2.m3u8' } }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(state.get().playbackInitiated).toBe(false); + + mediaElement.dispatchEvent(new Event('play')); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(state.get().playbackInitiated).toBe(true); + + cleanup(); + }); + + it('resets playbackInitiated to false when the media element is swapped', async () => { + const mediaElement = document.createElement('video'); + const { state, owners, cleanup } = setupTrackPlaybackInitiated( + { presentation: { url: 'http://example.com/stream.m3u8' } }, + { mediaElement } + ); + + mediaElement.dispatchEvent(new Event('play')); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(state.get().playbackInitiated).toBe(true); + + owners.set({ ...owners.get(), mediaElement: document.createElement('video') }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(state.get().playbackInitiated).toBe(false); cleanup(); }); it('does not reset playbackInitiated on unrelated state changes', async () => { const mediaElement = document.createElement('video'); - const state = createState({ - presentation: { url: 'http://example.com/stream.m3u8' }, - playbackInitiated: true, - }); - const owners = createState({ mediaElement }); - const events = createEventStream(); + const { state, cleanup } = setupTrackPlaybackInitiated( + { presentation: { url: 'http://example.com/stream.m3u8' } }, + { mediaElement } + ); - const cleanup = trackPlaybackInitiated({ state, owners, events }); - - state.patch({ playbackInitiated: true }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(state.current.playbackInitiated).toBe(true); - cleanup(); - }); - - it('does not reset playbackInitiated on initial URL set', async () => { - const mediaElement = document.createElement('video'); - const state = createState({}); - const owners = createState({ mediaElement }); - const events = createEventStream(); - - const cleanup = trackPlaybackInitiated({ state, owners, events }); - - state.patch({ presentation: { url: 'http://example.com/stream.m3u8' } }); mediaElement.dispatchEvent(new Event('play')); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(state.get().playbackInitiated).toBe(true); + + state.set({ ...state.get(), presentation: { url: 'http://example.com/stream.m3u8' } }); await new Promise((resolve) => setTimeout(resolve, 20)); - expect(state.current.playbackInitiated).toBe(true); - cleanup(); - }); - - it('does nothing when no mediaElement', async () => { - const state = createState({}); - const owners = createState({}); - const events = createEventStream(); - - const cleanup = trackPlaybackInitiated({ state, owners, events }); - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(state.current.playbackInitiated).toBeUndefined(); + expect(state.get().playbackInitiated).toBe(true); cleanup(); }); @@ -110,14 +110,10 @@ describe('trackPlaybackInitiated', () => { const mediaElement = document.createElement('video'); const addEventListenerSpy = vi.spyOn(mediaElement, 'addEventListener'); - const state = createState({}); - const owners = createState({ mediaElement }); - const events = createEventStream(); - - const cleanup = trackPlaybackInitiated({ state, owners, events }); + const { owners, cleanup } = setupTrackPlaybackInitiated({}, { mediaElement }); const callsBefore = addEventListenerSpy.mock.calls.length; - owners.patch({ videoBuffer: {} }); + owners.set({ ...owners.get(), videoBuffer: {} } as PlaybackInitiatedOwners & { videoBuffer?: unknown }); await new Promise((resolve) => setTimeout(resolve, 10)); expect(addEventListenerSpy.mock.calls.length).toBe(callsBefore); @@ -126,23 +122,17 @@ describe('trackPlaybackInitiated', () => { it('stops tracking after cleanup', async () => { const mediaElement = document.createElement('video'); - const state = createState({ - presentation: { url: 'http://example.com/stream1.m3u8' }, - }); - const owners = createState({ mediaElement }); - const events = createEventStream(); + const { state, cleanup } = setupTrackPlaybackInitiated( + { presentation: { url: 'http://example.com/stream1.m3u8' } }, + { mediaElement } + ); - const dispatched: PresentationAction[] = []; - events.subscribe((action) => dispatched.push(action)); - - const cleanup = trackPlaybackInitiated({ state, owners, events }); cleanup(); mediaElement.dispatchEvent(new Event('play')); - state.patch({ presentation: { url: 'http://example.com/stream2.m3u8' } }); + state.set({ ...state.get(), presentation: { url: 'http://example.com/stream2.m3u8' } }); await new Promise((resolve) => setTimeout(resolve, 20)); - expect(dispatched).toHaveLength(0); - expect(state.current.playbackInitiated).toBeUndefined(); + expect(state.get().playbackInitiated).toBeFalsy(); }); }); diff --git a/packages/spf/src/dom/features/tests/track-playback-rate.test.ts b/packages/spf/src/dom/features/tests/track-playback-rate.test.ts index ff2096b0..25ec0420 100644 --- a/packages/spf/src/dom/features/tests/track-playback-rate.test.ts +++ b/packages/spf/src/dom/features/tests/track-playback-rate.test.ts @@ -1,175 +1,140 @@ import { describe, expect, it, vi } from 'vitest'; -import { createState } from '../../../core/state/create-state'; -import { - canTrackPlaybackRate, - type PlaybackRateOwners, - type PlaybackRateState, - trackPlaybackRate, -} from '../track-playback-rate'; +import { signal } from '../../../core/signals/primitives'; +import { type PlaybackRateOwners, type PlaybackRateState, trackPlaybackRate } from '../track-playback-rate'; + +function setupTrackPlaybackRate(initialState: PlaybackRateState = {}, initialOwners: PlaybackRateOwners = {}) { + const state = signal(initialState); + const owners = signal(initialOwners); + const cleanup = trackPlaybackRate({ state, owners }); + return { state, owners, cleanup }; +} describe('trackPlaybackRate', () => { - describe('canTrackPlaybackRate', () => { - it('returns false when no mediaElement', () => { - expect(canTrackPlaybackRate({})).toBe(false); + it('syncs playbackRate immediately when mediaElement is provided', async () => { + const mediaElement = document.createElement('video'); + + const { state, cleanup } = setupTrackPlaybackRate({}, { mediaElement }); + + await vi.waitFor(() => { + expect(state.get().playbackRate).toBe(1); }); - it('returns true when mediaElement exists', () => { - const owners: PlaybackRateOwners = { - mediaElement: document.createElement('video'), - }; - - expect(canTrackPlaybackRate(owners)).toBe(true); - }); + cleanup(); }); - describe('trackPlaybackRate orchestration', () => { - it('syncs playbackRate immediately when mediaElement is provided', async () => { - const mediaElement = document.createElement('video'); + it('updates playbackRate on ratechange events', async () => { + const mediaElement = document.createElement('video'); - const state = createState({}); - const owners = createState({ mediaElement }); + const { state, cleanup } = setupTrackPlaybackRate({}, { mediaElement }); - const cleanup = trackPlaybackRate({ state, owners }); + mediaElement.playbackRate = 2; + mediaElement.dispatchEvent(new Event('ratechange')); - await vi.waitFor(() => { - expect(state.current.playbackRate).toBe(1); - }); - - cleanup(); + await vi.waitFor(() => { + expect(state.get().playbackRate).toBe(2); }); - it('updates playbackRate on ratechange events', async () => { - const mediaElement = document.createElement('video'); + cleanup(); + }); - const state = createState({}); - const owners = createState({ mediaElement }); + it('continues tracking on subsequent ratechange events', async () => { + const mediaElement = document.createElement('video'); - const cleanup = trackPlaybackRate({ state, owners }); + const { state, cleanup } = setupTrackPlaybackRate({}, { mediaElement }); - mediaElement.playbackRate = 2; - mediaElement.dispatchEvent(new Event('ratechange')); + mediaElement.playbackRate = 0.5; + mediaElement.dispatchEvent(new Event('ratechange')); + await vi.waitFor(() => expect(state.get().playbackRate).toBe(0.5)); - await vi.waitFor(() => { - expect(state.current.playbackRate).toBe(2); - }); + mediaElement.playbackRate = 1.5; + mediaElement.dispatchEvent(new Event('ratechange')); + await vi.waitFor(() => expect(state.get().playbackRate).toBe(1.5)); - cleanup(); + cleanup(); + }); + + it('does nothing when no mediaElement', async () => { + const { state, cleanup } = setupTrackPlaybackRate(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(state.get().playbackRate).toBeUndefined(); + + cleanup(); + }); + + it('continues tracking correctly after owners updates with unchanged mediaElement', async () => { + const mediaElement = document.createElement('video'); + const ratechangeHandler = vi.fn(); + mediaElement.addEventListener('ratechange', ratechangeHandler); + + const { state, owners, cleanup } = setupTrackPlaybackRate({}, { mediaElement }); + + await vi.waitFor(() => expect(state.get().playbackRate).toBe(1)); + + // Spread owners without changing mediaElement — effect may re-setup but no duplicate handling + owners.set({ ...owners.get() }); + await new Promise((resolve) => setTimeout(resolve, 30)); + + // ratechange should still update state exactly once + mediaElement.playbackRate = 2; + mediaElement.dispatchEvent(new Event('ratechange')); + + await vi.waitFor(() => expect(state.get().playbackRate).toBe(2)); + + cleanup(); + }); + + it('starts tracking when mediaElement is added later', async () => { + const { state, owners, cleanup } = setupTrackPlaybackRate(); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(state.get().playbackRate).toBeUndefined(); + + const mediaElement = document.createElement('video'); + owners.set({ ...owners.get(), mediaElement }); + + await vi.waitFor(() => { + expect(state.get().playbackRate).toBe(1); }); - it('continues tracking on subsequent ratechange events', async () => { - const mediaElement = document.createElement('video'); + cleanup(); + }); - const state = createState({}); - const owners = createState({ mediaElement }); + it('stops listening to old mediaElement when replaced', async () => { + const element1 = document.createElement('video'); + const element2 = document.createElement('video'); - const cleanup = trackPlaybackRate({ state, owners }); + const { state, owners, cleanup } = setupTrackPlaybackRate({}, { mediaElement: element1 }); - mediaElement.playbackRate = 0.5; - mediaElement.dispatchEvent(new Event('ratechange')); - await vi.waitFor(() => expect(state.current.playbackRate).toBe(0.5)); + await vi.waitFor(() => expect(state.get().playbackRate).toBe(1)); - mediaElement.playbackRate = 1.5; - mediaElement.dispatchEvent(new Event('ratechange')); - await vi.waitFor(() => expect(state.current.playbackRate).toBe(1.5)); + owners.set({ ...owners.get(), mediaElement: element2 }); + await vi.waitFor(() => expect(state.get().playbackRate).toBe(1)); - cleanup(); - }); + // Old element ratechange should no longer affect state + element1.playbackRate = 3; + element1.dispatchEvent(new Event('ratechange')); + await new Promise((resolve) => setTimeout(resolve, 30)); - it('does nothing when no mediaElement', async () => { - const state = createState({}); - const owners = createState({}); + expect(state.get().playbackRate).toBe(1); - const cleanup = trackPlaybackRate({ state, owners }); + cleanup(); + }); - await new Promise((resolve) => setTimeout(resolve, 50)); + it('removes ratechange listener on cleanup', async () => { + const mediaElement = document.createElement('video'); - expect(state.current.playbackRate).toBeUndefined(); + const { state, cleanup } = setupTrackPlaybackRate({}, { mediaElement }); - cleanup(); - }); + await vi.waitFor(() => expect(state.get().playbackRate).toBe(1)); - it('does not re-setup when owners updates but mediaElement is unchanged', async () => { - const mediaElement = document.createElement('video'); - const addEventListenerSpy = vi.spyOn(mediaElement, 'addEventListener'); + cleanup(); - const state = createState({}); - const owners = createState({ mediaElement }); + mediaElement.playbackRate = 2; + mediaElement.dispatchEvent(new Event('ratechange')); + await new Promise((resolve) => setTimeout(resolve, 30)); - const cleanup = trackPlaybackRate({ state, owners }); - - await vi.waitFor(() => expect(state.current.playbackRate).toBe(1)); - - const callsBefore = addEventListenerSpy.mock.calls.length; - - // Patch an unrelated owner — mediaElement is the same object - owners.patch({ videoBuffer: {} }); - await new Promise((resolve) => setTimeout(resolve, 30)); - - expect(addEventListenerSpy.mock.calls.length).toBe(callsBefore); - - cleanup(); - }); - - it('starts tracking when mediaElement is added later', async () => { - const state = createState({}); - const owners = createState({}); - - const cleanup = trackPlaybackRate({ state, owners }); - - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(state.current.playbackRate).toBeUndefined(); - - const mediaElement = document.createElement('video'); - owners.patch({ mediaElement }); - - await vi.waitFor(() => { - expect(state.current.playbackRate).toBe(1); - }); - - cleanup(); - }); - - it('stops listening to old mediaElement when replaced', async () => { - const element1 = document.createElement('video'); - const element2 = document.createElement('video'); - - const state = createState({}); - const owners = createState({ mediaElement: element1 }); - - const cleanup = trackPlaybackRate({ state, owners }); - - await vi.waitFor(() => expect(state.current.playbackRate).toBe(1)); - - owners.patch({ mediaElement: element2 }); - await vi.waitFor(() => expect(state.current.playbackRate).toBe(1)); - - // Old element ratechange should no longer affect state - element1.playbackRate = 3; - element1.dispatchEvent(new Event('ratechange')); - await new Promise((resolve) => setTimeout(resolve, 30)); - - expect(state.current.playbackRate).toBe(1); - - cleanup(); - }); - - it('removes ratechange listener on cleanup', async () => { - const mediaElement = document.createElement('video'); - - const state = createState({}); - const owners = createState({ mediaElement }); - - const cleanup = trackPlaybackRate({ state, owners }); - - await vi.waitFor(() => expect(state.current.playbackRate).toBe(1)); - - cleanup(); - - mediaElement.playbackRate = 2; - mediaElement.dispatchEvent(new Event('ratechange')); - await new Promise((resolve) => setTimeout(resolve, 30)); - - expect(state.current.playbackRate).toBe(1); - }); + expect(state.get().playbackRate).toBe(1); }); }); diff --git a/packages/spf/src/dom/features/tests/update-duration.test.ts b/packages/spf/src/dom/features/tests/update-duration.test.ts index 49f1b16a..25e86c27 100644 --- a/packages/spf/src/dom/features/tests/update-duration.test.ts +++ b/packages/spf/src/dom/features/tests/update-duration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { createState } from '../../../core/state/create-state'; +import { signal } from '../../../core/signals/primitives'; import type { Presentation } from '../../../core/types'; import { canUpdateDuration, @@ -9,6 +9,13 @@ import { updateDuration, } from '../update-duration'; +function setupUpdateDuration(initialState: DurationUpdateState, initialOwners: DurationUpdateOwners) { + const state = signal(initialState); + const owners = signal(initialOwners); + const cleanup = updateDuration({ state, owners }); + return { state, owners, cleanup }; +} + function makeMediaSource(duration = NaN) { return Object.create(MediaSource.prototype, { readyState: { value: 'open', writable: true }, @@ -173,10 +180,7 @@ describe('shouldUpdateDuration', () => { describe('updateDuration', () => { it('sets MediaSource.duration when conditions met', async () => { - const state = createState({}); - const owners = createState({}); - - const cleanup = updateDuration({ state, owners }); + const { state, owners, cleanup } = setupUpdateDuration({}, {}); // NaN is the real initial state of a freshly opened MediaSource const mockMediaSource = Object.create(MediaSource.prototype, { @@ -184,8 +188,9 @@ describe('updateDuration', () => { duration: { value: NaN, writable: true }, }); - owners.patch({ mediaSource: mockMediaSource }); - state.patch({ + owners.set({ ...owners.get(), mediaSource: mockMediaSource }); + state.set({ + ...state.get(), presentation: { duration: 60 } as Presentation, }); @@ -199,25 +204,22 @@ describe('updateDuration', () => { it('does not update again after initial set even if presentation duration changes', async () => { // Once the MediaSource duration is set (no longer NaN), subsequent presentation // duration changes must not trigger another set — doing so races with appendBuffer(). - const state = createState({}); - const owners = createState({}); - - const cleanup = updateDuration({ state, owners }); + const { state, owners, cleanup } = setupUpdateDuration({}, {}); const mockMediaSource = Object.create(MediaSource.prototype, { readyState: { value: 'open', writable: true }, duration: { value: NaN, writable: true }, }); - owners.patch({ mediaSource: mockMediaSource }); - state.patch({ presentation: { duration: 60 } as Presentation }); + owners.set({ ...owners.get(), mediaSource: mockMediaSource }); + state.set({ ...state.get(), presentation: { duration: 60 } as Presentation }); await vi.waitFor(() => { expect(mockMediaSource.duration).toBe(60); }); // Simulate presentation.duration changing (e.g. recalculated) — must not re-fire - state.patch({ presentation: { duration: 120 } as Presentation }); + state.set({ ...state.get(), presentation: { duration: 120 } as Presentation }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(mockMediaSource.duration).toBe(60); // unchanged @@ -226,10 +228,7 @@ describe('updateDuration', () => { }); it('does not update when MediaSource is not open', () => { - const state = createState({}); - const owners = createState({}); - - const cleanup = updateDuration({ state, owners }); + const { state, owners, cleanup } = setupUpdateDuration({}, {}); // Create mock with writable duration property const mockMediaSource = Object.create(MediaSource.prototype, { @@ -237,8 +236,9 @@ describe('updateDuration', () => { duration: { value: 0, writable: true }, }); - owners.patch({ mediaSource: mockMediaSource }); - state.patch({ + owners.set({ ...owners.get(), mediaSource: mockMediaSource }); + state.set({ + ...state.get(), presentation: { duration: 60 } as Presentation, }); @@ -248,10 +248,7 @@ describe('updateDuration', () => { }); it('does not update when duration is invalid', () => { - const state = createState({}); - const owners = createState({}); - - const cleanup = updateDuration({ state, owners }); + const { state, owners, cleanup } = setupUpdateDuration({}, {}); // Create mock with writable duration property const mockMediaSource = Object.create(MediaSource.prototype, { @@ -259,22 +256,25 @@ describe('updateDuration', () => { duration: { value: 0, writable: true }, }); - owners.patch({ mediaSource: mockMediaSource }); + owners.set({ ...owners.get(), mediaSource: mockMediaSource }); // Try NaN - state.patch({ + state.set({ + ...state.get(), presentation: { duration: NaN } as Presentation, }); expect(mockMediaSource.duration).toBe(0); // presentation validation guard fired // Try Infinity - state.patch({ + state.set({ + ...state.get(), presentation: { duration: Infinity } as Presentation, }); expect(mockMediaSource.duration).toBe(0); // Try negative - state.patch({ + state.set({ + ...state.get(), presentation: { duration: -10 } as Presentation, }); expect(mockMediaSource.duration).toBe(0); @@ -283,10 +283,7 @@ describe('updateDuration', () => { }); it('extends duration to match buffered range if needed', async () => { - const state = createState({}); - const owners = createState({}); - - const cleanup = updateDuration({ state, owners }); + const { state, owners, cleanup } = setupUpdateDuration({}, {}); // Create mock SourceBuffer with buffered data that exceeds presentation duration const mockBuffered = { @@ -305,13 +302,15 @@ describe('updateDuration', () => { duration: { value: NaN, writable: true }, }); - owners.patch({ + owners.set({ + ...owners.get(), mediaSource: mockMediaSource, videoSourceBuffer: mockVideoBuffer, }); // Presentation duration is 60, but buffered is 60.5 - state.patch({ + state.set({ + ...state.get(), presentation: { duration: 60 } as Presentation, }); @@ -324,15 +323,13 @@ describe('updateDuration', () => { }); it('does not throw when videoSourceBuffer is updating at moment of set', async () => { - const state = createState({}); - const owners = createState({}); - const cleanup = updateDuration({ state, owners }); + const { state, owners, cleanup } = setupUpdateDuration({}, {}); const mockMediaSource = makeMediaSource(); const { buffer: mockVideoBuffer, finishUpdating } = makeUpdatingSourceBuffer(); - owners.patch({ mediaSource: mockMediaSource, videoSourceBuffer: mockVideoBuffer }); - state.patch({ presentation: { duration: 60 } as Presentation }); + owners.set({ ...owners.get(), mediaSource: mockMediaSource, videoSourceBuffer: mockVideoBuffer }); + state.set({ ...state.get(), presentation: { duration: 60 } as Presentation }); // Buffer finishes immediately after state change — must not throw finishUpdating(); @@ -345,15 +342,13 @@ describe('updateDuration', () => { }); it('defers duration set until videoSourceBuffer finishes updating', async () => { - const state = createState({}); - const owners = createState({}); - const cleanup = updateDuration({ state, owners }); + const { state, owners, cleanup } = setupUpdateDuration({}, {}); const mockMediaSource = makeMediaSource(); const { buffer: mockVideoBuffer, finishUpdating } = makeUpdatingSourceBuffer(); - owners.patch({ mediaSource: mockMediaSource, videoSourceBuffer: mockVideoBuffer }); - state.patch({ presentation: { duration: 60 } as Presentation }); + owners.set({ ...owners.get(), mediaSource: mockMediaSource, videoSourceBuffer: mockVideoBuffer }); + state.set({ ...state.get(), presentation: { duration: 60 } as Presentation }); // Duration must not be set while buffer is still updating await new Promise((resolve) => setTimeout(resolve, 20)); @@ -370,20 +365,19 @@ describe('updateDuration', () => { }); it('defers until both video and audio SourceBuffers finish updating', async () => { - const state = createState({}); - const owners = createState({}); - const cleanup = updateDuration({ state, owners }); + const { state, owners, cleanup } = setupUpdateDuration({}, {}); const mockMediaSource = makeMediaSource(); const { buffer: mockVideoBuffer, finishUpdating: finishVideo } = makeUpdatingSourceBuffer(); const { buffer: mockAudioBuffer, finishUpdating: finishAudio } = makeUpdatingSourceBuffer(); - owners.patch({ + owners.set({ + ...owners.get(), mediaSource: mockMediaSource, videoSourceBuffer: mockVideoBuffer, audioSourceBuffer: mockAudioBuffer, }); - state.patch({ presentation: { duration: 60 } as Presentation }); + state.set({ ...state.get(), presentation: { duration: 60 } as Presentation }); // Neither buffer done — duration must not be set await new Promise((resolve) => setTimeout(resolve, 20)); @@ -405,9 +399,7 @@ describe('updateDuration', () => { }); it('does not throw when readyState transitions to ended during the async wait', async () => { - const state = createState({}); - const owners = createState({}); - const cleanup = updateDuration({ state, owners }); + const { state, owners, cleanup } = setupUpdateDuration({}, {}); const mockMediaSource = Object.create(MediaSource.prototype, { readyState: { value: 'open', writable: true }, @@ -416,8 +408,8 @@ describe('updateDuration', () => { // Attach an updating SourceBuffer so the task must await updateend const { buffer: mockVideoBuffer, finishUpdating } = makeUpdatingSourceBuffer(); - owners.patch({ mediaSource: mockMediaSource, videoSourceBuffer: mockVideoBuffer }); - state.patch({ presentation: { duration: 60 } as Presentation }); + owners.set({ ...owners.get(), mediaSource: mockMediaSource, videoSourceBuffer: mockVideoBuffer }); + state.set({ ...state.get(), presentation: { duration: 60 } as Presentation }); // Simulate endOfStream() being called concurrently while the task is waiting — // transitions readyState to 'ended' before the task can set duration @@ -432,10 +424,7 @@ describe('updateDuration', () => { }); it('sets duration once on initial NaN state then ignores further state changes', async () => { - const state = createState({}); - const owners = createState({}); - - const cleanup = updateDuration({ state, owners }); + const { state, owners, cleanup } = setupUpdateDuration({}, {}); const mockMediaSource = Object.create(MediaSource.prototype, { readyState: { value: 'open', writable: true }, @@ -443,18 +432,18 @@ describe('updateDuration', () => { }); // MediaSource attached but no presentation yet — nothing happens - owners.patch({ mediaSource: mockMediaSource }); + owners.set({ ...owners.get(), mediaSource: mockMediaSource }); expect(mockMediaSource.duration).toBeNaN(); // Presentation with duration arrives — initial set fires - state.patch({ presentation: { duration: 60 } as Presentation }); + state.set({ ...state.get(), presentation: { duration: 60 } as Presentation }); await vi.waitFor(() => { expect(mockMediaSource.duration).toBe(60); }); // Further state changes must not trigger another set - state.patch({ presentation: { duration: 90 } as Presentation }); + state.set({ ...state.get(), presentation: { duration: 90 } as Presentation }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(mockMediaSource.duration).toBe(60); // unchanged diff --git a/packages/spf/src/dom/features/track-current-time.ts b/packages/spf/src/dom/features/track-current-time.ts index 74005cee..b6030544 100644 --- a/packages/spf/src/dom/features/track-current-time.ts +++ b/packages/spf/src/dom/features/track-current-time.ts @@ -1,5 +1,6 @@ import { listen } from '@videojs/utils/dom'; -import type { WritableState } from '../../core/state/create-state'; +import { effect } from '../../core/signals/effect'; +import { type Signal, update } from '../../core/signals/primitives'; /** * State shape for current time tracking. @@ -40,18 +41,18 @@ export function canTrackCurrentTime(owners: CurrentTimeOwners): boolean { * @example * const cleanup = trackCurrentTime({ state, owners }); */ -export function trackCurrentTime({ +export function trackCurrentTime({ state, owners, }: { - state: WritableState; - owners: WritableState; + state: Signal; + owners: Signal; }): () => void { let lastMediaElement: HTMLMediaElement | undefined; let removeListeners: (() => void) | null = null; - const unsubscribe = owners.subscribe((currentOwners) => { - const { mediaElement } = currentOwners; + const cleanupEffect = effect(() => { + const { mediaElement } = owners.get(); if (mediaElement === lastMediaElement) return; @@ -61,10 +62,13 @@ export function trackCurrentTime({ if (!mediaElement) return; - // Sync immediately so consumers don't wait for the first event - state.patch({ currentTime: mediaElement.currentTime }); + const sync = () => { + const patch: Partial = { currentTime: mediaElement.currentTime }; + update(state, patch); + }; - const sync = () => state.patch({ currentTime: mediaElement.currentTime }); + // Sync immediately so consumers don't wait for the first event + sync(); const removeTimeupdate = listen(mediaElement, 'timeupdate', sync); const removeSeeking = listen(mediaElement, 'seeking', sync); removeListeners = () => { @@ -75,6 +79,6 @@ export function trackCurrentTime({ return () => { removeListeners?.(); - unsubscribe(); + cleanupEffect(); }; } diff --git a/packages/spf/src/dom/features/track-playback-initiated.ts b/packages/spf/src/dom/features/track-playback-initiated.ts index 5edf8fe3..d9f5d9b3 100644 --- a/packages/spf/src/dom/features/track-playback-initiated.ts +++ b/packages/spf/src/dom/features/track-playback-initiated.ts @@ -1,15 +1,14 @@ import { listen } from '@videojs/utils/dom'; -import type { EventStream } from '../../core/events/create-event-stream'; -import type { PresentationAction } from '../../core/features/resolve-presentation'; -import type { WritableState } from '../../core/state/create-state'; +import { effect } from '../../core/signals/effect'; +import { computed, type Signal, signal, update } from '../../core/signals/primitives'; /** * State shape for playback initiation tracking. */ export interface PlaybackInitiatedState { - /** True once the user has initiated playback (play event fired). */ + /** True once play has been requested for the current presentation URL. */ playbackInitiated?: boolean; - /** Current presentation — watched for URL changes to reset playbackInitiated. */ + /** Current presentation — URL is used to detect source changes. */ presentation?: { url?: string }; } @@ -21,70 +20,78 @@ export interface PlaybackInitiatedOwners { } /** - * Track whether playback has been initiated by the user. + * Track whether playback has been initiated for the current presentation URL. * - * Sets `state.playbackInitiated = true` when the media element fires a `play` - * event (via `element.play()`, native controls, or autoplay) and simultaneously - * dispatches `{ type: 'play' }` to the event stream so `resolvePresentation` - * can react. + * Uses a local intermediate signal written by two effect streams: + * - false stream: resets on URL change + * - true stream: sets on play event * - * Resets `state.playbackInitiated = false` when `presentation.url` changes, - * so a new source with `preload="none"` won't load segments until play is - * triggered again. - * - * This flag is used by `shouldLoadSegments` to allow segment loading after - * play is initiated regardless of the initial `preload` setting — `preload` - * is a startup hint, not a runtime gate. + * A third merge effect reads the local signal and writes to state, reading + * `state.get()` at merge time so the spread uses the up-to-date value after + * the async forward bridge has run. * * @example * const cleanup = trackPlaybackInitiated({ state, owners, events }); */ -export function trackPlaybackInitiated({ +export function trackPlaybackInitiated({ state, owners, - events, }: { - state: WritableState; - owners: WritableState; - events: EventStream; + state: Signal; + owners: Signal; }): () => void { - let lastMediaElement: HTMLMediaElement | undefined; - let removeListener: (() => void) | null = null; - let lastPresentationUrl: string | undefined; + const presentationUrl = computed(() => state.get().presentation?.url); + const mediaElement = computed(() => owners.get().mediaElement); - // Watch for presentation URL changes to reset playbackInitiated - const unsubscribeState = state.subscribe((currentState) => { - const url = currentState.presentation?.url; - if (url !== lastPresentationUrl) { - if (lastPresentationUrl !== undefined) { - // URL changed to a new value — reset so new source requires play again - state.patch({ playbackInitiated: false }); - } - lastPresentationUrl = url; + // Local signal: written by the URL effect (false) and the play listener (true). + // undefined = not yet initialized (suppresses the merge effect on startup). + const playbackInitiated = signal(undefined); + + let lastPresentationUrl: string | undefined; + let lastMediaElement: HTMLMediaElement | undefined; + + // False stream: reset on URL change or element swap. + const cleanupResetEffect = effect(() => { + const url = presentationUrl.get(); + const el = mediaElement.get(); + + const urlChanged = url !== lastPresentationUrl; + const elChanged = el !== lastMediaElement; + + if ((urlChanged && lastPresentationUrl !== undefined) || (elChanged && lastMediaElement !== undefined)) { + playbackInitiated.set(false); } + + lastPresentationUrl = url; + lastMediaElement = el; }); - // Bridge media element play event → state flag + event stream - const unsubscribeOwners = owners.subscribe((currentOwners) => { - const { mediaElement } = currentOwners; - - if (mediaElement === lastMediaElement) return; - - removeListener?.(); - removeListener = null; - lastMediaElement = mediaElement; - - if (!mediaElement) return; - - removeListener = listen(mediaElement, 'play', () => { - state.patch({ playbackInitiated: true }); - events.dispatch({ type: 'play' }); + // True stream: set on play event. Cleanup return removes the listener on element swap. + const cleanupPlayEffect = effect(() => { + const el = mediaElement.get(); + if (!el) return; + return listen(el, 'play', () => { + playbackInitiated.set(true); }); }); + // Merge effect: bridge local signal → state. + // Reads state.get() at merge time (after the async forward bridge has flushed) + // so the spread captures the current value rather than a stale event-time snapshot. + // The guard makes the write idempotent. + const cleanupMergeEffect = effect(() => { + const pi = playbackInitiated.get(); + if (pi === undefined) return; + const current = state.get(); + if (current.playbackInitiated !== pi) { + const patch: Partial = { playbackInitiated: pi }; + update(state, patch); + } + }); + return () => { - removeListener?.(); - unsubscribeState(); - unsubscribeOwners(); + cleanupResetEffect(); + cleanupPlayEffect(); + cleanupMergeEffect(); }; } diff --git a/packages/spf/src/dom/features/track-playback-rate.ts b/packages/spf/src/dom/features/track-playback-rate.ts index 776b9a28..2fd4a052 100644 --- a/packages/spf/src/dom/features/track-playback-rate.ts +++ b/packages/spf/src/dom/features/track-playback-rate.ts @@ -1,5 +1,6 @@ import { listen } from '@videojs/utils/dom'; -import type { WritableState } from '../../core/state/create-state'; +import { effect } from '../../core/signals/effect'; +import { computed, type Signal, update } from '../../core/signals/primitives'; /** * State shape for playback rate tracking. @@ -15,16 +16,6 @@ export interface PlaybackRateOwners { mediaElement?: HTMLMediaElement | undefined; } -/** - * Check if we can track playback rate. - * - * Requires: - * - mediaElement exists in owners - */ -export function canTrackPlaybackRate(owners: PlaybackRateOwners): boolean { - return !!owners.mediaElement; -} - /** * Track playback rate from the media element. * @@ -34,36 +25,28 @@ export function canTrackPlaybackRate(owners: PlaybackRateOwners): boolean { * @example * const cleanup = trackPlaybackRate({ state, owners }); */ -export function trackPlaybackRate({ +export function trackPlaybackRate({ state, owners, }: { - state: WritableState; - owners: WritableState; + state: Signal; + owners: Signal; }): () => void { - let lastMediaElement: HTMLMediaElement | undefined; - let removeListener: (() => void) | null = null; + const mediaElementSignal = computed(() => owners.get().mediaElement); + const canTrackPlaybackRate = computed(() => !!mediaElementSignal.get()); - const unsubscribe = owners.subscribe((currentOwners) => { - const { mediaElement } = currentOwners; + const cleanupEffect = effect(() => { + if (!canTrackPlaybackRate.get()) return; + const mediaElement = mediaElementSignal.get() as HTMLMediaElement; - if (mediaElement === lastMediaElement) return; + update(state, { playbackRate: mediaElement.playbackRate }); - removeListener?.(); - removeListener = null; - lastMediaElement = mediaElement; - - if (!mediaElement) return; - - state.patch({ playbackRate: mediaElement.playbackRate }); - - removeListener = listen(mediaElement, 'ratechange', () => { - state.patch({ playbackRate: mediaElement.playbackRate }); + return listen(mediaElement, 'ratechange', () => { + update(state, { playbackRate: mediaElement.playbackRate }); }); }); return () => { - removeListener?.(); - unsubscribe(); + cleanupEffect(); }; } diff --git a/packages/spf/src/dom/features/update-duration.ts b/packages/spf/src/dom/features/update-duration.ts index 6ea16675..e13302e1 100644 --- a/packages/spf/src/dom/features/update-duration.ts +++ b/packages/spf/src/dom/features/update-duration.ts @@ -1,5 +1,5 @@ -import { combineLatest } from '../../core/reactive/combine-latest'; -import type { WritableState } from '../../core/state/create-state'; +import { effect } from '../../core/signals/effect'; +import type { ReadonlySignal, Signal } from '../../core/signals/primitives'; import type { Presentation } from '../../core/types'; import { hasPresentationDuration } from '../../core/types'; @@ -9,6 +9,8 @@ export interface DurationUpdateState { export interface DurationUpdateOwners { mediaSource?: MediaSource; + /** Reactive mirror of `mediaSource.readyState` — updated via DOM events. */ + mediaSourceReadyState?: ReadonlySignal; videoSourceBuffer?: SourceBuffer; audioSourceBuffer?: SourceBuffer; } @@ -52,8 +54,10 @@ export function shouldUpdateDuration(state: DurationUpdateState, owners: Duratio const { mediaSource } = owners; const { presentation } = state; - // MediaSource must be open - if (mediaSource!.readyState !== 'open') return false; + // MediaSource must be open — use reactive readyState signal when available + // so the effect re-evaluates when readyState changes from 'closed' to 'open'. + const readyState = owners.mediaSourceReadyState?.get() ?? owners.mediaSource?.readyState; + if (readyState !== 'open') return false; const duration = presentation!.duration!; @@ -91,46 +95,51 @@ function waitForSourceBuffersReady(owners: DurationUpdateOwners): Promise /** * Update MediaSource duration when presentation duration becomes available. */ -export function updateDuration({ +export function updateDuration({ state, owners, }: { - state: WritableState; - owners: WritableState; + state: Signal; + owners: Signal; }): () => void { let destroyed = false; + let running = false; - const unsubscribe = combineLatest([state, owners]).subscribe( - async ([currentState, currentOwners]: [DurationUpdateState, DurationUpdateOwners]) => { - if (!shouldUpdateDuration(currentState, currentOwners)) return; + const cleanupEffect = effect(() => { + const currentState = state.get(); + const currentOwners = owners.get(); - const { mediaSource } = currentOwners; + if (!shouldUpdateDuration(currentState, currentOwners) || running) return; - // MSE spec: duration cannot be set while any SourceBuffer is updating - await waitForSourceBuffersReady(currentOwners); + const { mediaSource } = currentOwners; + running = true; - // Re-check after async wait: destroyed, or readyState changed (e.g. endOfStream - // already called endOfStream(), transitioning 'open' → 'ended'). - if (destroyed || mediaSource!.readyState !== 'open') return; + // MSE spec: duration cannot be set while any SourceBuffer is updating. + // Capture the snapshot; the async helper does not read signals. + waitForSourceBuffersReady(currentOwners) + .then(() => { + // Re-check after async wait: destroyed, or readyState changed (e.g. endOfStream + // already called endOfStream(), transitioning 'open' → 'ended'). + if (destroyed || mediaSource!.readyState !== 'open') return; - let duration = currentState.presentation!.duration!; + let duration = currentState.presentation!.duration!; - // Get max buffered end time across all SourceBuffers - const maxBufferedEnd = getMaxBufferedEnd(currentOwners); + // MSE spec: duration cannot be less than any buffered range. + // If buffered ranges exceed calculated duration, extend to match. + const maxBufferedEnd = getMaxBufferedEnd(currentOwners); + if (maxBufferedEnd > duration) { + duration = maxBufferedEnd; + } - // MSE spec: duration cannot be less than any buffered range - // If buffered ranges exceed calculated duration, extend to match - if (maxBufferedEnd > duration) { - duration = maxBufferedEnd; - } - - // Set duration on MediaSource - mediaSource!.duration = duration; - } - ); + mediaSource!.duration = duration; + }) + .finally(() => { + running = false; + }); + }); return () => { destroyed = true; - unsubscribe(); + cleanupEffect(); }; } diff --git a/packages/spf/src/dom/media/mediasource-setup.ts b/packages/spf/src/dom/media/mediasource-setup.ts index 8e9b8d2a..b81b168c 100644 --- a/packages/spf/src/dom/media/mediasource-setup.ts +++ b/packages/spf/src/dom/media/mediasource-setup.ts @@ -7,6 +7,8 @@ * Global ManagedMediaSource types are defined in ./mediasource.d.ts */ +import { type ReadonlySignal, signal } from '../../core/signals/primitives'; + /** * Check if MediaSource API is supported. */ @@ -78,7 +80,6 @@ export interface AttachMediaSourceResult { * @example * const mediaSource = createMediaSource(); * const { detach } = attachMediaSource(mediaSource, videoElement); - * await waitForSourceOpen(mediaSource); * // Use mediaSource... * // Later, to clean up: * detach(); @@ -116,57 +117,6 @@ export function attachMediaSource(mediaSource: MediaSource, mediaElement: HTMLMe return { url, detach }; } -/** - * Wait for a MediaSource to reach the 'open' state. - * Resolves immediately if already open. - * - * @param mediaSource - The MediaSource to wait for - * @param signal - Optional AbortSignal for cancellation - * @returns Promise that resolves when the MediaSource is open - * - * @example - * const mediaSource = createMediaSource(); - * attachMediaSource(mediaSource, videoElement); - * await waitForSourceOpen(mediaSource); - * // MediaSource is now ready for SourceBuffer creation - */ -export function waitForSourceOpen(mediaSource: MediaSource, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (mediaSource.readyState === 'open') { - resolve(); - return; - } - - // Check if already aborted - if (signal?.aborted) { - reject(new DOMException('Aborted', 'AbortError')); - return; - } - - // Use internal AbortController for cleanup coordination - const controller = new AbortController(); - const options = { signal: controller.signal }; - - mediaSource.addEventListener( - 'sourceopen', - () => { - controller.abort(); - resolve(); - }, - options - ); - - signal?.addEventListener( - 'abort', - () => { - controller.abort(); - reject(new DOMException('Aborted', 'AbortError')); - }, - options - ); - }); -} - /** * Create a SourceBuffer on a MediaSource. * @@ -176,7 +126,6 @@ export function waitForSourceOpen(mediaSource: MediaSource, signal?: AbortSignal * @throws Error if MediaSource is not open or codec is unsupported * * @example - * await waitForSourceOpen(mediaSource); * const buffer = createSourceBuffer(mediaSource, 'video/mp4; codecs="avc1.42E01E"'); */ export function createSourceBuffer(mediaSource: MediaSource, mimeCodec: string): SourceBuffer { @@ -209,3 +158,36 @@ export function isCodecSupported(mimeCodec: string): boolean { return MediaSource.isTypeSupported(mimeCodec); } + +/** + * Create a reactive signal that mirrors `mediaSource.readyState`. + * + * Listens to `sourceopen`, `sourceended`, and `sourceclose` events and updates + * the signal accordingly, making readyState visible to the TC39 signal graph. + * Listeners are automatically removed when `signal` is aborted. + * + * @param mediaSource - The MediaSource to observe + * @param signal - AbortSignal that controls listener lifetime + * @returns A `Signal.ReadonlyState` that reflects the current `readyState` + * + * @example + * const controller = new AbortController(); + * const readyState = observeMediaSourceReadyState(mediaSource, controller.signal); + * effect(() => { + * if (readyState.get() === 'open') { ... } + * }); + * // Later: + * controller.abort(); + */ +export function observeMediaSourceReadyState( + mediaSource: MediaSource, + abortSignal: AbortSignal +): ReadonlySignal { + const readyState = signal(mediaSource.readyState); + const update = () => readyState.set(mediaSource.readyState); + const options = { signal: abortSignal }; + mediaSource.addEventListener('sourceopen', update, options); + mediaSource.addEventListener('sourceended', update, options); + mediaSource.addEventListener('sourceclose', update, options); + return readyState; +} diff --git a/packages/spf/src/dom/media/source-buffer-actor.ts b/packages/spf/src/dom/media/source-buffer-actor.ts index 65ffdb93..342fb317 100644 --- a/packages/spf/src/dom/media/source-buffer-actor.ts +++ b/packages/spf/src/dom/media/source-buffer-actor.ts @@ -1,5 +1,5 @@ -import type { Actor, ActorSnapshot } from '../../core/actor'; -import { createState } from '../../core/state/create-state'; +import type { ActorSnapshot, SignalActor } from '../../core/actor'; +import { type ReadonlySignal, signal, update } from '../../core/signals/primitives'; import { SerialRunner, Task } from '../../core/task'; import type { Segment, Track } from '../../core/types'; import { type AppendData, appendSegment } from './append-segment'; @@ -63,7 +63,7 @@ export class SourceBufferActorError extends Error { } /** SourceBuffer actor: queues operations, owns its snapshot. */ -export interface SourceBufferActor extends Actor { +export interface SourceBufferActor extends SignalActor { send(message: SourceBufferMessage, signal: AbortSignal): Promise; batch(messages: SourceBufferMessage[], signal: AbortSignal): Promise; } @@ -230,22 +230,19 @@ export function createSourceBufferActor( sourceBuffer: SourceBuffer, initialContext?: Partial ): SourceBufferActor { - const state = createState({ + const snapshotSignal = signal({ status: 'idle', context: { segments: [], bufferedRanges: [], initTrackId: undefined, ...initialContext }, }); const runner = new SerialRunner(); - // Applies the completed context atomically with the idle transition and - // flushes synchronously so consumers observe the final snapshot when their - // awaited Promise resolves. + // Applies the completed context atomically with the idle transition. // If the actor was destroyed while the operation was in flight, preserve // 'destroyed' — do not regress to 'idle'. function applyResult(newContext: SourceBufferActorContext): void { - const status = state.current.status === 'destroyed' ? 'destroyed' : 'idle'; - state.patch({ status, context: newContext }); - state.flush(); + const status = snapshotSignal.get().status === 'destroyed' ? 'destroyed' : 'idle'; + snapshotSignal.set({ status, context: newContext }); } function handleError(e: unknown): never { @@ -254,38 +251,34 @@ export function createSourceBufferActor( // improvement should detect QuotaExceededError specifically and use total // bytes-in-buffer as a heuristic to identify the effective buffer capacity, // enabling targeted flush-and-retry rather than silent model drift. - const status = state.current.status === 'destroyed' ? 'destroyed' : 'idle'; - state.patch({ status }); - state.flush(); + const status = snapshotSignal.get().status === 'destroyed' ? 'destroyed' : 'idle'; + update(snapshotSignal, { status }); throw e; } return { - get snapshot(): SourceBufferActorSnapshot { - return state.current; - }, - - subscribe(listener: (snapshot: SourceBufferActorSnapshot) => void): () => void { - return state.subscribe(listener); + get snapshot(): ReadonlySignal { + return snapshotSignal; }, send(message: SourceBufferMessage, signal: AbortSignal): Promise { - if (state.current.status !== 'idle') { - return Promise.reject(new SourceBufferActorError(`send() called while actor is ${state.current.status}`)); + if (snapshotSignal.get().status !== 'idle') { + return Promise.reject( + new SourceBufferActorError(`send() called while actor is ${snapshotSignal.get().status}`) + ); } // Transition synchronously so any subsequent send/batch within the same // tick is rejected — the actor is now committed to this operation. - state.patch({ status: 'updating' }); + update(snapshotSignal, { status: 'updating' }); const onPartialContext = (ctx: SourceBufferActorContext) => { - state.patch({ status: 'updating', context: ctx }); - state.flush(); + snapshotSignal.set({ status: 'updating', context: ctx }); }; const task = messageToTask(message, { signal, - getCtx: () => state.current.context, + getCtx: () => snapshotSignal.get().context, sourceBuffer, onPartialContext, }); @@ -294,19 +287,21 @@ export function createSourceBufferActor( }, batch(messages: SourceBufferMessage[], signal: AbortSignal): Promise { - if (state.current.status !== 'idle') { - return Promise.reject(new SourceBufferActorError(`batch() called while actor is ${state.current.status}`)); + if (snapshotSignal.get().status !== 'idle') { + return Promise.reject( + new SourceBufferActorError(`batch() called while actor is ${snapshotSignal.get().status}`) + ); } if (messages.length === 0) return Promise.resolve(); // Transition synchronously — the entire batch is one 'updating' period. - state.patch({ status: 'updating' }); + update(snapshotSignal, { status: 'updating' }); // Each message is its own Task on the runner, executed in submission order. // workingCtx threads the result of each task into the next without - // patching shared state between steps — context is only written to state - // atomically when the last task completes. + // writing to the signal between steps — context is only written atomically + // when the last task completes. // // workingCtx is captured here (synchronously after status → 'updating'), // so it reflects the current context at the moment the batch was accepted. @@ -317,14 +312,13 @@ export function createSourceBufferActor( // workingCtx is not updated for that step and subsequent tasks in the // batch will operate on a stale context. This is an edge case — happy-path // appends do not fail — but worth revisiting if MSE error recovery lands. - let workingCtx = state.current.context; + let workingCtx = snapshotSignal.get().context; - // Partial context updates from streaming appends patch state directly so - // external subscribers see in-progress state, but workingCtx is only - // advanced on task completion to preserve batch context threading. + // Partial context updates from streaming appends write to the signal + // directly so external subscribers see in-progress state, but workingCtx + // is only advanced on task completion to preserve batch context threading. const onPartialContext = (ctx: SourceBufferActorContext) => { - state.patch({ status: 'updating', context: ctx }); - state.flush(); + snapshotSignal.set({ status: 'updating', context: ctx }); }; for (const message of messages.slice(0, -1)) { @@ -345,8 +339,7 @@ export function createSourceBufferActor( }, destroy(): void { - state.patch({ status: 'destroyed' }); - state.flush(); + update(snapshotSignal, { status: 'destroyed' }); runner.destroy(); }, }; diff --git a/packages/spf/src/dom/media/tests/mediasource-setup.test.ts b/packages/spf/src/dom/media/tests/mediasource-setup.test.ts index 1ffa4731..db4c5509 100644 --- a/packages/spf/src/dom/media/tests/mediasource-setup.test.ts +++ b/packages/spf/src/dom/media/tests/mediasource-setup.test.ts @@ -6,7 +6,6 @@ import { isCodecSupported, supportsManagedMediaSource, supportsMediaSource, - waitForSourceOpen, } from '../mediasource-setup'; describe('supportsMediaSource', () => { @@ -61,125 +60,13 @@ describe('attachMediaSource', () => { }); }); -describe('waitForSourceOpen', () => { - it('should resolve immediately if already open', async () => { - const mediaSource = createMediaSource(); - const mediaElement = document.createElement('video'); - attachMediaSource(mediaSource, mediaElement); - - // Wait for actual sourceopen - await waitForSourceOpen(mediaSource); - - expect(mediaSource.readyState).toBe('open'); - }); - - it('should reject when signal is aborted before call', async () => { - const mediaSource = createMediaSource(); - const controller = new AbortController(); - - controller.abort(); - - await expect(waitForSourceOpen(mediaSource, controller.signal)).rejects.toThrow('Aborted'); - }); - - it('should reject when signal is aborted while waiting', async () => { - const mediaSource = createMediaSource(); - const mediaElement = document.createElement('video'); - const controller = new AbortController(); - - attachMediaSource(mediaSource, mediaElement); - - const promise = waitForSourceOpen(mediaSource, controller.signal); - - // Abort while waiting - controller.abort(); - - await expect(promise).rejects.toThrow('Aborted'); - }); - - it('should clean up listeners after successful open', async () => { - const mediaSource = createMediaSource(); - const mediaElement = document.createElement('video'); - const controller = new AbortController(); - - attachMediaSource(mediaSource, mediaElement); - - await waitForSourceOpen(mediaSource, controller.signal); - - // After resolution, aborting external signal shouldn't cause issues - expect(() => controller.abort()).not.toThrow(); - - // MediaSource should still be open - expect(mediaSource.readyState).toBe('open'); - }); - - it('should clean up listeners after abort', async () => { - const mediaSource = createMediaSource(); - const mediaElement = document.createElement('video'); - const controller = new AbortController(); - - attachMediaSource(mediaSource, mediaElement); - - const promise = waitForSourceOpen(mediaSource, controller.signal); - - controller.abort(); - - await expect(promise).rejects.toThrow('Aborted'); - - // After abort, opening MediaSource shouldn't affect the rejected promise - // (listeners should be cleaned up) - expect(mediaSource.readyState).toBe('closed'); - }); - - it('should handle multiple concurrent waits', async () => { - const mediaSource = createMediaSource(); - const mediaElement = document.createElement('video'); - - attachMediaSource(mediaSource, mediaElement); - - // Multiple promises waiting on same MediaSource - const promise1 = waitForSourceOpen(mediaSource); - const promise2 = waitForSourceOpen(mediaSource); - const promise3 = waitForSourceOpen(mediaSource); - - // All should resolve when sourceopen fires - await expect(Promise.all([promise1, promise2, promise3])).resolves.toBeDefined(); - - expect(mediaSource.readyState).toBe('open'); - }); - - it('should handle abort racing with sourceopen', async () => { - const mediaSource = createMediaSource(); - const mediaElement = document.createElement('video'); - const controller = new AbortController(); - - attachMediaSource(mediaSource, mediaElement); - - const promise = waitForSourceOpen(mediaSource, controller.signal); - - // Race condition: abort happens very close to sourceopen - // One should win, but no errors should occur - setTimeout(() => controller.abort(), 5); - - try { - await promise; - // If sourceopen won, should be open - expect(mediaSource.readyState).toBe('open'); - } catch (error) { - // If abort won, should reject with AbortError - expect(error).toBeInstanceOf(DOMException); - expect((error as DOMException).name).toBe('AbortError'); - } - }); -}); - describe('createSourceBuffer', () => { it('should create SourceBuffer with codec string', async () => { const mediaSource = createMediaSource(); const mediaElement = document.createElement('video'); attachMediaSource(mediaSource, mediaElement); - await waitForSourceOpen(mediaSource); + await new Promise((resolve) => mediaSource.addEventListener('sourceopen', () => resolve(), { once: true })); const buffer = createSourceBuffer(mediaSource, 'video/mp4; codecs="avc1.42E01E"'); diff --git a/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts b/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts index c101278c..5d64422c 100644 --- a/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts +++ b/packages/spf/src/dom/media/tests/source-buffer-actor.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { effect } from '../../../core/signals/effect'; import { createSourceBufferActor, SourceBufferActorError } from '../source-buffer-actor'; // --------------------------------------------------------------------------- @@ -118,11 +119,11 @@ describe('createSourceBufferActor', () => { await actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); - expect(actor.snapshot.status).toBe('idle'); + expect(actor.snapshot.get().status).toBe('idle'); await actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-2' } }, neverAborted); - expect(actor.snapshot.context.initTrackId).toBe('track-2'); + expect(actor.snapshot.get().context.initTrackId).toBe('track-2'); actor.destroy(); }); @@ -146,8 +147,8 @@ describe('createSourceBufferActor', () => { await actor.batch(messages, neverAborted); expect(sourceBuffer.appendBuffer).toHaveBeenCalledTimes(2); - expect(actor.snapshot.context.initTrackId).toBe('track-1'); - expect(actor.snapshot.context.segments).toHaveLength(1); + expect(actor.snapshot.get().context.initTrackId).toBe('track-1'); + expect(actor.snapshot.get().context.segments).toHaveLength(1); actor.destroy(); }); @@ -173,10 +174,10 @@ describe('createSourceBufferActor', () => { neverAborted ); - const ids = actor.snapshot.context.segments.map((s) => s.id); + const ids = actor.snapshot.get().context.segments.map((s) => s.id); expect(ids).not.toContain('s1-low'); expect(ids).toContain('s1-high'); - expect(actor.snapshot.context.segments).toHaveLength(1); + expect(actor.snapshot.get().context.segments).toHaveLength(1); actor.destroy(); }); @@ -186,7 +187,9 @@ describe('createSourceBufferActor', () => { const actor = createSourceBufferActor(sourceBuffer); const statusValues: string[] = []; - const unsub = actor.subscribe((s) => statusValues.push(s.status)); + const cleanup = effect(() => { + statusValues.push(actor.snapshot.get().status); + }); await actor.batch( [ @@ -200,7 +203,7 @@ describe('createSourceBufferActor', () => { neverAborted ); - unsub(); + cleanup(); // Initial idle (immediate subscribe fire) → updating → idle // No intermediate context-update-while-updating snapshots @@ -278,8 +281,8 @@ describe('createSourceBufferActor', () => { await actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); - expect(actor.snapshot.context.initTrackId).toBe('track-1'); - expect(actor.snapshot.status).toBe('idle'); + expect(actor.snapshot.get().context.initTrackId).toBe('track-1'); + expect(actor.snapshot.get().status).toBe('idle'); actor.destroy(); }); @@ -301,14 +304,14 @@ describe('createSourceBufferActor', () => { neverAborted ); - expect(actor.snapshot.context.segments).toHaveLength(1); - expect(actor.snapshot.context.segments[0]).toMatchObject({ + expect(actor.snapshot.get().context.segments).toHaveLength(1); + expect(actor.snapshot.get().context.segments[0]).toMatchObject({ id: 's1', startTime: 0, duration: 10, trackId: 'track-1', }); - expect(actor.snapshot.status).toBe('idle'); + expect(actor.snapshot.get().status).toBe('idle'); actor.destroy(); }); @@ -339,10 +342,10 @@ describe('createSourceBufferActor', () => { neverAborted ); - const ids = actor.snapshot.context.segments.map((s) => s.id); + const ids = actor.snapshot.get().context.segments.map((s) => s.id); expect(ids).not.toContain('s1-low'); expect(ids).toContain('s1-high'); - expect(actor.snapshot.context.segments).toHaveLength(1); + expect(actor.snapshot.get().context.segments).toHaveLength(1); actor.destroy(); }); @@ -385,11 +388,11 @@ describe('createSourceBufferActor', () => { await actor.send({ type: 'remove', start: 0, end: 20 }, neverAborted); expect(sourceBuffer.remove).toHaveBeenCalledWith(0, 20); - const ids = actor.snapshot.context.segments.map((s) => s.id); + const ids = actor.snapshot.get().context.segments.map((s) => s.id); expect(ids).not.toContain('s1'); expect(ids).not.toContain('s2'); expect(ids).toContain('s3'); - expect(actor.snapshot.status).toBe('idle'); + expect(actor.snapshot.get().status).toBe('idle'); actor.destroy(); }); @@ -403,11 +406,13 @@ describe('createSourceBufferActor', () => { const actor = createSourceBufferActor(sourceBuffer); const statusValues: string[] = []; - const unsub = actor.subscribe((snapshot) => statusValues.push(snapshot.status)); + const cleanup = effect(() => { + statusValues.push(actor.snapshot.get().status); + }); await actor.send({ type: 'append-init', data: new ArrayBuffer(4), meta: { trackId: 'track-1' } }, neverAborted); - unsub(); + cleanup(); // Initial 'idle' (from immediate subscribe fire) → 'updating' → 'idle' expect(statusValues).toContain('updating'); @@ -420,17 +425,12 @@ describe('createSourceBufferActor', () => { // subscribe // --------------------------------------------------------------------------- - it('fires subscriber immediately with current snapshot', () => { + it('snapshot.get() returns the current snapshot', () => { const sourceBuffer = makeSourceBuffer(); const actor = createSourceBufferActor(sourceBuffer); - const snapshots: unknown[] = []; - const unsub = actor.subscribe((s) => snapshots.push(s)); + expect(actor.snapshot.get()).toMatchObject({ status: 'idle', context: { segments: [], bufferedRanges: [] } }); - expect(snapshots).toHaveLength(1); - expect(snapshots[0]).toMatchObject({ status: 'idle', context: { segments: [], bufferedRanges: [] } }); - - unsub(); actor.destroy(); }); @@ -446,8 +446,10 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer([[0, 10]]); const actor = createSourceBufferActor(sourceBuffer); - const snapshots: (typeof actor.snapshot)[] = []; - const unsub = actor.subscribe((s) => snapshots.push(s)); + const snapshots: ReturnType[] = []; + const cleanup = effect(() => { + snapshots.push(actor.snapshot.get()); + }); await actor.send( { @@ -457,7 +459,7 @@ describe('createSourceBufferActor', () => { }, neverAborted ); - unsub(); + cleanup(); const hadPartial = snapshots.some((s) => s.context.segments.some((seg) => seg.partial)); expect(hadPartial).toBe(false); @@ -469,10 +471,11 @@ describe('createSourceBufferActor', () => { const sourceBuffer = makeSourceBuffer([[0, 10]]); const actor = createSourceBufferActor(sourceBuffer); - const snapshots: (typeof actor.snapshot)[] = []; - const unsub = actor.subscribe((s) => - snapshots.push({ ...s, context: { ...s.context, segments: [...s.context.segments] } }) - ); + const snapshots: ReturnType[] = []; + const cleanup = effect(() => { + const s = actor.snapshot.get(); + snapshots.push({ ...s, context: { ...s.context, segments: [...s.context.segments] } }); + }); async function* twoChunks() { yield new Uint8Array(4); @@ -483,7 +486,7 @@ describe('createSourceBufferActor', () => { { type: 'append-segment', data: twoChunks(), meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' } }, neverAborted ); - unsub(); + cleanup(); const partialSnapshot = snapshots.find((s) => s.context.segments.some((seg) => seg.id === 's1' && seg.partial === true) @@ -506,7 +509,7 @@ describe('createSourceBufferActor', () => { neverAborted ); - const seg = actor.snapshot.context.segments.find((s) => s.id === 's1'); + const seg = actor.snapshot.get().context.segments.find((s) => s.id === 's1'); expect(seg).toBeDefined(); expect(seg?.partial).toBeUndefined(); @@ -545,7 +548,7 @@ describe('createSourceBufferActor', () => { // Wait until partial state is emitted (first chunk queued) await vi.waitFor(() => { - expect(actor.snapshot.context.segments.some((s) => s.id === 's1' && s.partial === true)).toBe(true); + expect(actor.snapshot.get().context.segments.some((s) => s.id === 's1' && s.partial === true)).toBe(true); }); // Abort — the stream is paused waiting for resolveFirst @@ -556,7 +559,7 @@ describe('createSourceBufferActor', () => { await pending.catch(() => {}); // partial: true entry should remain — accurately reflects data in SourceBuffer - const seg = actor.snapshot.context.segments.find((s) => s.id === 's1'); + const seg = actor.snapshot.get().context.segments.find((s) => s.id === 's1'); expect(seg).toBeDefined(); expect(seg?.partial).toBe(true); @@ -582,7 +585,7 @@ describe('createSourceBufferActor', () => { neverAborted ); - const seg = actorWithPartial.snapshot.context.segments.find((s) => s.id === 's1'); + const seg = actorWithPartial.snapshot.get().context.segments.find((s) => s.id === 's1'); expect(seg).toBeDefined(); expect(seg?.partial).toBeUndefined(); diff --git a/packages/spf/src/dom/playback-engine/adapter.ts b/packages/spf/src/dom/playback-engine/adapter.ts index cc893bcd..f17fb0d5 100644 --- a/packages/spf/src/dom/playback-engine/adapter.ts +++ b/packages/spf/src/dom/playback-engine/adapter.ts @@ -1,3 +1,4 @@ +import { update } from '../../core/signals/primitives'; import type { PlaybackEngineConfig } from './engine'; import { createPlaybackEngine, type PlaybackEngine } from './engine'; @@ -45,12 +46,12 @@ export class SpfMedia { // --------------------------------------------------------------------------- attach(mediaElement: HTMLMediaElement): void { - this.#engine.owners.patch({ mediaElement }); + update(this.#engine.owners, { mediaElement }); } detach(): void { this.#cancelPendingPlay(); - this.#engine.owners.patch({ mediaElement: undefined }); + update(this.#engine.owners, { mediaElement: undefined }); } destroy(): void { @@ -69,7 +70,7 @@ export class SpfMedia { set preload(value: '' | 'none' | 'metadata' | 'auto') { this.#preload = value; if (value) { - this.#engine.state.patch({ preload: value }); + update(this.#engine.state, { preload: value }); } // value = '' clears #preload (so the next engine recreation won't re-apply // an explicit value) but does not patch current state — the existing preload @@ -83,28 +84,28 @@ export class SpfMedia { // --------------------------------------------------------------------------- get src(): string { - return this.#engine.state.current.presentation?.url ?? ''; + return this.#engine.state.get().presentation?.url ?? ''; } set src(value: string) { - const prevMediaElement = this.#engine.owners.current.mediaElement; + const prevMediaElement = this.#engine.owners.get().mediaElement; this.#cancelPendingPlay(); this.#engine.destroy(); this.#engine = createPlaybackEngine(this.#config); - // Apply explicit preload before owners.patch so syncPreloadAttribute skips + // Apply explicit preload before setting owners so syncPreloadAttribute skips // element inference and the explicit value is preserved across src changes. if (this.#preload) { - this.#engine.state.patch({ preload: this.#preload }); + update(this.#engine.state, { preload: this.#preload }); } if (prevMediaElement) { - this.#engine.owners.patch({ mediaElement: prevMediaElement }); + update(this.#engine.owners, { mediaElement: prevMediaElement }); } if (value) { - this.#engine.state.patch({ presentation: { url: value } }); + update(this.#engine.state, { presentation: { url: value } }); } } @@ -114,13 +115,13 @@ export class SpfMedia { // --------------------------------------------------------------------------- play(): Promise { - const { mediaElement } = this.#engine.owners.current; + const { mediaElement } = this.#engine.owners.get(); if (!mediaElement) { return Promise.reject(new Error('SpfMedia: no media element attached')); } // Signal play intent — enables loading even with preload="none" - this.#engine.state.patch({ playbackInitiated: true }); + update(this.#engine.state, { playbackInitiated: true }); return mediaElement.play().catch((err: unknown) => { // If we have a pending HLS source, the rejection may be because MSE @@ -146,7 +147,7 @@ export class SpfMedia { #cancelPendingPlay(): void { if (!this.#loadstartListener) return; - const { mediaElement } = this.#engine.owners.current; + const { mediaElement } = this.#engine.owners.get(); mediaElement?.removeEventListener('loadstart', this.#loadstartListener); this.#loadstartListener = null; } diff --git a/packages/spf/src/dom/playback-engine/engine.ts b/packages/spf/src/dom/playback-engine/engine.ts index 9b2b45bc..0928d081 100644 --- a/packages/spf/src/dom/playback-engine/engine.ts +++ b/packages/spf/src/dom/playback-engine/engine.ts @@ -1,20 +1,12 @@ import type { BandwidthState } from '../../core/abr/bandwidth-estimator'; -import { createEventStream } from '../../core/events/create-event-stream'; import { calculatePresentationDuration } from '../../core/features/calculate-presentation-duration'; import { switchQuality } from '../../core/features/quality-switching'; -import { - type PresentationAction, - resolvePresentation, - syncPreloadAttribute, -} from '../../core/features/resolve-presentation'; -import { resolveTrack, type TrackResolutionAction } from '../../core/features/resolve-track'; -import { - selectAudioTrack, - selectTextTrack, - selectVideoTrack, - type TrackSelectionAction, -} from '../../core/features/select-tracks'; -import { createState } from '../../core/state/create-state'; +import { resolvePresentation } from '../../core/features/resolve-presentation'; +import { resolveTrack } from '../../core/features/resolve-track'; +import { selectAudioTrack, selectTextTrack, selectVideoTrack } from '../../core/features/select-tracks'; +import { syncPreloadAttribute } from '../../core/features/sync-preload-attribute'; +import type { ReadonlySignal, Signal } from '../../core/signals/primitives'; +import { signal } from '../../core/signals/primitives'; import { endOfStream } from '../features/end-of-stream'; import { loadSegments } from '../features/load-segments'; import type { TextTrackBufferState } from '../features/load-text-track-cues'; @@ -30,16 +22,6 @@ import { updateDuration } from '../features/update-duration'; import type { SourceBufferActor } from '../media/source-buffer-actor'; import { destroyVttParser } from '../text/parse-vtt-segment'; -/** - * Union of all action types used by playback engine orchestrations. - * Includes synthetic @@INITIALIZE@@ event for combineLatest bootstrapping. - */ -export type PlaybackEngineAction = - | PresentationAction - | TrackResolutionAction - | TrackSelectionAction - | { type: '@@INITIALIZE@@' }; - /** * Configuration for the playback engine. */ @@ -82,7 +64,7 @@ export interface PlaybackEngineConfig { export interface PlaybackEngineState { // Presentation state presentation?: any; - preload?: string; + preload?: 'auto' | 'metadata' | 'none'; // Track selection state selectedVideoTrackId?: string; @@ -118,6 +100,8 @@ export interface PlaybackEngineOwners { // MediaSource mediaSource?: MediaSource; + /** Reactive mirror of `mediaSource.readyState` — updated via DOM events. */ + mediaSourceReadyState?: ReadonlySignal; // SourceBuffers and their actors (created together by setupSourceBuffer) videoBuffer?: SourceBuffer; @@ -134,19 +118,14 @@ export interface PlaybackEngineOwners { */ export interface PlaybackEngine { /** - * Reactive state (for inspection/testing). + * Reactive state signal (for inspection/testing). */ - state: ReturnType>; + state: Signal; /** - * Mutable owners (for inspection/testing). + * Mutable owners signal (for inspection/testing). */ - owners: ReturnType>; - - /** - * Shared event stream (for inspection/testing/triggering events). - */ - events: ReturnType>; + owners: Signal; /** * Cleanup function to destroy all orchestrations. @@ -175,22 +154,23 @@ export interface PlaybackEngine { * preferredAudioLanguage: 'en', * }); * - * // Initialize by patching state and owners - * engine.owners.patch({ mediaElement: document.querySelector('video') }); - * engine.state.patch({ + * // Initialize by setting state and owners + * engine.owners.set({ ...engine.owners.get(), mediaElement: document.querySelector('video') }); + * engine.state.set({ + * ...engine.state.get(), * presentation: { url: 'https://example.com/playlist.m3u8' }, * preload: 'auto', * }); * * // Inspect state - * console.log(engine.state.current); + * console.log(engine.state.get()); * * // Cleanup * engine.destroy(); */ export function createPlaybackEngine(config: PlaybackEngineConfig = {}): PlaybackEngine { - // Create reactive state and owners (initially empty) - const state = createState({ + // Create reactive state and owners as signals + const state = signal({ bandwidthState: { fastEstimate: 0, fastTotalWeight: 0, @@ -199,51 +179,38 @@ export function createPlaybackEngine(config: PlaybackEngineConfig = {}): Playbac bytesSampled: 0, }, }); - const owners = createState({}); + const owners = signal({}); - // Create single shared event stream for all orchestrations - const events = createEventStream(); - - // Wire up orchestrations (all share single event stream) - // Note: @ts-expect-error needed due to EventStream invariance - each orchestration expects - // specific event types, but shared stream has union of all types. Proper fix would - // require making EventStream covariant or refactoring event system. + // Wire up orchestrations const cleanups = [ // 0a. Sync preload attribute from mediaElement → state.preload - // Only re-reads when the mediaElement reference changes (lastMediaElement guard). // Normalises '' (absent attribute) to 'auto' (browser default). - // @ts-expect-error - WritableState type variance - syncPreloadAttribute(state, owners), + syncPreloadAttribute({ state, owners }), - // 0b. Bridge media element play event → state.playbackInitiated + event stream - // Enables preload="none"/"metadata" resolution via native controls / element.play() - // @ts-expect-error - EventStream type variance - trackPlaybackInitiated({ state, owners, events }), + // 0b. Track media element play event → state.playbackInitiated + // Enables preload="none" resolution via native controls / element.play() + trackPlaybackInitiated({ state, owners }), // 1. Resolve presentation (URL already in state) - // @ts-expect-error - EventStream type variance - resolvePresentation({ state, events }), + resolvePresentation({ state }), // 2. Select initial tracks (when presentation loads) selectVideoTrack( - // @ts-expect-error - Owners and EventStream type compatibility - { state, owners, events }, + { state }, { type: 'video', ...(config.initialBandwidth !== undefined && { initialBandwidth: config.initialBandwidth }), } ), selectAudioTrack( - // @ts-expect-error - Owners and EventStream type compatibility - { state, owners, events }, + { state }, { type: 'audio', ...(config.preferredAudioLanguage !== undefined && { preferredAudioLanguage: config.preferredAudioLanguage }), } ), selectTextTrack( - // @ts-expect-error - Owners and EventStream type compatibility - { state, owners, events }, + { state }, { type: 'text', ...(config.preferredSubtitleLanguage !== undefined && { @@ -255,12 +222,9 @@ export function createPlaybackEngine(config: PlaybackEngineConfig = {}): Playbac ), // 3. Resolve selected tracks (fetch media playlists) - // @ts-expect-error - EventStream type variance - resolveTrack({ state, events }, { type: 'video' as const }), - // @ts-expect-error - EventStream type variance - resolveTrack({ state, events }, { type: 'audio' as const }), - // @ts-expect-error - EventStream type variance - resolveTrack({ state, events }, { type: 'text' as const }), + resolveTrack({ state }, { type: 'video' as const }), + resolveTrack({ state }, { type: 'audio' as const }), + resolveTrack({ state }, { type: 'text' as const }), // 3.5. Calculate presentation duration from resolved tracks calculatePresentationDuration({ state }), @@ -300,7 +264,10 @@ export function createPlaybackEngine(config: PlaybackEngineConfig = {}): Playbac trackCurrentTime({ state, owners }), // 5.75. ABR quality switching (reacts to bandwidth samples from loadSegments) - switchQuality({ state }), + switchQuality( + { state }, + config.initialBandwidth !== undefined ? { defaultBandwidth: config.initialBandwidth } : {} + ), // 6. Load segments (when SourceBuffer ready and track resolved) loadSegments({ state, owners }, { type: 'video' }), @@ -325,15 +292,10 @@ export function createPlaybackEngine(config: PlaybackEngineConfig = {}): Playbac loadTextTrackCues({ state, owners }), ]; - // Dispatch synthetic initialize event to satisfy combineLatest - // (combineLatest waits for all sources to emit before triggering listeners) - events.dispatch({ type: '@@INITIALIZE@@' }); - // Return engine instance return { state, owners, - events, destroy: () => { cleanups.forEach((cleanup) => cleanup()); destroyVttParser(); diff --git a/packages/spf/src/dom/playback-engine/index.ts b/packages/spf/src/dom/playback-engine/index.ts index cee55d02..0ebac310 100644 --- a/packages/spf/src/dom/playback-engine/index.ts +++ b/packages/spf/src/dom/playback-engine/index.ts @@ -1,3 +1,4 @@ +export { effect } from '../../core/signals/effect'; export { SpfMedia } from './adapter'; -export type { PlaybackEngine, PlaybackEngineConfig } from './engine'; +export type { PlaybackEngine, PlaybackEngineConfig, PlaybackEngineOwners, PlaybackEngineState } from './engine'; export { createPlaybackEngine } from './engine'; diff --git a/packages/spf/src/dom/playback-engine/tests/adapter.test.ts b/packages/spf/src/dom/playback-engine/tests/adapter.test.ts index 96250ec9..2332a306 100644 --- a/packages/spf/src/dom/playback-engine/tests/adapter.test.ts +++ b/packages/spf/src/dom/playback-engine/tests/adapter.test.ts @@ -52,21 +52,21 @@ describe('SpfMedia', () => { it('synchronously updates engine presentation state when src is set', () => { const media = new SpfMedia(); media.src = 'https://example.com/v.m3u8'; - expect(media.engine.state.current.presentation?.url).toBe('https://example.com/v.m3u8'); + expect(media.engine.state.get().presentation?.url).toBe('https://example.com/v.m3u8'); }); it('synchronously updates engine presentation state when src changes', () => { const media = new SpfMedia(); media.src = 'https://example.com/v1.m3u8'; media.src = 'https://example.com/v2.m3u8'; - expect(media.engine.state.current.presentation?.url).toBe('https://example.com/v2.m3u8'); + expect(media.engine.state.get().presentation?.url).toBe('https://example.com/v2.m3u8'); }); it('clears engine presentation state when src is set to empty string', () => { const media = new SpfMedia(); media.src = 'https://example.com/v.m3u8'; media.src = ''; - expect(media.engine.state.current.presentation).toBeUndefined(); + expect(media.engine.state.get().presentation).toBeUndefined(); }); }); @@ -118,7 +118,7 @@ describe('SpfMedia', () => { const el = document.createElement('video'); media.attach(el); media.src = 'https://example.com/v1.m3u8'; - expect(media.engine.owners.current.mediaElement).toBe(el); + expect(media.engine.owners.get().mediaElement).toBe(el); }); it('cancels pending play listener when src changes', async () => { @@ -139,14 +139,14 @@ describe('SpfMedia', () => { const media = new SpfMedia(); const el = document.createElement('video'); media.attach(el); - expect(media.engine.owners.current.mediaElement).toBe(el); + expect(media.engine.owners.get().mediaElement).toBe(el); }); it('clears mediaElement in owners when detached', () => { const media = new SpfMedia(); media.attach(document.createElement('video')); media.detach(); - expect(media.engine.owners.current.mediaElement).toBeUndefined(); + expect(media.engine.owners.get().mediaElement).toBeUndefined(); }); it('updates mediaElement when re-attached to a different element', () => { @@ -155,7 +155,7 @@ describe('SpfMedia', () => { const el2 = document.createElement('video'); media.attach(el1); media.attach(el2); - expect(media.engine.owners.current.mediaElement).toBe(el2); + expect(media.engine.owners.get().mediaElement).toBe(el2); }); it('preserves src across attach/detach cycles', () => { @@ -170,7 +170,7 @@ describe('SpfMedia', () => { const media = new SpfMedia(); media.src = 'https://example.com/v.m3u8'; media.attach(document.createElement('video')); - expect(media.engine.state.current.presentation?.url).toBe('https://example.com/v.m3u8'); + expect(media.engine.state.get().presentation?.url).toBe('https://example.com/v.m3u8'); }); it('detach does not destroy the engine', () => { @@ -199,7 +199,7 @@ describe('SpfMedia', () => { const media = new SpfMedia(); media.attach(document.createElement('video')); media.play().catch(() => {}); - expect(media.engine.state.current.playbackInitiated).toBe(true); + expect(media.engine.state.get().playbackInitiated).toBe(true); }); it('retries play() via loadstart when element has no src but adapter has one', async () => { @@ -302,7 +302,7 @@ describe('SpfMedia', () => { it('updates engine state immediately when set', () => { const media = new SpfMedia(); media.preload = 'none'; - expect(media.engine.state.current.preload).toBe('none'); + expect(media.engine.state.get().preload).toBe('none'); }); it('setting preload to empty string resets the stored value but does not clear current engine state', () => { @@ -311,7 +311,7 @@ describe('SpfMedia', () => { media.preload = ''; // '' only clears #preload so the next engine recreation won't re-apply // an explicit value — it does not patch the current engine state. - expect(media.engine.state.current.preload).toBe('auto'); + expect(media.engine.state.get().preload).toBe('auto'); }); it('survives src reassignment — explicit preload is preserved across engine recreation', () => { @@ -319,7 +319,7 @@ describe('SpfMedia', () => { media.preload = 'none'; media.src = 'https://example.com/v.m3u8'; expect(media.preload).toBe('none'); - expect(media.engine.state.current.preload).toBe('none'); + expect(media.engine.state.get().preload).toBe('none'); }); it('explicit preload is re-applied before owners.patch on src change so syncPreloadAttribute skips inference', () => { @@ -330,7 +330,7 @@ describe('SpfMedia', () => { media.src = 'https://example.com/v.m3u8'; // syncPreloadAttribute fires when owners.patch re-attaches the element, // but since preload was already patched into the new engine's state, it skips. - expect(media.engine.state.current.preload).toBe('none'); + expect(media.engine.state.get().preload).toBe('none'); }); }); diff --git a/packages/spf/src/dom/tests/load-segments-track-switch.test.ts b/packages/spf/src/dom/tests/load-segments-track-switch.test.ts index 01133933..453c47f0 100644 --- a/packages/spf/src/dom/tests/load-segments-track-switch.test.ts +++ b/packages/spf/src/dom/tests/load-segments-track-switch.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createState } from '../../core/state/create-state'; +import { signal } from '../../core/signals/primitives'; import type { Presentation, VideoSelectionSet } from '../../core/types'; import type { SegmentLoadingOwners, SegmentLoadingState } from '../features/load-segments'; import { loadSegments } from '../features/load-segments'; @@ -131,7 +131,7 @@ describe('loadSegments — track switch', () => { ], }); - const state = createState({ + const state = signal({ presentation, selectedVideoTrackId: 'track-a', preload: 'auto', @@ -139,7 +139,7 @@ describe('loadSegments — track switch', () => { currentTime: 5, }); - const owners = createState({ videoBuffer, videoBufferActor }); + const owners = signal({ videoBuffer, videoBufferActor }); const cleanup = loadSegments({ state, owners }, { type: 'video' }); @@ -147,7 +147,7 @@ describe('loadSegments — track switch', () => { await new Promise((r) => setTimeout(r, 20)); // ABR switches to track B - state.patch({ selectedVideoTrackId: 'track-b' }); + state.set({ ...state.get(), selectedVideoTrackId: 'track-b' }); // Wait for task to process track switch await new Promise((r) => setTimeout(r, 50)); @@ -156,7 +156,7 @@ describe('loadSegments — track switch', () => { expect(flushSpy).not.toHaveBeenCalledWith(videoBuffer, 0, Infinity); // New track-b init should be committed. - const ctx = owners.current.videoBufferActor?.snapshot.context; + const ctx = owners.get().videoBufferActor?.snapshot.get().context; expect(ctx?.initTrackId).toBe('track-b'); // Old track-a segments should be gone: time-aligned deduplication replaces @@ -181,7 +181,7 @@ describe('loadSegments — track switch', () => { const videoBuffer = makeMockSourceBuffer(); const videoBufferActor = createSourceBufferActor(videoBuffer); - const state = createState({ + const state = signal({ presentation, selectedVideoTrackId: 'track-a', preload: 'auto', @@ -189,14 +189,14 @@ describe('loadSegments — track switch', () => { currentTime: 0, }); - const owners = createState({ videoBuffer, videoBufferActor }); + const owners = signal({ videoBuffer, videoBufferActor }); const cleanup = loadSegments({ state, owners }, { type: 'video' }); // Wait for track-a init fetch to start (but leave it pending) await vi.waitFor(() => expect(fetchedUrls).toContain('https://example.com/track-a-init.mp4')); // Switch tracks while track-a init is still in-flight - state.patch({ selectedVideoTrackId: 'track-b' }); + state.set({ ...state.get(), selectedVideoTrackId: 'track-b' }); // Unblock the pending init fetch so the runner can progress resolve('https://example.com/track-a-init.mp4'); @@ -208,7 +208,7 @@ describe('loadSegments — track switch', () => { resolve('https://example.com/track-b-init.mp4'); - await vi.waitFor(() => expect(owners.current.videoBufferActor?.snapshot.context.initTrackId).toBe('track-b'), { + await vi.waitFor(() => expect(owners.get().videoBufferActor?.snapshot.get().context.initTrackId).toBe('track-b'), { timeout: 3000, }); @@ -231,7 +231,7 @@ describe('loadSegments — track switch', () => { ], }); - const state = createState({ + const state = signal({ presentation, selectedVideoTrackId: 'track-a', preload: 'auto', @@ -239,13 +239,13 @@ describe('loadSegments — track switch', () => { currentTime: 25, }); - const owners = createState({ videoBuffer, videoBufferActor }); + const owners = signal({ videoBuffer, videoBufferActor }); const cleanup = loadSegments({ state, owners }, { type: 'video' }); await new Promise((r) => setTimeout(r, 20)); // Switch to track-b at currentTime=25 - state.patch({ selectedVideoTrackId: 'track-b' }); + state.set({ ...state.get(), selectedVideoTrackId: 'track-b' }); const fetchedUrls: string[] = []; globalThis.fetch = vi.fn().mockImplementation((input: RequestInfo | URL) => { @@ -281,14 +281,14 @@ describe('loadSegments — track switch', () => { // Actor starts fresh (no prior track) const videoBufferActor = createSourceBufferActor(videoBuffer); - const state = createState({ + const state = signal({ presentation, selectedVideoTrackId: 'track-a', preload: 'auto', currentTime: 0, }); - const owners = createState({ videoBuffer, videoBufferActor }); + const owners = signal({ videoBuffer, videoBufferActor }); const cleanup = loadSegments({ state, owners }, { type: 'video' }); await new Promise((r) => setTimeout(r, 50)); diff --git a/packages/spf/src/dom/tests/playback-engine.test.ts b/packages/spf/src/dom/tests/playback-engine.test.ts index ba559efe..574d5b9c 100644 --- a/packages/spf/src/dom/tests/playback-engine.test.ts +++ b/packages/spf/src/dom/tests/playback-engine.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createPlaybackEngine, type PlaybackEngineState } from '../playback-engine/engine'; +import { createPlaybackEngine } from '../playback-engine/engine'; // Mock appendSegment to succeed without real MP4 data vi.mock('../media/append-segment', () => ({ @@ -18,12 +18,11 @@ describe('createPlaybackEngine', () => { // Restore original fetch globalThis.fetch = originalFetch; }); - it('creates engine with state, owners, and events', () => { + it('creates engine with state, owners, and destroy', () => { const engine = createPlaybackEngine(); expect(engine.state).toBeDefined(); expect(engine.owners).toBeDefined(); - expect(engine.events).toBeDefined(); expect(engine.destroy).toBeDefined(); expect(typeof engine.destroy).toBe('function'); @@ -33,7 +32,7 @@ describe('createPlaybackEngine', () => { it('initializes with empty state and owners', () => { const engine = createPlaybackEngine(); - expect(engine.state.current).toEqual({ + expect(engine.state.get()).toEqual({ bandwidthState: { fastEstimate: 0, fastTotalWeight: 0, @@ -42,7 +41,7 @@ describe('createPlaybackEngine', () => { bytesSampled: 0, }, }); - expect(engine.owners.current).toEqual({}); + expect(engine.owners.get()).toEqual({}); engine.destroy(); }); @@ -52,8 +51,9 @@ describe('createPlaybackEngine', () => { const mediaElement = document.createElement('video'); mediaElement.preload = 'auto'; - engine.owners.patch({ mediaElement }); - engine.state.patch({ + engine.owners.set({ ...engine.owners.get(), mediaElement }); + engine.state.set({ + ...engine.state.get(), presentation: { url: 'https://example.com/playlist.m3u8' }, preload: 'auto', }); @@ -61,8 +61,8 @@ describe('createPlaybackEngine', () => { // Wait for microtask queue to drain (patches are batched) await new Promise((resolve) => queueMicrotask(resolve)); - expect(engine.owners.current.mediaElement).toBe(mediaElement); - expect(engine.state.current.presentation?.url).toBe('https://example.com/playlist.m3u8'); + expect(engine.owners.get().mediaElement).toBe(mediaElement); + expect(engine.state.get().presentation?.url).toBe('https://example.com/playlist.m3u8'); engine.destroy(); }); @@ -131,7 +131,8 @@ http://example.com/segment1.m4s const engine = createPlaybackEngine(); // Patch state to trigger presentation resolution - engine.state.patch({ + engine.state.set({ + ...engine.state.get(), presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto', }); @@ -139,7 +140,7 @@ http://example.com/segment1.m4s // Wait for presentation to be resolved (no event needed - state-driven) await vi.waitFor( () => { - const { presentation } = engine.state.current; + const { presentation } = engine.state.get(); expect(presentation?.selectionSets).toBeDefined(); expect(presentation?.selectionSets?.length).toBeGreaterThan(0); }, @@ -203,8 +204,9 @@ http://example.com/audio-seg1.m4s mediaElement.preload = 'auto'; // Initialize: patch owners and state - engine.owners.patch({ mediaElement }); - engine.state.patch({ + engine.owners.set({ ...engine.owners.get(), mediaElement }); + engine.state.set({ + ...engine.state.get(), presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto', }); @@ -212,8 +214,8 @@ http://example.com/audio-seg1.m4s // Wait for complete orchestration pipeline await vi.waitFor( () => { - const state = engine.state.current; - const owners = engine.owners.current; + const state = engine.state.get(); + const owners = engine.owners.get(); // === IMMUTABLE STATE VERIFICATION === @@ -296,16 +298,17 @@ http://example.com/video-seg1.m4s const mediaElement = document.createElement('video'); mediaElement.preload = 'auto'; - engine.owners.patch({ mediaElement }); - engine.state.patch({ + engine.owners.set({ ...engine.owners.get(), mediaElement }); + engine.state.set({ + ...engine.state.get(), presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto', }); await vi.waitFor( () => { - const state = engine.state.current; - const owners = engine.owners.current; + const state = engine.state.get(); + const owners = engine.owners.get(); // Should create video track and buffer expect(state.selectedVideoTrackId).toBeDefined(); @@ -360,16 +363,17 @@ http://example.com/audio-seg1.m4s const mediaElement = document.createElement('video'); mediaElement.preload = 'auto'; - engine.owners.patch({ mediaElement }); - engine.state.patch({ + engine.owners.set({ ...engine.owners.get(), mediaElement }); + engine.state.set({ + ...engine.state.get(), presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto', }); await vi.waitFor( () => { - const state = engine.state.current; - const owners = engine.owners.current; + const state = engine.state.get(); + const owners = engine.owners.get(); // Should create audio track and buffer expect(state.selectedAudioTrackId).toBeDefined(); @@ -424,15 +428,16 @@ http://example.com/video-seg1.m4s const mediaElement = document.createElement('video'); mediaElement.preload = 'auto'; - engine.owners.patch({ mediaElement }); - engine.state.patch({ + engine.owners.set({ ...engine.owners.get(), mediaElement }); + engine.state.set({ + ...engine.state.get(), presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto', }); await vi.waitFor( () => { - const state = engine.state.current; + const state = engine.state.get(); // Should have resolved presentation with text tracks expect(state.presentation?.selectionSets).toBeDefined(); @@ -480,7 +485,8 @@ http://example.com/video-seg1.m4s const engine = createPlaybackEngine(); // Patch state but NOT owners (no mediaElement) - engine.state.patch({ + engine.state.set({ + ...engine.state.get(), presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto', }); @@ -488,7 +494,7 @@ http://example.com/video-seg1.m4s // Wait for presentation and track resolution await vi.waitFor( () => { - const state = engine.state.current; + const state = engine.state.get(); expect(state.presentation?.selectionSets).toBeDefined(); expect(state.selectedVideoTrackId).toBeDefined(); @@ -504,7 +510,7 @@ http://example.com/video-seg1.m4s // Give time for MediaSource setup (which shouldn't happen) await new Promise((resolve) => setTimeout(resolve, 100)); - const owners = engine.owners.current; + const owners = engine.owners.get(); // Should NOT create MediaSource or SourceBuffers without mediaElement expect(owners.mediaElement).toBeUndefined(); @@ -560,15 +566,16 @@ http://example.com/audio-seg1.m4s const mediaElement = document.createElement('video'); mediaElement.preload = 'none'; - engine.owners.patch({ mediaElement }); - engine.state.patch({ + engine.owners.set({ ...engine.owners.get(), mediaElement }); + engine.state.set({ + ...engine.state.get(), presentation: { url: 'http://example.com/playlist.m3u8' }, }); // PHASE 1: Verify nothing auto-resolves await new Promise((resolve) => setTimeout(resolve, 200)); - let state = engine.state.current; + let state = engine.state.get(); expect(state.presentation?.selectionSets).toBeUndefined(); expect(mockFetch).not.toHaveBeenCalled(); @@ -579,8 +586,8 @@ http://example.com/audio-seg1.m4s // Wait for complete orchestration await vi.waitFor( () => { - state = engine.state.current; - const owners = engine.owners.current; + state = engine.state.get(); + const owners = engine.owners.get(); // Now everything should be resolved and created expect(state.presentation?.selectionSets).toBeDefined(); @@ -638,15 +645,16 @@ http://example.com/seg1.m4s const mediaElement = document.createElement('video'); mediaElement.preload = 'metadata'; - engine.owners.patch({ mediaElement }); - engine.state.patch({ + engine.owners.set({ ...engine.owners.get(), mediaElement }); + engine.state.set({ + ...engine.state.get(), presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'metadata', }); await vi.waitFor( () => { - const state = engine.state.current; + const state = engine.state.get(); // Should resolve presentation and select/resolve track expect(state.presentation?.selectionSets).toBeDefined(); @@ -658,7 +666,7 @@ http://example.com/seg1.m4s expect(videoTrack?.segments).toBeDefined(); // Init segment should be loaded (advances readyState to HAVE_METADATA) - expect(engine.owners.current.videoBufferActor?.snapshot.context.initTrackId).toBeDefined(); + expect(engine.owners.get().videoBufferActor?.snapshot.get().context.initTrackId).toBeDefined(); }, { timeout: 2000 } ); @@ -709,19 +717,22 @@ http://example.com/seg1.m4s }); globalThis.fetch = mockFetch; - const engine = createPlaybackEngine(); + // Use a conservative initialBandwidth so switchQuality also selects 360p and + // doesn't immediately upgrade — verifying only the selected track is resolved. + const engine = createPlaybackEngine({ initialBandwidth: 600_000 }); const mediaElement = document.createElement('video'); mediaElement.preload = 'auto'; - engine.owners.patch({ mediaElement }); - engine.state.patch({ + engine.owners.set({ ...engine.owners.get(), mediaElement }); + engine.state.set({ + ...engine.state.get(), presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto', }); await vi.waitFor( () => { - const state = engine.state.current; + const state = engine.state.get(); expect(state.selectedVideoTrackId).toBeDefined(); // Selected track should be resolved @@ -733,7 +744,7 @@ http://example.com/seg1.m4s { timeout: 2000 } ); - const state = engine.state.current; + const state = engine.state.get(); const allVideoTracks = state.presentation?.selectionSets?.find((s: any) => s.type === 'video')?.switchingSets?.[0] ?.tracks; @@ -813,8 +824,9 @@ http://example.com/text-es-seg1.vtt const mediaElement = document.createElement('video'); mediaElement.preload = 'auto'; - engine.owners.patch({ mediaElement }); - engine.state.patch({ + engine.owners.set({ ...engine.owners.get(), mediaElement }); + engine.state.set({ + ...engine.state.get(), presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto', }); @@ -822,7 +834,7 @@ http://example.com/text-es-seg1.vtt // Wait for presentation to be resolved await vi.waitFor( () => { - const state = engine.state.current; + const state = engine.state.get(); expect(state.presentation?.selectionSets).toBeDefined(); const textSet = state.presentation?.selectionSets?.find((s: any) => s.type === 'text'); expect(textSet?.switchingSets?.[0]?.tracks.length).toBeGreaterThan(0); @@ -831,7 +843,7 @@ http://example.com/text-es-seg1.vtt ); // Get text track IDs - const textSet = engine.state.current.presentation?.selectionSets?.find((s: any) => s.type === 'text'); + const textSet = engine.state.get().presentation?.selectionSets?.find((s: any) => s.type === 'text'); const textTracks = textSet?.switchingSets?.[0]?.tracks; expect(textTracks?.length).toBe(2); @@ -839,14 +851,15 @@ http://example.com/text-es-seg1.vtt expect(englishTrack).toBeDefined(); // Manually select English text track - engine.state.patch({ + engine.state.set({ + ...engine.state.get(), selectedTextTrackId: englishTrack!.id, }); // Wait for text track to be resolved await vi.waitFor( () => { - const state = engine.state.current; + const state = engine.state.get(); expect(state.selectedTextTrackId).toBe(englishTrack!.id); // Text track should be resolved (has segments) @@ -911,8 +924,9 @@ http://example.com/text-es-seg1.vtt const mediaElement = document.createElement('video'); mediaElement.preload = 'auto'; - engine.owners.patch({ mediaElement }); - engine.state.patch({ + engine.owners.set({ ...engine.owners.get(), mediaElement }); + engine.state.set({ + ...engine.state.get(), presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto', }); @@ -920,7 +934,7 @@ http://example.com/text-es-seg1.vtt // Wait for DEFAULT text track to be auto-selected and resolved await vi.waitFor( () => { - const state = engine.state.current; + const state = engine.state.get(); expect(state.presentation?.selectionSets).toBeDefined(); // Should auto-select text track with DEFAULT=YES + AUTOSELECT=YES @@ -989,8 +1003,9 @@ http://example.com/text-fr-seg1.vtt const mediaElement = document.createElement('video'); mediaElement.preload = 'auto'; - engine.owners.patch({ mediaElement }); - engine.state.patch({ + engine.owners.set({ ...engine.owners.get(), mediaElement }); + engine.state.set({ + ...engine.state.get(), presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto', }); @@ -998,7 +1013,7 @@ http://example.com/text-fr-seg1.vtt // Wait for preferred language text track to be auto-selected and resolved await vi.waitFor( () => { - const state = engine.state.current; + const state = engine.state.get(); expect(state.presentation?.selectionSets).toBeDefined(); expect(state.selectedTextTrackId).toBeDefined(); @@ -1074,8 +1089,9 @@ http://example.com/text-es-seg1.vtt const mediaElement = document.createElement('video'); mediaElement.preload = 'auto'; - engine.owners.patch({ mediaElement }); - engine.state.patch({ + engine.owners.set({ ...engine.owners.get(), mediaElement }); + engine.state.set({ + ...engine.state.get(), presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto', }); @@ -1083,24 +1099,27 @@ http://example.com/text-es-seg1.vtt // Wait for presentation to be resolved await vi.waitFor( () => { - const state = engine.state.current; + const state = engine.state.get(); expect(state.presentation?.selectionSets).toBeDefined(); }, { timeout: 2000 } ); - const textSet = engine.state.current.presentation?.selectionSets?.find((s: any) => s.type === 'text'); + const textSet = engine.state.get().presentation?.selectionSets?.find((s: any) => s.type === 'text'); const textTracks = textSet?.switchingSets?.[0]?.tracks; const englishTrack = textTracks?.find((t: any) => t.language === 'en'); const spanishTrack = textTracks?.find((t: any) => t.language === 'es'); // Select English track - engine.state.patch({ selectedTextTrackId: englishTrack!.id }); + engine.state.set({ + ...engine.state.get(), + selectedTextTrackId: englishTrack!.id, + }); await vi.waitFor( () => { - const state = engine.state.current; + const state = engine.state.get(); expect(state.selectedTextTrackId).toBe(englishTrack!.id); const resolvedTrack = state.presentation?.selectionSets @@ -1113,11 +1132,14 @@ http://example.com/text-es-seg1.vtt ); // Switch to Spanish track - engine.state.patch({ selectedTextTrackId: spanishTrack!.id }); + engine.state.set({ + ...engine.state.get(), + selectedTextTrackId: spanishTrack!.id, + }); await vi.waitFor( () => { - const state = engine.state.current; + const state = engine.state.get(); expect(state.selectedTextTrackId).toBe(spanishTrack!.id); const resolvedTrack = state.presentation?.selectionSets @@ -1168,8 +1190,9 @@ http://example.com/video-seg1.m4s const mediaElement = document.createElement('video'); mediaElement.preload = 'auto'; - engine.owners.patch({ mediaElement }); - engine.state.patch({ + engine.owners.set({ ...engine.owners.get(), mediaElement }); + engine.state.set({ + ...engine.state.get(), presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto', }); @@ -1177,7 +1200,7 @@ http://example.com/video-seg1.m4s // Wait for text tracks to be set up await vi.waitFor( () => { - const owners = engine.owners.current; + const owners = engine.owners.get(); // Text tracks should be created expect(owners.textTracks).toBeDefined(); @@ -1273,8 +1296,9 @@ http://example.com/text-es-seg1.vtt const mediaElement = document.createElement('video'); mediaElement.preload = 'auto'; - engine.owners.patch({ mediaElement }); - engine.state.patch({ + engine.owners.set({ ...engine.owners.get(), mediaElement }); + engine.state.set({ + ...engine.state.get(), presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto', }); @@ -1282,12 +1306,12 @@ http://example.com/text-es-seg1.vtt // Wait for text tracks to be set up await vi.waitFor( () => { - expect(engine.owners.current.textTracks?.size).toBe(2); + expect(engine.owners.get().textTracks?.size).toBe(2); }, { timeout: 2000 } ); - const textTracks = engine.owners.current.textTracks!; + const textTracks = engine.owners.get().textTracks!; const tracks = Array.from(mediaElement.children) as HTMLTrackElement[]; // Initially all tracks should be hidden (no selection, managed by activateTextTrack) @@ -1302,7 +1326,10 @@ http://example.com/text-es-seg1.vtt expect(spanishTrackId).toBeDefined(); // Select English track - engine.state.patch({ selectedTextTrackId: englishTrackId! }); + engine.state.set({ + ...engine.state.get(), + selectedTextTrackId: englishTrackId!, + }); await vi.waitFor( () => { @@ -1316,7 +1343,10 @@ http://example.com/text-es-seg1.vtt ); // Switch to Spanish track - engine.state.patch({ selectedTextTrackId: spanishTrackId! }); + engine.state.set({ + ...engine.state.get(), + selectedTextTrackId: spanishTrackId!, + }); await vi.waitFor( () => { @@ -1329,8 +1359,9 @@ http://example.com/text-es-seg1.vtt { timeout: 2000 } ); - // Deselect (hide all) - engine.state.patch({ selectedTextTrackId: undefined } as unknown as Partial); + // Deselect (hide all) — omit selectedTextTrackId to satisfy exactOptionalPropertyTypes + const { selectedTextTrackId: _removed, ...deselected } = engine.state.get(); + engine.state.set(deselected as ReturnType); await vi.waitFor( () => { @@ -1386,8 +1417,9 @@ http://example.com/seg2.m4s const mediaElement = document.createElement('video'); mediaElement.preload = 'auto'; - engine.owners.patch({ mediaElement }); - engine.state.patch({ + engine.owners.set({ ...engine.owners.get(), mediaElement }); + engine.state.set({ + ...engine.state.get(), presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto', }); @@ -1395,7 +1427,7 @@ http://example.com/seg2.m4s await vi.waitFor( () => { // Buffer state now lives in the SourceBufferActor (in owners), not in state. - const videoCtx = engine.owners.current.videoBufferActor?.snapshot.context; + const videoCtx = engine.owners.get().videoBufferActor?.snapshot.get().context; // Should have init segment tracked (by track ID) expect(videoCtx?.initTrackId).toBeDefined(); @@ -1463,8 +1495,9 @@ http://example.com/audio-seg1.m4s const mediaElement = document.createElement('video'); mediaElement.preload = 'auto'; - engine.owners.patch({ mediaElement }); - engine.state.patch({ + engine.owners.set({ ...engine.owners.get(), mediaElement }); + engine.state.set({ + ...engine.state.get(), presentation: { url: 'http://example.com/playlist.m3u8' }, preload: 'auto', }); @@ -1472,8 +1505,8 @@ http://example.com/audio-seg1.m4s await vi.waitFor( () => { // Buffer state now lives in the SourceBufferActors (in owners), not in state. - const videoCtx = engine.owners.current.videoBufferActor?.snapshot.context; - const audioCtx = engine.owners.current.audioBufferActor?.snapshot.context; + const videoCtx = engine.owners.get().videoBufferActor?.snapshot.get().context; + const audioCtx = engine.owners.get().audioBufferActor?.snapshot.get().context; // Both video and audio actors should exist expect(videoCtx).toBeDefined(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 171bebae..3eb5aaf5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -345,6 +345,9 @@ importers: '@videojs/utils': specifier: workspace:* version: link:../utils + signal-polyfill: + specifier: ^0.2.2 + version: 0.2.2 devDependencies: '@vitest/browser': specifier: ^4.1.0 @@ -6827,6 +6830,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + signal-polyfill@0.2.2: + resolution: {integrity: sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==} + simple-git-hooks@2.13.1: resolution: {integrity: sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ==} hasBin: true @@ -15128,6 +15134,8 @@ snapshots: signal-exit@4.1.0: {} + signal-polyfill@0.2.2: {} + simple-git-hooks@2.13.1: {} simple-git@3.32.3: